Repository navigation
research phase 9 harness runtime
Status: studied (2026-09, W3 provider-config ground truth added 2026-09-08). The design (
design-phase-9-harness-runtime.md) builds on this; W1/W2 shipped, W3 shipped.Sources: official Claude Code docs (code.claude.com — setup / settings / env-vars / model-config), official Codex docs + the codex-rs SOURCE on GitHub (main @ 2026-09-08), the Phase 8 T1 dsh research (
research-phase-8-t1-deepseek-harness.md, pinned to dshv0.1.2-rc.1) and the acp-ref reference project's dsh overlay. W3 dsh facts were verified against the REAL@deepseek-ai/dsh@0.1.2-rc.1install in the local rig machine container (installed by the W2 acceptance run).
Phase 8 C3/C4 manage profile artifacts (skills, commands, MCP rows) on a machine. Nothing in the platform today can answer or act on:
- Is
claude/codex/dshinstalled on this machine? Which binary path, which version, installed how (npm / native / brew)? - Install / upgrade / pin that software remotely, one click.
- Configure the LLM provider route (provider, base URL, model, API key) the harness should use — remotely, without SSH.
- View the harness's effective configuration from the web UI (redacted).
- And the model question underneath it all: inventory should be Agent-first
(detect the Agent — the installed harness — then what's inside it), and
chat should key off the detected Agent, not off a deploy record: a
claude-code installed via the 3.5 emitter can't be chatted with today purely
because that install path never creates an
AgentInstance.
| Method | Command | Notes |
|---|---|---|
| Native (recommended) | curl -fsSL https://claude.ai/install.sh | bash |
No Node dependency. Pin: bash -s 2.1.89 or bash -s stable. Windows: install.ps1 / install.cmd. |
| npm | npm install -g @anthropic-ai/claude-code |
Node 22+ required since v2.1.198 (the package itself ships a native binary via optional deps). npm installs are soft-deprecated in favor of native. Never with sudo. |
| Homebrew |
brew install --cask claude-code (@latest for the bleeding-edge channel) |
No auto-update by default. |
- Native installs auto-update in the background (startup + periodic checks);
manual:
claude update.DISABLE_AUTOUPDATER=1(settingsenv) disables the background updater,DISABLE_UPDATESblocks everything. - npm:
npm install -g @anthropic-ai/claude-code@latest— notnpm update -g(respects the original semver range and may stay behind). - Channels:
latest(default, every release) vsstable(~a week old, regressions skipped) —autoUpdatesChannelin settings.json or/config.minimumVersionguards against downgrades when switching channels. -
Consequence for remote management: a platform-managed machine should set
DISABLE_AUTOUPDATERso our pinned/upgrade jobs are the only version movers.
-
claude --version→2.1.211 (Claude Code)(<semver> (Claude Code)). - Native launcher:
~/.local/bin/claude→~/.local/share/claude/versions/…; npm: resolves under the globalnode_modules; brew: under the brew prefix. Install-method detection = bin-path prefix sniff +claude doctorexists but is interactive-ish; path sniff is enough and cheap.
-
~/.claude/settings.jsonis the sanctioned file:envblock (ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY,ANTHROPIC_MODEL,ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL),model,autoUpdatesChannel,permissions,apiKeyHelper. Enterprise managed settings override it (out of our scope). - Routing nuance:
ANTHROPIC_BASE_URLonly changes where requests go; the model is chosen bymodel/ANTHROPIC_DEFAULT_*. Both must be set together for a provider switch. - Keys may live in settings.json
env— that is the documented pattern for custom endpoints (file perms 0600 apply as our hygiene, not theirs).
-
npm i -g @openai/codex(Node 18+; Rust binary via npm wrapper) ·brew install --cask codex· winget · direct binary from github.com/openai/codex releases. - No built-in updater contract we can rely on: upgrade = reinstall the same
channel (
npm i -g @openai/codex@latest,brew upgrade codex). Pinning =@<version>on npm. Version:codex --version→codex-cli <semver>(older builds) or a bare semver (newer); parse leniently.
-
Home:
$CODEX_HOME(default~/.codex). Config:config.toml—model,model_provider, and per-provider blocks:[model_providers.my-gateway] name = "My Provider" base_url = "https://gw.example.com/v1" wire_api = "chat" # or "responses" env_key = "MY_GATEWAY_API_KEY" # env var NAME the provider reads
-
Auth:
~/.codex/auth.json—{"auth_mode": "apikey", "OPENAI_API_KEY": "sk-…"}or ChatGPT-login tokens;codex login --with-api-key(key via stdin) writes it. -
Caveats that matter to us: a custom provider cannot reuse built-in OpenAI auth — supply its own key via
env_key(an env var the codex process must have) or a provider-specific auth file;requires_openai_auth = trueflips a provider back to auth.json and ignoresenv_key. Our remote apply must write BOTH the TOML block and a working key channel (auth.json for apikey mode; for custom providers the key has to reach the process env — see §5).
From the T1 research (ground truth pinned to v0.1.2-rc.1, developer preview —
expect movement across 0.1.x):
-
Install: npm-distributed —
npm i -g @deepseek-ai/dsh(runs fine vianpx @deepseek-ai/dsh …too). Home:$DSH_HOME→~/.dsh.dsh --versionprints the semver. -
Layering: profiles at
~/.dsh/profiles/<name>/(pnpm-managed plugin sets) → profilecordis.patch.yml→ homecordis.patch.yml(applies to every profile, hot-reloads live). T1 already owns a managed MARKED region in the home patch for MCP rows — the same region mechanism extends to provider rows. -
Provider config (verified in the acp-ref overlay): the LLM provider is a Cordis plugin, configured as patch rows:
- id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: demo: api: anthropic-messages # or the OpenAI-compatible api baseURL: https://… apiKeyEnv: LLM_API_KEY # env var NAME — the key never sits in the yml models: - id: <model-id>
The key reaches dsh via process env (
apiKeyEnv), not via a file. The acp-ref bridge does exactly this: template + env, applied with--patch.
-
Version probing is trivial and uniform — all three answer
<cli> --versionon PATH; a scanner can also locate the bin (which-walk over PATH + the known per-method locations) and classify the install method by path prefix (~/.local/share/claude→ native; globalnode_modules→ npm; brew prefix → brew; else unknown). -
Upgrades are idempotent reinstalls (npm
@latest/@<version>, brew upgrade) or a built-in updater (claude update). Downgrade = pin install. No separate "uninstall upgrade" concept — reversal is just another pin. -
Auto-update fights the manager only for Claude Code — remote-managed
machines should get
DISABLE_AUTOUPDATERalongside our installs. -
Secret placement differs per harness and the platform must map a
credential onto each native slot:
- claude-code →
settings.jsonenv(sanctioned; 0600 the file ourselves), - codex →
auth.json(apikey mode, 0600) or a custom provider'senv_key, - dsh →
apiKeyEnvprocess env (no file slot at all). The one genuinely awkward case is env-based key channels (dshapiKeyEnv, codexenv_key): daemon-spawned ACP sessions can be given env directly, but a key typed by the user into their own shell needs a persistent env file (~/.dsh/env+ shell hook, or a tiny wrapper) — W3 designs this per target.
- claude-code →
- Node prerequisites differ (claude-code npm needs Node 22+, codex 18+, dsh is itself a Node runtime) — but every machine running our daemon already has Node ≥20 (hnx requires it), so npm is a viable universal channel; the native installer is the fallback for claude-code on Node-less machines.
-
Machines need egress to npm (and claude.ai for native installs); proxied
machines must pass proxy env through to the spawned installer — the daemon
job executor should forward
HTTP(S)_PROXY/npm_config_registryfrom its own environment.
Today (C3/C4/C5): scanning enumerates items per target regardless of
whether the harness is even installed; AgentInstance rows exist only as a
side effect of C4 deploys (claude-code deliberately excluded — the 3.5
emitter is its preferred path); chat gating starts at AgentInstance → remoteChatEnabled → online → capability → cap. Consequence: a machine with a
perfectly good claude shows empty target cards (pre-fix: no explanation) and
no chat target.
Wanted: the Agent (installed harness runtime) is the primary object.
-
"Agent present" = the runtime probe finds the binary (§5.1). Items nest under it; a target without a runtime renders "Agent not installed" instead of an empty item list.
-
Profile attribution is optional. Items already carry
origin: platform | local(install ledger,harness-nexus*names, CC marketplace cache names). An Agent whose items are alllocalis simply in its default state (缺省) — nothing wrong, nothing to attribute. C3's collect+import (reuse-or-create) already is "save current state as profile"; it is just only reachable through the diff view today. Making it a first-class "capture as profile" action on the Agent is a UI/route reframing, not new machinery. -
Chat follows the Agent. Per-target ACP adapter prerequisites (from
daemon/acp/adapters.ts, verified):target adapter command prerequisites on the machine claude-code npx -y @zed-industries/claude-agent-acpNode/npx + local Claude auth (the adapter bundles the CLI itself) codex npx -y @zed-industries/codex-acpcodexon PATH + auth (ChatGPT login or API key)deepseek dsh --profile acpdshon PATH + a configured provider routeSo "detected Agent ⇒ chatable" is sound per target, with adapter failure (missing auth, missing dsh) surfacing at session open exactly as it does today. What's missing is only the instance registration for non-deployed agents — an auto-registered
AgentInstance (source: detected)closes the emitter gap with no protocol change.
- A runtime arm on the existing inventory report is the natural carrier for presence/version/method (C3 already round-trips per-target data on scan).
-
Install/upgrade rides the C4 job pipeline (queued → dispatched → running →
succeeded/failed, requeue-on-disconnect) as a new job type with a per-target
command table; the daemon gains a
runtimecapability. -
Provider/model config is a first-class server-side entity referencing a
distributable credential (existing
Credential.distributablerule), applied by the daemon into the native slots above — never into profile artifacts. -
Chat keys off the detected Agent: runtime detection auto-registers
AgentInstance (source: 'detected')rows (one per machine × target), so emitter-installed claude-code becomes chatable with zero protocol change. -
Config viewing is a redacted read-back of the harness's own files/env
(extend the daemon-side redactor that already scrubs env/header values to
${cred:<KEY>}).
-
Provider plugin
@deepseek-ai/dsh-llm-pi-ai(lib/types/config.d.ts): plugin config is{ providers?: Record<route, PiAiProviderProfile> }; the dict key IS the route.PiAiProviderProfilefields we use:displayName,api(wire protocol string — REQUIRED for a route the pi-ai catalog does not ship),baseURL(likewise required — no catalog default),apiKeyEnv(env var NAME, "resolved per request through ctx.credentials"),models: PiAiModelProfile[]where ONLYidis required (context window / max tokens / modalities default at route level: 262144 / 32768 /[text]). -
api flavor mapping (pi-ai
KnownApiunion):anthropic-messagesverbatim; ouropenai→openai-completions(the chat-completions interop name;openai-responsesalso exists in the union). -
Default model selection
@deepseek-ai/dsh-agent-default-model(README): config{ provider: <route>, model: <id> }— what FRESH agents start on. Mounting a provider route alone does NOT select it; the composition entry is the base, a user's saved selection layers over ours (user choice wins — honest, not a fight). -
Key channel — the big one: dsh has a NATIVE env-file credential layer.
dsh-credentials-localresolves anapiKeyEnvname through, highest first:- the inherited process environment (
KEY=… dsh), -
$DSH_HOME/.credentials.yaml(provider-managed, writable — YAMLrefs/recordsshapes, not worth generating), -
<invocation cwd>/.env(project layer), -
$DSH_HOME/.env(user layer — our slot).dsh-app-bootparses both.envfiles on EVERY launch and materializes non-inherited values — user shells, daemon ACP spawns, everything; NO wrapper script or shell snippet needed. The parser REJECTS bootstrap-only names (DSH_*prefix, proxy/CA vars,DEEPSEEK_BASE_URL,EDITOR, …);HARNESS_NEXUS_API_KEYis clear of the blocklist. (The design's~/.dsh/envguess was wrong by one dot — it's.env.)
- the inherited process environment (
-
Correction (2026-09-09, post-ship): do NOT insert these plugins as loader
entries. The dsh composition (e.g.
dsh-base+dsh-acp-appfor the acp profile) ALREADY mountsdsh-llm-pi-aianddsh-agent-default-model; a home-patch- insert:of either double-registers and crashes the plugin tree (configurable provider "amazon-bedrock" is already declared/service "agentDefaultModel" has been registered). The sanctioned channel for an already-mounted plugin is its SETTINGS section —dsh-settings-filekeeps one document at~/.dsh/settings.yaml(namespace → user section, hot-reloaded, 0600, loud on invalid):-
llm-pi-ai:→ the pluginConfig(providers:dict) — dormant until a section appears, routes activate live; -
agent-default-model:→{provider, model, reasoningEffort?}; - a duplicate top-level namespace key is a boot error — a hand-managed section must be refused, not merged.
-
- Also learned: an EMPTY
cordis.patch.ymlis itself a boot error ("must be a top-level YAML array") — normalize stripped docs to[]. -
Node floor: the dsh plugin tree needs Node ≥22.15 (zlib zstd
createZstdDecompressindsh-session-persistence-jsonl;Promise.withResolversindsh-agent-loop). Node 20 runsdsh --versionbut EVERY profile load (--profile acpincluded, i.e. chat) fails. The harness jobs now attach a resultwarningon deepseek targets when the daemon runs an older Node. -
More follow-up facts (2026-09-09, chat verification round):
- The
dsh-acp-appbundle's composition PINS the ACP plugin's model:- id: acp … config: {provider: deepseek-official, model: deepseek-v4-flash}— a plugin's explicit config beats the settings-layer default for its sessions, so the settingsagent-default-modelsection alone does NOT reroute chat. The patch layer's id-targeted CONFIG OVERRIDE (- id: acp / config: {provider: harness-nexus, model}) does — verified end to end (fresh acp sessions select our route). - A
session/newfired the instantinitializeresolves races the agent's model-adapter registration: dsh replies-32603 Internal errorwithdata.details: "no adapter registered for provider …"(the bare message hides the cause — our JSON-RPC client now surfacesdata.details, and the daemon retries that specific failure with backoff). - Adapters answer turn failures (bad API key → dsh replies an Internal
error turn; claude-code replies
-32000 Authentication required) as JSON-RPC errors WHILE STAYING ALIVE — a client must treat a rejected prompt as a turn error, not a dead channel. (dsh 0.1.2-rc.1's acp app DOES exit after a failed turn — its own bug; the error surfaces first.)
- The
-
wire_api = "chat"is REMOVED (model-provider-info/src/lib.rs):WireApihas exactly one variant —Responses; deserializing"chat"fails with a pointer to openai/codex discussion #7782. Consequence: nowire_apiin our emitted block, and a target gateway must speak the Responses API (/v1/responses). The old "default wire_api=chat for gateways" interop advice is dead. -
Key channel:
ModelProviderInfo.env_keyreads the key from the process environment (no file fallback — codex has no~/.codex/.envlayer). The file channel isrequires_openai_auth = true(default false): "login preference and token/key are stored in auth.json" — withauth.json = {"auth_mode":"apikey","OPENAI_API_KEY":…}the auth manager builds an ApiKey bearer (login/src/auth/manager.rs: from_auth_dot_json) sent to the provider'sbase_url(explicitbase_urloverrides the OpenAI default;create_openai_provider— the built-in — uses exactly this pairing withenv_key: None). Caveat: auth.json auth is GLOBAL for the codex install — pushing an apikey config switches the machine's codex off any ChatGPT login. That IS "point this harness at our gateway". - TOML surgery constraint: root keys (
model,model_provider) may only be edited in the region BEFORE the first[section]header — appended at file end they'd land inside the last table. HencemergeTomlRootKeysoperates on that region only; the[model_providers.harness_nexus]section is order-independent and rides the existingmergeTomlSection. - Provider id
harness_nexus(underscore — bare TOML key, no quoting).
- npm dist-tags (registry, 2026-09-08):
stable2.1.236 ·latest2.1.263 ·next2.1.263 —stableexists and tracks ~a week behind. Version-less managed installs use@stable. - The provider write is exactly the documented settings surface:
env.ANTHROPIC_BASE_URL+env.ANTHROPIC_AUTH_TOKEN(bearer to custom endpoints) + top-levelmodel— base URL alone doesn't switch the model.