Skip to content

Plugin security & quality cluster: SkillSpector package scanning, prompt PII masking, plugin v1.0 pass (#453, #431, #361)#477

Merged
Weegy merged 7 commits into
mainfrom
feat/cluster-plugin-security-quality
Jul 9, 2026
Merged

Plugin security & quality cluster: SkillSpector package scanning, prompt PII masking, plugin v1.0 pass (#453, #431, #361)#477
Weegy merged 7 commits into
mainfrom
feat/cluster-plugin-security-quality

Conversation

@Weegy

@Weegy Weegy commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This branch implements three related issues around plugin security and quality, hardened through four review rounds (three Claude adversarial reviews + cross-vendor codex review).

#453 — Advisory SkillSpector code scanning for ingested packages

Every executable plugin package that passes PackageUploadService.ingest (direct upload, hub install, Builder install) is statically scanned by an NVIDIA SkillSpector sidecar (middleware/sidecars/skillspector/, enabled via SKILLSPECTOR_URL, deterministic --no-llm mode, dependency pinned to an exact upstream commit SHA). The scan is fire-and-forget after ingest success: a scanner outage or an unset SKILLSPECTOR_URL can never fail or delay an install. Verdicts are cached by ZIP sha256 + scanner version (migration 0021_plugin_verdict.sql), surface as a badge + verdict field on the store detail page, and support an operator ack with a persisted audit trail (ack_severity recorded; ack cleared automatically when a re-scan worsens the verdict). Advisory-only in v1 — nothing blocks.

The result pipeline is fail-closed end to end:

  • The sidecar shim positively verifies SkillSpector's real report schema (observed live via a Docker E2E run against the pinned commit: findings arrive under issues, not the initially guessed keys) and answers ok: false on exit != 0 / non-JSON / schema mismatch; the middleware parser accepts only the promised contract. Anything else is recorded as scan_failed, never as a false no_signals all-clear.
  • Entry-point coverage is guaranteed (codex review fix): upload validation rejects a lifecycle.entry that resolves below node_modules, .git, or any hidden segment (package.entry_unscannable) — the scanner's directory walk skips those, so the runtime would otherwise execute code the scan never saw. As defense in depth, the scanner force-includes the manifest's entry file in the scan payload when the walk skipped it, and records scan_failed when coverage cannot be guaranteed (missing file, symlink, oversize, root escape, undeterminable manifest).

#361 — Free-text user-prompt PII masking (default off)

harness-plugin-privacy-guard 0.3.0 adds a default-off mask_user_prompt setup field. When on, PII spans detected in the user's own message (C0 regex baseline: email, IBAN, phone, German street+postal address, amounts, DOB dates) are replaced with realistic pseudonyms before the prompt crosses the LLM wire, via the shipped v4 pseudonym-projection mechanism with a server-held per-turn map — no on-wire token map. Real values are restored server-side in the final answer and persisted content; masked spans surface (PII-free) as maskedPromptSpans on the PrivacyReceipt. Failure-closed: a baseline failure or residual span blocks the turn; there is no pass-through-unmasked path. Flag-off is byte-identical to previous behavior.

Every LLM-bound copy consumes the masked wire variant: message assembly, live chat history (priorTurns, which replays persisted real values), ingested attachment tail, mid-turn steering messages injected via POST /chat/steer (codex review fix — steered text now runs through the same per-turn map before the iteration loop folds it into the conversation, so answer-side restore covers steered spans and a blocked mask fails the turn closed), model/persona routing, KG-recall query, recalled-context injection, direct-line relay payloads, fact-extraction prompts, nudge pipeline, card router, and the excerpt pass. Server-side persistence stores real values only; user-facing card content is restored before rendering. A committed validation harness (de + en fixtures, C0 tier) documents the recall/precision/latency gates required before enabling the flag for a locale.

#431 — v1.0 readiness pass across the earliest core plugins

Missing READMEs added (web-search, privacy-guard, quality-guard) with purpose, config keys, and published capabilities; agent-seo-analyst operator-catalog description translated to English; privacy-guard package.json aligned with its manifest; the v4 path declared the single canonical privacy-guard implementation; explicit adopt/skip decisions recorded for ctx.jobs / ctx.status / ctx.llm / ctx.mcp per plugin. No wire-behavior changes.

Maintainer decisions & things to sign off in this review

Tests and verification

New/extended coverage: orchestrator-level prompt-mask pipeline tests (main call, fact extraction, recall injection, priorTurns, steering — zero raw spans in the request body, surrogate on the wire, restored answer), upload rejection for node_modules/hidden entry points, scan-payload coverage guard, verdict store/scheduler tests including a pg-gated run, and sidecar parser fail-closed tests.

  • middleware: build + lint + test — 4000 tests, 0 failures.
  • web-ui: typecheck + lint + test — green; re-verified after merging current main (incl. fix(web-ui): humanize provider errors across chat surfaces (#403) #472): typecheck clean, 213/213 tests.
  • Sidecar E2E (Docker): benign fixture → no_signals; exfiltration fixture → flagged (E1); six malformed payload shapes → scan_failed.
  • Cross-vendor codex review: 2 high findings raised and fixed (steering mask bypass, scanner entry-point blind spot); re-review verdict approve, zero findings.

Closes #453
Closes #431
Refs #361

Weegy added 7 commits July 9, 2026 11:12
…ges (#453)

Every executable plugin package that passes PackageUploadService.ingest
(direct upload, hub install, Builder install) is now scanned by an NVIDIA
SkillSpector sidecar — fire-and-forget after ingest success, so a scanner
outage or an unset SKILLSPECTOR_URL can never fail or delay an install.
Verdicts are cached by ZIP sha256 + scanner version (migration 0021),
surface on the store detail page (badge + verdict field), and support an
operator ack with a persisted audit trail. Advisory-only in v1: nothing
blocks, per the approved plan on the issue (role-gated blocking is
deferred until omadia has a role model).
…431)

Checklist items from the 2026-07-08 triage on the issue: add the missing
READMEs (web-search, privacy-guard, quality-guard) with purpose, config
keys, and published capabilities; translate agent-seo-analyst's operator-
catalog description to English; align privacy-guard package.json with its
manifest version (0.2.0); declare the v4 path the single canonical
privacy-guard implementation (no legacy branch exists on main — the split
resolves as documentation, keeping src/v4/ paths stable for #361); record
explicit adopt/skip decisions for ctx.jobs / ctx.status / ctx.llm /
ctx.mcp per plugin and the per-kind package-layout convention. No wire-
behavior changes; privacyV4* suites untouched.
Implements the approved design on the issue: detected PII spans in the
user's own prompt (and the ingested attachment tail) are substituted with
realistic, type-shaped pseudonyms before the prompt crosses the LLM wire,
using the shipped v4 pseudonym-projection mechanism with a server-held
per-turn map that is inverted over the final answer — not a reintroduced
on-wire token map. Detection is the pluggable PromptPiiDetector seam: a
deterministic C0 regex baseline ships active, the C1 transformer slot
ships inert until the committed validation harness passes its documented
recall gates for a locale. Failure-closed: C1 failure degrades to C0 with
audit; baseline failure or a residual span blocks the turn. Gated on the
privacy-guard plugin's new default-off mask_user_prompt setup field —
flag-off is byte-identical to previous behavior. MCP tool-result coverage
via internToolResultV4 pinned in privacyInternPolicy.test.ts.
Review fixup for the prompt-masking wiring:

- Fact extraction (non-stream + direct-line) now receives the MASKED wire
  variants of user message and answer — its Haiku prompt is LLM-bound like
  the main call. Extracted facts are restored surrogate-to-real before
  ingest via a snapshot of the turn map (snapshotPromptRestorer on
  PrivacyGuardService/PrivacyTurnHandle) that stays valid after
  finalizeTurn dropped the live map. Direct-line skips extraction with an
  audit line when masking is blocked (fail closed).
- Persistence stores POST-restore answers: session log / KG / promoted
  memories and the Palaia excerpt carry real values, never surrogates —
  restoration moved before every persist site in chatInContextInner /
  chatStreamInner (final answer, choice-card short-circuits, excerpt,
  auto-promotion fallback).
- Recalled prior context is masked through the same per-turn pseudonym map
  before injection, so a raw span persisted on turn N never re-crosses the
  wire on turn N+1; answer-side restore covers recall spans too.
- createPromptPseudonymMap collision-checks surrogate candidates against
  ALL real values seen so far in the turn (earlier calls included).
- Docs aligned: manifest help/README state the every-LLM-bound-consumer
  coverage and the real-values-persisted contract; CHANGELOG + validation
  README state the de+en / C0-only harness coverage, the C1 inert stub,
  the concrete gates, and that per-locale enablement requires posting
  harness results to issue #361 first; stale 0.2.0 version bullet fixed.
- New orchestrator-level test (promptMaskPipeline.test.ts) asserts masked
  main + fact-extraction LLM params, real persisted values, and masked
  turn-N+1 recall injection with the real guard service and FactExtractor.
Second-review fixes for the #453+#431+#361 cluster:

- #361: input.priorTurns replay persisted REAL values, so every prior
  userMessage and assistant answer is now masked through the same
  per-turn map before message assembly on BOTH paths (non-streaming +
  streaming); the direct-line relay payload is masked before dispatch,
  the sub-agent answer restored, and a blocked mask fails the turn
  closed with the generic privacy answer. Pipeline test extended with
  the IBAN-in-turn-1 / priorTurns-in-turn-2 case.
- #453: the sidecar shim positively verifies SkillSpector's REAL report
  schema (issues + risk_assessment, observed live against the pinned
  commit via Docker: benign -> findings=[], exfil fixture -> E1) and
  answers ok:false on exit!=0 / non-JSON / schema mismatch, so the
  middleware records scan_failed instead of a false no_signals; the TS
  parser drops all field-name guessing too.
- supply chain: requirements.txt pinned to commit
  c2d09df019e358d3dc12d980b82c798b87cb9f56; pin-bump procedure in the
  new sidecar README.
- ack hygiene: acks record ack_severity and are cleared when a re-scan
  WORSENS the verdict (kept when equal/better); covered by fake-store
  tests and a pg-gated AgentGraphStore test run against real Postgres.
- no-scanner UX: SKILLSPECTOR_URL unset wires no scheduler at all — no
  verdict rows, no badge; NullPluginScanner removed.
- cosmetic surrogate exposure: pendingUserChoice (question/rationale/
  options) and follow-up buttons are restored surrogate->real before
  rendering/persisting.
- docs: manifest help, plugin README, CHANGELOG, security-architecture,
  .env.example and compose overlay updated so the 'every LLM-bound
  copy' claim is true and the no-scanner behavior is stated honestly.

Refs #361 #453 #431
…nt blind spot (#361, #453)

Codex review finding 1 (#361): POST /chat/steer text was appended raw to
the LLM-bound message array, bypassing prompt masking. The steering drain
loop now runs every steered message through maskPromptForWire on the same
per-turn pseudonym map (answer-side restore covers steered spans; fails
closed via PromptMaskBlockedError, converted to the graceful privacy done
event by the existing stream guard). The steer_applied echo stays raw —
it is user-facing, not wire content.

Codex review finding 2 (#453): a lifecycle.entry inside node_modules was
accepted by upload validation but skipped by the scanner walk, so the
runtime would execute code SkillSpector never saw under a no_signals
badge. Belt and braces: upload validation now rejects an entry that
resolves below node_modules/.git or any hidden segment
(package.entry_unscannable), and the scanner guarantees entry-point
coverage — force-including the manifest entry file when the walk skipped
it and recording scan_failed when coverage cannot be guaranteed (missing,
symlink, oversize, root escape, undeterminable manifest).

Tests: steering IBAN case in promptMaskPipeline (zero raw spans in the
streaming request body, surrogate on the wire, restored answer); upload
rejection for node_modules/hidden entries; collectScanPayload coverage
guard (force-include, absent entry, symlink, boilerplate layout).
Docs: CHANGELOG + security-architecture claims extended accordingly.
@Weegy Weegy merged commit 527fff3 into main Jul 9, 2026
7 checks passed
Weegy added a commit that referenced this pull request Jul 9, 2026
@Weegy Weegy deleted the feat/cluster-plugin-security-quality branch July 9, 2026 14:26
Weegy added a commit that referenced this pull request Jul 9, 2026
… finding)

Adversarial review of the admin routes proved a cross-operator authorization
gap by executing it: as operator 'bob' it read alice's job and streamed her
agent logs, and cancelled her running job. The launcher gate sat on POST /jobs
and /retry only; the other seven job routes — GET /jobs, GET /jobs/:id, the
artifacts routes, and the epic's single SSE stream — authorized by session
presence alone. /retry gating while /cancel did not was the tell that this was
an oversight, not a design.

The gate that protects who may START a job now equally protects who may read,
stream, cancel, or apply one. loadAuthorizedJob loads the job and checks the
caller against its repo; a missing job and an unauthorized one return the same
404, so it is not an oracle for which ids exist. GET /jobs filters to repos the
caller may launch. GET /jobs/:id/events authorizes before opening the stream —
its events carry log, tool, egress, and token payloads. Four cross-operator
tests now assert bob gets 404 on read, cancel, and SSE (with no log payload in
the body), and that the list is scoped per operator.

Two further review minors:
- isPermittedLauncher matched allowed_launchers against the caller's role, sub,
  AND email. Spec §6 defines them as role keys; folding in sub/email is a
  cross-namespace match that could grant launch to a caller whose opaque sub or
  IdP-controlled email happened to equal an entry. Narrowed to role keys plus
  the creator's sub.
- A failed POST /repos left the staged device-flow token parked in the vault
  pending slot with no repo to clean it up. The error path now clears it.

The load/authorize helpers moved to devPlatformShared.ts to keep devPlatform.ts
under the 500-line rule.

135 tests green. Migration is 0022 (0021 is plugin_verdict, merged to main via
#477; the branch merged main and renumbered).

Refs #470
Weegy added a commit that referenced this pull request Jul 10, 2026
… build-out (#361) (#481)

* feat(privacy): GLiNER PII-detector sidecar (C1 tier, #361)

The C0 regex baseline shipped in #477 structurally cannot detect person
names and free-form addresses — the classes this multilingual GLiNER
sidecar (urchade/gliner_multi_pii-v1, Apache-2.0; Piiranha was rejected
for its CC-BY-NC-ND license) provides for the mask_user_prompt feature.

Skillspector-pattern deployment: stdlib-only HTTP shim, stateless,
fail-closed (never a 200 half-result), exact-version pins, model baked
into the image at a pinned HF revision so the running container needs
no egress. Quantized ONNX on CPU by default to dodge the AVX2 torch
crash hazard; internal-network-only compose overlay because the sidecar
receives raw prompt PII (text and span values are never logged). Span
offsets are Unicode code points with the exact text slice echoed so the
middleware client can verify its UTF-16 conversion — a mis-anchored
span is a leak. Middleware-side wiring follows in the next unit.

* feat(privacy): wire C1 GLiNER detector into the prompt-mask seam (#361)

Implements the PromptPiiDetector contract over the pii-detector sidecar
as a fail-closed HTTP client (createC1HttpDetector, id c1-gliner) and
injects it through the existing createPrivacyGuardService({c1Detector})
slot — no service/mask/orchestrator logic changes; a throw rides the
shipped audited degrade-to-C0 path. Why the strictness: the sidecar
reports Unicode code-point offsets while the seam needs UTF-16, so the
client converts exactly and asserts slice==span text per span — a
mis-anchored person span would mask the wrong characters, i.e. leak.
URL resolves live per call (setup field c1_detector_url first, then
PRIVACY_C1_DETECTOR_URL); unset means C0-only with no C1 call, which is
deliberately not a degrade. Manifest 0.4.0 adds the non-secret URL
field and one reviewable network.outbound entry (plugin was previously
pure compute). Own ref'ed timeout timer instead of AbortSignal.timeout
because that signal's timer is unref'ed and a transport ignoring the
signal must still not stall maskUserPrompt.

* feat(privacy): 6-locale prompt-PII validation build-out (#361)

Fixtures for fr/es/it/nl plus scaled-up de/en (121 items per locale:
89 positives incl. a 25-item hand-built OOD slice, 32 negatives), all
original content (no ai4privacy rows or derivatives — restricted
commercial terms). Harness gains env-built detector sets (c0 always;
c0+c1 with an enforced person-recall gate and a never-gated c1-solo
ablation when PII_DETECTOR_URL is set), per-set un-timed warm-up,
hand-slice person-recall split, informational idnum reporting,
load-time fixture lint that fails the run loudly, and a --markdown
mode emitting GFM tables for posting run results to issue #361.
Scoring/aggregation/rendering helpers extracted to validation/report.ts
(pure functions, both files under the 500-line rule). c0-only run:
de/en still PASS structured gates; fr/es/it/nl C0 locale gaps recorded
honestly in the validation README. Flag policy unchanged.

* feat(web-ui): render maskedPromptSpans in the privacy receipt card (#361)

Mirror PromptMaskedSpanInfo into the frontend PrivacyReceipt and surface
the shipped backend field in PrivacyReceiptCard: collapsed summary chunk
("prompt: N masked"), expanded fact row with a per-type breakdown
(open-set types rendered verbatim, detector ids kept out of the UI), and
a dedicated explainer line. Absent or empty field renders the card
byte-identically to before. New privacyReceipt i18n keys in en + de.

* fix(privacy): overlap remainders kept + recorded 6-locale run (#361)

Review fixes on the C1 prompt-PII branch:

1. dedupSpans resolved detector overlaps by dropping the losing span
   wholesale. Because C0 spans always carry confidence 1, a long C1
   free-form-address span (GLiNER score 0.8) that merely brushed a short
   C0 hit inside it (e.g. the postal code) lost its entire coverage and
   the rest of the address reached the LLM wire unmasked - and the
   post-mask residual check only asserts kept map keys, so it could not
   catch the drop. Winners now own only the contested characters: every
   uncovered remainder of a losing span is kept as a masking span of its
   own (word-boundary re-extended, output still non-overlapping).
   Regression tests cover the reviewer's exact de-fixture scenario at
   the dedupSpans and maskPrompt level.

2. The plan's closing deliverable is now recorded: a full c0 / c0+c1 /
   c1-solo x 6-locale harness run against the real pinned GLiNER ONNX
   sidecar, committed as src/validation/RESULTS.md (tables ready to post
   to issue #361). de/en/it pass ALL gates on c0+c1 (person recall 100%
   incl. the hand-built OOD slice); es/fr/nl fail on the recorded C0
   structured locale gaps, not on C1 quality. Validation README now
   links the recorded run instead of only estimating c0 gaps.

Also: gitignore Python bytecode (sidecar unit-test runs left a
__pycache__/ in the tree).

* docs(privacy): reviewer nits — stale promptMask note, RESULTS.md EOF (#361)
Weegy added a commit that referenced this pull request Jul 11, 2026
…) (#476)

* feat(scripts): spec-driven wave workflows + W0 unit manifest for epic #470

Adds the three-script execution flow for building a large epic from wave specs,
in the idiom of the existing issue-*.workflow.mjs scripts:

- wave-decompose: spec -> conflict-checked unit manifest. Extraction fans out by
  slice; the dependsOn reduce is a barrier because it needs every unit at once.
  Hub files (index.ts, config.ts, messages/*.json) are subtracted from the
  collision signal and assigned to a single wiring unit, so a merge conflict
  becomes a dependency edge.
- wave-implement: worktree-isolated Engineer/Anvil per unit, then a cross-family
  Forge refutation and a Cato security audit on sensitive units. Colliding or
  dependent units run sequentially on one branch; disjoint units pipeline.
  Undeclared spec drift is a review failure; declared drift is expected and
  reported back as specDeltas.
- wave-verify: acceptance checklist, then an independent refuter and a security
  auditor. A criterion passes only when the checker and the refuter agree.
  End-to-end and UI criteria report as 'manual' rather than silently passing.

None of the three writes to GitHub or pushes; the caller owns the remote-write
choke point, and the human gates force the three-script split because a Workflow
script cannot call AskUserQuestion.

docs/dev-platform/w0-manifest.json is the W0 decomposition, derived from the W0
spec: 11 units, verified acyclic, 8 security-sensitive.

* feat(dev-platform): W0 unit w0-schema — migration 0021 + shared types

Migration 0021_dev_platform.sql: dev_repos, dev_jobs, dev_job_events,
dev_job_artifacts. Load-bearing details, each of which a spec review caught:

- dev_job_events.id is a BIGINT IDENTITY and the only ordering key (SSE id: and
  Last-Event-ID read it). A provision column plus UNIQUE(job_id, provision, seq)
  prevents the cross-provision collision that would otherwise discard a gated
  job's entire second-provision event stream under ON CONFLICT DO NOTHING.
- No CHECK on dev_job_events.type or dev_job_artifacts.kind: both enums grow in
  W1-W3 and are validated in TypeScript instead. A CHECK on a growing enum is a
  liability. The remaining six enums stay DB-constrained.
- status is 'applying', not 'pushing': the middleware moves the ref, not the
  runner. kind drops the phantom 'file_issues'. claimed_by is UUID.

types.ts: RUNNER_PROTOCOL_VERSION, the unions, and their runtime validators —
now the only enforcement for type/kind. Each union and its validator derive from
one 'as const' array so they cannot drift. DevJobSpec carries no credential
field; the clone token is fetched at git time and is read-only.

Reviewed by Forge (cross-family): applied the migration twice against Postgres
16, introspected pg_constraint, compiled under strict + noUncheckedIndexedAccess.
No findings.

Refs #470

* feat(dev-platform): W0 unit w0-store — durable job spine

DevJobStore over Postgres: FOR UPDATE SKIP LOCKED claim, lease-fenced worker
writes raising a typed DevJobLeaseLostError on a stale lease, append-only events
keyed (job_id, provision, seq) and ordered by the IDENTITY id. Queue state lives
in Postgres from day one; the builder's in-memory BuildQueue is deliberately not
copied, because a restart must never orphan a job.

finalizeDevJob is the single terminal-transition choke point. The store's
terminal write is guarded by an unexported brand symbol that only
finalizeDevJob.ts imports, so no sibling module can mark a job terminal — a
runtime guard, where a TypeScript 'private' would merely have made the sole
legitimate caller unable to reach it. W2 fills the revocation hook; the seam
exists now so W2 does not retrofit it.

jobToken: sha256 at rest, timing-safe verification that does not throw on a
length mismatch.

Split out of the store to stay under the 500-line rule: devRepoStore (repo CRUD)
and pgMappers (shared pg coercion).

Verified against a throwaway database on the local Postgres with the real
migration runner: 26 tests, including two concurrent claims returning disjoint
jobs, a stale lease raising, and appendEvents accepting the same seq under a
different provision.

Refs #470

* feat(dev-platform): W0 unit w0-onboarding — repo credentials, tracker read, brief

devRepoCredentials: per-repo tokens in the vault namespace core:dev-platform,
never in Postgres and never in a browser-facing shape. clear() purges all three
keys. Device-flow tokens stage under pending/<sub>/ until the repo row exists.

branchProtectionCheck: tri-state. 200 protected, 404 unprotected (the UI warns
loudly), 403 could-not-verify — classic device-flow tokens lack admin read, and
reporting that honestly beats reporting 'unprotected'.

githubIssuesTracker: the issues endpoint also returns pull requests; they are
filtered on the pull_request key.

briefComposer: the ticket body is wrapped in explicit untrusted markers, and a
body line reproducing a marker is neutralised — otherwise the reporter closes
the block early and speaks as omadia. This is framing, not enforcement, and the
code says so: W0's actual enforcement is that the runner holds no write
credential at all. The test drives an adversarial ticket that tries exactly that.

Reviewed by Forge (cross-family), which ran every marker-collision variant
against the real composer — exact, whitespace-padded, extra-dash, truncation
boundary, and marker-in-title — and found no early close. No findings.

Refs #470

* feat(dev-platform): W0 unit w0-forge-apply — server-side diff apply

The runner uploads a diff and never pushes. The middleware reconstructs the
commit from the diff it validated, so what was reviewed and what was committed
are the same object. This unit carries that guarantee for the whole epic.

apply(): parse -> numstat cross-check -> path-escape refusal -> policy verdict
(a seam returning allow in W0; W3 adds rules) -> blobs -> tree with base_tree at
the job's pinned base_sha -> commit -> create a fresh ref -> open the PR.
Validation is a read-only first phase; the first mutating call happens only
after every check passes.

applyHunks verifies each deletion and context line against the base before
consuming it, and fails closed with DiffContextMismatchError. Without that check
the applier was positional: a diff claiming to delete 'harmless' would delete
whatever sat at that line number, while the numstat totals still matched. Hunk
bodies must also consume exactly the counts their header declares, or
DiffStructureError. Both attacks were reproduced against the original code and
now abort before any write.

Content that reaches a tree is never runner-supplied: modified files are
reconstructed from the base blob fetched at base_sha. Symlink (120000) and
gitlink (160000) modes are refused; a declared 100755 is preserved. Binary
content is refused rather than fabricated, because a textual diff carries no
bytes. '.git/**' is refused locally rather than relying on the forge to reject it.

Two findings from my own audit of the reviewed code:
- The branch name was taken from the caller. assertJobBranch now enforces the
  omadia/job-* prefix here, so 'only a fresh job ref is ever created' is a
  property of this code rather than of whoever calls it.
- DiffContextMismatchError put the base line in its message. That message
  reaches dev_jobs.error and event payloads, which the admin UI renders and SSE
  streams; a base line belongs to the customer's repository and may be a secret.
  The lines stay on the error object; the message no longer carries them.

Reviewed by Forge (cross-family), which found the positional-apply hole and
reproduced it. A same-family reviewer shares the implementer's assumption that
reconstructing from base plus diff is sufficient.

Refs #470

* docs(dev-platform): state the terminal-write guarantee accurately

The store's brand symbol is exported, so any module could import it and call
finishTerminal directly. It prevents an accidental terminal write, not a
deliberate one. The earlier comment (and my commit message on a4a28d6) claimed
no sibling module could mark a job terminal; that overstates it. The invariant
is upheld by review, and the brand makes a violation loud rather than silent.

A comment that asserts a security property the code does not enforce is worse
than no comment: the next reader trusts it and removes the real control.

Refs #470

* feat(dev-platform): W0 unit w0-runner-api — phone-home router

createDevRunnerRouter, mounted at /api/v1/dev-runner. Deliberately not behind
requireAuth: the sole authentication is the per-job bearer token, verified
against its stored sha256 with a timing-safe compare. index.ts applies
requireAuth per router rather than as a blanket /api guard, so this mount is
correct as designed.

Six routes per spec §4. GET /spec carries the protocol version and no
credential field (asserted by a recursive key scan and a raw-text sentinel, a
regression test for a real spec bug). GET /scm-token is read-scoped and one-shot
per provision. POST /events validates event types in TypeScript, because 0021
deliberately dropped the DB CHECK, and is idempotent per (job, provision, seq).
POST /result routes failed and no_changes through finalizeDevJob, never the
store, and a spy on the choke point proves it.

Every body is size-capped and every field treated as hostile: the runner sits
inside the blast chamber. No error echoes the bearer, an upstream body, or a
stack.

Two fixes on top of the implementation:

- DevJobStore gains touchHeartbeat. appendEvents bumps last_heartbeat_at only
  alongside an event batch and returns early on an empty one, so an agent that
  thinks for two minutes without emitting a tool call would have been reaped by
  findStalled while perfectly healthy. The router first carried this as an
  optional injected hook that the wiring unit was expected to supply; an
  optional liveness bump is a trap, so it is now a required method on the
  injected store interface and cannot be omitted. Status-guarded, so a half-dead
  runner cannot revive a terminal job by heartbeating.

- req.params[k] is 'string | string[]' under this express typing. A repeated :id
  arrives as an array; the auth middleware now treats anything but a single
  string as unauthenticated rather than coercing it. The implementation reported
  a clean typecheck; the filter it used hid these two errors.

Refs #470

* fix(dev-platform): harden the phone-home router against a hostile runner

Adversarial review of 9bc5f6c rejected the unit with two majors, both
reproduced against the real router. This router is the only surface a
compromised runner can reach; two of its stated guarantees held only when the
runner cooperated.

POST /events never bound the client-supplied provision to the job. Idempotency
is (job_id, provision, seq), so a runner sending provision 999, then 1000,
replays the same seq forever and defeats dedupe entirely. The provision must now
equal the job's own; a real runner reads it from GET /spec, and a re-provision
bumps it server-side. The existing test codified the defect — it sent provision
2 for a job whose provision was 1 and asserted acceptance — so it now simulates
a real re-provision, and a mismatch is a 409.

POST /result never checked that diffArtifactId belonged to the job. A runner
naming another job's artifact would have had that diff committed and opened as
this job's pull request, which is precisely the guarantee the epic rests on.
Artifact ids are opaque, so this is defence in depth — but the check belongs
somewhere rather than in each layer's assumption about the other.

Also: the scm-token one-shot guard was a check-then-await-then-add TOCTOU, and
resolve() is a Vault round-trip; two concurrent requests both got a credential.
W0 resolves the same static credential so nothing new leaked, but W2 swaps in a
scoped token minted on demand and the race would mint two. It now reserves
before the await and rolls back on failure. diff_ready without a diffArtifactId
is refused. seq and provision are bounded to int4, and a single event payload is
capped, so a hostile runner cannot force 500s or store a 4 MiB payload verbatim.

Three guarantees were asserted in comments but never tested: auth before body
parsing, the one-shot under concurrency, and artifact ownership. Each now has a
regression test that fails against the previous code.

Splits, to honour the 500-line rule that 9bc5f6c had already broken at 522
lines: artifact access moves to devJobArtifactStore.ts, and the router's test
harness to devRunnerApi.harness.ts with the adversarial cases in
devRunnerApi.hostile.test.ts.

114 tests green.

Refs #470

* feat(dev-platform): W0 unit w0-admin-routes — admin REST + the epic's only SSE route

createDevPlatformRouter, mounted behind requireAuth at
/api/v1/admin/dev-platform. GET /jobs/:id/events is the ONLY job-event SSE route
in the epic: W3's chat card consumes this one rather than standing up a second.

The SSE id: field is dev_job_events.id, the server-assigned IDENTITY column,
never the runner's seq. A reconnect with Last-Event-ID therefore resumes
correctly across a provision boundary, which a seq-keyed stream could not — the
two provisions of a gated job both start their seq at zero. A test drives that
exact reconnect.

Launch authorization: POST /jobs requires the caller to be the repo's created_by
or hold one of allowed_launchers. An empty list means created_by alone, so the
gate is fail-closed. Without it any operator session launches jobs against any
registered repo, which does not survive an enterprise deployment. Note the
implementation matches allowed_launchers against the caller's role, sub, and
email, where the spec named only role keys — an extension, not a hole.

Auth-mode admission (the question-4 decision): subscription is refused when the
mode flag is off, when the repo's tests execute, when the source is not admin,
and on the fly backend. The route gives a good error; the worker will enforce
the same check as the boundary. The local backend is likewise refused for a
runs_tests repo and for any non-admin source.

Cancel routes through finalizeDevJob, never the store. Repo and job views carry
a credential status, never a token; a recursive key scan asserts it.

Split for the 500-line rule: repo onboarding into devPlatformRepos.ts, shared
guards and views into devPlatformShared.ts, the test fakes into
devPlatformRoutes.harness.ts.

131 tests green.

Refs #470

* fix(dev-platform): runner-api review minors — byte-accurate payload cap, scm-token rollback

Re-review of the hardened router passed both majors closed. Three minors:

- The per-event payload cap measured JSON.stringify(payload).length — UTF-16
  code units, not bytes — while the field, default, and message all say bytes.
  A CJK payload slipped ~3x past the nominal cap. Now Buffer.byteLength, matching
  POST /diff. A multibyte regression test asserts a 60-char CJK payload (180
  bytes) is rejected under a 128-byte cap where .length would have passed it.
- The scm-token reservation rolled back on a resolve() throw and a missing
  credential but not on a failed response send, so a client that dropped the
  socket mid-body left the provision un-issuable until restart. Now the send is
  inside the try; a legitimate same-provision retry works.
- The real store's uuid pre-filter on artifactBelongsToJob was exercised by no
  test (the fake keys off art-N ids). A pg test now asserts a real addArtifact
  id is a uuid, is accepted for its own job, and rejected for a foreign job and
  a non-uuid.

131 tests green.

Refs #470

* chore(dev-platform): renumber migration 0021 -> 0022 (0021 taken by plugin_verdict via PR #477)

* chore(dev-platform): drop old 0021_dev_platform.sql (renamed to 0022)

* fix(dev-platform): authorize every job route, not just create (review finding)

Adversarial review of the admin routes proved a cross-operator authorization
gap by executing it: as operator 'bob' it read alice's job and streamed her
agent logs, and cancelled her running job. The launcher gate sat on POST /jobs
and /retry only; the other seven job routes — GET /jobs, GET /jobs/:id, the
artifacts routes, and the epic's single SSE stream — authorized by session
presence alone. /retry gating while /cancel did not was the tell that this was
an oversight, not a design.

The gate that protects who may START a job now equally protects who may read,
stream, cancel, or apply one. loadAuthorizedJob loads the job and checks the
caller against its repo; a missing job and an unauthorized one return the same
404, so it is not an oracle for which ids exist. GET /jobs filters to repos the
caller may launch. GET /jobs/:id/events authorizes before opening the stream —
its events carry log, tool, egress, and token payloads. Four cross-operator
tests now assert bob gets 404 on read, cancel, and SSE (with no log payload in
the body), and that the list is scoped per operator.

Two further review minors:
- isPermittedLauncher matched allowed_launchers against the caller's role, sub,
  AND email. Spec §6 defines them as role keys; folding in sub/email is a
  cross-namespace match that could grant launch to a caller whose opaque sub or
  IdP-controlled email happened to equal an entry. Narrowed to role keys plus
  the creator's sub.
- A failed POST /repos left the staged device-flow token parked in the vault
  pending slot with no repo to clean it up. The error path now clears it.

The load/authorize helpers moved to devPlatformShared.ts to keep devPlatform.ts
under the 500-line rule.

135 tests green. Migration is 0022 (0021 is plugin_verdict, merged to main via
#477; the branch merged main and renumbered).

Refs #470

* fix(scripts): wave workflows need the meta block to run in the Workflow tool

* fix(scripts): wave-implement — explicit base checkout in agent worktrees, Engineer fallback for Anvil, crash-safe Forge/Cato stages

* feat(dev-platform): W0 unit w0-shim — runner shim package

New workspace package `@omadia/dev-runner-shim` (Node builtins only, never
imports middleware). Implements the spec §5 runner: fetch spec with a loud
protocol-version gate, read-only clone at the pinned base_sha, drive the
headless Claude CLI, translate its stream-json to runner events, and upload a
diff — the shim holds no write credential and moves no ref.

- protocol.ts: baked RUNNER_PROTOCOL_VERSION, DevJobSpec/event/result contract
  (deliberately duplicated from middleware; the version check catches skew),
  env reader.
- homeClient.ts: phone-home HTTP client + HomeApi seam; never echoes the bearer.
- gitOps.ts: clone via a 0600 git-credential-store file created outside the work
  tree and deleted in finally — token never in env, argv, or .git/config;
  https-only; stage + diff/numstat; no push anywhere.
- eventTranslate.ts: CLI NDJSON → the documented event table (system/init,
  coalesced text, tool_use/tool_result, result); 2 KB preview caps.
- agentRunner.ts: spawn with an ALLOWLIST env (keeps ANTHROPIC_BASE_URL — does
  not reuse CLI_ENV_SCRUB_KEYS), prompt on stdin, stderr → log events, 1 s/50
  batching.
- diffUpload.ts: self-describing numstat-marker bundle so the host splits the
  one text artifact back into diff + numstat.
- index.ts: lifecycle with heartbeat/cancel, serialized per-provision event seq,
  best-effort terminal result.

Tests (node:test): gitOps.test.ts is the verifiedBy — a recording fake-git
proves the credential/no-push guarantees and greps the bundle; plus event-table,
agent-runner (fake CLI: stderr→log, stdin prompt, allowlist env), and lifecycle
(protocol mismatch names both versions, clean/dirty paths).

* fix(scripts): wave-implement — default workflow agent for implementers (persona agents drop StructuredOutput on long runs)

* feat(dev-platform): W0 unit w0-ui — minimal /admin/dev-platform surface

Epic #470 W0, UI spec (issue comment 4922825984). Adds the operator-facing
admin surface for the dev platform:

- /admin/dev-platform hub: repos | jobs tabs (deep-linkable via ?tab=),
  repo table with credential-mode text colors, protection verdicts and
  first-run empty panel; job table with the spec's exact status->token
  mapping, waiting/failed row edges, client-side filters and a 5s poll
  as the W0 live-update fallback
- add-repo wizard (own page): device-flow quick start with text-only
  polling state (user code focal, .lume-busy-dots, no spinner), the
  --warning-edge plain-language trade-off block, PAT fallback, and the
  branch-protection verdict (warns loudly, never blocks)
- job detail: DevJobPhaseRail (eight stops, role=tablist, roving
  tabindex, Left/Right/Home/End keyboard support, aria-current=step vs
  aria-selected kept distinct, ?phase= deep link), live log pane on
  useStickToBottom + ScrollToBottomButton, SSE via new useDevJobEvents
  (modeled on useSpecEvents), metadata sidebar via useFormatter()
- shared promoted components under app/_components/devjobs/
  (DevJobPhaseRail, DevJobStatusText — single source of the status map)
- every string in messages/en.json + de.json (i18n:check green)

Lume invariants hold: state is text color + edges only, no fills, no
spinners, no toasts, no skeleton shimmer; only Button variants.

* fix(dev-platform): w0-shim review findings — gated LLM auth, job-scoped HOME, userinfo-URL guard, wall-clock kill

- LLM auth passthrough is now gated: OMADIA_ANTHROPIC_* crosses into the
  child CLI only when the backend plumbs OMADIA_LLM_ENV_ALLOWED=true (set
  exclusively under the W0 jail acknowledgment DEV_PLATFORM_UNSAFE_LOCAL);
  buildAgentEnv refuses to wire ANTHROPIC_* without llmEnvAllowed even when
  a caller supplies a token. W1 per-job proxy tokens replace this.
- Child HOME is ALWAYS job-scoped (workspace/home, mkdir'd by the shim, cwd
  fallback) — the parent HOME with the runner user's ~/.claude credentials
  is never inherited.
- cloneAtBaseSha rejects clone URLs with embedded userinfo before fetching
  a token or invoking git (argv/.git-config credential bypass).
- spec.limits.wallClockMs is enforced: timer kills a hung CLI (SIGTERM,
  then SIGKILL after killGraceMs), streams a budget_exceeded status event,
  fails the job with the budget named, exits 1.
- Tests updated/added for all four: gated env (no 'bearer' leak codified),
  HOME-in-workspace + parent-HOME canary, userinfo rejection with no git
  invocation, wall-clock kill with a sleeping fake CLI.

* feat(dev-platform): W0 unit w0-backend-local — RunnerBackend seam + jailed LocalProcessBackend

runnerBackend.ts re-exports the canonical seam from types.ts and adds the
admission-bearing DevJobProvisionContext (source, repo.runsTests) plus a
typed RunnerBackendError; provision() fails closed when the admission facts
are missing.

LocalProcessBackend (spec #470 W0 §1/§5):
- constructs only under the explicit DEV_PLATFORM_UNSAFE_LOCAL=true
  acknowledgment (boot warning naming the restriction) and a dedicated
  unprivileged uid (never 0)
- refuses runs_tests=true repos and any non-admin source at its own boundary
- shim env is ALLOWLIST-built from nothing: PATH/LANG/TERM, a job-scoped
  HOME (never the parent HOME), the five OMADIA_* shim inputs, and the
  OMADIA_LLM_ENV_ALLOWED gate set if and only if the jail acknowledgment is
  present — no Vault path, DATABASE_URL, or CLAUDE_CONFIG_DIR can leak
- spawns the shim detached in its own process group as the jail uid; a
  shim.pid file makes orphans findable across a middleware restart
- terminate() SIGTERMs the process group, escalates to SIGKILL after the
  grace window, and removes the workspace; hostile runner_handle ids that
  escape the workspace root are refused (no arbitrary rm -rf from DB JSONB)
- reap() SIGKILLs orphan pids from previous runs, removes leftover
  workspaces, and returns dead tracked handles so the worker can
  finalizeDevJob(..., 'stalled')

Verified: middleware build green; devplatform suite 150 pass (15 new);
dev-runner-shim suite 31 pass.

* fix(dev-platform): reap SIGKILLs the dead tracked runner's process group

Review finding on w0-backend-local: the tracked-dead branch of reap()
removed the workspace and returned the handle but never signalled the
process group. If the shim dies abnormally (OOM/SIGKILL/segfault) its
finally-cleanup never runs, and the CLI child it spawned — carrying
ANTHROPIC_BASE_URL/AUTH_TOKEN via the gated passthrough — survived as
an orphan making authenticated LLM calls after the job was finalized
'stalled'. Now the branch kills the whole group with SIGKILL before
removing the workspace, exactly like the sibling untracked-orphan
branch.

Test: FakeProcs models process groups (kill(-pgid) hits every live
member; the group outlives a dead leader), plus a regression test:
dead shim leader + live CLI child in the group → reap kills the child
and removes the workspace.

* fix(dev-platform): terminate() keys liveness on the process group, not the shim leader

terminate() gated its entire kill sequence on the LEADER pid being alive.
When the shim died abnormally (OOM/SIGKILL) its finally-cleanup never ran
and its process group could still hold a live claude CLI child carrying
ANTHROPIC_* credentials — terminate() then sent no signal, removed the
workspace + pid file, and dropped the handle, leaving the child
permanently unreapable. Same class of hole the previous fixup closed in
reap()'s tracked-dead branch, now mirrored into terminate():

- isGroupAlive(): probe kill(-pid, 0) with a leader-pid fallback
- terminate() gates SIGTERM -> grace -> SIGKILL on GROUP liveness and
  waitForGroupExit() polls the group, so a dead leader with a live child
  still gets the full escalation before the workspace is removed
- new test: dead leader + live group child => group is SIGTERM/SIGKILLed
  and the child is dead before the workspace disappears; the existing
  idempotency test still passes (a fully dead group draws zero signals)

* fix(dev-platform): reap's orphan branch kills the group unconditionally

The leader pid in a stale pid file may be long dead while its process group
still holds a CLI child holding ANTHROPIC_* credentials. Probing the leader
first reintroduced the blind spot terminate() and the tracked-dead branch
had already closed.

* fix(dev-platform): confirm process-group exit before deleting a runner workspace

Signal delivery is not proof of exit. terminate() and both reap() branches
SIGKILLed the runner group and then immediately rm-ed the workspace + pid file
— the only handles a later reap() has. When the group survived (EPERM masked by
killTree, or exit slower than the grace window) that stranded a live,
credential-bearing CLI child as a permanent unreapable orphan.

- killTree() now REPORTS its outcome instead of swallowing every error:
  'signalled' | 'gone' | 'failed'. EPERM (group alive, unsignalable) is 'failed',
  not silently retried as a single-pid kill. The single-pid fallback fires only
  on ESRCH-on-group (the non-detached edge where the leader might still be
  individually signalable).
- New killGroupAndConfirmExit() kills then confirms via waitForGroupExit; it
  returns true only when the group is provably gone. terminate() and both reap()
  branches now delete the workspace only on confirmed exit and otherwise LEAVE
  the workspace + pid file for the next reap sweep (terminate() throws
  local_terminate_incomplete, caught by finalizeDevJob's onError).
- provision()'s post-spawn catch now tears down the already-live shim group
  before rm, and keeps the workspace + writes a pid file if it cannot confirm
  exit, instead of orphaning a running child.

Tests: model EPERM (unkillable) and signal-surviving (immortal) group members in
FakeProcs, plus a configurable post-spawn failure; add 6 cases covering the
retain-on-unconfirmed-exit paths and the live-child spawn-failure cleanup.

* test(dev-platform): widen makeBackend spawnOpts to match makeSpawn (review nit)

* feat(devplatform): W0 devJobWorker — claim loop, enforcement, apply, auth admission

Add the in-process control loop for the epic #470 job spine:
- claim loop bounded by DEV_PLATFORM_MAX_CONCURRENT_JOBS (active-slot aware)
- stall -> stalled and wall-clock -> budget_exceeded, both via finalizeDevJob
- host-side apply of the uploaded diff bundle: applying -> done (+ pr_url) once,
  failure -> failed with the diff artifact retained for the apply retry
- auth-mode admission at the worker boundary (subscription refused for
  runs_tests repos, non-admin sources, and when the mode flag is unset)
- every terminal transition routes through finalizeDevJob; the worker owns
  terminate dispatch + onError, so a terminate() throwing
  local_terminate_incomplete still finalizes and is logged
- reaped runners finalized as stalled exactly once (finalize idempotency)

All terminal transitions go through finalizeDevJob; the numstat split marker
mirrors the shim's NUMSTAT_MARKER wire constant.

* fix(devplatform): exempt the host-side apply phase from runner-liveness enforcement

`applying` is a host-side phase with no live runner: the shim posts its diff and
exits 0 by design, then the host commits it. It was still subject to every
runner-liveness path, so a normally-completing apply was pre-empted and lost:

- reapBackends() finalized an `applying` job `stalled` when the backend reaped
  the (deliberately) dead pid before applyReady() ran. `stalled` is terminal,
  so POST /jobs/:id/apply refused to retry — the PR was unrecoverable (blocker).
- enforceTimeouts() applied stale-heartbeat and wall-clock enforcement to
  `applying` jobs, finalizing them `stalled`/`budget_exceeded` across a restart
  or a long agent run — same lost-PR outcome by a second route (major).

Fix: add `isHostSideApplyPhase` and skip `applying` in both reapBackends() and
enforceTimeouts(); reorder tick() so applyReady() runs before the liveness
sweeps (defense in depth). Split the pure policy/wire helpers into
devJobWorkerPolicy.ts to land devJobWorker.ts back under the 500-line guideline
(minor). Tests cover: a full tick() applying a job whose runner pid is already
dead (asserts apply runs, pr_url stored, done, never stalled); reapBackends
dropping an applying job's reaped handle; and an applying job that is BOTH stale
and over wall-clock still being applied, not finalized.

* chore(dev-platform): reconcile w0 manifest — worker policy module, wire-unit store seams

* feat(devplatform): wire W0 dev-platform behind DEV_PLATFORM_ENABLED + worker store seams

Epic #470 W0 hub unit. Bind the merged dev-platform modules into the middleware
boot path and implement the three DevJobStore seams the worker injects.

Wiring (src/devplatform/wireDevPlatform.ts, src/index.ts, src/config.ts):
- assembleDevPlatform builds the whole subsystem from the shared pool + vault;
  mountDevPlatform attaches the auth-gated admin router at
  /api/v1/admin/dev-platform and the job-token-gated runner router at
  /api/v1/dev-runner. The runner mount carries NO session guard by design — the
  per-job bearer token is its only auth (a guard there is a full auth bypass).
- Dark by default: DEV_PLATFORM_ENABLED unset mounts nothing and starts no
  worker; it also requires the Postgres graphPool. The worker starts with
  DEV_PLATFORM_MAX_CONCURRENT_JOBS and stops cleanly on SIGTERM/SIGINT.
- config.ts adds the DEV_PLATFORM_* env vars and two boot-time REFUSALS
  (subscription mode without its ack; unsafe-local without a uid).

Store seams (src/devplatform/devJobStore.ts + devJobWorkerSeams.ts, split for
the file-size guideline): countActiveJobs (concurrency gate), findActiveByHandleId
(reap mapping that EXCLUDES applying, so a completing job is never finalized
stalled), and prepareProvision (mint the one-time runner token — sha256 stored,
plaintext returned once — and pin branch/base_sha, lease-fenced).

UI: /admin/dev-platform nav leaf + admin-page card, en/de strings.
Docs: middleware/.env.example documents every new var.

Test: test/devplatform/devPlatform.e2e.test.ts — boot refusals (always) plus a
DB-gated full loop that mounts both routers the way index.ts does, drives a job
with a fake runner phoning home over HTTP and a stub forge, and asserts the
runner router needs only a job token while the admin router needs a session.

* fix(dev-platform): the runner router was behind the blanket /api auth guard

index.ts mounts `app.use('/api', requireAuth, chatRouter)`, and express runs
that middleware for every /api/* request whichever router answers. The runner
phone-home router therefore 401'd for every runner call in production, so no
job could ever finish — while the e2e test built a bare express() app and
proved the opposite.

Extract the public-paths allowlist into src/auth/publicPaths.ts so index.ts and
the e2e test consume the same array, add /api/v1/dev-runner to it, and mount the
guard in the test the way production does. Removing the entry now fails two
tests instead of only failing in production.

* docs(dev-platform): W1 unit manifest + option-D spike result + W0 handoffs

* feat(dev-platform): omadia/dev-runner image + publish both dev images (#470 W1)

Add the hardened per-job runner image at middleware/sidecars/dev-runner/
and publish it — plus the not-yet-built dev-runner-daemon image — from the
release workflow, version-locked to the middleware release.

The image runs as USER 1000:1000 with tini as PID 1 (reaps the CLI and git
on SIGTERM), CLAUDE_CONFIG_DIR=/tmp/claude-cli, and no openssh-client
(HTTPS-only clone in W1). It bundles the existing @omadia/dev-runner-shim
workspace package (compiled in a build stage, not duplicated) and pins the
Claude CLI to CLAUDE_CODE_VERSION=2.1.187 — the same pin as the root
middleware Dockerfile. No credentials or omadia secrets are baked in.

publish-images.yml gains dev-runner and dev-runner-daemon matrix entries
under ghcr.io/byte5ai, each tagged :<OMADIA_VERSION> and :sha-<gitsha> so
the daemon can pin the launch image by digest. The daemon build step is
guarded on its Dockerfile's presence so the workflow stays valid until the
w1-daemon-service-api unit adds it.

* feat(dev-platform): daemon<->middleware wire protocol schema (W1 #470)

Add the control-plane wire protocol for the runner daemon, dual-defined as
duplicated zod schemas: the middleware copy at src/devplatform/daemonProtocol.ts
and the standalone daemon copy at sidecars/dev-runner-daemon/src/protocol.ts.
The daemon package must not inherit the middleware dependency surface, so the
definitions are duplicated and kept honest by a contract test that snapshots
both copies to JSON Schema and diffs them (drift fails CI).

The POST /v1/jobs body is exactly { protocol, jobId, leaseTtlSec } via
strictObject: a body smuggling env/image/egressAllowlist/limits is rejected by
the schema itself (review finding S3 - a clamp that trusts caller-supplied
policy is not a clamp). Every request carries protocol: 1; a mismatch throws
WireProtocolMismatchError naming both versions. Distinct from the W0 phone-home
RUNNER_PROTOCOL_VERSION. Adds a minimal package.json/tsconfig so the schema and
its test typecheck; the full daemon scaffold lands in w1-daemon-service-api.

* feat(dev-platform): server-side job-policy derivation (epic #470 W1)

Add deriveJobPolicy() as the single source of a dev job's effective policy —
image, env, and egress allowlist — derived from the dev_repos row and the
job's auth_mode, never from the caller (review finding S3). Both the new
internal endpoint and (later) DockerBackend.provision call this one function so
they cannot disagree.

Mount GET /api/v1/dev-runner/internal/job-policy/:jobId (in its own module to
respect the 500-line rule), authenticated by the DAEMON token — a distinct
credential from the per-job djr_ runner bearer, which is rejected there. The
effective allowlist is {middleware host} u {forge host(s) from clone_url} u
DEV_EGRESS_BASE_ALLOWLIST u dev_repos.egress_allowlist, read live per job so an
added host takes effect on the next job with no restart. Subscription jobs get
the direct Anthropic hosts plus the CLI off-switches and no LLM-proxy base url.
The derived env never carries a provider key, a Vault path, DATABASE_URL, or the
daemon token.

* feat(dev-platform): add W1 middleware LLM proxy (epic #470)

Anthropic-compatible reverse proxy that keeps provider API keys out of job
containers. The Claude CLI in a job is handed ANTHROPIC_BASE_URL -> the proxy
and a per-job djr_ bearer as its ANTHROPIC_AUTH_TOKEN; the proxy validates the
bearer as a job token and swaps in the real provider key from Vault before
forwarding.

- createLlmProxyRouter (src/devplatform/llmProxy.ts): GET / probe (2xx for the
  CLI's reachability check), POST /v1/messages (and ?beta=true). Validates the
  job token, rejects terminal jobs (410), enforces the ctx.llm model allowlist
  (403 dev.model_not_allowed), attaches the Vault provider key as x-api-key and
  strips any client x-api-key / Authorization, streams the upstream response
  back verbatim (SSE passthrough, no buffering), and meters usage from
  message_start/message_delta into dev_jobs.tokens_in/out plus the token_usage
  ledger (source='dev-job', sessionId='devjob:<jobId>').
- Failure semantics: 429 passes Retry-After through; a 5xx is retried once, but
  only before any byte reaches the client, so a stream that already emitted
  tokens is never retried (no double-billing); a mid-stream disconnect still
  meters the partial usage and logs the truncation.
- devRunnerApi.ts: mount the proxy sub-router at /llm on the phone-home router
  (it does its own per-job bearer auth, so no session/authMw).
- llmProxy.test.ts: covers every acceptance criterion incl. the
  no-retry-after-tokens rule and header stripping.

The store/Vault/policy/usage seams are injected; the real wiring lands in the
DockerBackend/wire unit.

* fix(dev-platform): wire W1 keystones + close audit findings (epic #470)

Addresses the w1-layer1-audit-fixes review rejection.

Keystone wiring (were built but never mounted in prod):
- assembleDevPlatform now threads DEV_RUNNER_DAEMON_TOKEN, the job-policy
  config (image + egress base + middleware host + llm-proxy base url) and an
  always-mounted LLM proxy into createDevRunnerRouter; index.ts + config.ts
  supply them. New store seams DevJobStore.resolveJobByToken (hash lookup) and
  addJobUsage (atomic increment). The e2e now asserts the internal job-policy
  endpoint answers a daemon token, rejects a djr_ runner bearer, and that
  GET /llm/ probes 2xx while a job token reaches the proxy + model gate.

Hardening:
- llmProxy: refuse duplicate JSON keys (model-allowlist bypass); build upstream
  headers from an allowlist (drops cookie/x-forwarded-*/proxy-authorization/
  hop-by-hop); strip auth-like response headers + redact the provider key from
  non-stream error bodies; write dev_jobs (authoritative) before the ledger and
  surface — never swallow — an accounting-write failure.
- deriveJobPolicy: SSRF-guard clone_url (https only, no userinfo, reject
  internal/metadata hosts via the shared ssrfGuard predicate) and validate each
  egress entry as a bare hostname, dropping IP literals / schemes / ports /
  wildcards / CIDRs with a loud log.
- daemonProtocol (both byte-parallel copies): jobId is a UUID; leaseTtlSec is
  bounded to [30,3600].
- ssrfGuard: export isInternalHost/isInternalIp for reuse.

* fix(dev-platform): close W1 audit round-2 security findings (epic #470)

Address the five review findings on the W1 keystones:

- llmProxy: canonicalise the request body. Parse it, enforce the model
  allowlist on the parsed object, and forward JSON.stringify(parsed) with
  content-length recomputed — never the raw bytes. Kills the whole
  "validate one representation, forward another" class (duplicate keys,
  \u-escaped keys, whitespace/nesting). Reject a non-object top-level body.
- llmProxy: redact the provider key on the STREAMED path too. A rolling
  window (holds back N-1 bytes, N = key length) catches a secret split
  across SSE chunks, so an upstream error event can no longer leak the key
  into the job container.
- llmProxy: strip headers named by the Connection header (hop-by-hop,
  RFC 7230 §6.1) before applying the forward allowlist.
- devJobStore.resolveJobByToken: verify the bearer with a constant-time
  timingSafeEqual after the indexed lookup, and return null for terminal
  jobs so the proxy answers 401 (not 410) for both — no valid/terminal
  token oracle.
- ssrfGuard/deriveJobPolicy: strip a single trailing FQDN dot before the
  internal-host checks so `metadata.google.internal.` / `localhost.` can no
  longer slip into the egress allowlist; reject malformed (`..`, NUL) hosts.

Tests cover each finding; full devplatform suite 269/269 green with live pg.

* chore(dev-platform): escape control bytes in deriveJobPolicy's regex

A literal NUL and DEL inside the character class made git treat the file as
binary, so every diff of it was unreviewable. Semantically identical.

* Revert "chore(dev-platform): escape control bytes in deriveJobPolicy's regex"

The commit was made from a stale working tree and silently reverted every
security fix from e1f2517 (canonicalised body, StreamRedactor, timing-safe
token resolve, hostname normalisation, Connection-header stripping).

* chore(dev-platform): escape control bytes in deriveJobPolicy's regex

A literal NUL and DEL inside the character class made git treat the file as
binary, so every diff of it was unreviewable. Semantically identical
(\x00-\x20 plus \x7f).

* fix(security): IPv4-mapped IPv6 literals bypassed the SSRF internal-host guard

new URL('https://[::ffff:127.0.0.1]/').hostname canonicalises to the hex form
'[::ffff:7f00:1]', which the '::ffff:' dotted-quad strip did not recognise, so
loopback and RFC1918 targets slipped past isInternalHost/isInternalIp. Both
predicates now normalise either spelling before the range checks.

Found by the cross-family audit of epic #470 W1; ssrfGuard is shared with the
MCP and OAuth-metadata fetch paths, so the bypass was not dev-platform-only.

* feat(dev-platform): W1 runner daemon scaffold + bearer-authed control-plane API

Epic #470 W1 (unit w1-daemon-service-api). The daemon is the only process that
talks to the docker engine, keeping a docker socket out of the middleware next to
its Vault. This lands the standalone service scaffold; the clamp, image warmer,
lease reaper and egress proxy mount on the ContainerEngine seam it defines.

- src/daemon.mjs: node:http control-plane server. Routes POST/DELETE /v1/jobs,
  POST /v1/jobs/:id/lease, GET /v1/jobs, GET /v1/jobs/:id/logs, GET /v1/health,
  POST /v1/warm — every route bearer-gated, /v1/health included. Binds only the
  control-plane interface (DEV_DAEMON_BIND) and refuses a wildcard bind so
  nothing listens toward dev-engine. Binds to protocol.ts (no schema redefined).
- src/auth.mjs: DEV_RUNNER_DAEMON_TOKEN, >=32 chars enforced at boot, constant-
  time compare, comma-separated rotation list for zero-downtime rollover.
- src/policyClient.mjs: fetches GET /api/v1/dev-runner/internal/job-policy/:jobId
  with the daemon token and validates the response. The daemon derives policy
  itself; the caller names a job, never a policy (review finding S3).
- src/jobs.mjs: JobManager (idempotent create, in-flight de-dup, lease, reap
  source) + the ContainerEngine seam. createDockerEngine wires dockerode to dind
  over TLS (DOCKER_HOST) and implements ping; the mutating lifecycle methods are
  the seam the clamp unit fills, throwing EngineNotImplementedError until then.
- Dockerfile: node:22.23.1-alpine, non-root, tini; dockerode + zod only.

Standalone package (dockerode + zod + node builtins), never imports middleware.
26 daemon tests green (real server over a real socket); tsc --noEmit clean;
docker build succeeds; middleware devplatform suite (incl. protocol parity) green.

* fix(dev-platform): daemon-side policy clamp + pinned/bounded policy fetch (epic #470 W1)

Close the round-3 W1 daemon audit findings — the daemon must be the last line
of defence, not a courier for whatever the middleware returns.

- policyClient: clamp the UNTRUSTED job-policy response daemon-side. The image
  repository must be in DEV_RUNNER_ALLOWED_IMAGES (refused at boot when empty);
  the image must be digest-pinned unless DEV_RUNNER_REQUIRE_DIGEST is off
  (default on); a floating/malformed digest is refused; the policy env may not
  carry a reserved key (DOCKER_*, DEV_RUNNER_DAEMON_TOKEN, PATH, LD_PRELOAD,
  LD_LIBRARY_PATH, NODE_OPTIONS). Any violation throws before the policy reaches
  createJobContainer, so no container is created from a rejected policy.
- policyClient: refuse to follow a 30x redirect (redirect: 'error') so the fetch
  stays pinned to the configured endpoint.
- policyClient: keep the abort signal armed across the body read and cap the body
  at 256 KiB, reading the stream with a byte counter — a slow-body or oversized
  peer now fails fast instead of hanging/exhausting the daemon.
- jobs: dockerOptionsFromEnv enforces the tcp:// scheme with an explicit host+port
  and refuses unix/http/https/ssh/npipe; requires DOCKER_TLS_VERIFY=1 and a
  readable DOCKER_CERT_PATH, with a clear fatal message per failure.
- Dockerfile: default DEV_RUNNER_REQUIRE_DIGEST=true and document the required
  DEV_RUNNER_ALLOWED_IMAGES.
- Tests: new policyClient + dockerOptions suites (image/env/digest clamp,
  redirect/oversized/slow-body transport safety, DOCKER_HOST rejection table).

* fix(dev-platform): invert daemon policy-env clamp to an allowlist (epic #470 W1)

A denylist (RESERVED_ENV_KEYS + DOCKER_ prefix) could not enumerate the
command-execution env keys a runner image carries: it blocked NODE_OPTIONS
and LD_PRELOAD but forwarded GIT_SSH_COMMAND, GIT_EXTERNAL_DIFF,
GIT_PROXY_COMMAND, BASH_ENV, ENV and LD_AUDIT. A compromised middleware —
this unit's stated adversary — could ship policy env {GIT_SSH_COMMAND: ...}
and get arbitrary execution on the next git invocation, the exact class
NODE_OPTIONS/LD_PRELOAD were blocked for.

Invert assertPolicyEnv to an ALLOWLIST (ALLOWED_ENV_KEYS): accept only the
exact keys the runner needs (shim inputs, LLM/CLI behaviour keys, egress
proxy vars in both spellings, benign locale/tooling), refuse everything else
by key name (never its value). Error code daemon.env_reserved_key ->
daemon.env_key_not_allowed. Tests cover the GIT_*/BASH_ENV/LD_AUDIT/ENV
refusal table, every allowlisted key passing, mixed rejection, and the
rejection naming the key but never the value.

* fix(dev-platform): HOME and CLAUDE_CONFIG_DIR are not allowlistable policy env

Both were on the daemon's env allowlist (my own suggestion — wrong). They are
command-execution levers, not settings: an attacker-controlled HOME supplies
~/.gitconfig, whose core.pager / core.sshCommand / alias.* all execute, and an
attacker-controlled CLAUDE_CONFIG_DIR supplies Claude Code hooks. Admitting
them from an untrusted policy reopens the arbitrary-execution class the
allowlist exists to shut. The image fixes both to job-scoped paths, exactly as
localProcessBackend already does for the shim.

* fix(dev-platform): daemon owns runner identity/CLI env, never the policy (epic #470 W1)

The daemon-side policy clamp allowlisted four keys whose VALUES are execution
or redirection sinks, so a compromised/spoofed middleware could still steer the
runner even through the clamp:

- OMADIA_CLI_BIN: the shim reads it as cliBin and agentRunner spawns it; a
  policy value like ./pwn runs an attacker binary from the cloned repo.
- OMADIA_JOB_BASE_URL: the shim's homeClient builds every phone-home URL and
  the bearer header from it; a hostile value redirects the runner's spec fetch,
  SCM-token fetch, events, diff and result to an attacker host.
- OMADIA_JOB_ID / OMADIA_WORKSPACE: let the middleware skew a job's identity or
  the clone directory.

These are DAEMON-OWNED values the daemon already knows (its own middleware URL,
the UUID-validated request jobId, the container's fixed /workspace, its
configured CLI). Remove all four from ALLOWED_ENV_KEYS; reject a policy that
carries any of them LOUDLY (daemon.env_key_reserved — a policy that supplies one
is a compromised middleware, not a stray key); and INJECT the daemon-owned
values on every fetched policy. OMADIA_JOB_TOKEN stays policy-supplied — the
middleware legitimately mints it and, with the base URL now daemon-pinned, a
hostile token can no longer be aimed anywhere.

Wired from DEV_RUNNER_JOB_BASE_URL (default: the daemon's own middleware URL),
DEV_RUNNER_WORKSPACE (default /workspace), DEV_RUNNER_CLI_BIN (default claude).

* fix(dev-platform): daemon owns egress proxy, clamps egress allowlist, closes DELETE/create race (epic #470 W1)

Review round-4 fixes for w1-daemon-egress-and-races:

- HTTP(S)_PROXY / NO_PROXY (both spellings) are now DAEMON-OWNED, not
  policy-supplied. They are a generic egress-routing lever (redirect every
  http(s) client incl. git), unlike ANTHROPIC_BASE_URL. Removed from
  ALLOWED_ENV_KEYS, added to DAEMON_OWNED_ENV_KEYS (a policy carrying one is
  rejected), and injected by the daemon from its own config
  (DEV_RUNNER_EGRESS_PROXY_URL / DEV_RUNNER_NO_PROXY).
- egressAllowlist is clamped daemon-side with the same rigour as the image:
  each entry must be a bare hostname (no scheme/port/path/userinfo/wildcard/
  CIDR/control chars), not an IP literal. Ported classifyEgressEntry +
  isInternalIp into a standalone netClassify.mjs (copied, not imported) with a
  parity test. List length capped; a bad entry rejects the whole policy.
- DELETE racing an in-flight create no longer leaks an unreaped container:
  JobManager.destroy marks the id cancelled when it is in #inflight, and
  #provision tears down the just-created container instead of registering it,
  throwing JobCancelledError (mapped to 409). Deterministic race test added.
- The policy response's jobId is constrained to a UUID and must equal the
  requested id; a mismatch is rejected (daemon.policy_job_mismatch).

* fix(dev-platform): close egress numeric-IP bypass + daemon resource-exhaustion holes (epic #470 W1)

Review hardening for w1-daemon-final-hardening (4 findings):

- Egress allowlist 'no IP literal' clamp was bypassable with non-dotted IPv4
  spellings: WHATWG URL parsing canonicalises 2130706433 / 0x7f.0.0.1 /
  017700000001 / 3232235777 / 127.1 to loopback/RFC1918, but the label-shaped
  match accepted them as hostnames — allowlisting internal targets. Both copies
  of classifyEgressEntry (daemon netClassify.mjs + middleware deriveJobPolicy.ts)
  now canonicalise each entry through `new URL('http://'+entry+'/')` and reject
  any entry the parser rewrites (whole class: numeric/hex/octal/short-form) or
  that resolves to a v4/bracketed-v6 IP literal. Parity fixtures extended in both
  test files (numeric forms + bracketed IPv6 / IPv4-mapped-IPv6).

- JobManager.destroy() dropped the job from the registry BEFORE awaiting the
  engine teardown, so a failed docker cleanup leaked a container nothing tracked.
  Now tears down FIRST and only forgets the job on success; on failure it keeps
  the record (retryable) and raises JobCleanupError → HTTP 502.

- #jobs / #inflight were unbounded. Added admission caps
  (DEV_RUNNER_MAX_LIVE_JOBS=8, DEV_RUNNER_MAX_INFLIGHT_JOBS=4): a new job past
  either cap raises JobCapacityError → HTTP 429 and creates nothing; idempotent
  re-attach to an existing job is still allowed.

- GET /v1/jobs/:id/logs?follow=1 now destroys the upstream docker stream on
  client disconnect / res error, caps concurrent follows
  (DEV_RUNNER_MAX_LOG_FOLLOWS=4) → HTTP 429, and applies idle + absolute
  timeouts so abandoned follows cannot pin streams/sockets.

* fix(dev-platform): reserve the log-follow slot before the await, not after

The cap was check-then-act: it counted before `await engine.streamLogs()` and
incremented after. Real docker I/O spans event-loop turns, so a concurrent burst
all observed activeFollows === 0 and every request was admitted. The slot is now
reserved in the same turn as the check and released on every exit path.

The existing test opened follows sequentially against a stub that resolved in one
microtask, so it could not exercise the race. The new test bursts six requests at
a stub that resolves on a macrotask, and fails against the old logic.

Also completes both egress parity tables with the [::1] and [::ffff:127.0.0.1]
literals the acceptance criteria name.

* fix(dev-platform): canonicalise the daemon bind; accept the whole token rotation list

Two findings from the cross-family audit:

- The wildcard-bind guard matched spellings. Node binds 0, 000.000.000.000,
  ::0 and 0:0:0:0:0:0:0:0 to the wildcard just as it binds 0.0.0.0, so the
  control API could be exposed toward dev-engine through a spelling the Set
  did not list. The bind is now canonicalised before comparison — the same
  rule the egress classifier applies, for the same reason.

- DEV_RUNNER_DAEMON_TOKEN is a comma list so a token can rotate with zero
  downtime, and the daemon accepts every entry inbound — but the middleware
  compared against the raw string, so mid-rotation every policy lookup 401'd.
  Both ends now accept the list; every candidate is compared without early
  exit.

* fix(dev-platform): four residual daemon findings from the sign-off audit

- The bind guard still accepted ::ffff:0.0.0.0 (URL compresses it to ::ffff:0:0),
  which node listens on as the wildcard. Bind canonicalisation now reduces an
  IPv4-mapped IPv6 to its v4 form, reusing netClassify's toDottedQuad.

- assertPolicyEgress classified canonical hosts and then forwarded the RAW
  policy list, so 'GitHub.com.' and '[registry.npmjs.org]' reached the engine
  unnormalised. It now returns the classified hosts and the caller uses them.
  Same class as the LLM proxy's body canonicalisation.

- A create racing an in-flight DELETE was handed the doomed record, because the
  record survives until teardown is proven. Ids being destroyed are tracked and
  an overlapping create is refused.

- Malformed percent-encoding in a job-id path threw URIError and surfaced as a
  500; it is a bad request and now answers 400.

* fix(dev-platform): the cancel path must not drop the handle either

When a DELETE cancels an in-flight create, #provision tears the just-created
container down. If that teardown throws, the frame unwound without registering
the record — and the container, possibly still running, had no handle for a
later DELETE or the reaper. destroy() already refuses to cause that leak; the
cancel path now refuses it too, registering the record before rethrowing.

* fix(dev-platform): honour a disconnect during streamLogs; DELETE awaits the cancelled create

- A client that vanished while dockerode was still opening the log stream was
  never noticed: the stream resolved into nobody's hands, was never destroyed,
  and its reserved follow slot was never released. Enough of those and every
  later follow 429s until restart. The disconnect is now recorded from the
  moment the slot is taken and honoured when the stream arrives.

- destroy() answered success as soon as it flagged an in-flight create as
  cancelled. If the cancel teardown then failed, the container survived and
  stayed tracked while the DELETE caller had been told the job was gone. It now
  awaits the create's outcome and reports failure when the container survives.

* test(dev-platform): two race tests deadlocked against the new destroy() contract

destroy() now awaits the in-flight create's outcome, so a test that awaited it
before releasing the create gate could never settle. Node reported this as
`cancelled 9` with `fail 0` — the whole file ran zero assertions while the
summary looked green. Both tests now start the delete, release the gate, then
await the result.

* fix(dev-platform): one teardown per job id, and forget only the record you tore down

Two concurrent DELETEs both entered destroy(). The first tore the container
down, dropped the record and cleared the guard; a fresh create could then
register a new job — and when the second DELETE finally returned, its
unconditional `#jobs.delete(jobId)` removed that new record, leaving its
container untracked. The same lost-handle invariant the previous fixes protect.

Teardowns are now deduplicated per id (both callers await the same promise) and
the registry entry is only removed if it is still the record that was destroyed.

* feat(dev-runner-daemon): real DockerEngine with the §4 hardening clamp (epic #470 W1)

Fill the ContainerEngine seam's mutating methods behind the same signature the
JobManager depends on. The clamp IS the isolation:

- buildContainerCreateOptions is a PURE, exported function that builds the
  dockerode create-options from nothing (allowlist over HostConfig, never a
  scrub-list): non-root 1000:1000, read-only rootfs, CapDrop ALL / no CapAdd,
  no-new-privileges, memory+swap cap, NanoCpus, PidsLimit, nofile ulimit, tmpfs
  /tmp (no noexec), the per-job volume as the only bind, the per-job bridge as
  NetworkMode, RestartPolicy no. Forbidden fields are absent by construction.
- The engine hands that exact object to docker.createContainer (the validated
  object is the launched object), after creating the per-job network+volume;
  a floating-tag image fails with a spec_rejected-shaped error before any
  resource is created; any create failure rolls the partial resources back.
- destroyJobContainer removes container (graceful stop then force), network and
  volume, VERIFIES each is gone, is idempotent on 404s, and surfaces a failed
  removal so the caller keeps the handle to retry.
- streamLogs returns combined stdout/stderr as a Readable honouring follow;
  warmImages pulls each ref and returns the resolved digests.

Tests: clamp table asserts the exact create-options object (required present,
forbidden absent, exact key sets); engine driven against a fake dockerode for
provision/rollback/idempotent-teardown; a real-dockerode integration check
(env-guarded) confirms a genuine hardened container via docker inspect and reaps
it. All 197 prior daemon tests still green; tsc clean; docker build succeeds.

* fix(dev-runner-daemon): clamp refusal is a 400; demux job logs; pick the right RepoDigest

Four follow-ups on the clamp unit, three of them from its own review:

- SpecRejectedError mapped to 400 daemon.spec_rejected. The unit could not add
  it (daemon.mjs was outside its file set) and it surfaced as a 500: a caller
  naming an image the daemon refuses to run deserves to be told, not to see an
  internal error.
- streamLogs demuxes docker's 8-byte frame headers. The clamp creates non-TTY
  containers, so a following operator was reading docker's wire format. The
  test fake now emits real frames — a stub that hands back clean text could
  never have caught this.
- warmImages matched RepoDigests[0], which for a multi-registry image can be a
  digest from a different registry. It now matches on the repository actually
  pulled, with a parser that does not mistake a registry port for a tag.
- Dropped a dead if-branch whose body was a comment.

* fix(dev-runner-daemon): a failed rollback must name what survived

createJobContainer collected its rollback errors and then threw the original
failure, discarding them. The job is never registered on that path, so nothing
holds a handle on a container/network/volume the rollback could not remove — the
error was its only possible trace. It now raises CreateRollbackError naming the
deterministic omadia-job-<id> resources, so an operator or the reaper can find
them. Same lost-handle invariant destroy() already protects.

* fix(dev-runner-daemon): demux the one-shot log path too

Only the follow path was demuxed. A plain GET /v1/jobs/:id/logs handed the
operator docker's 8-byte frame headers, because the container is non-TTY on
both paths. The test fake now frames both, so the gap cannot reappear.

* fix(dev-runner): the job workspace must be writable by uid 1000

The clamp runs the runner as uid 1000 with a read-only rootfs and mounts a fresh
named volume at /workspace. Docker copies an image directory's contents AND its
ownership into an empty named volume on first mount — but only when that
directory is non-empty. /workspace was an empty, root-owned dir, so every volume
came up root:root and the shim's first clone would have died with EACCES. The
platform would not have run a single job.

Found by the cross-family audit, which reproduced it against the built image.
A  marker makes docker carry the 1000:1000 ownership across, and a
docker-gated integration test now asserts the property on the real image —
it fails against a Dockerfile without the marker.

* fix(dev-runner): the job workspace must be writable by uid 1000

The clamp runs the runner as uid 1000 with a read-only rootfs and mounts a fresh
named volume at /workspace. Docker copies an image directory's contents AND its
ownership into an empty named volume on first mount — but only when that
directory is non-empty. /workspace was an empty, root-owned dir, so every volume
came up root:root and the shim's first clone would have died with EACCES. The
platform would not have run a single job.

Found by the cross-family audit, which reproduced it against the built image.
A ".keep" marker makes docker carry the 1000:1000 ownership across, and a
docker-gated integration test now asserts the property on the real image —
it fails against a Dockerfile without the marker.

* feat(dev-runner-daemon): image-cache warming loop with digest pinning and health state (#470 W1)

Add src/warmer.mjs: a periodic image-warm loop wrapping the engine's
warmImages(refs), plus the warm-state object GET /v1/health reports.

- warms on boot and on a configurable interval (default 6h); the timer is
  unref'd, never stacks (a tick joins the in-flight pull), and stops on stop()
- getState() exposes { digests, warm, lastWarmedAt, lastError }; warm is never
  true until a pull has succeeded and resolved >=1 digest
- concurrent warms join one engine pull (no stampede); a pull failure is logged,
  recorded in lastError, retried next tick, never rejects the loop, and never
  marks the image warm or clobbers the last-good digest set
- digests are stored exactly as the engine resolved them (repositoryOf-matched
  in jobs.mjs), so a multi-registry ref keeps the right repository's digest

Standalone module: daemon.mjs is untouched (reaper unit owns it in parallel);
the orchestrator wires the warmer into /v1/health, /v1/warm, and main() via the
seam documented at the top of warmer.mjs.

* feat(dev-runner-daemon): enforce lease expiry, sweep orphans, rebuild state on boot

The daemon recorded and renewed leaseExpiresAt but never ENFORCED it, so a
wedged or compromised middleware could create allowed-image jobs and pin dind
resources forever. Add a periodic reaper that makes the daemon self-authoritative
for containers (spec #470 W1 §7):

- lease enforcement: a live job past its lease is torn down through the SAME
  deduplicated path a DELETE uses (JobManager.destroy) — cleanup proven before
  the record is forgotten, a failed teardown surfaced and the handle retained
  for the next sweep. No second teardown path.
- boot-time state rebuild: labelled containers are re-adopted (a daemon restart
  does not orphan a live job) and any already past its lease is reaped
  (boot_stale).
- orphan sweep: labelled containers/networks/volumes with no tracked job are
  removed, including the partial network/volume a failed create could not roll
  back (deterministic omadia-job-<id> names); a job that is live OR mid-create
  is never swept.
- configurable cadence (DEV_RUNNER_SWEEP_INTERVAL_MS), single-flighted, unref'd
  timer stopped on daemon close, survives an engine error and retries next tick.
- every reap logs the job id and reason (lease_expired | orphan | boot_stale).

Adds ContainerEngine.listManagedResources (label-set reconciliation source),
JobManager.adopt/tracks, and wires the reaper into the daemon boot in main().

* feat(dev-runner-daemon): mount the image warmer; /v1/health reports its state

The warmer unit could not touch daemon.mjs (the reaper was editing it in
parallel), so it shipped unmounted — the third time in this epic that a correct
component was built and never wired. main() now starts it before traffic and
stops it with the server; /v1/health reads the warmer's state and /v1/warm joins
its de-duplicated pull.

The new test failed for the wrong reason at first: startDaemon dropped the
warmer dep, so the assertions passed through the fallback path. The harness now
threads it, and the test fails when /v1/health stops reading the warmer.

* fix(dev-runner-daemon): a daemon restart must not kill the jobs it adopts

The boot rebuild read each container's lease from its create-time label, but a
live job's lease is renewed in memory — and that memory dies with the daemon. A
job renewed for an hour looked, from its label alone, an hour overdue, so the
rebuild would reap exactly the jobs it exists to preserve. Latent until W2 wires
renewal; a trap laid for a later wave.

boot_stale now keys on the honest signal: a container that is no longer running
is a corpse whatever its lease says. A running container is adopted with a grace
window (one sweep …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nvidia SkillSpector security scanning for the Plugin Builder v1.0 readiness pass across the earliest plugins

1 participant