From 467ffd2b45025c1a3f94fa3c335fa5276e8a77e0 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 5 May 2026 20:56:19 +0000 Subject: [PATCH 1/7] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/215 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..0dbb9d34 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-05T20:56:19.006Z for PR creation at branch issue-215-36d0d217ca4a for issue https://github.com/ProverCoderAI/docker-git/issues/215 \ No newline at end of file From fa86ac124c46bf233f68abf049fe8e1d268a0df4 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 5 May 2026 21:22:33 +0000 Subject: [PATCH 2/7] feat(app,docs): clarify host-Docker runtime contract and probe diagnostics Make the docker-git runtime contract explicit (host-Docker-backed via /var/run/docker.sock) and replace the generic "cannot access Docker" error with a classifier that names the actual failure mode (host daemon down, host socket permission denied, docker CLI missing) and prints contract-aware remediation steps. - New pure module controller-docker-diagnostics.ts classifies docker probe outcomes by exit code + stderr and renders a message that restates the host-Docker contract and lists per-mode fixes. - controller-docker.ts now captures stderr from direct + sudo probes (not just exit codes) and feeds them into the diagnostic renderer. - README.md and packages/api/README.md add a "Runtime contract" section distinguishing the three failure modes the issue called out. - Tests cover classifier (5 cases) and message renderer (6 cases). --- .gitkeep | 1 - README.md | 31 ++++ packages/api/README.md | 27 ++++ .../controller-docker-diagnostics.ts | 148 ++++++++++++++++++ .../app/src/docker-git/controller-docker.ts | 80 +++++++--- .../controller-docker-diagnostics.test.ts | 135 ++++++++++++++++ 6 files changed, 399 insertions(+), 23 deletions(-) delete mode 100644 .gitkeep create mode 100644 packages/app/src/docker-git/controller-docker-diagnostics.ts create mode 100644 packages/app/tests/docker-git/controller-docker-diagnostics.test.ts diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 0dbb9d34..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-05T20:56:19.006Z for PR creation at branch issue-215-36d0d217ca4a for issue https://github.com/ProverCoderAI/docker-git/issues/215 \ No newline at end of file diff --git a/README.md b/README.md index 8fcce950..c535deb8 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,34 @@ API - Просто апи сервер поднятный над LIB APP работает только с API, и не имеет доступа к LIB API работает только с LIB + +## Runtime contract: host-Docker-backed + +`docker-git` is host-Docker-backed by design. The controller container +(`docker-git-api`) talks to the host Docker daemon via the bind-mounted +`/var/run/docker.sock`, which is how it creates and manages per-project +containers. There is no isolated Docker-in-Docker runtime. + +This means the user that runs the host CLI (`bun run docker-git ...`) needs +to be able to talk to that same socket directly. Three failure modes can +look superficially identical and are diagnosed separately by the CLI: + +1. **Host Docker daemon is not reachable** – `docker info` fails with + "Cannot connect to the Docker daemon". Start Docker (e.g. + `sudo systemctl start docker`) or set `DOCKER_HOST` to a reachable + endpoint. +2. **Host Docker socket rejected this user** – `docker info` fails with + "permission denied" while talking to `/var/run/docker.sock`. This is a + host configuration issue, *not* a `docker-git` outage. Add the user to + the `docker` group, switch to rootless Docker, or fix the socket + ownership (`root:docker`, mode `660`). After changing groups, log out + and back in (or run `newgrp docker`). +3. **Controller container not running** – the host CLI cannot reach + `docker-git-api` on its API port. Bring the controller up via + `docker compose up -d --build`, or point the CLI at an existing + controller using `DOCKER_GIT_API_URL`. + +When the CLI cannot acquire Docker access it now prints a message that +names the specific failure mode, restates the host-Docker contract, and +lists remediation steps for that exact mode. Implementation lives in +`packages/app/src/docker-git/controller-docker-diagnostics.ts`. diff --git a/packages/api/README.md b/packages/api/README.md index c40f5092..a42f9bda 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -8,6 +8,33 @@ This is now the intended controller plane: - the API talks to Docker through `/var/run/docker.sock` - child project containers no longer depend on host bind mounts for bootstrap auth/env +## Runtime contract: host-Docker-backed + +`docker-git` is host-Docker-backed, not isolated. The controller container +created from this package binds the host socket +(`/var/run/docker.sock:/var/run/docker.sock`, see `docker-compose.yml`) and +uses it to spawn per-project containers. There is no Docker-in-Docker +runtime; the daemon is always the host's daemon. + +The host CLI (`packages/app`) also talks to that same daemon directly when +it bootstraps the controller. Three failure modes look identical at first +glance and the CLI now distinguishes them in its error output: + +- **Host daemon down** – `docker info` cannot connect. Start the host + Docker daemon or set `DOCKER_HOST`. +- **Host socket permission mismatch** – `docker info` returns + `permission denied` on `/var/run/docker.sock`. Fix host group membership + (`docker` group / rootless Docker / socket ownership). This is a host + configuration problem, not a `docker-git` outage. +- **Controller container not running / unreachable** – the API at + `DOCKER_GIT_API_URL` (default `http://127.0.0.1:3334`) does not answer. + Bring the controller up with `docker compose up -d --build` or point the + CLI at an existing controller via `DOCKER_GIT_API_URL`. + +Diagnostic classification + remediation messages live in +`packages/app/src/docker-git/controller-docker-diagnostics.ts` and are +covered by `packages/app/tests/docker-git/controller-docker-diagnostics.test.ts`. + ## UI wrapper After API startup open: diff --git a/packages/app/src/docker-git/controller-docker-diagnostics.ts b/packages/app/src/docker-git/controller-docker-diagnostics.ts new file mode 100644 index 00000000..06d558d9 --- /dev/null +++ b/packages/app/src/docker-git/controller-docker-diagnostics.ts @@ -0,0 +1,148 @@ +import { Match } from "effect" + +// PURITY: CORE +// EFFECT: pure functions; no IO, no process, no time +// INVARIANT: classification depends only on the supplied probe output and exit code + +export type DockerProbeFailureKind = + | "docker-cli-missing" + | "socket-permission-denied" + | "daemon-unreachable" + | "unknown" + +export type DockerProbeOutcome = { + readonly exitCode: number + readonly stderr: string +} + +const lowercase = (text: string): string => text.toLowerCase() + +const containsAny = (haystack: string, needles: ReadonlyArray): boolean => + needles.some((needle) => haystack.includes(needle)) + +const isCliMissingExitCode = (exitCode: number): boolean => exitCode === 127 + +const cliMissingMarkers: ReadonlyArray = [ + "command not found", + "not found", + "no such file or directory" +] + +const permissionMarkers: ReadonlyArray = [ + "permission denied", + "access is denied", + "got permission denied" +] + +const daemonDownMarkers: ReadonlyArray = [ + "cannot connect to the docker daemon", + "is the docker daemon running", + "no such file or directory", + "connection refused" +] + +export const classifyDockerProbeFailure = (outcome: DockerProbeOutcome): DockerProbeFailureKind => { + const normalized = lowercase(outcome.stderr) + + if (containsAny(normalized, permissionMarkers)) { + return "socket-permission-denied" + } + + if (isCliMissingExitCode(outcome.exitCode) && containsAny(normalized, cliMissingMarkers)) { + return "docker-cli-missing" + } + + if (containsAny(normalized, daemonDownMarkers)) { + return "daemon-unreachable" + } + + return "unknown" +} + +export type DockerAccessDeniedContext = { + readonly directProbe: DockerProbeOutcome + readonly sudoProbe: DockerProbeOutcome | null + readonly apiBaseUrl: string + readonly dockerHost: string | null +} + +const firstNonEmptyLine = (text: string): string => { + for (const line of text.split("\n")) { + const trimmed = line.trim() + if (trimmed.length > 0) { + return trimmed + } + } + return "" +} + +const renderProbeLine = (label: string, probe: DockerProbeOutcome | null): string => { + if (probe === null) { + return `${label}: skipped` + } + const stderrSummary = firstNonEmptyLine(probe.stderr) + const summaryText = stderrSummary.length > 0 ? stderrSummary : "no stderr" + return `${label}: exit=${probe.exitCode}; ${summaryText}` +} + +const renderHeadlineForKind = (kind: DockerProbeFailureKind): string => + Match.value(kind).pipe( + Match.when( + "socket-permission-denied", + () => "Host Docker socket rejected this user (socket permission mismatch, not a docker-git outage)." + ), + Match.when( + "daemon-unreachable", + () => "Host Docker daemon is not reachable from this user (daemon down or wrong DOCKER_HOST)." + ), + Match.when("docker-cli-missing", () => "docker CLI was not found on this machine."), + Match.when("unknown", () => "docker-git host CLI cannot access Docker from the client process."), + Match.exhaustive + ) + +const renderRemediationForKind = (kind: DockerProbeFailureKind, apiBaseUrl: string): ReadonlyArray => { + const apiHint = + `Or keep the docker-git backend container running and reach it via DOCKER_GIT_API_URL (default ${apiBaseUrl}).` + return Match.value(kind).pipe( + Match.when("socket-permission-denied", (): ReadonlyArray => [ + "docker-git is intentionally backed by the host Docker daemon via /var/run/docker.sock.", + "Add this user to the docker group, switch to rootless Docker, or fix /var/run/docker.sock ownership (root:docker, mode 660).", + "After changing groups, log out and back in (or run `newgrp docker`) so the new group membership applies.", + apiHint + ]), + Match.when("daemon-unreachable", (): ReadonlyArray => [ + "Start the Docker daemon (e.g. `sudo systemctl start docker`) or set DOCKER_HOST to a reachable endpoint.", + apiHint + ]), + Match.when("docker-cli-missing", (): ReadonlyArray => [ + "Install Docker Engine or Docker Desktop and ensure `docker` is on PATH.", + apiHint + ]), + Match.when("unknown", (): ReadonlyArray => [ + "Tried direct Docker and passwordless sudo Docker; both probes failed.", + "Grant this user direct Docker access (docker group/rootless Docker), configure passwordless sudo for docker, or", + apiHint + ]), + Match.exhaustive + ) +} + +// PURITY: CORE +// EFFECT: pure function over diagnostic context +// INVARIANT: emitted message names the failure mode, the contract, and the next action +export const renderDockerAccessDeniedMessage = (context: DockerAccessDeniedContext): string => { + const directKind = classifyDockerProbeFailure(context.directProbe) + const dockerHostLine = context.dockerHost !== null && context.dockerHost.length > 0 + ? `DOCKER_HOST: ${context.dockerHost}` + : "DOCKER_HOST: unset (defaults to unix:///var/run/docker.sock)" + + return [ + renderHeadlineForKind(directKind), + "Runtime contract: docker-git is host-Docker-backed; the controller container talks to the daemon via /var/run/docker.sock.", + ...renderRemediationForKind(directKind, context.apiBaseUrl), + "Probe commands: docker info; sudo -n docker info", + renderProbeLine("Direct probe", context.directProbe), + renderProbeLine("Sudo probe", context.sudoProbe), + dockerHostLine + ].join("\n") +} diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 30c63cb5..5b122b47 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -7,10 +7,17 @@ import { Effect } from "effect" import { runCommandCapture, runCommandExitCode, - runCommandExitCodeStreaming + runCommandExitCodeStreaming, + runCommandWithCapturedOutput } from "./frontend-lib/shell/command-runner.js" -import { type DockerNetworkIps, parseDockerNetworkIps, uniqueStrings } from "./controller-reachability.js" +import { type DockerProbeOutcome, renderDockerAccessDeniedMessage } from "./controller-docker-diagnostics.js" +import { + type DockerNetworkIps, + parseDockerNetworkIps, + resolveConfiguredApiBaseUrl, + uniqueStrings +} from "./controller-reachability.js" import { computeLocalControllerRevision, controllerRevisionEnvKey, @@ -66,14 +73,6 @@ const currentProcessEnv = (): Readonly> => Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) ) -const renderDockerAccessDeniedMessage = (): string => - [ - "docker-git host CLI cannot access Docker from the client process.", - "Tried direct Docker and passwordless sudo Docker.", - "Keep the docker-git backend container running and reach it via DOCKER_GIT_API_URL or the default local API port, grant this user direct Docker access (docker group/rootless Docker), or configure passwordless sudo for docker.", - "Probe commands: docker info; sudo -n docker info" - ].join("\n") - const runExitCode = ( command: string, args: ReadonlyArray @@ -89,25 +88,62 @@ const runExitCode = ( }) ) +type ProbeFailure = { + readonly _tag: "ProbeFailure" + readonly outcome: DockerProbeOutcome +} + +const captureProbeOutcome = ( + command: string, + args: ReadonlyArray +): Effect.Effect => + runCommandWithCapturedOutput( + { cwd: process.cwd(), command, args }, + [0], + (exitCode, output): ProbeFailure => ({ + _tag: "ProbeFailure", + outcome: { exitCode, stderr: output } + }) + ).pipe( + Effect.match({ + onFailure: (error) => + "outcome" in error + ? error.outcome + : { exitCode: 127, stderr: String(error) }, + onSuccess: () => ({ exitCode: 0, stderr: "" }) + }) + ) + export const resolveDockerCommand = (): Effect.Effect< ReadonlyArray, ControllerBootstrapError, CommandExecutor.CommandExecutor > => - runExitCode("docker", ["info"]).pipe( - Effect.flatMap((dockerInfoExit) => { - if (dockerInfoExit === 0) { - return Effect.succeed>(["docker"]) - } - return runExitCode("sudo", ["-n", "docker", "info"]).pipe( - Effect.flatMap((sudoDockerInfoExit) => - sudoDockerInfoExit === 0 - ? Effect.succeed>(["sudo", "-n", "docker"]) - : Effect.fail(controllerBootstrapError(renderDockerAccessDeniedMessage())) + Effect.gen(function*(_) { + const directProbe = yield* _(captureProbeOutcome("docker", ["info"])) + if (directProbe.exitCode === 0) { + return ["docker"] as ReadonlyArray + } + + const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"])) + if (sudoProbe.exitCode === 0) { + return ["sudo", "-n", "docker"] as ReadonlyArray + } + + const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? "" + return yield* _( + Effect.fail( + controllerBootstrapError( + renderDockerAccessDeniedMessage({ + directProbe, + sudoProbe, + apiBaseUrl: resolveConfiguredApiBaseUrl(), + dockerHost: dockerHostRaw.length > 0 ? dockerHostRaw : null + }) ) ) - }) - ) + ) + }) type DockerInvocation = { readonly command: string diff --git a/packages/app/tests/docker-git/controller-docker-diagnostics.test.ts b/packages/app/tests/docker-git/controller-docker-diagnostics.test.ts new file mode 100644 index 00000000..6491fda5 --- /dev/null +++ b/packages/app/tests/docker-git/controller-docker-diagnostics.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { + classifyDockerProbeFailure, + type DockerAccessDeniedContext, + renderDockerAccessDeniedMessage +} from "../../src/docker-git/controller-docker-diagnostics.js" + +const apiBaseUrl = "http://127.0.0.1:3334" + +const buildContext = (overrides: Partial = {}): DockerAccessDeniedContext => ({ + directProbe: { exitCode: 1, stderr: "" }, + sudoProbe: null, + apiBaseUrl, + dockerHost: null, + ...overrides +}) + +describe("classifyDockerProbeFailure", () => { + it.effect("classifies socket permission denied", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 1, + stderr: + "Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock" + }) + expect(kind).toBe("socket-permission-denied") + })) + + it.effect("classifies daemon unreachable when stderr says cannot connect", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" + }) + expect(kind).toBe("daemon-unreachable") + })) + + it.effect("classifies missing CLI when exit code is 127 and stderr says not found", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 127, + stderr: "docker: command not found" + }) + expect(kind).toBe("docker-cli-missing") + })) + + it.effect("returns unknown for empty stderr and non-recognised exit code", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ exitCode: 1, stderr: "" }) + expect(kind).toBe("unknown") + })) + + it.effect("prefers permission denied over daemon unreachable when both markers appear", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 1, + stderr: "permission denied: Cannot connect to the Docker daemon" + }) + expect(kind).toBe("socket-permission-denied") + })) +}) + +describe("renderDockerAccessDeniedMessage", () => { + it.effect("explains permission mismatch and mentions the contract", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + directProbe: { + exitCode: 1, + stderr: "Got permission denied while trying to connect to the Docker daemon socket" + }, + sudoProbe: { exitCode: 1, stderr: "sudo: a password is required" } + }) + ) + + expect(message).toContain("Host Docker socket rejected this user") + expect(message).toContain("Runtime contract: docker-git is host-Docker-backed") + expect(message).toContain("docker group") + expect(message).toContain(apiBaseUrl) + expect(message).toContain("Direct probe: exit=1; Got permission denied") + expect(message).toContain("Sudo probe: exit=1; sudo: a password is required") + })) + + it.effect("explains daemon-down case differently", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + directProbe: { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" + }, + sudoProbe: { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon" + } + }) + ) + + expect(message).toContain("Host Docker daemon is not reachable") + expect(message).toContain("systemctl start docker") + expect(message).toContain("DOCKER_HOST: unset") + })) + + it.effect("renders DOCKER_HOST when provided", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + dockerHost: "tcp://docker.example:2376" + }) + ) + + expect(message).toContain("DOCKER_HOST: tcp://docker.example:2376") + })) + + it.effect("marks sudo probe as skipped when not provided", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage(buildContext({ sudoProbe: null })) + expect(message).toContain("Sudo probe: skipped") + })) + + it.effect("recommends installing Docker when CLI is missing", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + directProbe: { exitCode: 127, stderr: "docker: command not found" }, + sudoProbe: { exitCode: 127, stderr: "sudo: docker: command not found" } + }) + ) + + expect(message).toContain("docker CLI was not found") + expect(message).toContain("Install Docker Engine") + })) +}) From 332cd8593a774dd7cf8ea13d5fd2012c8c8c3b84 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 5 May 2026 23:14:06 +0000 Subject: [PATCH 3/7] fix: drop redundant readonly array casts in docker probe --- packages/app/src/docker-git/controller-docker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 5b122b47..13dd1e30 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -122,12 +122,12 @@ export const resolveDockerCommand = (): Effect.Effect< Effect.gen(function*(_) { const directProbe = yield* _(captureProbeOutcome("docker", ["info"])) if (directProbe.exitCode === 0) { - return ["docker"] as ReadonlyArray + return ["docker"] } const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"])) if (sudoProbe.exitCode === 0) { - return ["sudo", "-n", "docker"] as ReadonlyArray + return ["sudo", "-n", "docker"] } const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? "" From 6249943950b6965df4c16716351f3ba5b56147d0 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 6 May 2026 00:13:37 +0000 Subject: [PATCH 4/7] ci: re-trigger after transient E2E hang From 330214b39550be5be55f29b4c0838831d93bb628 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 6 May 2026 00:13:38 +0000 Subject: [PATCH 5/7] ci: retrigger workflows after flaky E2E timeout From 25fc07d8cbc0240566f1f4b1ae32c25ede844d1b Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 9 May 2026 07:08:04 +0000 Subject: [PATCH 6/7] fix(app): satisfy checks after main merge --- .../app/src/web/actions-project-create.ts | 13 +-- packages/app/src/web/actions-projects.ts | 89 +++++++++---------- packages/app/src/web/api-event-payload.ts | 13 +++ packages/app/src/web/api-types.ts | 5 +- .../tests/docker-git/actions-projects.test.ts | 87 ++++++++++++------ 5 files changed, 119 insertions(+), 88 deletions(-) create mode 100644 packages/app/src/web/api-event-payload.ts diff --git a/packages/app/src/web/actions-project-create.ts b/packages/app/src/web/actions-project-create.ts index 9c2f5eb6..95a45af3 100644 --- a/packages/app/src/web/actions-project-create.ts +++ b/packages/app/src/web/actions-project-create.ts @@ -5,23 +5,12 @@ import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.j import type { CreateInputs } from "../docker-git/menu-types.js" import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { type BrowserActionContext, withBusy } from "./actions-shared.js" +import { readEventPayloadString } from "./api-event-payload.js" import { ProjectDetailsSchema } from "./api-schema.js" import { type ApiEvent, loadProjectDetails, type ProjectDetails, startCreateProject } from "./api.js" import { openProjectEventStream } from "./project-events.js" import { outputScreen, projectPickerScreen } from "./screen.js" -const readEventPayloadString = ( - event: ApiEvent, - key: string -): string | null => { - const payload = event.payload - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - return null - } - const value = Object.entries(payload).find(([name]) => name === key)?.[1] - return typeof value === "string" ? value : null -} - const readCreatedProjectId = (event: ApiEvent): string | null => event.type === "project.created" ? readEventPayloadString(event, "projectId") : null diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 276c3b02..49a80962 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -12,10 +12,11 @@ import { withSelectedProjectBusy } from "./actions-shared.js" import { loadSelectedProjectTasks } from "./actions-tasks.js" +import { readEventPayloadString } from "./api-event-payload.js" import { + type ApiEvent, applyAllProjects, applyProject, - type ApiEvent, deleteProject, downAllProjects, downProject, @@ -79,43 +80,23 @@ const resolveProjectTerminalKey = ( } const randomHex = (bytes: number): string => { - const getRandomValues = globalThis.crypto?.getRandomValues - if (typeof getRandomValues === "function") { + if (typeof globalThis.crypto.getRandomValues === "function") { const values = new Uint8Array(bytes) - getRandomValues.call(globalThis.crypto, values) + globalThis.crypto.getRandomValues(values) return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") } - let fallback = "" - while (fallback.length < bytes * 2) { - fallback += Math.floor(Math.random() * 0x1_0000_0000) - .toString(16) - .padStart(8, "0") - } - return fallback.slice(0, bytes * 2) + return Date.now().toString(16).padStart(bytes * 2, "0").slice(0, bytes * 2) } const createPendingTerminalSessionId = (): string => { - const randomUUID = globalThis.crypto?.randomUUID - if (typeof randomUUID === "function") { - return randomUUID.call(globalThis.crypto) + if (typeof globalThis.crypto.randomUUID === "function") { + return globalThis.crypto.randomUUID() } return `pending-${Date.now().toString(16)}-${randomHex(8)}` } -const readEventPayloadString = ( - event: ApiEvent, - key: string -): string | null => { - const payload = event.payload - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - return null - } - const value = Object.entries(payload).find(([name]) => name === key)?.[1] - return typeof value === "string" ? value : null -} - const readTerminalSessionCreatedId = ( event: ApiEvent, requestId: string @@ -148,6 +129,23 @@ const readTerminalStartupFailure = ( return readEventPayloadString(event, "message") ?? "SSH session startup failed." } +const addAttachedProjectTerminalSession = ( + context: BrowserActionContext, + projectId: string, + projectDisplayName: string, + projectKey: string, + session: Parameters[0]["session"] +) => { + context.addTerminalSession(buildProjectActiveTerminalSession({ + onExit: context.reloadDashboard, + onReady: context.reloadDashboard, + projectDisplayName, + projectId, + projectKey, + session + })) +} + export const connectProjectById = ( projectId: string, context: BrowserActionContext, @@ -179,6 +177,11 @@ export const connectProjectById = ( projectKey: resolvedProjectKey, ...(message === undefined ? {} : { message }) }) + const setPendingTerminalError = (error: string) => { + pendingSessionFinalized = true + appendOutputLine(context, `[error] ${error}`) + context.addTerminalSession(renderPendingTerminalSession(error, "error")) + } context.setSelectedProjectId(projectId) context.setOutput("") appendOutputLine(context, "[ssh.prepare] Preparing SSH session") @@ -198,23 +201,20 @@ export const connectProjectById = ( effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), label: "Attaching SSH terminal", onFailure: (error) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) + setPendingTerminalError(error) closeStream() }, onSuccess: (session) => { pendingSessionFinalized = true context.reloadDashboard() context.closeTerminalSession(pendingSessionId) - context.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: context.reloadDashboard, - onReady: context.reloadDashboard, - projectDisplayName, + addAttachedProjectTerminalSession( + context, projectId, - projectKey: resolvedProjectKey, + projectDisplayName, + resolvedProjectKey, session - })) + ) context.setMessage(`Project is ready. SSH terminal is connecting for ${projectDisplayName}.`) closeStream() } @@ -225,9 +225,7 @@ export const connectProjectById = ( effect: startProjectTerminalSession(resolvedProjectKey, pendingSessionId), label: "Opening SSH terminal", onFailure: (error) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) + setPendingTerminalError(error) }, onSuccess: (accepted) => { appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`) @@ -237,9 +235,7 @@ export const connectProjectById = ( onEvent: (event) => { const failure = readTerminalStartupFailure(event, accepted.requestId) if (failure !== null) { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${failure}`) - context.addTerminalSession(renderPendingTerminalSession(failure, "error")) + setPendingTerminalError(failure) context.setMessage(failure) closeStream() return @@ -306,14 +302,13 @@ export const attachProjectTerminalById = ( effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), label: "Attaching SSH terminal", onSuccess: (session) => { - context.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: context.reloadDashboard, - onReady: context.reloadDashboard, - projectDisplayName, + addAttachedProjectTerminalSession( + context, projectId, - projectKey: resolvedProjectKey, + projectDisplayName, + resolvedProjectKey, session - })) + ) context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) } }) diff --git a/packages/app/src/web/api-event-payload.ts b/packages/app/src/web/api-event-payload.ts new file mode 100644 index 00000000..503748ce --- /dev/null +++ b/packages/app/src/web/api-event-payload.ts @@ -0,0 +1,13 @@ +import type { ApiEvent } from "./api.js" + +export const readEventPayloadString = ( + event: ApiEvent, + key: string +): string | null => { + const payload = event.payload + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + return null + } + const value = Object.entries(payload).find(([name]) => name === key)?.[1] + return typeof value === "string" ? value : null +} diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 4e66774f..840fb37a 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -23,8 +23,9 @@ import type { export type ProjectSummary = Schema.Schema.Type export type ProjectDetails = Schema.Schema.Type export type CreateProjectAcceptedResponse = Schema.Schema.Type -export type StartProjectTerminalSessionAccepted = - Schema.Schema.Type +export type StartProjectTerminalSessionAccepted = Schema.Schema.Type< + typeof StartProjectTerminalSessionAcceptedResponseSchema +> export type ProjectPortForward = Schema.Schema.Type export type ProjectBrowserSession = Schema.Schema.Type export type ProjectDatabaseForward = Schema.Schema.Type diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index f7c2483d..dbf23d59 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -3,16 +3,41 @@ import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" -import type { ProjectDetails, StartProjectTerminalSessionAccepted, TerminalSession } from "../../src/web/api.js" +import type { + ApiEvent, + ProjectDetails, + StartProjectTerminalSessionAccepted, + TerminalSession +} from "../../src/web/api.js" import type { ActiveTerminalSession } from "../../src/web/terminal.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" +type ProjectEventStreamHandlers = { + readonly onEvent?: (event: ApiEvent) => void +} + +type ProjectEventStreamControls = { + readonly close: () => void +} + +type ConnectProjectFixture = { + readonly addTerminalSession: ReturnType void>> + readonly closeTerminalSession: ReturnType void>> + readonly context: ReturnType["context"] + readonly reloadDashboard: ReturnType["reloadDashboard"] + readonly setMessage: ReturnType["setMessage"] +} + const applyAllProjectsMock = vi.hoisted(() => vi.fn()) const applyProjectMock = vi.hoisted(() => vi.fn()) const eventStreamCloseMock = vi.hoisted(() => vi.fn()) const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) -const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) -const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => + vi.fn<(projectId: string, handlers: ProjectEventStreamHandlers) => ProjectEventStreamControls>() +) +const startProjectTerminalSessionMock = vi.hoisted(() => + vi.fn<(projectKey: string, requestId: string) => Effect.Effect>() +) vi.mock("../../src/web/api.js", () => ({ applyAllProjects: applyAllProjectsMock, @@ -91,6 +116,27 @@ const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAc requestId }) +const connectProjectFixture = (): ConnectProjectFixture => { + const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() + const closeTerminalSession = vi.fn<(sessionId: string) => void>() + const { context, reloadDashboard, setMessage } = makeBrowserActionContext({ + addTerminalSession, + closeTerminalSession, + selectedProjectId: "project-1", + selectedProjectKey: "octocat/hello-world" + }) + + connectProjectById("project-1", context, "octocat/hello-world") + + return { + addTerminalSession, + closeTerminalSession, + context, + reloadDashboard, + setMessage + } +} + describe("web project actions", () => { beforeEach(() => { vi.restoreAllMocks() @@ -116,16 +162,7 @@ describe("web project actions", () => { ) loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(session)) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) - const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const closeTerminalSession = vi.fn<(sessionId: string) => void>() - const { context, reloadDashboard, setMessage } = makeBrowserActionContext({ - addTerminalSession, - closeTerminalSession, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" - }) - - connectProjectById("project-1", context, "octocat/hello-world") + const { addTerminalSession, closeTerminalSession, context, reloadDashboard, setMessage } = connectProjectFixture() yield* _(waitForAssertion(() => { expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) @@ -192,33 +229,29 @@ describe("web project actions", () => { ) })) - it.effect("starts SSH terminal creation when randomUUID is unavailable", () => + it.effect("starts SSH terminal creation from getRandomValues when randomUUID is unavailable", () => Effect.gen(function*(_) { - const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x1234) - const mathRandomMock = vi.spyOn(Math, "random").mockReturnValue(0.5) - vi.stubGlobal("crypto", {}) + const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x1_9A_11_7B_D6_1F) + vi.stubGlobal("crypto", { + getRandomValues: (values: Uint8Array) => { + values.set([0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE]) + return values + } + }) startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) => Effect.succeed(startTerminalAccepted(requestId)) ) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) - const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const { context } = makeBrowserActionContext({ - addTerminalSession, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" - }) - - connectProjectById("project-1", context, "octocat/hello-world") + const { addTerminalSession } = connectProjectFixture() yield* _(waitForAssertion(() => { expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1) })) const requestId = startProjectTerminalSessionMock.mock.calls[0]?.[1] - expect(requestId).toBe("pending-1234-8000000080000000") + expect(requestId).toBe("pending-19a117bd61f-1032547698badcfe") expect(addTerminalSession).toHaveBeenCalledTimes(1) expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - mathRandomMock.mockRestore() dateNowMock.mockRestore() })) From 5cceb3fad144ae63bc7c205ffa49c189c5aa781e Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 9 May 2026 12:39:57 +0000 Subject: [PATCH 7/7] fix(ci): bound openssh setup apt calls --- .github/actions/setup/action.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 7bb58e92..cb33036e 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -24,9 +24,19 @@ runs: - name: Install OpenSSH client shell: bash run: | + if command -v ssh >/dev/null 2>&1 && command -v ssh-keygen >/dev/null 2>&1; then + echo "OpenSSH client is already installed; skipping apt install" + exit 0 + fi + for attempt in 1 2 3; do sudo rm -rf /var/lib/apt/lists/* - if sudo apt-get -o Acquire::Retries=3 -o Acquire::By-Hash=force update; then + if sudo timeout 120s apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::By-Hash=force \ + -o Acquire::http::Timeout=20 \ + -o Acquire::https::Timeout=20 \ + update; then break fi if [[ "$attempt" == "3" ]]; then @@ -36,7 +46,11 @@ runs: echo "apt-get update attempt ${attempt} failed; retrying..." >&2 sleep $((attempt * 2)) done - sudo apt-get -o Acquire::Retries=3 install -y openssh-client + sudo timeout 120s apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=20 \ + -o Acquire::https::Timeout=20 \ + install -y openssh-client - name: Install node-gyp shell: bash run: npm install -g node-gyp