Loading files
a2547e789This file is pinned to the selected release. Download the original to open it in your design tool.
import 'dotenv/config'
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import { z } from 'zod'
import { parseHerdrSessions, parseTerminalBackends } from './terminalConfig.js'
import { adoptComputerId } from '../lib/computerIdentity.js'
// Packaged files (cli.js/notify.mjs) live in ~/.harness/cli; mutable state in ~/.harness/cli/data.
// `~/.harness` is the PRODUCT data root and does not move — see the naming discipline in CLAUDE.md.
const adapterRootDir = join(homedir(), '.harness')
const adapterCliDir = join(adapterRootDir, 'cli')
const adapterDataDir = join(adapterCliDir, 'data')
// The private, checksum-verified Node the installers and Desktop Harness provision. `current-node`
// inside it names the binary in use; both writers rewrite that file whenever they lay down a runtime.
const adapterRuntimeDir = join(adapterRootDir, 'runtime')
// The `harness` launcher the installers write. Not under ~/.harness: it has to sit on PATH.
const adapterBinDir = join(homedir(), '.local', 'bin')
// The computer id lives at the PRODUCT root, one level ABOVE `cli/`, and deliberately not in the data
// dir: `harness reset` wipes that dir, the ~/.machine adoption below force-replaces entries in it, and
// a custom ADAPTER_DATA_DIR moves it. A computer's identity must outlive all three — the backend binds
// a machine to it, so a regenerated id silently mints a SECOND machine for a box that already had one.
// The SSO session is the only other product-root state (`~/.harness/auth/session.json`); both it and
// this id intentionally survive an adapter-data reset.
const computerIdFile = join(adapterRootDir, 'computer-id')
// ── One-time adoption of adapter state written under an older name ────────────────────────────────
//
// Two accidents from the machine rename, with different resolutions:
//
// 1. A released build resolved the root as `~/.machine/cli` instead of `~/.harness/cli`. Anyone who
// ran it has been LIVING there since — that tree holds their current token, tmux registry and
// E2EE pairings, while `~/.harness` is frozen at whatever it was when they upgraded. So the data
// dir is FORCE-MOVED across: `~/.machine` wins outright. Anything else would silently roll those
// users back to a stale token and an empty session registry.
// 2. Files INSIDE the data dir were renamed (machine-id → computer-id, harness-name → machine-name,
// machine.log → harness.log — the log carries the PRODUCT's name, like the tree it sits in, so this
// one went out and came back). Within one tree there is nothing to arbitrate, so those only fire
// when the new name is still free — and they run first, so the force-move above still wins.
//
// `renameSync` preserves the inode, so a daemon still holding an fd on the old path keeps writing
// into the same file under its new name.
/** Move `legacy` → `current` only when `current` is free (in-tree rename). */
function adoptPath(current: string, legacy: string): boolean {
try {
if (existsSync(current) || !existsSync(legacy)) return false
renameSync(legacy, current)
return true
} catch {
return false // best-effort: a failed adoption must never stop the CLI from starting
}
}
/** Move `legacy` → `current`, replacing whatever is there. Used only for the ~/.machine data dir. */
function forceMove(current: string, legacy: string): boolean {
try {
rmSync(current, { recursive: true, force: true })
renameSync(legacy, current)
return true
} catch {
return false
}
}
function migrateLegacyAdapterState(): void {
// The computer id is lifted UNCONDITIONALLY, before the custom-dir bail-out below. It is the one
// piece of state whose location is not the caller's to choose: it is this box's identity, the
// backend binds a machine to it, and an install that kept it in a custom data dir must have it
// carried across rather than silently re-minted at the root. Point ADAPTER_COMPUTER_ID_FILE (or
// ADAPTER_COMPUTER_ID) somewhere else if you genuinely want a separate identity.
// Runs BEFORE the ~/.machine force-move below so that tree can no longer REPLACE an id we already
// adopted — see lib/computerIdentity.ts for why replacing one is a data-loss bug, not a refresh.
const adopted = adoptComputerId(computerIdFile, [
join(process.env.ADAPTER_DATA_DIR || adapterDataDir, 'computer-id'),
join(process.env.ADAPTER_DATA_DIR || adapterDataDir, 'machine-id'),
join(adapterDataDir, 'computer-id'),
join(adapterDataDir, 'machine-id'),
join(homedir(), '.machine', 'cli', 'data', 'computer-id'),
join(homedir(), '.machine', 'cli', 'data', 'machine-id'),
])
// Everything past here only applies to the default location. An explicit ADAPTER_DATA_DIR (tests,
// custom installs) is the caller's business and must not be reshaped underneath them.
if (process.env.ADAPTER_DATA_DIR) return
let moved = adopted
// In-tree renames first, so a machine that never saw the ~/.machine build still lands on the new
// names. The force-move below overrides these where both exist.
for (const [current, legacy] of [
['computer-id', 'machine-id'],
['machine-name', 'harness-name'],
['harness.log', 'machine.log'],
]) {
if (adoptPath(join(adapterDataDir, current), join(adapterDataDir, legacy))) moved++
}
const strayDataDir = join(homedir(), '.machine', 'cli', 'data')
if (existsSync(strayDataDir)) {
mkdirSync(adapterDataDir, { recursive: true, mode: 0o700 })
for (const entry of readdirSync(strayDataDir)) {
if (forceMove(join(adapterDataDir, entry), join(strayDataDir, entry))) moved++
}
// Whole stray tree goes: its cli.js/notify.mjs are only a bundle copy the launcher never runs
// (it execs ~/.harness/cli/cli.js) and the next self-update rewrites them.
try {
rmSync(join(homedir(), '.machine'), { recursive: true, force: true })
} catch { /* ignore */ }
console.warn(`[machine] moved adapter state from ~/.machine/cli/data into ${adapterDataDir}`)
} else if (moved) {
console.warn(`[machine] renamed ${moved} pre-rename file(s) in ${adapterDataDir}`)
}
}
migrateLegacyAdapterState()
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
// Localhost port the SessionStart/SessionEnd hook callbacks POST to (hook/notify.mjs). A quiet FIXED
// value (below the OS ephemeral range, outside the project's 80xx/8100-8999/9001-9999 ranges). No
// free-port fallback — if it's taken, the adapter reports it (another adapter is likely running).
PORT: z.string().default('18473').transform(Number),
// The loopback port `harness login` listens on for the SSO redirect. 0 (the default) takes whatever
// the OS gives, which is right on a real computer: the browser and the listener are the same
// loopback, so the port never has to be known in advance.
//
// It has to be PINNABLE for a login that happens inside a container. There the browser is on the
// host and the listener is not, so the only way the redirect ever arrives is a published port — and
// a port cannot be published before it is known. Set this, publish the same number, and the real
// SSO flow works from a box with no browser of its own. See cli/docker/remote-machine/.
ADAPTER_LOGIN_CALLBACK_PORT: z.string().default('0').transform(Number),
// The backend the CLI dials (`/api/adapter-ws`). Local dev: ws://localhost:8090.
BACKEND_WS_URL: z.string().default('wss://harness-api.autonomous.ai'),
// SSO account plane used by native-loopback login and the adapter WebSocket.
AUTONOMOUS_ENV: z.enum(['prod', 'stag']).default('prod'),
// Web app base URL — used to print the agent's chat link on `adapter start`. Local: http://localhost:3000.
WEB_URL: z.string().default('https://harness.autonomous.ai'),
// Set to '1' to let the New Agent folder browser (fs_list_dir) list directories outside $HOME.
// Off by default so a fat-fingered path or a compromised relay hop can't walk the whole filesystem.
HARNESS_FS_BROWSE_UNRESTRICTED: z.string().optional(),
// Where Claude Code writes its per-session JSONL transcripts.
CLAUDE_PROJECTS_DIR: z.string().default(join(homedir(), '.claude', 'projects')),
// Codex state root. Only hook-registered rollout files beneath <CODEX_HOME>/sessions are exposed.
CODEX_HOME: z.string().default(join(homedir(), '.codex')),
// Grok state root. Conversation records live below <GROK_HOME>/sessions/<encoded-cwd>/<uuid>/updates.jsonl.
GROK_HOME: z.string().default(join(homedir(), '.grok')),
// Antigravity CLI state root. Conversation transcripts live below
// <AGY_HOME>/brain/<conversationId>/.system_generated/logs/transcript_full.jsonl — the `_full`
// sibling is the one to read: plain `transcript.jsonl` re-quotes tool arguments and truncates
// long content. The hook payload names the same path, so this default is only the fallback.
AGY_HOME: z.string().default(join(homedir(), '.gemini', 'antigravity-cli')),
// Where the Antigravity CLI reads its shared customization root (hooks.json lives here).
AGY_CONFIG_DIR: z.string().default(join(homedir(), '.gemini', 'config')),
// GitHub Copilot CLI state root. Its per-session event stream lives at
// <COPILOT_HOME>/session-state/<sessionId>/events.jsonl, and hooks are read from
// <COPILOT_HOME>/hooks/*.json — a directory, so Harness drops in its own file.
COPILOT_HOME: z.string().default(join(homedir(), '.copilot')),
// Cursor state root. Interactive transcripts live below <CURSOR_HOME>/projects and local
// subagent linkage metadata lives below <CURSOR_HOME>/chats.
CURSOR_HOME: z.string().default(join(homedir(), '.cursor')),
// OpenCode state root — the SQLite store lives at <OPENCODE_DATA_DIR>/opencode.db (honors
// XDG_DATA_HOME). Sessions are polled from that DB (no per-session transcript file).
OPENCODE_DATA_DIR: z
.string()
.default(join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode')),
// OpenCode plugin dir the adapter drops its discovery plugin into (honors XDG_CONFIG_HOME).
OPENCODE_PLUGIN_DIR: z
.string()
.default(join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'opencode', 'plugin')),
// Kilo state root — the SQLite store lives at <KILO_DATA_DIR>/kilo.db. Kilo is an opencode fork and
// keeps the same layout, but NOT the same overrides: measured on 7.4.20 via `kilo debug paths`, it
// honours XDG_DATA_HOME and ignores both `KILO_DATA_DIR` and `OPENCODE_DATA_DIR`. So this variable
// steers the ADAPTER's reads only; anything that has to move KILO's own writes (the recap one-shot)
// must set XDG_DATA_HOME on the child instead — see `KiloWorker` in lib/oneshot.ts.
KILO_DATA_DIR: z
.string()
.default(join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'kilo')),
// Kilo plugin dir the adapter drops its discovery plugin into (honors XDG_CONFIG_HOME — measured).
KILO_PLUGIN_DIR: z
.string()
.default(join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'kilo', 'plugin')),
// Pi state root. Session transcripts live under <PI_HOME>/agent/sessions/--<mangled-cwd>--/*.jsonl and
// the adapter's discovery extension is installed into <PI_HOME>/agent/extensions.
PI_HOME: z.string().default(join(homedir(), '.pi')),
// Hermes state root — the SQLite store is <HERMES_HOME>/state.db and the shell hooks the adapter
// installs live in <HERMES_HOME>/config.yaml (+ shell-hooks-allowlist.json).
HERMES_HOME: z.string().default(join(homedir(), '.hermes')),
// Command Code state root — transcripts live under <COMMANDCODE_HOME>/projects/<cwd-slug>/<id>.jsonl
// and the adapter installs its shell hooks into <COMMANDCODE_HOME>/settings.json.
COMMANDCODE_HOME: z.string().default(join(homedir(), '.commandcode')),
// Devin CLI state root — history is the SQLite store <DEVIN_HOME>/sessions.db (WAL, no transcript file
// unless the user passes --export) and <DEVIN_HOME>/session_locks/<id>.lock holds the owning PID.
DEVIN_HOME: z
.string()
.default(join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'devin', 'cli')),
// Muse Code state root. Transcripts are JSONL but the layout is DATE-SHARDED, not hashed by project
// path: <MUSE_HOME>/sessions/YYYY/MM/DD/<session-uuid>/session.jsonl, with sub-agents one level deeper
// under `subagent/<child-uuid>/`. The only link back to a project is `workspace_root`, carried in the
// FIRST record of each file — which is why this engine is discovered by scanning rather than by a hook.
MUSE_HOME: z
.string()
.default(join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'muse')),
MUSE_CONFIG_DIR: z
.string()
.default(join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'muse')),
// Amp plugin dir the adapter drops its discovery plugin into (honors XDG_CONFIG_HOME). Amp calls these
// "system plugins" and loads every `*.ts` there for EVERY thread, which is what makes one install cover
// all projects — the project-local `.amp/plugins/` alternative would need one copy per repo.
AMP_PLUGIN_DIR: z
.string()
.default(join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'amp', 'plugins')),
// Where the Amp plugin writes the transcripts this adapter tails.
//
// Amp is the only supported engine that keeps NO conversation on disk: threads live on the server and
// the sole local artefacts are a metadata-only debug log (no message text at all — measured) and
// `session.json`. `amp threads export` can fetch the content, but it costs a ~1.5s network round trip
// per read and never shows a message before it is complete. So the plugin writes the transcript we tail,
// and this directory — ours, not Amp's — is the trusted root for it.
AMP_SESSIONS_DIR: z.string().default(join(adapterDataDir, 'amp-sessions')),
// Amp's own state dir, read-only for us: `session.json` there maps a tmux pane to the thread started in
// it, which is how a re-attaching daemon re-binds a pane without guessing.
AMP_STATE_DIR: z
.string()
.default(join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'amp')),
// Devin's user-level config, where the adapter merges its hooks under a "hooks" key. Devin reads
// Claude's hook schema verbatim, so the installed block is shaped exactly like ~/.claude/settings.json.
DEVIN_CONFIG_PATH: z
.string()
.default(join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'devin', 'config.json')),
// Where the tmux-session registry and daemon-local state are persisted.
ADAPTER_DATA_DIR: z.string().default(adapterDataDir),
// Logs meant to be READ and sent — the dial's `dial-YYYYMMDD.log` — beside the desktop app's own
// `app-*.log`/`cli-*.log`, so one directory holds everything a bug report needs. Not the data dir:
// `harness.log` there is the daemon's console, and `harness reset` wipes it.
HARNESS_LOGS_DIR: z.string().default(join(adapterRootDir, 'logs')),
// Where domain-specific harnesses are installed (`harness dsh install`): one directory per
// `<owner>/<name>` plus `installed.json`. Product-root state like the SSO session, not daemon data.
DSH_DIR: z.string().default(join(adapterRootDir, 'dsh')),
// The ref the built-in shelf (`store/*` of the Harness monorepo) installs from, instead of the one
// its registry entries name (`main`). For trying a store change end to end BEFORE it merges: push
// the branch, run the daemon with HARNESS_STORE_REF=<branch>, and Get in the store fetches from it.
HARNESS_STORE_REF: z.string().regex(/^[A-Za-z0-9._\/-]{1,200}$/).optional().catch(undefined),
// Optional catalog mirror. Public HTTPS in production; loopback HTTP supports isolated tests.
HARNESS_STORE_CATALOG_URL: z.string().url().refine((value) => {
const url = new URL(value)
return !url.username && !url.password && (url.protocol === 'https:'
|| (url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))
}).optional().catch(undefined),
// This computer's stable id, minted once and never regenerated (see computerIdFile above). Pin it
// explicitly on a box with no durable home — a container or CI job that gets a fresh ~/.harness on
// every boot would otherwise look like a NEW computer each time and collect a machine per start.
// Setting this is a pin, not a regeneration: it is never written to disk, so unsetting it returns
// you to the file's id. Any 16-64 hex (dashes allowed, the backend de-dashes).
ADAPTER_COMPUTER_ID: z.string().optional(),
ADAPTER_COMPUTER_ID_FILE: z.string().default(computerIdFile),
// Set to 'true' to skip auto-installing lifecycle hooks for every supported engine.
DISABLE_HOOK_INSTALL: z.string().default('false').transform((v) => v === 'true'),
// `harness start` and `harness login` install the `grid` CLI when the machine has none (see
// lib/gridInstall.ts). Off for tests and for a machine whose grid is managed some other way.
DISABLE_GRID_INSTALL: z.string().default('false').transform((v) => v === 'true'),
// Additive terminal capability. Order controls deterministic primary-route tie breaking.
//
// UNSET MEANS AUTO — every backend that is actually usable here, which is what makes `herdr` then an
// engine behave like `tmux new` then an engine, with nothing to configure. Set it to pin: `tmux` is
// how you turn Herdr off. Validation is unchanged for a value that IS given.
TERMINAL_BACKENDS: z.string().optional().transform((value, context) => {
if (value === undefined || value === '') return undefined
try { return parseTerminalBackends(value) } catch (error) {
context.addIssue({ code: 'custom', message: error instanceof Error ? error.message : 'invalid terminal backends' })
return z.NEVER
}
}),
// Named Herdr sessions. UNSET means "adopt the sessions Herdr reports as running"; a value is a strict
// allowlist that discovery never widens, and hook-supplied socket paths never expand it either.
HERDR_SESSIONS: z.string().optional().transform((value, context) => {
if (value === undefined || value === '') return undefined
try { return parseHerdrSessions(value) } catch (error) {
context.addIssue({ code: 'custom', message: error instanceof Error ? error.message : 'invalid Herdr sessions' })
return z.NEVER
}
}),
HERDR_BIN: z.string().default('herdr'),
// Neutral discovery interval. The legacy tmux name remains a one-release fallback.
TERMINAL_RECONCILE_INTERVAL_MS: z.string().optional().transform((value) => value === undefined ? undefined : Number(value)),
// How often (ms) the reaper checks tmux panes and drops dead sessions.
TMUX_REAP_INTERVAL_MS: z.string().default('5000').transform(Number),
// Path to the `claude` CLI for the device turn-recap one-shot (else resolved from PATH).
CLAUDE_PATH: z.string().optional(),
// Path to the Cursor Agent CLI for recap one-shots (else a verified `agent`/`cursor-agent` is used).
CURSOR_PATH: z.string().optional(),
// Path to the `opencode` CLI for OpenCode recap one-shots (else `opencode` is resolved from PATH).
OPENCODE_PATH: z.string().optional(),
// Path to the `pi` CLI for Pi recap one-shots (else `pi` is resolved from PATH).
PI_PATH: z.string().optional(),
// Path to the `hermes` CLI for Hermes recap one-shots (else `hermes` is resolved from PATH).
HERMES_PATH: z.string().optional(),
// Path to the `commandcode` CLI for Command Code recap one-shots (else resolved from PATH).
COMMANDCODE_PATH: z.string().optional(),
// Path to the `devin` CLI for Devin recap one-shots (else `devin` is resolved from PATH).
DEVIN_PATH: z.string().optional(),
MUSE_PATH: z.string().optional(),
// Path to the `amp` CLI for Amp recap one-shots (else `amp` is resolved from PATH).
AMP_PATH: z.string().optional(),
// Path to the `kilo` CLI for Kilo recap one-shots (else `kilo` is resolved from PATH). `@kilocode/cli`
// also installs it as `kilocode`; both are the same file.
KILO_PATH: z.string().optional(),
// Path to the xAI Grok CLI for interactive sessions and recap one-shots.
GROK_PATH: z.string().optional(),
// Path to the GitHub Copilot CLI. Two builds answer to `copilot` on a typical machine — a compiled
// binary and the npm loader script — so an override is worth having.
COPILOT_PATH: z.string().optional(),
// Path to the Antigravity CLI. Two binaries answer to `agy` on a typical PATH — the CLI itself and
// the Antigravity IDE launcher — so an override is worth having.
AGY_PATH: z.string().optional(),
// Model for the device turn-recap one-shot.
SUMMARY_MODEL: z.string().default('sonnet'),
// Balanced Codex counterpart used only for recap workers; never inherits the interactive CLI model.
CODEX_SUMMARY_MODEL: z.string().default('gpt-5.5'),
// Cursor's Auto model keeps recap availability aligned with the user's Cursor account.
CURSOR_SUMMARY_MODEL: z.string().default('auto'),
// OpenCode recap model (`provider/model`). Empty → use the user's opencode config default model.
OPENCODE_SUMMARY_MODEL: z.string().default(''),
// Kilo recap model (`provider/model` as `kilo models` prints it). Empty → the user's kilo default.
KILO_SUMMARY_MODEL: z.string().default(''),
// Grok recap model. Empty → use the user's Grok CLI default model.
GROK_SUMMARY_MODEL: z.string().default(''),
// Copilot recap model. Empty -> `auto`, which lets Copilot pick and keeps the user's credit policy.
COPILOT_SUMMARY_MODEL: z.string().default(''),
// Antigravity recap model (a slug from `agy models`). Flash at low effort: a recap is a short
// summarisation of text already in hand, so the cheapest tier in the catalog is the right one.
AGY_SUMMARY_MODEL: z.string().default('gemini-3.7-flash-low'),
// Pi recap model (`provider/model` or a model pattern). Empty → use the user's pi config default.
PI_SUMMARY_MODEL: z.string().default(''),
// Hermes recap model. Empty → use the user's ~/.hermes/config.yaml default (keeps their provider).
HERMES_SUMMARY_MODEL: z.string().default(''),
// Command Code recap model. Empty → use the user's own account default.
COMMANDCODE_SUMMARY_MODEL: z.string().default(''),
// Devin recap model. Empty → use the user's own account default (their plan decides what is available).
DEVIN_SUMMARY_MODEL: z.string().default(''),
MUSE_SUMMARY_MODEL: z.string().default(''),
// Amp recap agent mode. Amp exposes no model list — `-m` picks a MODE (low|medium|high|ultra) and the
// mode picks the model. `medium` on the project owner's call: `low` was the cheaper default but is no
// more reliable on this path (both modes hit Amp's ~30s network timeout at similar rates, measured),
// so the better answer wins over the cheaper one.
AMP_SUMMARY_MODE: z.string().default('medium'),
// Shared recap reasoning level for Claude and Codex. Cursor effort is part of its model identifier.
SUMMARY_EFFORT: z.enum(['low', 'medium', 'high']).default('low'),
// Who writes the device recap's headline. `local` (default since 2026-09-15): no model in the loop —
// the answer's first sentence is excerpted, instantly; the dial shows what the terminal shows, as it
// shows it. `model`: a disposable one-shot of the session's own engine, fed the previous recap, the
// user's ask and the answer — reads better across turns, but measured at ~9s of the user's turn to
// rephrase "Blue selected." as "Blue selected as the colour choice" (owner: the dial is cabled to the
// window already showing the answer in full; the recap is a glance, not prose).
SUMMARY_MODE: z.enum(['model', 'local']).default('local'),
RECAP_WITHOUT_DEVICE: z.string().default('true').transform((v) => v !== 'false'),
// Model for the voice router one-shot classifier (Overview voice → pick the agent). Small/fast by default.
VOICE_ROUTE_MODEL: z.string().default('haiku'),
// Test override: run the recap even with no device connected (mirrors node isRecapForced()).
RECAP_FORCE: z.string().default('false').transform((v) => v === 'true'),
// Log one line per backend frame (type + audience + a few opaque ids). OFF by default: it is noisy,
// and it is the only place frames are visible in the clear — every content-bearing frame is E2EE
// encrypted on the wire, so a packet capture cannot answer "what did the adapter actually send".
// Never prints message text, transcripts, tokens or payload bodies.
LOG_FRAMES: z.string().default('false').transform((v) => v === 'true'),
// TEMPORARY / diagnostic. Forces iceTransportPolicy:'relay', so the ONLY candidate either peer can
// offer is a Cloudflare TURN allocation — the way to exercise the relay path on a pair that would
// otherwise always connect directly. Costs money per GB while it is on; never ship it enabled.
TERMINAL_P2P_FORCE_RELAY: z.string().default('false').transform((v) => v === 'true'),
// ── OpenRouter gateway agents (`ori claude`, `ori codex`, …) ───────────────────────────────────
// An agent whose CLI is pointed at OpenRouter has no vendor credential to spend, so its recap and
// voice route are served by ONE direct chat/completions call instead of a vendor one-shot: a recap
// is a 200-token condensation, and spawning a whole coding agent to do it would bill an account the
// user may not even have. Both default to a cheap, fast model; '' disables that call (the
// VOICE_ROUTE_MODEL convention) and falls back to the engine path.
ORI_SUMMARY_MODEL: z.string().default('deepseek/deepseek-v4-flash'),
ORI_VOICE_ROUTE_MODEL: z.string().default('deepseek/deepseek-v4-flash'),
// Where `ori login` stores the key ({ createdAt, key, userId }). Only read when neither the daemon
// env nor the agent's own process supplies one.
ORI_CREDENTIALS_PATH: z.string().default(join(homedir(), '.ori', 'credentials.json')),
// ── self-update (the daemon polls a GCS manifest and swaps its own bundle) ──────────────────────
// Manifest URL (same GCS bucket + metadata.json shape as the device OTA; key = ADAPTER_UPDATE_KEY).
ADAPTER_UPDATE_URL: z
.string()
.default('https://storage.googleapis.com/s3-autonomous-upgrade-3/harness/cli/metadata.json'),
ADAPTER_UPDATE_KEY: z.string().default('cli'),
// How often the daemon checks for a newer build (ms). The poll is a tiny no-cache metadata fetch;
// the artifact is downloaded only when the manifest version is strictly newer.
ADAPTER_UPDATE_CHECK_MS: z.string().default('60000').transform(Number),
// The wall-clock second each check lands on, when the interval divides a minute. The desktop app
// spawns `harness start` only around :15 (see desktop/lib/ws/local_cli_discovery.dart `inSpawnSlot`);
// keeping the update handoff 30s away from that is what stops the two from fighting over the spawn
// lock. Set to a negative number to keep the plain interval.
ADAPTER_UPDATE_SLOT_SEC: z.string().default('45').transform(Number),
// Set 'true' to disable self-update entirely.
ADAPTER_UPDATE_DISABLE: z.string().default('false').transform((v) => v === 'true'),
// Install dir holding the packaged cli.js + notify.mjs that the self-updater swaps in place.
ADAPTER_CLI_DIR: z.string().default(adapterCliDir),
// Where the managed Node runtime lives. Read (never written) by this process: the hook command
// lines have to name an interpreter by absolute path, because a hook fires in a shell whose PATH
// we do not control — see managedNodePath() in lib/nodeRuntime.ts.
ADAPTER_RUNTIME_DIR: z.string().default(adapterRuntimeDir),
// The runtime manifest — a DIFFERENT document from ADAPTER_UPDATE_URL's: keyed by platform
// (`darwin-arm64`, `linux-x64`, …) with url/sha256/size/archiveRoot per entry. Deliberately the
// same URL `install.sh` and Desktop Harness read, so all three land on identical bytes.
ADAPTER_RUNTIME_METADATA_URL: z
.string()
.default('https://storage.googleapis.com/s3-autonomous-upgrade-3/harness/runtime/metadata.json'),
// The managed grid's manifest — its own document, as tmux's is (harness/runtime/tmux/metadata.json):
// install.sh slices a manifest by the FIRST platform key it finds, and Node's already has one. The
// same entry shape (version/url/sha256/size/archiveRoot), and its version is the PIN: the grid this
// build of the CLI drives, moved on purpose by a release and never by grid's own updater — see
// ensureManagedGrid() in lib/runtimeInstall.ts, which follows it on every daemon start.
ADAPTER_GRID_RUNTIME_METADATA_URL: z
.string()
.default('https://storage.googleapis.com/s3-autonomous-upgrade-3/harness/runtime/grid/metadata.json'),
// Where the `harness` launcher lives. Same name (and default) `scripts/install-cli.sh` uses, so a
// sandboxed install and this process agree on which launcher they are talking about.
HARNESS_BIN_DIR: z.string().default(adapterBinDir),
// ── the dial on the USB cable ──────────────────────────────────────────────────────────────────
// Set 'true' to leave the serial port alone entirely. The port is exclusive, so this is what a
// developer flips before running esptool or a serial monitor against the dial.
CABLE_DISABLE: z.string().default('false').transform((v) => v === 'true'),
// Transcription for voice captured on the dial. The device holds no cloud credential and never talks to
// one; the daemon has an account, and this call is authenticated by the SSO session `harness login`
// leaves behind — no shared secret, because this repository is public. Path only: the host comes from
// BACKEND_WS_URL, so a daemon pointed at a local backend transcribes against that one too.
CABLE_STT_PATH: z.string().default('/api/voice/stt'),
// Firmware for the dial: the SAME published manifest the device used to poll before it lost WiFi, so
// `make upload-circle` remains the only way a release happens — this only changes who downloads.
CABLE_FW_MANIFEST_URL: z
.string()
.default('https://storage.googleapis.com/s3-autonomous-upgrade-3/harness/esp32/ota/metadata.json'),
CABLE_FW_DISABLE: z.string().default('false').transform((v) => v === 'true'),
})
export type Env = z.infer<typeof envSchema>
function validateEnv(): Env {
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors)
process.exit(1)
}
const data = parsed.data
// Pair WEB_URL to the backend's environment. The `join` link is served by the WEB app that talks to
// the SAME backend the adapter dials — so a code created on a LOCAL backend must be confirmed on the
// LOCAL web. If WEB_URL wasn't set explicitly and BACKEND_WS_URL is loopback, default WEB_URL to the
// local web (:3000) instead of prod — otherwise the printed link points at prod, which can't see it.
if (!process.env.WEB_URL && /^wss?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(data.BACKEND_WS_URL)) {
data.WEB_URL = 'http://localhost:3000'
}
const reconcileMs = data.TERMINAL_RECONCILE_INTERVAL_MS ?? data.TMUX_REAP_INTERVAL_MS
if (!Number.isFinite(reconcileMs) || reconcileMs < 5_000) {
console.error('Invalid environment variables: TERMINAL_RECONCILE_INTERVAL_MS must be at least 5000')
process.exit(1)
}
return data
}
export const env = validateEnv()