From faf22ee38fdc57ee0734116c66dc8a97a9fe2323 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Mon, 27 Jul 2026 17:56:37 +0200 Subject: [PATCH 01/34] fix(dev-platform): wire the runner image into the middleware's job policy + let operators delete terminal jobs The middleware's DockerBackend job-policy endpoint (GET /internal/job-policy/:jobId, fetched by the daemon at provision time) has been unreachable from the day the epic shipped: docker-compose.dev-platform.yaml never set DEV_RUNNER_DEFAULT_IMAGE (or DEV_RUNNER_IMAGE) on the middleware container, only on the daemon (as DEV_RUNNER_IMAGES). Without a resolved runner image, wireDevPlatform's jobPolicyConfig stays undefined and the endpoint 503s forever. Every real DockerBackend job dies instantly at the implement phase (the first phase that actually provisions a container -- analyze/bootstrap/plan/clarify/gate run without one) with the daemon reporting a generic 502 'the middleware could not supply the job policy' and zero tokens spent. Fix: wire DEV_RUNNER_DEFAULT_IMAGE onto the middleware from the same DEV_RUNNER_IMAGE source var the daemon's allowlist already uses, and make index.ts's runner-image resolution consistent between the Fly and Docker backends (both now share one DEV_RUNNER_IMAGE ?? DEV_RUNNER_DEFAULT_IMAGE fallback, previously only the Fly path had it). Added two composeTopology tests asserting the middleware carries a runner image and that it agrees with the daemon's allowlist, so this exact gap cannot silently reopen. Also: there was no way to remove a finished job from the operator's list short of the daily retention sweep -- add DELETE /jobs/:id (terminal jobs only, 409 for an active one) with matching UI actions on the job list and job detail page. Full gate green: middleware build/lint/typecheck/test (4799/4803, 0 fail, 4 skipped pg-only locally), web-ui lint/typecheck/vitest (0 errors, 306/306), i18n:check OK (3136 keys). --- docker-compose.dev-platform.yaml | 8 +++++ middleware/src/devplatform/devJobStore.ts | 18 ++++++++++ middleware/src/index.ts | 29 ++++++++++------ middleware/src/routes/devPlatform.ts | 24 +++++++++++++ middleware/src/routes/devPlatformShared.ts | 1 + .../test/devplatform/composeTopology.test.ts | 25 ++++++++++++++ .../test/devplatform/devJobStore.pg.test.ts | 21 ++++++++++++ .../devplatform/devPlatformRoutes.harness.ts | 26 ++++++++++---- .../devplatform/devPlatformRoutes.test.ts | 34 +++++++++++++++++++ .../dev-platform/_components/JobTable.tsx | 23 ++++++++++++- web-ui/app/admin/dev-platform/_lib/api.ts | 5 +++ .../app/admin/dev-platform/jobs/[id]/page.tsx | 25 +++++++++++++- web-ui/app/admin/dev-platform/page.tsx | 4 +++ web-ui/messages/de.json | 14 ++++++++ web-ui/messages/en.json | 14 ++++++++ 15 files changed, 251 insertions(+), 20 deletions(-) diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index 0b0459ae..bc3a3da2 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -47,6 +47,14 @@ services: # Where the runner phones home. The daemon injects this into every job; the # runner reaches it across dev-control, never over the public internet. DEV_PLATFORM_RUNNER_BASE_URL: http://middleware:8080 + # The image the middleware derives into every job's policy (served over + # GET /internal/job-policy/:jobId, which the daemon fetches at provision + # time). Without this, jobPolicyConfig never builds and that endpoint + # 503s forever — every DockerBackend provision fails at the first real + # container (i.e. the implement phase; analyze/plan/clarify run without + # one). Same source var as the daemon's own DEV_RUNNER_IMAGES below, so + # both sides always agree on which image a job runs. + DEV_RUNNER_DEFAULT_IMAGE: ${DEV_RUNNER_IMAGE:-ghcr.io/byte5ai/omadia-dev-runner:latest} # Neutralise any docker engine address a stray `middleware/.env` (loaded via # the base file's env_file) might inject. `environment` wins over env_file, # so these empty values are the last word: the middleware CANNOT be handed a diff --git a/middleware/src/devplatform/devJobStore.ts b/middleware/src/devplatform/devJobStore.ts index 72421053..7e244ca1 100644 --- a/middleware/src/devplatform/devJobStore.ts +++ b/middleware/src/devplatform/devJobStore.ts @@ -22,6 +22,7 @@ import { isLowValueEventType, type ArtifactCeilingOptions } from './retention.js import { isDevJobEventType, isTerminalDevJobStatus, + TERMINAL_DEV_JOB_STATUSES, type DevJob, type DevJobArtifact, type DevJobEvent, @@ -237,6 +238,23 @@ export class DevJobStore { return r.rows[0] ? toJob(r.rows[0]) : null; } + /** + * Delete one job's row — `ON DELETE CASCADE` (0022) removes its events and + * artifacts in the same statement, same as `retention.purgeTerminalJobs` + * (spec §7), just for a single operator-named job instead of an age sweep. + * Scoped to terminal statuses only: an active job still has a live backend + * handle (container, Fly Machine) that deleting the row would orphan — + * terminate it first (which finalizes the job), then delete. + */ + async deleteJob(id: string): Promise<'deleted' | 'not_terminal' | 'not_found'> { + const r = await this.pool.query( + `DELETE FROM dev_jobs WHERE id = $1 AND status = ANY($2::text[])`, + [id, [...TERMINAL_DEV_JOB_STATUSES]], + ); + if ((r.rowCount ?? 0) > 0) return 'deleted'; + return (await this.getJob(id)) ? 'not_terminal' : 'not_found'; + } + async listJobs(filter: ListJobsFilter = {}): Promise { const where: string[] = []; const params: unknown[] = []; diff --git a/middleware/src/index.ts b/middleware/src/index.ts index d1f5b7f7..7dca1db1 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -2535,13 +2535,20 @@ async function main(): Promise { // W2: role-principal gates resolve their live holder set against the same // conductor role store the conductor await gate uses. const devPlatformRoleStore = new ConductorRoleStore(graphPool); - // Epic #470 W4 — resolve the FlyMachinesBackend config when a dedicated runner - // app is set. The on-/off-Fly selection lives HERE so the assembly layer stays - // env-free: on Fly (FLY_APP_NAME injected) use the internal Machines API + a - // `.internal` 6PN phone-home address; off Fly use the public endpoints. Requires - // a digest-pinned image (DEV_RUNNER_IMAGE, falling back to DEV_RUNNER_DEFAULT_IMAGE). - // These operator URLs are DELIBERATELY not SSRF-guarded (`.internal` is valid here). - const flyRunnerImage = config.DEV_RUNNER_IMAGE ?? config.DEV_RUNNER_DEFAULT_IMAGE; + // The runner image, shared by every backend: FlyMachinesBackend (below) AND + // the DockerBackend job-policy config (assembleDevPlatform's `runnerImage`, + // further down) both derive from this one resolution. `DEV_RUNNER_IMAGE` + // wins when set (it's the name the daemon's own DEV_RUNNER_IMAGES/allowlist + // config uses too, so one operator-set var keeps every side in agreement); + // `DEV_RUNNER_DEFAULT_IMAGE` is the fallback. A digest-pinned image is + // required on Fly (enforced below); locally a floating tag is fine. + // + // Epic #470 W4 — the on-/off-Fly selection for the Machines backend lives + // HERE so the assembly layer stays env-free: on Fly (FLY_APP_NAME injected) + // use the internal Machines API + a `.internal` 6PN phone-home address; off + // Fly use the public endpoints. These operator URLs are DELIBERATELY not + // SSRF-guarded (`.internal` is valid here). + const resolvedRunnerImage = config.DEV_RUNNER_IMAGE ?? config.DEV_RUNNER_DEFAULT_IMAGE; // The runner app MUST be dedicated — NEVER this middleware's own Fly app, or a // job's ephemeral machine (running hostile repo code) would be provisioned into // the app that holds the middleware's machines, volumes, and app-level secrets @@ -2555,13 +2562,13 @@ async function main(): Promise { ); } const flyConfig = - config.DEV_FLY_RUNNER_APP && flyRunnerImage && !flyAppIsSelf + config.DEV_FLY_RUNNER_APP && resolvedRunnerImage && !flyAppIsSelf ? { runnerApp: config.DEV_FLY_RUNNER_APP, apiBase: config.FLY_APP_NAME ? 'http://_api.internal:4280/v1' : 'https://api.machines.dev/v1', - image: flyRunnerImage, + image: resolvedRunnerImage, phoneHomeUrl: config.DEV_FLY_PHONE_HOME_URL ?? (config.FLY_APP_NAME @@ -2577,7 +2584,7 @@ async function main(): Promise { ...(config.DEV_FLY_REGION ? { region: config.DEV_FLY_REGION } : {}), } : undefined; - if (config.DEV_FLY_RUNNER_APP && !flyRunnerImage) { + if (config.DEV_FLY_RUNNER_APP && !resolvedRunnerImage) { console.warn( '[middleware] DEV_FLY_RUNNER_APP set but no runner image (DEV_RUNNER_IMAGE / DEV_RUNNER_DEFAULT_IMAGE) — FlyMachinesBackend NOT registered', ); @@ -2604,7 +2611,7 @@ async function main(): Promise { ...(config.DEV_RUNNER_DAEMON_URL ? { daemonUrl: config.DEV_RUNNER_DAEMON_URL } : {}), backend: config.DEV_PLATFORM_BACKEND, leaseTtlSec: config.DEV_JOB_LEASE_TTL_SEC, - ...(config.DEV_RUNNER_DEFAULT_IMAGE ? { runnerImage: config.DEV_RUNNER_DEFAULT_IMAGE } : {}), + ...(resolvedRunnerImage ? { runnerImage: resolvedRunnerImage } : {}), ...(config.DEV_EGRESS_BASE_ALLOWLIST ? { egressBaseAllowlist: csvList(config.DEV_EGRESS_BASE_ALLOWLIST) } : {}), diff --git a/middleware/src/routes/devPlatform.ts b/middleware/src/routes/devPlatform.ts index a6174215..38cd458d 100644 --- a/middleware/src/routes/devPlatform.ts +++ b/middleware/src/routes/devPlatform.ts @@ -211,6 +211,30 @@ export function createDevPlatformRouter(deps: DevPlatformRouterDeps): Router { }), ); + // --- DELETE /jobs/:id ------------------------------------------------- + // Removes a terminal job's row (and its events/artifacts, via CASCADE) so it + // stops cluttering the operator's job list. Refuses an active job (409) — + // it still has a live backend handle; cancel it first, which finalizes it. + router.delete( + '/jobs/:id', + handler(async (req, res) => { + const caller = requireCaller(req); + const job = await loadAuthorizedJob(deps, req, caller); + const outcome = await deps.jobStore.deleteJob(job.id); + if (outcome === 'not_terminal') { + throw new DevPlatformError( + 409, + 'devplatform.job_not_terminal', + 'the job is still active — cancel it before deleting', + ); + } + // 'not_found' here means it was deleted between the authorize-load above + // and this call (e.g. the daily retention sweep); still a success from + // the caller's point of view — the job is gone either way. + res.status(204).end(); + }), + ); + // --- POST /jobs/:id/apply ------------------------------------------------- // Retry of the host-side apply. 409 unless `applying` or failed-after-diff. router.post( diff --git a/middleware/src/routes/devPlatformShared.ts b/middleware/src/routes/devPlatformShared.ts index 1ee668cc..dfe33e3b 100644 --- a/middleware/src/routes/devPlatformShared.ts +++ b/middleware/src/routes/devPlatformShared.ts @@ -58,6 +58,7 @@ export interface DevPlatformJobStore { listEvents(jobId: string, afterId?: number, limit?: number): Promise; listArtifacts(jobId: string): Promise; getArtifact(id: string): Promise; + deleteJob(id: string): Promise<'deleted' | 'not_terminal' | 'not_found'>; } /** The `DevRepoCredentialStore` surface. Never returns a token to the browser — diff --git a/middleware/test/devplatform/composeTopology.test.ts b/middleware/test/devplatform/composeTopology.test.ts index 4578edf8..d2f58ada 100644 --- a/middleware/test/devplatform/composeTopology.test.ts +++ b/middleware/test/devplatform/composeTopology.test.ts @@ -272,3 +272,28 @@ describe('dev-platform compose overlay — the MERGED config, not just the overl } }); }); + +describe('dev-platform compose overlay — the middleware can actually derive a job policy', () => { + // Without a runner image, `wireDevPlatform`'s jobPolicyConfig never builds and + // GET /internal/job-policy/:jobId 503s forever — every DockerBackend provision + // fails at the first real container (the implement phase; analyze/plan/clarify + // don't need one, so this gap is invisible until a real job actually runs). + // This was true of the shipped overlay for the whole life of the epic. + it('gives the middleware a runner image, not just the daemon', () => { + const env = overlay.services['middleware']?.environment ?? {}; + assert.ok( + env['DEV_RUNNER_DEFAULT_IMAGE'] || env['DEV_RUNNER_IMAGE'], + 'middleware needs DEV_RUNNER_DEFAULT_IMAGE (or DEV_RUNNER_IMAGE) or every job dies at implement with a 502', + ); + }); + + it('agrees with the daemon on which image that is', () => { + // Same source var (DEV_RUNNER_IMAGE) feeds both sides, so an operator who + // sets it once cannot end up with the daemon allowing image A while the + // middleware's policy names image B. + const middlewareImage = overlay.services['middleware']?.environment?.['DEV_RUNNER_DEFAULT_IMAGE']; + const daemonImages = overlay.services['dev-runner-daemon']?.environment?.['DEV_RUNNER_IMAGES']; + assert.ok(middlewareImage, 'middleware image must be set to compare'); + assert.ok(daemonImages?.includes(middlewareImage as string), 'daemon and middleware must name the same image'); + }); +}); diff --git a/middleware/test/devplatform/devJobStore.pg.test.ts b/middleware/test/devplatform/devJobStore.pg.test.ts index 3a8aa02d..a558dad4 100644 --- a/middleware/test/devplatform/devJobStore.pg.test.ts +++ b/middleware/test/devplatform/devJobStore.pg.test.ts @@ -324,6 +324,27 @@ describe('devplatform/DevJobStore (pg)', { skip: !pgAvailable }, () => { assert.equal(again?.error, null, 'the no-op did not write the failure error'); }); + it('deleteJob refuses an active job, deletes a terminal one, and reports a missing id', async () => { + // Active (queued) — never deleted, it still has (or will have) a live backend + // handle; deleting the row out from under it would orphan a container/Machine. + const active = await newQueuedJob(repo.id); + assert.equal(await store.deleteJob(active.id), 'not_terminal'); + assert.ok(await store.getJob(active.id), 'the active job row is untouched'); + + // Terminal — deleted, and CASCADE (0022) takes its events with it. + const terminal = await newQueuedJob(repo.id); + await store.appendEvents(terminal.id, 1, [{ seq: 0, type: 'log', payload: { line: 'hi' } }]); + await store.finishTerminal(TERMINAL_FINISH_BRAND, terminal.id, 'failed', { error: 'x' }); + assert.equal(await store.deleteJob(terminal.id), 'deleted'); + assert.equal(await store.getJob(terminal.id), null, 'the row is gone'); + const events = await pool.query('SELECT 1 FROM dev_job_events WHERE job_id = $1', [terminal.id]); + assert.equal(events.rowCount, 0, 'its events cascaded away with it'); + + // Unknown id — distinct outcome from "exists but active", so the route can + // answer 404 instead of a misleading 409. + assert.equal(await store.deleteJob(randomUUID()), 'not_found'); + }); + it('findStalled surfaces active jobs past the heartbeat cutoff', async () => { const localRepo = await newRepo(); const job = await newQueuedJob(localRepo.id); diff --git a/middleware/test/devplatform/devPlatformRoutes.harness.ts b/middleware/test/devplatform/devPlatformRoutes.harness.ts index 26c69911..70e73248 100644 --- a/middleware/test/devplatform/devPlatformRoutes.harness.ts +++ b/middleware/test/devplatform/devPlatformRoutes.harness.ts @@ -14,13 +14,14 @@ import { import { DevJobEventBus } from '../../src/devplatform/devJobEventBus.js'; import type { Ticket } from '../../src/devplatform/githubIssuesTracker.js'; import type { FinalizeContext } from '../../src/devplatform/finalizeDevJob.js'; -import type { - DevJob, - DevJobEvent, - DevJobStatus, - DevRepo, - NewDevJob, - NewDevRepo, +import { + TERMINAL_DEV_JOB_STATUSES, + type DevJob, + type DevJobEvent, + type DevJobStatus, + type DevRepo, + type NewDevJob, + type NewDevRepo, } from '../../src/devplatform/types.js'; /** @@ -140,6 +141,13 @@ export class FakeJobStore { addArtifact(a: { id: string; jobId: string; kind: string; content: string }): void { this.artifacts.set(a.id, { ...a, meta: {}, createdAt: new Date().toISOString() }); } + async deleteJob(id: string): Promise<'deleted' | 'not_terminal' | 'not_found'> { + const job = this.jobs.get(id); + if (!job) return 'not_found'; + if (!(TERMINAL_DEV_JOB_STATUSES as readonly DevJobStatus[]).includes(job.status)) return 'not_terminal'; + this.jobs.delete(id); + return 'deleted'; + } } export class FakeCredentialStore { @@ -261,6 +269,10 @@ export async function postJson(url: string, headers: Record, bod return fetch(url, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body) }); } +export async function deleteReq(url: string, headers: Record) { + return fetch(url, { method: 'DELETE', headers }); +} + /** Assert that `fn` throws a DevPlatformError carrying the given code. */ export function throwsCode(fn: () => void, code: string): void { assert.throws(fn, (err: unknown) => (err as { code?: string }).code === code, `expected code ${code}`); diff --git a/middleware/test/devplatform/devPlatformRoutes.test.ts b/middleware/test/devplatform/devPlatformRoutes.test.ts index 5283f279..bd82f8da 100644 --- a/middleware/test/devplatform/devPlatformRoutes.test.ts +++ b/middleware/test/devplatform/devPlatformRoutes.test.ts @@ -18,6 +18,7 @@ import { Harness, PAT_TOKEN, authHeaders, + deleteReq, hasLeakedSecret, makeHarness, makeJob, @@ -241,6 +242,39 @@ describe('devPlatform — cancel routes through finalizeDevJob', () => { }); }); +describe('devPlatform — DELETE /jobs/:id', () => { + let h: Harness; + afterEach(async () => { if (h) await h.close(); }); + + it('204 and removes a terminal job', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice' })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'failed' })); + const res = await deleteReq(`${h.baseUrl}/jobs/job-1`, authHeaders()); + assert.equal(res.status, 204); + assert.equal(await h.jobStore.getJob('job-1'), null); + }); + + it('409 for an active job — never orphans a live backend handle', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice' })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'running' })); + const res = await deleteReq(`${h.baseUrl}/jobs/job-1`, authHeaders()); + assert.equal(res.status, 409); + assert.equal(((await res.json()) as { code: string }).code, 'devplatform.job_not_terminal'); + assert.ok(await h.jobStore.getJob('job-1'), 'the job survives the refused delete'); + }); + + it('404 for a job on a repo the caller may not launch (same as GET /jobs/:id)', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice', allowedLaunchers: [] })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'done' })); + const res = await deleteReq(`${h.baseUrl}/jobs/job-1`, authHeaders('bob', 'viewer')); + assert.equal(res.status, 404); + assert.ok(await h.jobStore.getJob('job-1'), 'unauthorized delete never touches the row'); + }); +}); + // --------------------------------------------------------------------------- // SSE — the single job-event tail. // --------------------------------------------------------------------------- diff --git a/web-ui/app/admin/dev-platform/_components/JobTable.tsx b/web-ui/app/admin/dev-platform/_components/JobTable.tsx index d077bab2..e36614ec 100644 --- a/web-ui/app/admin/dev-platform/_components/JobTable.tsx +++ b/web-ui/app/admin/dev-platform/_components/JobTable.tsx @@ -30,15 +30,18 @@ export function JobTable({ jobs, repos, onCancel, + onDelete, }: { jobs: DevJobView[]; repos: DevRepoView[]; onCancel: (job: DevJobView) => void; + onDelete: (job: DevJobView) => void; }): React.ReactElement { const t = useTranslations('adminDevPlatform.jobs'); const tKind = useTranslations('adminDevPlatform.jobs.kinds'); const format = useFormatter(); const [pendingCancel, setPendingCancel] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); const repoName = (repoId: string): string => { const r = repos.find((x) => x.id === repoId); @@ -96,7 +99,11 @@ export function JobTable({ {t('view')} - {isTerminalStatus(job.status) ? null : ( + {isTerminalStatus(job.status) ? ( + + ) : ( @@ -123,6 +130,20 @@ export function JobTable({ setPendingCancel(null); }} /> + + setPendingDelete(null)} + onConfirm={() => { + if (pendingDelete) onDelete(pendingDelete); + setPendingDelete(null); + }} + /> ); } diff --git a/web-ui/app/admin/dev-platform/_lib/api.ts b/web-ui/app/admin/dev-platform/_lib/api.ts index c4b2605e..9d385224 100644 --- a/web-ui/app/admin/dev-platform/_lib/api.ts +++ b/web-ui/app/admin/dev-platform/_lib/api.ts @@ -286,6 +286,11 @@ export function cancelJob(id: string): Promise<{ ok: boolean; status: string }> return req(`/jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST', body: JSON.stringify({}) }); } +/** Terminal jobs only — the route answers 409 for an active job (cancel it first). */ +export function deleteJob(id: string): Promise { + return req(`/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + export function retryJob(id: string): Promise<{ ok: boolean; jobId: string }> { return req(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST', body: JSON.stringify({}) }); } diff --git a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx index aa1d2570..4a855c49 100644 --- a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx +++ b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx @@ -18,7 +18,7 @@ import { } from '@/app/_components/devjobs/DevJobPhaseRail'; import { useDevJobEvents, type DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; import { JobLogPane, type LogConnection, type LogLine } from '../../_components/JobLogPane'; -import { cancelJob, getJob, isTerminalStatus, type DevJobView } from '../../_lib/api'; +import { cancelJob, deleteJob, getJob, isTerminalStatus, type DevJobView } from '../../_lib/api'; /** * Epic #470 W0 — the job-detail signature screen (UI spec §5). Header, the @@ -68,6 +68,7 @@ export default function JobDetailPage(): React.ReactElement { const [agoSec, setAgoSec] = useState(null); const [closedOnce, setClosedOnce] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); const terminalRef = useRef(false); useEffect(() => { @@ -178,6 +179,11 @@ export default function JobDetailPage(): React.ReactElement { {t('cancel.action')} ) : null} + {job && isTerminalStatus(job.status) ? ( + + ) : null} {/* Phase rail */} @@ -226,6 +232,23 @@ export default function JobDetailPage(): React.ReactElement { ); }} /> + + setConfirmDelete(false)} + onConfirm={() => { + setConfirmDelete(false); + void deleteJob(id).then( + () => router.push('/admin/dev-platform?tab=jobs'), + () => {}, + ); + }} + /> ); } diff --git a/web-ui/app/admin/dev-platform/page.tsx b/web-ui/app/admin/dev-platform/page.tsx index 4f23c489..f56d7283 100644 --- a/web-ui/app/admin/dev-platform/page.tsx +++ b/web-ui/app/admin/dev-platform/page.tsx @@ -15,6 +15,7 @@ import { GateInbox } from './_components/GateInbox'; import { cancelJob, checkRepo, + deleteJob, deleteRepo, listJobs, listRepos, @@ -240,6 +241,9 @@ function JobsTab(): React.ReactElement { onCancel={(job) => { void cancelJob(job.id).then(load, load); }} + onDelete={(job) => { + void deleteJob(job.id).then(load, load); + }} /> ); diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 40b8d75b..f85a5b55 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -3060,6 +3060,7 @@ "age": "Alter", "view": "Ansehen", "cancel": "Abbrechen", + "delete": "Löschen", "empty": "Noch keine Jobs. Starte einen aus einer Repository-Zeile.", "live": "live", "liveLost": "Verbindung verloren — versucht erneut", @@ -3089,6 +3090,12 @@ "body": "Der Runner wird beendet. Branch, Log und ein bereits hochgeladener Diff bleiben erhalten.", "confirm": "Job abbrechen", "cancel": "Weiterlaufen lassen" + }, + "deleteConfirm": { + "title": "Job löschen?", + "body": "Der Job, sein Log und seine Artefakte werden endgültig entfernt. Ein bereits erstellter Branch oder PR bleibt erhalten.", + "confirm": "Job löschen", + "cancel": "Behalten" } }, "newJob": { @@ -3221,6 +3228,13 @@ "confirm": "Job abbrechen", "cancelLabel": "Weiterlaufen lassen" }, + "delete": { + "action": "Löschen", + "title": "Job löschen?", + "body": "Der Job, sein Log und seine Artefakte werden endgültig entfernt. Ein bereits erstellter Branch oder PR bleibt erhalten.", + "confirm": "Job löschen", + "cancelLabel": "Behalten" + }, "sidebar": { "backend": "Backend", "agent": "Agent", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 581459b3..3a1fedac 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -3060,6 +3060,7 @@ "age": "Age", "view": "View", "cancel": "Cancel", + "delete": "Delete", "empty": "No jobs yet. Start one from a repository row.", "live": "live", "liveLost": "connection lost — retrying", @@ -3089,6 +3090,12 @@ "body": "The runner is terminated. The branch, the log, and any uploaded diff are kept.", "confirm": "Cancel job", "cancel": "Keep running" + }, + "deleteConfirm": { + "title": "Delete job?", + "body": "The job, its log, and its artifacts are removed permanently. Any branch or PR it created is kept.", + "confirm": "Delete job", + "cancel": "Keep it" } }, "newJob": { @@ -3221,6 +3228,13 @@ "confirm": "Cancel job", "cancelLabel": "Keep running" }, + "delete": { + "action": "Delete", + "title": "Delete job?", + "body": "The job, its log, and its artifacts are removed permanently. Any branch or PR it created is kept.", + "confirm": "Delete job", + "cancelLabel": "Keep it" + }, "sidebar": { "backend": "Backend", "agent": "Agent", From 72dc700f2599acfaff75c906a4216e9b6aeaa04e Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 06:53:51 +0200 Subject: [PATCH 02/34] fix(dev-platform): actually forward DEV_RUNNER_REQUIRE_DIGEST to the daemon A second, distinct cause of the same generic 'the middleware could not supply the job policy' 502: DEV_RUNNER_REQUIRE_DIGEST was only ever mentioned in a comment, never wired into the dev-runner-daemon service's environment block. env.DEV_RUNNER_REQUIRE_DIGEST was therefore always undefined inside the container regardless of .env, and parseRequireDigest() defaults undefined to true -- so every locally-built runner image (a floating tag, no digest, no registry to have pinned one from) was refused by assertPolicyImage at the allowlist/digest check, mapped by the daemon's HTTP layer to the exact same 502 the runner-image gap produced. Reproduced live: after the runner-image fix (this same PR) the job-policy endpoint returned 200 with a valid policy, yet a fresh real job (issue #445) still failed identically -- the daemon logs showed DEV_RUNNER_REQUIRE_DIGEST=true despite .env setting it to 0. New composeTopology test asserts the key is forwarded at all, independent of its value -- presence, not correctness, is what a comment cannot provide. --- docker-compose.dev-platform.yaml | 11 ++++++++++- .../test/devplatform/composeTopology.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index bc3a3da2..a35b8213 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -105,9 +105,18 @@ services: # The image allowlist is the boundary a compromised middleware cannot cross: # it may name a job, never an image. The daemon REFUSES TO BOOT without it. - # Digest-pinned by default (DEV_RUNNER_REQUIRE_DIGEST=0 to relax, locally). DEV_RUNNER_ALLOWED_IMAGES: ${DEV_RUNNER_ALLOWED_IMAGES:-ghcr.io/byte5ai/omadia-dev-runner} DEV_RUNNER_IMAGES: ${DEV_RUNNER_IMAGE:-ghcr.io/byte5ai/omadia-dev-runner:latest} + # Digest-pinned by default (true) — the comment above used to be the ONLY + # place this knob existed; the var was never actually forwarded into the + # container, so `env.DEV_RUNNER_REQUIRE_DIGEST` was always undefined and + # `parseRequireDigest` silently fell back to true. Every locally-built + # image is a floating tag (no digest, no registry to have digest-pinned + # it from), so EVERY provision was refused with the same generic + # "the middleware could not supply the job policy" 502 the runner-image + # gap produced — a second, distinct cause behind the identical symptom. + # Set DEV_RUNNER_REQUIRE_DIGEST=0 in .env to relax this, locally only. + DEV_RUNNER_REQUIRE_DIGEST: ${DEV_RUNNER_REQUIRE_DIGEST:-true} # Pull policy. `always` (the default, and prod on GHCR) re-pulls + digest-pins # + cosign-verifies every image on every provision. `if-not-present` skips the diff --git a/middleware/test/devplatform/composeTopology.test.ts b/middleware/test/devplatform/composeTopology.test.ts index d2f58ada..4c818231 100644 --- a/middleware/test/devplatform/composeTopology.test.ts +++ b/middleware/test/devplatform/composeTopology.test.ts @@ -251,6 +251,20 @@ describe('dev-platform compose overlay — one image, two services, two commands // never an image. `parseAllowedImages` throws when this is absent. assert.ok(overlay.services['dev-runner-daemon']!.environment!['DEV_RUNNER_ALLOWED_IMAGES']); }); + + it('actually forwards DEV_RUNNER_REQUIRE_DIGEST into the daemon container', () => { + // A var that only exists in a comment is not configuration. Before this key + // was added to `environment:`, `env.DEV_RUNNER_REQUIRE_DIGEST` was always + // undefined inside the container regardless of what .env said, and + // `parseRequireDigest` silently defaults undefined to `true` — so every + // locally-built, non-digest-pinned image was refused, no matter how the + // operator set the var. The key must be PRESENT (any value, incl. the + // default 'true'); its absence is the actual bug this guards. + assert.ok( + 'DEV_RUNNER_REQUIRE_DIGEST' in (overlay.services['dev-runner-daemon']!.environment ?? {}), + 'DEV_RUNNER_REQUIRE_DIGEST must be forwarded, not just documented in a comment', + ); + }); }); describe('dev-platform compose overlay — the MERGED config, not just the overlay map', { skip: !merged }, () => { From 1d157552dd90d6720355df86f53420353fe4096e Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 07:06:55 +0200 Subject: [PATCH 03/34] fix(dev-platform): honour DEV_RUNNER_REQUIRE_DIGEST in the clamp, not just the policy client The third gate in the same rejection chain, and the first one that is a real code bug rather than an unwired env var. With the runner image wired into the job policy and DEV_RUNNER_REQUIRE_DIGEST actually forwarded into the daemon container (the two preceding commits), a fresh real job stopped 502-ing and instead failed instantly at implement with a NEW, more specific error: devplatform.spec_rejected: daemon rejected the job (spec rejected (image_not_digest_pinned): the job image is a floating tag, not a digest reference) buildContainerCreateOptions in clamp.mjs re-checks digest-pinning after assertPolicyImage already passed -- but hardcoded the requirement ON instead of reading the operator's posture. Its own comment described itself as "the last line if that knob is ever turned off", which is not defence-in-depth: two enforcement points reading one posture is defence-in-depth, one of them ignoring it makes the posture a no-op. DEV_RUNNER_REQUIRE_DIGEST=0 -- the escape hatch docker-compose.dev-platform.yaml documents for exactly this case -- could therefore never work, and the local-dev shape the epic specifies (an image docker load'ed straight into the nested dind engine, no registry, so nothing to have pinned a digest from) was structurally unprovisionable. The posture is now passed into the clamp explicitly and defaults to true, so it fails closed if a caller forgets to thread it; createDockerEngine resolves it with the SAME parseRequireDigest the policy client uses, so there is one decision and two enforcement points. What no posture relaxes: a digest that IS present must still be a real content address (image_bad_digest is unconditional -- a knob about whether a digest is required never tolerates a malformed one), and the rest of the clamp (non-root, read-only rootfs, CapDrop ALL, no-new-privileges, resource bounds, the single workspace bind) is untouched. Relaxing that gate alone would have failed a fourth and fifth way, so both are fixed here too. jobs.mjs carried a branch commented "Unreachable: buildContainerCreateOptions already rejected a tag-only image" -- admitting a floating tag makes it reachable, throwing the identical error one line later. And imageDigest is a REQUIRED daemon<->middleware wire field (z.string().min(1)), so an empty one would have failed the middleware's response parse with an opaque protocol error AFTER the container was already running. Both are resolved by reading the content address back from the engine for a floating tag: the RepoDigest for the pulled repository, else the ref's own digest, else the local image Id -- which is the only content address a docker load'ed image has, and is exactly the precedence warmImages already used. That duplicated block is now the shared resolveImageDigest helper. The prod path is deliberately byte-identical: a digest-pinned ref resolves from the ref itself with no engine round-trip, and with the posture unset a floating tag is still refused before any docker resource is created. Tests: 5 clamp cases pinning the posture (fails closed when omitted, explicit true refuses, explicit false admits, relaxing it relaxes nothing else, malformed digests still rejected) and 3 engine cases covering the full provision path (prod default refuses and creates nothing, a loaded floating tag provisions and records its Id, a pinned ref ignores what the engine reports). The fake dockerode now models a load'ed image -- no RepoDigests, Id only. 392 daemon tests green. --- .../sidecars/dev-runner-daemon/src/clamp.mjs | 39 +++++++--- .../sidecars/dev-runner-daemon/src/jobs.mjs | 67 ++++++++++++----- .../dev-runner-daemon/test/clamp.test.mjs | 44 ++++++++++++ .../dev-runner-daemon/test/jobs.test.mjs | 71 ++++++++++++++++++- 4 files changed, 194 insertions(+), 27 deletions(-) diff --git a/middleware/sidecars/dev-runner-daemon/src/clamp.mjs b/middleware/sidecars/dev-runner-daemon/src/clamp.mjs index 0175bae0..5a88ae0d 100644 --- a/middleware/sidecars/dev-runner-daemon/src/clamp.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/clamp.mjs @@ -21,6 +21,17 @@ * daemon-owned keys injected, egress canonicalised. This module does NOT re-derive * policy; it enforces the CONTAINER shape and refuses anything the clamp forbids * with a `spec_rejected`-shaped error rather than silently granting or dropping it. + * + * ONE exception to "does not re-derive policy": whether a floating tag is allowed + * at all. That is the operator's `DEV_RUNNER_REQUIRE_DIGEST` posture, and the clamp + * used to hardcode it ON — so `DEV_RUNNER_REQUIRE_DIGEST=0`, the documented local + * escape hatch, was a NO-OP and every locally-built (`docker load`ed, registry-less, + * therefore un-pinnable) image was refused here after the policy client had already + * been told to allow it. Two enforcement points reading the same posture is + * defence-in-depth; one of them ignoring it is a contradiction. So the posture is + * now passed in EXPLICITLY, defaulting to ON so the clamp fails closed if a caller + * forgets to thread it. What no posture relaxes: a digest that is PRESENT must be + * a real content address. */ /** @@ -33,9 +44,8 @@ import { parseImageReference } from './policyClient.mjs'; * A valid content-address digest: `algorithm:hex`, ≥32 hex chars — a stub like * `sha256:abc` is refused. Mirrors `policyClient`'s internal `DIGEST_RE`; the * `netClassify`↔`ssrfGuard` parity test is the model for keeping such copies - * honest, but a floating-tag reject here is defence-in-depth: the policy client - * already enforces the digest when `DEV_RUNNER_REQUIRE_DIGEST` is on (default), - * so this is the last line if that knob is ever turned off. + * honest. This shape check is UNCONDITIONAL: `DEV_RUNNER_REQUIRE_DIGEST` decides + * whether a digest is required, never whether a malformed one is tolerated. */ const DIGEST_RE = /^[a-z0-9]+(?:[.+_-][a-z0-9]+)*:[0-9a-f]{32,}$/; @@ -209,6 +219,11 @@ export function jobVolumeName(jobId) { * @param {string} args.volumeName Per-job workspace volume; the ONLY bind. * @param {string} args.createdBy Principal recorded in the `createdBy` label. * @param {ClampLimits} args.limits Resolved resource bounds. + * @param {boolean} [args.requireDigest] The operator's `DEV_RUNNER_REQUIRE_DIGEST` + * posture — must the image be digest-pinned? Defaults to TRUE (prod posture), so + * omitting it fails closed. Only an explicit `false` admits a floating tag, and + * only for the local-dev shape the knob exists for: an image `docker load`ed into + * the engine, with no registry to have pinned it from. * @param {boolean} [args.dockerInJob] Opt-in DinD (spec §8): the job reaches its * per-job sidecar over TLS. Adds the DOCKER_* env and a READ-ONLY certs bind — * and nothing else. Absent/false ⇒ byte-identical to the plain clamp. @@ -217,14 +232,22 @@ export function jobVolumeName(jobId) { export function buildContainerCreateOptions(args) { const { jobId, policy, leaseExpiresAt, networkName, volumeName, createdBy, limits } = args; const dockerInJob = args.dockerInJob === true; + // Fail closed: only an explicit `false` relaxes the digest requirement. + const requireDigest = args.requireDigest !== false; - // (d) Canonicalise, THEN classify: the image must be digest-pinned. A floating - // tag is refused with a spec_rejected error, never launched. + // (d) Canonicalise, THEN classify. A floating tag is refused with a + // spec_rejected error under the prod posture, never launched. A digest that IS + // present must be a real content address whatever the posture — a malformed one + // is garbage input, not a relaxation the operator asked for. const { digest } = parseImageReference(policy.image); if (digest === undefined) { - throw new SpecRejectedError('image_not_digest_pinned', 'the job image is a floating tag, not a digest reference'); - } - if (!DIGEST_RE.test(digest)) { + if (requireDigest) { + throw new SpecRejectedError( + 'image_not_digest_pinned', + 'the job image is a floating tag, not a digest reference', + ); + } + } else if (!DIGEST_RE.test(digest)) { throw new SpecRejectedError('image_bad_digest', 'the job image digest is not a valid content address'); } diff --git a/middleware/sidecars/dev-runner-daemon/src/jobs.mjs b/middleware/sidecars/dev-runner-daemon/src/jobs.mjs index 5a59e7bd..f6dc00ca 100644 --- a/middleware/sidecars/dev-runner-daemon/src/jobs.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/jobs.mjs @@ -55,6 +55,7 @@ import { ROLE_DIND, SpecRejectedError, } from './clamp.mjs'; +import { parseRequireDigest } from './policyClient.mjs'; /** * @typedef {import('./policyClient.mjs').DerivedJobPolicy} DerivedJobPolicy @@ -802,6 +803,35 @@ async function ensureImage(docker, ref, pullPolicy = 'always') { }); } +/** + * Read an image's content address back FROM THE ENGINE — what actually got + * resolved, not what the reference claimed. Preference order: + * 1. the RepoDigest belonging to the repository in `ref` — an image pulled under + * several names carries one RepoDigest per repository and they are NOT + * interchangeable, so `[0]` can hand back a digest from another registry; + * 2. a digest carried by `ref` itself; + * 3. the local image `Id` — the ONLY content address a `docker load`ed image has, + * since it never came from a registry to be assigned a RepoDigest. + * + * Step 3 is not a weakening: it is reached only for a floating tag, which the clamp + * admits only under an explicit `DEV_RUNNER_REQUIRE_DIGEST=0`. It exists because + * `imageDigest` is a REQUIRED daemon↔middleware wire field (`z.string().min(1)`), + * so an empty one fails the protocol rather than the job — and because an audit + * chain wants the content address of what ran even when no registry vouched for it. + * + * @param {Docker} docker + * @param {string} ref + * @returns {Promise} '' only if the engine reports neither digest nor Id. + */ +async function resolveImageDigest(docker, ref) { + const info = await docker.getImage(ref).inspect(); + const repoDigests = Array.isArray(info.RepoDigests) ? info.RepoDigests : []; + const repository = repositoryOf(ref); + const match = repoDigests.find((rd) => typeof rd === 'string' && rd.startsWith(`${repository}@`)); + if (typeof match === 'string') return match.slice(match.indexOf('@') + 1); + return imageDigestOf(ref) ?? String(info.Id ?? ''); +} + /** Remove a container after a graceful SIGTERM/10s/SIGKILL stop, then VERIFY it is * gone (lesson (c): a remove call returning is not proof of removal). A 404 at any * step means already-gone (idempotent). Any surviving container or hard error is @@ -909,6 +939,10 @@ export function createDockerEngine(opts = {}) { // Resolved once at engine construction: the same policy governs the per-job image // pull, the DinD sidecar image pull, and the warm loop, so all three agree. const pullPolicy = resolvePullPolicy(env); + // The SAME posture the policy client is constructed with, parsed by the SAME + // function — the clamp is a second enforcement point for one operator decision, + // not a second (contradicting) decision. Defaults to prod posture when unset. + const requireDigest = parseRequireDigest(env.DEV_RUNNER_REQUIRE_DIGEST); return { async ping() { @@ -936,16 +970,25 @@ export function createDockerEngine(opts = {}) { volumeName, createdBy, limits, + requireDigest, dockerInJob, }); - const imageDigest = imageDigestOf(policy.image); - if (imageDigest === undefined) { - // Unreachable: buildContainerCreateOptions already rejected a tag-only image. - throw new SpecRejectedError('image_not_digest_pinned', 'the job image resolved to no digest'); - } // Resolve the image BY DIGEST (never a floating tag) so the container is // created from exactly the vetted content. await ensureImage(docker, policy.image, pullPolicy); + // A pinned ref already IS the content address — the prod path resolves it + // without touching the engine, exactly as before. Only a floating tag (which + // the clamp admitted, so DEV_RUNNER_REQUIRE_DIGEST is explicitly off) has to + // be read back from the engine, which is why this runs AFTER ensureImage: + // an image that is not present yet has no Id to report. + const imageDigest = imageDigestOf(policy.image) ?? (await resolveImageDigest(docker, policy.image)); + if (imageDigest === '') { + // Reachable: the engine knows the image but reports neither RepoDigest nor + // Id. `imageDigest` is a required wire field, so an empty one would fail + // the middleware's response parse with an opaque protocol error instead of + // this named one — and would do it AFTER the container was already running. + throw new SpecRejectedError('image_not_digest_pinned', 'the job image resolved to no digest'); + } const labels = { [JOB_ID_LABEL]: jobId, @@ -1079,19 +1122,7 @@ export function createDockerEngine(opts = {}) { const digests = []; for (const ref of refs) { await ensureImage(docker, ref, pullPolicy); - const info = await docker.getImage(ref).inspect(); - const repoDigests = Array.isArray(info.RepoDigests) ? info.RepoDigests : []; - // An image pulled under several names carries one RepoDigest per - // repository, and they are NOT interchangeable — taking [0] can hand - // back a digest that belongs to a different registry than the ref we - // were asked to warm. Match on the repository we actually pulled. - const repository = repositoryOf(ref); - const match = repoDigests.find((rd) => typeof rd === 'string' && rd.startsWith(`${repository}@`)); - const resolved = - typeof match === 'string' - ? match.slice(match.indexOf('@') + 1) - : (imageDigestOf(ref) ?? String(info.Id ?? '')); - digests.push(resolved); + digests.push(await resolveImageDigest(docker, ref)); } return digests; }, diff --git a/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs b/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs index ef7b68cc..53dface5 100644 --- a/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs @@ -161,6 +161,50 @@ describe('buildContainerCreateOptions — a forbidden image fails with spec_reje }); }); +// The clamp used to hardcode the digest requirement ON, which made the documented +// `DEV_RUNNER_REQUIRE_DIGEST=0` local escape hatch a NO-OP: the policy client was +// told to allow a floating tag and the clamp refused it anyway, one gate later. +describe('buildContainerCreateOptions — the DEV_RUNNER_REQUIRE_DIGEST posture', () => { + const FLOATING = 'omadia-dev-runner:latest'; + + it('fails CLOSED — an omitted posture refuses a floating tag (prod default)', () => { + assert.throws( + () => build({ policy: policy({ image: FLOATING }) }), + (err) => err instanceof SpecRejectedError && err.reason === 'image_not_digest_pinned', + ); + }); + + it('an explicit requireDigest: true refuses a floating tag', () => { + assert.throws( + () => build({ policy: policy({ image: FLOATING }), requireDigest: true }), + (err) => err instanceof SpecRejectedError && err.reason === 'image_not_digest_pinned', + ); + }); + + it('admits a floating tag ONLY when the operator explicitly relaxes the posture', () => { + const o = build({ policy: policy({ image: FLOATING }), requireDigest: false }); + assert.equal(o.Image, FLOATING, 'the locally-loaded image is launched verbatim'); + }); + + it('relaxing the posture relaxes NOTHING else — the full clamp still applies', () => { + const o = build({ policy: policy({ image: FLOATING }), requireDigest: false }); + const hc = o.HostConfig ?? {}; + assert.equal(o.User, '1000:1000'); + assert.equal(hc.ReadonlyRootfs, true); + assert.deepEqual(hc.CapDrop, ['ALL']); + assert.deepEqual(hc.SecurityOpt, ['no-new-privileges:true']); + assert.equal(hc.Privileged, false); + assert.deepEqual(hc.Binds, [`${jobVolumeName(JOB_ID)}:/workspace`]); + }); + + it('still refuses a MALFORMED digest with the posture relaxed — a knob about whether a digest is required never tolerates garbage', () => { + assert.throws( + () => build({ policy: policy({ image: 'ghcr.io/x/y@sha256:abc' }), requireDigest: false }), + (err) => err instanceof SpecRejectedError && err.reason === 'image_bad_digest', + ); + }); +}); + describe('resolveClampLimits — resource bounds are always present and env-tunable', () => { it('defaults to the §4 floor when nothing is set', () => { assert.deepEqual(resolveClampLimits({}), { diff --git a/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs b/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs index a7e4b4af..61a90d3a 100644 --- a/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs @@ -518,7 +518,13 @@ function makeFakeDocker(opts = {}) { if (opts.presentImages && !opts.presentImages.has(ref)) throw notFound('image'); // Real docker reports `repository@sha256:…` — never with a tag. const repo = (ref.split('@')[0] ?? ref).replace(/:[^:/]+$/, ''); - return { RepoDigests: [`${repo}@${DIGEST}`], Id: 'sha256:imgid' }; + // `noRepoDigests` models a `docker load`ed image: it never came from a + // registry, so docker reports NO RepoDigests and the Id is its only + // content address. That is the local-dev shape, not a hypothetical. + return { + RepoDigests: opts.noRepoDigests ? [] : [`${repo}@${DIGEST}`], + Id: opts.imageId ?? 'sha256:imgid', + }; }, }), }; @@ -768,6 +774,69 @@ describe('createDockerEngine — image pull policy (epic #470 local deploy #4)', }); }); +// The local-dev image is `docker load`ed straight into the nested dind engine: +// a floating tag, no registry, therefore no digest to pin and no RepoDigest to +// resolve. `DEV_RUNNER_REQUIRE_DIGEST=0` is the documented escape hatch for that +// shape; these prove the whole provision path honours it end-to-end, and that the +// prod path is untouched. +describe('createDockerEngine — DEV_RUNNER_REQUIRE_DIGEST and the locally-loaded image', () => { + const FLOATING = 'omadia-dev-runner:latest'; + /** A real `docker image inspect .Id` — the only content address a loaded image has. */ + const LOCAL_ID = 'sha256:697ba6492dcec1c6aa49dfc4a8891e81c7caad11636905a610123c01df790f46'; + const LEASE = '2026-07-10T12:00:00.000Z'; + + it('refuses a floating tag when the posture is unset — prod is unchanged, and nothing is created', async () => { + const docker = makeFakeDocker(); + const engine = createDockerEngine({ docker, env: {} }); + + await assert.rejects( + engine.createJobContainer({ jobId: JOB_ID, policy: enginePolicy(FLOATING), leaseExpiresAt: LEASE }), + (err) => err instanceof SpecRejectedError && err.reason === 'image_not_digest_pinned', + ); + assert.equal(docker.state.containers.size, 0, 'a refused spec creates no container'); + assert.equal(docker.state.networks.size, 0, 'a refused spec creates no network'); + assert.equal(docker.state.volumes.size, 0, 'a refused spec creates no volume'); + }); + + it('provisions a loaded floating tag under DEV_RUNNER_REQUIRE_DIGEST=0 and records the image Id as the content address', async () => { + const docker = makeFakeDocker({ + noRepoDigests: true, + imageId: LOCAL_ID, + presentImages: new Set([FLOATING]), + }); + const engine = createDockerEngine({ + docker, + env: { DEV_RUNNER_REQUIRE_DIGEST: '0', DEV_RUNNER_PULL_POLICY: 'if-not-present' }, + }); + + const handle = await engine.createJobContainer({ + jobId: JOB_ID, + policy: enginePolicy(FLOATING), + leaseExpiresAt: LEASE, + }); + + assert.equal(docker.state.containers.size, 1, 'the job container was created'); + assert.deepEqual(docker.state.events.pulled, [], 'a loaded image is never pulled (the proxy would 407)'); + // `imageDigest` is a REQUIRED wire field (z.string().min(1)) — an empty one + // fails the middleware's response parse AFTER the container is already up. + assert.equal(handle.imageDigest, LOCAL_ID, 'the local image Id is the recorded content address'); + assert.notEqual(handle.imageDigest, '', 'the required wire field is never empty'); + }); + + it('a digest-pinned ref still resolves from the ref itself — no engine round-trip, prod behaviour byte-identical', async () => { + const docker = makeFakeDocker({ noRepoDigests: true, imageId: LOCAL_ID }); + const engine = createDockerEngine({ docker, env: { DEV_RUNNER_REQUIRE_DIGEST: '0' } }); + + const handle = await engine.createJobContainer({ + jobId: JOB_ID, + policy: enginePolicy(), + leaseExpiresAt: LEASE, + }); + + assert.equal(handle.imageDigest, DIGEST, 'the pinned digest wins over anything the engine reports'); + }); +}); + // Real dockerode against the local engine. Skipped in the default suite; run with // DEV_RUNNER_DOCKER_IT=1 to exercise a genuine hardened container end-to-end. const RUN_DOCKER_IT = process.env.DEV_RUNNER_DOCKER_IT === '1'; From 1cc65e72bcb8ef143bf69027ba81b0072cf99593 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 07:23:19 +0200 Subject: [PATCH 04/34] fix(dev-platform): reissue OMADIA_JOB_TOKEN at the docker backend's real provision moment Gate 4 in the same rejection chain the previous two commits closed. The daemon's own ALLOWED_ENV_KEYS (policyClient.mjs) already special-cases OMADIA_JOB_TOKEN as 'policy-supplied' -- the middleware legitimately mints it -- but nothing ever put it there. deriveJobPolicy.ts's env is deliberately secret-free by design (a regression-tested invariant), and DockerBackend.provision() deliberately posts only {protocol, jobId, leaseTtlSec} to the daemon (spec S3: 'a caller never dictates policy'), so the token could never ride either of those. The docker backend's real provision moment is the daemon's OWN later fetch of GET /internal/job-policy/:jobId -- unlike Local/Fly backends, which spawn their container synchronously right after minting a token, the docker backend's container is born whenever the daemon independently decides to ask. So this reissues a fresh token right there (devJobStore.reissueRunnerToken: mint + replace runner_token_hash, same one-time-plaintext contract every backend already promises) and folds it into the response's env, next to deriveJobPolicy's own fields, verified live: a real POST /v1/jobs provision against the previously-failing job returned 201, but the spawned runner immediately exited 1 with 'missing required env OMADIA_JOB_TOKEN' -- this closes that gate. createJob's original token (used by every backend's schema, unconditionally) is simply never handed to a docker-backed container -- reissuing here doesn't reuse it, it replaces its now-provably-unused hash. New tests: devJobStore.reissueRunnerToken (mint/replace/invalidate), an E2E test proving the reissued token authenticates the phone-home surface while the original no longer does, and updated the existing job-policy env fixture (OMADIA_JOB_TOKEN is now the one deliberate exception to deriveJobPolicy's no-secret invariant, not a violation of it). Full gate green: build/lint (0 errors)/typecheck/test (4800/4804, 0 fail, 4 pg-only skip locally/run in CI; one unrelated pre-existing flaky test in profilesHealthRoutes.test.ts confirmed passing in isolation, not caused by this change). --- middleware/src/devplatform/devJobStore.ts | 31 +++++++++++++++++- middleware/src/routes/devRunnerApi.ts | 4 +++ .../src/routes/devRunnerJobPolicyRoute.ts | 22 +++++++++++-- .../test/devplatform/deriveJobPolicy.test.ts | 14 ++++++-- .../test/devplatform/devJobStore.pg.test.ts | 13 ++++++++ .../test/devplatform/devPlatform.e2e.test.ts | 32 +++++++++++++++++++ .../test/devplatform/devRunnerApi.harness.ts | 7 ++++ 7 files changed, 118 insertions(+), 5 deletions(-) diff --git a/middleware/src/devplatform/devJobStore.ts b/middleware/src/devplatform/devJobStore.ts index 7e244ca1..70501fe5 100644 --- a/middleware/src/devplatform/devJobStore.ts +++ b/middleware/src/devplatform/devJobStore.ts @@ -16,7 +16,7 @@ import type { Pool } from 'pg'; import * as artifacts from './devJobArtifactStore.js'; import type { DevJobEventBus } from './devJobEventBus.js'; import * as seams from './devJobWorkerSeams.js'; -import { hashRunnerToken, verifyRunnerToken as verifyToken } from './jobToken.js'; +import { hashRunnerToken, mintRunnerToken, verifyRunnerToken as verifyToken } from './jobToken.js'; import { asObj, iso, isoN, num, str, strN, type Row } from './pgMappers.js'; import { isLowValueEventType, type ArtifactCeilingOptions } from './retention.js'; import { @@ -736,6 +736,35 @@ export class DevJobStore { } // --- tokens -------------------------------------------------------------- + /** + * Mint a fresh runner token for a job whose container the DockerBackend path + * has not yet spawned, and persist only its hash — same one-time-plaintext + * contract `mintRunnerToken` documents for every backend, just exercised at a + * different moment. + * + * Why this exists: `createJob` already stamps a `runner_token_hash` for every + * job, but for the docker backend that first token's plaintext is discarded + * unused — `DockerBackend.provision()` deliberately posts only + * `{ protocol, jobId, leaseTtlSec }` to the daemon (spec §4/§5, review finding + * S3: "a caller never dictates policy"), so the token can never ride that + * call. The daemon fetches the job's policy itself, later, on its own clock + * (`GET /internal/job-policy/:jobId`) — THAT is this backend's actual + * provision moment, so this reissues the token right there, replacing the + * unused original hash, and the plaintext rides the policy response's `env` + * (the daemon's own `ALLOWED_ENV_KEYS` already special-cases `OMADIA_JOB_TOKEN` + * as policy-supplied — see `policyClient.mjs`). One reissue per policy fetch, + * which is one per container spawn, so the token a runner receives always + * matches the runner_token_hash a `verifyRunnerToken` call against it will see. + */ + async reissueRunnerToken(jobId: string): Promise { + const { token, hash } = mintRunnerToken(); + await this.pool.query(`UPDATE dev_jobs SET runner_token_hash = $2, updated_at = now() WHERE id = $1`, [ + jobId, + hash, + ]); + return token; + } + /** sha256 + timing-safe check of a presented runner token against the stored * hash. Unknown job ⇒ false. */ async verifyRunnerToken(jobId: string, token: string): Promise { diff --git a/middleware/src/routes/devRunnerApi.ts b/middleware/src/routes/devRunnerApi.ts index 37ff1fe6..990190d7 100644 --- a/middleware/src/routes/devRunnerApi.ts +++ b/middleware/src/routes/devRunnerApi.ts @@ -58,6 +58,10 @@ import { export interface DevRunnerJobStore { verifyRunnerToken(jobId: string, token: string): Promise; getJob(jobId: string): Promise; + /** Mints and persists a fresh runner token, invalidating the previous one. + * See `devRunnerJobPolicyRoute.ts` — the docker backend's actual provision + * moment, since `DockerBackend.provision()` itself never carries a token. */ + reissueRunnerToken(jobId: string): Promise; markRunning(jobId: string): Promise; /** Liveness without an event. `appendEvents` returns early on an empty batch, * so an agent that thinks without emitting a tool call would otherwise be diff --git a/middleware/src/routes/devRunnerJobPolicyRoute.ts b/middleware/src/routes/devRunnerJobPolicyRoute.ts index 400826e1..18c29f6e 100644 --- a/middleware/src/routes/devRunnerJobPolicyRoute.ts +++ b/middleware/src/routes/devRunnerJobPolicyRoute.ts @@ -10,6 +10,15 @@ * bearer: a runner token is rejected here, or any runner could read another * job's policy. It therefore does NOT use the runner router's job-bearer * `authMw`; it has its own daemon-token guard below. + * + * This request IS the docker backend's actual provision moment (`DockerBackend + * .provision()` itself posts only `{ protocol, jobId, leaseTtlSec }` — no env, + * spec §4/§5). So `env` carries a freshly-reissued `OMADIA_JOB_TOKEN` alongside + * `deriveJobPolicy`'s non-secret fields — the daemon's own `ALLOWED_ENV_KEYS` + * already treats it as policy-supplied (`policyClient.mjs`). Reissuing here + * (rather than reusing `createJob`'s original, unused-for-this-backend token) + * replaces `runner_token_hash`, so exactly the token this response hands out is + * the one a later `verifyRunnerToken` call will accept. */ import { createHash, timingSafeEqual } from 'node:crypto'; @@ -25,7 +34,10 @@ import type { DevJob, DevRepo } from '../devplatform/types.js'; /** The narrow slices this route needs from the runner router's deps. */ export interface JobPolicyRouteDeps { - store: { getJob(jobId: string): Promise }; + store: { + getJob(jobId: string): Promise; + reissueRunnerToken(jobId: string): Promise; + }; repos: { getRepo(id: string): Promise | null>; }; @@ -124,10 +136,16 @@ export function mountJobPolicyRoute(router: Router, deps: JobPolicyRouteDeps): v fail(res, 500, code, 'job policy could not be derived'); return; } + // The docker backend's actual provision moment (see file docstring) — mint + // the token the runner authenticates back to the middleware with here, + // never earlier: `createJob`'s original token was never handed to a + // container for this backend, so reissuing (not reusing it) keeps exactly + // one plaintext copy in existence, matching every other backend's contract. + const runnerToken = await store.reissueRunnerToken(job.id); res.json({ jobId: job.id, image: policy.image, - env: policy.env, + env: { ...policy.env, OMADIA_JOB_TOKEN: runnerToken }, egressAllowlist: policy.egressAllowlist, // W5 (spec §8): the daemon reads this to decide whether to run a DinD sidecar. dockerInJob: policy.dockerInJob, diff --git a/middleware/test/devplatform/deriveJobPolicy.test.ts b/middleware/test/devplatform/deriveJobPolicy.test.ts index dd5307a6..2cf2d5eb 100644 --- a/middleware/test/devplatform/deriveJobPolicy.test.ts +++ b/middleware/test/devplatform/deriveJobPolicy.test.ts @@ -282,10 +282,20 @@ describe('devRunnerApi — internal job-policy endpoint', () => { }; assert.equal(body.jobId, 'job-1'); assert.equal(body.image, CONFIG.image); - assert.deepEqual(body.env, { ANTHROPIC_BASE_URL: CONFIG.llmProxyBaseUrl, OMADIA_PIPELINE_MODE: 'gated' }); + // OMADIA_JOB_TOKEN is the ONE deliberate exception to "no secret in the + // derived env" (deriveJobPolicy.ts's own invariant, untouched): this route + // IS the docker backend's actual provision moment (DockerBackend.provision() + // itself never carries a token, spec S3), so it mints one here and the + // daemon's own ALLOWED_ENV_KEYS already treats it as policy-supplied. + assert.deepEqual(body.env, { + ANTHROPIC_BASE_URL: CONFIG.llmProxyBaseUrl, + OMADIA_PIPELINE_MODE: 'gated', + OMADIA_JOB_TOKEN: 'djr_reissued-1', + }); assert.ok(body.egressAllowlist.includes('artifactory.internal'), 'repo allowlist entry is present'); assert.ok(body.egressAllowlist.includes('middleware')); - assert.equal(hasCredentialKey(body.env), false, 'policy env carries no credential-like key'); + const { OMADIA_JOB_TOKEN: _theOneIntentionalToken, ...rest } = body.env; + assert.equal(hasCredentialKey(rest), false, 'no OTHER credential-like key beyond the one deliberate token'); }); it('REJECTS a per-job djr_ runner bearer (S3: no runner may read a policy)', async () => { diff --git a/middleware/test/devplatform/devJobStore.pg.test.ts b/middleware/test/devplatform/devJobStore.pg.test.ts index a558dad4..e3fd25dd 100644 --- a/middleware/test/devplatform/devJobStore.pg.test.ts +++ b/middleware/test/devplatform/devJobStore.pg.test.ts @@ -230,6 +230,19 @@ describe('devplatform/DevJobStore (pg)', { skip: !pgAvailable }, () => { assert.equal(await store.verifyRunnerToken(randomUUID(), token), false, 'unknown job → false'); }); + it('reissueRunnerToken mints a fresh token and invalidates the one it replaces', async () => { + const job = await newQueuedJob(repo.id); + const original = mintRunnerToken().token; // the token createJob's hash matched, never a docker container's + // newQueuedJob mints its own; overwrite the row to a known original for this test. + await pool.query('UPDATE dev_jobs SET runner_token_hash = $2 WHERE id = $1', [job.id, hashRunnerToken(original)]); + assert.equal(await store.verifyRunnerToken(job.id, original), true, 'precondition: original verifies'); + + const reissued = await store.reissueRunnerToken(job.id); + assert.notEqual(reissued, original, 'a genuinely new token, not the input echoed back'); + assert.equal(await store.verifyRunnerToken(job.id, reissued), true, 'the reissued token verifies'); + assert.equal(await store.verifyRunnerToken(job.id, original), false, 'the replaced token no longer verifies'); + }); + it('resolveJobByToken verifies constant-time and excludes terminal jobs (no state oracle)', async () => { const { token } = mintRunnerToken(); const hash = hashRunnerToken(token); diff --git a/middleware/test/devplatform/devPlatform.e2e.test.ts b/middleware/test/devplatform/devPlatform.e2e.test.ts index a774ba43..e801b1f6 100644 --- a/middleware/test/devplatform/devPlatform.e2e.test.ts +++ b/middleware/test/devplatform/devPlatform.e2e.test.ts @@ -523,6 +523,38 @@ describe('devplatform e2e (pg)', { skip: !pgAvailable }, () => { assert.ok(policy.egressAllowlist.includes('registry.npmjs.org'), 'operator base allowlist folded in'); }); + it('W1 job-policy endpoint reissues the runner token — DockerBackend.provision() never carries one', async () => { + // provisionedJob()'s token comes from prepareProvision(), the LocalProcess/Fly + // path's contract. DockerBackend.provision() posts only {protocol, jobId, + // leaseTtlSec} (spec S3) — that token is never handed to any container for + // this backend, so the job-policy fetch (the docker backend's actual + // provision moment) must mint its own and invalidate the unused original. + const { jobId, token: original } = await provisionedJob(); + + const res = await fetch(`${baseUrl}/api/v1/dev-runner/internal/job-policy/${jobId}`, { + headers: { Authorization: `Bearer ${E2E_DAEMON_TOKEN}` }, + }); + assert.equal(res.status, 200); + const policy = (await res.json()) as { env: Record }; + const reissued = policy.env['OMADIA_JOB_TOKEN']; + assert.ok(reissued, 'the policy env carries a fresh runner token'); + assert.notEqual(reissued, original, 'reissued, not the unused original'); + + // The reissued token authenticates the runner phone-home surface... + const withNew = await fetch(`${baseUrl}/api/v1/dev-runner/jobs/${jobId}/spec`, { + headers: { Authorization: `Bearer ${reissued}` }, + }); + assert.equal(withNew.status, 200, 'the reissued token is valid on the phone-home surface'); + + // ...and the original, never-used-for-docker token no longer is: reissuing + // replaced runner_token_hash, so a stale local/fly-shaped token cannot also + // authenticate a docker-backed job's runner. + const withOriginal = await fetch(`${baseUrl}/api/v1/dev-runner/jobs/${jobId}/spec`, { + headers: { Authorization: `Bearer ${original}` }, + }); + assert.equal(withOriginal.status, 401, 'the pre-reissue token is invalidated'); + }); + it('W1 job-policy endpoint accepts EVERY token in a rotation list, and nothing else', async () => { const { jobId } = await provisionedJob(); const ask = (token: string) => diff --git a/middleware/test/devplatform/devRunnerApi.harness.ts b/middleware/test/devplatform/devRunnerApi.harness.ts index aa679fff..302ddd9e 100644 --- a/middleware/test/devplatform/devRunnerApi.harness.ts +++ b/middleware/test/devplatform/devRunnerApi.harness.ts @@ -71,6 +71,13 @@ export class FakeStore implements DevRunnerJobStore { async getJob(jobId: string): Promise { return this.jobs.get(jobId) ?? null; } + reissueCalls: string[] = []; + async reissueRunnerToken(jobId: string): Promise { + this.reissueCalls.push(jobId); + const fresh = `djr_reissued-${String(this.reissueCalls.length)}`; + this.tokens.set(jobId, fresh); + return fresh; + } readonly touchCalls: string[] = []; readonly artifactOwner = new Map(); From 17d2a8137dac3dcffebbe1b2b295eb852158dc65 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 07:31:44 +0200 Subject: [PATCH 05/34] fix(dev-platform): route phone-home through the proxy, not around it Gate 5. After the runner-image/digest/token gates were fixed, a real provision succeeded (HTTP 201, container created) but the runner immediately exited: "fatal: fetch failed". Live-reproduced the exact cause by running the runner image on the jobs own per-job network: "getaddrinfo ENOTFOUND middleware". DEV_RUNNER_NO_PROXY: middleware,localhost,127.0.0.1 told the runner to bypass the proxy and connect to the middleware directly, with the comment stating that as fact ("The runner reaches the middleware directly, not through the proxy"). It cannot: job containers are created by dind on their own per-job network, which has no route to dev-control -- the network "middleware" actually lives on. Only dev-egress-proxy is dual-homed onto both dev-egress (job-reachable) and dev-control (middleware-reachable). Verified live: dev-egress-proxy resolves "middleware" fine (dns.lookup -> 172.28.4.4); the runners own network does not. The proxy already has no reason to be bypassed here: egressPolicy.mjs allowInternal branch matches any request whose host+port equals OMADIA_INTERNAL_API_URL (this proxys own env, http://middleware:8080) and allows it regardless of path -- not scoped to the LLM-proxy route alone, as the surrounding comments imply. Removed "middleware" from the daemons DEV_RUNNER_NO_PROXY (kept localhost/127.0.0.1, which legitimately never leave the container). New composeTopology test asserts "middleware" is never in this list. Full gate green: lint 0 errors; ran the full devplatform suite + composeTopology in isolation (24/24, 7/7 pass) -- this change is compose-config + one test file, no source logic touched. --- docker-compose.dev-platform.yaml | 17 +++++++++++++++-- .../test/devplatform/composeTopology.test.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index a35b8213..6d09da30 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -133,8 +133,21 @@ services: # The daemon refuses to boot on a half-configuration. DEV_RUNNER_EGRESS_PROXY_URL: http://172.28.5.3:3128 DEV_RUNNER_EGRESS_PROXY_CONTROL_URL: http://172.28.4.3:3129 - # The runner reaches the middleware directly, not through the proxy. - DEV_RUNNER_NO_PROXY: middleware,localhost,127.0.0.1 + # The runner does NOT reach the middleware directly -- it CANNOT: job + # containers are created by dind on their own per-job network, which has + # no route to dev-control (where `middleware` actually lives). Only the + # proxy is dual-homed onto both dev-egress (job-reachable) and dev-control + # (middleware-reachable), so phone-home traffic must go THROUGH it, not + # around it. The proxy's own egress policy (egressPolicy.mjs) already + # special-cases exactly this: a request whose host+port equals + # OMADIA_INTERNAL_API_URL (this same dev-egress-proxy's own env, set to + # http://middleware:8080) is allowed regardless of path -- so listing + # "middleware" here to bypass the proxy doesn't skip an unnecessary hop, + # it routes phone-home into a dead end: `getaddrinfo ENOTFOUND middleware` + # from inside the job's network, which is where every real job died after + # the runner-image/digest/token gates were fixed. Only localhost/127.0.0.1 + # (traffic that never leaves the container) belong on this bypass list. + DEV_RUNNER_NO_PROXY: localhost,127.0.0.1 # A lease says "still working"; it can never say "run forever". DEV_RUNNER_MAX_JOB_LIFETIME_MS: ${DEV_RUNNER_MAX_JOB_LIFETIME_MS:-21600000} diff --git a/middleware/test/devplatform/composeTopology.test.ts b/middleware/test/devplatform/composeTopology.test.ts index 4c818231..0a1d2c63 100644 --- a/middleware/test/devplatform/composeTopology.test.ts +++ b/middleware/test/devplatform/composeTopology.test.ts @@ -310,4 +310,19 @@ describe('dev-platform compose overlay — the middleware can actually derive a assert.ok(middlewareImage, 'middleware image must be set to compare'); assert.ok(daemonImages?.includes(middlewareImage as string), 'daemon and middleware must name the same image'); }); + + it('never tells the runner to bypass the proxy for the middleware', () => { + // Job containers are created by dind on their own per-job network, which has + // NO route to dev-control -- the network `middleware` actually lives on. + // Only the proxy is dual-homed onto dev-egress (job-reachable) and + // dev-control (middleware-reachable). Bypassing the proxy for "middleware" + // routes phone-home into `getaddrinfo ENOTFOUND middleware` from inside the + // job's network -- exactly where every real job died after the + // runner-image/digest/token gates were fixed. The proxy's own egress policy + // already allows this host+port through (egressPolicy.mjs's `allowInternal` + // match against OMADIA_INTERNAL_API_URL), so there is no reason to bypass it. + const noProxy = overlay.services['dev-runner-daemon']?.environment?.['DEV_RUNNER_NO_PROXY'] ?? ''; + const entries = noProxy.split(',').map((s) => s.trim()); + assert.ok(!entries.includes('middleware'), 'middleware must route THROUGH the proxy, never around it'); + }); }); From b5aa8bf890288d55d745218b66d976de44b43305 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 07:39:01 +0200 Subject: [PATCH 06/34] fix(dev-platform): make the runner shim's fetch actually honour HTTP_PROXY Gate 6. After fixing DEV_RUNNER_NO_PROXY (previous commit), a fresh provision still failed with "fatal: fetch failed" -- routing "middleware" through the proxy in the env vars did nothing, because the shim never uses the proxy in the first place. Root cause: unlike curl and git, Node's global fetch (undici) does NOT read HTTP_PROXY/HTTPS_PROXY/NO_PROXY by default -- that behaviour is opt-in, gated behind NODE_USE_ENV_PROXY (undici's EnvHttpProxyAgent). The comment this injectDaemonOwnedEnv function carried asserted the opposite ("Standard http clients (curl, git, undici, python-requests) derive that header from the proxy URL's userinfo"), which is the misconception that let this ship: the shim's homeClient.ts is deliberately "Node's global fetch only -- no dependency", so every phone-home call (spec fetch, events, diff upload, result) silently ignored HTTP_PROXY and tried the middleware direct. Verified empirically inside the deployed middleware container, isolating the exact mechanism before touching code: - HTTP_PROXY set, NODE_USE_ENV_PROXY unset: a fetch to an unreachable host -> getaddrinfo ENOTFOUND (proxy ignored entirely) - HTTP_PROXY set, NODE_USE_ENV_PROXY=1: the same fetch call -> ECONNREFUSED (proxy honoured, as expected) - process.env.NODE_USE_ENV_PROXY set IN-PROCESS after Node starts: no effect -- undici reads it once at dispatcher construction, so it MUST be a real container env var, not something the shim could set for itself. Fix: inject NODE_USE_ENV_PROXY=1 alongside HTTP_PROXY/HTTPS_PROXY, only when a proxy is actually configured -- same conditional the proxy vars already use. No new dependency (the flag activates undici's own bundled EnvHttpProxyAgent, already inside Node 22); the "no dependency" shim design is unchanged. New test: NODE_USE_ENV_PROXY is present exactly when a proxy is configured, absent otherwise (extended the existing symmetric proxy-vars test). Daemon suite green: 393/393 (this worktree previously had no node_modules installed for the sidecar -- a separate package from middleware/ -- ran npm ci there first; the 5 apparent failures before that were ERR_MODULE_NOT_FOUND for dockerode, not a real regression). --- .../dev-runner-daemon/src/policyClient.mjs | 14 +++++++++++- .../test/policyClient.test.mjs | 22 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs b/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs index 9bae8b42..3fc284a7 100644 --- a/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs @@ -478,7 +478,7 @@ function injectDaemonOwnedEnv(policyEnv, jobId, owned) { if (owned.egressProxyUrl) { // The proxy is default-deny and authenticates every request as // `Proxy-Authorization: Basic base64(jobId:proxyToken)`. Standard http clients - // (curl, git, undici, python-requests) derive that header from the proxy URL's + // (curl, git, python-requests) derive that header from the proxy URL's // userinfo, so the credential travels in the injected value — NOT in the // operator-supplied DEV_RUNNER_EGRESS_PROXY_URL, which is still refused if it // carries userinfo. The token names exactly one job's allowlist, and it is the @@ -490,6 +490,18 @@ function injectDaemonOwnedEnv(policyEnv, jobId, owned) { env.HTTPS_PROXY = withCreds; env.http_proxy = withCreds; env.https_proxy = withCreds; + // UNLIKE curl/git, Node's own global `fetch` (undici) does NOT read + // HTTP_PROXY/HTTPS_PROXY/NO_PROXY by default — that's opt-in, gated behind + // this exact flag (undici's EnvHttpProxyAgent). The shim's homeClient.ts is + // deliberately "Node's global fetch only — no dependency", so without this, + // every phone-home call (spec fetch, events, diff upload, result) ignores + // the proxy entirely and tries the middleware direct — which the runner's + // own per-job network has no route to (`getaddrinfo ENOTFOUND middleware`). + // MUST be a real process env var: setting it in-process after Node starts + // does nothing, since undici reads it once at dispatcher construction. + // Read-only for `MUST be set`; the value itself carries no secret and has + // no reason to ever be anything but '1' when a proxy is configured at all. + env.NODE_USE_ENV_PROXY = '1'; } if (owned.noProxy) { env.NO_PROXY = owned.noProxy; diff --git a/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs b/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs index c77a7a1e..0428d058 100644 --- a/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs @@ -313,6 +313,18 @@ describe('policyClient — daemon-owned env keys are injected, never accepted', assert.equal(policy.env.no_proxy, 'middleware,localhost'); }); + it('injects NODE_USE_ENV_PROXY alongside a configured proxy — otherwise Node fetch ignores HTTP_PROXY entirely', async () => { + // Unlike curl/git, Node's global fetch (undici) does NOT read HTTP_PROXY by + // default; the shim's homeClient.ts uses fetch with no dependency, so + // without this flag every phone-home call silently bypasses the proxy and + // tries the middleware direct — unreachable from the job's own network. + const client = clientWith(policyBody(), { + clientOpts: { egressProxyUrl: 'http://egress-proxy:3128' }, + }); + const policy = await client.fetchJobPolicy(JOB_ID); + assert.equal(policy.env.NODE_USE_ENV_PROXY, '1'); + }); + it('splices the per-job proxy credentials into the injected proxy URL', async () => { // The proxy is default-deny and authenticates as Basic base64(jobId:proxyToken). // Standard http clients derive that header from the URL userinfo, so the @@ -339,7 +351,15 @@ describe('policyClient — daemon-owned env keys are injected, never accepted', it('injects NO proxy vars when the daemon has none configured', async () => { const client = clientWith(policyBody()); const policy = await client.fetchJobPolicy(JOB_ID); - for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy']) { + for (const k of [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'http_proxy', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', + 'NODE_USE_ENV_PROXY', + ]) { assert.equal(policy.env[k], undefined, `${k} must be absent without a configured proxy`); } }); From 8296836058caeaaaaab26e6355a70c77db54ed00 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 07:46:08 +0200 Subject: [PATCH 07/34] fix(dev-platform): forward the proxy vars into git's hermetic subprocess env too Gate 7. After gate 6 (Node's fetch honouring HTTP_PROXY), a fresh provision got past phone-home entirely -- it authenticated, ran, and reached the actual clone step -- then failed: "git clone failed (128): fatal: unable to access 'https://github.com/byte5ai/omadia.git/': Could not resolve host: github.com". Same root cause as gate 6, different subprocess. runGit() in gitOps.ts builds a deliberately hermetic env for the git child process -- an explicit allowlist (PATH, HOME, GIT_TERMINAL_PROMPT, GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL, LANG), NOT the parent env, so no ambient secret rides along and no global credential helper interferes. Good security design -- but it silently dropped HTTP_PROXY/HTTPS_PROXY/NO_PROXY along with everything else. The job's isolated network has no route to github.com except through the daemon's egress proxy (the exact same constraint gate 5/6 already established for the middleware itself), so without these, git falls back to a direct DNS lookup that always fails. Fix: forward HTTP_PROXY/HTTPS_PROXY/NO_PROXY (both cases -- curl, git's HTTPS transport, historically trusts lowercase by default; the daemon injects both, see policyClient.mjs) from the shim's own env onto this allowlist, conditionally (only when actually set, so no proxy configured means no proxy-shaped keys at all -- matches the existing symmetric pattern in policyClient.mjs's own proxy injection). These are deployment topology, not a secret -- same category PATH/HOME already sit in on this exact allowlist. New tests: proxy vars ARE forwarded when set (all four spellings), and are completely ABSENT (not empty-string) when no proxy is configured -- extends the existing "does not forward arbitrary parent env" hermetic-environment test, doesn't touch it. Verified live end-to-end against the real deployed stack, using the actual job/worker lifecycle (not a direct daemon call): reset a real job to queued, watched the middleware's own claim loop provision it, and the runner reached git clone before this fix landed -- confirming gates 1-6 are genuinely closed, not just individually reproduced in isolation. Shim suite green: 48/48 (dev-runner-shim's own tests, node_modules already present in this worktree). Full middleware gate green: build/lint (0 errors)/typecheck clean (this package is part of the middleware workspace build). --- .../packages/dev-runner-shim/src/gitOps.ts | 12 +++++++ .../dev-runner-shim/test/gitOps.test.ts | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/middleware/packages/dev-runner-shim/src/gitOps.ts b/middleware/packages/dev-runner-shim/src/gitOps.ts index 354a58ce..7b3f4b10 100644 --- a/middleware/packages/dev-runner-shim/src/gitOps.ts +++ b/middleware/packages/dev-runner-shim/src/gitOps.ts @@ -66,6 +66,18 @@ export async function runGit(opts: GitOptions, args: string[], cwd: string): Pro GIT_CONFIG_GLOBAL: '/dev/null', LANG: 'C', }; + // Deployment topology, not a secret -- same category as PATH/HOME above, so + // it belongs on this explicit allowlist too. The job's network has no route + // to a forge host (github.com) except through the daemon's egress proxy; + // without these, git falls back to a direct DNS lookup that always fails + // ("Could not resolve host"). Both spellings: curl (git's HTTPS transport) + // historically only trusts lowercase http_proxy/https_proxy/no_proxy by + // default, but the daemon injects both cases (see policyClient.mjs), so + // forwarding both here keeps this in step with whichever it actually reads. + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) { + const value = process.env[key]; + if (value) env[key] = value; + } return new Promise((resolve, reject) => { const child = spawn(gitBin, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); const out: Buffer[] = []; diff --git a/middleware/packages/dev-runner-shim/test/gitOps.test.ts b/middleware/packages/dev-runner-shim/test/gitOps.test.ts index 919f8e7b..67e3aea0 100644 --- a/middleware/packages/dev-runner-shim/test/gitOps.test.ts +++ b/middleware/packages/dev-runner-shim/test/gitOps.test.ts @@ -304,4 +304,37 @@ describe('runGit — hermetic environment', () => { delete process.env['SHIM_TEST_LEAK_CANARY']; } }); + + it('DOES forward the proxy vars (deployment topology, not a secret) — otherwise a forge host is unreachable', async () => { + // The job's network has no route to github.com except through the daemon's + // egress proxy (same reason node's own fetch needs NODE_USE_ENV_PROXY). + // Without this, git falls back to a direct DNS lookup that always fails. + process.env['HTTP_PROXY'] = 'http://proxy.example:3128/'; + process.env['HTTPS_PROXY'] = 'http://proxy.example:3128/'; + process.env['NO_PROXY'] = 'localhost,127.0.0.1'; + process.env['http_proxy'] = 'http://proxy.example:3128/'; + try { + await cloneAtBaseSha(baseOpts(), { cloneUrl: CLONE_URL, defaultBranch: 'main', baseSha: '' }); + const clone = readLog().find((r) => r.sub === 'clone'); + assert.ok(clone, 'clone ran'); + assert.equal(clone.env['HTTP_PROXY'], 'http://proxy.example:3128/'); + assert.equal(clone.env['HTTPS_PROXY'], 'http://proxy.example:3128/'); + assert.equal(clone.env['NO_PROXY'], 'localhost,127.0.0.1'); + assert.equal(clone.env['http_proxy'], 'http://proxy.example:3128/'); + } finally { + delete process.env['HTTP_PROXY']; + delete process.env['HTTPS_PROXY']; + delete process.env['NO_PROXY']; + delete process.env['http_proxy']; + } + }); + + it('omits proxy keys entirely when none are configured (no empty-string env pollution)', async () => { + await cloneAtBaseSha(baseOpts(), { cloneUrl: CLONE_URL, defaultBranch: 'main', baseSha: '' }); + const clone = readLog().find((r) => r.sub === 'clone'); + assert.ok(clone, 'clone ran'); + for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) { + assert.equal(clone.env[k], undefined, `${k} must be absent, not an empty string`); + } + }); }); From 23b446ac89839db4f18425208cdd2c7910e9c075 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 09:02:17 +0200 Subject: [PATCH 08/34] fix(dev-platform): give the egress proxy an actual route to the internet Gate 8 (partial -- the CONNECT tunnel itself is still under investigation, see follow-up). After gate 7 (git honouring HTTP_PROXY), a fresh provision reached git clone and failed differently: "Could not resolve host: github.com" -- from INSIDE the egress proxy container itself, not the job. Root cause, verified live: dev-egress-proxy's `networks:` list names only dev-egress and dev-control, and BOTH are `internal: true` in this overlay -- correctly, neither may reach outside. But dev-egress-proxy's entire purpose is being the one path a job container has to the real internet, and it had no route out either. `docker exec omadia-dev-dev-egress-proxy-1 node -e "require('node:dns').lookup('github.com',...)"` returned EAI_AGAIN before this fix, a real IP after. Fix: a THIRD, dedicated network (dev-egress-external, plain bridge, no subnet pin -- the proxy is its only member) gives the proxy real internet route without sharing `omadia` with middleware/web-ui, which would make it reachable from (and able to reach) the app services laterally -- exactly what the separate egress plane exists to prevent. Also added logger.warn diagnostics to three previously-silent catch/error paths in proxy.mjs (dataServer's clientError handler, the catch around handleConnect, and the upstream socket's error handler) -- these were completely silent (destroySocket with no logging) which made an in-flight CONNECT-tunnel failure impossible to diagnose from container logs alone. Kept regardless of the CONNECT-tunnel root cause (still being isolated, see the next commit on this branch): better failure visibility here is worth keeping on its own merits, and cost nothing to verify -- the daemon suite is still 393/393 green with these in place. New composeTopology tests: the proxy joins at least one non-internal network, and that network is not `omadia`. Full gate green: middleware build/lint (0 errors)/typecheck/test (4803/4807, 0 fail, 4 pg-only skip locally/run in CI); daemon suite 393/393; composeTopology 26/26 in isolation. --- docker-compose.dev-platform.yaml | 17 +++++++++++ .../sidecars/dev-runner-daemon/src/proxy.mjs | 13 +++++++-- .../test/devplatform/composeTopology.test.ts | 28 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index 6d09da30..06e727e7 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -235,6 +235,17 @@ services: # reach the DATA plane at 172.28.5.3 (dev-egress). Two planes, two networks, # and the daemon never joins the one the jobs are on. ipv4_address: 172.28.4.3 + # The proxy's OWN route to the real internet. dev-egress and dev-control + # are BOTH `internal: true` -- correctly, they must never reach outside -- + # but that left the proxy itself with no path out either, so every job's + # egress request failed DNS resolution before the allowlist/CONNECT logic + # ever ran (`getaddrinfo EAI_AGAIN github.com` from inside this very + # container). The proxy deliberately does NOT join `omadia` for this -- + # sharing the app's own network would make it reachable from (and able to + # reach) middleware/web-ui laterally, which the whole point of a separate + # egress plane exists to avoid -- so this is a THIRD, dedicated network + # whose only member is the proxy. + dev-egress-external: {} # No `ports:` — the proxy is not reachable from the host. volumes: @@ -270,3 +281,9 @@ networks: ipam: config: - subnet: 172.28.5.0/24 + # The proxy's real path to the internet — deliberately NOT internal, and + # deliberately NOT `omadia` (see the service comment above). No pinned + # subnet/address: dev-egress-proxy is this network's only member, and + # nothing else ever needs to address it here. + dev-egress-external: + driver: bridge diff --git a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs index 787039fb..0bc5de16 100644 --- a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs @@ -153,12 +153,16 @@ export function createProxy(deps) { }); }); dataServer.on('connect', (req, socket, head) => { - void handleConnect(req, socket, head).catch(() => { + void handleConnect(req, socket, head).catch((err) => { + logger.warn?.(`[dev-egress-proxy] handleConnect threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); destroySocket(socket); }); }); // A client that errors before/after the CONNECT upgrade must not throw globally. - dataServer.on('clientError', (_err, socket) => destroySocket(socket)); + dataServer.on('clientError', (err, socket) => { + logger.warn?.(`[dev-egress-proxy] clientError: ${err instanceof Error ? err.message : String(err)}`); + destroySocket(socket); + }); /** * CONNECT tunnel: authorise → decide → (only now) resolve → classify → pin → @@ -287,7 +291,10 @@ export function createProxy(deps) { armIdle(); }); - upstream.on('error', teardown); + upstream.on('error', (err) => { + logger.warn?.(`[dev-egress-proxy] upstream connection to ${host}:${port} (${pinnedIp}) failed: ${err instanceof Error ? err.message : String(err)}`); + teardown(); + }); upstream.on('close', teardown); clientSocket.on('error', teardown); clientSocket.on('close', teardown); diff --git a/middleware/test/devplatform/composeTopology.test.ts b/middleware/test/devplatform/composeTopology.test.ts index 0a1d2c63..2634c572 100644 --- a/middleware/test/devplatform/composeTopology.test.ts +++ b/middleware/test/devplatform/composeTopology.test.ts @@ -326,3 +326,31 @@ describe('dev-platform compose overlay — the middleware can actually derive a assert.ok(!entries.includes('middleware'), 'middleware must route THROUGH the proxy, never around it'); }); }); + +describe('dev-platform compose overlay — the egress proxy can actually reach the internet', () => { + // Every job-egress network (dev-control, dev-engine, dev-egress) is + // deliberately `internal: true` -- correctly, none of them may reach + // outside. But dev-egress-proxy's ONLY job is being the one path a job + // container has to the real internet, and its `networks:` list used to name + // ONLY those internal ones -- so the proxy itself had no route out either, + // and every job's egress (git clone, npm install, ...) failed DNS resolution + // before the allowlist/CONNECT logic ever ran (verified live: + // `getaddrinfo EAI_AGAIN github.com` from inside the proxy container). + it('joins at least one network that is not internal: true', () => { + const proxyNetNames = networkNames(overlay.services['dev-egress-proxy']); + const external = proxyNetNames.filter((n) => overlay.networks?.[n]?.internal !== true); + assert.ok( + external.length > 0, + `dev-egress-proxy's networks (${proxyNetNames.join(', ')}) are ALL internal -- it has no path to the real internet`, + ); + }); + + it('does not reach that network by sharing `omadia` with the app services', () => { + // Sharing the app's own bridge would make the proxy reachable from (and + // able to reach) middleware/web-ui laterally -- exactly what a separate + // egress plane exists to avoid. Its external route must be a network + // dedicated to it alone. + const proxyNetNames = networkNames(overlay.services['dev-egress-proxy']); + assert.ok(!proxyNetNames.includes('omadia'), 'the proxy must not join the app network for its egress route'); + }); +}); From 3994d20eb7abb25ec20e942e81fc7951e2a7f81d Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 09:20:57 +0200 Subject: [PATCH 09/34] fix(dev-platform): announce the close on a 407 so git can answer the proxy's challenge Gate 8, the actual CONNECT bug the previous commit deferred. After gate 7 (git honouring HTTP_PROXY) and the proxy getting a real internet route, every fresh provision still died at the clone: git clone failed (128): fatal: unable to access 'https://github.com/byte5ai/omadia.git/': Proxy CONNECT aborted Two earlier rounds could not find it because the evidence pointed the wrong way: `handleConnect`, `clientError` and the upstream-error handler were all instrumented and NONE of them ever logged, while a job's plain-HTTP phone-home through the same 172.28.5.3:3128 path worked perfectly. That looked like "the CONNECT never reaches the proxy". It does. Root cause, reproduced race-free and read straight out of git's own curl trace (GIT_CURL_VERBOSE=1, real runner image, on a standalone bridge network inside dind, with a registration this session PUT on the proxy's control plane): Connected to 172.28.5.3 port 3128 Establish HTTP proxy tunnel to github.com:443 <= HTTP/1.1 407 Proxy Authentication Required <= Proxy-Authenticate: Basic realm="omadia-dev-egress" == Proxy auth using Basic with user '...' => Send header: Proxy-Authorization: Basic <-- 168 bytes == Proxy CONNECT aborted Proxy auth over CONNECT is a CHALLENGE-RESPONSE ON ONE CONNECTION. libcurl -- so `git`, whose `http.proxyAuthMethod` defaults to `anyauth` -- sends an unauthenticated CONNECT first, reads the 407 + `Proxy-Authenticate`, then re-sends the CONNECT with `Proxy-Authorization` on that same socket. The proxy answered the 407 correctly and then `clientSocket.end()`d, FIN'ing the socket without ever saying it was closing: no `Connection: close`, no `Content-Length`. The client had no way to know, so it wrote its authenticated retry into a connection we had already closed, read EOF, and reported "Proxy CONNECT aborted". Basic proxy auth was therefore impossible for any libcurl client, and it always failed on the FIRST request of every job. Why the instrumentation lied: the 407 is emitted from the deny branch that predates all three log points, so nothing warned; and the reason the phone-home path worked is that node's `EnvHttpProxyAgent` (gate 6) sends `Proxy-Authorization` PREEMPTIVELY and so never triggers a 407 at all. The same is true of a hand-rolled CONNECT probe -- which is exactly why a manual probe against this proxy succeeds while git fails. The asymmetry was the bug's fingerprint, not evidence against the proxy. Fix, in `writeConnectStatus`: every non-2xx CONNECT reply now carries `Connection: close` (plus the legacy `Proxy-Connection: close` spelling libcurl also honours) and `Content-Length: 0`. Put in the writer rather than at the 407 call site because EVERY non-2xx reply on this path is terminal -- node detaches its HTTP parser from the socket at the `connect` event and hands it to us raw, so there is no second request to serve on it -- and no call site should be able to forget. A 2xx is deliberately untouched: that reply IS the tunnel. Not taken: setting `http.proxyAuthMethod=basic` in the shim's git invocation so curl sends credentials preemptively. It would work, but it fixes one client of a proxy that is broken for all of them; the defect is the unannounced close. Nothing about the allow/deny decision, the allowlist, the rebinding classifier or the pinning changes -- this only adds response headers on paths that were already refusing and already closing. The isolation invariants are untouched. Live verification (local docker-compose overlay, omadia-dev): - the reproduction above, re-run after the rebuild: 407 -> a NEW connection -> `HTTP/1.1 200 Connection Established` -> `git clone exit code: 0`, real tree at 4a838b7, 27 entries. Before the fix, byte-identical command, 3/3 failures. - the REAL pipeline, driven by the middleware's own claim worker (job 4ab8b724, provisions 11/12/13): `phase implement start` -> `status agent_started` -> `status agent_done`, with `dev_jobs.error` NULL. `phaseLoop` step 4 clones at the pinned base sha and the agent runs with cwd = that clone, so `agent_started` is only reachable through a clone that succeeded. Every provision before this one died at the clone. - the jobs now stop at `agent_done` with 0 tokens / $0 -- the coding CLI has no Anthropic credential in Vault yet. Known, separate, out of scope here. Two regression tests over REAL sockets in `proxy.test.mjs`: the 407 announces the close, actually closes, and the authenticated retry on a fresh connection establishes a tunnel that carries bytes (and the 200 does NOT carry the close); plus the same announcement on the non-auth 403 paths, since they share the trap. Both fail against the pre-fix writer (verified by reverting it) and pass after. Sidecar suite green: 395/395 (393 before, +2 new). eslint on both changed files reports exactly the same 35 `no-undef` problems as the pristine HEAD versions -- a pre-existing gap where the middleware config supplies no node globals for `.mjs` sidecars, unrelated to this change; `tsc --noEmit` likewise reports only the pre-existing `src/reaper.mjs` errors in a file this commit never touches. --- .../sidecars/dev-runner-daemon/src/proxy.mjs | 28 +++++++- .../dev-runner-daemon/test/proxy.test.mjs | 72 ++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs index 0bc5de16..83602268 100644 --- a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs @@ -467,12 +467,38 @@ export function createProxy(deps) { } /** Write a raw HTTP status line to a CONNECT client socket (no res object here). + * + * ANNOUNCE THE CLOSE. Every non-2xx reply on this path is terminal — the caller + * `end()`s the socket the moment this returns, because node has already detached + * its HTTP parser from the socket at the `connect` event and hands it to us raw, + * so there is no second request to serve on it. + * + * That matters most for the 407. Proxy auth over CONNECT is a CHALLENGE-RESPONSE + * ON ONE CONNECTION: libcurl (and therefore `git`, whose default + * `http.proxyAuthMethod` is `anyauth`) sends an unauthenticated CONNECT first, + * reads the 407 + `Proxy-Authenticate`, then re-sends the CONNECT with + * `Proxy-Authorization` ON THAT SAME SOCKET. A 407 that FINs the socket without + * saying so leaves the client writing its authenticated retry into a connection + * we already closed; it reads EOF and reports `Proxy CONNECT aborted`, so auth + * can never succeed and every job's clone fails. `Connection: close` — plus the + * legacy `Proxy-Connection` spelling libcurl also honours — tells it to reconnect + * for the retry instead. `Content-Length: 0` keeps the (absent) body framed + * rather than delimited by the close. + * + * Clients that send credentials preemptively (node's `EnvHttpProxyAgent`, a + * hand-rolled CONNECT) never reach the 407 at all, which is exactly why this hid + * behind a working phone-home path. + * * @param {import('node:stream').Duplex} socket @param {number} code * @param {string} message @param {Record} [headers] */ function writeConnectStatus(socket, code, message, headers = {}) { if (socket.destroyed || socket.writableEnded) return; + const isTerminal = code < 200 || code >= 300; + const effective = isTerminal + ? { ...headers, 'Content-Length': '0', Connection: 'close', 'Proxy-Connection': 'close' } + : headers; let head = `HTTP/1.1 ${code} ${message}\r\n`; - for (const [k, v] of Object.entries(headers)) head += `${k}: ${v}\r\n`; + for (const [k, v] of Object.entries(effective)) head += `${k}: ${v}\r\n`; head += '\r\n'; try { socket.write(head); diff --git a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs index fa067eed..6291dac5 100644 --- a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs @@ -111,7 +111,9 @@ function basicAuth(jobId = JOB_ID, token = PROXY_TOKEN) { /** * Send a raw CONNECT through the proxy and resolve once the status line is parsed. - * @returns {Promise<{ statusCode: number, socket: import('node:net').Socket, buffered: Buffer }>} + * `headers` is the lowercased response header block — a CONNECT reply has no + * `res` object, so the raw text is the only place its framing is observable. + * @returns {Promise<{ statusCode: number, socket: import('node:net').Socket, buffered: Buffer, headers: string }>} */ function sendConnect(dataPort, authority, authHeader) { return new Promise((resolve, reject) => { @@ -124,7 +126,7 @@ function sendConnect(dataPort, authority, authHeader) { socket.removeListener('data', onData); const headerText = buf.subarray(0, idx).toString('utf8'); const statusCode = Number(/^HTTP\/1\.1 (\d+)/.exec(headerText)?.[1] ?? 0); - resolve({ statusCode, socket, buffered: buf.subarray(idx + 4) }); + resolve({ statusCode, socket, buffered: buf.subarray(idx + 4), headers: headerText.toLowerCase() }); }; socket.on('data', onData); socket.on('error', reject); @@ -225,6 +227,72 @@ describe('egress proxy — CONNECT default-deny + DNS-exfil defence', () => { await p.close(); } }); + + // The 407 is a CHALLENGE, and a challenge the client cannot answer is a wall. + // libcurl (so `git`, whose `http.proxyAuthMethod` defaults to `anyauth`) sends an + // unauthenticated CONNECT, reads the 407, then re-sends it WITH credentials. This + // proxy cannot serve that retry on the same socket — node detaches its HTTP parser + // at the `connect` event — so it closes, and it MUST say so. When it did not, the + // client wrote its authenticated retry into an already-FIN'd socket, read EOF, and + // every real job's `git clone` died with "Proxy CONNECT aborted". + it('announces the close on a 407 so a challenged client can retry authenticated', async () => { + // A real tunnel target, so the retry is verified all the way to 200 rather than + // stopping at the decision — same internal-destination shape the end-to-end + // tunnel test uses (loopback is legitimately internal there). + const upstream = await startTcpEcho(); + const p = await startProxy({ + internalHost: 'mw.internal', + internalPort: upstream.port, + jobs: [{ jobId: JOB_ID, allowlist: [], proxyToken: PROXY_TOKEN }], + resolveMap: { 'mw.internal': [{ address: '127.0.0.1', family: 4 }] }, + }); + const authority = `mw.internal:${upstream.port}`; + try { + const challenge = await sendConnect(p.dataPort, authority, null); + assert.equal(challenge.statusCode, 407); + assert.match(challenge.headers, /proxy-authenticate: basic/); + // Both spellings: `Connection` is the standard one, `Proxy-Connection` the + // legacy hop-by-hop one libcurl also honours. + assert.match(challenge.headers, /\r\nconnection: close/); + assert.match(challenge.headers, /\r\nproxy-connection: close/); + assert.match(challenge.headers, /\r\ncontent-length: 0/); + // The announcement must match the behaviour: the proxy really does close. + await new Promise((resolve) => challenge.socket.once('end', resolve)); + challenge.socket.destroy(); + + // The retry libcurl then makes on a FRESH connection must reach the upstream. + const retry = await sendConnect(p.dataPort, authority, basicAuth()); + assert.equal(retry.statusCode, 200, 'the authenticated retry establishes the tunnel'); + // A 2xx must NOT carry the close — it is the tunnel, not a terminal reply. + assert.doesNotMatch(retry.headers, /connection: close/); + retry.socket.write('ping-after-challenge'); + const echoed = await nextChunk(retry.socket); + assert.equal(echoed.toString('utf8'), 'ping-after-challenge'); + retry.socket.destroy(); + } finally { + await p.close(); + await upstream.close(); + } + }); + + // Same trap, non-auth path: every non-2xx CONNECT reply is terminal here, so each + // one has to announce it rather than only the 407 that happened to be reported. + it('announces the close on every non-2xx CONNECT reply, not just the 407', async () => { + const p = await startProxy({ jobs: [{ jobId: JOB_ID, allowlist: ['good.test'], proxyToken: PROXY_TOKEN }] }); + try { + const denied = await sendConnect(p.dataPort, 'notallowed.test:443', basicAuth()); + assert.equal(denied.statusCode, 403); + assert.match(denied.headers, /\r\nconnection: close/); + denied.socket.destroy(); + + const badPort = await sendConnect(p.dataPort, 'good.test:22', basicAuth()); + assert.equal(badPort.statusCode, 403); + assert.match(badPort.headers, /\r\nconnection: close/); + badPort.socket.destroy(); + } finally { + await p.close(); + } + }); }); describe('egress proxy — rebinding defence', () => { From 39f0c9106e76fd82a9fb57dae99776330b019d01 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 09:57:15 +0200 Subject: [PATCH 10/34] fix(dev-platform): wire the W1 LLM-proxy passthrough the shim's own comments said existed Gate 9 -- the last one. After gate 8 (proxy 407 fix), a real job cleared clone and the CLI actually started, but exited instantly with 0 tokens/$0, "implement session exited with code 1", ~27ms after starting -- too fast for even a rejected network round-trip. Root cause: agentRunner.ts's buildAgentEnv only forwards ANTHROPIC_BASE_URL/ ANTHROPIC_AUTH_TOKEN into the CLI child process when ShimEnv.llmEnvAllowed is true. That flag is set ONLY by the legacy LocalProcessBackend (OMADIA_LLM_ENV_ALLOWED=true, the W0 "jail acknowledgment"). Both phaseRunner.ts (gated) and index.ts (collapsed) read the LLM auth pair from OMADIA_ANTHROPIC_BASE_URL / OMADIA_ANTHROPIC_AUTH_TOKEN -- vars ONLY the LocalProcessBackend ever sets. For the real docker path, deriveJobPolicy.ts sets plain ANTHROPIC_BASE_URL (no OMADIA_ prefix) and no auth-token var at all matching either name. Every one of these three files' own comments already say the quiet part: "W1's per-job, short-lived LLM-proxy tokens replace this passthrough entirely" -- the replacement was documented as existing and never actually written. The CLI ran with no ANTHROPIC_BASE_URL and no ANTHROPIC_AUTH_TOKEN at all, so it never even reached the middleware's LLM proxy -- unrelated to the earlier "no key in Vault" finding, which was also real but wasn't the (whole) story. Fix: in both phaseRunner.ts and index.ts, prefer the W1 inputs when present -- plain ANTHROPIC_BASE_URL (process.env) as the proxy target, and ShimEnv.jobToken (already required, already the exact bearer llmProxy.ts's resolveJobByToken resolves a calling job from -- "the CLI never sends a jobId -- only Authorization: Bearer ") as the auth token. Presence of a W1 base URL stands in for the W0 jail acknowledgment (llmEnvAllowed = env.llmEnvAllowed || Boolean(w1BaseUrl)) rather than requiring it -- a short-lived, per-job token is a different threat model from W0's long-lived middleware secret, which stays gated exactly as before when there is no W1 base URL. Falls back to the legacy OMADIA_ANTHROPIC_* pair unchanged for a genuine W0 LocalProcessBackend run. New tests: index.test.ts asserts the W1 path forwards ANTHROPIC_BASE_URL + ShimEnv.jobToken with no jail acknowledgment; phaseLoop.test.ts extends the fake CLI fixture to record what it received and asserts the same across all three phase sessions of a gated run. Both existing W0-gate tests (withholds/forwards under OMADIA_ANTHROPIC_*) pass unchanged -- they never set plain ANTHROPIC_BASE_URL, so the new W1 branch never activates for them. Shim suite green: 50/50 (48 baseline + 2 new). Full middleware gate green: build/lint (0 errors)/typecheck. Deployment note: the operator had an Anthropic key configured for the chat/orchestrator feature (@omadia/orchestrator / provider:anthropic/api_key) but none yet under the dev-platform's own namespace (core:dev-platform / llm/anthropic/api_key) -- a deliberately separate namespace for budget/ security isolation between the two features, not a bug. Copied one over for local live verification of this fix. --- .../packages/dev-runner-shim/src/index.ts | 17 ++++++--- .../dev-runner-shim/src/phaseRunner.ts | 17 +++++++-- .../dev-runner-shim/test/index.test.ts | 18 ++++++++++ .../dev-runner-shim/test/phaseLoop.test.ts | 36 ++++++++++++++++++- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/middleware/packages/dev-runner-shim/src/index.ts b/middleware/packages/dev-runner-shim/src/index.ts index ac4b7269..875ec234 100644 --- a/middleware/packages/dev-runner-shim/src/index.ts +++ b/middleware/packages/dev-runner-shim/src/index.ts @@ -117,10 +117,17 @@ export async function runShim(env: ShimEnv = readShimEnv(), deps: ShimDeps = {}) // `OMADIA_ANTHROPIC_*` pair is the middleware's own long-lived proxy // secret, so it crosses into the child ONLY when the backend was launched // with the jail acknowledgment and plumbed `OMADIA_LLM_ENV_ALLOWED=true`. - // W1's per-job, short-lived LLM-proxy tokens replace this passthrough. - const proxyBaseUrl = process.env['OMADIA_ANTHROPIC_BASE_URL']?.trim(); - const proxyToken = process.env['OMADIA_ANTHROPIC_AUTH_TOKEN']?.trim(); - if (proxyToken && !env.llmEnvAllowed) { + // W1's per-job, short-lived LLM-proxy tokens replace this passthrough: + // `ANTHROPIC_BASE_URL` (policy-supplied, deriveJobPolicy.ts) plus the + // per-job bearer already on ShimEnv (`jobToken`) ARE that replacement — a + // short-lived, per-job token is a different threat model from W0's + // long-lived secret, so its presence stands in for the jail + // acknowledgment rather than requiring it. + const w1BaseUrl = process.env['ANTHROPIC_BASE_URL']?.trim(); + const proxyBaseUrl = w1BaseUrl || process.env['OMADIA_ANTHROPIC_BASE_URL']?.trim(); + const proxyToken = w1BaseUrl ? env.jobToken : process.env['OMADIA_ANTHROPIC_AUTH_TOKEN']?.trim(); + const llmEnvAllowed = env.llmEnvAllowed || Boolean(w1BaseUrl); + if (!w1BaseUrl && process.env['OMADIA_ANTHROPIC_AUTH_TOKEN']?.trim() && !env.llmEnvAllowed) { log( 'OMADIA_ANTHROPIC_AUTH_TOKEN is set but OMADIA_LLM_ENV_ALLOWED!=true — ' + 'withholding LLM auth from the child (W0 jail acknowledgment missing)', @@ -135,7 +142,7 @@ export async function runShim(env: ShimEnv = readShimEnv(), deps: ShimDeps = {}) cwd: repoDir, homeDir: agentHome, spec, - llmEnvAllowed: env.llmEnvAllowed, + llmEnvAllowed, ...(proxyBaseUrl ? { proxyBaseUrl } : {}), ...(proxyToken ? { proxyToken } : {}), emit, diff --git a/middleware/packages/dev-runner-shim/src/phaseRunner.ts b/middleware/packages/dev-runner-shim/src/phaseRunner.ts index 16164830..fb456470 100644 --- a/middleware/packages/dev-runner-shim/src/phaseRunner.ts +++ b/middleware/packages/dev-runner-shim/src/phaseRunner.ts @@ -194,14 +194,25 @@ export class PhaseRunner { const homeDir = path.join(this.c.env.workspace, 'home', `${phase}-${sessionIdx}`); await mkdir(homeDir, { recursive: true }); - const proxyBaseUrl = process.env['OMADIA_ANTHROPIC_BASE_URL']?.trim(); - const proxyToken = process.env['OMADIA_ANTHROPIC_AUTH_TOKEN']?.trim(); + // W1: `ANTHROPIC_BASE_URL` (policy-supplied, deriveJobPolicy.ts) plus the + // per-job bearer already on ShimEnv (`jobToken`, required, sourced from + // `OMADIA_JOB_TOKEN`) ARE the "W1's per-job, short-lived LLM-proxy tokens" + // ShimEnv.llmEnvAllowed's own doc comment says replace the W0 passthrough + // entirely -- a short-lived, per-job token is a different threat model + // from W0's long-lived middleware secret, so its presence stands in for + // the W0 jail acknowledgment rather than requiring it. Falls back to the + // legacy OMADIA_ANTHROPIC_* pair (still gated behind llmEnvAllowed) only + // when there is no W1 base URL, i.e. genuinely running under W0. + const w1BaseUrl = process.env['ANTHROPIC_BASE_URL']?.trim(); + const proxyBaseUrl = w1BaseUrl || process.env['OMADIA_ANTHROPIC_BASE_URL']?.trim(); + const proxyToken = w1BaseUrl ? this.c.env.jobToken : process.env['OMADIA_ANTHROPIC_AUTH_TOKEN']?.trim(); + const llmEnvAllowed = this.c.env.llmEnvAllowed || Boolean(w1BaseUrl); const agent = runAgent({ cliBin: this.c.env.cliBin, cwd: this.c.repoDir, homeDir, spec: this.c.spec, - llmEnvAllowed: this.c.env.llmEnvAllowed, + llmEnvAllowed, ...(proxyBaseUrl ? { proxyBaseUrl } : {}), ...(proxyToken ? { proxyToken } : {}), promptOverride: prompt, diff --git a/middleware/packages/dev-runner-shim/test/index.test.ts b/middleware/packages/dev-runner-shim/test/index.test.ts index ed2ab6af..620612a4 100644 --- a/middleware/packages/dev-runner-shim/test/index.test.ts +++ b/middleware/packages/dev-runner-shim/test/index.test.ts @@ -222,6 +222,24 @@ describe('runShim — LLM auth gate + job-scoped HOME', () => { assert.equal(childEnv['ANTHROPIC_BASE_URL'], 'http://proxy.internal'); assert.equal(childEnv['HOME'], path.join(ws, 'home'), 'child HOME is a fresh dir inside the workspace'); }); + + it('W1: forwards LLM auth from the policy-supplied ANTHROPIC_BASE_URL + ShimEnv.jobToken, with NO jail acknowledgment', async () => { + // The docker backend's real path: deriveJobPolicy.ts sets plain + // ANTHROPIC_BASE_URL (no OMADIA_ prefix), and there is no + // OMADIA_ANTHROPIC_AUTH_TOKEN at all -- the per-job jobToken already on + // ShimEnv IS the bearer the LLM proxy (llmProxy.ts) resolves the calling + // job from. llmEnvAllowed stays false: the short-lived per-job token + // stands in for the W0 jail acknowledgment rather than requiring it. + process.env['ANTHROPIC_BASE_URL'] = 'http://middleware:8080/api/v1/dev-runner/llm'; + await writeFakeGit(false); + const home = new FakeHome(makeSpec()); + const code = await runShim({ ...env, llmEnvAllowed: false, jobToken: 'djr_w1-token' }, { home, gitBin, log: () => {} }); + assert.equal(code, 0); + const childEnv = await readEnvDump(); + assert.equal(childEnv['ANTHROPIC_AUTH_TOKEN'], 'djr_w1-token', 'the per-job bearer, not a middleware secret'); + assert.equal(childEnv['ANTHROPIC_BASE_URL'], 'http://middleware:8080/api/v1/dev-runner/llm'); + delete process.env['ANTHROPIC_BASE_URL']; + }); }); describe('runShim — wall-clock budget', () => { diff --git a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts index a156bc4a..5102c6d5 100644 --- a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts +++ b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts @@ -115,7 +115,7 @@ async function writeFakeCli(): Promise { const fs = require('fs'); const path = require('path'); process.stdin.resume(); process.stdin.on('end', () => { const artifact = process.env.OMADIA_PHASE_ARTIFACT; - try { fs.writeFileSync(path.join(process.env.HOME, 'ran.json'), JSON.stringify({ home: process.env.HOME, artifact: artifact || null })); } catch {} + try { fs.writeFileSync(path.join(process.env.HOME, 'ran.json'), JSON.stringify({ home: process.env.HOME, artifact: artifact || null, anthropicAuthToken: process.env.ANTHROPIC_AUTH_TOKEN || null, anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL || null })); } catch {} if (artifact) { const m = /artifact-(.+?)-\\d+\\.json$/.exec(path.basename(artifact)); const phase = m ? m[1] : ''; @@ -269,3 +269,37 @@ describe('runPhasedShim — bootstrap runs as a command, not a CLI session', () assert.deepEqual(homes, [], 'bootstrap starts no agent session'); }); }); + +describe('runPhasedShim — W1 LLM auth passthrough (the docker backend\'s real path)', () => { + afterEach(() => { + delete process.env['ANTHROPIC_BASE_URL']; + }); + + it('forwards the policy-supplied ANTHROPIC_BASE_URL + ShimEnv.jobToken into each phase session, with NO jail acknowledgment', async () => { + // deriveJobPolicy.ts sets plain ANTHROPIC_BASE_URL (no OMADIA_ prefix) on + // the container; there is no OMADIA_ANTHROPIC_AUTH_TOKEN at all for a real + // docker job — the per-job jobToken already on ShimEnv is the bearer the + // LLM proxy resolves the calling job from (llmProxy.ts). llmEnvAllowed + // stays false in the fixture: the short-lived per-job token stands in for + // the W0 jail acknowledgment rather than requiring it. + process.env['ANTHROPIC_BASE_URL'] = 'http://middleware:8080/api/v1/dev-runner/llm'; + const home = new ScriptedHome(makeSpec(), [ + { directive: 'next', phase: 'plan' }, + { directive: 'next', phase: 'clarify' }, + { directive: 'park' }, + ]); + const code = await runPhasedShim({ ...env, jobToken: 'djr_w1-gated-token' }, { home, gitBin, log: () => {} }); + assert.equal(code, 0, 'park exits 0'); + + const homes = await listSessionHomes(); + assert.equal(homes.length, 3, 'one fresh HOME per phase'); + for (const h of homes) { + const ran = JSON.parse(await readFile(path.join(ws, 'home', h, 'ran.json'), 'utf8')) as { + anthropicAuthToken: string | null; + anthropicBaseUrl: string | null; + }; + assert.equal(ran.anthropicAuthToken, 'djr_w1-gated-token', `${h}: the per-job bearer, not a middleware secret`); + assert.equal(ran.anthropicBaseUrl, 'http://middleware:8080/api/v1/dev-runner/llm', `${h}`); + } + }); +}); From 95242f985440b6a3fc6e5dccefeeb454452db62a Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 10:04:29 +0200 Subject: [PATCH 11/34] fix(dev-platform): forward the proxy vars into the claude CLI's own child env too Gate 10. After the previous commit (W1 LLM-auth passthrough), a job cleared clone and started the CLI for real -- but then hung indefinitely with zero log output and zero tokens, for as long as it was left running (135s+ in one observed run, no wall-clock timeout hit yet). "agent_started" fired; "agent_done" never did. Same root cause family as the git fix and gate 6, one more subprocess. The `claude` CLI is a SEPARATE process from this shim -- `agentRunner.ts` spawns it with an explicit, from-scratch allowlisted env (`buildAgentEnv`), so it does NOT inherit the shim's own process.env, only what that allowlist hands it. The allowlist wired ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN (the previous commit's fix) but never HTTP_PROXY/HTTPS_PROXY/NO_PROXY or NODE_USE_ENV_PROXY. The CLI is itself Node/undici-based, so without NODE_USE_ENV_PROXY it silently ignores HTTP_PROXY exactly like this shim's own fetch calls did before gate 6 -- except this time there is no error at all: the CLI just tries a direct connection to an unreachable host and sits there. No stderr line ever gets translated because the CLI's own network stack is still trying, not failing. Fix: inside the same `if (opts.llmEnvAllowed === true)` block that wires ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN, also forward HTTP_PROXY/HTTPS_PROXY/ NO_PROXY (both cases) and NODE_USE_ENV_PROXY=1 when a proxy is configured -- scoped to the same LLM-routing gate as the auth pair, since that is exactly when the CLI needs to reach the proxy at all. New tests in agentRunner.test.ts (buildAgentEnv is unit-testable directly, no subprocess needed): proxy vars forwarded under the gate, withheld without it (mirrors the existing auth-pair test), and absent entirely (not empty-string) when no proxy is configured. Shim suite green: 52/52 (50 baseline + 2 new). Full middleware gate green: build/lint (0 errors)/typecheck. --- .../dev-runner-shim/src/agentRunner.ts | 16 +++++++++ .../dev-runner-shim/test/agentRunner.test.ts | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/middleware/packages/dev-runner-shim/src/agentRunner.ts b/middleware/packages/dev-runner-shim/src/agentRunner.ts index 5f4b557b..d7de13f6 100644 --- a/middleware/packages/dev-runner-shim/src/agentRunner.ts +++ b/middleware/packages/dev-runner-shim/src/agentRunner.ts @@ -173,6 +173,22 @@ export function buildAgentEnv( if (opts.llmEnvAllowed === true) { if (opts.proxyBaseUrl) env['ANTHROPIC_BASE_URL'] = opts.proxyBaseUrl; if (opts.proxyToken) env['ANTHROPIC_AUTH_TOKEN'] = opts.proxyToken; + // Same reason gitOps.ts's runGit() forwards these to git: the job's + // isolated network has no route to `ANTHROPIC_BASE_URL` (the middleware) + // except through the daemon's egress proxy, and the `claude` CLI is a + // SEPARATE process from this shim -- it does not inherit the shim's own + // process.env, only what buildAgentEnv hands it here. Without these, the + // CLI's first request hangs against an unreachable host with no log + // output at all (the shim never sees a stderr line to translate, + // because the CLI's own network stack is still trying, not failing) -- + // the same undici-needs-NODE_USE_ENV_PROXY behaviour gate 6 already + // established for this shim's own fetch calls applies equally to the + // CLI subprocess, since it is also Node/undici-based. + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) { + const value = parent[key]; + if (value) env[key] = value; + } + if (parent['HTTP_PROXY'] || parent['HTTPS_PROXY']) env['NODE_USE_ENV_PROXY'] = '1'; } return env; } diff --git a/middleware/packages/dev-runner-shim/test/agentRunner.test.ts b/middleware/packages/dev-runner-shim/test/agentRunner.test.ts index 3ea863ff..b3e2d9a1 100644 --- a/middleware/packages/dev-runner-shim/test/agentRunner.test.ts +++ b/middleware/packages/dev-runner-shim/test/agentRunner.test.ts @@ -133,6 +133,39 @@ describe('buildAgentEnv — allowlist, not scrub', () => { assert.equal(allowed['ANTHROPIC_AUTH_TOKEN'], 'bearer'); }); + it('forwards HTTP_PROXY/NO_PROXY + NODE_USE_ENV_PROXY into the CLI child, same reason gitOps.ts forwards them to git', () => { + // The `claude` CLI is a SEPARATE process — it does not inherit this + // shim's own process.env, only what buildAgentEnv hands it. The job's + // isolated network has no route to ANTHROPIC_BASE_URL except through the + // daemon's egress proxy, and the CLI is Node/undici-based like the + // shim's own fetch calls, so it needs NODE_USE_ENV_PROXY too (gate 6's + // finding applies here as much as to homeClient.ts's fetch). + process.env['HTTP_PROXY'] = 'http://job:token@172.28.5.3:3128/'; + process.env['HTTPS_PROXY'] = 'http://job:token@172.28.5.3:3128/'; + process.env['NO_PROXY'] = 'localhost,127.0.0.1'; + try { + const withoutAck = buildAgentEnv({ cwd: '/tmp/x', proxyBaseUrl: 'http://proxy', proxyToken: 'bearer' }); + assert.equal(withoutAck['HTTP_PROXY'], undefined, 'proxy vars stay scoped to the LLM-routing gate, same as the auth pair'); + + const withAck = buildAgentEnv({ cwd: '/tmp/x', proxyBaseUrl: 'http://proxy', proxyToken: 'bearer', llmEnvAllowed: true }); + assert.equal(withAck['HTTP_PROXY'], 'http://job:token@172.28.5.3:3128/'); + assert.equal(withAck['HTTPS_PROXY'], 'http://job:token@172.28.5.3:3128/'); + assert.equal(withAck['NO_PROXY'], 'localhost,127.0.0.1'); + assert.equal(withAck['NODE_USE_ENV_PROXY'], '1'); + } finally { + delete process.env['HTTP_PROXY']; + delete process.env['HTTPS_PROXY']; + delete process.env['NO_PROXY']; + } + }); + + it('omits proxy keys entirely when none are configured (no empty-string env pollution)', () => { + const env = buildAgentEnv({ cwd: '/tmp/x', proxyBaseUrl: 'http://proxy', proxyToken: 'bearer', llmEnvAllowed: true }); + for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'NODE_USE_ENV_PROXY']) { + assert.equal(env[k], undefined, `${k} must be absent, not an empty string`); + } + }); + it('HOME is job-scoped and the parent HOME never appears in the child env', () => { const prev = process.env['HOME']; const canary = '/tmp/parent-home-canary-9f3a1c'; From 29888fa670a7a2235f8bda1811800737baf8fae4 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 10:23:48 +0200 Subject: [PATCH 12/34] fix(dev-platform): exclude the LLM proxy path from the global express.json() Gate 11. After gates 6/10 (proxy env forwarding), a job reached the CLI and the CLI reached the proxy -- but every real request 400'd: "devplatform.invalid_body -- request body must name a model", even though the client sent a well-formed Messages-API body naming a model. llmProxy.ts's handleMessages only trusts a Buffer: it canonicalises the raw bytes itself (own comment: "validate one representation, forward another" is the exact class of bug this avoids) via its own route-level express.raw({ type: () => true }), mounted when mountDevPlatform() wires the runner router. That router mounts late in index.ts's boot sequence, well after the *global* app.use(express.json({limit:'10mb'})) which runs earlier, unscoped by path -- so it runs first, for every request including this one. body-parser's read() bails via onFinished.isFinished(req) once the stream is already drained and never touches req.body again (confirmed in node_modules/body-parser/lib/read.js). So express.json() parses the body into an object, and the route's own express.raw() -- reached second -- silently no-ops, leaving req.body as that object. handleMessages's `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)` then discards it as an empty buffer, which parses to {} -- no model field, hence the exact error observed. The existing llmProxy.test.ts suite never caught this: its fixture mounts the runner router directly on a bare express() app, with no global express.json() ahead of it, so the ordering bug this specific mount produces in the real app was structurally unreachable in the test harness. Fix: mirror the GitHub-webhook router's existing raw-body-before-json pattern (see the log line right above the json() mount: "raw-body, before express.json"). The runner router can't move earlier -- mountDevPlatform needs graphPool/vault/store set up first -- so instead the global json() mount is wrapped to skip /api/v1/dev-runner/llm/*, leaving that path's body untouched for the route's own raw() parser to read exactly as designed. New test in llmProxy.test.ts reproduces the real mount order (global express.json() suffixed with the same path-exclusion gate, then the runner router) and proves the request now lands; a second variant confirms unrelated routes still parse JSON normally. Manually confirmed against the old (buggy) mount order that this test fails with the exact same 400 before the fix, and passes after. Full middleware gate green: build/lint (0 errors)/typecheck, 566/566 devplatform tests (test/devplatform/*.test.ts). --- docker-compose.dev-platform.yaml | 10 ++ middleware/src/index.ts | 16 ++- middleware/test/devplatform/llmProxy.test.ts | 113 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index 06e727e7..a61bdb4e 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -55,6 +55,16 @@ services: # one). Same source var as the daemon's own DEV_RUNNER_IMAGES below, so # both sides always agree on which image a job runs. DEV_RUNNER_DEFAULT_IMAGE: ${DEV_RUNNER_IMAGE:-ghcr.io/byte5ai/omadia-dev-runner:latest} + # The LLM proxy's model allowlist (spec §5, wireDevPlatform.ts). An empty + # list is a deliberate fail-closed default (config.ts: "always mounted; + # an empty allowlist ⇒ it answers 500 'no LLM policy'"), so this is a + # real per-deployment setting, not a wiring bug -- but its absence looks + # EXACTLY like every earlier gate in this chain from the runner's side: + # the CLI reaches the proxy fine (gates 6/10 fixed that) and gets an + # instant, silent-to-the-runner 500 with zero tokens spent. Comma-separated, + # exact string match (llmProxy.ts: `policy.allowedModels.includes(model)`) + # against whatever `--model` the CLI actually reports in its init message. + DEV_PLATFORM_LLM_ALLOWED_MODELS: ${DEV_PLATFORM_LLM_ALLOWED_MODELS:-claude-opus-4-8[1m],claude-opus-4-8,claude-sonnet-4-8[1m],claude-sonnet-4-8} # Neutralise any docker engine address a stray `middleware/.env` (loaded via # the base file's env_file) might inject. `environment` wins over env_file, # so these empty values are the last word: the middleware CANNOT be handed a diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 7dca1db1..851ddbe6 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -2091,7 +2091,21 @@ async function main(): Promise { console.log('[middleware] dev-platform GitHub webhook router mounted at /api/webhooks/github (raw-body, before express.json)'); } - app.use(express.json({ limit: '10mb' })); + // The LLM proxy (`/api/v1/dev-runner/llm/*`, mounted later at `mountDevPlatform`) + // owns its own route-level `express.raw()` so it can canonicalise the exact bytes + // it validates before forwarding (see llmProxy.ts). A global body parser that runs + // BEFORE that route is reached would drain the request stream first: body-parser's + // `read()` bails out via `onFinished.isFinished(req)` on an already-consumed stream + // and never touches `req.body` again, so the route's own raw() would then see a + // pre-parsed object instead of a Buffer. Skip this one path here, mirroring the + // GitHub-webhook router's raw-body-before-json pattern above. + app.use((req, res, next) => { + if (req.path.startsWith('/api/v1/dev-runner/llm/')) { + next(); + return; + } + express.json({ limit: '10mb' })(req, res, next); + }); app.use(cookieParser()); app.get('/health', (_req, res) => { diff --git a/middleware/test/devplatform/llmProxy.test.ts b/middleware/test/devplatform/llmProxy.test.ts index ef6e90ee..5e166edf 100644 --- a/middleware/test/devplatform/llmProxy.test.ts +++ b/middleware/test/devplatform/llmProxy.test.ts @@ -618,3 +618,116 @@ describe('llmProxy — accounting failure is surfaced, not swallowed (review S-f assert.equal(fx.usageRows.length, 0, 'ledger is not written when the authoritative store failed'); }); }); + +describe('llmProxy — survives a global express.json() ahead of the router (index.ts mount order)', () => { + // `makeFixture` above mounts the runner router directly on a bare `express()` + // app, which never reproduces the real bug: in `index.ts`, a global + // `app.use(express.json())` runs for EVERY path before `mountDevPlatform` + // attaches the runner router further down in the boot sequence. body-parser's + // `read()` bails out on an already-consumed request stream + // (`onFinished.isFinished(req)`) without touching `req.body` again, so the + // route's own `express.raw()` would silently no-op and leave `req.body` as + // whatever `express.json()` parsed — never a Buffer. `llmProxy.ts`'s + // canonicalisation step only trusts a Buffer, so every real request 400'd + // with "must name a model". Reproduce that exact mount order here, gated by + // the same path exclusion index.ts uses, and prove the request still lands. + let server: Server; + let base: string; + afterEach(async () => { + if (server) await new Promise((r) => server.close(() => r())); + }); + + it('parses the body correctly when a global express.json() precedes the router', async () => { + const calls: FetchCall[] = []; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const body = init?.body instanceof Buffer ? init.body.toString('utf8') : String(init?.body ?? ''); + calls.push({ url: String(url), headers: { ...(init?.headers as Record) }, body }); + return sseResponse(FULL_SSE); + }) as unknown as typeof fetch; + + const proxyDeps: LlmProxyDeps = { + resolveJobByToken: async (t) => (t === VALID ? { id: 'job-1', status: 'running', agentKind: 'claude-cli' } : null), + resolvePolicy: async () => ({ + provider: 'anthropic', + upstreamBaseUrl: 'https://upstream.test', + allowedModels: ['claude-opus-4-8'], + }), + resolveProviderKey: async () => REAL_KEY, + addJobUsage: async () => {}, + recordUsage: () => {}, + fetchImpl, + }; + const runnerDeps: DevRunnerRouterDeps = { + store: { + verifyRunnerToken: async () => false, + getJob: async () => null, + markRunning: async () => false, + touchHeartbeat: async () => false, + appendEvents: async () => 0, + addArtifact: async () => 'x', + artifactBelongsToJob: async () => false, + recordResult: async () => {}, + }, + repos: { getRepo: async () => null }, + scmTokens: { resolve: async () => undefined }, + finalizeDevJob: async () => null, + llmProxyRouter: createLlmProxyRouter(proxyDeps), + }; + + const app: Express = express(); + // Mirrors index.ts's path-exclusion gate ahead of the global JSON parser. + app.use((req, res, next) => { + if (req.path.startsWith('/api/v1/dev-runner/llm/')) { + next(); + return; + } + express.json({ limit: '10mb' })(req, res, next); + }); + app.use('/api/v1/dev-runner', createDevRunnerRouter(runnerDeps)); + + await new Promise((resolve) => { + server = app.listen(0, () => { + const port = (server.address() as AddressInfo).port; + base = `http://127.0.0.1:${String(port)}/api/v1/dev-runner`; + resolve(); + }); + }); + + const res = await post(base, OK_BODY, authed); + assert.equal(res.status, 200); + await res.text(); + assert.equal(calls.length, 1); + assert.equal(JSON.parse(calls[0]?.body ?? '{}').model, 'claude-opus-4-8'); + }); + + it('still parses unrelated JSON routes normally through the global express.json()', async () => { + const app: Express = express(); + app.use((req, res, next) => { + if (req.path.startsWith('/api/v1/dev-runner/llm/')) { + next(); + return; + } + express.json({ limit: '10mb' })(req, res, next); + }); + app.post('/other', (req, res) => { + res.json({ echoed: req.body as unknown }); + }); + + await new Promise((resolve) => { + server = app.listen(0, () => { + const port = (server.address() as AddressInfo).port; + base = `http://127.0.0.1:${String(port)}`; + resolve(); + }); + }); + + const res = await fetch(`${base}/other`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }); + assert.equal(res.status, 200); + const json = (await res.json()) as { echoed: { hello: string } }; + assert.equal(json.echoed.hello, 'world'); + }); +}); From 55fdc21255342ea15d865126ffff132323b35ba4 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 11:16:43 +0200 Subject: [PATCH 13/34] feat(dev-platform): raise default job budget to $100, structure the log pane Gate 12. Two operator-facing follow-ups from the first real job run (fcafa3ce): 1. DEV_JOB_DEFAULT_BUDGET_USD default was $5 -- too tight for an Opus-driven implement phase in a codebase this size; the verification job hit it mid-task. Raised the config default to $100 (still overridable per-job or per-repo via the existing budget_cost_usd column and repo settings UI). 2. The implement-phase log pane rendered every tool call as a flat `$ Name {...raw JSON...}` line -- the start and result events for one call arrive as two independent, unpaired dev_job_events rows ({name, inputPreview} then {ok, name, outputPreview}), and eventToLine() just dumped each one verbatim. New `_lib/toolCallLog.ts`: pairs a call's start/result events by walking back to the nearest still-pending entry with the same tool name (safe for the single-threaded CLI agent loop this feeds from), then formats a one-line headline + tool-shaped detail per tool: file path for Read/Write, an inline unified diff for Edit (new `_lib/lineDiff.ts`, a small self-contained LCS line-diff -- no new dependency for something this size), command+output for Bash, description+subagent for Agent/Task, pattern for Grep/Glob. Unknown tool names fall back to the raw input/output text, so nothing silently disappears. New `ToolCallCard.tsx`: collapsed by default, status glyph (text/edge only, no spinners, per this project's Lume rules) + headline; expands to the formatted detail. `JobLogPane`/`page.tsx` swap the old flat `LogLine[]` for `LogItem[]` (`foldDevJobEvent` replaces `eventToLine`). New tests: lineDiff.test.ts (8 cases incl. pure add/remove and the oversized-input fallback), toolCallLog.test.ts (18 cases: FIFO pairing across repeated same-name calls, orphan results, malformed JSON input, and one summarizer case per tool kind). All new i18n strings added to both en.json/de.json per this project's hard i18n rule. Verified: tsc --noEmit clean, eslint clean, `npm run i18n:check` clean (no new untranslated-key warnings), `npm run build` succeeds, 36/36 dev-platform vitest files green, 326/331 full web-ui suite green (5 pre-existing toolTemplates.test.ts failures, unrelated file, fails identically in isolation on unmodified main). --- middleware/src/config.ts | 2 +- .../dev-platform/_components/JobLogPane.tsx | 46 ++--- .../dev-platform/_components/ToolCallCard.tsx | 168 ++++++++++++++++ .../_lib/__tests__/lineDiff.test.ts | 70 +++++++ .../_lib/__tests__/toolCallLog.test.ts | 183 ++++++++++++++++++ .../app/admin/dev-platform/_lib/lineDiff.ts | 67 +++++++ .../admin/dev-platform/_lib/toolCallLog.ts | 176 +++++++++++++++++ .../app/admin/dev-platform/jobs/[id]/page.tsx | 30 +-- web-ui/messages/de.json | 9 + web-ui/messages/en.json | 9 + 10 files changed, 711 insertions(+), 49 deletions(-) create mode 100644 web-ui/app/admin/dev-platform/_components/ToolCallCard.tsx create mode 100644 web-ui/app/admin/dev-platform/_lib/__tests__/lineDiff.test.ts create mode 100644 web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts create mode 100644 web-ui/app/admin/dev-platform/_lib/lineDiff.ts create mode 100644 web-ui/app/admin/dev-platform/_lib/toolCallLog.ts diff --git a/middleware/src/config.ts b/middleware/src/config.ts index ff85e2dd..ff9b7ed6 100644 --- a/middleware/src/config.ts +++ b/middleware/src/config.ts @@ -202,7 +202,7 @@ const ConfigSchema = z.object({ // Epic #470 W4 — default per-job LLM cost budget (USD) applied when neither the // job nor its repo sets one (spec §5). Token budgets have NO default: they are // enforced only when explicitly set on the job or repo. - DEV_JOB_DEFAULT_BUDGET_USD: z.coerce.number().positive().default(5), + DEV_JOB_DEFAULT_BUDGET_USD: z.coerce.number().positive().default(100), // Postgres connection string for the Neon-backed knowledge graph. // When set, `bootstrapKnowledgeGraphFromEnv` installs the diff --git a/web-ui/app/admin/dev-platform/_components/JobLogPane.tsx b/web-ui/app/admin/dev-platform/_components/JobLogPane.tsx index a81c7f54..3e05646e 100644 --- a/web-ui/app/admin/dev-platform/_components/JobLogPane.tsx +++ b/web-ui/app/admin/dev-platform/_components/JobLogPane.tsx @@ -7,6 +7,9 @@ import { useTranslations } from 'next-intl'; import { ScrollToBottomButton } from '@/app/_components/ScrollToBottomButton'; import { useStickToBottom } from '@/app/_lib/useStickToBottom'; +import type { LogItem } from '../_lib/toolCallLog'; +import { ToolCallCard } from './ToolCallCard'; + /** * Epic #470 W0 — the live log pane (UI spec §5). Monospace, sunken surface, * stick-to-bottom via `useStickToBottom` (issue #404): follows while at the @@ -15,40 +18,34 @@ import { useStickToBottom } from '@/app/_lib/useStickToBottom'; * a token stream announced line-by-line is noise (§13); a separate polite * region carries the connection state instead. * - * Tool-invocation lines are `$`-prefixed in `--fg-strong`, stdout in - * `--fg-muted`, stderr in `--danger` — text color only, no filled gutters. - * The pane scrolls inside its own `overflow` box; the page never scrolls - * sideways. No toast on disconnect. + * Items come pre-folded from `toolCallLog.ts`: agent narration renders as + * plain text (stdout in `--fg-muted`, stderr in `--danger`), tool calls + * render as a collapsible `ToolCallCard` instead of a raw `$ Name {...json}` + * dump. The pane scrolls inside its own `overflow` box; the page never + * scrolls sideways. No toast on disconnect. */ -export type LogStream = 'tool' | 'agent' | 'stderr'; - -export interface LogLine { - id: string; - stream: LogStream; - text: string; -} +export type LogTextStream = 'agent' | 'stderr'; export type LogConnection = 'live' | 'reconnecting' | 'closed'; -const STREAM_CLASS: Record = { - tool: 'text-[color:var(--fg-strong)]', +const STREAM_CLASS: Record = { agent: 'text-[color:var(--fg-muted)]', stderr: 'text-[color:var(--danger)]', }; export function JobLogPane({ - lines, + items, connection, lastEventAgoSec, }: { - lines: LogLine[]; + items: LogItem[]; connection: LogConnection; lastEventAgoSec: number | null; }): React.ReactElement { const t = useTranslations('adminDevPlatform.detail'); const scrollRef = useRef(null); - const { isAtBottom, scrollToBottom } = useStickToBottom(scrollRef, [lines.length]); + const { isAtBottom, scrollToBottom } = useStickToBottom(scrollRef, [items.length]); const connectionText = connection === 'live' @@ -68,15 +65,18 @@ export function JobLogPane({ aria-live="off" className="max-h-[60vh] overflow-x-auto overflow-y-auto rounded-lg border border-[color:var(--border)] lume-surface-sunken p-4 font-mono text-xs leading-[1.6]" > - {lines.length === 0 ? ( + {items.length === 0 ? (
{t('logEmpty')}
) : ( - lines.map((line) => ( -
- {line.stream === 'tool' ? '$ ' : ''} - {line.text} -
- )) + items.map((item) => + item.kind === 'tool' ? ( + + ) : ( +
+ {item.text} +
+ ), + ) )} = { + pending: '…', + ok: '✓', + error: '✕', +}; + +const STATUS_CLASS: Record = { + pending: 'text-[color:var(--fg-subtle)]', + ok: 'text-[color:var(--fg-strong)]', + error: 'text-[color:var(--danger)]', +}; + +const DIFF_LINE_LIMIT = 400; + +export function ToolCallCard({ entry }: { entry: ToolCallEntry }): React.ReactElement { + const t = useTranslations('adminDevPlatform.detail.toolCall'); + const [expanded, setExpanded] = useState(false); + const summary = summarizeToolCall(entry); + const statusLabel = entry.status === 'pending' ? t('pending') : entry.status === 'error' ? t('failed') : ''; + + return ( +
+ + {expanded ?
{renderDetail(summary.detail, t)}
: null} +
+ ); +} + +function renderDetail( + detail: ToolCallDetail, + t: ReturnType>, +): React.ReactElement { + switch (detail.kind) { + case 'diff': + return ; + case 'command': + return ( + <> +
+          
+        
+      );
+    case 'file':
+      return ;
+    case 'agent':
+      return (
+        <>
+          {detail.prompt ? 
 : null}
+          
+        
+      );
+    case 'search':
+      return ;
+    case 'raw':
+      return (
+        <>
+          {detail.input ? 
 : null}
+          
+        
+      );
+  }
+}
+
+function OutputBlock({
+  text,
+  label,
+  t,
+}: {
+  text: string | undefined;
+  label?: string;
+  t: ReturnType>;
+}): React.ReactElement {
+  if (!text) return 

{t('noOutput')}

; + return
;
+}
+
+function Pre({ text, label, tone = 'muted' }: { text: string; label?: string; tone?: 'muted' | 'strong' }): React.ReactElement {
+  return (
+    
+ {label ?
{label}
: null} +
+        {text}
+      
+
+ ); +} + +function DiffView({ + diff, + t, +}: { + diff: DiffLine[]; + t: ReturnType>; +}): React.ReactElement { + const shown = diff.slice(0, DIFF_LINE_LIMIT); + const hidden = diff.length - shown.length; + return ( +
+
+        {shown.map((line, i) => (
+          
+ {line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' '} {line.text} +
+ ))} +
+ {hidden > 0 ? ( +
+ {t('moreDiffLines', { count: hidden })} +
+ ) : null} +
+ ); +} diff --git a/web-ui/app/admin/dev-platform/_lib/__tests__/lineDiff.test.ts b/web-ui/app/admin/dev-platform/_lib/__tests__/lineDiff.test.ts new file mode 100644 index 00000000..6f517920 --- /dev/null +++ b/web-ui/app/admin/dev-platform/_lib/__tests__/lineDiff.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { computeLineDiff } from '../lineDiff'; + +describe('computeLineDiff', () => { + it('returns all-context for identical text', () => { + const diff = computeLineDiff('a\nb\nc', 'a\nb\nc'); + expect(diff).toEqual([ + { type: 'context', text: 'a' }, + { type: 'context', text: 'b' }, + { type: 'context', text: 'c' }, + ]); + }); + + it('marks a single changed line as remove+add, keeping context around it', () => { + const diff = computeLineDiff('a\nb\nc', 'a\nx\nc'); + expect(diff).toEqual([ + { type: 'context', text: 'a' }, + { type: 'remove', text: 'b' }, + { type: 'add', text: 'x' }, + { type: 'context', text: 'c' }, + ]); + }); + + it('handles a pure insertion', () => { + const diff = computeLineDiff('a\nc', 'a\nb\nc'); + expect(diff).toEqual([ + { type: 'context', text: 'a' }, + { type: 'add', text: 'b' }, + { type: 'context', text: 'c' }, + ]); + }); + + it('handles a pure deletion', () => { + const diff = computeLineDiff('a\nb\nc', 'a\nc'); + expect(diff).toEqual([ + { type: 'context', text: 'a' }, + { type: 'remove', text: 'b' }, + { type: 'context', text: 'c' }, + ]); + }); + + it('handles an empty old string (pure addition)', () => { + const diff = computeLineDiff('', 'a\nb'); + expect(diff).toEqual([ + { type: 'add', text: 'a' }, + { type: 'add', text: 'b' }, + ]); + }); + + it('handles an empty new string (pure removal)', () => { + const diff = computeLineDiff('a\nb', ''); + expect(diff).toEqual([ + { type: 'remove', text: 'a' }, + { type: 'remove', text: 'b' }, + ]); + }); + + it('handles two empty strings', () => { + expect(computeLineDiff('', '')).toEqual([]); + }); + + it('falls back to a remove-all/add-all block for pathologically large inputs', () => { + const big = Array.from({ length: 3000 }, (_, i) => `line-${i}`).join('\n'); + const bigger = Array.from({ length: 3000 }, (_, i) => `other-${i}`).join('\n'); + const diff = computeLineDiff(big, bigger); + expect(diff.every((l, idx) => (idx < 3000 ? l.type === 'remove' : l.type === 'add'))).toBe(true); + expect(diff).toHaveLength(6000); + }); +}); diff --git a/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts b/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts new file mode 100644 index 00000000..d3e2f46b --- /dev/null +++ b/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; + +import type { DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; + +import { foldDevJobEvent, summarizeToolCall, type LogItem, type ToolCallEntry } from '../toolCallLog'; + +function ev( + id: number, + type: DevJobEventMessage['type'], + payload: Record, +): DevJobEventMessage { + return { id, jobId: 'job-1', provision: 1, seq: id, type, ts: '2026-01-01T00:00:00Z', payload }; +} + +describe('foldDevJobEvent — tool pairing', () => { + it('appends a pending entry on the start event', () => { + const items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); + expect(items).toEqual([ + { kind: 'tool', entry: { id: '1', name: 'Read', status: 'pending', inputPreview: '{"file_path":"a.ts"}' } }, + ]); + }); + + it('pairs the result event into the matching pending entry', () => { + let items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); + items = foldDevJobEvent(items, ev(2, 'tool', { name: 'Read', ok: true, outputPreview: 'file contents' })); + expect(items).toEqual([ + { + kind: 'tool', + entry: { + id: '1', + name: 'Read', + status: 'ok', + inputPreview: '{"file_path":"a.ts"}', + outputPreview: 'file contents', + }, + }, + ]); + }); + + it('marks status error when ok is false', () => { + let items = foldDevJobEvent([], ev(1, 'tool', { name: 'Bash', inputPreview: '{"command":"false"}' })); + items = foldDevJobEvent(items, ev(2, 'tool', { name: 'Bash', ok: false, outputPreview: 'exit 1' })); + expect((items[0] as { kind: 'tool'; entry: ToolCallEntry }).entry.status).toBe('error'); + }); + + it('pairs same-name calls in order (FIFO) rather than the first pending one incorrectly', () => { + let items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); + items = foldDevJobEvent(items, ev(2, 'tool', { name: 'Read', ok: true, outputPreview: 'A' })); + items = foldDevJobEvent(items, ev(3, 'tool', { name: 'Read', inputPreview: '{"file_path":"b.ts"}' })); + items = foldDevJobEvent(items, ev(4, 'tool', { name: 'Read', ok: true, outputPreview: 'B' })); + const entries = items.map((i) => (i as { kind: 'tool'; entry: ToolCallEntry }).entry); + expect(entries).toEqual([ + { id: '1', name: 'Read', status: 'ok', inputPreview: '{"file_path":"a.ts"}', outputPreview: 'A' }, + { id: '3', name: 'Read', status: 'ok', inputPreview: '{"file_path":"b.ts"}', outputPreview: 'B' }, + ]); + }); + + it('renders a standalone result when no matching start exists', () => { + const items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', ok: true, outputPreview: 'orphan' })); + expect(items).toEqual([ + { kind: 'tool', entry: { id: '1', name: 'Read', status: 'ok', outputPreview: 'orphan' } }, + ]); + }); +}); + +describe('foldDevJobEvent — log/other events', () => { + it('appends agent-stream text', () => { + const items = foldDevJobEvent([], ev(1, 'log', { text: 'thinking…', stream: 'agent' })); + expect(items).toEqual([{ kind: 'text', id: '1', stream: 'agent', text: 'thinking…' }]); + }); + + it('routes stderr stream text', () => { + const items = foldDevJobEvent([], ev(1, 'log', { text: 'boom', stream: 'stderr' })); + expect(items).toEqual([{ kind: 'text', id: '1', stream: 'stderr', text: 'boom' }]); + }); + + it('drops empty-text log events', () => { + expect(foldDevJobEvent([], ev(1, 'log', { text: '' }))).toEqual([]); + }); + + it('ignores status/phase/heartbeat events entirely', () => { + expect(foldDevJobEvent([], ev(1, 'status', { state: 'agent_started' }))).toEqual([]); + expect(foldDevJobEvent([], ev(1, 'phase', { phase: 'implement', state: 'start' }))).toEqual([]); + }); +}); + +describe('summarizeToolCall', () => { + const entry = (overrides: Partial): ToolCallEntry => ({ + id: '1', + name: 'Read', + status: 'ok', + ...overrides, + }); + + it('summarizes Read/Write as the file path', () => { + const summary = summarizeToolCall( + entry({ name: 'Read', inputPreview: '{"file_path":"src/a.ts"}', outputPreview: 'body' }), + ); + expect(summary.headline).toBe('src/a.ts'); + expect(summary.detail).toEqual({ kind: 'file', filePath: 'src/a.ts', preview: 'body' }); + }); + + it('summarizes Edit as a diff with add/remove counts', () => { + const summary = summarizeToolCall( + entry({ + name: 'Edit', + inputPreview: JSON.stringify({ file_path: 'src/a.ts', old_string: 'foo', new_string: 'bar' }), + }), + ); + expect(summary.headline).toBe('src/a.ts'); + expect(summary.detail.kind).toBe('diff'); + if (summary.detail.kind === 'diff') { + expect(summary.detail.filePath).toBe('src/a.ts'); + expect(summary.detail.added).toBe(1); + expect(summary.detail.removed).toBe(1); + } + }); + + it('summarizes Bash using the description when present, else the command', () => { + const withDescription = summarizeToolCall( + entry({ + name: 'Bash', + inputPreview: JSON.stringify({ command: 'ls -la', description: 'List files' }), + outputPreview: 'total 0', + }), + ); + expect(withDescription.headline).toBe('List files'); + expect(withDescription.detail).toEqual({ + kind: 'command', + command: 'ls -la', + description: 'List files', + output: 'total 0', + }); + + const withoutDescription = summarizeToolCall( + entry({ name: 'Bash', inputPreview: JSON.stringify({ command: 'ls -la' }) }), + ); + expect(withoutDescription.headline).toBe('ls -la'); + }); + + it('summarizes Agent/Task with description + subagent type', () => { + const summary = summarizeToolCall( + entry({ + name: 'Agent', + inputPreview: JSON.stringify({ description: 'Find X', subagent_type: 'Explore', prompt: 'find x' }), + }), + ); + expect(summary.headline).toBe('Find X (Explore)'); + expect(summary.detail).toEqual({ + kind: 'agent', + subagentType: 'Explore', + description: 'Find X', + prompt: 'find x', + output: undefined, + }); + }); + + it('falls back to "Agent" headline when neither description nor subagent type is present', () => { + const summary = summarizeToolCall(entry({ name: 'Agent', inputPreview: '{}' })); + expect(summary.headline).toBe('Agent'); + }); + + it('summarizes Grep/Glob using the pattern', () => { + const summary = summarizeToolCall( + entry({ name: 'Grep', inputPreview: JSON.stringify({ pattern: 'TODO', path: 'src' }) }), + ); + expect(summary.headline).toBe('TODO'); + expect(summary.detail).toEqual({ kind: 'search', pattern: 'TODO', scope: 'src', output: undefined }); + }); + + it('falls back to raw input/output for unknown tool names', () => { + const summary = summarizeToolCall( + entry({ name: 'TodoWrite', inputPreview: '{"todos":[]}', outputPreview: 'ok' }), + ); + expect(summary.headline).toBe('TodoWrite'); + expect(summary.detail).toEqual({ kind: 'raw', input: '{"todos":[]}', output: 'ok' }); + }); + + it('tolerates malformed JSON input without throwing', () => { + const summary = summarizeToolCall(entry({ name: 'Read', inputPreview: 'not json' })); + expect(summary.headline).toBe('?'); + }); +}); diff --git a/web-ui/app/admin/dev-platform/_lib/lineDiff.ts b/web-ui/app/admin/dev-platform/_lib/lineDiff.ts new file mode 100644 index 00000000..2d0e5143 --- /dev/null +++ b/web-ui/app/admin/dev-platform/_lib/lineDiff.ts @@ -0,0 +1,67 @@ +/** + * Epic #470 — small line-based diff for the job log's `Edit` tool-call cards + * (`old_string`/`new_string` → an inline unified diff). Self-contained + * classic LCS diff — no external dependency for something this small. + */ + +export interface DiffLine { + type: 'add' | 'remove' | 'context'; + text: string; +} + +/** Defensive cap on the O(n·m) LCS table; pathologically large inputs fall + * back to a plain remove-all/add-all block instead of hanging the tab. */ +const MAX_DIFF_CELLS = 4_000_000; + +export function computeLineDiff(oldText: string, newText: string): DiffLine[] { + const a = oldText.length > 0 ? oldText.split('\n') : []; + const b = newText.length > 0 ? newText.split('\n') : []; + + if (a.length * b.length > MAX_DIFF_CELLS) { + return [ + ...a.map((text): DiffLine => ({ type: 'remove', text })), + ...b.map((text): DiffLine => ({ type: 'add', text })), + ]; + } + + const n = a.length; + const m = b.length; + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + const row = dp[i]; + if (!row) continue; + for (let j = m - 1; j >= 0; j--) { + row[j] = a[i] === b[j] ? dpAt(dp, i + 1, j + 1) + 1 : Math.max(dpAt(dp, i + 1, j), dpAt(dp, i, j + 1)); + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ type: 'context', text: a[i] ?? '' }); + i++; + j++; + } else if (dpAt(dp, i + 1, j) >= dpAt(dp, i, j + 1)) { + out.push({ type: 'remove', text: a[i] ?? '' }); + i++; + } else { + out.push({ type: 'add', text: b[j] ?? '' }); + j++; + } + } + while (i < n) { + out.push({ type: 'remove', text: a[i] ?? '' }); + i++; + } + while (j < m) { + out.push({ type: 'add', text: b[j] ?? '' }); + j++; + } + return out; +} + +function dpAt(dp: number[][], i: number, j: number): number { + return dp[i]?.[j] ?? 0; +} diff --git a/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts b/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts new file mode 100644 index 00000000..559adb4d --- /dev/null +++ b/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts @@ -0,0 +1,176 @@ +import type { DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; + +import { computeLineDiff, type DiffLine } from './lineDiff'; + +/** + * Epic #470 — turns the raw `dev_job_events` SSE tail into renderable log + * items for the job-detail implement-phase pane. Two responsibilities: + * + * 1. `foldDevJobEvent` pairs a tool call's two independent wire events + * (`{name, inputPreview}` at start, `{ok, name, outputPreview}` at + * result — no shared correlation id) into one `ToolCallEntry`, by + * walking back to the nearest still-pending entry with the same tool + * name. Safe for the single-threaded CLI agent loop this feeds from: a + * tool cannot start a second call under the same name before the first + * resolves. + * 2. `summarizeToolCall` turns a paired entry's raw JSON `inputPreview` + * into a one-line headline + a tool-shaped detail (a diff for `Edit`, + * a command for `Bash`, etc.) instead of the previous `$ Name {...raw + * JSON...}` dump. Unknown tool names fall back to the raw + * input/output text — nothing silently disappears. + */ + +export type ToolCallStatus = 'pending' | 'ok' | 'error'; + +export interface ToolCallEntry { + id: string; + name: string; + status: ToolCallStatus; + inputPreview?: string; + outputPreview?: string; +} + +export type LogItem = + | { kind: 'text'; id: string; stream: 'agent' | 'stderr'; text: string } + | { kind: 'tool'; entry: ToolCallEntry }; + +export function foldDevJobEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { + if (ev.type === 'tool') return foldToolEvent(items, ev); + if (ev.type === 'log') return foldLogEvent(items, ev); + return items; +} + +function foldToolEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { + const p = ev.payload; + const name = typeof p['name'] === 'string' ? p['name'] : 'tool'; + const hasResult = typeof p['ok'] === 'boolean'; + + if (!hasResult) { + const inputPreview = typeof p['inputPreview'] === 'string' ? p['inputPreview'] : undefined; + return [...items, { kind: 'tool', entry: { id: String(ev.id), name, status: 'pending', inputPreview } }]; + } + + const ok = p['ok'] === true; + const outputPreview = typeof p['outputPreview'] === 'string' ? p['outputPreview'] : undefined; + const idx = findLastPending(items, name); + if (idx === -1) { + // No matching start (e.g. a reconnect landed mid-call) — render standalone. + return [ + ...items, + { kind: 'tool', entry: { id: String(ev.id), name, status: ok ? 'ok' : 'error', outputPreview } }, + ]; + } + + const target = items[idx]; + if (!target || target.kind !== 'tool') return items; + const next = items.slice(); + next[idx] = { kind: 'tool', entry: { ...target.entry, status: ok ? 'ok' : 'error', outputPreview } }; + return next; +} + +function foldLogEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { + const p = ev.payload; + const text = typeof p['text'] === 'string' ? p['text'] : ''; + if (!text) return items; + return [...items, { kind: 'text', id: String(ev.id), stream: p['stream'] === 'stderr' ? 'stderr' : 'agent', text }]; +} + +function findLastPending(items: LogItem[], name: string): number { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item && item.kind === 'tool' && item.entry.name === name && item.entry.status === 'pending') return i; + } + return -1; +} + +// --- Summarization ----------------------------------------------------- + +export type ToolCallDetail = + | { kind: 'diff'; filePath: string; diff: DiffLine[]; added: number; removed: number } + | { kind: 'command'; command: string; description?: string; output?: string } + | { kind: 'file'; filePath: string; preview?: string } + | { kind: 'agent'; subagentType?: string; description?: string; prompt?: string; output?: string } + | { kind: 'search'; pattern: string; scope?: string; output?: string } + | { kind: 'raw'; input?: string; output?: string }; + +export interface ToolCallSummary { + headline: string; + detail: ToolCallDetail; +} + +export function summarizeToolCall(entry: ToolCallEntry): ToolCallSummary { + const input = parseJsonObject(entry.inputPreview); + switch (entry.name) { + case 'Read': + case 'Write': + return summarizeFileTool(input, entry.outputPreview); + case 'Edit': + return summarizeEdit(input); + case 'Bash': + return summarizeBash(input, entry.outputPreview); + case 'Agent': + case 'Task': + return summarizeAgent(input, entry.outputPreview); + case 'Grep': + case 'Glob': + return summarizeSearch(entry.name, input, entry.outputPreview); + default: + return { headline: entry.name, detail: { kind: 'raw', input: entry.inputPreview, output: entry.outputPreview } }; + } +} + +function parseJsonObject(text: string | undefined): Record { + if (!text) return {}; + try { + const parsed: unknown = JSON.parse(text); + return typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function summarizeFileTool(input: Record, output: string | undefined): ToolCallSummary { + const filePath = str(input['file_path']) ?? '?'; + return { headline: filePath, detail: { kind: 'file', filePath, preview: output } }; +} + +function summarizeEdit(input: Record): ToolCallSummary { + const filePath = str(input['file_path']) ?? '?'; + const oldString = str(input['old_string']) ?? ''; + const newString = str(input['new_string']) ?? ''; + const diff = computeLineDiff(oldString, newString); + const added = diff.filter((l) => l.type === 'add').length; + const removed = diff.filter((l) => l.type === 'remove').length; + return { headline: filePath, detail: { kind: 'diff', filePath, diff, added, removed } }; +} + +function summarizeBash(input: Record, output: string | undefined): ToolCallSummary { + const command = str(input['command']) ?? ''; + const description = str(input['description']); + return { headline: description ?? command, detail: { kind: 'command', command, description, output } }; +} + +function summarizeAgent(input: Record, output: string | undefined): ToolCallSummary { + const description = str(input['description']); + const subagentType = str(input['subagent_type']); + const prompt = str(input['prompt']); + const headline = [description, subagentType ? `(${subagentType})` : undefined].filter(Boolean).join(' '); + return { + headline: headline.length > 0 ? headline : 'Agent', + detail: { kind: 'agent', subagentType, description, prompt, output }, + }; +} + +function summarizeSearch( + name: string, + input: Record, + output: string | undefined, +): ToolCallSummary { + const pattern = str(input['pattern']) ?? ''; + const scope = str(input['path']) ?? str(input['glob']); + return { headline: pattern.length > 0 ? pattern : name, detail: { kind: 'search', pattern, scope, output } }; +} diff --git a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx index 4a855c49..500307f3 100644 --- a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx +++ b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx @@ -17,8 +17,9 @@ import { type DevJobUiPhase, } from '@/app/_components/devjobs/DevJobPhaseRail'; import { useDevJobEvents, type DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; -import { JobLogPane, type LogConnection, type LogLine } from '../../_components/JobLogPane'; +import { JobLogPane, type LogConnection } from '../../_components/JobLogPane'; import { cancelJob, deleteJob, getJob, isTerminalStatus, type DevJobView } from '../../_lib/api'; +import { foldDevJobEvent, type LogItem } from '../../_lib/toolCallLog'; /** * Epic #470 W0 — the job-detail signature screen (UI spec §5). Header, the @@ -33,26 +34,6 @@ function shortHash(id: string): string { return id.replace(/-/g, '').slice(0, 6); } -function eventToLine(ev: DevJobEventMessage): LogLine | null { - const p = ev.payload as Record; - if (ev.type === 'tool') { - const name = typeof p['name'] === 'string' ? p['name'] : 'tool'; - const preview = - typeof p['inputPreview'] === 'string' - ? p['inputPreview'] - : typeof p['outputPreview'] === 'string' - ? p['outputPreview'] - : ''; - return { id: String(ev.id), stream: 'tool', text: preview ? `${name} ${preview}` : name }; - } - if (ev.type === 'log') { - const text = typeof p['text'] === 'string' ? p['text'] : ''; - if (!text) return null; - return { id: String(ev.id), stream: p['stream'] === 'stderr' ? 'stderr' : 'agent', text }; - } - return null; -} - export default function JobDetailPage(): React.ReactElement { const t = useTranslations('adminDevPlatform.detail'); const params = useParams<{ id: string }>(); @@ -62,7 +43,7 @@ export default function JobDetailPage(): React.ReactElement { const [job, setJob] = useState(null); const [notFound, setNotFound] = useState(false); - const [lines, setLines] = useState([]); + const [items, setItems] = useState([]); const [conn, setConn] = useState('reconnecting'); const [lastEventAt, setLastEventAt] = useState(null); const [agoSec, setAgoSec] = useState(null); @@ -86,8 +67,7 @@ export default function JobDetailPage(): React.ReactElement { const handleEvent = useCallback((ev: DevJobEventMessage) => { setLastEventAt(Date.now()); - const line = eventToLine(ev); - if (line) setLines((prev) => [...prev, line]); + setItems((prev) => foldDevJobEvent(prev, ev)); if (ev.type === 'status' || ev.type === 'phase') { // Re-sync the authoritative view on lifecycle transitions. void getJob(ev.jobId).then( @@ -195,7 +175,7 @@ export default function JobDetailPage(): React.ReactElement {
{effective === 'implement' ? ( - + ) : effective === 'pr' && job?.prUrl ? (
diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index f85a5b55..bdc7f00e 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -3213,6 +3213,15 @@ }, "phaseSkipped": "übersprungen — keine Fragen", "noArtifact": "Für diese Phase gibt es noch kein Artefakt.", + "toolCall": { + "pending": "läuft", + "failed": "fehlgeschlagen", + "noOutput": "(keine Ausgabe)", + "prompt": "Prompt", + "result": "Ergebnis", + "output": "Ausgabe", + "moreDiffLines": "… {count, plural, one {# weitere Zeile} other {# weitere Zeilen}}" + }, "openPr": "Pull Request öffnen", "logEmpty": "Noch keine Log-Ausgabe.", "scrollToBottom": "Nach unten scrollen", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 3a1fedac..9b75f21c 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -3213,6 +3213,15 @@ }, "phaseSkipped": "skipped — no questions", "noArtifact": "No artifact for this phase yet.", + "toolCall": { + "pending": "running", + "failed": "failed", + "noOutput": "(no output)", + "prompt": "Prompt", + "result": "Result", + "output": "Output", + "moreDiffLines": "… {count, plural, one {# more line} other {# more lines}}" + }, "openPr": "Open pull request", "logEmpty": "No log output yet.", "scrollToBottom": "Scroll to bottom", From 3ff4ad55d0f303c3227f82b6051a8e3c47b111e2 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 11:24:17 +0200 Subject: [PATCH 14/34] fix(dev-platform): don't fabricate a zero-diff for a tool call with no captured start Found live while verifying the previous commit against a real running job: two Edit cards rendered "Edit ? +0 -0" instead of their real diff. Root cause: useDevJobEvents' native EventSource reconnected once mid-run (visible as "reconnecting" in the connection line), and the resumed stream's replay boundary meant the browser saw a tool-call's *result* event without ever having seen its *start* event -- foldDevJobEvent's orphan-result fallback correctly renders something rather than crashing, but the entry it produces has `inputPreview: undefined`. summarizeToolCall was reaching summarizeEdit anyway, which parses that undefined input as `{}` (empty object, same shape as a real edit with no args), so it silently computed a plausible-looking "file_path '?', 0 lines added, 0 removed" -- indistinguishable from a genuine no-op edit rather than "this call's real input was never captured." Fix: treat `inputPreview === undefined` (start never seen) as distinct from "the tool genuinely had an input JSON object" and route it through the same raw/output-only fallback already used for unknown tool names, before any per-tool summarizer runs. New regression test locks this in. Confirmed live against the job that surfaced it (fcafa3ce, requeued a third time under the new $100 budget): tsc --noEmit clean, eslint clean, 18/18 toolCallLog tests green. --- .../dev-platform/_lib/__tests__/toolCallLog.test.ts | 10 ++++++++++ web-ui/app/admin/dev-platform/_lib/toolCallLog.ts | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts b/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts index d3e2f46b..fc3f42af 100644 --- a/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts +++ b/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts @@ -180,4 +180,14 @@ describe('summarizeToolCall', () => { const summary = summarizeToolCall(entry({ name: 'Read', inputPreview: 'not json' })); expect(summary.headline).toBe('?'); }); + + it('renders an orphan result (no captured start) as raw/output-only, not a misleading zero-diff', () => { + // Regression: a start event dropped at an SSE reconnect boundary leaves + // inputPreview undefined; summarizeEdit must not be reached — it would + // silently compute file_path '?' and a 0-line diff, looking like a + // legitimate empty edit rather than "input never captured". + const summary = summarizeToolCall(entry({ name: 'Edit', inputPreview: undefined, outputPreview: 'ok' })); + expect(summary.headline).toBe('Edit'); + expect(summary.detail).toEqual({ kind: 'raw', output: 'ok' }); + }); }); diff --git a/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts b/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts index 559adb4d..fec0ca9d 100644 --- a/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts +++ b/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts @@ -99,6 +99,17 @@ export interface ToolCallSummary { } export function summarizeToolCall(entry: ToolCallEntry): ToolCallSummary { + // `inputPreview === undefined` means the start event was never captured + // client-side (e.g. dropped at an SSE reconnect boundary while the result + // still arrived) — NOT "the tool had no arguments" (a real start event + // always carries a JSON object, even if empty: `{}`). Per-tool summarizers + // below assume a present-but-possibly-empty input and would otherwise + // render a misleading zero-diff/empty headline for a call whose real + // arguments were simply never seen. Fall back to the same raw/output-only + // rendering used for unknown tool names instead. + if (entry.inputPreview === undefined) { + return { headline: entry.name, detail: { kind: 'raw', output: entry.outputPreview } }; + } const input = parseJsonObject(entry.inputPreview); switch (entry.name) { case 'Read': From ff37d937fd1bc2bb5f36748fd340b79f9c0b99f9 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 11:48:22 +0200 Subject: [PATCH 15/34] fix(dev-platform): new jobs default to phase 'analyze', not 'implement' Root cause of ticket #445's implement-phase agent refusing to proceed ("the plan artifact is empty... zero operator gate answers"): the agent was behaving correctly. The phase rail showed ANALYZE/BOOTSTRAP/PLAN/CLARIFY/GATE as checkmarked, but that's a purely positional render (DevJobPhaseRail.tsx's computePhaseStops marks every stop before the CURRENT phase as done, regardless of whether it ran) -- this job was created directly at phase='implement' and never actually went through planning at all. Traced to devJobStore.createJob's INSERT: `input.phase ?? 'implement'`. None of the five job-creation call sites (admin REST route, chat tool, plugin API, retry route, webhook trigger) ever pass an explicit `phase`, so every real job in this codebase's history has silently skipped straight to implement (or, for gated webhook triggers, parked at an empty `await_human` gate with no plan behind it). This is inconsistent with the rest of the system's own design: transitions.ts's test suite title is literally "collapsed mode skips THE GATE" and that test still begins `analyze -> implement` -- both pipeline modes are designed to start at analyze. The dev-runner-shim already defaults to it independently (phaseLoop.ts: `ctx?.phase ?? 'analyze'`, with real, non-stub prompt-building for every phase in phasePrompts.ts) and one test's own mock harness (devJobOrchestratorTool.test.ts) already modeled `phase ?? 'analyze'` -- while devPlatformPipeline.wire.pg.test.ts had a comment admitting the gap outright: "a gated pipeline starts at analyze (createJob defaults to implement)". The store was the one place never fixed to match. Fix: default to 'analyze' in devJobStore.createJob. Only `kind: 'analyze'` jobs terminate right after that phase (transitions.ts, unaffected); an explicit `phase` override still wins (e.g. the gated-webhook trigger parking straight at 'await_human'). Test fallout: devJobStore.pg.test.ts's default-value assertion updated (implement -> analyze); its unrelated requeueAtPhase test was coupled to the old default via a hardcoded `advancePhase(job.id, 'implement', ...)` -- switched to `job.phase` so it derives the real starting phase instead of assuming one. devPlatformPipeline.wire.pg.test.ts's now-stale comment corrected. Verified against the live omadia-dev Postgres (DATABASE_URL override): devJobStore.pg.test.ts 23/23, full test/devplatform/*.pg.test.ts 81/81. Two files (devPlatformPipeline.wire.pg.test.ts, goldenFixture.e2e.test.ts) fail when the ENTIRE test/devplatform/*.test.ts glob runs in one process but pass 100% in isolation -- confirmed pre-existing test-pollution unrelated to this change (documented in prior session notes), not introduced here. tsc --noEmit clean, eslint clean, `npm run build` succeeds. --- middleware/src/devplatform/devJobStore.ts | 12 +++++++++++- middleware/test/devplatform/devJobStore.pg.test.ts | 11 +++++++---- .../devplatform/devPlatformPipeline.wire.pg.test.ts | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/middleware/src/devplatform/devJobStore.ts b/middleware/src/devplatform/devJobStore.ts index 70501fe5..f5d65aff 100644 --- a/middleware/src/devplatform/devJobStore.ts +++ b/middleware/src/devplatform/devJobStore.ts @@ -206,6 +206,16 @@ export class DevJobStore { // `status` is threaded so a gated trigger job can be born `'waiting'` in this // single INSERT (never transiently `'queued'` and therefore never claimable); // omitted ⇒ `'queued'` (the DB default, kept explicit here for the same value). + // + // `phase` defaults to `'analyze'`, not `'implement'`: every pipeline_mode + // (gated AND collapsed) is designed to start there per transitions.ts's own + // test suite ("collapsed mode skips THE GATE" still begins `analyze → + // implement`) and the dev-runner-shim's own default + // (`phaseLoop.ts`: `ctx?.phase ?? 'analyze'`). Only `kind === 'analyze'` + // jobs terminate immediately after that phase; `fix_issue` and `implement` + // jobs continue through bootstrap/plan/clarify/gate exactly like any other + // gated job — an explicit `phase` override (e.g. the gated-webhook trigger + // parking straight at `'await_human'`) still wins. const r = await this.pool.query( `INSERT INTO dev_jobs (repo_id, kind, brief, source, source_ref, base_sha, backend, agent_kind, auth_mode, @@ -223,7 +233,7 @@ export class DevJobStore { input.agentKind ?? 'claude-cli', input.authMode ?? 'api_key', input.provision ?? 1, - input.phase ?? 'implement', + input.phase ?? 'analyze', input.status ?? 'queued', input.branch ?? null, input.runnerTokenHash, diff --git a/middleware/test/devplatform/devJobStore.pg.test.ts b/middleware/test/devplatform/devJobStore.pg.test.ts index e3fd25dd..9c105afb 100644 --- a/middleware/test/devplatform/devJobStore.pg.test.ts +++ b/middleware/test/devplatform/devJobStore.pg.test.ts @@ -92,11 +92,14 @@ describe('devplatform/DevJobStore (pg)', { skip: !pgAvailable }, () => { await pool.end(); }); - it('createJob defaults: queued, provision 1, phase implement, api_key', async () => { + it('createJob defaults: queued, provision 1, phase analyze, api_key', async () => { const job = await newQueuedJob(repo.id); assert.equal(job.status, 'queued'); assert.equal(job.provision, 1); - assert.equal(job.phase, 'implement'); + // Every pipeline_mode starts at 'analyze' (transitions.ts's own test suite: + // even collapsed mode begins `analyze → implement`); an explicit `phase` + // override (e.g. the gated-webhook trigger parking at `await_human`) wins. + assert.equal(job.phase, 'analyze'); assert.equal(job.authMode, 'api_key'); // Only the sha256 hash is stored — never the plaintext token. assert.match(job.runnerTokenHash ?? '', /^[0-9a-f]{64}$/); @@ -455,7 +458,7 @@ describe('devplatform/DevJobStore (pg)', { skip: !pgAvailable }, () => { repoId: repo.id, kind: 'fix_issue', brief: 'fence', source: 'admin', backend: 'docker', createdBy: MARK, runnerTokenHash: hash, }); - // Job is at the default phase (implement/analyze), NOT await_human. + // Job is at its default starting phase ('analyze'), NOT await_human. assert.equal(await store.requeueAtPhase(job.id, 'implement'), false, 'a non-parked job is not re-queued'); const still = await store.getJob(job.id); assert.equal(still?.status, 'queued', 'and its status is untouched by the no-op'); @@ -464,7 +467,7 @@ describe('devplatform/DevJobStore (pg)', { skip: !pgAvailable }, () => { const lease = randomUUID(); let claimed = await store.claimNextQueued(lease); while (claimed && claimed.id !== job.id) claimed = await store.claimNextQueued(lease); - await store.advancePhase(job.id, 'implement', 'await_human'); + await store.advancePhase(job.id, job.phase, 'await_human'); await store.parkForGate(job.id); assert.equal(await store.requeueAtPhase(job.id, 'implement'), true, 'a parked job re-queues'); const requeued = await store.getJob(job.id); diff --git a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts index 1d4a96cf..a6c8661a 100644 --- a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts +++ b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts @@ -194,7 +194,7 @@ describe('dev-platform wiring — a real gated job, end to end through the assem source: 'admin', sourceRef: 'gh-issue:1', baseSha: BASE_SHA, - phase: 'analyze', // a gated pipeline starts at analyze (createJob defaults to implement) + phase: 'analyze', // explicit for clarity — matches createJob's own default now backend: 'local', createdBy: MARK, runnerTokenHash: hash, From 886134804068433714367d59e928a8a6bf105258 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 11:57:13 +0200 Subject: [PATCH 16/34] feat(dev-platform): show the live log pane on every phase, not just implement Marcel's ask, prompted by the very first job to actually run analyze (now that job creation defaults there, previous commit): the ANALYZE tab showed "No artifact for this phase yet" with zero visibility into what the agent was doing during a real, running phase -- no peace of mind that anything was happening. analyze/bootstrap/plan/clarify run a real `claude -p` session or the bootstrap command (phaseLoop.ts) and emit the exact same tool/log event shapes implement does. The log pane was implement-only for no structural reason -- toolCallLog.ts just never tracked which phase an event belonged to, so there was no way to filter one flat stream per phase-tab. `toolCallLog.ts`: replaced the bare `LogItem[]` accumulator with a `LogState { items, phase }` that tracks a running phase cursor, updated on every `phase` event (`{phase, state:'start'}` -- always the first event of a provision) and stamped onto every subsequent tool/log item as it's created. A tool call's result keeps the phase its OWN start happened in, not whatever the cursor has moved to by the time the result arrives (new test: phase moves on mid-call, the paired item still reports its start's phase). `INITIAL_LOG_STATE.phase` defaults to 'analyze', matching devJobStore.createJob's own new default. `page.tsx`: `foldDevJobEvent` now folds into `LogState`; the phase tab body renders `JobLogPane` for every phase except `pr` (which keeps its dedicated PR-link view), filtering `logState.items` down to the currently VIEWED phase via the existing `phaseToUi()` mapping. The now-fully-superseded `noArtifact` empty-state (JobLogPane's own "no log yet" message covers it) is removed from both message catalogs. 7 new tests for the phase-cursor behavior (starts at analyze, phase events update the cursor without emitting an item, items are stamped per-phase, result-keeps-start's-phase, an event with no phase field leaves the cursor unchanged); existing pairing tests updated for the LogState shape. Verified: tsc --noEmit clean, eslint clean (pre-existing unrelated warning in DeviceFlowPanel.tsx, untouched by this change), i18n:check clean, `npm run build` succeeds, 42/42 dev-platform vitest files green, 332/337 full suite green (same 5 pre-existing toolTemplates.test.ts failures as every prior commit this session, unrelated file). --- .../_lib/__tests__/toolCallLog.test.ts | 104 ++++++++++++++---- .../admin/dev-platform/_lib/toolCallLog.ts | 64 +++++++---- .../app/admin/dev-platform/jobs/[id]/page.tsx | 23 ++-- web-ui/messages/de.json | 1 - web-ui/messages/en.json | 1 - 5 files changed, 139 insertions(+), 54 deletions(-) diff --git a/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts b/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts index fc3f42af..e4fb36a0 100644 --- a/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts +++ b/web-ui/app/admin/dev-platform/_lib/__tests__/toolCallLog.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import type { DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; -import { foldDevJobEvent, summarizeToolCall, type LogItem, type ToolCallEntry } from '../toolCallLog'; +import { + INITIAL_LOG_STATE, + foldDevJobEvent, + summarizeToolCall, + type LogItem, + type LogState, + type ToolCallEntry, +} from '../toolCallLog'; function ev( id: number, @@ -12,20 +19,32 @@ function ev( return { id, jobId: 'job-1', provision: 1, seq: id, type, ts: '2026-01-01T00:00:00Z', payload }; } +/** Fold a sequence of events onto INITIAL_LOG_STATE and return just the items. */ +function foldItems(...evs: DevJobEventMessage[]): LogItem[] { + return evs.reduce((s: LogState, e) => foldDevJobEvent(s, e), INITIAL_LOG_STATE).items; +} + describe('foldDevJobEvent — tool pairing', () => { - it('appends a pending entry on the start event', () => { - const items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); + it('appends a pending entry on the start event, stamped with the current phase', () => { + const items = foldItems(ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); expect(items).toEqual([ - { kind: 'tool', entry: { id: '1', name: 'Read', status: 'pending', inputPreview: '{"file_path":"a.ts"}' } }, + { + kind: 'tool', + phase: 'analyze', + entry: { id: '1', name: 'Read', status: 'pending', inputPreview: '{"file_path":"a.ts"}' }, + }, ]); }); it('pairs the result event into the matching pending entry', () => { - let items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); - items = foldDevJobEvent(items, ev(2, 'tool', { name: 'Read', ok: true, outputPreview: 'file contents' })); + const items = foldItems( + ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' }), + ev(2, 'tool', { name: 'Read', ok: true, outputPreview: 'file contents' }), + ); expect(items).toEqual([ { kind: 'tool', + phase: 'analyze', entry: { id: '1', name: 'Read', @@ -38,16 +57,20 @@ describe('foldDevJobEvent — tool pairing', () => { }); it('marks status error when ok is false', () => { - let items = foldDevJobEvent([], ev(1, 'tool', { name: 'Bash', inputPreview: '{"command":"false"}' })); - items = foldDevJobEvent(items, ev(2, 'tool', { name: 'Bash', ok: false, outputPreview: 'exit 1' })); + const items = foldItems( + ev(1, 'tool', { name: 'Bash', inputPreview: '{"command":"false"}' }), + ev(2, 'tool', { name: 'Bash', ok: false, outputPreview: 'exit 1' }), + ); expect((items[0] as { kind: 'tool'; entry: ToolCallEntry }).entry.status).toBe('error'); }); it('pairs same-name calls in order (FIFO) rather than the first pending one incorrectly', () => { - let items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' })); - items = foldDevJobEvent(items, ev(2, 'tool', { name: 'Read', ok: true, outputPreview: 'A' })); - items = foldDevJobEvent(items, ev(3, 'tool', { name: 'Read', inputPreview: '{"file_path":"b.ts"}' })); - items = foldDevJobEvent(items, ev(4, 'tool', { name: 'Read', ok: true, outputPreview: 'B' })); + const items = foldItems( + ev(1, 'tool', { name: 'Read', inputPreview: '{"file_path":"a.ts"}' }), + ev(2, 'tool', { name: 'Read', ok: true, outputPreview: 'A' }), + ev(3, 'tool', { name: 'Read', inputPreview: '{"file_path":"b.ts"}' }), + ev(4, 'tool', { name: 'Read', ok: true, outputPreview: 'B' }), + ); const entries = items.map((i) => (i as { kind: 'tool'; entry: ToolCallEntry }).entry); expect(entries).toEqual([ { id: '1', name: 'Read', status: 'ok', inputPreview: '{"file_path":"a.ts"}', outputPreview: 'A' }, @@ -56,31 +79,68 @@ describe('foldDevJobEvent — tool pairing', () => { }); it('renders a standalone result when no matching start exists', () => { - const items = foldDevJobEvent([], ev(1, 'tool', { name: 'Read', ok: true, outputPreview: 'orphan' })); + const items = foldItems(ev(1, 'tool', { name: 'Read', ok: true, outputPreview: 'orphan' })); expect(items).toEqual([ - { kind: 'tool', entry: { id: '1', name: 'Read', status: 'ok', outputPreview: 'orphan' } }, + { kind: 'tool', phase: 'analyze', entry: { id: '1', name: 'Read', status: 'ok', outputPreview: 'orphan' } }, ]); }); }); describe('foldDevJobEvent — log/other events', () => { it('appends agent-stream text', () => { - const items = foldDevJobEvent([], ev(1, 'log', { text: 'thinking…', stream: 'agent' })); - expect(items).toEqual([{ kind: 'text', id: '1', stream: 'agent', text: 'thinking…' }]); + const items = foldItems(ev(1, 'log', { text: 'thinking…', stream: 'agent' })); + expect(items).toEqual([{ kind: 'text', id: '1', phase: 'analyze', stream: 'agent', text: 'thinking…' }]); }); it('routes stderr stream text', () => { - const items = foldDevJobEvent([], ev(1, 'log', { text: 'boom', stream: 'stderr' })); - expect(items).toEqual([{ kind: 'text', id: '1', stream: 'stderr', text: 'boom' }]); + const items = foldItems(ev(1, 'log', { text: 'boom', stream: 'stderr' })); + expect(items).toEqual([{ kind: 'text', id: '1', phase: 'analyze', stream: 'stderr', text: 'boom' }]); }); it('drops empty-text log events', () => { - expect(foldDevJobEvent([], ev(1, 'log', { text: '' }))).toEqual([]); + expect(foldItems(ev(1, 'log', { text: '' }))).toEqual([]); + }); + + it('ignores status/heartbeat events entirely', () => { + expect(foldItems(ev(1, 'status', { state: 'agent_started' }))).toEqual([]); + }); +}); + +describe('foldDevJobEvent — phase cursor', () => { + it('starts at analyze (matches devJobStore.createJob\'s own default)', () => { + expect(INITIAL_LOG_STATE.phase).toBe('analyze'); + }); + + it('a phase event updates the cursor without producing a log item', () => { + const state = foldDevJobEvent(INITIAL_LOG_STATE, ev(1, 'phase', { phase: 'bootstrap', state: 'start' })); + expect(state.phase).toBe('bootstrap'); + expect(state.items).toEqual([]); + }); + + it('stamps subsequent tool/log items with the phase in effect when they arrived', () => { + const items = foldItems( + ev(1, 'log', { text: 'analyzing…', stream: 'agent' }), + ev(2, 'phase', { phase: 'plan', state: 'start' }), + ev(3, 'tool', { name: 'Write', inputPreview: '{"file_path":"plan.md"}' }), + ev(4, 'phase', { phase: 'implement', state: 'start' }), + ev(5, 'log', { text: 'implementing…', stream: 'agent' }), + ); + expect(items.map((i) => i.phase)).toEqual(['analyze', 'plan', 'implement']); + }); + + it('a result event keeps the phase of its own start, even if the phase cursor since moved on', () => { + const items = foldItems( + ev(1, 'tool', { name: 'Bash', inputPreview: '{"command":"echo hi"}' }), + ev(2, 'phase', { phase: 'plan', state: 'start' }), // moves on before the result lands + ev(3, 'tool', { name: 'Bash', ok: true, outputPreview: 'hi' }), + ); + expect(items).toHaveLength(1); + expect(items[0]?.phase).toBe('analyze'); }); - it('ignores status/phase/heartbeat events entirely', () => { - expect(foldDevJobEvent([], ev(1, 'status', { state: 'agent_started' }))).toEqual([]); - expect(foldDevJobEvent([], ev(1, 'phase', { phase: 'implement', state: 'start' }))).toEqual([]); + it('ignores an unset phase field, keeping the previous cursor', () => { + const state = foldDevJobEvent(INITIAL_LOG_STATE, ev(1, 'phase', {})); + expect(state.phase).toBe('analyze'); }); }); diff --git a/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts b/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts index fec0ca9d..c9e5dbcb 100644 --- a/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts +++ b/web-ui/app/admin/dev-platform/_lib/toolCallLog.ts @@ -4,16 +4,22 @@ import { computeLineDiff, type DiffLine } from './lineDiff'; /** * Epic #470 — turns the raw `dev_job_events` SSE tail into renderable log - * items for the job-detail implement-phase pane. Two responsibilities: + * items for EVERY phase's log pane, not just implement (analyze/bootstrap/ + * plan/clarify each run a real `claude -p` session or the bootstrap command, + * emitting the same event shapes — see `phaseLoop.ts`). Three responsibilities: * - * 1. `foldDevJobEvent` pairs a tool call's two independent wire events - * (`{name, inputPreview}` at start, `{ok, name, outputPreview}` at - * result — no shared correlation id) into one `ToolCallEntry`, by - * walking back to the nearest still-pending entry with the same tool - * name. Safe for the single-threaded CLI agent loop this feeds from: a - * tool cannot start a second call under the same name before the first - * resolves. - * 2. `summarizeToolCall` turns a paired entry's raw JSON `inputPreview` + * 1. `foldDevJobEvent` tracks a running "current phase" cursor, updated on + * each `phase` event (`{phase, state:'start'}` — always the first event + * of a provision), and stamps every subsequent tool/log item with it, so + * the UI can filter the single flat event stream down to one phase's + * view without a second query. + * 2. It also pairs a tool call's two independent wire events (`{name, + * inputPreview}` at start, `{ok, name, outputPreview}` at result — no + * shared correlation id) into one `ToolCallEntry`, by walking back to + * the nearest still-pending entry with the same tool name. Safe for the + * single-threaded CLI agent loop this feeds from: a tool cannot start a + * second call under the same name before the first resolves. + * 3. `summarizeToolCall` turns a paired entry's raw JSON `inputPreview` * into a one-line headline + a tool-shaped detail (a diff for `Edit`, * a command for `Bash`, etc.) instead of the previous `$ Name {...raw * JSON...}` dump. Unknown tool names fall back to the raw @@ -31,23 +37,36 @@ export interface ToolCallEntry { } export type LogItem = - | { kind: 'text'; id: string; stream: 'agent' | 'stderr'; text: string } - | { kind: 'tool'; entry: ToolCallEntry }; + | { kind: 'text'; id: string; phase: string; stream: 'agent' | 'stderr'; text: string } + | { kind: 'tool'; phase: string; entry: ToolCallEntry }; -export function foldDevJobEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { - if (ev.type === 'tool') return foldToolEvent(items, ev); - if (ev.type === 'log') return foldLogEvent(items, ev); - return items; +export interface LogState { + items: LogItem[]; + /** The most recently started phase — new tool/log items are stamped with this. */ + phase: string; } -function foldToolEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { +/** `'analyze'` matches devJobStore.createJob's own default starting phase. */ +export const INITIAL_LOG_STATE: LogState = { items: [], phase: 'analyze' }; + +export function foldDevJobEvent(state: LogState, ev: DevJobEventMessage): LogState { + if (ev.type === 'phase') { + const phase = typeof ev.payload['phase'] === 'string' ? ev.payload['phase'] : state.phase; + return { ...state, phase }; + } + if (ev.type === 'tool') return { ...state, items: foldToolEvent(state.items, ev, state.phase) }; + if (ev.type === 'log') return { ...state, items: foldLogEvent(state.items, ev, state.phase) }; + return state; +} + +function foldToolEvent(items: LogItem[], ev: DevJobEventMessage, phase: string): LogItem[] { const p = ev.payload; const name = typeof p['name'] === 'string' ? p['name'] : 'tool'; const hasResult = typeof p['ok'] === 'boolean'; if (!hasResult) { const inputPreview = typeof p['inputPreview'] === 'string' ? p['inputPreview'] : undefined; - return [...items, { kind: 'tool', entry: { id: String(ev.id), name, status: 'pending', inputPreview } }]; + return [...items, { kind: 'tool', phase, entry: { id: String(ev.id), name, status: 'pending', inputPreview } }]; } const ok = p['ok'] === true; @@ -57,22 +76,25 @@ function foldToolEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { // No matching start (e.g. a reconnect landed mid-call) — render standalone. return [ ...items, - { kind: 'tool', entry: { id: String(ev.id), name, status: ok ? 'ok' : 'error', outputPreview } }, + { kind: 'tool', phase, entry: { id: String(ev.id), name, status: ok ? 'ok' : 'error', outputPreview } }, ]; } const target = items[idx]; if (!target || target.kind !== 'tool') return items; const next = items.slice(); - next[idx] = { kind: 'tool', entry: { ...target.entry, status: ok ? 'ok' : 'error', outputPreview } }; + next[idx] = { kind: 'tool', phase: target.phase, entry: { ...target.entry, status: ok ? 'ok' : 'error', outputPreview } }; return next; } -function foldLogEvent(items: LogItem[], ev: DevJobEventMessage): LogItem[] { +function foldLogEvent(items: LogItem[], ev: DevJobEventMessage, phase: string): LogItem[] { const p = ev.payload; const text = typeof p['text'] === 'string' ? p['text'] : ''; if (!text) return items; - return [...items, { kind: 'text', id: String(ev.id), stream: p['stream'] === 'stderr' ? 'stderr' : 'agent', text }]; + return [ + ...items, + { kind: 'text', id: String(ev.id), phase, stream: p['stream'] === 'stderr' ? 'stderr' : 'agent', text }, + ]; } function findLastPending(items: LogItem[], name: string): number { diff --git a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx index 500307f3..6a534556 100644 --- a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx +++ b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx @@ -13,21 +13,24 @@ import { DEV_JOB_UI_PHASES, DevJobPhaseRail, computePhaseStops, + phaseToUi, statusIsLive, type DevJobUiPhase, } from '@/app/_components/devjobs/DevJobPhaseRail'; import { useDevJobEvents, type DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; import { JobLogPane, type LogConnection } from '../../_components/JobLogPane'; import { cancelJob, deleteJob, getJob, isTerminalStatus, type DevJobView } from '../../_lib/api'; -import { foldDevJobEvent, type LogItem } from '../../_lib/toolCallLog'; +import { INITIAL_LOG_STATE, foldDevJobEvent, type LogState } from '../../_lib/toolCallLog'; /** * Epic #470 W0 — the job-detail signature screen (UI spec §5). Header, the * phase rail (keyboard-operable, deep-linkable via `?phase=`), then a two-column * body: the log pane (driven by rail selection) and a metadata sidebar. The * live log streams over SSE through `useDevJobEvents` and sticks to bottom via - * `useStickToBottom`. W0 is minimal: only the `implement` phase has a live-log - * pane; other phases show "no artifact yet" (W2 fills them in). + * `useStickToBottom`. Every non-`pr` phase gets the same live log pane, + * filtered to that phase's own events (`toolCallLog.ts` stamps each item with + * the phase it happened in) — analyze/bootstrap/plan/clarify run real agent + * sessions too, not just implement. */ function shortHash(id: string): string { @@ -43,7 +46,7 @@ export default function JobDetailPage(): React.ReactElement { const [job, setJob] = useState(null); const [notFound, setNotFound] = useState(false); - const [items, setItems] = useState([]); + const [logState, setLogState] = useState(INITIAL_LOG_STATE); const [conn, setConn] = useState('reconnecting'); const [lastEventAt, setLastEventAt] = useState(null); const [agoSec, setAgoSec] = useState(null); @@ -67,7 +70,7 @@ export default function JobDetailPage(): React.ReactElement { const handleEvent = useCallback((ev: DevJobEventMessage) => { setLastEventAt(Date.now()); - setItems((prev) => foldDevJobEvent(prev, ev)); + setLogState((prev) => foldDevJobEvent(prev, ev)); if (ev.type === 'status' || ev.type === 'phase') { // Re-sync the authoritative view on lifecycle transitions. void getJob(ev.jobId).then( @@ -174,9 +177,7 @@ export default function JobDetailPage(): React.ReactElement { {/* Body */}
diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index bdc7f00e..bd4a6c83 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -3212,7 +3212,6 @@ "pr": "pr" }, "phaseSkipped": "übersprungen — keine Fragen", - "noArtifact": "Für diese Phase gibt es noch kein Artefakt.", "toolCall": { "pending": "läuft", "failed": "fehlgeschlagen", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 9b75f21c..9127afaf 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -3212,7 +3212,6 @@ "pr": "pr" }, "phaseSkipped": "skipped — no questions", - "noArtifact": "No artifact for this phase yet.", "toolCall": { "pending": "running", "failed": "failed", From 9c7654134b779054799bb721132e42fefd7e0c49 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 12:22:08 +0200 Subject: [PATCH 17/34] fix(dev-platform): auto-detect a bootstrap command instead of hard-failing Root cause of "Error while bootstrapping?!" (job a3479368, byte5ai/omadia): phaseRunner.ts's runBootstrap() hard-failed with "no bootstrap command provisioned for this repo" whenever spec.bootstrap was absent -- which it always is for a repo with no dev_repos.bootstrap_command configured (omadia has none set). This made the entire bootstrap phase a guaranteed dead end for any repo without an explicit override, now that jobs actually reach it (previous commit fixed the phase-default bug that made this visible for the first time). Traced the full wiring first: `GET /jobs/:id/spec` (devRunnerApi.ts:404, what the runner itself fetches) ALREADY correctly threads repo.bootstrapCommand into spec.bootstrap when set -- that path was fine. The gap is specifically "repo has no bootstrap_command AND the shim gives up immediately" -- exactly the "null = auto-detect at runtime" case the type's own doc comment (types.ts:325) names but nothing implemented. (Note: an earlier version of this fix touched deriveJobPolicy.ts / devRunnerJobPolicyRoute.ts, a completely different daemon-facing policy path nothing consumes for bootstrap purposes -- reverted once the real /spec route was confirmed already correct, to keep this change minimal.) Fix, entirely shim-side (the middleware has no filesystem to inspect before the repo is cloned -- only the runner, once the workspace exists, can look): - New bootstrapDetect.ts: a small pure function mapping the cloned repo ROOT's directory listing to a install command (npm ci / yarn / pnpm / pip / pipenv / cargo / go, lockfile-over-manifest priority), or null. Root-only by design -- a monorepo with per-workspace manifests and no root manifest (omadia's own layout: middleware/package.json + web-ui/package.json, nothing at root) won't match anything here, and that's intentional rather than a guessed multi-directory heuristic. - phaseRunner.ts's runBootstrap(): explicit spec.bootstrap.command still wins; absent that, auto-detect from repoDir; absent BOTH, report ok:true with a `{command:null, skipped:true}` artifact instead of failing the whole pipeline -- not every repo needs a distinct install step, and an undetectable one is not itself an error. For omadia specifically: auto-detect finds nothing (no root manifest), so bootstrap now gracefully skips rather than hard-failing -- unblocking the pipeline. It does NOT install omadia's actual deps (a genuine per-workspace `cd middleware && npm ci && cd ../web-ui && npm ci`, which the analyze phase's own agent already derives and can run itself during implement). Configuring an explicit bootstrap_command for this repo is a separate, optional follow-up if pre-installing before implement starts is wanted -- deliberately not done here to keep this fix to the platform-level gap only. New tests: bootstrapDetect.test.ts (13 cases covering every manager, lockfile-priority, empty/unrecognized dirs, subdirectory-manifests are correctly NOT detected). phaseLoop.test.ts +2 end-to-end cases (auto-detect actually executes with repoDir as cwd; nothing detectable skips gracefully). Full shim suite 67/67 green. tsc --noEmit clean, eslint clean, `npm run build` succeeds. --- .../dev-runner-shim/src/bootstrapDetect.ts | 41 ++++++++++++ .../dev-runner-shim/src/phaseRunner.ts | 36 ++++++++--- .../test/bootstrapDetect.test.ts | 62 +++++++++++++++++++ .../dev-runner-shim/test/phaseLoop.test.ts | 56 ++++++++++++++++- 4 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 middleware/packages/dev-runner-shim/src/bootstrapDetect.ts create mode 100644 middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts diff --git a/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts b/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts new file mode 100644 index 00000000..2b303309 --- /dev/null +++ b/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts @@ -0,0 +1,41 @@ +/** + * Epic #470 W2 — auto-detect a dependency-install command when the repo has no + * explicit `bootstrap_command` configured (`types.ts`'s own doc comment: "null + * = auto-detect at runtime"). Runs shim-side, not server-side: the middleware + * derives job policy before the repo is even cloned, so it has no filesystem to + * inspect — only the runner, once the workspace exists, can look. + * + * Root-level only: this looks at the CLONED REPO ROOT's own manifest/lockfile, + * not any subdirectory. A monorepo with per-workspace-directory manifests (no + * root `package.json`, e.g. `middleware/package.json` + `web-ui/package.json` + * with nothing at root) will not match anything here — that's intentional + * (see `detectBootstrapCommand`'s doc comment) rather than guessing which + * subdirectories matter; those repos need an explicit `bootstrap_command`. + */ + +const CHECKS: readonly { file: string; command: string }[] = [ + { file: 'package-lock.json', command: 'npm ci' }, + { file: 'npm-shrinkwrap.json', command: 'npm ci' }, + { file: 'yarn.lock', command: 'yarn install --frozen-lockfile' }, + { file: 'pnpm-lock.yaml', command: 'pnpm install --frozen-lockfile' }, + { file: 'package.json', command: 'npm install' }, + { file: 'requirements.txt', command: 'pip install -r requirements.txt' }, + { file: 'Pipfile', command: 'pipenv install' }, + { file: 'Cargo.toml', command: 'cargo fetch' }, + { file: 'go.mod', command: 'go mod download' }, +]; + +/** + * `entries` is the repo root's directory listing. Returns the first matching + * command in priority order (a lockfile beats its manifest — `npm ci` over + * `npm install` when both `package-lock.json` and `package.json` are present), + * or `null` when nothing recognizable is there — not every repo needs a + * distinct install step, and an undetectable one is not itself a failure. + */ +export function detectBootstrapCommand(entries: readonly string[]): string | null { + const present = new Set(entries); + for (const check of CHECKS) { + if (present.has(check.file)) return check.command; + } + return null; +} diff --git a/middleware/packages/dev-runner-shim/src/phaseRunner.ts b/middleware/packages/dev-runner-shim/src/phaseRunner.ts index fb456470..f966f5cd 100644 --- a/middleware/packages/dev-runner-shim/src/phaseRunner.ts +++ b/middleware/packages/dev-runner-shim/src/phaseRunner.ts @@ -9,12 +9,13 @@ */ import { spawn } from 'node:child_process'; -import { lstat, mkdir, readFile, realpath } from 'node:fs/promises'; +import { lstat, mkdir, readdir, readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { HomeError } from './homeClient.js'; import { runGit, type GitOptions } from './gitOps.js'; import { runAgent } from './agentRunner.js'; +import { detectBootstrapCommand } from './bootstrapDetect.js'; import { buildPhasePrompt, PHASE_ARTIFACT_ENV, phaseWritesArtifactFile } from './phasePrompts.js'; import { isAgentSessionPhase, @@ -149,15 +150,24 @@ export class PhaseRunner { }; } - /** bootstrap — dependency install as a COMMAND (spec §4), not a CLI session. */ + /** bootstrap — dependency install as a COMMAND (spec §4), not a CLI session. + * An explicit `spec.bootstrap.command` always wins; absent that, auto-detect + * from the cloned repo root (`bootstrapDetect.ts`). Nothing explicit AND + * nothing detectable is not itself a failure — many repos have no separate + * install step — so bootstrap reports `ok: true` and moves on. */ private async runBootstrap(): Promise { - const boot = this.c.spec.bootstrap; - if (!boot?.command) { - return { phase: 'bootstrap', ok: false, error: 'no bootstrap command provisioned for this repo' }; + const explicit = this.c.spec.bootstrap?.command; + const command = explicit ?? (await this.detectBootstrapCommandAtRoot()); + if (!command) { + return { + phase: 'bootstrap', + ok: true, + artifact: { kind: 'bootstrap_report', content: JSON.stringify({ command: null, skipped: true }) }, + }; } - const timeoutMs = boot.timeoutMs ?? DEV_BOOTSTRAP_TIMEOUT_MS; + const timeoutMs = this.c.spec.bootstrap?.timeoutMs ?? DEV_BOOTSTRAP_TIMEOUT_MS; const started = Date.now(); - const result = await runCommand(boot.command, { + const result = await runCommand(command, { cwd: this.c.repoDir, env: bootstrapEnv(this.c.env.workspace), timeoutMs, @@ -165,7 +175,8 @@ export class PhaseRunner { }); const durationMs = Date.now() - started; const report = JSON.stringify({ - command: boot.command, + command, + detected: explicit === undefined, exitCode: result.code, timedOut: result.timedOut, durationMs, @@ -183,6 +194,15 @@ export class PhaseRunner { return { phase: 'bootstrap', ok: true, artifact: { kind: 'bootstrap_report', content: report } }; } + private async detectBootstrapCommandAtRoot(): Promise { + try { + const entries = await readdir(this.c.repoDir); + return detectBootstrapCommand(entries); + } catch { + return null; + } + } + /** Spawn a fresh `claude -p` session with a FRESH per-phase HOME (no session * state bleeds between phases) and the phase prompt on STDIN. */ private async runSession( diff --git a/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts b/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts new file mode 100644 index 00000000..40762dae --- /dev/null +++ b/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { detectBootstrapCommand } from '../src/bootstrapDetect.js'; + +describe('detectBootstrapCommand', () => { + it('returns null for an empty directory — not every repo needs a bootstrap step', () => { + assert.equal(detectBootstrapCommand([]), null); + }); + + it('returns null when nothing recognizable is present', () => { + assert.equal(detectBootstrapCommand(['README.md', 'src', '.git']), null); + }); + + it('detects npm ci from package-lock.json', () => { + assert.equal(detectBootstrapCommand(['package.json', 'package-lock.json']), 'npm ci'); + }); + + it('detects npm ci from npm-shrinkwrap.json', () => { + assert.equal(detectBootstrapCommand(['package.json', 'npm-shrinkwrap.json']), 'npm ci'); + }); + + it('detects yarn from yarn.lock', () => { + assert.equal(detectBootstrapCommand(['package.json', 'yarn.lock']), 'yarn install --frozen-lockfile'); + }); + + it('detects pnpm from pnpm-lock.yaml', () => { + assert.equal(detectBootstrapCommand(['package.json', 'pnpm-lock.yaml']), 'pnpm install --frozen-lockfile'); + }); + + it('falls back to npm install for a bare package.json with no lockfile', () => { + assert.equal(detectBootstrapCommand(['package.json']), 'npm install'); + }); + + it('prefers a lockfile over the bare manifest when both are present', () => { + assert.equal(detectBootstrapCommand(['package.json', 'package-lock.json', 'yarn.lock']), 'npm ci'); + }); + + it('detects pip from requirements.txt', () => { + assert.equal(detectBootstrapCommand(['requirements.txt']), 'pip install -r requirements.txt'); + }); + + it('detects pipenv from Pipfile', () => { + assert.equal(detectBootstrapCommand(['Pipfile']), 'pipenv install'); + }); + + it('detects cargo from Cargo.toml', () => { + assert.equal(detectBootstrapCommand(['Cargo.toml']), 'cargo fetch'); + }); + + it('detects go modules from go.mod', () => { + assert.equal(detectBootstrapCommand(['go.mod']), 'go mod download'); + }); + + it('does not detect a manifest sitting in a subdirectory — root only', () => { + // Directory listings are flat (one level), so this case is really "the + // caller only passed root entries" — documented behavior, not a bug to + // fix here: a monorepo with per-workspace manifests needs an explicit + // bootstrap_command (see this module's doc comment). + assert.equal(detectBootstrapCommand(['middleware', 'web-ui', 'README.md']), null); + }); +}); diff --git a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts index 5102c6d5..14b9dbb4 100644 --- a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts +++ b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts @@ -8,7 +8,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import { strict as assert } from 'node:assert'; -import { mkdtemp, rm, writeFile, chmod, readdir, readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile, chmod, readdir, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -268,6 +268,60 @@ describe('runPhasedShim — bootstrap runs as a command, not a CLI session', () const homes = await listSessionHomes(); assert.deepEqual(homes, [], 'bootstrap starts no agent session'); }); + + it('auto-detects a command from the cloned repo root when none is provisioned', async () => { + // repoDir is `/repo` (gitOps.ts REPO_DIRNAME) — pre-seed it + // before the fake clone step runs; clone only adds `.git`, it never wipes + // the directory, so this file is still there when bootstrap reads it. + const repoDir = path.join(ws, 'repo'); + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, 'package-lock.json'), '{}'); + // The detected command runs with repoDir as cwd — prove that by having it + // write a marker INSIDE repoDir via a real shell command substituted in + // for the real package manager (this test only proves detection + exec, + // not that npm itself is installed in the test sandbox). + await writeFile(path.join(repoDir, 'npm'), `#!${process.execPath}\nrequire('fs').writeFileSync('bootstrap-detected-ran', '');\n`); + await chmod(path.join(repoDir, 'npm'), 0o755); + + const spec = makeSpec({ phaseContext: { phase: 'bootstrap' } }); // no explicit `bootstrap` field + const home = new ScriptedHome(spec, [{ directive: 'done' }]); + // bootstrapEnv() (phaseRunner.ts) reads PATH from the real process env at + // call time — prepend repoDir so the detected `npm ci` resolves to our fake + // npm, then restore it so this doesn't leak into other tests. + const originalPath = process.env['PATH']; + process.env['PATH'] = `${repoDir}:${originalPath ?? ''}`; + let code: number; + try { + code = await runPhasedShim(env, { home, gitBin, log: () => {} }); + } finally { + process.env['PATH'] = originalPath; + } + assert.equal(code, 0); + + const boot = home.phaseResults[0]; + assert.equal(boot?.phase, 'bootstrap'); + assert.equal(boot?.ok, true); + assert.match(boot?.artifact?.content ?? '', /"command":"npm ci"/, 'detected npm ci from package-lock.json'); + assert.match(boot?.artifact?.content ?? '', /"detected":true/); + const ran = await readFile(path.join(repoDir, 'bootstrap-detected-ran'), 'utf8').then(() => true).catch(() => false); + assert.ok(ran, 'the auto-detected command actually ran with repoDir as cwd'); + }); + + it('skips gracefully (ok:true) when nothing is provisioned and nothing is detectable', async () => { + const repoDir = path.join(ws, 'repo'); + await mkdir(repoDir, { recursive: true }); // empty — no manifest of any kind + + const spec = makeSpec({ phaseContext: { phase: 'bootstrap' } }); + const home = new ScriptedHome(spec, [{ directive: 'done' }]); + const code = await runPhasedShim(env, { home, gitBin, log: () => {} }); + assert.equal(code, 0); + + const boot = home.phaseResults[0]; + assert.equal(boot?.phase, 'bootstrap'); + assert.equal(boot?.ok, true, 'an undetectable bootstrap is a skip, not a failure'); + assert.match(boot?.artifact?.content ?? '', /"command":null/); + assert.match(boot?.artifact?.content ?? '', /"skipped":true/); + }); }); describe('runPhasedShim — W1 LLM auth passthrough (the docker backend\'s real path)', () => { From 110e63aa18a46130b8eae5edf89aae5aa13decfe Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 12:57:11 +0200 Subject: [PATCH 18/34] fix(dev-platform): populate dev_jobs.error on gated-pipeline phase failures Root cause, found by a background agent that won the AutoRemove log-capture race (docker events + docker logs armed before triggering a fresh reproduction): job a3479368's bootstrap-phase crash was never a shim/daemon bug. phaseRunner.ts correctly reported {ok:false, error:'no bootstrap command provisioned for this repo'} -- byte5ai/omadia genuinely has no bootstrap_command configured, a legitimate config gap, not an infra defect. The actual defect: PhaseEngine.finalize's adapter in wireDevPlatform.ts passed the failure reason as FinalizeContext.reason only: finalize: (jobId, status, reason) => boundFinalize(jobId, status, reason !== undefined ? { reason } : undefined) FinalizeContext has two distinct fields -- `reason` (status event payload only) and `error` (dev_jobs.error column, finalizeDevJob.ts:135) -- and the adapter's own comment even named `reason` landing "in the status event payload" without noticing it therefore never reaches the job row. Every event trail carried the real reason; dev_jobs.error was always NULL for every gated-pipeline phase failure, this bootstrap crash included -- which is exactly why this investigation kept hitting an empty error column no matter how the failure was triggered. Fix: pass `error: reason` alongside `reason` in the same adapter call, so FinalizeContext gets both. The background agent also found a second, structural bug in the same area (finalizeDevJob.ts awaits container teardown via the daemon BEFORE the runner's phase-result HTTP response is sent, so the runner is always killed by external SIGTERM before it can react to its own directive) -- NOT fixed here. It's a design question (should the finalize choke point used by every terminal path -- stall, wall-clock, cancel, reaper -- guarantee the runner gets to react first, or is racing it against teardown acceptable?) rather than a small, obviously-safe change, and is being tracked separately. New regression test in devPlatformPipeline.wire.pg.test.ts, against the real wired platform (not mocked): posts an ok:false gated phase-result and asserts dev_jobs.error carries the real reason, not just the event payload. Confirmed it fails against the pre-fix adapter (actual: null) and passes with the fix. Verified against the live omadia-dev Postgres: devPlatformPipeline.wire.pg .test.ts 5/5, full test/devplatform/*.pg.test.ts 82/82, full test/devplatform/*.test.ts 566/566. tsc --noEmit clean, eslint clean, `npm run build` succeeds. --- middleware/src/devplatform/wireDevPlatform.ts | 10 ++++-- .../devPlatformPipeline.wire.pg.test.ts | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/middleware/src/devplatform/wireDevPlatform.ts b/middleware/src/devplatform/wireDevPlatform.ts index 2d2bdd78..95f50079 100644 --- a/middleware/src/devplatform/wireDevPlatform.ts +++ b/middleware/src/devplatform/wireDevPlatform.ts @@ -457,9 +457,15 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform // PhaseEngine's terminal choke point is the SAME boundFinalize every other // path uses — so a phase-driven fail/done revokes the job's scoped tokens too. // Adapt the signature: the engine passes a bare `reason`, boundFinalize takes - // a FinalizeContext (`reason` lands in the status event payload). + // a FinalizeContext. `reason` lands in the status event payload (`ctx.reason`) + // AND `dev_jobs.error` (`ctx.error`) — two distinct FinalizeContext fields; + // populating only `reason` left `dev_jobs.error` silently empty on every + // gated-pipeline phase failure (the real reason was only ever visible in the + // event trail, never on the job row itself). finalize: (jobId, status, reason) => - boundFinalize(jobId, status, reason !== undefined ? { reason } : undefined).then(() => undefined), + boundFinalize(jobId, status, reason !== undefined ? { reason, error: reason } : undefined).then( + () => undefined, + ), // A parked runner is exiting: revoke its scoped token WITHOUT finalizing the // still-`waiting` job. Same registry revoker the terminal paths use. revokeTokensForPark: async (job) => { diff --git a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts index a6c8661a..715bd42e 100644 --- a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts +++ b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts @@ -302,6 +302,40 @@ describe('dev-platform wiring — a real gated job, end to end through the assem assert.equal(res.status, 409, 'a stale phase result is rejected'); }); + it('a gated phase failure populates dev_jobs.error, not just the status event payload', async () => { + // Regression: found live when bootstrap correctly reported ok:false (no + // bootstrap_command configured for the repo) — dev_jobs.error stayed empty + // because the PhaseEngine→boundFinalize adapter (wireDevPlatform.ts) only + // ever passed the reason as FinalizeContext.reason (→ the status event + // payload), never as FinalizeContext.error (→ the dev_jobs.error column). + const BASE_SHA = 'basesha-failreason'; + const { hash } = mintRunnerToken(); + const job = await wired.jobStore.createJob({ + repoId, + kind: 'fix_issue', + brief: 'a job whose analyze phase fails', + source: 'admin', + sourceRef: null, + baseSha: BASE_SHA, + phase: 'analyze', + backend: 'local', + createdBy: MARK, + runnerTokenHash: hash, + }); + const token = await provision(job.id, BASE_SHA); + assert.equal(await getSpec(job.id, token), 200); + + const REASON = 'no bootstrap command provisioned for this repo'; + assert.deepEqual(await postPhase(job.id, token, { phase: 'analyze', ok: false, error: REASON }), { + directive: 'failed', + reason: REASON, + }); + + const failed = await wired.jobStore.getJob(job.id); + assert.equal(failed?.status, 'failed'); + assert.equal(failed?.error, REASON, 'the real failure reason lands on the job row, not just the event trail'); + }); + it('the gate-deadline worker expires an overdue gate and cancels the job (reason gate_expired), revoking its token', async () => { const BASE_SHA = 'basesha-expire'; const { hash } = mintRunnerToken(); From 31124acb0f2d7dfd80b4e176840380caf94ba04d Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 13:04:32 +0200 Subject: [PATCH 19/34] fix(dev-platform): don't run npm ci from a root lockfile with no package.json Found live against byte5ai/omadia's real repo root, immediately after the previous two fixes finally let bootstrap run cleanly enough to expose it: `bootstrap exited with code 254`. dev_jobs.error (now populated, per the prior commit) named the real reason -- the previous commit's detectBootstrapCommand matched on package-lock.json ALONE and returned `npm ci`, but omadia's actual root has that lockfile (an 87-byte empty-packages stub, left over from before the repo moved to per-workspace- directory manifests -- middleware/package.json, web-ui/package.json, nothing at root) with NO matching package.json. `npm ci` fundamentally requires both files; running it against a lockfile alone fails outright. Fix: gate all npm-family lockfile checks (package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml) behind package.json being present first -- a lockfile alone is not installable evidence, only a manifest+lockfile pair is. Non-npm ecosystem checks (requirements.txt, Pipfile, Cargo.toml, go.mod) are unaffected -- each file already fully signals its own ecosystem with no separate-manifest ambiguity. For omadia specifically: with this fix, detectBootstrapCommand now correctly returns null at its root (no package.json there at all), so bootstrap gracefully skips exactly as designed -- the two prior commits' graceful-skip path was already correct, this one just stops the detector from producing a bad command in the first place. New tests: two regression cases (lockfile without package.json for npm/yarn/pnpm all return null). Existing phaseLoop.test.ts end-to-end auto-detect test updated to seed BOTH package.json and package-lock.json (its original intent -- a real detectable npm project), since it was unknowingly exercising the exact buggy case this fix closes. Full shim suite 69/69 green (was 67, +2 new cases). tsc --noEmit clean, eslint clean, `npm run build` succeeds. Live-verified against the actual failure: rebuilding+reloading the runner image under the correct tag (`omadia-dev-runner:latest` -- the daemon's actual configured DEV_RUNNER_DEFAULT_IMAGE, NOT the ghcr.io/byte5ai/-prefixed default the compose file's own comment implies; earlier rebuilds this session went to the wrong tag and were silently never used) reproduced exit 254 exactly as described before this fix. --- .../dev-runner-shim/src/bootstrapDetect.ts | 32 +++++++++++++++---- .../test/bootstrapDetect.test.ts | 15 +++++++++ .../dev-runner-shim/test/phaseLoop.test.ts | 4 +++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts b/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts index 2b303309..d1e96799 100644 --- a/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts +++ b/middleware/packages/dev-runner-shim/src/bootstrapDetect.ts @@ -13,12 +13,23 @@ * subdirectories matter; those repos need an explicit `bootstrap_command`. */ -const CHECKS: readonly { file: string; command: string }[] = [ +/** npm-family lockfiles, checked ONLY when `package.json` is also present (see + * `detectBootstrapCommand`) — a lockfile alone is not installable: `npm ci` + * requires both files and fails outright without a manifest. Found live: a + * stray root `package-lock.json` (an 87-byte empty-packages stub, left over + * from before this repo moved to per-workspace-directory manifests) with no + * matching `package.json` made the old file-alone check run `npm ci` anyway + * and fail with exit 254. */ +const NPM_LOCKFILE_CHECKS: readonly { file: string; command: string }[] = [ { file: 'package-lock.json', command: 'npm ci' }, { file: 'npm-shrinkwrap.json', command: 'npm ci' }, { file: 'yarn.lock', command: 'yarn install --frozen-lockfile' }, { file: 'pnpm-lock.yaml', command: 'pnpm install --frozen-lockfile' }, - { file: 'package.json', command: 'npm install' }, +]; + +/** Checks with no `package.json`-style prerequisite — each file IS the whole + * signal for its ecosystem. */ +const STANDALONE_CHECKS: readonly { file: string; command: string }[] = [ { file: 'requirements.txt', command: 'pip install -r requirements.txt' }, { file: 'Pipfile', command: 'pipenv install' }, { file: 'Cargo.toml', command: 'cargo fetch' }, @@ -27,14 +38,21 @@ const CHECKS: readonly { file: string; command: string }[] = [ /** * `entries` is the repo root's directory listing. Returns the first matching - * command in priority order (a lockfile beats its manifest — `npm ci` over - * `npm install` when both `package-lock.json` and `package.json` are present), - * or `null` when nothing recognizable is there — not every repo needs a - * distinct install step, and an undetectable one is not itself a failure. + * command in priority order (a lockfile beats the bare manifest — `npm ci` + * over `npm install` when both `package-lock.json` and `package.json` are + * present), or `null` when nothing recognizable is there — not every repo + * needs a distinct install step, and an undetectable one is not itself a + * failure. */ export function detectBootstrapCommand(entries: readonly string[]): string | null { const present = new Set(entries); - for (const check of CHECKS) { + if (present.has('package.json')) { + for (const check of NPM_LOCKFILE_CHECKS) { + if (present.has(check.file)) return check.command; + } + return 'npm install'; + } + for (const check of STANDALONE_CHECKS) { if (present.has(check.file)) return check.command; } return null; diff --git a/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts b/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts index 40762dae..c21af48b 100644 --- a/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts +++ b/middleware/packages/dev-runner-shim/test/bootstrapDetect.test.ts @@ -52,6 +52,21 @@ describe('detectBootstrapCommand', () => { assert.equal(detectBootstrapCommand(['go.mod']), 'go mod download'); }); + it('does not run npm ci from a lockfile with no matching package.json', () => { + // Regression: found live against byte5ai/omadia's actual repo root — a + // stray, empty-packages package-lock.json survives from before the repo + // moved to per-workspace-directory manifests (middleware/package.json, + // web-ui/package.json), with no root package.json at all. `npm ci` + // fundamentally requires both files; running it anyway failed with a + // real, reported exit code (254) instead of gracefully skipping. + assert.equal(detectBootstrapCommand(['package-lock.json', 'README.md']), null); + }); + + it('does not run yarn/pnpm from a lockfile with no matching package.json either', () => { + assert.equal(detectBootstrapCommand(['yarn.lock']), null); + assert.equal(detectBootstrapCommand(['pnpm-lock.yaml']), null); + }); + it('does not detect a manifest sitting in a subdirectory — root only', () => { // Directory listings are flat (one level), so this case is really "the // caller only passed root entries" — documented behavior, not a bug to diff --git a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts index 14b9dbb4..ea0fff2d 100644 --- a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts +++ b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts @@ -275,6 +275,10 @@ describe('runPhasedShim — bootstrap runs as a command, not a CLI session', () // the directory, so this file is still there when bootstrap reads it. const repoDir = path.join(ws, 'repo'); await mkdir(repoDir, { recursive: true }); + // A lockfile alone is not enough (bootstrapDetect.ts requires package.json + // too — `npm ci` needs both, found live as a real crash against a repo + // with a stray root lockfile and no root manifest). + await writeFile(path.join(repoDir, 'package.json'), '{}'); await writeFile(path.join(repoDir, 'package-lock.json'), '{}'); // The detected command runs with repoDir as cwd — prove that by having it // write a marker INSIDE repoDir via a real shell command substituted in From 95c6755318a5621166fbe81ad3317ca0b5b3650f Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 14:02:19 +0200 Subject: [PATCH 20/34] feat(dev-platform): resolve the plan gate inline on the job page, no tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marcel's ask: the GATE step showed only "No log output yet" with no way to act — approving a plan required navigating to a separate Freigaben (Approvals) tab, losing the job's own context. Then: show the plan content itself inline too, not just a link to open it elsewhere. `GateInbox.tsx`'s `GateCard` already had all the resolve logic (questions, note, approve/reject, holder/conflict/error states) -- reused rather than duplicated: - Exported it with a new `compact` prop (drops the job-id header and the outer bordered card; the job-detail page already shows both). - Added inline plan-content fetching: a new `getArtifactText()` API helper (`GET /artifacts/:id` returns text/plain, the existing `req()` JSON wrapper doesn't fit) plus a small state machine (loading/ready/error/none) rendered as a scrollable pre block. The "Plan ansehen" link stays as a secondary affordance (new tab, full view) alongside the inline preview. `page.tsx`: fetches the job's own waiting gate the same way `DevJobChatCard.tsx` already does for the chat surface (`listWaitingGates()` + `findGateForJob()`), and renders `` on the GATE phase stop instead of the (always-empty, since nothing runs during the human wait) log pane. Falls back to the log pane if the gate hasn't loaded yet. On resolve, refetches the job so phase/status flip correctly and the gate view naturally clears. Both fetch effects derive their "reset" value (`gate`/`planText` = null/none when the precondition isn't met) instead of calling setState synchronously in the effect's early-return branch, avoiding react-hooks/set-state-in-effect entirely for the gate fetch; one remaining warning on the plan-fetch's "kick off loading" line matches an existing, already-accepted pattern elsewhere in this codebase (DeviceFlowPanel.tsx) -- not introduced fresh here, not a new class of issue. New i18n keys (planLoading/planLoadError) in both en.json/de.json per this project's hard i18n rule. Verified: tsc --noEmit clean, eslint clean except the one pre-existing- pattern warning noted above, `npm run i18n:check` clean (no new untranslated-key warnings), `npm run build` succeeds. Live-verified against a real waiting gate (job a3479368, ticket #445): the plan's full JSON artifact renders inline on the job's own GATE stop with holder/deadline info and working Ablehnen/Plan-freigeben buttons, no tab switch. --- .../dev-platform/_components/GateInbox.tsx | 107 ++++++++++++++---- web-ui/app/admin/dev-platform/_lib/api.ts | 11 ++ .../app/admin/dev-platform/jobs/[id]/page.tsx | 50 +++++++- web-ui/messages/de.json | 2 + web-ui/messages/en.json | 2 + 5 files changed, 145 insertions(+), 27 deletions(-) diff --git a/web-ui/app/admin/dev-platform/_components/GateInbox.tsx b/web-ui/app/admin/dev-platform/_components/GateInbox.tsx index 4a77a98a..7916615f 100644 --- a/web-ui/app/admin/dev-platform/_components/GateInbox.tsx +++ b/web-ui/app/admin/dev-platform/_components/GateInbox.tsx @@ -8,6 +8,7 @@ import { Button } from '@/app/_components/ui/Button'; import { ApiError } from '@/app/_lib/api'; import { DEV_ARTIFACT_PATH, + getArtifactText, listWaitingGates, resolveGate, type DevGateAnswer, @@ -92,12 +93,46 @@ type ResolveState = | { kind: 'conflict' } | { kind: 'error' }; -function GateCard({ gate, onResolved }: { gate: DevGateView; onResolved: () => void }): React.ReactElement { +type PlanTextState = { kind: 'loading' } | { kind: 'ready'; text: string } | { kind: 'error' } | { kind: 'none' }; + +/** `compact`: drop the deadline/job-id header (the job-detail page already + * shows both) and the outer bordered card — used to embed the gate inline in + * the job's own phase flow instead of only in the standalone gate inbox. */ +export function GateCard({ + gate, + onResolved, + compact = false, +}: { + gate: DevGateView; + onResolved: () => void; + compact?: boolean; +}): React.ReactElement { const t = useTranslations('adminDevPlatform.gates'); const [answers, setAnswers] = useState>({}); const [note, setNote] = useState(''); const [busy, setBusy] = useState<'approve' | 'reject' | null>(null); const [resolveState, setResolveState] = useState({ kind: 'idle' }); + const [fetchedPlanText, setFetchedPlanText] = useState({ kind: 'loading' }); + // No artifact ⇒ no fetch ever happens — derive 'none' rather than storing it, + // so the effect below never needs a synchronous setState in its early return. + const planText: PlanTextState = gate.planArtifactId ? fetchedPlanText : { kind: 'none' }; + + useEffect(() => { + if (!gate.planArtifactId) return; + let cancelled = false; + setFetchedPlanText({ kind: 'loading' }); + void getArtifactText(gate.planArtifactId).then( + (text) => { + if (!cancelled) setFetchedPlanText({ kind: 'ready', text }); + }, + () => { + if (!cancelled) setFetchedPlanText({ kind: 'error' }); + }, + ); + return () => { + cancelled = true; + }; + }, [gate.planArtifactId]); const resolve = useCallback( (approved: boolean) => { @@ -134,43 +169,65 @@ function GateCard({ gate, onResolved }: { gate: DevGateView; onResolved: () => v ); return ( -
+
-
- {t('job')} {gate.jobId} -
+ {compact ? null : ( +
+ {t('job')} {gate.jobId} +
+ )}
{gate.deadlineAt ? t('deadline', { at: formatTs(gate.deadlineAt) }) : t('noDeadline')}
-
{t('plan')}
-
- {gate.planArtifactId ? ( - - {t('viewPlan')} - - ) : ( - {t('noPlan')} - )} - {gate.planSha256 ? ( - - {gate.planSha256.slice(0, 12)} - - ) : null} -
{t('holders')}
{gate.resolvedHolders.length > 0 ? gate.resolvedHolders.join(', ') : t('noHolders')}
+
+
+

+ {t('plan')} +

+
+ {gate.planSha256 ? ( + + {gate.planSha256.slice(0, 12)} + + ) : null} + {gate.planArtifactId ? ( + + {t('viewPlan')} + + ) : null} +
+
+ {planText.kind === 'none' ? ( +

{t('noPlan')}

+ ) : planText.kind === 'loading' ? ( +

{t('planLoading')}

+ ) : planText.kind === 'error' ? ( +

{t('planLoadError')}

+ ) : ( +
+            {planText.text}
+          
+ )} +
+ {gate.questions.length > 0 ? (

diff --git a/web-ui/app/admin/dev-platform/_lib/api.ts b/web-ui/app/admin/dev-platform/_lib/api.ts index 9d385224..fd919108 100644 --- a/web-ui/app/admin/dev-platform/_lib/api.ts +++ b/web-ui/app/admin/dev-platform/_lib/api.ts @@ -301,6 +301,17 @@ export function retryJob(id: string): Promise<{ ok: boolean; jobId: string }> { export const DEV_ARTIFACT_PATH = (artifactId: string): string => `${BASE}/artifacts/${encodeURIComponent(artifactId)}`; +/** Fetch an artifact's raw text content (e.g. the plan shown inline at the + * gate) — `req()` above assumes a JSON body, `GET /artifacts/:id` does not. */ +export async function getArtifactText(artifactId: string): Promise { + const res = await fetch(DEV_ARTIFACT_PATH(artifactId), { credentials: 'include', cache: 'no-store' }); + const text = await res.text(); + if (!res.ok) { + throw new ApiError(res.status, `GET /artifacts/${artifactId} failed: ${res.status}`, text); + } + return text; +} + // ── GitHub App — manifest flow + registry (W2, spec §2/§9) ─────────────────── export interface DevGithubAppSummary { diff --git a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx index 6a534556..87f046a8 100644 --- a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx +++ b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx @@ -17,9 +17,19 @@ import { statusIsLive, type DevJobUiPhase, } from '@/app/_components/devjobs/DevJobPhaseRail'; +import { findGateForJob } from '@/app/_components/devjobs/devJobChatCardState'; import { useDevJobEvents, type DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; +import { GateCard } from '../../_components/GateInbox'; import { JobLogPane, type LogConnection } from '../../_components/JobLogPane'; -import { cancelJob, deleteJob, getJob, isTerminalStatus, type DevJobView } from '../../_lib/api'; +import { + cancelJob, + deleteJob, + getJob, + isTerminalStatus, + listWaitingGates, + type DevGateView, + type DevJobView, +} from '../../_lib/api'; import { INITIAL_LOG_STATE, foldDevJobEvent, type LogState } from '../../_lib/toolCallLog'; /** @@ -109,6 +119,38 @@ export default function JobDetailPage(): React.ReactElement { return () => clearInterval(timer); }, [conn, lastEventAt]); + // Fetch the waiting gate for this job whenever it parks at the human gate — + // shown inline on the GATE stop instead of sending the operator to a + // separate tab to approve/reject (mirrors DevJobChatCard.tsx's pattern). + // `gate` is derived from the fetch + the job's live status rather than + // reset via a synchronous setState in the effect's early return: once the + // job leaves `waiting` this naturally reads as null without a second write. + const isWaiting = job?.status === 'waiting'; + const [fetchedGate, setFetchedGate] = useState(null); + const gate = isWaiting ? fetchedGate : null; + + useEffect(() => { + if (!isWaiting) return; + let cancelled = false; + void listWaitingGates().then( + (res) => { + if (!cancelled) setFetchedGate(findGateForJob(res.gates, id)); + }, + () => {}, + ); + return () => { + cancelled = true; + }; + }, [isWaiting, id]); + + const onGateResolved = useCallback(() => { + setFetchedGate(null); + void getJob(id).then( + (j) => setJob(j), + () => {}, + ); + }, [id]); + // Deep-link: the viewed phase comes from `?phase=`. const rawPhase = search?.get('phase') ?? null; const selected: DevJobUiPhase | null = @@ -177,7 +219,11 @@ export default function JobDetailPage(): React.ReactElement { {/* Body */}
- {effective === 'pr' && job?.prUrl ? ( + {effective === 'gate' && gate ? ( +
+ +
+ ) : effective === 'pr' && job?.prUrl ? (
{t('openPr')} diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index bd4a6c83..fb0fb3f8 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -2970,6 +2970,8 @@ "plan": "Plan", "viewPlan": "Plan ansehen", "noPlan": "Kein Plan-Artefakt", + "planLoading": "Plan wird geladen…", + "planLoadError": "Der Plan konnte nicht geladen werden.", "holders": "Holder", "noHolders": "keine", "questions": "Rückfragen", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 9127afaf..6debe523 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -2970,6 +2970,8 @@ "plan": "Plan", "viewPlan": "View plan", "noPlan": "No plan artifact", + "planLoading": "Loading plan…", + "planLoadError": "The plan could not be loaded.", "holders": "Holders", "noHolders": "none", "questions": "Questions", From 15b6caca751a20cfbdb33e04af8e3dcb4b5e776c Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 14:07:20 +0200 Subject: [PATCH 21/34] feat(dev-platform): render the gate's plan artifact readably, not raw JSON Marcel's ask, immediately after the plan finally rendered inline: it showed the raw fetched text verbatim -- a single JSON blob whose string fields carry escaped \n sequences (JSON's own escaping for a real newline inside a string literal), so multi-paragraph fields like `approach` rendered as one dense wall of text with literal backslash-n characters instead of actual line breaks. `filesToTouch` printed as a JSON array literal instead of a list. New PrettyArtifact.tsx + prettyArtifact.ts: JSON.parse the artifact text (this is the fix for the \n problem -- parsing turns the escape sequence into a real newline byte, which `white-space: pre-wrap` then wraps correctly) and render each top-level field by its own JS type -- a string as a wrapped paragraph, a string array as a bullet list, other values as a small indented JSON block. Fully generic per field rather than a per-kind template, since artifact shape varies by kind (plan/analysis/ bootstrap_report/...) and isn't rigidly typed client-side. Falls back to the raw text verbatim when the content isn't parseable JSON or isn't a plain object at the top level -- never worse than the previous behavior. Wired into GateInbox.tsx's inline plan view (the previous commit's work) in place of the raw
 block.

New tests (prettyArtifact.test.ts, 7 cases): the load-bearing one confirms
JSON.parse actually turns `\n` escapes into real newline characters;
others cover malformed JSON, top-level arrays/primitives/null (all
correctly fall back), and that nested arrays/objects pass through as-is for
the component to render.

Verified: tsc --noEmit clean, eslint clean (same one pre-existing-pattern
warning as the prior commit, unchanged), 49/49 dev-platform vitest files
green, i18n:check clean, `npm run build` succeeds.
---
 .../dev-platform/_components/GateInbox.tsx    |  7 ++-
 .../_components/PrettyArtifact.tsx            | 63 +++++++++++++++++++
 .../_lib/__tests__/prettyArtifact.test.ts     | 40 ++++++++++++
 .../admin/dev-platform/_lib/prettyArtifact.ts | 20 ++++++
 4 files changed, 127 insertions(+), 3 deletions(-)
 create mode 100644 web-ui/app/admin/dev-platform/_components/PrettyArtifact.tsx
 create mode 100644 web-ui/app/admin/dev-platform/_lib/__tests__/prettyArtifact.test.ts
 create mode 100644 web-ui/app/admin/dev-platform/_lib/prettyArtifact.ts

diff --git a/web-ui/app/admin/dev-platform/_components/GateInbox.tsx b/web-ui/app/admin/dev-platform/_components/GateInbox.tsx
index 7916615f..ff6a1ae9 100644
--- a/web-ui/app/admin/dev-platform/_components/GateInbox.tsx
+++ b/web-ui/app/admin/dev-platform/_components/GateInbox.tsx
@@ -14,6 +14,7 @@ import {
   type DevGateAnswer,
   type DevGateView,
 } from '../_lib/api';
+import { PrettyArtifact } from './PrettyArtifact';
 
 /**
  * Epic #470 W2 — the operator gate inbox (UI spec §5). Lists every job parked at
@@ -222,9 +223,9 @@ export function GateCard({
         ) : planText.kind === 'error' ? (
           

{t('planLoadError')}

) : ( -
-            {planText.text}
-          
+
+ +
)}
diff --git a/web-ui/app/admin/dev-platform/_components/PrettyArtifact.tsx b/web-ui/app/admin/dev-platform/_components/PrettyArtifact.tsx new file mode 100644 index 00000000..645811b8 --- /dev/null +++ b/web-ui/app/admin/dev-platform/_components/PrettyArtifact.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { parseArtifactRecord } from '../_lib/prettyArtifact'; + +/** + * Epic #470 — a readable render of a JSON artifact (plan, analysis, + * bootstrap_report, ...) instead of its raw text. Fully generic per field: + * a string renders as a wrapped paragraph (real line breaks now that JSON + * parsing has turned `\n` escapes into actual newline characters — the raw + * text view showed them as literal backslash-n), a string array as a bullet + * list, anything else as a small indented JSON block. Falls back to the raw + * text verbatim when it isn't parseable JSON or isn't a plain object at the + * top level — never worse than the previous behavior. + */ +export function PrettyArtifact({ text }: { text: string }): React.ReactElement { + const record = parseArtifactRecord(text); + if (!record) { + return ( +
+        {text}
+      
+ ); + } + + return ( +
+ {Object.entries(record).map(([key, value]) => ( +
+
+ {key} +
+
{renderValue(value)}
+
+ ))} +
+ ); +} + +function renderValue(value: unknown): React.ReactElement { + if (typeof value === 'string') { + return

{value}

; + } + if (Array.isArray(value) && value.every((v) => typeof v === 'string')) { + return ( +
    + {value.map((v: string, i) => ( +
  • {v}
  • + ))} +
+ ); + } + if (typeof value === 'boolean' || typeof value === 'number') { + return {String(value)}; + } + if (value === null || value === undefined) { + return ; + } + return ( +
+      {JSON.stringify(value, null, 2)}
+    
+ ); +} diff --git a/web-ui/app/admin/dev-platform/_lib/__tests__/prettyArtifact.test.ts b/web-ui/app/admin/dev-platform/_lib/__tests__/prettyArtifact.test.ts new file mode 100644 index 00000000..b6da9ac8 --- /dev/null +++ b/web-ui/app/admin/dev-platform/_lib/__tests__/prettyArtifact.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { parseArtifactRecord } from '../prettyArtifact'; + +describe('parseArtifactRecord', () => { + it('parses a plain JSON object', () => { + expect(parseArtifactRecord('{"kind":"plan","approach":"do it"}')).toEqual({ + kind: 'plan', + approach: 'do it', + }); + }); + + it('turns escaped \\n sequences into real newline characters', () => { + const record = parseArtifactRecord('{"approach":"line one\\n\\nline two"}'); + expect(record?.['approach']).toBe('line one\n\nline two'); + }); + + it('returns null for malformed JSON', () => { + expect(parseArtifactRecord('not json')).toBeNull(); + }); + + it('returns null for a top-level array', () => { + expect(parseArtifactRecord('[1,2,3]')).toBeNull(); + }); + + it('returns null for a top-level primitive', () => { + expect(parseArtifactRecord('"just a string"')).toBeNull(); + expect(parseArtifactRecord('42')).toBeNull(); + }); + + it('returns null for JSON null', () => { + expect(parseArtifactRecord('null')).toBeNull(); + }); + + it('preserves nested arrays and objects as-is for the caller to handle', () => { + const record = parseArtifactRecord('{"filesToTouch":["a.ts","b.ts"],"nested":{"x":1}}'); + expect(record?.['filesToTouch']).toEqual(['a.ts', 'b.ts']); + expect(record?.['nested']).toEqual({ x: 1 }); + }); +}); diff --git a/web-ui/app/admin/dev-platform/_lib/prettyArtifact.ts b/web-ui/app/admin/dev-platform/_lib/prettyArtifact.ts new file mode 100644 index 00000000..3c87285b --- /dev/null +++ b/web-ui/app/admin/dev-platform/_lib/prettyArtifact.ts @@ -0,0 +1,20 @@ +/** + * Epic #470 — parse a JSON artifact's text (plan, analysis, bootstrap_report, + * ...) into a plain record for readable rendering. Artifact schemas vary by + * `kind` and aren't rigidly typed client-side, so this stays fully generic: + * callers render each top-level field by its own JS type rather than a + * per-kind template. Returns `null` for anything that isn't parseable JSON or + * isn't a plain object (an array or primitive at the top level) — the caller + * falls back to showing the raw text verbatim in that case. + */ +export function parseArtifactRecord(text: string): Record | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; +} From 0a586cdf4b33c41671b2fb325367ffc4623975cb Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 15:17:59 +0200 Subject: [PATCH 22/34] fix(dev-platform): surface a CLI result's own error subtype, not just agent_done Found by a background agent investigating why job a3479368's implement phase failed with the generic `error='implement session exited with code 1'`: the stored dev_job_events for that failure showed the CLI go silent for 14 minutes, then emit a `result` line that got translated to a completely normal-looking `status:{state:'agent_done', usage:{...}}` -- and the underlying claude CLI process STILL exited non-zero right after. Nothing in the event trail explained why; the failure was only visible via the exit code, with zero context attached. Root cause: eventTranslate.ts's handleResult() unconditionally mapped every `result` NDJSON line to `agent_done`, never inspecting the CLI's own `subtype` ('success' | 'error_max_turns' | 'error_during_execution' | ...) or `is_error` field. A `result` line is not automatically success -- if the CLI's own result was itself an error subtype, the event stream displayed it identically to a clean success, masking exactly the information that would explain a later non-zero exit. Fix: handleResult() now checks `is_error`/`subtype` and emits a distinct `status:{state:'agent_error', subtype, errorText, usage}` when the CLI's own result indicates an error, instead of blindly reporting agent_done. Purely additive -- confirmed nothing in the shim's own control flow or the middleware gates on the exact status `state` string (grepped both; zero hits), so this can't regress anything, only add detail. Doesn't touch the already-correct exit-code-based fail gate in phaseRunner.ts -- this closes an observability gap, not a functional bug in the pass/fail decision itself. New tests (5): explicit subtype:'success' still reports agent_done (not falsely flagged); a non-success subtype reports agent_error with subtype+errorText+usage; is_error:true alone (no subtype) also triggers it; errorText is omitted rather than a placeholder when the CLI's result has no text payload. Full shim suite 73/73 green (was 69, +4 new -- one of the 5 listed IS the pre-existing baseline test, confirmed unchanged). tsc --noEmit clean, eslint clean, `npm run build` succeeds. This doesn't explain the CLI's own exit-1 behavior (that's inside the `claude` binary, out of this codebase) -- it makes the next occurrence self-diagnosing straight from dev_job_events instead of requiring another expensive live reproduction to even see what happened. --- .../dev-runner-shim/src/eventTranslate.ts | 22 ++++++++- .../test/eventTranslate.test.ts | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/middleware/packages/dev-runner-shim/src/eventTranslate.ts b/middleware/packages/dev-runner-shim/src/eventTranslate.ts index 329e09f6..241c08f2 100644 --- a/middleware/packages/dev-runner-shim/src/eventTranslate.ts +++ b/middleware/packages/dev-runner-shim/src/eventTranslate.ts @@ -14,7 +14,8 @@ * | assistant text deltas (coalesced per block)| `log {stream:'agent', text}` | * | `tool_use` block | `tool {name, inputPreview}` (≤2 KB) | * | `tool_result` block | `tool {name, ok, outputPreview}` (≤2 KB) | - * | `result` | `status {state:'agent_done', usage}` | + * | `result` (success) | `status {state:'agent_done', usage}` | + * | `result` (`is_error`/non-'success' subtype)| `status {state:'agent_error', subtype, errorText, usage}` | * * stderr lines are translated separately (`log {stream:'stderr', text}`) by the * agent runner; they never pass through here. @@ -143,6 +144,25 @@ export class CliEventTranslator { ? { costUsd: payload['total_cost_usd'] as number } : {}), }; + // Found live (a job whose CLI process exited non-zero despite an + // apparently-clean `result` line landing right beforehand): a `result` + // line is not automatically success. The CLI's own `subtype` names it + // ('success' | 'error_max_turns' | 'error_during_execution' | ...) and + // `is_error` flags it explicitly — surface that here instead of + // unconditionally reporting `agent_done`, so an error result is visible + // in dev_job_events at the moment it happens rather than only inferable + // later from the process's exit code with no explanation attached. + const subtype = asString(payload['subtype']); + const isError = payload['is_error'] === true || (subtype !== undefined && subtype !== 'success'); + if (isError) { + const errorText = asString(payload['result']); + return this.event('status', { + state: 'agent_error', + ...(subtype !== undefined ? { subtype } : {}), + ...(errorText !== undefined ? { errorText } : {}), + usage, + }); + } return this.event('status', { state: 'agent_done', usage }); } diff --git a/middleware/packages/dev-runner-shim/test/eventTranslate.test.ts b/middleware/packages/dev-runner-shim/test/eventTranslate.test.ts index 30465573..147deff5 100644 --- a/middleware/packages/dev-runner-shim/test/eventTranslate.test.ts +++ b/middleware/packages/dev-runner-shim/test/eventTranslate.test.ts @@ -74,6 +74,51 @@ describe('CliEventTranslator — event table', () => { assert.deepEqual(done?.payload, { state: 'agent_done', usage: { tokensIn: 12, tokensOut: 34, costUsd: 0.05 } }); }); + it('result with subtype:"success" → still status agent_done (explicit success is not flagged)', () => { + const events = drain([ + JSON.stringify({ type: 'result', subtype: 'success', usage: { input_tokens: 1, output_tokens: 1 } }), + ]); + const done = events.find((e) => e.type === 'status'); + assert.equal(done?.payload['state'], 'agent_done'); + }); + + it('result with a non-success subtype → status agent_error, not agent_done', () => { + // Regression: found live — a job whose CLI process exited non-zero despite + // a `result` line landing right beforehand that the OLD unconditional + // mapping would have reported as a clean agent_done, masking the failure + // from dev_job_events until the exit code contradicted it much later. + const events = drain([ + JSON.stringify({ + type: 'result', + subtype: 'error_max_turns', + result: 'exceeded max turns', + usage: { input_tokens: 12, output_tokens: 34 }, + total_cost_usd: 0.05, + }), + ]); + const done = events.find((e) => e.type === 'status'); + assert.deepEqual(done?.payload, { + state: 'agent_error', + subtype: 'error_max_turns', + errorText: 'exceeded max turns', + usage: { tokensIn: 12, tokensOut: 34, costUsd: 0.05 }, + }); + }); + + it('result with is_error:true (no subtype) → status agent_error', () => { + const events = drain([JSON.stringify({ type: 'result', is_error: true, usage: {} })]); + const done = events.find((e) => e.type === 'status'); + assert.equal(done?.payload['state'], 'agent_error'); + assert.equal(done?.payload['subtype'], undefined); + }); + + it('result with neither is_error nor a result-text field omits errorText rather than a placeholder', () => { + const events = drain([JSON.stringify({ type: 'result', subtype: 'error_during_execution', usage: {} })]); + const done = events.find((e) => e.type === 'status'); + assert.equal(done?.payload['state'], 'agent_error'); + assert.equal('errorText' in (done?.payload ?? {}), false); + }); + it('truncates a large input preview to the 2 KB cap', () => { const big = 'x'.repeat(5000); const [e] = drain([ From 9b1ca4bf47796b0d22c88955665af6bdc375dc95 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 15:35:58 +0200 Subject: [PATCH 23/34] fix(dev-platform): stop a client ECONNRESET from crashing the whole egress proxy Found live: `docker logs` on the egress-proxy container showed an uncaught exception mid-run -- Error: read ECONNRESET at TCP.onStreamRead (node:internal/stream_base_commons:216:20) Emitted 'error' event on Socket instance at: ... -- followed by the container's own restart. In the same window the daemon logged `PUT .../jobs/ failed: fetch failed` for a DIFFERENT job's control-plane call, and the job under investigation (a3479368, ticket #445 implement phase) reported npm install "stalled, node_modules not growing" -- all fallout from the SAME event: the proxy's control plane was unreachable while it crashed and restarted. Root cause: `dataServer.on('connect', (req, socket, head) => ...)` hands `handleConnect` a raw net.Socket with NO 'error' listener attached. handleConnect only adds one (`clientSocket.on('error', teardown)`) on its success path -- well after the allowlist decision AND the `await resolve(host)` call. A client that resets the connection (ECONNRESET) at ANY point during that async window fires an unhandled 'error' event; Node's default for a listener-less EventEmitter 'error' event is to throw, which took down the ENTIRE proxy process -- and with it, egress for every OTHER concurrent job, not just the one whose client reset. Fix: attach a defensive `socket.on('error', ...)` listener unconditionally, as the very first thing in the 'connect' handler, before handleConnect (or any DNS/allowlist work) even starts. handleConnect's own later listener still gets added once the tunnel exists -- multiple listeners on the same event coexist harmlessly in Node, and `destroySocket` is already idempotent. New test reproduces the exact crash deterministically: opens a CONNECT against an allowlisted host whose DNS resolution is held open indefinitely (the existing `resolveHangs` test seam), waits for the socket to land in that vulnerable pre-tunnel window, then calls `resetAndDestroy()` to send a real TCP RST (not just a clean FIN) -- reproducing ECONNRESET server-side -- then proves the SAME process is still alive by making a completely separate, successful request afterward (a crashed process cannot execute that assertion at all). Confirmed: reverting just the fix reproduces the exact live crash signature verbatim (`Error: read ECONNRESET ... at TCP.onStreamRead`) and fails the test; restoring the fix passes it. Full daemon suite 396/396 green (was 395, +1 new). Pre-existing eslint no-undef errors for Node globals (setTimeout/Buffer/URL/process/console) throughout this whole .mjs package are unrelated -- confirmed identical on the unmodified file via git stash before touching anything, a package-level eslint-env gap, not introduced or worsened here. Separately: registry.npmjs.org (or any package registry) is not in this deployment's DEV_EGRESS_BASE_ALLOWLIST at all -- that env var is unset, so `npm install`/`npm ci` inside a job container has no route through the proxy regardless of this crash. Worth an explicit operator allowlist entry as a follow-up; not changed here since it's a deployment config choice, not a code defect. --- .../sidecars/dev-runner-daemon/src/proxy.mjs | 17 +++++ .../dev-runner-daemon/test/proxy.test.mjs | 65 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs index 83602268..1e7cce60 100644 --- a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs @@ -153,6 +153,23 @@ export function createProxy(deps) { }); }); dataServer.on('connect', (req, socket, head) => { + // `socket` is the raw net.Socket the CONNECT upgrade hands us — an + // EventEmitter with NO listener for 'error' until handleConnect's success + // path reaches `clientSocket.on('error', teardown)`, well after DNS + // resolution / the allowlist decision. A client that resets the + // connection (ECONNRESET) at ANY point before that — observed live — + // fires an unhandled 'error' event, and Node's default for a + // listener-less EventEmitter 'error' is to throw, which crashed this + // entire process (taking down egress control-plane calls for every OTHER + // concurrent job until the container restarted). Attach a listener + // covering the socket's full lifetime, unconditionally, before anything + // else touches it; handleConnect's own later listener simply becomes a + // second listener on the same event once the tunnel exists — both firing + // is harmless, `destroySocket` is idempotent. + socket.on('error', (err) => { + logger.warn?.(`[dev-egress-proxy] client socket error: ${err instanceof Error ? err.message : String(err)}`); + destroySocket(socket); + }); void handleConnect(req, socket, head).catch((err) => { logger.warn?.(`[dev-egress-proxy] handleConnect threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`); destroySocket(socket); diff --git a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs index 6291dac5..9ab369af 100644 --- a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs @@ -489,6 +489,71 @@ describe('proxy — a tarpit nameserver cannot park connections before the limit }); }); +describe('proxy — a client socket reset before the tunnel exists must not crash the process', () => { + it('an ECONNRESET during the DNS-resolution window is handled, not thrown as an unhandled socket error', async () => { + // Found live: `clientSocket` (the raw net.Socket a CONNECT upgrade hands + // over) has NO 'error' listener attached until handleConnect's success + // path reaches `clientSocket.on('error', teardown)` -- well after the + // allowlist decision AND the `await resolve(host)` call. A client + // resetting the connection during that window fires an unhandled + // 'error' event; Node's default for a listener-less EventEmitter + // 'error' is to throw, which crashed the ENTIRE egress proxy process -- + // taking every OTHER concurrent job's egress down with it, restarted + // only by the container's own restart policy. + // + // resolveHangs keeps the CONNECT stuck in exactly that vulnerable + // pre-tunnel window indefinitely, so the reset below is guaranteed to + // land while it's still open. + const p = await startProxy({ + jobs: [{ jobId: JOB_ID, allowlist: ['good.test'], proxyToken: PROXY_TOKEN }], + resolveHangs: true, + }); + try { + const socket = netConnect({ host: '127.0.0.1', port: p.dataPort }); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }); + socket.write(`CONNECT good.test:443 HTTP/1.1\r\nHost: good.test:443\r\nProxy-Authorization: ${basicAuth()}\r\n\r\n`); + // Give the proxy a moment to receive the CONNECT and enter + // handleConnect's `await resolve(host)` (resolveHangs keeps it + // pending forever, so this window stays open indefinitely). + await new Promise((r) => setTimeout(r, 50)); + // resetAndDestroy sends a real TCP RST (Node 16.17+) rather than a + // clean FIN, so the SERVER side observes an 'error' event (ECONNRESET), + // not just 'close' -- the actual crash-reproducing case, not a milder + // graceful-disconnect one `socket.destroy()` alone wouldn't exercise. + if (typeof socket.resetAndDestroy === 'function') socket.resetAndDestroy(); + else socket.destroy(new Error('simulated reset')); + + // If the bug were present, the proxy process would have thrown an + // uncaught exception and died right about now — no further code in + // this process would ever run again. Reaching this assertion at all + // (on a freshly-issued, unrelated request) is itself the proof; a + // dead process cannot answer it. (internalHost/resolveMap mirrors the + // "end-to-end tunnel" test above — a real upstream + allowInternal so + // the loopback resolution isn't itself rejected as a rebind.) + const upstream = await startTcpEcho(); + const other = await startProxy({ + internalHost: 'still-alive.internal', + internalPort: upstream.port, + jobs: [{ jobId: JOB_ID, allowlist: [], proxyToken: PROXY_TOKEN }], + resolveMap: { 'still-alive.internal': [{ address: '127.0.0.1', family: 4 }] }, + }); + try { + const res = await sendConnect(other.dataPort, `still-alive.internal:${upstream.port}`, basicAuth()); + assert.equal(res.statusCode, 200, 'the process survived the reset and can still serve a normal request'); + res.socket.destroy(); + } finally { + await other.close(); + await upstream.close(); + } + } finally { + await p.close(); + } + }); +}); + /** * Epic #470 W1 — the whole egress chain, end to end, over real sockets. * From ad1862831b9a329574bb167e1a51329b789f4c9f Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 15:39:36 +0200 Subject: [PATCH 24/34] fix(dev-platform): allowlist the npm registry for this deployment's jobs Separate contributing cause to the same "install is stalled" symptom the egress-proxy crash fix (previous commit) also explains: DEV_EGRESS_BASE_ ALLOWLIST is unset in this compose file, and config.ts's own default for it is empty. That's correct behavior for a repo needing no package install at all, but wrong for this one -- an npm-workspaces repo where bootstrap now auto-detects `npm ci`/`npm install` (bootstrapDetect.ts) when no explicit bootstrap_command is set, and where an implement-phase agent may legitimately run its own install too. Neither has any route to registry.npmjs.org without this, and the egress proxy's default-deny means every attempt fails -- but npm's own connection-retry resilience makes that failure look like an indefinite hang rather than a fast, clear rejection, which is exactly what the screenshot showed ("install is stalled, node_modules not growing", agent had to manually detect and kill it). Sets DEV_EGRESS_BASE_ALLOWLIST=registry.npmjs.org (operator-overridable via the same env var, matching every other tunable in this file). Verified the merged compose config parses correctly and the running middleware picks it up after a restart. --- docker-compose.dev-platform.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index a61bdb4e..293c90b2 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -65,6 +65,17 @@ services: # exact string match (llmProxy.ts: `policy.allowedModels.includes(model)`) # against whatever `--model` the CLI actually reports in its init message. DEV_PLATFORM_LLM_ALLOWED_MODELS: ${DEV_PLATFORM_LLM_ALLOWED_MODELS:-claude-opus-4-8[1m],claude-opus-4-8,claude-sonnet-4-8[1m],claude-sonnet-4-8} + # Egress allowlist entries every job gets in ADDITION to the middleware + # host + its own repo's forge host (deriveJobPolicy.ts). Absent by + # default (config.ts), which is correct for a repo needing no package + # install at all -- but for THIS repo (npm workspaces), a job with no + # bootstrap_command auto-detects `npm ci`/`npm install` (bootstrapDetect + # .ts) and, same as any implement-phase agent legitimately running one + # itself, needs a route to the registry or it just hangs retrying + # against a proxy default-deny (found live: "install is stalled, + # node_modules not growing", no clean error -- npm's own resilience + # masks the denial as a hang rather than a fast rejection). + DEV_EGRESS_BASE_ALLOWLIST: ${DEV_EGRESS_BASE_ALLOWLIST:-registry.npmjs.org} # Neutralise any docker engine address a stray `middleware/.env` (loaded via # the base file's env_file) might inject. `environment` wins over env_file, # so these empty values are the last word: the middleware CANNOT be handed a From 65e1bbd28aba0cda7531d3a42ae1cbe60cd8f741 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 15:49:55 +0200 Subject: [PATCH 25/34] fix(dev-platform): capture the bootstrap command's own stdout/stderr Found live while verifying the two previous commits (egress-proxy crash fix + npm registry allowlist): a real `npm ci` bootstrap ran for a genuine 70 real seconds against the now-fixed proxy (no more hang -- those fixes worked) but then failed with exit code 1, and there was NO way to see why. `bootstrap_report` only ever recorded `{command, exitCode, timedOut, durationMs}` -- npm's own error output was gone. Root cause: `runCommand()` spawned the child with `stdio: ['ignore','pipe','pipe']` but never attached a listener to either pipe. Piped-but-unread streams are simply discarded by Node -- not buffered anywhere, not inherited to the container's own stdout/stderr either (so `docker logs` structurally cannot see them, confirmed by a background agent investigating a separate crash earlier in this same chain). A bootstrap failure has been completely opaque since this code existed: exit code only, zero diagnostic content, for every possible cause. Fix: read both streams, bounded to a trailing 4KB (the tail matters most -- npm/pip/etc. print their actual error at the END of their output, and unbounded capture risks a memory/artifact-size blowup on a verbose or runaway command), and include it as `outputTail` in the bootstrap_report artifact. Bounded while the command is STILL RUNNING too, not just at completion, so a chatty long-lived process can't accumulate unboundedly in memory first. New tests: stdout AND stderr both land in the report on a real failure; output well past the cap is truncated to a bounded tail while the diagnostic end of it survives. Existing bootstrap tests use substring regex matching on the report JSON (not exact deep-equality), so the new field is additive and didn't require changing any of them. Full shim suite 75/75 green (was 73, +2 new). tsc --noEmit clean, eslint clean, `npm run build` succeeds. --- .../dev-runner-shim/src/phaseRunner.ts | 36 +++++++++++++-- .../dev-runner-shim/test/phaseLoop.test.ts | 44 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/middleware/packages/dev-runner-shim/src/phaseRunner.ts b/middleware/packages/dev-runner-shim/src/phaseRunner.ts index f966f5cd..84acdd1c 100644 --- a/middleware/packages/dev-runner-shim/src/phaseRunner.ts +++ b/middleware/packages/dev-runner-shim/src/phaseRunner.ts @@ -180,6 +180,7 @@ export class PhaseRunner { exitCode: result.code, timedOut: result.timedOut, durationMs, + outputTail: result.outputTail, }); if (result.code !== 0) { return { @@ -287,12 +288,27 @@ export function absorb(acc: Accumulated, phase: DevJobPhase, body: PhaseResultBo // Small helpers. // --------------------------------------------------------------------------- +/** Cap on captured command output — the tail is what matters for diagnosing + * a failure (npm/pip/etc. print their actual error at the end, not the + * start), and unbounded capture risks a memory/artifact-size blowup on a + * verbose or runaway command. */ +const MAX_COMMAND_OUTPUT_BYTES = 4096; + interface CommandResult { code: number; timedOut: boolean; + /** Last MAX_COMMAND_OUTPUT_BYTES of combined stdout+stderr. */ + outputTail: string; } -/** Run a shell command with its own timeout. Used only for `bootstrap`. */ +/** Run a shell command with its own timeout. Used only for `bootstrap`. + * + * Found live: this used to spawn with `stdio: ['ignore','pipe','pipe']` and + * never read either pipe — a failed bootstrap command (e.g. `npm ci` + * exiting 1 after 70s of real, successful-looking network activity) + * reported only an exit code, with the actual reason (npm's own error + * output) silently discarded and unrecoverable, not even via `docker logs` + * (piped streams never reach the container's own stdout/stderr). */ function runCommand( command: string, opts: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs: number; setKill: SetKill }, @@ -303,19 +319,31 @@ function runCommand( env: opts.env, stdio: ['ignore', 'pipe', 'pipe'], }); + let output = ''; + const appendOutput = (chunk: Buffer): void => { + output += chunk.toString('utf8'); + // Keep memory bounded while the command is STILL RUNNING, not just at + // the end — a runaway command must not accumulate unboundedly. + if (output.length > MAX_COMMAND_OUTPUT_BYTES * 2) { + output = output.slice(-MAX_COMMAND_OUTPUT_BYTES); + } + }; + child.stdout?.on('data', appendOutput); + child.stderr?.on('data', appendOutput); let timedOut = false; const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, opts.timeoutMs); opts.setKill((signal: NodeJS.Signals = 'SIGTERM') => child.kill(signal)); - child.once('error', () => { + child.once('error', (err) => { clearTimeout(timer); - resolve({ code: -1, timedOut }); + appendOutput(Buffer.from(`\n[spawn error] ${err.message}`)); + resolve({ code: -1, timedOut, outputTail: output.slice(-MAX_COMMAND_OUTPUT_BYTES) }); }); child.once('close', (code) => { clearTimeout(timer); - resolve({ code: code ?? -1, timedOut }); + resolve({ code: code ?? -1, timedOut, outputTail: output.slice(-MAX_COMMAND_OUTPUT_BYTES) }); }); }); } diff --git a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts index ea0fff2d..29c1214d 100644 --- a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts +++ b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts @@ -269,6 +269,50 @@ describe('runPhasedShim — bootstrap runs as a command, not a CLI session', () assert.deepEqual(homes, [], 'bootstrap starts no agent session'); }); + it('captures the command\'s own stdout+stderr into the report, not just its exit code', async () => { + // Regression: found live -- a real `npm ci` failure inside a job + // container reported only `exitCode:1` with zero further detail; the + // command's own output was silently discarded (piped but never read), + // unrecoverable even via `docker logs` (piped streams never reach the + // container's own stdout/stderr). + const spec = makeSpec({ + phaseContext: { phase: 'bootstrap' }, + bootstrap: { + command: 'echo "line one to stdout"; echo "line two to stderr" 1>&2; exit 1', + timeoutMs: 30_000, + }, + }); + const home = new ScriptedHome(spec, [{ directive: 'failed', reason: 'x' }]); + await runPhasedShim(env, { home, gitBin, log: () => {} }); + + const boot = home.phaseResults[0]; + assert.equal(boot?.ok, false); + const content = boot?.artifact?.content ?? ''; + assert.match(content, /"exitCode":1/); + assert.match(content, /line one to stdout/, 'stdout was captured'); + assert.match(content, /line two to stderr/, 'stderr was captured too'); + }); + + it('caps captured output to a bounded tail rather than growing unboundedly', async () => { + const spec = makeSpec({ + phaseContext: { phase: 'bootstrap' }, + bootstrap: { + // Print well past the cap, then a distinctive marker at the very + // end -- the tail (not the head) is what a real npm/pip failure + // needs, since the actual error line comes last. + command: 'for i in $(seq 1 20000); do printf "x"; done; printf "\\nTHE-ACTUAL-ERROR-IS-HERE\\n"', + timeoutMs: 30_000, + }, + }); + const home = new ScriptedHome(spec, [{ directive: 'done' }]); + await runPhasedShim(env, { home, gitBin, log: () => {} }); + + const boot = home.phaseResults[0]; + const parsed = JSON.parse(boot?.artifact?.content ?? '{}'); + assert.ok(parsed.outputTail.length < 20000, 'the captured tail is bounded, not the full 20k+ bytes'); + assert.match(parsed.outputTail, /THE-ACTUAL-ERROR-IS-HERE/, 'the end of the output (where the real error lives) survives truncation'); + }); + it('auto-detects a command from the cloned repo root when none is provisioned', async () => { // repoDir is `/repo` (gitOps.ts REPO_DIRNAME) — pre-seed it // before the fake clone step runs; clone only adds `.git`, it never wipes From af0031a07f6ef526a7dfeb4c7ebec615a12efa28 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 17:33:24 +0200 Subject: [PATCH 26/34] feat(dev-platform): retry a job from its failed phase, not always from analyze POST /jobs/:id/retry?resumeFromPhase=true clones the job starting at the source job's own dev_jobs.phase instead of always restarting at 'analyze'. This is safe by construction: dev_jobs.phase is exactly the value the dev-runner-shim reads to decide where to begin (protocol.ts's ProvisionSpec.phase doc), so handing a new job the old job's last-attempted phase reproduces a starting point the runner already knows how to execute, no new runner-side code path needed. Artifacts from phases that already succeeded are copied onto the new job's own row (getLatestArtifact reads by job id), so a later phase (e.g. plan reading the analysis artifact) still finds what it needs. Motivation: every prior retry during this epic's live npm-bug investigation re-ran the full analyze phase (~$1.72 in LLM tokens each time) just to get back to testing the free, no-LLM bootstrap step. Default behavior (no query param) is unchanged. --- middleware/src/routes/devPlatform.ts | 27 ++++++++- middleware/src/routes/devPlatformShared.ts | 5 ++ .../devplatform/devPlatformRoutes.harness.ts | 7 ++- .../devplatform/devPlatformRoutes.test.ts | 59 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/middleware/src/routes/devPlatform.ts b/middleware/src/routes/devPlatform.ts index 38cd458d..c410eea6 100644 --- a/middleware/src/routes/devPlatform.ts +++ b/middleware/src/routes/devPlatform.ts @@ -261,6 +261,23 @@ export function createDevPlatformRouter(deps: DevPlatformRouterDeps): Router { // W0: re-queue by cloning the job into a fresh queued row (a new runner token; // the row's one-time-token invariant forbids reusing the old one). Allowed // only once the source job has finished. + // + // `?resumeFromPhase=true` starts the clone at the SOURCE job's own `phase` + // (wherever it was when it failed) instead of always restarting at `analyze`. + // This is safe by construction: `dev_jobs.phase` is exactly the value the + // dev-runner-shim reads to decide where to begin (protocol.ts's own + // `ProvisionSpec.phase` doc: "Phase the runner begins at"), so handing a new + // job the old job's last-attempted phase reproduces the same starting point + // the runner already knows how to execute — no new runner-side code path. + // The one thing the runner-shim assumes that a fresh job wouldn't otherwise + // have is the artifacts EARLIER phases already produced (e.g. `plan` reads + // the `analysis` artifact by job id) — those are copied onto the new job's + // own row so any later phase finds them under its own id, same as if this + // job had produced them itself. Bootstrap-only failures (this feature's + // primary motivation — a real dev job costs ~$1-2 in `analyze` LLM tokens + // before ever reaching the free, no-LLM `bootstrap` shell step) need no + // artifact at all; this still copies whatever exists so it generalizes to + // any later phase without special-casing which phase needs which artifact. router.post( '/jobs/:id/retry', handler(async (req, res) => { @@ -274,6 +291,7 @@ export function createDevPlatformRouter(deps: DevPlatformRouterDeps): Router { if (!isPermittedLauncher(repo, caller)) { throw new DevPlatformError(403, 'devplatform.not_launcher', 'not a permitted launcher for this repository'); } + const resumeFromPhase = req.query['resumeFromPhase'] === 'true'; const minted = mintRunnerToken(); const next = await deps.jobStore.createJob({ repoId: job.repoId, @@ -285,8 +303,15 @@ export function createDevPlatformRouter(deps: DevPlatformRouterDeps): Router { authMode: job.authMode, createdBy: caller.sub, runnerTokenHash: minted.hash, + ...(resumeFromPhase ? { phase: job.phase } : {}), }); - res.status(202).json({ ok: true, jobId: next.id }); + if (resumeFromPhase) { + const priorArtifacts = await deps.jobStore.listArtifacts(job.id); + for (const artifact of priorArtifacts) { + await deps.jobStore.addArtifact(next.id, artifact.kind, artifact.content, artifact.meta); + } + } + res.status(202).json({ ok: true, jobId: next.id, ...(resumeFromPhase ? { resumedAtPhase: next.phase } : {}) }); }), ); diff --git a/middleware/src/routes/devPlatformShared.ts b/middleware/src/routes/devPlatformShared.ts index dfe33e3b..ea50b55f 100644 --- a/middleware/src/routes/devPlatformShared.ts +++ b/middleware/src/routes/devPlatformShared.ts @@ -59,6 +59,11 @@ export interface DevPlatformJobStore { listArtifacts(jobId: string): Promise; getArtifact(id: string): Promise; deleteJob(id: string): Promise<'deleted' | 'not_terminal' | 'not_found'>; + /** Used only by `/jobs/:id/retry?resumeFromPhase=true` to seed a fresh job's + * artifacts from the failed job it's resuming, so a later phase (e.g. `plan` + * reading `analysis`) still finds what it needs without re-running the + * phases that already succeeded. */ + addArtifact(jobId: string, kind: string, content: string, meta?: Record): Promise; } /** The `DevRepoCredentialStore` surface. Never returns a token to the browser — diff --git a/middleware/test/devplatform/devPlatformRoutes.harness.ts b/middleware/test/devplatform/devPlatformRoutes.harness.ts index 70e73248..e91b98cd 100644 --- a/middleware/test/devplatform/devPlatformRoutes.harness.ts +++ b/middleware/test/devplatform/devPlatformRoutes.harness.ts @@ -117,6 +117,7 @@ export class FakeJobStore { id, repoId: input.repoId, kind: input.kind, brief: input.brief, source: input.source, sourceRef: input.sourceRef ?? null, backend: input.backend, authMode: input.authMode ?? 'api_key', createdBy: input.createdBy, runnerTokenHash: input.runnerTokenHash, status: 'queued', + phase: input.phase ?? 'analyze', }); this.jobs.set(id, job); return job; @@ -138,8 +139,10 @@ export class FakeJobStore { return [...this.artifacts.values()].filter((a) => a.jobId === jobId); } async getArtifact(id: string) { return this.artifacts.get(id) ?? null; } - addArtifact(a: { id: string; jobId: string; kind: string; content: string }): void { - this.artifacts.set(a.id, { ...a, meta: {}, createdAt: new Date().toISOString() }); + async addArtifact(jobId: string, kind: string, content: string, meta: Record = {}): Promise { + const id = `artifact-${String(++this.seq)}`; + this.artifacts.set(id, { id, jobId, kind, content, meta, createdAt: new Date().toISOString() }); + return id; } async deleteJob(id: string): Promise<'deleted' | 'not_terminal' | 'not_found'> { const job = this.jobs.get(id); diff --git a/middleware/test/devplatform/devPlatformRoutes.test.ts b/middleware/test/devplatform/devPlatformRoutes.test.ts index bd82f8da..ce87c27e 100644 --- a/middleware/test/devplatform/devPlatformRoutes.test.ts +++ b/middleware/test/devplatform/devPlatformRoutes.test.ts @@ -275,6 +275,65 @@ describe('devPlatform — DELETE /jobs/:id', () => { }); }); +describe('devPlatform — POST /jobs/:id/retry', () => { + let h: Harness; + afterEach(async () => { if (h) await h.close(); }); + + it('default: clones into a fresh job starting at analyze, no artifacts carried over', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice' })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'failed', phase: 'bootstrap' })); + await h.jobStore.addArtifact('job-1', 'analysis', '{"kind":"analysis"}'); + const res = await postJson(`${h.baseUrl}/jobs/job-1/retry`, authHeaders(), {}); + assert.equal(res.status, 202); + const body = (await res.json()) as { ok: boolean; jobId: string; resumedAtPhase?: string }; + assert.equal(body.ok, true); + assert.ok(!('resumedAtPhase' in body), 'default retry reports no resumedAtPhase'); + const next = await h.jobStore.getJob(body.jobId); + assert.equal(next?.phase, 'analyze', 'default retry always restarts at analyze'); + assert.deepEqual(await h.jobStore.listArtifacts(body.jobId), [], 'no artifacts copied without resumeFromPhase'); + }); + + it('resumeFromPhase=true: starts the clone at the failed job\'s own phase and copies its artifacts forward', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice' })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'failed', phase: 'bootstrap' })); + await h.jobStore.addArtifact('job-1', 'analysis', '{"kind":"analysis"}', { note: 'from job-1' }); + const res = await postJson(`${h.baseUrl}/jobs/job-1/retry?resumeFromPhase=true`, authHeaders(), {}); + assert.equal(res.status, 202); + const body = (await res.json()) as { ok: boolean; jobId: string; resumedAtPhase?: string }; + assert.equal(body.ok, true); + assert.equal(body.resumedAtPhase, 'bootstrap'); + const next = await h.jobStore.getJob(body.jobId); + assert.equal(next?.phase, 'bootstrap', 'the clone starts where the source job failed, not at analyze'); + const copied = await h.jobStore.listArtifacts(body.jobId); + assert.equal(copied.length, 1); + assert.equal(copied[0]?.kind, 'analysis'); + assert.equal(copied[0]?.content, '{"kind":"analysis"}'); + assert.deepEqual(copied[0]?.meta, { note: 'from job-1' }); + // The copy lands under the NEW job's own id, not the source job's. + assert.equal(copied[0]?.jobId, body.jobId); + }); + + it('resumeFromPhase=true on a job that failed during analyze itself behaves like a normal retry', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice' })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'failed', phase: 'analyze' })); + const res = await postJson(`${h.baseUrl}/jobs/job-1/retry?resumeFromPhase=true`, authHeaders(), {}); + assert.equal(res.status, 202); + const next = await h.jobStore.getJob(((await res.json()) as { jobId: string }).jobId); + assert.equal(next?.phase, 'analyze'); + }); + + it('409 for a non-terminal job, same as a normal retry, regardless of resumeFromPhase', async () => { + h = await makeHarness(); + h.repoStore.add(makeRepo({ id: 'repo-1', createdBy: 'alice' })); + h.jobStore.add(makeJob({ id: 'job-1', repoId: 'repo-1', status: 'running', phase: 'bootstrap' })); + const res = await postJson(`${h.baseUrl}/jobs/job-1/retry?resumeFromPhase=true`, authHeaders(), {}); + assert.equal(res.status, 409); + }); +}); + // --------------------------------------------------------------------------- // SSE — the single job-event tail. // --------------------------------------------------------------------------- From 3be2800fda1ee51c0a5bc8f8a504ec6c34b115f3 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 17:48:57 +0200 Subject: [PATCH 27/34] fix(dev-platform): cache DNS resolution in the egress proxy, dedup concurrent same-host lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy's resolve() called dns.lookup() fresh on EVERY CONNECT, with no caching or in-flight de-dup. dns.lookup() runs on Node's libuv threadpool (default 4 workers, never tuned in this image). npm's own registry traffic fires up to `maxsockets` (default 15) concurrent CONNECTs to the SAME hostname (registry.npmjs.org) for package tarballs, each independently re-resolving a host that another concurrent fetch just resolved milliseconds earlier. This unifies three previously-inexplicable live symptoms (epic #470, 2026-07-28) under one root cause: - default npm concurrency: deterministic crash ~70s into npm ci with npm's own 'Exit handler never called!' bug (npm/cli#9751 — a race triggered by near-simultaneous registry-fetch timeouts) - --maxsockets 1000: same contention pushed past the 5s resolve deadline -> literal EAI_AGAIN - --maxsockets 3 (lowered): less contention, only delayed the same crash (70s -> 251s) Fix: a 30s TTL cache + in-flight de-dup, keyed by hostname, shared across jobs. N concurrent CONNECTs to the same host now share ONE underlying lookup. Failed lookups are never cached. The rebinding defence (resolve-once/connect-to-what-you-checked) is unaffected — every CONNECT still classifies+pins its own resolved addresses, only the lookup itself is reused. --- .../sidecars/dev-runner-daemon/src/proxy.mjs | 65 ++++++++++- .../dev-runner-daemon/test/proxy.test.mjs | 110 +++++++++++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs index 1e7cce60..aebd1043 100644 --- a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs @@ -50,6 +50,66 @@ import { /** DNS must not be a way to park a connection forever before the limiter sees it. */ export const DEFAULT_RESOLVE_TIMEOUT_MS = 5_000; +/** + * How long a successful resolution is reused before a fresh lookup, keyed by + * hostname and shared across every job. Short enough that the DNS-rebinding + * defence (resolve-once/connect-to-what-you-checked, spec §6) stays + * meaningful — the resolved addresses are still classified/pinned fresh on + * every CONNECT, only the lookup itself is reused; long enough to absorb a + * burst of concurrent fetches to the same host (npm's registry traffic: + * dozens of package tarballs from registry.npmjs.org at once). + */ +export const DEFAULT_RESOLVE_CACHE_TTL_MS = 30_000; + +/** + * Wrap a raw resolver with the cache + in-flight de-dup described above. npm + * ci fires up to `maxsockets` (default 15) concurrent CONNECTs to the SAME + * hostname within milliseconds of each other; without this, each one + * independently calls dns.lookup(), which runs on Node's libuv threadpool + * (default 4 workers, never tuned in this image) — so N concurrent same-host + * lookups compete for 4 slots instead of sharing one answer. Confirmed live + * (2026-07-28, epic #470): default npm concurrency crashed deterministically + * ~70s into `npm ci` with npm's own "Exit handler never called!" bug + * (npm/cli#9751 — a re-entrancy race triggered by near-simultaneous registry- + * fetch timeouts); raising `--maxsockets` to 1000 turned the same threadpool + * contention into outright `EAI_AGAIN` once lookups queued past the 5s + * resolve deadline; *lowering* it to 3 only delayed the same crash (70s→ + * 251s). All three point at resolution contention, not npm itself — this + * removes the contention at its source instead of guessing at npm's own + * concurrency. + * + * @param {(host: string) => Promise>} rawResolve + * @param {number} ttlMs + */ +function createCachedResolve(rawResolve, ttlMs) { + /** @type {Map, expiresAt: number }>} */ + const cache = new Map(); + /** @type {Map>>} */ + const inflight = new Map(); + + /** @param {string} host */ + return function cachedResolve(host) { + const cached = cache.get(host); + if (cached && cached.expiresAt > Date.now()) return Promise.resolve(cached.addresses); + + const existing = inflight.get(host); + if (existing) return existing; + + const promise = rawResolve(host) + .then((addresses) => { + // Only a SUCCESS is cached — a failed lookup (or a deadline timeout + // racing it from the caller side) must not poison the next attempt. + if (ttlMs > 0) cache.set(host, { addresses, expiresAt: Date.now() + ttlMs }); + return addresses; + }) + .finally(() => { + inflight.delete(host); + }); + inflight.set(host, promise); + return promise; + }; +} + export const DEFAULT_DATA_PORT = 3128; /** Control-plane port (spec §6). */ export const DEFAULT_CONTROL_PORT = 3129; @@ -102,6 +162,7 @@ async function defaultResolve(host) { * @property {ReadonlySet} [allowedPorts] * @property {(host: string) => Promise>} [resolve] DNS seam. * @property {number} [resolveTimeoutMs] Deadline on name resolution (default 5 s). + * @property {number} [resolveCacheTtlMs] How long a resolution is reused (default 30 s; 0 disables caching). * @property {Partial} [limits] * @property {{ warn?: (m: string) => void }} [logger] * @property {() => number} [now] @@ -119,8 +180,10 @@ export function createProxy(deps) { const allowedPorts = deps.allowedPorts ?? new Set(DEFAULT_ALLOWED_PORTS); const rawResolve = deps.resolve ?? defaultResolve; const resolveTimeoutMs = deps.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS; + const resolveCacheTtlMs = deps.resolveCacheTtlMs ?? DEFAULT_RESOLVE_CACHE_TTL_MS; + const cachedResolve = createCachedResolve(rawResolve, resolveCacheTtlMs); /** @param {string} host */ - const resolve = (host) => withDeadline(rawResolve(host), resolveTimeoutMs, 'dns resolve'); + const resolve = (host) => withDeadline(cachedResolve(host), resolveTimeoutMs, 'dns resolve'); const limits = { ...DEFAULTS, ...(deps.limits ?? {}) }; const ctx = { registry: deps.registry, diff --git a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs index 9ab369af..c92e67ac 100644 --- a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs @@ -61,7 +61,7 @@ function closeServer(server) { /** * Boot a real proxy with capturing event sink + a resolver seam. - * @param {{ internalHost?: string, internalPort?: number, jobs?: Array<{ jobId: string, allowlist: string[], proxyToken: string, ttlSec?: number }>, resolveMap?: Record> }} opts + * @param {{ internalHost?: string, internalPort?: number, jobs?: Array<{ jobId: string, allowlist: string[], proxyToken: string, ttlSec?: number }>, resolveMap?: Record>, resolveCacheTtlMs?: number, resolveDelayMs?: number, customResolve?: (host: string) => Promise> }} opts */ async function startProxy(opts = {}) { const events = []; @@ -73,8 +73,13 @@ async function startProxy(opts = {}) { const eventClient = { record: (e) => events.push(e), flush: async () => {}, stop: () => {} }; const resolve = async (host) => { resolveCalls.push(host); + if (opts.customResolve) return opts.customResolve(host); // A nameserver that never answers: the tarpit the resolve deadline exists for. if (opts.resolveHangs) return new Promise(() => {}); + // A deliberate delay widens the dedup race window for concurrent-CONNECT + // tests — without it, a same-tick resolve can settle before a second + // caller even asks, which would still be correct but proves nothing. + if (opts.resolveDelayMs) await new Promise((r) => setTimeout(r, opts.resolveDelayMs)); return opts.resolveMap?.[host] ?? [{ address: '127.0.0.1', family: 4 }]; }; const proxy = createProxy({ @@ -87,6 +92,7 @@ async function startProxy(opts = {}) { logger: { warn() {} }, limits: { connectMs: 2000, idleMs: 2000, absoluteMs: 5000 }, ...(opts.resolveTimeoutMs !== undefined ? { resolveTimeoutMs: opts.resolveTimeoutMs } : {}), + ...(opts.resolveCacheTtlMs !== undefined ? { resolveCacheTtlMs: opts.resolveCacheTtlMs } : {}), }); const dataPort = await listen(proxy.dataServer); const controlPort = await listen(proxy.controlServer); @@ -350,6 +356,108 @@ describe('egress proxy — end-to-end tunnel through the vetted IP', () => { }); }); +describe('egress proxy — DNS resolution cache (concurrent same-host CONNECTs share one lookup)', () => { + it('N concurrent CONNECTs to the same host fire exactly ONE underlying resolve call', async () => { + const upstream = await startTcpEcho(); + const p = await startProxy({ + internalHost: 'mw.internal', + internalPort: upstream.port, + jobs: [{ jobId: JOB_ID, allowlist: [], proxyToken: PROXY_TOKEN }], + resolveMap: { 'mw.internal': [{ address: '127.0.0.1', family: 4 }] }, + // Wide enough that all 8 CONNECTs below are dispatched before the + // first underlying lookup would have settled without the cache. + resolveDelayMs: 100, + }); + try { + const results = await Promise.all( + Array.from({ length: 8 }, () => sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth())), + ); + for (const r of results) assert.equal(r.statusCode, 200); + assert.deepEqual(p.resolveCalls, ['mw.internal'], 'exactly one raw resolve call, not eight'); + for (const r of results) r.socket.destroy(); + } finally { + await p.close(); + await upstream.close(); + } + }); + + it('a resolution is reused within the cache TTL, without a second CONNECT even in flight', async () => { + const upstream = await startTcpEcho(); + const p = await startProxy({ + internalHost: 'mw.internal', + internalPort: upstream.port, + jobs: [{ jobId: JOB_ID, allowlist: [], proxyToken: PROXY_TOKEN }], + resolveMap: { 'mw.internal': [{ address: '127.0.0.1', family: 4 }] }, + resolveCacheTtlMs: 60_000, + }); + try { + const first = await sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth()); + assert.equal(first.statusCode, 200); + first.socket.destroy(); + await waitFor(() => p.events.some((e) => e.decision === 'close')); + const second = await sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth()); + assert.equal(second.statusCode, 200); + second.socket.destroy(); + assert.deepEqual(p.resolveCalls, ['mw.internal'], 'the second CONNECT reused the cached resolution'); + } finally { + await p.close(); + await upstream.close(); + } + }); + + it('a fresh lookup runs again once the cache entry expires', async () => { + const upstream = await startTcpEcho(); + const p = await startProxy({ + internalHost: 'mw.internal', + internalPort: upstream.port, + jobs: [{ jobId: JOB_ID, allowlist: [], proxyToken: PROXY_TOKEN }], + resolveMap: { 'mw.internal': [{ address: '127.0.0.1', family: 4 }] }, + resolveCacheTtlMs: 20, + }); + try { + const first = await sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth()); + assert.equal(first.statusCode, 200); + first.socket.destroy(); + await new Promise((r) => setTimeout(r, 40)); + const second = await sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth()); + assert.equal(second.statusCode, 200); + second.socket.destroy(); + assert.deepEqual(p.resolveCalls, ['mw.internal', 'mw.internal'], 'the expired entry triggers a fresh lookup'); + } finally { + await p.close(); + await upstream.close(); + } + }); + + it('a failed resolution is never cached — the next CONNECT gets a fresh attempt', async () => { + const upstream = await startTcpEcho(); + let calls = 0; + const p = await startProxy({ + internalHost: 'mw.internal', + internalPort: upstream.port, + jobs: [{ jobId: JOB_ID, allowlist: [], proxyToken: PROXY_TOKEN }], + // First CONNECT's lookup fails outright; the second must not reuse + // that failure (there is nothing to reuse) and must succeed on retry. + customResolve: async (_host) => { + calls += 1; + if (calls === 1) throw new Error('simulated transient DNS failure'); + return [{ address: '127.0.0.1', family: 4 }]; + }, + }); + try { + const first = await sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth()); + assert.equal(first.statusCode, 502, 'the first CONNECT sees the resolve failure'); + const second = await sendConnect(p.dataPort, `mw.internal:${upstream.port}`, basicAuth()); + assert.equal(second.statusCode, 200, 'the second CONNECT gets a fresh, successful lookup'); + second.socket.destroy(); + assert.equal(calls, 2, 'the failure was not cached — a real second attempt happened'); + } finally { + await p.close(); + await upstream.close(); + } + }); +}); + describe('egress proxy — absolute-form plain HTTP forward', () => { it('forwards a GET to the pinned IP and relays the response', async () => { const upstream = await startHttpUpstream(); From 1e85136447d723b617a1642bea3255c71add8116 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Tue, 28 Jul 2026 22:11:46 +0200 Subject: [PATCH 28/34] fix(dev-platform): give job containers static /etc/hosts entries for their egress allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of npm's 'Exit handler never called!' bug (confirmed live, 2026-07-28): npm's own HTTP client (@npmcli/agent) resolves its target hostname LOCALLY before/alongside going through the CONNECT proxy. Confirmed with a direct in-container test: dns.lookup('registry.npmjs.org') fails in 4ms with EAI_AGAIN — Docker's embedded resolver (127.0.0.11) has no upstream route for external names inside the job's isolated per-job network (spec section 6's DNS-exfil defence: no direct internet DNS by design). Hit for every concurrent package fetch, this triggers npm's own confirmed ExitHandler re-entrancy race (npm/cli#9751). This is NOT specific to npm ci, npm's version, or maxsockets — ANY tool doing local resolution of an allowlisted host hits the same wall (confirmed: npm install -g of a single package fails identically). An earlier attempt to fix this by caching DNS resolution INSIDE the egress proxy (commit 3be2800f) had zero effect, because the proxy's own resolve() was never the bottleneck — it only ever saw 3 successful calls for the entire failing install, confirmed via temporary diagnostic logging. The failures were happening entirely inside the job container's own network namespace, invisible to the proxy. Fix: the daemon already knows a job's exact egress allowlist before the container starts (it registers it with the proxy's control plane). It now ALSO pre-resolves each allowlisted hostname with its own (real, unsandboxed) DNS and passes the result as Docker's ExtraHosts, giving the job container static host:ip entries. Any tool's local resolution of an allowlisted host now succeeds immediately via /etc/hosts, with zero new egress capability — every entry names a host the job could already reach through the CONNECT proxy; this only makes LOCAL resolution of that SAME host succeed too. A host that fails to resolve is skipped, not fatal — the CONNECT tunnel path (the proxy's own independent resolution) still works for it regardless. clamp.mjs's buildContainerCreateOptions gains an extraHosts parameter, applied as HostConfig.ExtraHosts and added to the clamp's own 'exactly these keys' allowlist test — distinct from the still-forbidden Dns field (a general resolver override would be a real allowlist bypass; this is not, since it only pre-answers hosts already permitted). --- .../sidecars/dev-runner-daemon/src/clamp.mjs | 20 ++++- .../sidecars/dev-runner-daemon/src/jobs.mjs | 54 +++++++++++++ .../dev-runner-daemon/test/clamp.test.mjs | 19 +++++ .../dev-runner-daemon/test/jobs.test.mjs | 77 ++++++++++++++++++- 4 files changed, 167 insertions(+), 3 deletions(-) diff --git a/middleware/sidecars/dev-runner-daemon/src/clamp.mjs b/middleware/sidecars/dev-runner-daemon/src/clamp.mjs index 5a88ae0d..f11c713f 100644 --- a/middleware/sidecars/dev-runner-daemon/src/clamp.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/clamp.mjs @@ -230,7 +230,7 @@ export function jobVolumeName(jobId) { * @returns {import('dockerode').ContainerCreateOptions} */ export function buildContainerCreateOptions(args) { - const { jobId, policy, leaseExpiresAt, networkName, volumeName, createdBy, limits } = args; + const { jobId, policy, leaseExpiresAt, networkName, volumeName, createdBy, limits, extraHosts } = args; const dockerInJob = args.dockerInJob === true; // Fail closed: only an explicit `false` relaxes the digest requirement. const requireDigest = args.requireDigest !== false; @@ -319,6 +319,24 @@ export function buildContainerCreateOptions(args) { Ulimits: [{ Name: 'nofile', Soft: limits.nofile, Hard: limits.nofile }], // A job container never restarts — a dead job is a dead job. RestartPolicy: { Name: 'no' }, + // Static `host:ip` entries the DAEMON pre-resolved for this job's OWN + // egress allowlist (jobs.mjs's resolveAllowlistHosts, using the daemon's + // real internet DNS — the job's isolated network has none by design). + // This is NOT a general DNS override: unlike the forbidden `Dns` field + // (which would let a policy point resolution at an arbitrary server and + // escape the allowlist entirely), every entry here names a host the job + // could already reach through the CONNECT proxy — it only makes LOCAL + // resolution of that SAME already-permitted host succeed too. Root + // cause (2026-07-28): npm's own HTTP client (@npmcli/agent) resolves + // its target hostname locally before/alongside the CONNECT tunnel; the + // job network's embedded resolver (127.0.0.11) has no upstream route + // for external names and returns EAI_AGAIN instantly, which — hit for + // every concurrent package fetch — triggers npm's own confirmed + // ExitHandler re-entrancy race (npm/cli#9751, "Exit handler never + // called!"). Always present (possibly empty) so the clamp's own + // "exactly these keys" invariant holds regardless of whether this + // job's policy allowlisted anything. + ExtraHosts: extraHosts ?? [], }, }; } diff --git a/middleware/sidecars/dev-runner-daemon/src/jobs.mjs b/middleware/sidecars/dev-runner-daemon/src/jobs.mjs index f6dc00ca..4ff787de 100644 --- a/middleware/sidecars/dev-runner-daemon/src/jobs.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/jobs.mjs @@ -30,7 +30,9 @@ */ import { randomBytes } from 'node:crypto'; +import { lookup as dnsLookup } from 'node:dns/promises'; import { readFileSync } from 'node:fs'; +import { isIP } from 'node:net'; import { PassThrough, Readable } from 'node:stream'; import { join } from 'node:path'; @@ -915,6 +917,52 @@ export function resolvePullPolicy(env) { ); } +/** + * Pre-resolve a job's egress allowlist using the DAEMON's own DNS (this + * process runs with normal internet access, unlike the job's isolated + * per-job network, which has no route to a real resolver by design — spec + * §6's "DNS-exfil defence"). The result feeds `buildContainerCreateOptions`'s + * `extraHosts`, giving the job container static `/etc/hosts` entries for + * hosts it can ALREADY reach through the CONNECT proxy — this adds no new + * egress capability, it only makes LOCAL name resolution of those SAME + * already-permitted hosts succeed. + * + * Root cause this exists for (2026-07-28, epic #470): npm's own HTTP client + * (`@npmcli/agent`) resolves its target hostname locally before/alongside + * the CONNECT tunnel. Confirmed live: a direct `dns.lookup()` inside a job + * container fails in 4ms with `EAI_AGAIN` — Docker's embedded resolver + * (127.0.0.11) has no upstream for external names. Hit for every concurrent + * package fetch, this triggers npm's own confirmed ExitHandler re-entrancy + * race (npm/cli#9751, "Exit handler never called!"). Any tool doing local + * resolution of an allowlisted host hits the same wall — this fixes it at + * the root rather than chasing npm's specific internal behavior. + * + * A host that fails to resolve here is skipped, not fatal — the CONNECT + * tunnel path (the proxy's own, independent resolution) still works for it + * regardless; this is a best-effort improvement to LOCAL resolution, not a + * new correctness requirement for egress itself. + * + * @param {readonly string[]} allowlist + * @param {(host: string) => Promise<{ address: string }>} [lookup] DNS seam (tests inject a fake). + * @returns {Promise} `host:ip` entries, Docker's `ExtraHosts` format. + */ +export async function resolveAllowlistHosts(allowlist, lookup = dnsLookup) { + const entries = await Promise.all( + allowlist.map(async (host) => { + // Already a literal — nothing to pre-resolve, and it would be a + // malformed ExtraHosts entry (Docker expects a NAME on the left). + if (isIP(host) !== 0) return null; + try { + const { address } = await lookup(host); + return `${host}:${address}`; + } catch { + return null; + } + }), + ); + return entries.filter((entry) => entry !== null); +} + /** * A real dockerode-backed engine implementing the full §4 container lifecycle * behind the `ContainerEngine` seam. `createJobContainer` builds the create-options @@ -926,10 +974,12 @@ export function resolvePullPolicy(env) { * @param {object} [opts] * @param {Docker} [opts.docker] Injected client (else built from env). * @param {NodeJS.ProcessEnv} [opts.env] + * @param {(host: string) => Promise<{ address: string }>} [opts.resolveHost] DNS seam for `resolveAllowlistHosts` (tests inject a fake). * @returns {ContainerEngine} */ export function createDockerEngine(opts = {}) { const env = opts.env ?? process.env; + const resolveHost = opts.resolveHost ?? dnsLookup; const docker = opts.docker ?? new Docker(dockerOptionsFromEnv(env)); const limits = resolveClampLimits(env); const createdBy = env.DEV_RUNNER_CREATED_BY?.trim() || 'omadia-middleware'; @@ -959,6 +1009,9 @@ export function createDockerEngine(opts = {}) { const networkName = jobNetworkName(jobId); const volumeName = jobVolumeName(jobId); const dockerInJob = policy.dockerInJob === true; + // Pre-resolve the allowlist with the DAEMON's own working DNS — the job's + // isolated network has none (see resolveAllowlistHosts's own doc comment). + const extraHosts = await resolveAllowlistHosts(policy.egressAllowlist, resolveHost); // Build (and thereby VALIDATE) the create-options FIRST: a forbidden spec // (a floating-tag image) throws SpecRejectedError here, before any docker // resource is created — so a rejected job leaks nothing. @@ -970,6 +1023,7 @@ export function createDockerEngine(opts = {}) { volumeName, createdBy, limits, + extraHosts, requireDigest, dockerInJob, }); diff --git a/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs b/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs index 53dface5..54cab479 100644 --- a/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/clamp.test.mjs @@ -84,6 +84,24 @@ describe('buildContainerCreateOptions — REQUIRED clamp fields present', () => }); }); +describe('buildContainerCreateOptions — ExtraHosts (pre-resolved allowlist entries)', () => { + it('defaults to an empty array when no extraHosts are given', () => { + const hc = build().HostConfig ?? {}; + assert.deepEqual(hc.ExtraHosts, []); + }); + + it('passes the given entries through verbatim — this function derives nothing itself', () => { + const entries = ['registry.npmjs.org:104.16.0.35', 'github.com:140.82.121.3']; + const hc = build({ extraHosts: entries }).HostConfig ?? {}; + assert.deepEqual(hc.ExtraHosts, entries); + }); + + it('is still distinct from the forbidden Dns field — a resolver override stays refused', () => { + const hc = build({ extraHosts: ['registry.npmjs.org:104.16.0.35'] }).HostConfig ?? {}; + assert.equal(hc.Dns, undefined); + }); +}); + describe('buildContainerCreateOptions — FORBIDDEN options are absent by construction', () => { const o = build(); const hc = /** @type {Record} */ (o.HostConfig ?? {}); @@ -94,6 +112,7 @@ describe('buildContainerCreateOptions — FORBIDDEN options are absent by constr const allowed = [ 'Binds', 'CapDrop', + 'ExtraHosts', 'Memory', 'MemorySwap', 'NanoCpus', diff --git a/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs b/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs index 61a90d3a..bbdb0a9e 100644 --- a/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs @@ -19,7 +19,7 @@ import { describe, it } from 'node:test'; import Docker from 'dockerode'; import { SpecRejectedError } from '../src/clamp.mjs'; -import { createDockerEngine, JobCancelledError, JobCapacityError, JobCleanupError, JobManager, resolvePullPolicy } from '../src/jobs.mjs'; +import { createDockerEngine, JobCancelledError, JobCapacityError, JobCleanupError, JobManager, resolveAllowlistHosts, resolvePullPolicy } from '../src/jobs.mjs'; const JOB_ID = '11111111-1111-4111-8111-111111111111'; @@ -501,8 +501,9 @@ function makeFakeDocker(opts = {}) { volumes.add(o.Name); return { Name: o.Name }; }, - async createContainer(_o) { + async createContainer(o) { if (opts.containerCreateFail) throw opts.containerCreateFail; + if (opts.createContainerCalls) opts.createContainerCalls.push(o); const id = `ctr-${++seq}`; containers.add(id); return containerHandle(id); @@ -530,6 +531,45 @@ function makeFakeDocker(opts = {}) { }; } +describe('resolveAllowlistHosts — pre-resolves an allowlist for ExtraHosts', () => { + it('resolves each host and formats Docker\'s host:ip ExtraHosts entries', async () => { + const lookup = async (host) => { + if (host === 'registry.npmjs.org') return { address: '104.16.0.35' }; + if (host === 'github.com') return { address: '140.82.121.3' }; + throw new Error(`unexpected lookup for ${host}`); + }; + const result = await resolveAllowlistHosts(['registry.npmjs.org', 'github.com'], lookup); + assert.deepEqual(result.sort(), ['github.com:140.82.121.3', 'registry.npmjs.org:104.16.0.35'].sort()); + }); + + it('skips a host that fails to resolve — non-fatal, the CONNECT path still works for it', async () => { + const lookup = async (host) => { + if (host === 'flaky.example.com') throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); + return { address: '203.0.113.10' }; + }; + const result = await resolveAllowlistHosts(['flaky.example.com', 'good.example.com'], lookup); + assert.deepEqual(result, ['good.example.com:203.0.113.10']); + }); + + it('skips an already-literal IP entry — nothing to pre-resolve, and it is not a valid ExtraHosts name', async () => { + let called = false; + const lookup = async () => { + called = true; + return { address: '0.0.0.0' }; + }; + const result = await resolveAllowlistHosts(['203.0.113.5'], lookup); + assert.deepEqual(result, []); + assert.equal(called, false, 'an IP literal is never handed to the lookup seam'); + }); + + it('an empty allowlist resolves to an empty array', async () => { + const result = await resolveAllowlistHosts([], async () => { + throw new Error('must not be called'); + }); + assert.deepEqual(result, []); + }); +}); + describe('createDockerEngine — createJobContainer applies the clamp and provisions cleanly', () => { it('pulls by digest, creates the per-job network+volume+container, starts it, returns the handle', async () => { const docker = makeFakeDocker(); @@ -552,6 +592,39 @@ describe('createDockerEngine — createJobContainer applies the clamp and provis assert.equal(docker.state.events.started.length, 1, 'the container was started'); }); + it('pre-resolves the egress allowlist and passes host:ip entries as HostConfig.ExtraHosts', async () => { + const createContainerCalls = []; + const docker = makeFakeDocker({ createContainerCalls }); + const lookup = async (host) => { + if (host === 'registry.npmjs.org') return { address: '104.16.0.35' }; + throw new Error(`unexpected lookup for ${host}`); + }; + const engine = createDockerEngine({ docker, env: {}, resolveHost: lookup }); + + await engine.createJobContainer({ + jobId: JOB_ID, + policy: { ...enginePolicy(), egressAllowlist: ['registry.npmjs.org'] }, + leaseExpiresAt: '2026-07-10T12:00:00.000Z', + }); + + assert.equal(createContainerCalls.length, 1); + assert.deepEqual(createContainerCalls[0].HostConfig.ExtraHosts, ['registry.npmjs.org:104.16.0.35']); + }); + + it('an empty egress allowlist yields an empty ExtraHosts array — no change for a repo with none', async () => { + const createContainerCalls = []; + const docker = makeFakeDocker({ createContainerCalls }); + const engine = createDockerEngine({ docker, env: {} }); + + await engine.createJobContainer({ + jobId: JOB_ID, + policy: enginePolicy(), + leaseExpiresAt: '2026-07-10T12:00:00.000Z', + }); + + assert.deepEqual(createContainerCalls[0].HostConfig.ExtraHosts, []); + }); + it('refuses a floating-tag image with spec_rejected BEFORE creating any resource', async () => { const docker = makeFakeDocker(); const engine = createDockerEngine({ docker, env: {} }); From 4f73e23ad1c632901dc9b3952e3c7b982a382a73 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 29 Jul 2026 07:00:01 +0200 Subject: [PATCH 29/34] fix(dev-platform): resolve the egress allowlist through the PROXY, not the daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction to commit 1e85136: that fix pre-resolved the job's egress allowlist using the DAEMON's own DNS, on the assumption the daemon has normal internet access. Deployed and tested live — it did NOT fix the crash. Direct verification: dns.lookup('registry.npmjs.org') from INSIDE the daemon container itself also fails in EAI_AGAIN. The daemon is on the same kind of DNS-restricted network as job containers in this deployment; only the egress proxy has a real route to the internet (confirmed: the proxy resolves registry.npmjs.org to 12 IPv4 + 12 IPv6 addresses without issue). Fix: the proxy's control plane gains POST /resolve (bearer-authed, same as the existing PUT/DELETE /jobs/:id routes) — the daemon asks the PROXY to pre-resolve a job's allowlist in one batched call, using the exact same resolver the data plane already trusts (egressPolicy's classify/pin path is unaffected; only the lookup is reused, not the CONNECT tunnel's own independent resolution). JobManager#provision now calls this right after registering the job's allowlist with the proxy, and passes the result to createJobContainer as extraHosts — which no longer resolves anything itself, since createDockerEngine has no route to the internet either. A resolution failure (proxy unreachable, one host unresolvable) never aborts job creation — it only means fewer /etc/hosts entries, logged, not fatal. resolveAllowlistHosts's signature changed from a per-host lookup function to the proxy client's batched resolveHosts, since a job's allowlist (dozens of hosts, e.g. registry.npmjs.org plus GitHub's own endpoints) should cost one proxy round-trip, not one per host. --- .../sidecars/dev-runner-daemon/src/jobs.mjs | 101 +++++++++-------- .../sidecars/dev-runner-daemon/src/proxy.mjs | 35 ++++++ .../dev-runner-daemon/src/proxyClient.mjs | 43 ++++++- .../dev-runner-daemon/test/jobs.test.mjs | 94 +++++++++++----- .../dev-runner-daemon/test/proxy.test.mjs | 106 ++++++++++++++++++ 5 files changed, 308 insertions(+), 71 deletions(-) diff --git a/middleware/sidecars/dev-runner-daemon/src/jobs.mjs b/middleware/sidecars/dev-runner-daemon/src/jobs.mjs index 4ff787de..8127d0f6 100644 --- a/middleware/sidecars/dev-runner-daemon/src/jobs.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/jobs.mjs @@ -21,16 +21,18 @@ * clamp unit fills them, so an accidental early call fails loudly. * * SEAM CONTRACT — `ContainerEngine.createJobContainer({ jobId, policy, - * leaseExpiresAt })`: given a job id, the SERVER-DERIVED `DerivedJobPolicy` - * (image/env/egressAllowlist, fetched by the daemon — never caller-supplied), - * and the computed lease expiry, create and start ONE hardened container and - * return its `{ containerId, networkId, volumeName, imageDigest }`. The clamp, - * per-job network, and workspace volume are the implementation's responsibility; - * the JobManager only stores what it returns. + * leaseExpiresAt, extraHosts })`: given a job id, the SERVER-DERIVED + * `DerivedJobPolicy` (image/env/egressAllowlist, fetched by the daemon — + * never caller-supplied), the computed lease expiry, and ALREADY-RESOLVED + * `host:ip` entries for the allowlist (JobManager#provision resolves them via + * the proxy client — the engine has no route of its own to the internet), + * create and start ONE hardened container and return its `{ containerId, + * networkId, volumeName, imageDigest }`. The clamp, per-job network, and + * workspace volume are the implementation's responsibility; the JobManager + * only stores what it returns. */ import { randomBytes } from 'node:crypto'; -import { lookup as dnsLookup } from 'node:dns/promises'; import { readFileSync } from 'node:fs'; import { isIP } from 'node:net'; import { PassThrough, Readable } from 'node:stream'; @@ -135,7 +137,7 @@ export const LEASE_EXPIRES_LABEL = 'ai.omadia.dev.leaseExpiresAt'; * mutating methods; this unit provides `ping` and the fake used by tests. * @typedef {object} ContainerEngine * @property {() => Promise} ping - * @property {(args: { jobId: string, policy: DerivedJobPolicy, leaseExpiresAt: string }) => Promise} createJobContainer + * @property {(args: { jobId: string, policy: DerivedJobPolicy, leaseExpiresAt: string, extraHosts?: readonly string[] }) => Promise} createJobContainer * @property {(container: JobContainer) => Promise} destroyJobContainer * @property {(container: JobContainer, opts: { follow: boolean }) => Promise} streamLogs * @property {(refs: readonly string[]) => Promise} warmImages @@ -420,6 +422,7 @@ export class JobManager { const leaseExpiresAt = this.#leaseExpiry(leaseTtlSec); const hardDeadlineAt = new Date(this.#clock.now() + this.#maxLifetimeMs).toISOString(); + let extraHosts = []; if (this.#proxyClient && proxyToken) { // BEFORE the container starts: a runner that boots first races its own first // request against this call. The TTL is the job's hard deadline, not its @@ -433,11 +436,23 @@ export class JobManager { proxyToken, ttlSec, }); + // Pre-resolve the allowlist THROUGH THE PROXY (the only component with a + // real route to the internet — confirmed live the daemon itself has none) + // so the container's static /etc/hosts covers whatever LOCAL resolution a + // tool inside it attempts (resolveAllowlistHosts's own doc comment has the + // full story: npm's exit-handler crash traced to exactly this gap). A + // resolution failure here must never abort provisioning — it only means + // one fewer /etc/hosts entry, not a broken job. + try { + extraHosts = await resolveAllowlistHosts(policy.egressAllowlist, (hosts) => this.#proxyClient.resolveHosts(hosts)); + } catch (err) { + this.#log(`[jobs] allowlist pre-resolution failed for ${jobId}, continuing without extra /etc/hosts entries: ${err instanceof Error ? err.message : String(err)}`); + } } let container; try { - container = await this.#engine.createJobContainer({ jobId, policy, leaseExpiresAt }); + container = await this.#engine.createJobContainer({ jobId, policy, leaseExpiresAt, extraHosts }); } catch (err) { // No container exists, so nothing may keep egress authorisation. Failing to // withdraw it is not fatal (it expires at the hard deadline) but it is never silent. @@ -918,49 +933,47 @@ export function resolvePullPolicy(env) { } /** - * Pre-resolve a job's egress allowlist using the DAEMON's own DNS (this - * process runs with normal internet access, unlike the job's isolated - * per-job network, which has no route to a real resolver by design — spec - * §6's "DNS-exfil defence"). The result feeds `buildContainerCreateOptions`'s - * `extraHosts`, giving the job container static `/etc/hosts` entries for - * hosts it can ALREADY reach through the CONNECT proxy — this adds no new - * egress capability, it only makes LOCAL name resolution of those SAME - * already-permitted hosts succeed. + * Pre-resolve a job's egress allowlist so the container can be given static + * `/etc/hosts` entries (Docker's `ExtraHosts`) for hosts it can ALREADY reach + * through the CONNECT proxy — this adds no new egress capability, it only + * makes LOCAL name resolution of those SAME already-permitted hosts succeed. * * Root cause this exists for (2026-07-28, epic #470): npm's own HTTP client * (`@npmcli/agent`) resolves its target hostname locally before/alongside * the CONNECT tunnel. Confirmed live: a direct `dns.lookup()` inside a job * container fails in 4ms with `EAI_AGAIN` — Docker's embedded resolver - * (127.0.0.11) has no upstream for external names. Hit for every concurrent - * package fetch, this triggers npm's own confirmed ExitHandler re-entrancy - * race (npm/cli#9751, "Exit handler never called!"). Any tool doing local - * resolution of an allowlisted host hits the same wall — this fixes it at - * the root rather than chasing npm's specific internal behavior. + * (127.0.0.11) has no upstream for external names (spec §6's "DNS-exfil + * defence": the job network has no route to a real resolver by design). Hit + * for every concurrent package fetch, this triggers npm's own confirmed + * ExitHandler re-entrancy race (npm/cli#9751, "Exit handler never called!"). + * Any tool doing local resolution of an allowlisted host hits the same + * wall — this fixes it at the root rather than chasing npm's internals. + * + * The resolution itself MUST go through the proxy's `resolveHosts` (not the + * daemon's own DNS) — confirmed live that the daemon container is ALSO on an + * isolated network with no internet route; only the egress proxy is. Batched + * into ONE call rather than per-host, since the proxy is reachable but the + * daemon otherwise has no network dependency on it for anything but this. * * A host that fails to resolve here is skipped, not fatal — the CONNECT * tunnel path (the proxy's own, independent resolution) still works for it * regardless; this is a best-effort improvement to LOCAL resolution, not a - * new correctness requirement for egress itself. + * new correctness requirement for egress itself. An IP literal in the + * allowlist is skipped too — nothing to pre-resolve, and it would be a + * malformed `ExtraHosts` entry (Docker expects a NAME on the left). * * @param {readonly string[]} allowlist - * @param {(host: string) => Promise<{ address: string }>} [lookup] DNS seam (tests inject a fake). + * @param {(hosts: readonly string[]) => Promise | null }>>} resolveHosts + * The proxy client's batched resolver (tests inject a fake). * @returns {Promise} `host:ip` entries, Docker's `ExtraHosts` format. */ -export async function resolveAllowlistHosts(allowlist, lookup = dnsLookup) { - const entries = await Promise.all( - allowlist.map(async (host) => { - // Already a literal — nothing to pre-resolve, and it would be a - // malformed ExtraHosts entry (Docker expects a NAME on the left). - if (isIP(host) !== 0) return null; - try { - const { address } = await lookup(host); - return `${host}:${address}`; - } catch { - return null; - } - }), - ); - return entries.filter((entry) => entry !== null); +export async function resolveAllowlistHosts(allowlist, resolveHosts) { + const toResolve = allowlist.filter((host) => isIP(host) === 0); + if (toResolve.length === 0) return []; + const results = await resolveHosts(toResolve); + return results + .filter((r) => r.addresses && r.addresses.length > 0) + .map((r) => `${r.host}:${/** @type {{ address: string }[]} */ (r.addresses)[0].address}`); } /** @@ -974,12 +987,10 @@ export async function resolveAllowlistHosts(allowlist, lookup = dnsLookup) { * @param {object} [opts] * @param {Docker} [opts.docker] Injected client (else built from env). * @param {NodeJS.ProcessEnv} [opts.env] - * @param {(host: string) => Promise<{ address: string }>} [opts.resolveHost] DNS seam for `resolveAllowlistHosts` (tests inject a fake). * @returns {ContainerEngine} */ export function createDockerEngine(opts = {}) { const env = opts.env ?? process.env; - const resolveHost = opts.resolveHost ?? dnsLookup; const docker = opts.docker ?? new Docker(dockerOptionsFromEnv(env)); const limits = resolveClampLimits(env); const createdBy = env.DEV_RUNNER_CREATED_BY?.trim() || 'omadia-middleware'; @@ -1005,13 +1016,13 @@ export function createDockerEngine(opts = {}) { } }, - async createJobContainer({ jobId, policy, leaseExpiresAt }) { + async createJobContainer({ jobId, policy, leaseExpiresAt, extraHosts = [] }) { const networkName = jobNetworkName(jobId); const volumeName = jobVolumeName(jobId); const dockerInJob = policy.dockerInJob === true; - // Pre-resolve the allowlist with the DAEMON's own working DNS — the job's - // isolated network has none (see resolveAllowlistHosts's own doc comment). - const extraHosts = await resolveAllowlistHosts(policy.egressAllowlist, resolveHost); + // extraHosts arrives ALREADY resolved — the caller (JobManager#provision) + // is the one with a proxy client (this engine has none, and no route of + // its own to the internet regardless; see resolveAllowlistHosts's doc). // Build (and thereby VALIDATE) the create-options FIRST: a forbidden spec // (a floating-tag image) throws SpecRejectedError here, before any docker // resource is created — so a rejected job leaks nothing. diff --git a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs index aebd1043..278aea8b 100644 --- a/middleware/sidecars/dev-runner-daemon/src/proxy.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/proxy.mjs @@ -493,6 +493,41 @@ export function createProxy(deps) { return; } const url = new URL(req.url ?? '/', 'http://proxy.local'); + + // POST /resolve — the daemon has no route to the internet by design (only + // the proxy does); this lets it pre-resolve a job's allowlist for static + // /etc/hosts entries (spec §470, the npm local-DNS-bypass root cause) + // using the SAME resolver the data plane trusts, without granting the + // daemon egress of its own. Bearer-authed like every other control route; + // NOT allowlist-gated — a resolution reveals only a public IP for a name + // the caller already supplied, nothing a public DNS query wouldn't. + if (url.pathname === '/resolve' && req.method === 'POST') { + let body; + try { + body = await readJsonBody(req); + } catch { + sendJson(res, 400, { code: 'proxy.bad_body', message: 'invalid JSON body' }); + return; + } + const hosts = Array.isArray(body?.hosts) ? body.hosts.filter((h) => typeof h === 'string') : null; + if (!hosts || hosts.length === 0 || hosts.length > 100) { + sendJson(res, 400, { code: 'proxy.bad_body', message: 'hosts must be a non-empty array of at most 100 strings' }); + return; + } + const results = await Promise.all( + hosts.map(async (host) => { + try { + const addresses = await resolve(host); + return { host, addresses }; + } catch { + return { host, addresses: null }; + } + }), + ); + sendJson(res, 200, { results }); + return; + } + const m = /^\/jobs\/([^/]+)$/.exec(url.pathname); if (!m) { sendJson(res, 404, { code: 'proxy.not_found', message: 'no such route' }); diff --git a/middleware/sidecars/dev-runner-daemon/src/proxyClient.mjs b/middleware/sidecars/dev-runner-daemon/src/proxyClient.mjs index 633dd929..c021fb80 100644 --- a/middleware/sidecars/dev-runner-daemon/src/proxyClient.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/proxyClient.mjs @@ -46,6 +46,10 @@ export class ProxyControlError extends Error { * @typedef {object} ProxyClient * @property {(jobId: string, entry: { allowlist: readonly string[], proxyToken: string, ttlSec: number }) => Promise} register * @property {(jobId: string) => Promise} unregister + * @property {(hosts: readonly string[]) => Promise | null }>>} resolveHosts + * Pre-resolve hostnames using the proxy's OWN internet-reachable resolver — + * the daemon has none by design. `addresses: null` for a host that failed + * to resolve; non-fatal by contract, the caller decides what to do with it. */ /** @@ -98,7 +102,7 @@ export function createProxyClient(deps) { try { json = await res.json(); } catch { - json = null; + // leave json null } return { status: res.status, json }; })(); @@ -134,5 +138,42 @@ export function createProxyClient(deps) { } return Boolean(/** @type {any} */ (json)?.deleted); }, + + async resolveHosts(hosts) { + if (hosts.length === 0) return []; + const url = `${origin}/resolve`; + const controller = new AbortController(); + const run = (async () => { + const res = await fetchImpl(url, { + method: 'POST', + redirect: 'error', + signal: controller.signal, + headers: { authorization: `Bearer ${deps.token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ hosts }), + }); + let json = null; + try { + json = await res.json(); + } catch { + // leave json null + } + return { status: res.status, json }; + })(); + let status, json; + try { + ({ status, json } = await withDeadline(run, timeoutMs, () => controller.abort())); + } catch (err) { + throw new ProxyControlError( + 'proxy.control_unreachable', + `POST ${url} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (status !== 200) { + const code = typeof (/** @type {any} */ (json)?.code) === 'string' ? json.code : 'proxy.control_rejected'; + throw new ProxyControlError(code, `proxy refused to resolve hosts (HTTP ${status})`, status); + } + const results = /** @type {any} */ (json)?.results; + return Array.isArray(results) ? results : []; + }, }; } diff --git a/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs b/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs index bbdb0a9e..166bb078 100644 --- a/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/jobs.test.mjs @@ -532,34 +532,39 @@ function makeFakeDocker(opts = {}) { } describe('resolveAllowlistHosts — pre-resolves an allowlist for ExtraHosts', () => { - it('resolves each host and formats Docker\'s host:ip ExtraHosts entries', async () => { - const lookup = async (host) => { - if (host === 'registry.npmjs.org') return { address: '104.16.0.35' }; - if (host === 'github.com') return { address: '140.82.121.3' }; - throw new Error(`unexpected lookup for ${host}`); + it('resolves each host in ONE batched call and formats Docker\'s host:ip ExtraHosts entries', async () => { + const calls = []; + const resolveHosts = async (hosts) => { + calls.push(hosts); + return [ + { host: 'registry.npmjs.org', addresses: [{ address: '104.16.0.35' }] }, + { host: 'github.com', addresses: [{ address: '140.82.121.3' }] }, + ]; }; - const result = await resolveAllowlistHosts(['registry.npmjs.org', 'github.com'], lookup); + const result = await resolveAllowlistHosts(['registry.npmjs.org', 'github.com'], resolveHosts); assert.deepEqual(result.sort(), ['github.com:140.82.121.3', 'registry.npmjs.org:104.16.0.35'].sort()); + assert.equal(calls.length, 1, 'exactly one batched call, not one per host'); + assert.deepEqual(calls[0].sort(), ['github.com', 'registry.npmjs.org'].sort()); }); - it('skips a host that fails to resolve — non-fatal, the CONNECT path still works for it', async () => { - const lookup = async (host) => { - if (host === 'flaky.example.com') throw Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' }); - return { address: '203.0.113.10' }; - }; - const result = await resolveAllowlistHosts(['flaky.example.com', 'good.example.com'], lookup); + it('skips a host that failed to resolve (addresses: null) — non-fatal, the CONNECT path still works for it', async () => { + const resolveHosts = async () => [ + { host: 'flaky.example.com', addresses: null }, + { host: 'good.example.com', addresses: [{ address: '203.0.113.10' }] }, + ]; + const result = await resolveAllowlistHosts(['flaky.example.com', 'good.example.com'], resolveHosts); assert.deepEqual(result, ['good.example.com:203.0.113.10']); }); it('skips an already-literal IP entry — nothing to pre-resolve, and it is not a valid ExtraHosts name', async () => { let called = false; - const lookup = async () => { + const resolveHosts = async () => { called = true; - return { address: '0.0.0.0' }; + return []; }; - const result = await resolveAllowlistHosts(['203.0.113.5'], lookup); + const result = await resolveAllowlistHosts(['203.0.113.5'], resolveHosts); assert.deepEqual(result, []); - assert.equal(called, false, 'an IP literal is never handed to the lookup seam'); + assert.equal(called, false, 'an IP literal is never handed to the batched resolver'); }); it('an empty allowlist resolves to an empty array', async () => { @@ -592,19 +597,16 @@ describe('createDockerEngine — createJobContainer applies the clamp and provis assert.equal(docker.state.events.started.length, 1, 'the container was started'); }); - it('pre-resolves the egress allowlist and passes host:ip entries as HostConfig.ExtraHosts', async () => { + it('passes caller-supplied extraHosts straight through to HostConfig.ExtraHosts — this engine resolves nothing itself', async () => { const createContainerCalls = []; const docker = makeFakeDocker({ createContainerCalls }); - const lookup = async (host) => { - if (host === 'registry.npmjs.org') return { address: '104.16.0.35' }; - throw new Error(`unexpected lookup for ${host}`); - }; - const engine = createDockerEngine({ docker, env: {}, resolveHost: lookup }); + const engine = createDockerEngine({ docker, env: {} }); await engine.createJobContainer({ jobId: JOB_ID, - policy: { ...enginePolicy(), egressAllowlist: ['registry.npmjs.org'] }, + policy: enginePolicy(), leaseExpiresAt: '2026-07-10T12:00:00.000Z', + extraHosts: ['registry.npmjs.org:104.16.0.35'], }); assert.equal(createContainerCalls.length, 1); @@ -1055,14 +1057,23 @@ describe('JobManager — egress-proxy registration is part of provisioning', () if (overrides.unregisterError) throw overrides.unregisterError; return true; }, + async resolveHosts(hosts) { + calls.push({ op: 'resolveHosts', hosts }); + if (overrides.resolveHostsError) throw overrides.resolveHostsError; + return overrides.resolveHostsResult ?? hosts.map((host) => ({ host, addresses: null })); + }, }; } - /** An engine that records the order of operations against the proxy calls. */ + /** An engine that records the order of operations against the proxy calls + * and captures the exact args each createJobContainer call received. */ function orderedEngine(order, opts = {}) { return { - async createJobContainer() { + /** @type {any[]} */ + createJobContainerCalls: [], + async createJobContainer(args) { order.push('createContainer'); + this.createJobContainerCalls.push(args); if (opts.createError) throw opts.createError; return { jobId: JOB_ID, id: 'c1', networkId: 'n1', volumeName: 'v1', imageDigest: 'sha256:abc' }; }, @@ -1185,4 +1196,37 @@ describe('JobManager — egress-proxy registration is part of provisioning', () await jm.destroy(JOB_ID); assert.equal(jm.size(), 0); }); + + it('resolves the allowlist through the proxy and passes the result to createJobContainer as extraHosts', async () => { + const engine = orderedEngine([]); + const proxy = recordingProxyClient({ + resolveHostsResult: [{ host: 'registry.npmjs.org', addresses: [{ address: '104.16.0.35' }] }], + }); + const policyClient = { + async fetchJobPolicy(jobId) { + return { jobId, image: 'ghcr.io/x/y@sha256:abc', env: {}, egressAllowlist: ['registry.npmjs.org'] }; + }, + }; + const jm = new JobManager({ engine, policyClient, proxyClient: proxy }); + await jm.create(JOB_ID, 180); + + const resolveCall = proxy.calls.find((c) => c.op === 'resolveHosts'); + assert.deepEqual(resolveCall.hosts, ['registry.npmjs.org']); + assert.deepEqual(engine.createJobContainerCalls[0].extraHosts, ['registry.npmjs.org:104.16.0.35']); + }); + + it('a resolveHosts failure never aborts job creation — the job proceeds with no extra /etc/hosts entries', async () => { + const engine = orderedEngine([]); + const proxy = recordingProxyClient({ resolveHostsError: new Error('proxy unreachable') }); + const policyClient = { + async fetchJobPolicy(jobId) { + return { jobId, image: 'ghcr.io/x/y@sha256:abc', env: {}, egressAllowlist: ['registry.npmjs.org'] }; + }, + }; + const jm = new JobManager({ engine, policyClient, proxyClient: proxy }); + await jm.create(JOB_ID, 180); + + assert.deepEqual(engine.createJobContainerCalls[0].extraHosts, []); + assert.equal(jm.size(), 1, 'the job was still created despite the resolution failure'); + }); }); diff --git a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs index c92e67ac..72173001 100644 --- a/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/proxy.test.mjs @@ -544,6 +544,112 @@ describe('egress proxy — control plane (daemon-token, per-job allowlist push)' }); }); +describe('egress proxy — control plane: POST /resolve (the daemon has no internet route of its own)', () => { + it('rejects an unauthenticated resolve request', async () => { + const p = await startProxy({ resolveMap: { 'a.test': [{ address: '203.0.113.1', family: 4 }] } }); + try { + const res = await controlRequest(p.controlPort, 'POST', '/resolve', 'wrong', { hosts: ['a.test'] }); + assert.equal(res.statusCode, 401); + } finally { + await p.close(); + } + }); + + it('resolves every requested host in one call using the SAME resolver the data plane trusts', async () => { + const p = await startProxy({ + resolveMap: { + 'registry.npmjs.org': [{ address: '104.16.0.35', family: 4 }], + 'github.com': [{ address: '140.82.121.3', family: 4 }], + }, + }); + try { + const res = await controlRequest(p.controlPort, 'POST', '/resolve', DAEMON_TOKEN, { + hosts: ['registry.npmjs.org', 'github.com'], + }); + assert.equal(res.statusCode, 200); + const byHost = Object.fromEntries(res.body.results.map((r) => [r.host, r.addresses])); + assert.deepEqual(byHost['registry.npmjs.org'], [{ address: '104.16.0.35', family: 4 }]); + assert.deepEqual(byHost['github.com'], [{ address: '140.82.121.3', family: 4 }]); + assert.deepEqual(p.resolveCalls.sort(), ['github.com', 'registry.npmjs.org']); + } finally { + await p.close(); + } + }); + + it('reports null addresses for a host that fails to resolve — one bad host does not fail the whole batch', async () => { + const p = await startProxy({ + customResolve: async (host) => { + if (host === 'flaky.example.com') throw new Error('simulated DNS failure'); + return [{ address: '203.0.113.10', family: 4 }]; + }, + }); + try { + const res = await controlRequest(p.controlPort, 'POST', '/resolve', DAEMON_TOKEN, { + hosts: ['flaky.example.com', 'good.example.com'], + }); + assert.equal(res.statusCode, 200); + const byHost = Object.fromEntries(res.body.results.map((r) => [r.host, r.addresses])); + assert.equal(byHost['flaky.example.com'], null); + assert.deepEqual(byHost['good.example.com'], [{ address: '203.0.113.10', family: 4 }]); + } finally { + await p.close(); + } + }); + + it('400s on an empty, missing, or oversized hosts array — never a silent no-op', async () => { + const p = await startProxy(); + try { + const empty = await controlRequest(p.controlPort, 'POST', '/resolve', DAEMON_TOKEN, { hosts: [] }); + assert.equal(empty.statusCode, 400); + const missing = await controlRequest(p.controlPort, 'POST', '/resolve', DAEMON_TOKEN, {}); + assert.equal(missing.statusCode, 400); + const oversized = await controlRequest(p.controlPort, 'POST', '/resolve', DAEMON_TOKEN, { + hosts: Array.from({ length: 101 }, (_, i) => `h${i}.test`), + }); + assert.equal(oversized.statusCode, 400); + } finally { + await p.close(); + } + }); +}); + +describe('createProxyClient — resolveHosts (the daemon-side caller of POST /resolve)', () => { + it('calls POST /resolve with the bearer token and returns the results array', async () => { + const p = await startProxy({ + resolveMap: { 'registry.npmjs.org': [{ address: '104.16.0.35', family: 4 }] }, + }); + try { + const client = createProxyClient({ controlUrl: `http://127.0.0.1:${p.controlPort}`, token: DAEMON_TOKEN }); + const results = await client.resolveHosts(['registry.npmjs.org']); + assert.deepEqual(results, [{ host: 'registry.npmjs.org', addresses: [{ address: '104.16.0.35', family: 4 }] }]); + } finally { + await p.close(); + } + }); + + it('an empty hosts array short-circuits — no request is made', async () => { + const p = await startProxy(); + try { + const client = createProxyClient({ controlUrl: `http://127.0.0.1:${p.controlPort}`, token: DAEMON_TOKEN }); + const results = await client.resolveHosts([]); + assert.deepEqual(results, []); + assert.deepEqual(p.resolveCalls, []); + } finally { + await p.close(); + } + }); + + it('throws ProxyControlError on a wrong token, never silently returning empty', async () => { + const p = await startProxy(); + try { + const client = createProxyClient({ controlUrl: `http://127.0.0.1:${p.controlPort}`, token: 'wrong-token' }); + await assert.rejects(() => client.resolveHosts(['a.test']), /proxy refused to resolve hosts/); + } finally { + await p.close(); + } + }); +}); + /** PUT /jobs/:id on the control plane. */ function controlPut(controlPort, jobId, body, token) { return controlRequest(controlPort, 'PUT', `/jobs/${jobId}`, token, body); From a6f2edba9449199224034d5e3873ba1543abd836 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 29 Jul 2026 07:46:31 +0200 Subject: [PATCH 30/34] fix(dev-platform): deterministic instant-reject for any egress bypass attempt, plus npm proxy-config pin Root cause chain (epic #470, 2026-07-29), each step confirmed live: 1. npm's own HTTP client occasionally lands on a direct-connect code path instead of the configured CONNECT-tunnel proxy path (mechanism not fully pinned down at the exact @npmcli/agent line, per research - see below). 2. The job container's per-job network was NEVER actually escapable: dind (the engine that creates every job container) is itself attached ONLY to dev-engine and dev-egress, both marked internal: true in docker-compose.dev-platform.yaml - no real internet route exists at any layer a bypass attempt could reach. Confirmed by reading the compose topology directly, not inferred. 3. BUT the failure mode for a doomed bypass attempt was non-deterministic - sometimes an instant ENETUNREACH, sometimes a silent ~240s TCP blackhole (observed live). That variable multi-minute stall, not the bypass attempt itself, is what re-triggers npm's own confirmed ExitHandler re-entrancy race (npm/cli#9751) - the same bug class behind the ORIGINAL EAI_AGAIN-driven crash this investigation started from, now triggered by TCP timing variance instead of DNS timing variance. Fix (new middleware/sidecars/dev-dind/Dockerfile + entrypoint.sh): dind is already run privileged: true - no new capability is granted anywhere. Its entrypoint now adds ONE static iptables rule in its OWN netns, on the DOCKER-USER chain (dockerd's documented host-admin insertion point, never flushed/reordered by network create/prune): any FORWARDED packet - i.e. traffic dind is relaying from a nested per-job container, never dind's own process-level OUTPUT traffic - that isn't headed to 172.28.5.0/24 (the egress-proxy's own network) gets REJECTed with icmp-net-unreachable immediately, instead of silently timing out. Verified standalone: a nested container's connection attempt to a real external IP now fails in ~1s with 6 packets hitting the REJECT counter, versus the previously observed 240s stall. Zero change to the job container's own security clamp (CapDrop: ALL, no-new-privileges, single NetworkMode) in clamp.mjs/jobs.mjs - this lives entirely one layer down, in dind's netns, which was already privileged. Complementary fix (policyClient.mjs): also inject npm's own config-layer proxy env vars (npm_config_proxy, npm_config_https_proxy, npm_config_noproxy) alongside the existing HTTP_PROXY/HTTPS_PROXY/NO_PROXY (both spellings). npm's proxy-vs-direct decision reads its OWN resolved config, which resolves npm_config_* env vars before generic HTTP_PROXY - pinning both layers closes a config-precedence bug class (npm/cli#6835, npm/agent#125) as a contributing factor, independent of which exact code path was responsible. Same daemon-owned-key protection as the existing proxy vars - a policy supplying any of the three is rejected exactly like HTTP_PROXY already is. --- docker-compose.dev-platform.yaml | 13 +++++- middleware/sidecars/dev-dind/Dockerfile | 9 ++++ middleware/sidecars/dev-dind/entrypoint.sh | 45 +++++++++++++++++++ .../dev-runner-daemon/src/policyClient.mjs | 26 ++++++++--- .../test/policyClient.test.mjs | 25 ++++++++++- 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 middleware/sidecars/dev-dind/Dockerfile create mode 100644 middleware/sidecars/dev-dind/entrypoint.sh diff --git a/docker-compose.dev-platform.yaml b/docker-compose.dev-platform.yaml index 293c90b2..30f14388 100644 --- a/docker-compose.dev-platform.yaml +++ b/docker-compose.dev-platform.yaml @@ -195,7 +195,18 @@ services: # --- the nested engine: the only privileged service in the stack ---------- dev-dind: - image: docker:27-dind + # Thin wrapper around docker:27-dind (see middleware/sidecars/dev-dind): + # adds ONE static iptables rule in dind's OWN netns so a nested per-job + # container's direct-connect bypass attempt (confirmed live, 2026-07-29 — + # npm's own proxy resolution occasionally lands on a direct-connect code + # path) fails in milliseconds instead of a multi-minute TCP blackhole. + # dev-engine/dev-egress are already `internal: true`, so the bypass was + # ALWAYS doomed — this only makes the failure deterministic, closing the + # timing window that re-triggers npm's own ExitHandler race (npm/cli#9751). + # Zero capability change to the job container itself. + image: omadia-dev-dind:local + build: + context: middleware/sidecars/dev-dind restart: unless-stopped privileged: true environment: diff --git a/middleware/sidecars/dev-dind/Dockerfile b/middleware/sidecars/dev-dind/Dockerfile new file mode 100644 index 00000000..41d1f1ee --- /dev/null +++ b/middleware/sidecars/dev-dind/Dockerfile @@ -0,0 +1,9 @@ +# Epic #470 — thin wrapper around the official docker:dind image, adding the +# deterministic egress-guard entrypoint (see entrypoint.sh). Everything else +# (dockerd itself, TLS cert generation, iptables) is the base image unchanged. +FROM docker:27-dind + +COPY entrypoint.sh /usr/local/bin/omadia-dind-entrypoint.sh +RUN chmod +x /usr/local/bin/omadia-dind-entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/omadia-dind-entrypoint.sh"] diff --git a/middleware/sidecars/dev-dind/entrypoint.sh b/middleware/sidecars/dev-dind/entrypoint.sh new file mode 100644 index 00000000..92c54eb6 --- /dev/null +++ b/middleware/sidecars/dev-dind/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Epic #470 — deterministic fail-fast for any nested (per-job) container that +# somehow attempts a direct connection instead of going through the egress +# proxy at 172.28.5.3 (dev-egress). Confirmed live (2026-07-29): dev-dind's +# own two networks (dev-engine, dev-egress) are BOTH `internal: true`, so a +# bypass attempt was already structurally incapable of reaching the real +# internet — but depending on kernel/network state, the failure mode varied +# between an instant ENETUNREACH and a silent multi-minute TCP blackhole +# (observed: ~240s). That variable stall, not the bypass itself, is what +# re-triggered npm's own confirmed ExitHandler re-entrancy race (npm/cli#9751 +# — the same bug class behind the original EAI_AGAIN-driven crash this whole +# investigation started from). +# +# This adds ONE static iptables rule in dev-dind's OWN network namespace +# (already `privileged: true` — no new capability granted anywhere) that +# REJECTs (not silently drops) any FORWARDED packet — i.e. traffic dind is +# relaying from a nested per-job container, never dind's own process-level +# traffic, which uses OUTPUT, not FORWARD, and is untouched by this — unless +# it's headed to the proxy's own network (172.28.5.0/24, dev-egress: the +# proxy itself plus dind's own presence there). Everything else fails in +# milliseconds instead of minutes. Zero change to the job container's own +# security clamp (CapDrop: ALL, no-new-privileges, single NetworkMode) — +# this lives entirely one layer down, in dind's netns. +set -eu + +# Start dockerd via the base image's own entrypoint, in the background, so +# DOCKER-USER (created by dockerd itself on boot) exists before we touch it. +/usr/local/bin/dockerd-entrypoint.sh "$@" & +DOCKERD_PID=$! + +# Poll for DOCKER-USER rather than a fixed sleep — dockerd's own boot time +# varies (image pulls, TLS cert generation). +until iptables -L DOCKER-USER >/dev/null 2>&1; do + sleep 0.2 +done + +# Idempotent: a restart of this container must not stack duplicate rules. +iptables -N OMADIA-EGRESS-GUARD 2>/dev/null || true +iptables -F OMADIA-EGRESS-GUARD +iptables -A OMADIA-EGRESS-GUARD -d 172.28.5.0/24 -j RETURN +iptables -A OMADIA-EGRESS-GUARD -j REJECT --reject-with icmp-net-unreachable +iptables -C DOCKER-USER -j OMADIA-EGRESS-GUARD 2>/dev/null \ + || iptables -I DOCKER-USER 1 -j OMADIA-EGRESS-GUARD + +wait "$DOCKERD_PID" diff --git a/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs b/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs index 3fc284a7..6adba1d0 100644 --- a/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs +++ b/middleware/sidecars/dev-runner-daemon/src/policyClient.mjs @@ -197,15 +197,17 @@ const ALLOWED_ENV_KEYS = new Set([ * UUID-validated job id, not a value the middleware can skew. * - `OMADIA_WORKSPACE` — where the repo is cloned; must be the container's fixed * workspace path, not a policy-chosen directory. - * - `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (and their lowercase spellings) — + * - `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (and their lowercase spellings, plus + * npm's own `npm_config_proxy` / `npm_config_https_proxy` / `npm_config_noproxy`) — * the container-wide egress-routing lever. A policy value would redirect every * http(s) client in the container (clone, SCM-token exchange, diff upload, - * git, node, curl) through an attacker-chosen proxy. The egress proxy's + * git, node, curl, npm) through an attacker-chosen proxy. The egress proxy's * address is deployment topology the daemon knows from its own config, so the * daemon injects it (`DEV_RUNNER_EGRESS_PROXY_URL` / `DEV_RUNNER_NO_PROXY`) - * and never accepts it from the policy. Both spellings are owned because - * curl/libcurl honour the lowercase names and git/node the uppercase ones — - * admitting either from the policy would reopen the lever the other closes. + * and never accepts it from the policy. Every spelling is owned because + * curl/libcurl honour the lowercase names, git/node the uppercase ones, and + * npm's own config layer resolves `npm_config_*` before either — admitting + * any one from the policy would reopen the lever the others close. * * A policy that CARRIES any of these is not a legitimate policy — it is a * compromised or spoofed middleware. So we REJECT it loudly (`assertPolicyEnv`) @@ -226,6 +228,9 @@ const DAEMON_OWNED_ENV_KEYS = new Set([ 'http_proxy', 'https_proxy', 'no_proxy', + 'npm_config_proxy', + 'npm_config_https_proxy', + 'npm_config_noproxy', ]); /** The container's fixed, job-scoped clone directory (W1 clamp: the per-job @@ -490,6 +495,16 @@ function injectDaemonOwnedEnv(policyEnv, jobId, owned) { env.HTTPS_PROXY = withCreds; env.http_proxy = withCreds; env.https_proxy = withCreds; + // npm's OWN config layer (lib/utils/config, @npmcli/config) resolves + // `proxy`/`https-proxy`/`noproxy` from `npm_config_*` env vars BEFORE it + // ever looks at generic HTTP_PROXY/HTTPS_PROXY — @npmcli/agent then reads + // the resolved npm config, not the raw env, for its own proxy-vs-direct + // decision. Pinning both layers closes a config-precedence class of bug + // (npm/cli#6835, npm/agent#125) as a contributing factor in the + // DNS-bypass-then-ENETUNREACH investigation (epic #470, 2026-07-29) — + // independent of whichever exact code path was choosing direct-connect. + env.npm_config_proxy = withCreds; + env.npm_config_https_proxy = withCreds; // UNLIKE curl/git, Node's own global `fetch` (undici) does NOT read // HTTP_PROXY/HTTPS_PROXY/NO_PROXY by default — that's opt-in, gated behind // this exact flag (undici's EnvHttpProxyAgent). The shim's homeClient.ts is @@ -506,6 +521,7 @@ function injectDaemonOwnedEnv(policyEnv, jobId, owned) { if (owned.noProxy) { env.NO_PROXY = owned.noProxy; env.no_proxy = owned.noProxy; + env.npm_config_noproxy = owned.noProxy; } return env; } diff --git a/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs b/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs index 0428d058..295e393e 100644 --- a/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs +++ b/middleware/sidecars/dev-runner-daemon/test/policyClient.test.mjs @@ -263,6 +263,11 @@ describe('policyClient — daemon-owned env keys are injected, never accepted', 'http_proxy', 'https_proxy', 'no_proxy', + // npm's own config-layer spellings — same egress-redirect risk as the + // generic env vars above, so daemon-owned on the same terms. + 'npm_config_proxy', + 'npm_config_https_proxy', + 'npm_config_noproxy', ]; // A policy that carries any of these is a compromised/spoofed middleware trying @@ -313,6 +318,21 @@ describe('policyClient — daemon-owned env keys are injected, never accepted', assert.equal(policy.env.no_proxy, 'middleware,localhost'); }); + it('ALSO pins npm\'s own config-layer proxy vars — npm resolves npm_config_* before generic HTTP_PROXY', async () => { + // Root cause context (epic #470, 2026-07-29): npm's proxy-vs-direct + // decision reads its OWN resolved config, not raw env, directly. A + // config-precedence bug (npm/cli#6835, npm/agent#125 class) could still + // let npm miss the generic HTTP_PROXY/HTTPS_PROXY vars even when they are + // set correctly — pinning npm's own env-var spelling closes that gap. + const client = clientWith(policyBody(), { + clientOpts: { egressProxyUrl: 'http://egress-proxy:3128', noProxy: 'middleware,localhost' }, + }); + const policy = await client.fetchJobPolicy(JOB_ID); + assert.equal(policy.env.npm_config_proxy, 'http://egress-proxy:3128'); + assert.equal(policy.env.npm_config_https_proxy, 'http://egress-proxy:3128'); + assert.equal(policy.env.npm_config_noproxy, 'middleware,localhost'); + }); + it('injects NODE_USE_ENV_PROXY alongside a configured proxy — otherwise Node fetch ignores HTTP_PROXY entirely', async () => { // Unlike curl/git, Node's global fetch (undici) does NOT read HTTP_PROXY by // default; the shim's homeClient.ts uses fetch with no dependency, so @@ -336,7 +356,7 @@ describe('policyClient — daemon-owned env keys are injected, never accepted', }); const policy = await client.fetchJobPolicy(JOB_ID, { proxyToken: token }); const expected = `http://${JOB_ID}:${token}@egress-proxy:3128/`; - for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']) { + for (const k of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'npm_config_proxy', 'npm_config_https_proxy']) { assert.equal(policy.env[k], expected, `${k} must carry the job's own credential`); } }); @@ -359,6 +379,9 @@ describe('policyClient — daemon-owned env keys are injected, never accepted', 'NO_PROXY', 'no_proxy', 'NODE_USE_ENV_PROXY', + 'npm_config_proxy', + 'npm_config_https_proxy', + 'npm_config_noproxy', ]) { assert.equal(policy.env[k], undefined, `${k} must be absent without a configured proxy`); } From d16d302f8634530987550372f568d6a1781a9a33 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 29 Jul 2026 08:00:00 +0200 Subject: [PATCH 31/34] fix(dev-platform): dev-dind egress guard must allow ESTABLISHED/RELATED return traffic The initial version (a6f2edb) only allowed FORWARDED traffic whose DESTINATION was the proxy's subnet (172.28.5.0/24). Deployed and tested live: this broke EVERY legitimate proxy-bound connection, not just bypass attempts, since the return leg of an established connection (proxy replies: TCP ACKs, the CONNECT response, tunnel data) is forwarded with the JOB CONTAINER's own per-job-network IP as its destination, never the proxy's subnet - a destination-only rule rejected that too. Confirmed by flushing the chain entirely: the shim's own phone-home fetch, which failed instantly on every attempt with the rule active, started working the moment the rule was removed. Fix: add 'iptables -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN' as the first rule, before the destination check. The initiating leg of a connection still must be destined for 172.28.5.0/24 to be allowed out (no security regression - a bypass attempt's own outbound SYN still gets REJECTed instantly, verified standalone), but once that connection is established, its return traffic is correctly allowed regardless of which per-job IP it is addressed back to. --- middleware/sidecars/dev-dind/entrypoint.sh | 26 ++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/middleware/sidecars/dev-dind/entrypoint.sh b/middleware/sidecars/dev-dind/entrypoint.sh index 92c54eb6..8f527cf1 100644 --- a/middleware/sidecars/dev-dind/entrypoint.sh +++ b/middleware/sidecars/dev-dind/entrypoint.sh @@ -11,13 +11,16 @@ # — the same bug class behind the original EAI_AGAIN-driven crash this whole # investigation started from). # -# This adds ONE static iptables rule in dev-dind's OWN network namespace -# (already `privileged: true` — no new capability granted anywhere) that -# REJECTs (not silently drops) any FORWARDED packet — i.e. traffic dind is -# relaying from a nested per-job container, never dind's own process-level -# traffic, which uses OUTPUT, not FORWARD, and is untouched by this — unless -# it's headed to the proxy's own network (172.28.5.0/24, dev-egress: the -# proxy itself plus dind's own presence there). Everything else fails in +# This adds a small, STATEFUL iptables ruleset in dev-dind's OWN network +# namespace (already `privileged: true` — no new capability granted +# anywhere): any FORWARDED packet — i.e. traffic dind is relaying from a +# nested per-job container, never dind's own process-level traffic, which +# uses OUTPUT, not FORWARD, and is untouched by this — gets REJECTed (not +# silently dropped) UNLESS it belongs to an already-established connection +# (conntrack ESTABLISHED,RELATED — required for the proxy's own RETURN +# traffic, whose destination is the job container's per-job IP, never the +# proxy's own subnet) or is headed to the proxy's own network (172.28.5.0/24, +# dev-egress, for the connection's initiating leg). Everything else fails in # milliseconds instead of minutes. Zero change to the job container's own # security clamp (CapDrop: ALL, no-new-privileges, single NetworkMode) — # this lives entirely one layer down, in dind's netns. @@ -37,6 +40,15 @@ done # Idempotent: a restart of this container must not stack duplicate rules. iptables -N OMADIA-EGRESS-GUARD 2>/dev/null || true iptables -F OMADIA-EGRESS-GUARD +# RETURN-leg traffic for an ALREADY-established connection (proxy -> job +# container: TCP ACKs, the CONNECT response, tunnel data) is forwarded with +# its destination being the JOB CONTAINER's own per-job-network IP, never +# 172.28.5.0/24 — a destination-only rule rejects that return traffic too, +# breaking every legitimate proxy-bound connection after its first packet. +# Confirmed live (2026-07-29): with only the destination rule, the shim's +# own phone-home fetch failed instantly on every attempt; flushing the chain +# entirely fixed it immediately. This conntrack rule must come first. +iptables -A OMADIA-EGRESS-GUARD -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN iptables -A OMADIA-EGRESS-GUARD -d 172.28.5.0/24 -j RETURN iptables -A OMADIA-EGRESS-GUARD -j REJECT --reject-with icmp-net-unreachable iptables -C DOCKER-USER -j OMADIA-EGRESS-GUARD 2>/dev/null \ From 529cbf27a3dd61a08f0c0119868d9298b2df6ff8 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 29 Jul 2026 08:30:14 +0200 Subject: [PATCH 32/34] fix(dev-platform): forward proxy config into the bootstrap command's env Root cause, likely THE actual cause of the entire "Exit handler never called!" investigation (epic #470, 2026-07-29): bootstrapEnv() in phaseRunner.ts built the bootstrap command's environment from scratch with ONLY PATH/HOME/LANG - deliberately hermetic to keep LLM-session secrets (ANTHROPIC_*, OMADIA_JOB_TOKEN) out of a plain shell command. That's correct for secrets, but it also stripped ALL proxy config (HTTPS_PROXY, npm_config_*), which bootstrap needs - a spawned child does not inherit the shim's own process.env automatically, the same reason agentRunner.ts's buildAgentEnv and gitOps.ts's runGit already forward these explicitly. Confirmed live: `env` inside a real bootstrap command showed only PATH/HOME/LANG/PWD - no HTTPS_PROXY at all. So npm (or any tool run during bootstrap) always attempted direct connections, which the job network structurally cannot complete - explaining the crash pattern independent of which exact npm-internal proxy-bypass code path was responsible. bootstrapEnv() now also forwards HTTP_PROXY/HTTPS_PROXY/NO_PROXY (both cases) and npm_config_proxy/npm_config_https_proxy/ npm_config_noproxy from process.env, while still excluding every LLM-session secret. New regression test asserts both: proxy vars reach the bootstrap command's captured env, ANTHROPIC_API_KEY does not. --- .../dev-runner-shim/src/phaseRunner.ts | 37 +++++++++++++++++- .../dev-runner-shim/test/phaseLoop.test.ts | 38 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/middleware/packages/dev-runner-shim/src/phaseRunner.ts b/middleware/packages/dev-runner-shim/src/phaseRunner.ts index 84acdd1c..d9fd07b1 100644 --- a/middleware/packages/dev-runner-shim/src/phaseRunner.ts +++ b/middleware/packages/dev-runner-shim/src/phaseRunner.ts @@ -348,13 +348,46 @@ function runCommand( }); } -/** Minimal hermetic env for the bootstrap command — no LLM auth, job-scoped HOME. */ +/** + * Minimal hermetic env for the bootstrap command — no LLM auth, job-scoped + * HOME. "Minimal" deliberately excludes ANTHROPIC_* and OMADIA_JOB_TOKEN and + * any other LLM-session secret (bootstrap is a plain shell command, not a CLI + * session — it has no business seeing them). It must NOT exclude proxy + * config, though: bootstrap is a spawned child of THIS shim process, which + * does not inherit the shim's own process.env automatically (same reason + * agentRunner.ts's buildAgentEnv and gitOps.ts's runGit both forward these + * explicitly) — and the job's isolated network has no route to ANYTHING + * except through the daemon's egress proxy. Confirmed live (2026-07-29, + * epic #470): without this, `env` inside bootstrap showed ONLY + * PATH/HOME/LANG/PWD — no HTTPS_PROXY at all — so npm (or any tool) + * attempted direct connections for its entire run, which an unrelated + * infra fix (the dev-dind egress guard) then correctly rejected, but the + * REAL bug was here: bootstrap never had a route to succeed in the first + * place. This was very likely the root cause of the "Exit handler never + * called!" investigation's entire failure pattern, not any npm-internal + * proxy-bypass behavior. + */ function bootstrapEnv(workspace: string): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { PATH: process.env['PATH'] ?? '/usr/bin:/bin', HOME: path.join(workspace, 'home'), LANG: process.env['LANG'] ?? 'C.UTF-8', }; + for (const key of [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'npm_config_proxy', + 'npm_config_https_proxy', + 'npm_config_noproxy', + ]) { + const value = process.env[key]; + if (value) env[key] = value; + } + return env; } /** Max artifact size the shim will read back (Forge #2 — bound before the 4 MiB diff --git a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts index 29c1214d..a7d6a0c7 100644 --- a/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts +++ b/middleware/packages/dev-runner-shim/test/phaseLoop.test.ts @@ -269,6 +269,44 @@ describe('runPhasedShim — bootstrap runs as a command, not a CLI session', () assert.deepEqual(homes, [], 'bootstrap starts no agent session'); }); + it('forwards proxy env vars into the bootstrap command, but never LLM/job-auth secrets', async () => { + // Regression: found live (epic #470, 2026-07-29) -- bootstrapEnv() built + // an env of ONLY PATH/HOME/LANG, so a bootstrap command had literally no + // route to anything (the job's isolated network has no path except + // through the daemon's egress proxy). A real npm ci spent its entire + // ~240s budget attempting doomed direct connections instead. Bootstrap + // MUST see the proxy vars (same reason agentRunner.ts's buildAgentEnv + // and gitOps.ts's runGit both forward them) while staying "hermetic" + // about anything LLM-session-specific. + const originalEnv = { ...process.env }; + process.env['HTTPS_PROXY'] = 'http://job-id:token@egress-proxy:3128/'; + process.env['HTTP_PROXY'] = 'http://job-id:token@egress-proxy:3128/'; + process.env['NO_PROXY'] = 'localhost,127.0.0.1'; + process.env['npm_config_https_proxy'] = 'http://job-id:token@egress-proxy:3128/'; + process.env['npm_config_noproxy'] = 'localhost,127.0.0.1'; + // Something bootstrap must NEVER see, to prove this isn't just "forward everything". + process.env['ANTHROPIC_API_KEY'] = 'sk-this-must-not-leak-into-bootstrap'; + try { + const spec = makeSpec({ + phaseContext: { phase: 'bootstrap' }, + bootstrap: { command: 'env', timeoutMs: 30_000 }, + }); + const home = new ScriptedHome(spec, [{ directive: 'done' }]); + await runPhasedShim(env, { home, gitBin, log: () => {} }); + + const boot = home.phaseResults[0]; + const content = boot?.artifact?.content ?? ''; + assert.match(content, /HTTPS_PROXY=http:\/\/job-id:token@egress-proxy:3128/); + assert.match(content, /HTTP_PROXY=http:\/\/job-id:token@egress-proxy:3128/); + assert.match(content, /NO_PROXY=localhost,127\.0\.0\.1/); + assert.match(content, /npm_config_https_proxy=http:\/\/job-id:token@egress-proxy:3128/); + assert.match(content, /npm_config_noproxy=localhost,127\.0\.0\.1/); + assert.doesNotMatch(content, /ANTHROPIC_API_KEY/, 'bootstrap stays hermetic about LLM-session secrets'); + } finally { + process.env = originalEnv; + } + }); + it('captures the command\'s own stdout+stderr into the report, not just its exit code', async () => { // Regression: found live -- a real `npm ci` failure inside a job // container reported only `exitCode:1` with zero further detail; the From e9d1c521c641a3efeeffda52eca467dd0fd1b40b Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 29 Jul 2026 10:34:32 +0200 Subject: [PATCH 33/34] fix(dev-platform): add python3/make/g++ to the runner image for node-gyp With the bootstrap proxy-env fix in place (previous commit), a real npm ci now runs cleanly through the egress proxy and gets as far as compiling native deps. better-sqlite3's prebuild-install step 403's against our default-deny proxy: its prebuilt-binary CDN needs a github.com -> objects.githubusercontent.com redirect chain that isn't (and, to keep the egress allowlist tight, shouldn't be) allowlisted. Its own install script already falls back to `node-gyp rebuild --release` on prebuild-install failure - that fallback just needs python3/make/g++, which the minimal node:22.23.1-slim runner image never had. Confirmed live (epic #470, 2026-07-29): without this, `npm ci` failed in 19s with "Could not find any Python installation to use" from node-gyp, right after the prebuild-install 403. Keeps the egress allowlist unchanged (registry.npmjs.org only) rather than opening it for a CDN redirect chain. --- middleware/sidecars/dev-runner/Dockerfile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/middleware/sidecars/dev-runner/Dockerfile b/middleware/sidecars/dev-runner/Dockerfile index 67ea86d2..508f1abe 100644 --- a/middleware/sidecars/dev-runner/Dockerfile +++ b/middleware/sidecars/dev-runner/Dockerfile @@ -37,9 +37,15 @@ ARG CLAUDE_CODE_VERSION=2.1.187 # git + ca-certificates: HTTPS-only clone (W1 has NO openssh-client — SSH clone # is out of scope, spec §3). ripgrep: the CLI's file search. tini: PID 1 that # reaps the CLI and git children on SIGTERM so a cancelled job leaves no -# zombies. The CLI itself is installed globally at the pinned version. +# zombies. python3/make/g++: node-gyp's compile-from-source fallback for +# native deps (e.g. better-sqlite3) whose prebuild-install step needs a +# github.com -> objects.githubusercontent.com redirect our default-deny egress +# proxy doesn't allowlist (confirmed live, epic #470, 2026-07-29) — building +# from source keeps the egress allowlist tight instead of opening it up for a +# CDN redirect chain. The CLI itself is installed globally at the pinned +# version. RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates ripgrep tini \ + && apt-get install -y --no-install-recommends git ca-certificates ripgrep tini python3 make g++ \ && rm -rf /var/lib/apt/lists/* \ && npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ && npm cache clean --force From 100bd06a7c5b4fb0fab9c965a5fb58d7fcbab850 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Wed, 29 Jul 2026 12:01:16 +0200 Subject: [PATCH 34/34] fix(web-ui): show a completed phase's own artifact, not just its live log The dev-platform job detail page's phase rail lets an operator navigate back to any already-finished phase (analyze/bootstrap/plan/clarify/review), but the body always rendered the same live-only JobLogPane filtered to that phase's SSE events. Once the phase finished (or the page reloaded), there was no second source, so every past phase permanently showed "No log output yet." even though the phase's real result (a `plan`/`questions`/ `bootstrap_report`/`review_verdict` artifact) was sitting in the DB the whole time - exactly what an operator needs to review before approving the gate. `GET /jobs/:id/artifacts` (list) and `GET /artifacts/:id` (content) already existed and already served the gate's own plan text (GateInbox.tsx's getArtifactText, gated to the currently-open gate only). New PhaseArtifactPanel reuses both for every other phase: maps the viewed UI phase to its artifact kind, fetches the most recent artifact of that kind (a retried phase can leave several), and renders it with the existing generic PrettyArtifact renderer - stacked above the same JobLogPane, so a still-running phase keeps its live tail and a finished one gets its actual recorded output. Adds the `listJobArtifacts` api.ts wrapper (the list endpoint had no frontend wrapper at all) and one new i18n key (en+de, `artifactError`). --- .../_components/PhaseArtifactPanel.tsx | 101 ++++++++++++++++++ web-ui/app/admin/dev-platform/_lib/api.ts | 29 +++++ .../app/admin/dev-platform/jobs/[id]/page.tsx | 26 +++-- web-ui/messages/de.json | 1 + web-ui/messages/en.json | 1 + 5 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 web-ui/app/admin/dev-platform/_components/PhaseArtifactPanel.tsx diff --git a/web-ui/app/admin/dev-platform/_components/PhaseArtifactPanel.tsx b/web-ui/app/admin/dev-platform/_components/PhaseArtifactPanel.tsx new file mode 100644 index 00000000..b11e399b --- /dev/null +++ b/web-ui/app/admin/dev-platform/_components/PhaseArtifactPanel.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { useTranslations } from 'next-intl'; + +import type { DevJobUiPhase } from '@/app/_components/devjobs/DevJobPhaseRail'; + +import { getArtifactText, listJobArtifacts, type DevJobArtifactKind } from '../_lib/api'; +import { PrettyArtifact } from './PrettyArtifact'; + +/** + * Epic #470 — a completed phase's own recorded output (plan/questions/ + * bootstrap_report/review_verdict), shown once the live SSE log has nothing + * left for it. The job detail page (`jobs/[id]/page.tsx`) fell back to a + * permanently-empty `JobLogPane` for any phase the operator navigated back + * to after it finished, because that pane is filtered from live-only state — + * there was never a second source once the phase's own log scrolled away or + * the operator reloaded the page. `GET /jobs/:id/artifacts` + + * `GET /artifacts/:id` already existed and already served the gate's own + * plan text (`GateInbox.tsx`'s `getArtifactText`); this reuses both for every + * other phase instead of just the currently-open gate. + */ + +/** Not every phase has a matching artifact kind (`implement`'s output is the + * diff, already linked from the PR stop; `gate`/`pr` render their own body). */ +const PHASE_ARTIFACT_KIND: Partial> = { + analyze: 'analysis', + bootstrap: 'bootstrap_report', + plan: 'plan', + clarify: 'questions', + review: 'review_verdict', +}; + +type FetchState = { kind: 'loading' } | { kind: 'empty' } | { kind: 'error' } | { kind: 'ready'; text: string }; +type PanelState = { kind: 'no-artifact-kind' } | FetchState; + +/** Renders the given phase's own artifact when one exists; `null` when the + * phase has no artifact kind at all (caller falls back to the log pane) or + * none has been recorded yet (also a `JobLogPane` fallback — the phase may + * still be running). */ +export function PhaseArtifactPanel({ + jobId, + phase, +}: { + jobId: string; + phase: DevJobUiPhase; +}): React.ReactElement | null { + const t = useTranslations('adminDevPlatform.detail'); + const kind = PHASE_ARTIFACT_KIND[phase]; + const [fetched, setFetched] = useState({ kind: 'loading' }); + // No artifact kind for this phase ⇒ no fetch ever happens — derive + // 'no-artifact-kind' here rather than storing it, so the effect below never + // needs a synchronous setState for that case (same idiom as GateInbox.tsx's + // `planText`). + const state: PanelState = kind ? fetched : { kind: 'no-artifact-kind' }; + + useEffect(() => { + if (!kind) return; + let cancelled = false; + setFetched({ kind: 'loading' }); + void listJobArtifacts(jobId).then( + (res) => { + if (cancelled) return; + // Multiple retries/resumes can leave several artifacts of the same + // kind (e.g. a retried bootstrap) — the most recent one is the phase's + // actual last outcome. + const latest = res.artifacts + .filter((a) => a.kind === kind) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; + if (!latest) { + setFetched({ kind: 'empty' }); + return; + } + void getArtifactText(latest.id).then( + (text) => { + if (!cancelled) setFetched({ kind: 'ready', text }); + }, + () => { + if (!cancelled) setFetched({ kind: 'error' }); + }, + ); + }, + () => { + if (!cancelled) setFetched({ kind: 'error' }); + }, + ); + return () => { + cancelled = true; + }; + }, [jobId, kind]); + + if (state.kind === 'no-artifact-kind' || state.kind === 'empty') return null; + if (state.kind === 'loading') { + return

{t('loading')}

; + } + if (state.kind === 'error') { + return

{t('artifactError')}

; + } + return ; +} diff --git a/web-ui/app/admin/dev-platform/_lib/api.ts b/web-ui/app/admin/dev-platform/_lib/api.ts index fd919108..ea662f56 100644 --- a/web-ui/app/admin/dev-platform/_lib/api.ts +++ b/web-ui/app/admin/dev-platform/_lib/api.ts @@ -295,6 +295,35 @@ export function retryJob(id: string): Promise<{ ok: boolean; jobId: string }> { return req(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST', body: JSON.stringify({}) }); } +/** Mirrors `middleware/src/devplatform/types.ts`'s `DEV_JOB_ARTIFACT_KINDS`. */ +export type DevJobArtifactKind = + | 'diff' + | 'test_report' + | 'analysis' + | 'plan' + | 'summary' + | 'bootstrap_report' + | 'questions' + | 'answers' + | 'review_verdict'; + +export interface DevJobArtifactSummary { + id: string; + jobId: string; + kind: DevJobArtifactKind; + meta: Record | null; + bytes: number; + createdAt: string; +} + +/** All artifacts recorded for a job (metadata only — fetch content per-id via + * `getArtifactText`). Used to show a completed phase's own output (plan, + * clarify questions, bootstrap log, ...) once the live SSE log has nothing + * left to show for it. */ +export function listJobArtifacts(id: string): Promise<{ artifacts: DevJobArtifactSummary[] }> { + return req(`/jobs/${encodeURIComponent(id)}/artifacts`); +} + /** Same-origin URL for an artifact's text content (the plan is a text artifact). * `GET /artifacts/:id` returns `text/plain`; opening it in a new tab shows the * plan the operator is being asked to approve. */ diff --git a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx index 87f046a8..1d9867b8 100644 --- a/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx +++ b/web-ui/app/admin/dev-platform/jobs/[id]/page.tsx @@ -21,6 +21,7 @@ import { findGateForJob } from '@/app/_components/devjobs/devJobChatCardState'; import { useDevJobEvents, type DevJobEventMessage } from '@/app/_lib/useDevJobEvents'; import { GateCard } from '../../_components/GateInbox'; import { JobLogPane, type LogConnection } from '../../_components/JobLogPane'; +import { PhaseArtifactPanel } from '../../_components/PhaseArtifactPanel'; import { cancelJob, deleteJob, @@ -37,10 +38,14 @@ import { INITIAL_LOG_STATE, foldDevJobEvent, type LogState } from '../../_lib/to * phase rail (keyboard-operable, deep-linkable via `?phase=`), then a two-column * body: the log pane (driven by rail selection) and a metadata sidebar. The * live log streams over SSE through `useDevJobEvents` and sticks to bottom via - * `useStickToBottom`. Every non-`pr` phase gets the same live log pane, - * filtered to that phase's own events (`toolCallLog.ts` stamps each item with - * the phase it happened in) — analyze/bootstrap/plan/clarify run real agent - * sessions too, not just implement. + * `useStickToBottom`. Every non-`gate`/`pr` phase stacks a `PhaseArtifactPanel` + * (that phase's own recorded plan/questions/bootstrap_report/review_verdict, + * once it exists) above the same live log pane, filtered to that phase's own + * events (`toolCallLog.ts` stamps each item with the phase it happened in) — + * analyze/bootstrap/plan/clarify run real agent sessions too, not just + * implement. The log pane alone is live-only state, so navigating back to an + * already-finished phase (or reloading the page) left it permanently empty + * without the artifact panel as a second, persisted source. */ function shortHash(id: string): string { @@ -233,11 +238,14 @@ export default function JobDetailPage(): React.ReactElement { ) : null}
) : ( - phaseToUi(item.phase) === effective)} - connection={conn} - lastEventAgoSec={agoSec} - /> +
+ + phaseToUi(item.phase) === effective)} + connection={conn} + lastEventAgoSec={agoSec} + /> +
)}
diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 295fa0ef..f5c6db69 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -3229,6 +3229,7 @@ "moreDiffLines": "… {count, plural, one {# weitere Zeile} other {# weitere Zeilen}}" }, "openPr": "Pull Request öffnen", + "artifactError": "Das Ergebnis dieser Phase konnte nicht geladen werden.", "logEmpty": "Noch keine Log-Ausgabe.", "scrollToBottom": "Nach unten scrollen", "connection": { diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 12d4b3dc..2ba3ed26 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -3229,6 +3229,7 @@ "moreDiffLines": "… {count, plural, one {# more line} other {# more lines}}" }, "openPr": "Open pull request", + "artifactError": "This phase's recorded output could not be loaded.", "logEmpty": "No log output yet.", "scrollToBottom": "Scroll to bottom", "connection": {