design phase 9 harness runtime - sinrimin/harness-nexus GitHub Wiki
Status: W1βW4 shipped (2026-09) β Phase 9 complete. Ground truth:
research-phase-9-harness-runtime.md. Rides Phase 8's machines / daemon / jobs infrastructure (C1βC4); touchessharedβcliβserverβsdk-tsβwebin that order.
The platform manages profile artifacts on machines but is blind to the
harness software itself. Users cannot see whether claude / codex / dsh is
installed (which binary, which version, installed how), cannot install or
upgrade it remotely, cannot push the LLM provider/model configuration it should
run with, and cannot view the harness's effective config from the UI.
- Agent-first inventory β the Agent (installed harness runtime) is the primary object: cards group by Agent, items nest under it, a target without a runtime says "not installed" instead of showing empty lists; an Agent in default state (no platform items) can be captured as a profile.
- Runtime inventory β per machine Γ target: installed?, bin path, version, install method; refreshed by the same scan cycle as C3 inventory.
- One-click install / upgrade / pin of claude-code, codex, deepseek on a machine, as C4-style jobs with progress and failure reporting.
- Provider config push β set the LLM route (provider label, base URL, API flavor, model, API key) a harness uses, from a server-side entity that references a distributable credential.
- Redacted config viewing in the web UI β click a runtime, see the harness's effective config with secrets masked.
- Chat keys off the Agent, not the deploy record β any detected runtime (including emitter-installed claude-code) is a chat target.
- Session-level model overrides, per-profile model routing.
- Uninstalling harness software (reversal = pin an older version; full removal stays manual β destructive and cheap to add later if asked).
-
zcode/hermesruntime management (unlisted targets; hermes was CANCELLED with the user 2026-09-17 β dropped, not deferred). - Enterprise managed-settings hierarchies (Claude Code managed settings,
- OS packaging beyond npm + claude native + brew detection).
- ECC / Superpower import (still Phase 3) and orchestration (C6).
packages/shared/src/schemas/inventory.ts gains:
export const runtimeInfoSchema = z.object({
target: scannableTargetSchema, // claude-code | codex | deepseek
installed: z.boolean(),
binPath: z.string().max(512).optional(),
version: z.string().max(64).optional(), // raw `--version` output, trimmed
installMethod: z.enum(['npm', 'native', 'brew', 'unknown']).optional(),
});
// InventoryReport payload += runtimes: z.array(runtimeInfoSchema).max(8)- Produced by a
probeRuntimes()helper inpackages/cli/src/inventory/β PATH walk + known-location sniff +<bin> --versionwith a short timeout; method classification by path prefix (research Β§5.1). No config file contents in the snapshot. - Stored inside the existing
machine_inventorylatest-row JSON (no migration);GET /api/machines/:id/inventoryreturns it alongside items. -
Primacy:
runtimesbecomes the grouping key of the MachineDetail inventory view β items render under their Agent card; a target withinstalled: falserenders a single "Agent not installed" state (plus the W2 install button) instead of an empty item list.
type: 'harness'
payload: {
action: 'install' | 'upgrade' | 'pin' | 'apply-config' // see 4.3
target: 'claude-code' | 'codex' | 'deepseek'
version?: string // pin/upgrade target; omit = latest
channel?: 'stable' | 'latest' // claude-code only, optional
config?: RuntimeConfigSpec // apply-config only
}Same lifecycle, requeue rules, AgentInstance-free (harness jobs do NOT create
agent instances), same /app job:update push. The daemon executor
(packages/cli/src/daemon/runtime.ts) maps action Γ target to commands:
| target | install / pin | upgrade |
|---|---|---|
| claude-code | npm: npm i -g @anthropic-ai/claude-code@<v>; native fallback: curl -fsSL https://claude.ai/install.sh | bash -s <v>
|
npm @latest / claude update (native); writes DISABLE_AUTOUPDATER into settings env on first manage |
| codex | npm i -g @openai/codex@<v> |
npm i -g @openai/codex@latest |
| deepseek | npm i -g @deepseek-ai/dsh@<v> |
npm i -g @deepseek-ai/dsh@latest |
Installers spawn with the daemon's proxy/registry env forwarded, a generous
per-command timeout, and stream stdout tail into job:progress (last ~4 KB).
One row per (machine, target), owner-scoped, upserted via REST:
{
id, machineId, ownerId, target,
providerLabel: string, // display only
baseUrl?: string, // omit = provider default
api: 'anthropic-messages' | 'openai',
model: string,
credentialName: string, // Credential handle β MUST be distributable
extra?: Record<string, unknown>, // target-specific (e.g. codex wire_api)
updatedAt
}apply-config resolves the credential server-side (C4 deploy-bundle precedent)
and ships { spec, secret } inside the job payload β same blast-radius rules as
the deploy bundle: machine-PAT-only REST surface, never logged, never returned.
Per-target native placement (daemon-side writer, all files 0600):
| target | config write | key write |
|---|---|---|
| claude-code |
~/.claude/settings.json: env.ANTHROPIC_BASE_URL, env.ANTHROPIC_AUTH_TOKEN, model (merge, preserve user keys) |
same env block |
| codex |
~/.codex/config.toml: model, model_providers.harness_nexus block (base_url, wire_api, env_key) |
~/.codex/auth.json {"auth_mode":"apikey","OPENAI_API_KEY":β¦}
|
| dsh | managed MARKED region in home cordis.patch.yml (extends the T1 region): @deepseek-ai/dsh-llm-pi-ai row with providers.harness-nexus {api, baseURL, apiKeyEnv, models}
|
~/.dsh/env (KEY=VALUE, 0600) β sourced by daemon-spawned dsh processes; user shells get a documented snippet |
Writers are merge-preserving: unknown/user keys survive; every write is idempotent and re-runnable (upgrade = re-apply).
AgentInstance gains an additive source: 'deploy' | 'detected' (migration
0011, same one as RuntimeConfig):
-
deploy β today's rows (C4 upsert,
profileIdset). -
detected β upserted by the server whenever an inventory report shows an
installed runtime for (machine, target) and no deploy row exists:
profileId: null,name: <target>β the Agent in its current (possibly default) state. Two consecutive scans without the runtime remove the detected row (hysteresis against a flaky probe). A later deploy upgrades the row tosource: 'deploy'in place.
Chat re-gating (C5 change): the gating chain drops its implicit
deploy-only assumption β instance exists (any source) β remoteChatEnabled β online β capability β cap β and the session-open failure path (adapter
missing / no auth) stays the honest signal for an Agent that is installed but
not chat-ready. No wire-protocol change.
An Agent card action: "Capture current state as profile" β the C3
collect+import pipeline (reuse-or-create; MCP items become McpServer rows,
env/header values already redacted daemon-side) invoked without a diff
baseline. The Agent's default state becomes a named, deployable profile. The
existing diff view stays as the comparison tool; nothing about import
semantics changes.
- New capability string
runtimeadvertised inmachine:hello; routes gate on it exactly likeinventory/deploy. -
/ctlevents (shaped like the inventory pair):-
runtime:scanβ returnsRuntimeInfo[](also folded into every inventory report by the daemon, so one scan feeds both cards); -
runtime:config.getβ redacted effective config per target (files: [{path, content}]with secret-ish values masked by the daemon-side redactor extended from env/header masking to key-name-aware TOML/JSON/YAML value masking; plus aredacted: string[]list of what was hidden).
-
- Job dispatch (
job:dispatchwithtype: 'harness') handled by the runtime executor; progress/result via the existingjob:progress/job:result.
-
GET /api/machines/:id/inventoryβ now also carriesruntimes(W1). -
POST /api/machines/:id/jobsβ acceptstype: 'harness'payloads (deployJobPayloadSchemawidens to a discriminated union ontype). -
GET/PUT /api/machines/:id/runtime-config/:targetβ read (never returns the secret β echoescredentialName) / upsert-apply (queuesapply-config). -
GET /api/machines/:id/runtimes/:target/configβ proxied redacted view (asks the daemon, requires online +runtimecapability).
The inventory view regroups per Agent card (claude-code / codex / deepseek) β items nest under their Agent; a not-installed Agent shows a muted "not installed" line with the install button. Each card:
- status line: installed version in
IBM Plex Mono(2.1.211) + method badge (npm/native/brew), or a muted "not installed"; - Install / Upgrade button (confirm-first; optional version-pin input; Upgrade disabled when not installed) β creates the job, shows live status in the existing Deployments/jobs list;
- Provider config sub-form: base URL, api flavor, model, credential picker (distributable credentials only) β PUT queues apply-config;
-
View config β drawer with the redacted files (mono, wrap), source
text-muted-foregroundnote when values were masked. - Capture as profile (confirm-first) β C3 import without a baseline; links to the created profile.
Signal discipline: no new colors; version/mono as data; the accent stays on
live links/focus only. All strings via strings/machineDetail.ts (en/zh,
full-width punctuation rules for zh).
-
apply-configrequires the referenced credential to bedistributable(global non-distributable β 409, mirroring dial-site rules). - Secrets: resolved once server-side inside the job payload, written by the
daemon only into the native slots above (0600), never into profile artifacts,
never echoed by
config.get(masked daemon-side before upload), never in job progress/result/logs. - Harness jobs and runtime-config are owner-only (admins may view, not mutate β matches chat's owner-only stance for machine-touching actions).
-
W1 Agent-first inventory β SHIPPED (2026-09). Implementation notes
(deviations settled during the build):
- The runtime arm rides the EXISTING
inventory:reportevent as an optionalruntimes: RuntimeInfo[]array (one probe pass per scan cycle, folded into every report β absent on daemons without theruntimecapability); the server picks the snapshot target's entry and stores it as a per-rowruntimecolumn (migration0011alongside the agent_instances rework). The standaloneruntime:scanevent stays unimplemented β no W1 consumer; it lands with the wave that needs it. -
RUNTIME_TARGETS = [claude-code, codex, deepseek]in shared (hermes unlisted β no runtime management);probeRuntimes()lives inpackages/cli/src/inventory/runtime.ts(PATH walk + known-location fallback for the claude native launcher,<bin> --versionwith a 5 s per-probe timeout, parallel probes, realpath-based method classification with npm checked BEFORE the~/.localnative guess so an npm prefix of~/.localcan't masquerade as native). - Detected-instance sync (
server/src/realtime/runtime-instances.ts) runs fire-and-forget after each report is persisted (never blocks the report path):installed+ no deploy row for the target β upsert thesource: 'detected'row (directory= the snapshot's agent home β the chat cwd); a null arm is NO signal (old daemons neither register nor remove); two consecutive not-installed reports remove the row (in-memory hysteresis counter).JobService.registerInstanceupgrades a detected row tosource: 'deploy'in place when a deploy lands for that target. - Chat re-gating needed NO code change β C5's chain already keys off any
AgentInstance; detected instances simply exist now (covered byserver/test/runtime-instances.test.tsopening a channel against a detected claude-code instance). - Capture-as-profile is
POST /api/machines/:id/inventory/capture({target, profileName}) β the C3 collect+import core (collectAndBundle, now shared by both routes) with the item list derived server-side from the latest snapshot's importables; zero importables bundle a zero-entry profile ("default state" capture). MachineDetail regroups per Agent card (runtime status line: version + method badge or muted "not installed"; not-installed Agents stop rendering empty item tables) with the capture form on every card. - Verified by: unit/integration tests (probe fixtures incl. a hanging bin,
sync hysteresis/deploy-precedence, capture idempotence, detected-instance
chat), smoke
[9 W1](fake bins on a fixture PATH), and the docker rig.
- The runtime arm rides the EXISTING
-
W2 Install/upgrade/pin jobs β SHIPPED (2026-09). Implementation notes
(deviations settled during the build):
- npm is the ONLY install channel (
npm i -g <pkg>@<version|latest>β the daemon host always runs Node β₯20); the design's claude-native-installer fallback is unnecessary there. The one native path kept:claude updatefor an UPGRADE of an already-native claude-code install (npm over it would shadow, not upgrade); PINNING a native install refuses with a clear error. Managed claude-code machines getenv.DISABLE_AUTOUPDATER='1'merged into~/.claude/settings.json(merge-preserving, 0600; an unreadable settings file is a non-fatalwarningon the job result). -
channelis NOT in the W2 payload (CC stable/latest stays a W3 open question, Β§11).apply-configjoins the payload union in W3. - The REST body is a
type-discriminated union (createMachineJobSchema); a body WITHOUTtypedefaults to deploy (every pre-W2 SDK caller). Harness create is OWNER-ONLY (admin on a foreign machine β 403MACHINE_OWNER_ONLY) and soft-gated on the daemon'sharnesscapability when online β NOTruntime: W1 daemons already advertiseruntimewithout the executor (they settle harness jobs as unsupported), so the executor gets its own tag (0.6.0-p9w2advertises both). Harness jobs never create AgentInstances. - The daemon executor (
cli/src/daemon/runtime.ts) inherits the daemon's env (proxy/registry pass-through), a 10-min per-command timeout with SIGTERMβSIGKILL, streams a throttled stdout/stderr tail (last ~4 KB, last line per progress event) intojob:progress, re-probes the target on success, reports the landing version injob:result.data(harnessResultDataSchema), and AUTO-REPORTS the target's inventory (one report feeds the Agent card + the detected-instance sync). - Known race (accepted): a harness job's auto-report can be overwritten by the daemon's still-in-flight connect-time full report (probe latency); the next scan self-heals. Real flows (scan β see not-installed β install) don't hit it.
- Verified by: unit/integration (fake-
npmshim on PATH β argv, tail on failure, native-upgrade path, settings merge; route gates incl. owner-only + capability + payload union; harness round-trip with no AgentInstance side effects), smoke[9 W2], and the docker rig.
- npm is the ONLY install channel (
-
W3 Provider config β SHIPPED (2026-09). Implementation notes
(deviations and Β§11 resolutions settled during the build):
-
RuntimeConfigrides migration0012exactly as modeled (one row per machine Γ target,specJSON,UNIQUE (machine_id, target)), with aRuntimeConfigRepositoryport + sqlite/memory drivers and a machine-delete cascade. REST:GET/PUT /api/machines/:id/runtime-config/:target(GET owner-or-admin 404-hiding; PUT owner-only 403MACHINE_OWNER_ONLY, queues{type:'harness',action:'apply-config',target}β a bare apply-config body atPOST β¦/jobsis refused 409USE_RUNTIME_CONFIG_ENDPOINT). Route policy fromshared(runtimeSpecUnsupportedReason): api flavor per target (RUNTIME_API_SUPPORT: claude-code=anthropic-messages, codex=openai, deepseek=both) and deepseek REQUIRES a baseUrl. Credential gates mirror dial-site rules: unknown/foreign-personal β 404, non-distributable β 409. -
The secret never rides the job. The persisted payload names only the
target; the daemon fetches the resolved
{spec, secret}bundle at EXECUTION time fromGET /api/client/runtime-config?target=(machine-PAT REST exception #3, the TIGHTEST one β non-machine callers get a flat 404). Execution-time resolution means a requeued job after a credential rotation picks up the new value (deploy-bundle semantics, not a frozen snapshot). - Soft capability gate on the new
runtime-configtag (W2 daemons advertiseharnesswithout the writer; they would settle the job asharness payload invalid). Daemon0.7.0-p9w3advertises both. - Per-target writers (
packages/cli/src/daemon/runtime-config.ts, all files 0600, merge-preserving, idempotent): claude-code mergesenv.ANTHROPIC_AUTH_TOKEN+env.ANTHROPIC_BASE_URL(a baseUrl-less re-apply REMOVES ours β the platform owns the route) + top-levelmodelinto~/.claude/settings.json. codex sets root keysmodel+model_providerviamergeTomlRootKeys(top-level region only), replaces[model_providers.harness_nexus](name/base_url/requires_openai_auth = true, NOwire_apiβ see below), and merges~/.codex/auth.jsonto apikey mode withOPENAI_API_KEY. dsh writes aharness-nexus:providermarked region in the home patch with TWO insert rows β@deepseek-ai/dsh-llm-pi-ai(providers.harness-nexus: displayName/api/baseURL/apiKeyEnv/models[{id}]) AND@deepseek-ai/dsh-agent-default-model(provider/model β mounting a route alone doesn't select it) β plus the key into~/.dsh/.env(see Β§11). - Β§11 resolutions (all verified against sources): dsh key channel is
~/.dsh/.env(NOT~/.dsh/envas drafted) β dsh's native user-env credential layer (dsh-credentials-local: process env >~/.dsh/.credentials.yaml>./.env>~/.dsh/.env), read on EVERY launch including user shells β no wrapper, no shell snippet, no daemon ACP env injection needed; the env nameHARNESS_NEXUS_API_KEYavoids the bootstrap-only blocklist (DSH_*, proxy vars). claude-code version-less installs/upgrades track thestablenpm dist-tag (CHANNEL_TAGin the executor;latest/nextexist but move a managed fleet too fast) β no payloadchannelfield. Multiple claude binaries: first-found stands (W1 behavior), and the card already surfacesbinPath. codex keys: auth.json wins βrequires_openai_auth = true+ apikey-mode auth.json is the built-in provider's own pairing (bearer to the custom base_url; no env_key, no env persistence mechanism to invent). And a discovery beyond the question: current codex REMOVEDwire_api = "chat"(source: codex-rsmodel-provider-info/src/lib.rs, discussion #7782) β every route speaks Responses, so the emitter writes nowire_apiand gateways must be Responses-compatible;extra.wireApiwas dropped from the spec. - W2 fix riding along: the executor was called without
opts, so production daemons never wroteDISABLE_AUTOUPDATER(homeDir !== undefinedguard skipped it β tests/smoke always passed a fixture HOME and masked it);runHarnessJobnow defaultshomeDirtoos.homedir(). - Verified by: unit/integration (writer fixtures with planted user content
β merge preservation, idempotence, 0600, unset-removes-ours; route gates
incl. owner-only/distributable/flavor/baseUrl/machine-PAT-only bundle +
credential rotation; payload union), smoke
[9 W3](a REAL daemon dist fetches the bundle over REST and writes a fixture HOME), and the docker rig (codex + deepseek apply jobs succeeded; files verified in the container;dsh --versionstill boots with the patch rows; UI form prefilled per Agent card). -
Follow-up fix (2026-09-09): the dsh half was rewritten. The insert
rows above DOUBLE-REGISTER plugins the composition already mounts and
crash
dsh --profile acp(chat!). The writer now manages THREE slots: (1) a marked region in~/.dsh/settings.yamlwith thellm-pi-ai(provider route) andagent-default-model(selection) namespaces β the general default; (2) an id-targeted CONFIG OVERRIDE of theacpentry in the homecordis.patch.ymlβ the dsh-acp-app composition PINSprovider: deepseek-officialon the acp plugin itself, and a plugin's explicit config beats the settings default for its sessions, so chat needs the override; (3)~/.dsh/.envwith the key. A hand-managed namespace section or acp override is refused (no clobbering), the legacy W3 region is retired by the same apply, and[]/{}placeholder bases are absorbed (appending after them yields two YAML documents β a boot error). Separately discovered: dsh's plugin tree needs Node β₯22.15 (zlib zstd +Promise.withResolvers) β deepseek jobs on older daemons now carry a resultwarning. Rig verified end-to-end: Node upgraded, config re-applied,--profile acpboots, chat session ready on our route (the fake rig key surfaces as a visible 401 turn error).
-
-
W4 Config viewer β SHIPPED (2026-09). Implementation notes (deviations
settled during the build):
- Protocol: the design's
runtime:config.getpair, shaped exactly like C3's inventory flow βConfigViewerCoordinator(server/src/realtime/ config-viewer.ts, requestId waiters + timeout + failMachine on disconnect/ revoke) andGET /api/machines/:id/runtimes/:target/config(owner-or-admin, 404-hiding). Nothing is cached β every view is a live round-trip (a stale config view would lie). Errors: offline β 409MACHINE_OFFLINE, no capability β 409, timeout β 504VIEW_TIMEOUT, daemon error arm β 502. Config:RUNTIME_CONFIG_VIEW_TIMEOUT_MS(5 s default). - The gate is the NEW
runtime-config-viewcapability, not the design'sruntime(W3 daemons advertiseruntime/runtime-configwithout the viewer; the executor-tag rule from W2 applies). Daemon0.8.0-p9w4advertises all seven. - Redaction happens DAEMON-SIDE before upload (
cli/src/daemon/ config-view.ts): a key-name-aware JSON walk (token|key|secret|password| passwd|credential|authorization|bearer), UNANCHORED line masking for TOML/YAML (also catches inline maps andheaders = { Authorization: β¦ }), a quoted-pair scrub as the broken-JSON fallback (an anchored matcher misses"KEY":"value"shapes β caught in review), and.envfiles masked WHOLESALE (they exist to hold credentials). Over-redaction is safe; under-redaction is not. Files over 128 KiB or containing NUL are skipped with a placeholder; paths are display paths (~/.codex/auth.json) β the daemon's home never leaks.~/.dsh/.credentials.yamlis deliberately NOT read (pure secret store, not config). - Files per target: claude-code
~/.claude/settings.json; codex~/.codex/config.toml+~/.codex/auth.json; deepseek~/.dsh/settings.yaml+~/.dsh/.env(swapped from the patch file in the 2026-09-09 fix β the settings layer is the effective config now). - "Re-scan after apply" from the wave plan was DROPPED as a no-op: apply-config doesn't touch anything a C3 scan enumerates, so a rescan would return identical rows β the View button (live read) is the post-apply verification.
- Web: a right-side Drawer (
components/ui/drawer.tsx, the first Sheet β Radix Dialog primitives re-composed for tall read-only content; prefer it over centered dialogs there) with per-file mono<pre>blocks and a mutedtext-xscount of daemon-masked values; masked values render as the literal${redacted}. No--signal(data, not liveness). - Verified by: unit/integration (redactor fixtures incl. broken JSON and
the W3-written shapes; round-trip with a fake daemon + gates + late-
reply drop), smoke
[9 W4](the W3 fixture machine's real files come back masked β the planted secret never appears), and the rig (REST + drawer:${redacted}in auth.json, the masked-count note, zero leak).
- Protocol: the design's
Each wave lands independently (branch β tests β smoke section [9] β docs).
- Unit: version-string parsers, pathβmethod classification, TOML/JSON/YAML merge writers + redactor (fixture configs with planted secrets), job payload schema union.
- Integration: runtime executor against a fake
npmshim on PATH (records argv, prints canned version) β no network in tests. - Smoke (
scripts/smoke.mjs [9]): enroll β scan (runtimes present) β harness-install dsh@<pin>β poll job β runtime row shows version β apply-config (fixture credential) β redacted config view masks the key.
-
dshβapiKeyEnvfor user-typed shells~/.dsh/.env, dsh's own user-env credential layer (no wrapper/snippet). -
Claude Codeβstablevslatestdefaultstabledist-tag. -
Detected-instance lifecycle edge cases (multiple claude binaries)β keep first-found; the card surfacesbinPath. -
codex custom-provider keys withoutβ auth.json viaenv_keyrequires_openai_auth = true;wire_api = "chat"was REMOVED upstream (Responses-only).