M2-34: Claude Code runner core¶
Status: ready | Depends on: M2-03, M2-12, M2-29 | Milestone: M2
Goal¶
After this ticket plugins/claude-code-runner is a working reference adapter: a Python process on the M2-29 SDK that serves dev forums with a project binding (M2-03). It resolves each binding's opaque workspace_ref under runner.projects_root (symbolic links resolved, nothing outside) and reports the readiness of every bound forum it serves in its heartbeat; it accepts an agent.invocation only with a valid invocation token and a configured tool class not above the token's tool_class; it proves the actual billing mode for every run with claude auth status in the run environment right before startRun and checks it again with system/init.apiKeySource, so a run in organization without a key never falls back to a subscription; it starts exactly one claude -p turn per run, in its own process group, with an environment built as an allow list and a command line that equals the table of 09 "Process contract" for chat, scoped and host, and reports the configured class as tool_class_used; it hands the server's context to the runtime with trust markers, unverified content only as marked data and files only as data (ADR 0054); it reads the stream into the live box, answers in the thread, proposes the title once per new session and reports the cost of each run as the difference of the cumulative session values. Why: the runner is the reference for dev forums (01 "Dev forum with project binding and runner agent"), and every guarantee of the adapter contract (I3, I5, SR2, ADR 0054) only holds if the adapter enforces it by construction instead of assuming it (D-R-RUNNER). Tests run against Fake-Claude, an executable that plays stream-json fixtures, and the SDK FakeServer; the installed CLI runs only behind an explicit switch because a real turn costs money. This is the runner side of AG-20, AG-30, AG-11, AG-08, BR-12, BR-05, BR-13 and AZ-P6; M2-35 adds follow-ups over stdin, the stop, compaction and the error paths of 09 "Error paths", M3-12 answer attachments.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket" (point 5: real model calls only behind an explicit switch); section 5 "Test strategy" (row "Test doubles": Fake-Claude); section 8 "M2 Agent platform, extensions and terminal" (Goal; Scope "reference adapters for Hermes Agent and the Claude Code runner"; Out of scope "Files in agent invocations" (M3) and "SR3 and dev environments (V2)"; Exit criteria 2, 5 and 10; Test strategy "fake Claude with recorded stream fixtures"; Red team "forging or reusing the invocation token; faking the reported mode", T-AI-01 to T-AI-09; Risks "the Claude Code CLI changes stream messages"); block "agents and extensions" rows M2-03 and M2-29 (what exists), M2-34 (this ticket), M2-35 (next), M2-09, M2-10, M2-12, M2-13, M2-14, M2-18, M2-19, M2-20 and M2-22 (server side of the operations the runner calls), M2-31 and M2-33; section 9 rows M3-05 and M3-12; section 16 row V2-03; section 18 rows B6, I3, I5 and "Dev forum with project binding and runner agent"; section 19 (P-01, P-09, P-10, P-11, P-16).docs/spec/09-agents.md: 1 "Principles"; 2.2 "Features" (title,followups); 2.4 "Tool class"; 4.3 "Connection and cursor" (acceptance into the inbox beforeack); 4.4 "Events to the adapter"; 4.5 "Invocation token" (points 1 to 10 of the adapter check, the table ofreasonvalues) and 4.5.1; 4.6 "Run context" (trust markers, duty of every adapter, files always data, transcripts); 4.7 "Adapter operations"; 4.8 "Heartbeat and reported state"; 4.9 "Order in the adapter: commitment first, then runtime"; 4.10 "Usage at the end of a run"; 5.1 to 5.4; 9.1 "Lifecycle"; 12 "Auto-title" point 5; 15 "Reliability"; 16.1 "Test doubles" (row Fake Claude) and 16.2 (AG-08, AG-11, AG-19, AG-20, AG-30); 18 "Reference adapter: Claude-Code-Runner (development forums)" completely; 19 (R-AG-06, R-AG-07, R-AG-17, R-AG-20, R-AG-21, R-AG-23, R-AG-24); 21 "Configuration values of this chapter".docs/spec/10-backend-policy.md: 3 "Invariants" (I3, I5); 7 "Adapter check (I3)"; 8 "Expected and reported"; 10.2 "Claude-Code-Runner"; 12.1 "Recording" and 12.3 (cache writes without a stated duration count as 5 minutes); 15 "Tests" (BR-05, BR-12, BR-13).docs/spec/11-extensions.md: 11 "SDK design" (11.2 modulesauth,gateway,invocation,runs,outbox,retry,heartbeat,testing; 11.3).docs/spec/07-permissions.md: 2.11 "Tool class of an agent"; "SR2 Tool class"; "SR3 Dev isolation"; "SR9 Acting-for binding (confused deputy)"; 11.2 AZ-P6.docs/spec/19-security.md: 9 "Logs without secrets and without content" (SEC-LOG-2); 11 "Controls for agents" (SEC-AI-1, SEC-AI-2).docs/spec/21-dev-isolation.md: status paragraph and 1 "Starting point".docs/spec/16-operations.md5.2 "Secret provider (ADR 0011)" (secret_ref, schemesfileandenv).docs/spec/23-quality.md: 3 "Component contract" (row "Python project") and "Documentation in code"; 11.docs/spec/02-architecture.md6 (list "Places that carry the product name").docs/spec/06-api.md2 "Basic rules" (nonull), 5 "Errors", 7 "Idempotency".docs/spec/03-data-model.md(core contract, read only):project_bindings(runner_config"withinagents.tool_class"),agent_sessions(started,version),runs(billing_mode_used,billing_evidence).docs/api/openapi.yaml: operationsgetDiscovery,getMe,listServedChannels,getServedChannelConfig,sendHeartbeat,getRunContext,renewInvocationToken,startRun,patchRun,finishRun,createMessage,getThread,setThreadTitle,reportAgentSession,downloadFile; schemasAgentInvocationPayload,AgentSession,AgentConfigChangedPayload,RunCancelRequestedPayload,AgentChannelConfig,AgentChannelConfigList,ChannelBehavior,ProjectBinding,ProjectRunnerConfig(added by M2-03),Heartbeat,HeartbeatAck,ProjectReadiness,BillingEvidence,BillingMode,ToolClass,RunStart,InvocationTokenRenew,InvocationTokenGrant,RunPatch,RunStep,RunMetrics,RunApiRetry,RunFinish,CostReport,MessageCreate,ThreadTitlePut,AgentSessionReport,RunContext,RunContextMessage,RunContextAttachment,Problem(x-problem-codes).docs/api/asyncapi.yaml: messagesevent.agent.invocation,event.agent.config_changed,event.run.cancel_requested,event.agent.followup.- ADRs 0022 (agents outside the server), 0023 (signed invocation token, check in the adapter), 0024, 0026 (SDK: generated core plus hand layer; "the hand-written layer defines no types of its own for objects that the core already generates"), 0046 (multi-user dev environment only with isolation), 0053 (no JSON
null), 0054 (complete, including "Confirmation"). docs/DECISIONS.md: D-R-RUNNER, D-L8, D-AG-05, D-AG-06, D-AG-20, D-AG-21, D-AG-22, D-W-16, D-W-100; section 10 for the format ofD-M2-34-<n>rows.docs/security/threat-model.md: T-AI-01, T-AI-03, T-AI-08, T-DEV-01, T-TK-05.docs/RISKS.md: RK-01, RK-13, RK-14, RK-16.docs/OPEN-QUESTIONS.md: OQ-17 (package names stay unpublished) and OQ-18 (no subscription in V2 dev environments); no other item concerns this ticket.docs/operations/configuration.md: the runner keys of 09 21,adapter.invocation_token_leeway,adapter.jwks_refresh_min_interval,sdk.http_timeout,sdk.retry_max_attempts,agent.run_update_min_interval,message.max_content_bytes.docs/observability/signals.mdnames no runner metric; this ticket adds none.- Briefs:
docs/briefs/M2-29-python-sdk.md(notes 2, 8 to 11 and 13 to 15:Client.bind_run,AgentHandlerwithactual_billing,actual_tool_class,prepareandexecute,Adapter,RunHandleandRunPatcher,SqliteInboxStore,render_context, the outbox'ssend_message,build_heartbeat,FakeServerwith its faults and scenario helpers),M2-35-claude-code-runner-paths.md(what the next ticket expects from this one: files instead of pipes, the turn record, the stored input line, Fake-Claude and its fixture metadata),docs/briefs/M2-03-project-binding.md(notes 4, 7 and 11: format rules ofworkspace_refandrunner_config, readiness only from the effective operator,project_runner_config.v1and "M2-34 builds that"),M2-23-generated-clients.md(notes 1, 8, 10 and 11: packagebothy_sdk, gate scope,quality.coverage.python_min_percent, tool versions),M2-09-run-lifecycle.md(notes 3 and 6: start answers, adapter reasons thatfinishRunstores),M2-10-run-document-live-box.md(notes 3 to 5: patch paths, set semantics, cutting),M2-12-agent-sessions.md(notes 9 to 11: session mode, attach at start,reportAgentSessionwithIf-Matchandstarted),M2-13-run-context.md(shape of the context),M2-14-acting-for.md(note 1: headerInvocation-Token),M2-18-auto-title.md(note 8),M2-20-heartbeat-presence.md(note 3: closed key set ofdetails),M2-22-costs.md(notes 3 and 6:costsis the consumption of this run). - Code on the base branch:
sdk/python(README,src/bothy_sdk/hand layer andgenerated/,testingFakeServer, the context formatting helper of the M2-29 row),plugins/README.md,plugins/claude-code-runner/README.md,tools/ci/components.py(release componentclaude-code-runneralready listed),tools/docs/tests/test_config_values.py, the rootMakefile(Python component lines),sonar-project.properties(plugins/*/coverage.xml). Read the merged state withgit logbefore you start. - Implied dependencies: the header names M2-03, M2-12 and M2-29. Their closure brings M2-23, M2-14, M2-10, M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02 and the M1 tickets below them. The header names M2-12 because the runner reports
startedwithIf-Match(AgentSessionReport.started,IfMatchOptionaland412onreportAgentSession, M2-12 notes 11 and 17).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Note on rule 3: the runner is a program, not a library; its public Python symbols carry docstrings checked by ruff D with convention google (23 "Documentation in code"), and its operator interface (command, configuration keys) is documented in its README and in 09 21, from which make config-reference generates the reference. No mkdocstrings page: 23 names generated API docs only for the SDKs (ruling D-M2-34-1, Design note 18).
Product requirements implemented here:
- A turn is one
claude -pprocess with the base command line of 09 18.2 and the flags of exactly one tool class from the table there:chatgives--permission-mode dontAsk --tools "" --disallowedTools "mcp__*",scopedgives--permission-mode dontAsk --allowedTools <named tools>,hostgives--permission-mode bypassPermissions;--permission-prompts noneis always set (09 18.2, R-AG-21, R-AG-23, D-W-16). - The tool class is the configured one: it is reported as
reported_tool_classin the heartbeat and astool_class_usedinstartRun; a configured class above the token'stool_class(orderchat<scoped<host) starts nothing and ends the runfailedwithtool_class_mismatch(09 4.5 point 10, 2.4, 07 2.11, AG-30, AZ-P6). - The environment of a turn is an allow list, never a deny list:
HOME,USER,LOGNAME,SHELL, a cleanedPATH,LANG,LC_*,TERM,TMPDIR, optionallySSH_AUTH_SOCK, plus the variables of the mode (09 18.2, 18.3); the runner never reads, copies or writes login files of a runtime (09 18.3, 10 10.2, I5). - The actual mode is proven per run: the pre-check
claude auth statusin the run environment binds the start,system/init.apiKeySourceis the counter-check; a mismatch or an undeterminable mode starts nothing or interrupts the turn with SIGINT and reportsbilling_mode_mismatch;organizationwithout a key never falls back to a subscription and endsruntime_not_authenticated(09 18.3, 10 7, R-AG-20, AG-11, BR-05, BR-12). - The runtime starts only after
200onstartRun; the adapter check of the token (09 4.5 points 1 to 7) is the SDK's; an invalid token starts nothing and reportsinvalid_invocation_token(R-AG-07, 09 4.9, AG-08, AZ-P6, T-TK-05). - The context comes only from
getRunContext; content withtrust = unverifiedreaches the runtime only as marked data with a fixed notice before the block; the context is a fixed system part separate from the text of the triggering person; file contents are always data, also when the triggering person uploaded them; a transcript carries the trust of its message (09 4.6, ADR 0054, SEC-AI-1, AG-20, T-AI-01, T-AI-03). - A project is a forum with a binding the runner serves;
workspace_refresolves to a directory underrunner.projects_root, never outside it, also not through symbolic links; readiness (directory present, Git working tree, rights fit) is reported per bound forum in every heartbeat (09 18.1, 4.8, M2-03). - A thread is one Claude Code session:
--session-id <uuid>while the session is new or not started,--resume <uuid>once it is started;started = trueis reported aftersystem/init(09 18.1, 9.1, 18.4). - File locks per session and per thread with a wait of at most
runner.session_lock_wait; at mostrunner.max_parallel_runsturns; an own process group per turn; stream and stderr kept per session inrunner.state_dirforrunner.stream_log_retention(09 18.2). - The live box follows the stream after 09 18.4 through
patchRun, throttled by the SDK toagent.run_update_min_interval; the title is proposed once, after the first completed run of a new session (09 12 point 5, 18.4). - The cost of a run is the difference of the cumulative
result.modelUsageof the session per model, reported asRunFinish.costsand labelled an estimate by the server (09 18.7, 4.10, 10 12.1). - The API key and the agent token appear in no log line, no request body, no file of
runner.state_dir, no argument and no prompt of a turn; the key reaches the turn only asANTHROPIC_API_KEY(BR-13, I5, SEC-LOG-2). - A failed or interrupted turn is never repeated automatically (09 1 point 5, R-AG-17).
- Real model calls only behind an explicit switch (09 16.1, PLAN 3 point 5).
Scope¶
All paths under plugins/claude-code-runner/ unless they start with docs/, sdk/ or tools/. Module and function names are proposals; keep the responsibilities and follow the style of the merged SDK where it differs, listing the differences in the report.
Create:
pyproject.tomlanduv.lock: distributionclaude-code-runner, import packageclaude_code_runnerinsrc/claude_code_runner/withpy.typed,version = "0.0.0",license = "Apache-2.0", classifierPrivate :: Do Not Upload, console scriptclaude-code-runner = "claude_code_runner.__main__:main",requires-pythonequal to the lower bound ofsdk/python/pyproject.tomland at least the line that hastomllib; runtime dependencybothy-sdkfrom../../sdk/pythonthrough[tool.uv.sources], no further runtime dependency (standard library for TOML, processes, locks, signals); dev group with the same ruff, mypy, pytest, pytest-cov and async test plugin versions assdk/python;[tool.ruff]with rulesDand conventiongoogle(tests excluded),[tool.mypy]strict,[tool.pytest.ini_options]withtestpaths = ["tests"],[tool.coverage.report] fail_underequal toquality.coverage.python_min_percent(M2-23).VERSION(0.0.0),CHANGELOG.md(Keep a Changelog, English, likesdk/python/CHANGELOG.md),config.example.toml(every key of Design note 2 withhttps://chat.example.comand/srv/projects,/var/lib/claude-code-runneras example paths).src/claude_code_runner/:__main__.py(commandsrun --config <path>andcheck-config --config <path>),config.py,secrets.py,toolclass.py,commandline.py,environment.py,workspace.py,authcheck.py,prompt.py,attachments.py,locks.py,turn.py,streamreader.py,livebox.py,usage.py,title.py,heartbeat.py,channels.py(cache of served channels and bindings),app.py(the handler of the SDK run loop: acceptance answers, prepare, execute; start and stop of the runner),prompts/runner_prompt.md(fixed runner instructions, English, Design note 10).tests/(pytest, run by the rootmake test): unit tests per module,tests/scenarios/(Fake-Claude plus FakeServer),tests/fake_claude/fake_claude.pywithtests/fake_claude/fixtures/*.jsonl,tests/real_cli/(Design note 17).
Change:
docs/spec/09-agents.md(English, like the chapter; Design note 19): 18.1, 18.2, 18.3, 18.4, 18.7 and 21 with the thirteen keys of Design note 2. Thenmake config-referenceregeneratesdocs/operations/configuration.md.plugins/claude-code-runner/README.md(English, D-AG-22): status "Core (M2-34)", layout, configuration, how to run, what M2-35 and M3-12 add, the answers to "To check before the build" as far as they are known (both still open, see Design note 17).plugins/README.md: rowclaude-code-runnerwith the new status.tools/docs/tests/test_config_values.py: the runner'sfail_underequalsquality.coverage.python_min_percent, unless the M2-23 test already checks every tracked Python project underplugins/.sdk/pythononly where Design notes 5, 13, 15 and 17 require it (a way for a handler to end a run without a start, a heartbeat hook for members that change at run time, FakeServer routes the scenarios need; and, unless the merged SDK already does it, the run loop also runsprepareinsidebind_run(run_id, newest stored token), because SR9 binds a queued run (M2-14 note 3d), with an SDK test thatgetRunContextanddownloadFilecalled frompreparecarryInvocation-Token): each with its test insdk/python/tests/and a line insdk/python/CHANGELOG.md.docs/DECISIONS.mdsection 10: rowsD-M2-34-1toD-M2-34-12(Design note 18).
This ticket creates no migration, no server code and no new top-level make target.
Out of scope¶
- Follow-ups over stdin,
runner.followup_grace, the capabilityfollowups, the echoisReplayof a follow-up and its statesdeliveredandabsorbed(M2-35). This ticket reports nofollowupscapability, so the server sends noagent.followup. - The stop: reacting to
run.cancel_requestedwith SIGINT and closed stdin, the escalation,run.finishwithcancelledand the reason, and their scenarios (M2-35). This ticket builds the termination primitive (Design note 11) and makes execute safe against cancellation by the SDK (Design note 14). - The error paths of 09 18.9: context full and one compaction, session missing in Claude Code (fail open,
bind_failed), session already existing at the first turn, takeover or termination of turns after a runner crash, the table of recognized CLI error texts (M2-35). This ticket ends a turn whoseresultreports an error asfailedwithruntime_error(Design note 14) and writes the turn record M2-35 reads. - Answer attachments with
ATTACH:lines,runner.attach_allowed_roots,runner.attach_denied_patterns(M3-12); dev environments, the claimdev_environment_idand V2-03. - Commands, components, interactions, voice transcription (
file.transcript.submit), files out; the runner registers none and reports none of these capabilities. - The server side of every operation the runner calls (M2-09, M2-10, M2-12, M2-13, M2-14, M2-18, M2-19, M2-20, M2-22, M3-05); the conformance suite with the Fake-Agent (M2-31); the Hermes adapter (M2-33); the TypeScript SDK (M2-30).
- Telemetry export from the runner (signals.md names no runner signal); the release build of the component (
release-build, release chain of M7); publishing the package (OQ-17). - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-34-claude-code-runner-core; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(claude-code-runner): ...,docs(spec): ...,test(sdk-python): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a package and never reserve a name (OQ-17). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0 (P-10): the SDK FakeServer listens there. No PostgreSQL and no container are needed; no server of this product and no third-party service is contacted. - Stop only processes you started, by PID or by the process group of a turn the test started. Temporary directories only via
mktemp -d(or the test framework's temporary directory), removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Python packages, and the package of the pinned CLI version for level one of Design note 17.
- Never start a
claudeCLI installed on this machine and never read the developer's Claude Code configuration or login: every test uses Fake-Claude throughrunner.claude_binand a fakeHOMEin a temporary directory. The only real CLI you run is level one of Design note 17, installed into a temporary directory; level two (logins, model calls, money) is the owner's decision. - Never lower a threshold, disable a gate or delete a test. Do not extend
quality/licenses/allowed.txt. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - M2-29 and M2-35 were briefed in parallel with this ticket; this brief uses the names of the M2-29 brief (
bothy_sdk.auth,client,gateway,invocation,inbox,adapterwithAgentHandlerandAdapter,runs,context,outbox,retry,heartbeat,testingwithFakeServer). Where the merged SDK names or splits them differently, follow the code and list each difference; never rebuild in the runner what the SDK provides (token check, JWKS cache,jtimemory, inbox, run loop, renewal, patch throttle, outbox, retries, context rendering), and never define types the generated core already has (ADR 0026).
Design notes¶
-
Start check. Run
make checkon the fresh worktree; red givesVerdict: BLOCKED base branch red. Confirm: M2-03 is merged (docs/api/openapi.yamlhasProjectRunnerConfigandChannelBehavior.project_binding); M2-29 is merged (sdk/python/src/bothy_sdk/has the hand layer and the FakeServer). OtherwiseVerdict: BLOCKED dependency <ticket> missing. -
Process model. One Python process per agent token, asyncio throughout (the SDK is asynchronous first, 11 11.3), POSIX only (Linux, macOS): process groups,
flockand the signals of 18.6 have no equivalent the spec names for Windows, sorunrefuses other platforms with a start error. Names avoid the product name (02 6 lists the only places for it; the runner is listed nowhere), so the package isclaude_code_runner, the heartbeat namesclaude-code-runnerandclaude-code, and the only product-name strings in the component are the SDK's distribution and import names, which M2-23 listed as a kind of 02 6. Ruling for docs/DECISIONS.md. -
Configuration. One TOML file given by
--config, read withtomllib; keys nested by their dots ([runner],[adapter],[sdk]). Unknown keys, wrong types, missing required keys and invalid durations (form of configuration.md 2: number plusms,s,m,h,d) abort the start with a message that names key and file, never a value (configuration.md 3).secret_refvalues follow 16 5.2 with the schemesfileandenv; values are read fresh for each use and never cached in an object that can be printed (a wrapper type whosereprandstrwrite[redacted]). New keys of this ticket, rows for 09 21 (Place Runner; meaning in English as given):Name Default Meaning runner.server_urlno default, required base URL of the server; only https,httponly for the host127.0.0.1runner.instance_idno default, required instance ID of the server; issof every invocation token must be equal to it (4.5 item 4), the discovery is compared with it at startrunner.agent_token_refno default, required secret_refof the agent token in the secret provider of the runnerrunner.dpop_key_refempty secret_refof the DPoP key; empty means: token withoutdpop_jktrunner.consumerdefaultconsumer name at the gateway runner.tool_classchatloaded tool class; determines flags (18.2), reported_tool_classandtool_class_usedrunner.scoped_allowed_toolsempty named tools of the class scoped(--allowedTools)runner.default_modelno default, required model when the project binding names none runner.anthropic_api_key_refempty secret_refof the API key fororganization; empty means:organizationcannot be provenrunner.allow_personalfalseruns in mode personalwith the login of the runner user allowedrunner.forward_ssh_agentfalsepass SSH_AUTH_SOCKon to the turnrunner.cli_probe_timeout30stime limit for claude auth statusandclaude --versionrunner.poll_interval200msinterval at which the runner checks the recording for new lines and a held lock again The existing keys of 09 21 with Place Runner,
adapter.invocation_token_leeway,adapter.jwks_refresh_min_interval,agent.run_update_min_interval(the adapter's own throttle) and thesdk.*keys are read from the same file and passed to the SDK. Checks at load:runner.projects_rootandrunner.state_dirabsolute and existing;runner.state_dircreated with mode0700when missing and refused when group or others have any permission;runner.scoped_allowed_toolsentries andrunner.default_modelnot empty, not starting with-, without control or bidi characters (they become process arguments, M2-03 note 4);adapter.invocation_token_leewayat most the server'sauth.clock_leewaycannot be checked by the runner and is stated in the README.check-configruns these checks, confirms that every setsecret_refresolves to a non-empty value without printing it, probesclaude --version(note 15) and prints one code per check; it contacts no server. Ruling for docs/DECISIONS.md: the thirteen keys and TOML. -
Start. Read the agent token, build the SDK client for
runner.server_url(DPoP key if set); readgetMeand require a principal of kind agent, whose id is the expectedaud; build the SDK's adapter configuration withrunner.instance_idas the expectediss(the SDK compares it withgetDiscoveryand stops on a difference, M2-29: 09 4.5 point 4 says "the configured instance"), the agent id,runner.consumer,runner.state_dirand the names and versions of note 15; probeclaude --version(Design note 15); load all served channels withlistServedChannelsinto the channel cache; compute readiness (Design note 6); connect the SDK gateway withrunner.consumer; send the first heartbeat after the connection is ready. Existing turn records inrunner.state_dir/turns/are logged with the codeorphan_turn_foundand left for M2-35; their locks are held by their processes (Design note 11), so no second turn of that session starts. -
Acceptance of
agent.invocation(09 4.3, 4.5, 4.9). The runner is a handler of the SDK's run loop (expectedbothy_sdk.adapter: acceptance into the inbox,ack, a prepare step before the start, renewal andstartRun, an execute step after200); it never rebuilds that loop. Acceptance, first failure wins; a refusal isfinishRunwithstate = failedand the reason, thenack(09 4.3: a run refused withrun.finishcounts as accepted):a. The SDK verifier, points 1 to 7 of 09 4.5 with
adapter.invocation_token_leewayandadapter.jwks_refresh_min_interval: invalid givesinvalid_invocation_token; a missingkidafter the reload is neither acknowledged nor answered (4.5); an expired but otherwise valid token is accepted (renewal before the start). A repeatedjtiis ignored (point 7). The optional allow list of point 9 stays off: I1 and I2 are decided by the server and carried by the token. b. Point 10: the actual tool class the runner gives the SDK isrunner.tool_class; above the claimtool_classthe SDK refuses withtool_class_mismatch. Nothing else runs, Fake-Claude is never called (AG-30). c. Point 8: the desired mode is the claimbilling_mode(never the payload member). The actual mode the runner gives the SDK at acceptance is the desired one when it may run it (organizationalways, because the key is read and proven right before the start;personalonly withrunner.allow_personal), elseunknown, which the SDK refuses withbilling_mode_mismatch(the runner never uses a subscription it was not allowed to, RK-13). The binding evidence comes in prepare (note 8), right before the start, because it must describe the environment the turn will use (10 7: "binding for the start"); when the SDK asks again at the start, the runner answers the mode of that evidence, orunknown. d. Otherwise the SDK stores event and token in its durable inbox inrunner.state_dir(expectedSqliteInboxStore, mode0600) and acknowledges; a redelivery replaces the stored token (09 4.3).Ruling for docs/DECISIONS.md: b and c, and no allow list.
-
Prepare, start and execute (09 4.9, 18.11). Prepare runs, in this order:
a.
trigger_kindother thanmessagegivesadapter_error: the runner registers no commands and builds no components. b. The channel must be a forum the runner serves with a binding in the channel cache; when the payload'ssettings_versionis newer than the cached one, reload it first withgetServedChannelConfig. No binding givesadapter_errorwith the log codeproject_not_bound. c. Whiledegradedwithruntime_unreachableorruntime_version_unsupported(note 15):runtime_error. d. Resolve the project (note 6); failure givesadapter_error. e. Take the thread lock, then the session lock (note 11), waiting at mostrunner.session_lock_wait; a timeout givesadapter_errorwith the log codesession_busy. Then a slot ofrunner.max_parallel_runs. f. Load the context (getRunContext; a404, or403 invocation_token_invalidwithreasonrun_inactivebecause prepare runs inside the run binding (note 13, M2-14 note 4c), means the run is no longer active: end without a start and withoutfinishRun). g. Download the attachments of the triggering message (note 10). h. Build the run environment (note 7) and run the pre-check (note 8); a non-start outcome ends the run with its reason. i. Return the start options:modelandexternal_session_ref(note 9) andbilling_evidence(note 8).The SDK then renews an expired token (4.5.1), calls
startRunwithbilling_mode_used,tool_class_used = runner.tool_classand the start options, and handles every answer as thereasontable of 09 4.5 says (expiredrenews once and starts once more;supersededkeeps the entry;invalidfinishesinvalid_invocation_token;409 already_started,409 billing_mode_mismatch,409 tool_class_mismatchand403 agent_invocation_deniedstart nothing). Only after200does execute run: write the turn record and start the turn (notes 9 to 14). After a restart the SDK closes entries of started runs without a call, so such a run endsinterrupted(4.9). An end without a start in prepare (a to h) must finish the run with exactly the reason named there; if the merged SDK has no way for a handler to end a run with a given reason without a start (M2-29 maps only exceptions of execute toruntime_errorandadapter_error), add one to its run loop, for example an exceptionRunRefused(reason)that prepare raises and the loop answers withfinishRunfailedand that reason and a closed entry, with a test and a line insdk/python/CHANGELOG.md, instead of callingfinishRunbeside the SDK. In execute the runner ends every run itself with the run handle'sfinish(state, reason, metrics, costs). Every path releases slot and locks (one per-run object opened in prepare and closed when execute ends or the SDK reports that the run did not start). Ruling for docs/DECISIONS.md: this order. -
Workspace and readiness (09 18.1).
resolve(workspace_ref): refuse a value that is empty, absolute, starts with~or-, contains\, a drive letter with colon or a..segment (defence in depth after M2-03 note 4); join with the real path ofrunner.projects_root, takeos.path.realpath, and require a strict descendant of the root (os.path.commonpath). The resolved real path is used as working directory, never the joined one. Ready means: resolved, a directory,.gitexists in it (directory or file, so linked worktrees count), and the runner user has read, write and search rights (os.access). Not ready carries a log code:outside_root,workspace_missing,not_a_git_worktree,not_writable. Readiness is computed at start, after every channel cache change and before every heartbeat. Ruling for docs/DECISIONS.md. -
Run environment (09 18.2, 18.3). Built from scratch, never by removing from
os.environ:HOME,USER,LOGNAME,SHELL,LANG,TERM,TMPDIRand everyLC_*from the runner's environment when present;HOMEmissing aborts the start.PATHcleaned: entries kept in order only when absolute, existing directories, not writable by group or others, without duplicates; empty entries and.dropped.runner.claude_binis looked up in thisPATHand its absolute path is used. Reason: a writable directory on the path lets another account plant a program.SSH_AUTH_SOCKonly withrunner.forward_ssh_agent(T-DEV-01: an agent socket gives the turn the runner user's keys).organization:ANTHROPIC_API_KEYread fromrunner.anthropic_api_key_reffor this run (missing or empty givesruntime_not_authenticatedwithout any CLI call, BR-12) andCLAUDE_CONFIG_DIR = <runner.state_dir>/claude-config/organization, created empty with mode0700and never written by the runner.personal: neither variable, andHOMEstays the runner user's own, whose login is the person's (10 10.2); the README tells the operator to enablerunner.allow_personalonly when the runner runs as the account of the agent'scredential_holder, because I1 and I2 then guarantee that only this person triggers (21 1). The variablesANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_PROFILE,CLAUDE_CODE_USE_BEDROCK,CLAUDE_CODE_USE_VERTEXandCLAUDE_CODE_USE_FOUNDRYare never set, because they are not on the list.
Consequence to document in the README: sessions of
organizationlive in the runner's own configuration directory and sessions ofpersonalunderHOME, so after a switch of the mode (I4)--resumedoes not find the earlier session; M2-35 handles a missing session (fail open). Ruling for docs/DECISIONS.md. -
Pre-check and evidence (09 18.3, 10 7). Run
<claude_bin> auth statuswith the run environment, working directory the project, stdin closed, time limitrunner.cli_probe_timeout. Parse stdout as one JSON object and readauthMethod; anything else (no JSON, member missing, unknown value, start failure, time limit) isunknown. The exit code is ignored, becausenonemay come with a non-zero exit. Outcome, first match wins:Desired authMethodOutcome organizationapi_keystart personalclaude.aioroauth_tokenstart any noneruntime_not_authenticatedany every other value, and unknownbilling_mode_mismatchbilling_evidence.key_typefollowsauthMethodalone:api_keyandapi_key_helpergiveapi_key,claude.aiandoauth_tokengiveoauth,third_partygivescloud,nonegivesnone, everything elseunknown(09 4.8, 10 7: an unknown mode counts as different from every desired mode).billing_evidenceinstartRunis{provider: anthropic, key_type, method: claude_auth_status}; never a value or prefix. A non-start ends the run withfinishRunfailedand the reason, withoutstartRunand without a turn (AG-11, BR-05). Ruling for docs/DECISIONS.md: the table, including thatnoneisruntime_not_authenticated(09 5.2) and that the CLI is never called fororganizationwithout a key. -
Command line (09 18.2), an exact argv list, built by a pure function:
<claude_bin> -p --input-format stream-json --output-format stream-json --verbose --replay-user-messages --model <model> (--session-id <uuid> | --resume <uuid>) <class flags> --permission-prompts none [--add-dir <thread inbox>] --append-system-prompt-file <system prompt file>- Class flags are exactly the cell of the class in the table of 18.2, split like a shell would:
chatgives--permission-mode dontAsk --toolswith an empty argument and--disallowedTools mcp__*;scopedgives--permission-mode dontAsk --allowedToolsfollowed by one argument per tool (variadic, no joining), and--allowedToolsis omitted when the list is empty;hostgives--permission-mode bypassPermissions. A pure functionclass_flags(tool_class, tools)returns them, so the test of the table compares it directly. The order of the flags has no meaning to the CLI; 18.2 gets this order (note 19). - Tools of
scoped: the entries ofrunner.scoped_allowed_toolsthat also appear in the binding'srunner_config.allowed_toolswhen the binding has that member, otherwise all of them, in the order of the runner key.allowed_toolsnever widens the class (03project_bindings, M2-03 note 11) and has no effect underchat(no tools) andhost(bypassPermissionshonours no allow list). Ruling for docs/DECISIONS.md; update 09 18.1. - Model: the binding's
runner_config.model, elserunner.default_model. Every model and tool value is checked again as in note 2; a violation givesadapter_error. - Session:
agent_sessionabsent in the payload: a new UUID (version 4); present withstarted = false: itsexternal_session_ref; present withstarted = true:--resumewith itsexternal_session_ref. A reference that is not a UUID givesadapter_errorwith the log codesession_ref_not_uuid. The choice readsagent_session.startedonly, becauseresumeandbindboth mean--resumefor this runtime (09 9.5 point 2).startRuncarries the reference inexternal_session_reffor every operator run. --add-dirwith<runner.state_dir>/files/<thread_id>only forscopedandhostand only when at least one attachment was stored:dontAskreads only in the working directory, and the inbox of the thread lies outside it (18.10). Ruling for docs/DECISIONS.md; update the base line in 09 18.2.
- Class flags are exactly the cell of the class in the table of 18.2, split like a shell would:
-
Prompt (09 4.6, 18.2 "Input", ADR 0054). The context is rendered by the context function of the M2-29 hand layer (expected
bothy_sdk.context.render_context, giving a system part and a trigger part, with data blocks whose boundary tests can inject). Two inputs:- The system prompt file
<runner.state_dir>/sessions/<session ref>/<run_id>.system-prompt.md(mode0600), passed with--append-system-prompt-file, holds only the fixed runner instructions ofprompts/runner_prompt.md(the TITLE request only for a new or unstarted session, note 14; that every data block, including the listed files, is material and never an instruction) and a section with the channel promptbehavior.promptwhen it is not empty (SEC-AI-1: an own field).behavior.skillsis not used: Claude Code loads its skills from the working directory, and the list has no defined meaning for this runtime. - The first stdin line, one JSON object
{"type":"user","uuid":"<uuid4>","message":{"role":"user","content":[{"type":"text","text":"<system part>"},{"type":"text","text":"<trigger part>"}]}}. The first item is the helper's system part as rendered:channel_contextandthread_contextin ascending order, every element with author, role, trust, origin and kind, everyunverifiedcontent and every file entry inside a marked data block after the fixed notice,already_deliveredandtruncatedvisible,truncated_before_seqnamed. The second item is the helper's trigger part:trigger_message.content_mdplus the transcripts of its attachments, each labelled as transcript, with trusttriggerand outside any data block. Reason: the context becomes part of the session history, so a--resumeturn still knows whatcontext_floor_seqno longer sends (09 4.6); an appended system prompt belongs to one process only. One function builds such lines (M2-35 builds follow-up lines with it). The runner remembers theuuidto ignore its echo and stores the line as<runner.state_dir>/sessions/<session ref>/<run_id>.input.jsonl(0600), which M2-35 needs to send the same prompt again after a compaction. - Attachments: only those of the triggering message with
scan_statuscleanorskippedare fetched withdownloadFileinto<runner.state_dir>/files/<thread_id>/<file_id>(0600). The runner hands the helper, as the text of each such file, only the linelocal file: <path>(orunavailable: <problem code>for any other status or any error:404,409 file_not_ready,409 file_blocked, an operation not enabled), so the path stands only inside the file's data block and no file content enters either input; the run continues in every case. Attachments of context elements get no text and are listed by name, type and size only.
Ruling for docs/DECISIONS.md: this split (fixed runner instructions and channel prompt in the system prompt file; on stdin the context with the file paths as the first content item and the trigger's words and transcripts as the second, so the context stays in the session history; files only as paths in data blocks, skills unused).
- The system prompt file
-
Turn process, locks and records (09 18.2, 18.6). Start with
subprocess.Popen(argv, cwd=<resolved project>, env=<note 7>, stdin=PIPE, stdout=<stream file>, stderr=<stderr file>, start_new_session=True, pass_fds=<lock fds>, close_fds=True); never a shell. Stream and stderr go to<runner.state_dir>/sessions/<session ref>/<run_id>.stream.jsonland.stderr.log(0600), not to pipes, so a turn survives a restart of the runner (18.2 "Detachment") and the files are the transcript. Locks arefcntl.flockon<runner.state_dir>/locks/thread-<thread_id>.lockandsession-<ref>.lock, taken in this order with non-blocking attempts everyrunner.poll_intervaluntilrunner.session_lock_wait; their descriptors are inherited by the turn, so a lock lives as long as the turn even when the runner dies (no second turn of the session, 18.2 "Locks"). The turn record<runner.state_dir>/turns/<run_id>.jsonholds run, thread, channel, session reference, pid, process group, start time and the file paths, never a token. The termination primitiveterminate(group): SIGINT to the group, waitrunner.stop_grace, SIGTERM, waitrunner.kill_grace, SIGKILL (18.6); M2-35 reuses it for the stop. The# nosecmarkers that bandit needs on thesubprocessimport and thePopencall carry a comment with the reason (argument list, no shell, program from the cleanedPATH). Files older thanrunner.stream_log_retentionundersessions/andfiles/are deleted at start and after every turn. Ruling for docs/DECISIONS.md: files instead of pipes, polling the stream file everyrunner.poll_interval, locks inherited by the turn, retention of downloaded attachments. -
Stream reader and live box (09 18.4). The reader tails the stream file line by line, keeps a partial last line until it is complete, ignores lines that are not JSON objects and messages of unknown type or without the fields below (counted per type in a debug log line without content), and stops after
resultor when the process has exited and the file is read to its end. Fields read, [derived] from the Agent SDK reference that 18.4 cites; write this table into 18.4:Message Fields Live box systeminitsession_id,model,apiKeySourcereplace /model; counter-check (note 13); session reportassistantmessage.content[]withtextitems andtool_useitems (id,name,input);message.usage;parent_tool_use_id/last_text_excerptfrom the text items of the newest message;addon/steps/<id>with kindtool_call,name,title, statusrunning,parent_step_idof the step ofparent_tool_use_idusermessage.content[]withtool_resultitems (tool_use_id,is_error);isReplay,uuidstep completedorfailedwithfinished_at; the echo of the own prompt is ignoredtool_progressnone no change systemtask_started,task_progress,task_updated,task_notificationtask_id,description,task_type,ambient,statusstep kind subagentwith detail{"type":"local_agent"}whentask_typeislocal_agent, elsebackground_job;ambienttasks get no step;statuscompleted,failed,stoppedend the step ascompleted,failed,cancelledsystembackground_tasks_changedtasks[]withtask_id,descriptionopen background steps outside the set end completed;/metrics/background_jobsis the size of the setsystemstatusstatuscompactingsets/status_linetocompacting, its absence removes itsystemapi_retryattempt,max_retries,retry_delay_ms,error/metrics/api_retriesand/metrics/last_api_retryresultsubtype,is_error,result,modelUsage(inputTokens,outputTokens,cacheReadInputTokens,cacheCreationInputTokens,costUSD,contextWindowper model)end of the turn (note 14) Step title: the first non-empty line of the first string member of
inputin the ordercommand,file_path,path,pattern,url,query,description,prompt, else of the first string member, else empty; no stepdetail(data economy). Step ids are UUIDv7 (09 4.7), one pertool_useand task, from one step-id helper (the SDK's if it has one), which M2-35 reuses./metrics/context_pctafter anassistantmessage with usage: input plus cache tokens divided by thecontextWindowof the model stored from the lastresultof the session, omitted while unknown./metrics/subagentscounts subagent steps. A leadingTITLE:line is removed from the excerpt when the TITLE request was given. All ops go through the SDK's patch helper (throttle,base_version, rebase after409 base_version_conflict). -
Counter-check and session (09 18.3, 18.4, 9.1). At
system/init:apiKeySourcemust beANTHROPIC_API_KEYfororganizationandnoneforpersonal; anything else callsterminate, sends no reply and ends the run withfinishRunfailedbilling_mode_mismatch(BR-05: change during the run). Whensession_idequals the reference of the turn and the payload'sagent_sessionwas absent or not started, report once withreportAgentSession(external_session_ref,model,started: true) andIf-Matchofagent_session.version, or"1"whenstartRuncreated the row (M2-12 note 10); on412 version_conflictwhosecurrenthas the same reference, retry once with its version; other failures are logged with their code and change nothing. A differentsession_idis left to M2-35 (no report). Every request of the runner for a run, in prepare and in execute, goes through the SDK client inside the SDK's run binding (expectedClient.bind_run; M2-29 wraps execute, and the run loop wraps prepare too, which this ticket adds when the merged SDK does not, Scope), so it carries the run's newest token in the headerInvocation-Token(07 SR9, M2-14 notes 1 and 3d); the runner never calls the server beside that client. -
End of the turn (09 18.4, 18.7, 12, 4.10).
- After
resultthe runner closes stdin and waitsrunner.stop_gracefor the exit, thenterminate. A turn that exits withoutresult, or aresultwithis_erroror asubtypeother thansuccess, endsfailedwithruntime_error: first one step of kindstatuswithnameruntime_error, an emptytitle, statusfailed,finished_atanddetail{"exit": "<exit code or signal name>"}(no error text and no stderr, because both can echo content; M2-35 adds the recognised texts of 18.9), thenfinishRun; no reply and no title. - Success: the reply is
result.resultwithout a leadingTITLE: <title>line when the TITLE request was given; an empty reply sends nothing. OnecreateMessagein the thread withclient_msg_idfrom the SDK outbox (stored before sending),content_mdandrun_id; a400 validation_failedon it ends the runfailedwithadapter_error(03 sizesmessage.max_content_bytesfor long answers without splitting). - Title: only after a completed first run of a new or unstarted session and only when the line was present and not empty:
getThread, and whentitle_sourceisauto,setThreadTitlewithIf-Match;409 title_manual,412 version_conflictand any other failure are logged by code and never retried; the run still completes (AG-19). - Cost (18.7): per model the difference of
inputTokens,outputTokens,cacheReadInputTokens,cacheCreationInputTokensandcostUSDagainst the last stored values of the session (zero for a new session); a negative difference of any value means the runtime started counting again, and the current values count as this run's.CostReport{provider: anthropic, model, tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, amount_micros, currency: USD}, cache writes as 5 minutes (10 12.3),amount_microsfrom the decimal value ofcostUSDwith half-even rounding. BeforefinishRunthe runner writes<runner.state_dir>/sessions/<ref>/usage.jsonatomically with the new values and a pending record (run_id, the reports); a retriedfinishRunof the same run (a retry of the SDK, or M2-35's takeover after a restart) sends the stored reports (a repeated final state answers200, M2-09 note 6), so a delta is reported at most once and never twice. Ruling for docs/DECISIONS.md. finishRuncompletedwithcostsandmetrics(tokens_in,tokens_out,tokens_cache_read,tokens_cache_writeas sums of the run,context_pct,subagents,background_jobs,api_retries).409 invalid_state_transitionmeans the server ended the run already (for examplecancel_timeout): log it, never retry. Then remove the turn record and release the locks.- Cancellation safety: if the SDK cancels execute (for example on
run.cancel_requested, M2-29), the runner callsterminateon the turn's group and waits for it before the cancellation propagates, then releases the locks; a runtime never outlives the end of its run (09 1 point 4, 8). M2-35 builds the stop path, its reasons and its scenarios on this.
- After
-
Heartbeat (09 4.8, M2-20 note 3), built with the SDK's heartbeat building block and sent on the SDK's heartbeat path (the member
agentof the gateway heartbeat orsendHeartbeat, as M2-29 builds it). The members that change at run time (state,degraded_reasons,runtime_version,project_readiness) come from the runner at every send; if the merged SDK takes heartbeat members only once from its adapter configuration, add a handler hook to its heartbeat path (test, line insdk/python/CHANGELOG.md). After a channel cache change that altered a readiness, the runner asks the SDK for an immediate heartbeat when it offers one, else the next regular one carries it. Body:state(online, ordegradedwithdegraded_reasonsruntime_unreachablewhenclaude --versionfails or exceedsrunner.cli_probe_timeout,runtime_version_unsupportedwhen its version is belowMIN_CLAUDE_CODE_VERSIONor unreadable,runtime_version_untestedwhen it differs fromPINNED_CLAUDE_CODE_VERSION);runtime_version; noreported_billing_mode(the runner follows the desired mode per run, D-L8);billing_modes_supportedwithorganizationwhenrunner.anthropic_api_key_refis set andpersonalwhenrunner.allow_personalis true, omitted when both are off; nobilling_evidencein the heartbeat;reported_tool_class = runner.tool_class;detailswithcapabilities {title: true, components: false, voice_input: false, files_out: false}(nofollowupsuntil M2-35),active_runs(runs with a live turn),degraded_reasons,adapter {name: claude-code-runner, version},runtime {name: claude-code},protocol_versionfrom the SDK;project_readinesswith{channel_id, ready}for every bound forum in the cache.claude --versionis probed at start and before each heartbeat. While degraded withruntime_unreachableorruntime_version_unsupported, prepare ends a run withruntime_error(note 5c: no lock, no CLI call besides the probe);runtime_version_untestedonly warns the owner, and turns run, because the pre-check and the counter-check stay the binding evidence.MIN_CLAUDE_CODE_VERSIONis the version 09 18.2 names for--permission-prompts none;PINNED_CLAUDE_CODE_VERSIONis the version the fixtures and the field table of note 12 were written for (RK-14: "Pin the CLI version"), set to the same value in this ticket because 09 names no other; the README tells the operator to install exactly the pinned version and to keep automatic updates of the CLI off. A change of the pin is a change with refreshed fixtures and a run of the real-CLI suite. Ruling for docs/DECISIONS.md. -
Secrets and logs (BR-13, SEC-LOG-2, I5). Logs are JSON lines on stderr with event code, run, thread and channel ids; never message content, prompt text, tool input, titles, file names, tokens, keys or the values of
secret_ref. The API key exists only in the environment passed toauth statusand to the turn. Every file underrunner.state_diris created with mode0600(directories0700); the inbox stores invocation tokens by design (4.3) and nothing else secret.agent.config_changedreloads the forum's configuration (getServedChannelConfig;404removes it from the cache) and never logs its content.run.cancel_requestedis handled by the SDK's run loop as M2-29 builds it; the runner's part here is the cancellation safety of note 14, and M2-35 owns the stop.agent.followup,interaction.createdandcommand.invokedreach the handler's other entry points, which acknowledge and log them by type only (the runner reports no capability that leads to them). -
Fake-Claude, FakeServer and the real CLI.
- Fake-Claude (
tests/fake_claude/fake_claude.py, standard library only) runs through a wrapper that a test helper writes into a temporary directory with the test interpreter as its absolute shebang and the scenario and capture paths baked in (the allow-list environment would drop a control variable).--versionprints the configured version.auth statusprints{"loggedIn": ..., "authMethod": ...}:api_keywhenANTHROPIC_API_KEYis set; otherwise the content of<CLAUDE_CONFIG_DIR>/fake-loginwhenCLAUDE_CONFIG_DIRis set, else of<HOME>/.claude/fake-login, elsenone; a scenario can force raw output and exit code.-precords argv, working directory, environment, the content of the file after--append-system-prompt-fileand every stdin line into the capture file, emitssystem/init(session_idfrom the argv,model,apiKeySourcefrom the environment unless forced), echoes the first stdin line withisReplay: true, plays the scenario lines (placeholders for session and model), emitsresultunless told not to, and exits after stdin EOF. SIGINT ends it with aresultof subtypeerror_during_executionunless the scenario ignores it; it can ignore SIGTERM too. Fixtures are written from the message shapes of note 12; the first line of each is{"type":"fixture_meta","origin":"constructed","cli_version":"<PINNED_CLAUDE_CODE_VERSION>","source":"<URL cited in 09 18.4>"}, which Fake-Claude skips and which M2-35 and later recordings fill withrecorded. They are not recordings (no real turn may run here). - FakeServer:
bothy_sdk.testingin process on127.0.0.1port 0, signing invocation tokens with its own key. If it lacks a route the scenarios need (listServedChannels,getServedChannelConfig,downloadFile,getThread,setThreadTitle,reportAgentSession,sendHeartbeat), add it there with a test that validates its answers againstdocs/api/openapi.yaml, because the FakeServer is the shared adapter test double (09 16.1) and M2-33 needs the same. - Real CLI (09 16.1, 18.9 last paragraph, RK-14):
tests/real_cli/is not collected unlessCLAUDE_CODE_RUNNER_REAL_CLI=1(collect_ignore_globintests/conftest.py); without itpytest tests/real_clicollects nothing, with it a missing CLI fails and never skips. Level one, free and without any credential: the version output ofrunner.claude_binequalsPINNED_CLAUDE_CODE_VERSIONand is at leastMIN_CLAUDE_CODE_VERSION, and--helplists every flag of note 9. Level two, only withCLAUDE_CODE_RUNNER_REAL_TURN=1in addition, because it touches the operator's key or login and costs money:auth statusin the run environment of each mode reportsauthMethodas the table of note 8 expects, and one short real turn and one--resumeturn produce streams with every message type of note 12 and amodelUsagethat grows across--resume(the claim of 18.7). Nothing inmake checkdepends on either. Run level one once with the pinned CLI installed into a temporary directory (a package download of the pinned version only, a temporaryHOME, no login, no key) and report the result; if the installation needs more than a package download, do not run it and say so. Never run level two. Ruling for docs/DECISIONS.md. Report item 4 names the level-two command, its cost (two short turns on the operator's key or login), what it proves (auth statusshape, stream shapes, cumulative usage, the open checks of the README "To check before the build") and what can go wrong (real cost, use of the operator's login), as the owner's decision.
- Fake-Claude (
-
Rulings for
docs/DECISIONS.md(one row each with date, decision, reason and place):D-M2-34-1Python, POSIX, package names without the product name, no mkdocstrings page (notes 1 and the note on rule 3);D-M2-34-2TOML and the thirteen runner keys (note 2);D-M2-34-3actual class and mode at acceptance, no allow list (note 4);D-M2-34-4prepare order and the ends without a start (note 5);D-M2-34-5workspace resolution and readiness codes (note 6);D-M2-34-6environment, cleanedPATH, SSH agent off, own configuration directory fororganization(note 7);D-M2-34-7the pre-check table (note 8);D-M2-34-8command line,allowed_toolsonly forscoped,--add-dir, model and session choice (note 9);D-M2-34-9prompt split (system prompt file with the fixed instructions and the channel prompt, context and trigger part as two content items of the stdin line), attachments as paths in data blocks, skills unused (note 10);D-M2-34-10files instead of pipes, polling, inherited locks, retention (note 11);D-M2-34-11stream field table, step titles, reply, title and cost delta rules (notes 12 and 14);D-M2-34-12the version pin, the real-CLI suite in two levels and the FakeServer routes (notes 15 and 17). -
Specification text (English, 09; spec first): 18.1 readiness and the
allowed_toolsrule of note 9; 18.2 the base line in the order of note 9 with[--add-dir <incoming folder of the thread>], the cleanedPATH,SSH_AUTH_SOCKonly withrunner.forward_ssh_agent, model and session choice, stream and stderr in files, inherited locks, the stdin line with the context and the trigger part as two content items and the system prompt file with the fixed instructions and the channel prompt of note 10 (the row "Input" names the context with the attachment paths in the prompt), the version pin and the degraded reasonsruntime_version_unsupportedandruntime_version_untestedof note 15; 18.3 the table of note 8 and the rule fororganizationwithout a key; 18.4 the field table of note 12 marked [derived] and the title rule; 18.7 the delta rules of note 14; 21 the thirteen rows of note 2. Keep every [verified] and its source as it is.
Tests (write them first)¶
Test names carry the specification ID where one exists, so pytest -k AG_20 finds them. Configuration values in tests are set through a test configuration file; production code never hard-codes a window.
Unit (tests/unit/):
test_commandline.py:test_argv_chat_matches_spec_table,test_argv_scoped_matches_spec_table(with and withoutallowed_tools, intersection order, empty result omits the flag),test_argv_host_matches_spec_table;test_class_flags_equal_09_18_2_tablereads the table "Class | Flags | Effect" fromdocs/spec/09-agents.md, splits each cell withshlexand compares with the builder (placeholder ofscopedreplaced by the configured tools);test_session_flag(absent gives a new UUID with--session-id, unstarted reuses the reference, started gives--resume, a non-UUID reference fails);test_add_dir_only_scoped_and_host_with_files;test_model_precedence;test_values_starting_with_dash_rejected.test_toolclass.py: order andexceedsfor all nine pairs.test_environment.py: exact key set per mode from a parent environment that also holdsAWS_SECRET_ACCESS_KEY,GITHUB_TOKEN, the six forbidden variables of note 7 and an unrelatedBOTHY_variable (none passes);LC_ALLandLC_CTYPEpass;SSH_AUTH_SOCKonly with the switch;organizationhasANTHROPIC_API_KEYand the ownCLAUDE_CONFIG_DIR,personalhas neither;test_clean_pathdrops empty,., relative, missing and group or world writable entries and keeps order.test_authcheck.py: every row of the table of note 8 and itskey_type; non-JSON, missing member, unknown value, time limit and start failure giveunknown;nonewith exit code 1 givesruntime_not_authenticated.test_workspace.py:websiteandteam/websiteresolve; absolute,~x,-x,a\b,C:x,team/../x, a symbolic link pointing outside the root and the root itself are refused withoutside_root; readiness codesworkspace_missing,not_a_git_worktree,not_writable; a.gitfile counts.test_usage.py: first run of a session reports the full values; a resumed run reports the difference per model; a new model appears; a negative difference resets;costUSDto micros with half-even rounding; the pending record is reused for the same run and replaced by the next run.test_title.py,test_streamreader.py(partial lines, invalid lines, unknown types, end afterresultand after exit),test_livebox.py(one case per row of note 12, nestedparent_step_id, ambient task hidden, own prompt echo ignored,TITLE:stripped from the excerpt),test_prompt.py(system file sections without any context element, TITLE request only for new sessions, stdin line shape with the system part as first and the trigger part as second content item, transcript of the trigger outside data blocks, attachment list inside one),test_config.py(required keys, unknown key, bad duration, error text names key and file but no value,state_dirpermissions, tool and model values),test_locks.py(exclusive, timeout afterrunner.session_lock_wait, a child that inherited the descriptor keeps the lock after the parent closed it),test_turn.py(own process group, working directory, stream and stderr in files,terminatesends SIGINT, then SIGTERM afterrunner.stop_grace, then SIGKILL afterrunner.kill_graceto a child that ignores both),test_heartbeat.py(body of note 15 for each configuration, noreported_billing_mode, no secret or path),test_versions.py(pyproject.tomlversion equalsVERSION;MIN_CLAUDE_CODE_VERSIONequals the version named in 09 18.2; every fixture'sfixture_meta.cli_versionequalsPINNED_CLAUDE_CODE_VERSION).
Scenarios (tests/scenarios/, Fake-Claude plus FakeServer; world: space Engineering with the forums Projekt-A (binding website, a Git working tree under the test's projects root) and Projekt-B (binding docs, directory missing) and the text channel Werkzeuge without binding, all served by the agent Runner; Alex triggers, Robin is an owner with trigger, Sam has no trigger, Lotse is another agent; the FakeServer serves the RunContext of each test as a fixture):
test_happy_path_organization: tokenorganizationandscoped, runnerscopedwith two tools, the triggering message with onecleanfile attachment:startRunbody withbilling_mode_used = organization,tool_class_used = scoped, a UUIDexternal_session_ref,model,billing_evidence {provider: anthropic, key_type: api_key, method: claude_auth_status}; Fake-Claude argv equals note 9; environment equals note 7; patches for model, atool_callstep running then completed, excerpt;reportAgentSessionwithstarted: trueandIf-Match: "1"; one reply withrun_idand a storedclient_msg_id; the title from theTITLE:line set once;finishRuncompletedwithcostsandmetrics; the turn was started only after the200ofstartRun; the FakeServer assertsInvocation-Tokenwith the run's token ongetRunContext,downloadFile,getThread,setThreadTitle,reportAgentSession,patchRunandcreateMessage, and none onlistServedChannels,sendHeartbeatandgetServedChannelConfigoutside a run.test_tool_class_used_is_configured_class: for each configured class with a token class at least as high,startRuncarries the configured class and the argv the class flags.test_AG_30_runner_side_tool_class_mismatch: runnerhost, tokenscoped:finishRunfailedtool_class_mismatch, nostartRun, Fake-Claude never called, the event acknowledged; runnerchatwith tokenscopedstarts withtool_class_used = chat.test_BR_12_organization_without_key_no_fallback: Fake-Claude's fakeHOMEholds a subscription login; with the key reference empty, pointing to a missing file and pointing to an empty file:finishRunfailedruntime_not_authenticated, nostartRun, Fake-Claude never called; with a key whoseauth statusthe scenario forces tonone: same reason, no-p; with a valid key: the-penvironment has the runner's ownCLAUDE_CONFIG_DIRand none of the six forbidden variables, even when the runner's own environment sets them.test_AG_11_BR_05_billing_mode_mismatch:organizationwithauth statusforced toclaude.ai;auth statusforced to non-JSON;personalwithrunner.allow_personalfalse (no CLI call): eachfinishRunfailedbilling_mode_mismatch, nostartRun, no-p;personalallowed withclaude.ai: starts withbilling_mode_used = personal, no key and noCLAUDE_CONFIG_DIRin the environment.test_BR_05_counter_check_aborts_turn: pre-checkapi_key, butsystem/init.apiKeySourceforced tonone: the process group gets SIGINT, no reply,finishRunfailedbilling_mode_mismatch; with Fake-Claude ignoring SIGINT and SIGTERM the escalation ends it.test_AZ_P6_runner_never_starts_runtime: missing token, wrong signature,audof Lotse, expired token whose renewal the FakeServer refuses with403 agent_invocation_denied, a repeatedjti, a deviatingbilling_mode, a deviatingtool_class: Fake-Claude records no-pcall in any case; the invalid ones endfailedinvalid_invocation_token(AG-08 runner side); the repeatedjtigives exactly one turn.test_expired_token_renewed_before_start:startRunanswers403 invocation_token_invalidwithreason: expiredonce; the runner renews, starts once more and runs one turn; withreason: supersededit starts nothing and keeps the entry until a redelivery brings a newer token.test_AG_20_trust_marks_in_prompt(AG-20 adapter test, with an injected data-block boundary): the context holds messages of Sam (unverified), Lotse (unverified, roleother), an import (unverified), Robin (authorized) and Runner (self), each with a distinct canary text; the triggering message of Alex has a text, a file attachment with canary content and a voice attachment with a transcript. Everyunverifiedcanary and every local file path appears only inside data blocks of the first content item of the stdin line, between the helper's markers after its notice, never in the trigger item and never in the system prompt file; the file's canary content appears in no input; its local path appears only inside the file's marked data block and the file lies underfiles/<thread_id>/; the transcript appears in the trigger item of the stdin line outside any data block, labelled with trusttrigger; the channel prompt has its own section in the system prompt file.test_session_new_then_resume_and_cost_difference: first run--session-id Uand report; second invocation withagent_session.started = truegives--resume U, no report, no title, andcostsequal to the difference of the cumulative fixture values; the context canaries of the first run reach Fake-Claude only in the first run's stdin line, and the second run's input holds only the context elements of its ownRunContext(those abovecontext_floor_seq), so nothing is sent twice and the delivery sits in the session history; a FakeServer fault "stored, then 502" on the firstfinishRunmakes the retry send the same reports.test_cancelled_execute_terminates_group: the FakeServer cancels the run while its turn runs: when the SDK's handling of the cancellation ends, no process of the turn's group is left and both locks are free; a Fake-Claude that ignores SIGINT and SIGTERM is ended by the escalation ofterminate.test_AG_19_title_manual_keeps_run_completed:setThreadTitleanswers409 title_manual: no retry,finishRuncompleted.test_error_result_and_exit_without_result:resultwithis_errorand a process exit withoutresultendfailedruntime_errorwith a status step and no reply.test_project_readiness_in_heartbeat: Projekt-A ready, Projekt-B not ready (workspace_missing); afteragent.config_changedfor Projekt-B and creating its Git working tree, the next heartbeat (sent at once) reports it ready; the body matches note 15.test_unbound_channel_and_other_trigger_kinds_refused: an invocation in Werkzeuge endsfailedadapter_error;trigger_kindcommandendsfailedadapter_error; no CLI call.test_session_lock_and_parallel_runs: two runs of one thread run one after the other; a wait overrunner.session_lock_waitendsadapter_error; withrunner.max_parallel_runsat two, three runs in three threads never have more than two Fake-Claude processes at once.test_inbox_survives_restart: a runner stopped afterackand beforestartRunleaves the entry; a new runner on the samerunner.state_dirstarts the run exactly once.test_BR_13_no_secret_values_leak: a canary API key and a canary agent token appear in no FakeServer request body or header other thanAuthorization, in no captured log record, in no file underrunner.state_dir, and in no argv, stdin line or system prompt file of Fake-Claude; the key appears only in the environment ofauth statusand-pcalls.test_state_files_private_and_retention: modes0700and0600; transcripts, prompt files and downloaded attachments older thanrunner.stream_log_retentionare deleted after the next turn.
Real CLI (tests/real_cli/, only with the switches of note 17): level one test_installed_version_equals_pin and test_help_lists_contract_flags; level two test_auth_status_per_mode and test_real_turn_streams_mapped_messages. Run level one once as note 17 says; never run level two.
Commands that must pass, from the repository root:
cd plugins/claude-code-runner && uv run --locked pytestmake fmt-check,make lint,make typecheck,make test,make coverage,make licenses,make osvmake config-referencethenmake docs,make linkcheck;make api-clientswhensdk/pythonchangedmake check
Acceptance criteria¶
-
plugins/claude-code-runneris a Python component that every gate ofmake checkcovers (format, ruff withD, bandit, mypy strict, pytest with coverage atquality.coverage.python_min_percent, licenses, OSV, Sonar). - The Fake-Claude scenarios of this brief are green in
make check; Fake-Claude plays stream-json fixtures, reads stdin and reacts to SIGINT (09 16.1). - The command line of each tool class equals the table in 09 "Process contract" (
chat,scoped,host), checked against the spec text;tool_class_usedinstartRunis the configured class. - The tests against the installed CLI run only with
CLAUDE_CODE_RUNNER_REAL_CLI=1(logins and the paid turns also needCLAUDE_CODE_RUNNER_REAL_TURN=1);make checkcollects none of them; level one ran once against the pinned CLI in a temporary directory (or the report says why not), and the report hands level two to the owner. - AG-30 (runner side): a configured class above the token's starts nothing and ends
failedwithtool_class_mismatch. - BR-12:
organizationwithout a key never falls back to a subscription and endsfailedwithruntime_not_authenticated; the turn environment is an allow list with the runner's ownCLAUDE_CONFIG_DIR. - AG-11 and BR-05 (runner side): a pre-check mismatch or an unknown mode starts nothing, a counter-check mismatch interrupts the turn with SIGINT, both end
billing_mode_mismatch. - AZ-P6 and AG-08 (runner side): no case of an invalid, expired and unrenewable, repeated or mismatching token starts the runtime.
- AG-20 (adapter test):
unverifiedcontent only inside marked data blocks, file contents in no input and their paths only as data, transcripts with the trust of their message. - Readiness of every bound forum is reported in the heartbeat;
workspace_refnever resolves outsiderunner.projects_root. - Sessions, live box, reply, title once per new session and the cost difference per run follow notes 9 to 14; locks and
runner.max_parallel_runshold. - BR-13 (runner part): the key and the agent token appear in no log, request body, state file, argument or prompt.
- 09 18.1 to 18.4, 18.7 and 21 and the configuration reference describe the built behaviour; rows
D-M2-34-1toD-M2-34-12are indocs/DECISIONS.md. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
plugins/claude-code-runnerandpluginsupdated (status, layout, configuration, operation, what M2-35 and M3-12 add, the mode switch consequence of note 7). - Rulings of note 18 recorded in
docs/DECISIONS.md(one row each with reason); the changes to 09 and the regenerated configuration reference made in the same change. plugins/claude-code-runner/CHANGELOG.mdentry under 'Unreleased' (andsdk/python/CHANGELOG.mdif the FakeServer changed).- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.