| D-M2-34-0 |
2026-10-23 |
Built first on an integration branch with the dependencies stacked, then ported onto main after M2-03, M2-12 and M2-29 landed: the ticket's own commits are cherry-picked; the spec text of 18.1 to 18.4, 18.7 and 21 is written into the English chapter 09-agents.md, the tool class table of 18.2, R-AG-23 and D-W-16 were on main already; the rulings and changelog entries are fragments (PL/24). |
The dependencies reached main before this ticket; the rules of main replace those of the integration branch. |
| D-M2-34-1 |
2026-10-21 |
The runner is Python on the SDK, asyncio, POSIX only (Linux, macOS; run refuses other platforms); distribution claude-code-runner, package claude_code_runner, heartbeat names claude-code-runner and claude-code; no mkdocstrings page: the public symbols carry google docstrings checked by ruff D, the operator interface is the README and 09 21. |
Process groups, flock and the signals of 18.6 have no Windows equivalent the spec names; 02 6 lists no place of the product name for the runner; 23 names generated API docs only for the SDKs. |
| D-M2-34-2 |
2026-10-21 |
One TOML file (--config, tomllib), keys nested by their dots ([runner], [adapter], [agent], [sdk]); unknown keys, wrong types, missing required keys and invalid durations (ms, s, m, h, d) stop the start with key and file, never a value. Thirteen new keys in 09 21 (runner.server_url, runner.instance_id, runner.agent_token_ref, runner.dpop_key_ref, runner.consumer, runner.tool_class, runner.scoped_allowed_tools, runner.default_model, runner.anthropic_api_key_ref, runner.allow_personal, runner.forward_ssh_agent, runner.cli_probe_timeout, runner.poll_interval); the existing runner keys runner.followup_grace and runner.attach_* are read and unused until M2-35 and M3-12. secret_ref values (file, env) are read fresh for each use into the SDK's redacted Secret. runner.server_url is the server origin; the runner appends /api/v1. A DPoP key must be a file: reference. |
configuration.md 2 and 3; a value that can be printed must never hold a secret. |
| D-M2-34-3 |
2026-10-21 |
Acceptance: the actual tool class is runner.tool_class (above the claim the SDK refuses with tool_class_mismatch); the actual mode is the claim's when the runner may run it (organization always, personal only with runner.allow_personal), else unknown (billing_mode_mismatch); after prepare the mode of the pre-check evidence. No allow list of point 9. A trigger_kind command has no counterpart in the SDK verifier yet and ends invalid_invocation_token there (M2-29); interaction reaches prepare and ends adapter_error. |
I1 and I2 are decided by the server and carried by the token; the binding evidence must describe the environment of the turn (10 7). |
| D-M2-34-4 |
2026-10-21 |
Prepare order: trigger kind, binding (reload when settings_version is newer), runtime degraded, project, session and model choice, thread lock then session lock then a slot, context (404 or run_inactive end the run without finishRun), attachments, environment and pre-check, start options. The SDK got RunRefused(reason), which ends a run from prepare with exactly that reason (or without finishRun), and AgentHandler.on_not_started, which frees slot and locks of every prepare that does not reach execute. A lost finishRun answer (5xx, 429, transport) is sent again with the same body. |
09 4.9 and 18.11; the SDK mapped every prepare exception to adapter_error; a run must never stay running because one answer was lost, and only the call is repeated, never the turn (R-AG-17). |
| D-M2-34-5 |
2026-10-21 |
Workspace: refuse an empty, absolute, ~ or - led reference, one with \, a drive letter or a .. segment; join with the real root, realpath, require a strict descendant; the real path is the working folder. Ready: a folder with .git (folder or file) and read, write and search rights; log codes outside_root, workspace_missing, not_a_git_worktree, not_writable; computed at start, after channel cache changes and before every heartbeat. |
Defence in depth after M2-03 note 4; linked work trees count. |
| D-M2-34-6 |
2026-10-21 |
The run environment is built from scratch: HOME, USER, LOGNAME, SHELL, LANG, TERM, TMPDIR, LC_*, a cleaned PATH (absolute, existing, not group or world writable, no duplicates), SSH_AUTH_SOCK only with runner.forward_ssh_agent; organization adds ANTHROPIC_API_KEY and <runner.state_dir>/claude-config/organization; personal adds nothing. |
09 18.2, 18.3; a writable path folder or an agent socket would hand the turn more than its class (T-DEV-01). |
| D-M2-34-7 |
2026-10-21 |
Pre-check table: organization with api_key and personal with claude.ai or oauth_token start; none is runtime_not_authenticated; every other value and unknown is billing_mode_mismatch; key_type from authMethod alone; the exit code is ignored; organization without a key calls no CLI. |
09 5.2 and 18.3, BR-12: the runner never falls back to a subscription. |
| D-M2-34-8 |
2026-10-21 |
Command line in the order of 09 18.2; class flags exactly the cells of the table (scoped one argument per tool, --allowedTools left out without tools); allowed_tools narrows only scoped; --add-dir with the thread's incoming folder only for scoped and host with stored files; model from the binding, else runner.default_model; session by agent_session.started only; values that start with - or hold controls are refused. |
dontAsk reads only in the working folder; bypassPermissions honours no allow list; arguments must never turn into flags. |
| D-M2-34-9 |
2026-10-21 |
Prompt split: the system prompt file holds the fixed runner instructions (TITLE request only for a new or unstarted session) and the channel prompt in its own section; the first stdin line holds the rendered context with the attachment paths as the first content item and the trigger's words and transcripts as the second; attachments are files under files/<thread_id>/, named only as local file: <path> inside their data block; behavior.skills is unused. |
ADR 0054 and SEC-AI-1; the context stays in the session history for --resume; Claude Code loads skills from the working folder. |
| D-M2-34-10 |
2026-10-21 |
Stream and stderr go to files, read every runner.poll_interval; lock descriptors are inherited by the turn; transcripts, prompt files and downloaded attachments are swept after runner.stream_log_retention at start and after every turn; terminate is SIGINT, SIGTERM after runner.stop_grace, SIGKILL after runner.kill_grace to the group, and a cancelled execute terminates the group before the cancellation propagates. |
18.2 "Detachment" and "Locks", 18.6; a runtime never outlives its run. |
| D-M2-34-11 |
2026-10-21 |
The stream field table of 18.4 [derived], step titles from the first string member in a fixed order, the reply without the TITLE: line, the title once after the first completed run of a new session, cost as the per-model difference of the cumulative values with a reset on a negative difference, half-even micros and a pending record in usage.json. Logs are JSON lines with codes and IDs only; the WebSocket library's frame dumps stay at level warning because a frame can hold the API token. |
09 12, 18.4, 18.7; SEC-LOG-2. |
| D-M2-34-12 |
2026-10-21 |
MIN_CLAUDE_CODE_VERSION and PINNED_CLAUDE_CODE_VERSION are both 2.1.259; the version probe runs at start and again in the background whenever the SDK builds a heartbeat (so a heartbeat carries the outcome of the probe before it); the real-CLI suite has level one (CLAUDE_CODE_RUNNER_REAL_CLI=1) and level two (also CLAUDE_CODE_RUNNER_REAL_TURN=1); the FakeServer got the routes and world parts the scenarios need (forums with bindings, agent.config_changed, getMe, downloadFile, getThread, setThreadTitle, reportAgentSession, files, contexts per run, Fault.problem) and accepts costs only with FakeServer(costs=True), because the server books costs only from M2-22 on. The agent under test of the FakeServer stays Lotse, and Texter is the other agent of the scenarios. |
RK-14; 09 16.1 names the FakeServer the shared adapter test double; the heartbeat hook must not block on a process. |
| D-M2-34-13 |
2026-10-23 |
Fake-Claude never waits a fixed real time: a scenario holds auth status or a turn with a gate file the test creates (auth_gate, turn_gate), and helper.install and helper.update refuse the former keys auth_sleep, version_sleep and delay; Fake-Claude holds SIGINT back while it writes, so its handler never runs inside a blocked flush of the same buffer. Every wait of the runner tests is bounded only by quality.test.patience (bothy_sdk.testing._patience.PATIENCE), including the scenario world's session_lock_wait and cli_probe_timeout unless a test of that limit sets its own. |
The red team reproduced a failure of the AZ-P6 token scenario on a throttled machine: the clock steps outlasted a two second auth_sleep, so the token never expired before the pre-check answered. The delta red team reproduced the counter-check scenario failing when SIGINT arrived inside Fake-Claude's flush (a reentrant write killed it before it recorded its end). |
| D-M2-34-14 |
2026-10-24 |
Two SonarCloud findings are marked NOSONAR with a reason: the synchronous open in locks.acquire and the read of the --config path in config.load_config. |
Opening a local lock file does not block, and a thread executor would leak the descriptor when the waiting task is cancelled; the --config path comes from the operator's command line, not from a model. |