feat(runner): a role runs through the command-line harness the operator chose (itd-2609201916056194) - #767
Merged
Conversation
…re only internal/core/runner is phase 1 of spc-2609221533057881: the runner interface, the claude CLI and opencode adapters, the route per role, the one fallback branch with its receipt, and the tally the run's summary reports. Nothing calls it yet; the loop and the CLI wire it in phase 2. - Route: roles.<role>.runner through the layered resolver (host when unset); runner.<name> enables a shipped runner with an optional <provider>/<model> route; runner.fallback_host is the landing with no host session. - Allowlist: a runner's model route is admitted through the oracle adapter's Admit (allowlist, then the vendor denylist) when the configuration is read, and again before launch. - Fallback: absent, refused, failed, unparsable or invalid hands the role to the host session, else to the configured host, and writes exactly one receipt naming the role, the runner asked for, the reason and the route that ran. No host session and no fallback host is refused before launch. - claude: --print --bare, stream-json, no session persistence, --permission-mode dontAsk with the contract's tools allowed. - opencode: run --format=json --pure --dir --file, the prompt behind --. - Process hygiene: argv vector, binary on PATH refused inside the repository, gitutil.ScrubbedEnv, null stdin, bounded stdout/stderr, own process group killed on timeout, errors written by abcd only. - Transcripts land in the history store (history.Capture, redacted). Decisions taken here, not in the record: - The runner namespace is machine-only (a repository declaring it is refused), on the grounds of ruling AA(b) and the provider blocks' precedent: which harness starts, on whose credential, is the machine's. roles.<role>.runner may come from either layer. - A role routed to a runner the machine did not enable is an absent runner: recorded and fallen back, never a silent host run. - Environment is ScrubbedEnv, not IsolatedEnv: an implementer's git must keep the person's global identity; repository selection and config injection are still stripped. - opencode gets --pure (no external plugins) as its analogue of --bare, and not --auto. - A failed fallback host is an error naming both; the receipt of the fallback tried is still recorded. Refs: itd-2609201916056194 Assisted-by: Claude:claude-opus-5-5
…on a rune boundary failureOf had no caller once the dispatch took failures by errors.As; the validator refusal a fallback receipt carries is bounded at 300 bytes and is now cut without splitting a rune. Refs: itd-2609201916056194 Assisted-by: Claude:claude-opus-5-5
Assisted-by: Claude:claude-opus-5-5
…n's ruling H9 Main retired the bundled anthropic/* vendor denylist (ruling H9, adr-2609300107513982, superseding Decision 2 of adr-2609221009491186). The runner's configuration comment still named "the vendor denylist", and TestModelOffTheAllowlistIsRefused carried a "denied vendor prefix" case (local/anthropic/claude-opus) that, after the merge, passed only because the model was not on the provider's list, not because of any denylist. The stale case is dropped, and TestAllowlistAloneDecides states the ruling: a vendor-prefixed model a provider lists is admitted, and an oracle.denylist entry the configuration writes still refuses it. The test was watched fail on the pre-merge base (the bundled denylist refused the listed model) and pass after the merge. Refs: itd-2609201916056194, spc-2609221533057881 Assisted-by: Claude:claude-opus-5-5
… the loop The loop's process driver (spc-2609202134338445 piece 3) and the runner's loop half (itd-2609201916056194 phase 2). loop.Drive performs the next stage as Advance does; when the stage awaits an agent whose role roles.<role>.runner routes to a runner, it starts that agent through runner.Dispatcher with the brief and receipt path the host would get, and the dispatcher's validator is the stage's own receipt verifier, run through Receipt under the run's lock, so a runner's answer is judged exactly as a host sub-agent's and a verified one advances the lane there and then. - A role left on the host returns the await unchanged, with the same state bytes a plain step writes (criterion 2). - A verified receipt, or a validator's recorded return, names the route that ran it (asked, ran, reported model); a host-run one names none, so a runner-run review's record differs from a host-run one's in the route alone (criterion 7, structural). The run record gains a "runner" line. - A runner that is absent, refuses, fails, answers unparsably or writes a receipt the verifier refuses leaves the lane awaiting, appends one fallback receipt to the state's fallbacks[] and a "fallback" record line, and hands the host the role with the reason (criterion 3); the run record carries the fallbacks and runner.Tally's counts per runner and per role (criterion 4). - State schema 8 (fallbacks, route); a version-7 file is read and written back at 8, and one carrying either is refused. - The runner's Validate is handed the name of the runner that ran, so the loop can stamp the route. - The tools a runner grants each loop role: a reviewer's agent definition's tools plus Write (its brief tells it to write its return), held to agents/*.md by a test; the implementer's are what a lane's work takes. A host session always drives the call: the no-host path (runner.fallback_host for a host-routed role) is the process driver's reversal of the host-delegated boundary, whose ADR (itd-2609201916151817 decision 6) is owed before it ships, so it stays in the core and no surface reaches it here. Tests watched fail against a stub Drive that only advanced: TestARoutedRoleRunsThroughItsRunner, TestARunnerThatCannotRunTheRoleFallsBackAndIsRecorded (absent, invalid), TestAReviewThroughARunnerDiffersFromAHostReviewOnlyInItsRoute, TestRoleToolsFollowTheAgentDefinitions. Every harness is the test binary on a PATH of its own; no real harness runs. Refs: itd-2609201916056194, spc-2609221533057881, itd-2609201916151817, spc-2609202134338445 Assisted-by: Claude:claude-opus-5-5
… step The front door onto the runner's loop half (itd-2609201916056194 phase 2, spc-2609202134338445 piece 3). - `abcd build` and `build next` read the runner configuration (loop.LoadRunners over runner.Load at layered.RootsFor) before the run is created: a fault, a model route its provider's allowlist does not admit included, is refused at the `runner` stage with nothing created or launched (criterion 5); the diagnostics go to stderr. - `abcd implement step` reads it on every call and drives through loop.Drive: a routed role is started by the step itself, its transcript stored in abcd's history store keyed on the root commit (resolved on the first transcript), and the result names the `route` that ran it or the `fallback` it recorded. An interrupt or a termination ends the context, so the runner kills the process group it started. - `implement status` and `implement record` count the fallbacks per runner and per role (criterion 4); the record names the route on a receipt or a verdict a runner produced, and lists each fallback. - The verbs' help, the generated CLI reference, commands/build.md, commands/implement.md and the build surface chapter describe it; ACKNOWLEDGEMENTS credits the claude CLI's print mode and opencode. - The driver's tests cite the harness docs as read on 2026-09-30: the claude page documents the stream-json events the fake prints, dontAsk and --bare; opencode's page does not document its JSON events' shape, which stays the adapter's assumption and a live check owed. A step that re-tells an await starts no runner and records no second fallback (TestAStepThatReTellsAnAwaitStartsNoRunner; found in design, not watched fail). Tests watched fail on the previous commit (a scratch copy with the new test file) and pass here: TestBuildRefusesARunnerModelOffItsAllowlistBeforeTheRun (build exited 0), TestAStepWhoseRunnerIsAbsentHandsTheHostTheRoleAndCountsIt (no fallback named). That test runs with PATH holding git's own directory alone and asserts no claude or opencode resolves. Refs: itd-2609201916056194, spc-2609221533057881, itd-2609201916151817, spc-2609202134338445 Assisted-by: Claude:claude-opus-5-5
`implement step` reaches the loop through Drive, so Advance and the role tool table had no caller outside the package and the exported-reach audit refused them as new unreached exports. They are unexported (advance, toolsFor) rather than baselined; the package doc names the process driver. Refs: spc-2609202134338445 Assisted-by: Claude:claude-opus-5-5
…e proof spc-2609221533057881 gains a Progress section: the core and the loop wiring have landed, criteria 1 to 6 are met on fake harnesses, and the spec stays open for the live proof and the security review. The loop spec (spc-2609202134338445) records piece 3 as landed in part: the step starts a routed role through its runner; the loop driving itself with no host session waits on decision 6's ADR. The owed live proof is captured as iss-2609301519558538: what a person must run, including the headless page's statement that --bare never reads OAuth or the keychain, so the claude runner spends an API key rather than the person's subscription. Refs: iss-2609301519558538 Refs: itd-2609201916056194, spc-2609221533057881, spc-2609202134338445 Assisted-by: Claude:claude-opus-5-5
…nly the worktree A lane's worktree lives in the machine-scoped store, outside the checkout the run belongs to, and the launcher refused a PATH binary only inside the directory the role runs in. Wired to the loop, that left a program planted in the checkout itself (repository content, reachable through a PATH entry into it) admissible. runner.Request gains Checkout, the loop sets it to the run's checkout, and admit refuses a binary inside either, lexically or through a symlink. TestBinaryInsideTheCheckoutIsRefused was watched fail (the planted opencode launched) and passes. Refs: itd-2609201916056194, spc-2609221533057881 Assisted-by: Claude:claude-opus-5-5
…hers can write A claude init event's model went unbounded and unshaped into the answer and, through the route record, into state.json: a model over 4 MiB pushed the state past its read bound and bricked the run, and control bytes travelled verbatim. The model is now held to modelRe, bounded and plain as sessionRe holds a session id (a bracketed context suffix admitted), and any other is an unparsable answer: the route falls back and the fallback is recorded, the model never written. admit started a harness binary on PATH even when the binary, the directory PATH reaches it through, or the directory it resolves into was writable by group or other. Each is now stat'd after EvalSymlinks and refused as absent when its mode carries a group or other write bit, through the one test CallersAlone applies, exported as fsutil.WritableByOthers. Ownership is not required: a root-owned system binary is a legitimate harness. Tests watched fail first: TestAModelPastItsShapeIsARefusalOfTheRoute, TestAHarnessOthersCanWriteIsRefused, and the huge-model and control-model cases of TestARunnerThatCannotRunTheRoleFallsBackAndIsRecorded. Refs: iss-2609301557141123 Refs: iss-2609301557191251 Assisted-by: Claude:claude-opus-5-5
…s model bounded, writable harness refused Resolves: iss-2609301557141123 Resolves: iss-2609301557191251 Assisted-by: Claude:claude-opus-5-5
Brings the branch up to main at df8815a before the runner lands. The merge is textually clean (docs/reference/cli/commands.md auto-merged) and the tree builds and vets. Assisted-by: Claude:claude-opus-5-5
On a Homebrew Mac /opt/homebrew/bin is group admin, mode 0775, so a harness installed there was refused as one "group or other can write". The runner now admits a binary or directory whose only write bit beyond its owner's is the group's, when that group is the system administrator group (gid 0, or gid 80 on darwin): its members can already act as root, so the write grants nothing new. Other-writable stays refused whatever the group, and so does every other group and an unreadable one. TestAHarnessOnlyTheAdministratorGroupCanWriteIsAdmitted names the group through a test seam (fileGroup), since a test cannot chown to gid 0 or 80. The decision is recorded in DECISIONS.md. Assisted-by: Claude:claude-opus-5-5
The runner paragraph said the record gains a `runner` line. The record writes none: it names the runner as a suffix on the receipt or verdict line (build.go routeSuffix). Found by the docs review this lane recorded as HOLD before the spec close. Assisted-by: Claude:claude-opus-5-5
The build chapter and the implement step help said every runner runs with the role's tools granted without a prompt. The opencode runner passes no tool grant and leaves permissions at the harness's own configuration (opencode.go), so both now say so. The CLI reference is regenerated. Found by the docs review this lane recorded as HOLD before the spec close. Assisted-by: Claude:claude-opus-5-5
Closes spc-2609221533057881 with impact additive under the product thinker's ruling RN1 of 2026-09-30: the runner closes on the fake-harness tests, with the three live checks recorded as owed. The intent's Audit Notes carry the dated ruling; the live checks stay open on their capture. The docs review for the close is saved as PROMOTE after two HOLD findings were corrected in the brief and the step help. Refs: iss-2609301519558538 Delivers: itd-2609201916056194 Assisted-by: Claude:claude-opus-5-5
The preflight on this branch failed two detectors that predate it: TestNoPrivateDelimiterCompare refused the transcript's stderr marker, which opened with a `---` run a frontmatter reader could take for a block delimiter, and TestTestGitCallsAreHermetic refused dispatch_test.go's git init, which built its own environment instead of gittest.Env. The marker opens with `==` and the test's git runs through gittest.Env. Assisted-by: Claude:claude-opus-5-5
The administrator-group exception admitted a group-writable harness binary or directory whose group was gid 0 on every OS, on the premise that its members can already act as root. That holds for darwin's admin group (gid 80, whose members may sudo by default), not for Linux's root group nor darwin's wheel: membership of either does not by itself let someone act as root. The exception now holds only on darwin and only for gid 80, which still covers Homebrew's /opt/homebrew/bin; gid 0 is refused on every OS, and other-writable stays refused whatever the group. Recorded as a new dated line in DECISIONS.md. Assisted-by: Claude:claude-opus-5-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A role in the build loop can run through a command-line harness the machine enables, instead of an agent the host starts. When a stage hands the lane to a role that
roles.<role>.runnerroutes toclaudeoropencode,abcd implement stepstarts the harness itself with the same brief and receipt path the host would get, checks its receipt with the stage's own verifier, stores its transcript in abcd's history store, and records which route ran it. When the harness is missing, refuses, fails or writes a receipt that does not verify, the host is handed the role exactly as before and the run records the fallback;implement statusandimplement recordcount fallbacks per runner and per role. A role left unset behaves exactly as today.This delivers itd-2609201916056194: its spec, spc-2609221533057881, closes here with impact additive, under the product thinker's ruling RN1 of 2026-09-30 ("the runner may close on the fake-harness tests with the three live checks recorded as owed"). It also lands piece 3 of spc-2609202134338445 in part.
Owed, not done: the three live checks have not been run: (1) a real review routed to opencode, its record read beside a host-run review's and differing only in the route; (2) the claude runner under
--barewith the role's tools granted, confirming a nested claude does not refuse under an inherited session variable (it spends an API key, not the subscription); (3) opencode's run mode, confirming its JSON event shape and whether it stops to ask for a permission. Every harness in the tests is the test binary on a PATH of its own; no real claude or opencode binary has run a role. The checks are tracked on iss-2609301519558538, which stays open, and the intent's Audit Notes say so.What changes:
loop.Drive(internal/core/implement/loop/drive.go): the step starts a routed role throughrunner.Dispatcher; the dispatcher's validator is the loop's ownReceipt, run under the run's lock. State schema 8 addsfallbacksand arouteon a verified receipt or a validator's return.abcd buildandbuild nextread the runner configuration before the run is created, and refuse a model route off its provider's allowlist at therunnerstage with nothing created.implement stepdrives throughDrive, with an interrupt killing the runner's process group;statusandrecordrender the fallback counts and the route./opt/homebrew/binis mode 0775, group admin, so a harness installed there was refused. A directory or binary that only darwin's admin group (gid 80) can write beyond its owner is admitted, on darwin only, since that group's members may sudo by default; gid 0 is refused on every OS (Linux's root group and darwin's wheel do not by themselves let a member act as root), gid 80 is refused off darwin, other-writable stays refused whatever the group, and so does every other group (internal/core/runner/proc.goadminGroupWritableOnly, recorded in DECISIONS.md).oracle.denylistentry still refuses.runnerrecord line that is not written, and tools granted unasked for opencode, which leaves permissions to its own configuration); both are corrected here and the review is saved as PROMOTE.Not built: the loop driving itself with no host session (
runner.fallback_hostat the surface). That is the process driver's reversal of the host-delegated boundary, and decision 6 of itd-2609201916151817 owes an ADR before it ships.For the reviewer: the claude CLI's headless page says
--barenever reads OAuth credentials or the keychain, so a claude runner spends an API key from its environment, not the person's subscription. opencode's CLI page does not document its JSON events' shape, so the adapter's parse of it is an assumption the live check must confirm. The administrator-group exception is darwin's admin group (gid 80) only; gid 0 is refused on every OS.Delivers: itd-2609201916056194
Refs: iss-2609301519558538
Resolves: iss-2609301557141123
Resolves: iss-2609301557191251
Refs: spc-2609221533057881, itd-2609201916151817, spc-2609202134338445
Assisted-by: Claude:claude-opus-5-5