mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat(pi): switch accounts and preserve selection through the seat lifecycle (#2630)
rig provider switch --account-home and --clear-account-home select or clear an existing Pi seat's account home through a confirmed successor with a fresh conversation and handover; an unconfirmed or failed switch leaves the old account and state intact, and the selection survives restart, resume, restore and daemon restart (migration 151 keeps switch history and creates or copies no credentials). The chosen directory must already hold a readable auth.json, which the runner links into the seat's managed agent directory; env-only accounts and wrappers that create credentials later are outside this path, and an existing seat-local login or unmanaged auth link is preserved through the confirmed switch. Selected accounts load a Node preload that points only their auth-lock operations at the chosen account's auth.json.lock pathname, even when that auth.json is a symlink, and removes its own NODE_OPTIONS entry before Pi starts tools; outside the managed target it does nothing. A Node-hosted launch wrapper that execs Pi can consume the preload, so that Pi gets no shared-auth lock and keeps its native per-seat lock; compiled Pi and wrappers that discard NODE_OPTIONS are likewise not covered. Contributed by seanmcn; requested in #2629.
This commit is contained in:
1 parent
892eb55b5e
commit
b1065870f6
33 files changed
+976
-86
No files matched your search
@@ -564,7 +564,7 @@ or local settings. Managed settings aren't inspected. `rig export` writes the se
|
||||
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
|
||||
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
|
||||
|
||||
**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home
|
||||
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
|
||||
while staying in its rig:
|
||||
|
||||
```sh
|
||||
@@ -576,7 +576,20 @@ rig seat set-runtime dev-impl@my-rig --runtime claude-code \
|
||||
|
||||
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
|
||||
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
|
||||
Claude Code before the successor starts.
|
||||
Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links
|
||||
that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state
|
||||
until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden.
|
||||
|
||||
```sh
|
||||
rig provider switch --seat pi-worker@my-rig --to-account work \
|
||||
--account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run
|
||||
# Run again without --dry-run to stage the successor and request its confirmation.
|
||||
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
|
||||
```
|
||||
|
||||
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
|
||||
and another runtime is not supported by this account-home feature.
|
||||
|
||||
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
|
||||
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
|
||||
@@ -920,7 +933,7 @@ members:
|
||||
|
||||
- **Scope and literal argv:** Supported only on local built-in `runtime: pi` and `runtime: codex` members (and legacy flat `pi`/`codex` nodes); other runtimes (`claude-code`, `omp`, `terminal`, `stub` and community harnesses) reject the field, including `[]`. There is no rig-level, pod-level or fork inheritance: a fork uses its target member's own `launch_wrapper`. Every element must be a string without NUL, CR or LF characters and is quoted literally with `shellQuote`, preserving argument order, spaces, quotes, shell metacharacters and empty non-executable arguments without shell or template expansion.
|
||||
- **Executable resolution and `exec` contract:** `launch_wrapper[0]` must be a non-empty bare command filename on the daemon's launch `PATH` or an absolute path (no relative paths with slashes such as `./x` or `../x`, no `/` in a bare name, and no shell aliases or functions). Preflight and launch resolve `launch_wrapper[0]` to an executable file using the daemon's launch `PATH` and daemon working directory (before the seat's `rig` bin directory is prepended). For `codex`, the prefix appears immediately before the resolved Codex entry (`'<abs node>' '<abs codex.js>' …` or `'<abs codex>' …`) inside the public seat environment envelope, preserving the session's TTY stdio. For `pi`, OpenRig's host runner stays host-side and unwrapped (`node <pi-runner.js> …`), preserving its RPC lifecycle, sidecars, and status reporting; the wrapper prefixes the resolved Pi child executable (`pi --mode rpc`) inside that runner, and the child's stdio pipes preserve RPC stdin, stdout, and stderr (not a TTY). The wrapper must prepare its environment and `exec` the target command in place so the child process preserves working directory, child stdio pipes (for Pi) or TTY stdio (for Codex), OpenRig session/identity environment, process lineage, exit status and signals; supervising wrappers that keep a parent process are unsupported.
|
||||
- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and `pi`↔`codex` runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads.
|
||||
- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and supported runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads.
|
||||
- **Persistence, `[]` clear vs absent retain, and recreation:** On a new seat, omitting `launch_wrapper` or declaring `launch_wrapper: []` stores no wrapper (`NULL`). When an authored spec is applied to an existing seat, omitting `launch_wrapper` retains the seat's stored wrapper, `launch_wrapper: []` explicitly clears it, and a non-empty array replaces it. A rig that is recreated or imported, including a replacement import, creates new nodes from the incoming spec and uses only what that spec declares, so omitting the field gives the new seat no wrapper. Stored wrapper definitions remain strictly validated; corrupted wrapper rows fail closed during reads without silent unwrap. Restoring an older snapshot that predates `launch_wrapper` preserves the seat's current stored wrapper. First-start retry (`rig launch <rig> <member>`) compares the retained declaration with the node row and refuses to add, change or remove a wrapper on retry. Switching a wrapped seat to an unsupported runtime (`claude-code`, `omp`, etc.) is refused before side effects. There is no live `rig seat` edit verb or management route for `launch_wrapper`; to change or clear a wrapper, edit the `RigSpec` (use `launch_wrapper: []` to clear) and use the supported create/recreate/apply flow. Export (`rig export`) and cached node inventory / `rig ps --nodes --json` (including `--full` and `--fields launchWrapper`) include `launch_wrapper` / `launchWrapper` when configured and omit it when absent.
|
||||
- **Boundary and limits:** A `launch_wrapper` is only an argv prefix. Configuring or observing a prefix is **not** proof of sandbox isolation, tooling availability, memory wiring or governance enforcement; verify the wrapper's own guarantees and the runtime independently.
|
||||
|
||||
@@ -928,10 +941,14 @@ members:
|
||||
|
||||
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
|
||||
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches/<generation>/<session>/` directory; subsequent launches and resumes use that committed directory.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
|
||||
|
||||
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
|
||||
Generated
+37
@@ -4260,6 +4260,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphlib": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||
@@ -5230,6 +5237,18 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
@@ -5531,6 +5550,16 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -5839,6 +5868,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
|
||||
@@ -7317,6 +7353,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -603,6 +603,9 @@ API-key approval before the first turn. OpenRig does not enforce API budgets or
|
||||
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
|
||||
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
|
||||
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
|
||||
Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor
|
||||
with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without
|
||||
a launch_wrapper; changing a seat between Pi and another runtime is not supported.
|
||||
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
|
||||
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
|
||||
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
|
||||
@@ -638,7 +641,7 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
|
||||
try {
|
||||
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
|
||||
const runtime = status.data.runtime;
|
||||
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
|
||||
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code" && runtime !== "pi")) {
|
||||
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
|
||||
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
|
||||
process.exitCode = 1; return;
|
||||
|
||||
@@ -90,6 +90,15 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
|
||||
expect(seen.length).toBe(before);
|
||||
});
|
||||
|
||||
it.each([false, true])("preflights and submits a Pi account change (clear=%s)", async clear => {
|
||||
const before = seen.length;
|
||||
const result = await run(["switch", "--seat", "pi-seat@rig", ...(clear ? ["--clear-account-home"] : ["--to-account", "work", "--account-home", "/accounts/pi"]), "--reason", "Pi budget", "--json"], runningDeps(port));
|
||||
expect(result.exitCode ?? 0).toBe(0);
|
||||
const calls = seen.slice(before);
|
||||
expect(JSON.parse(calls.find(c => c.url.startsWith("/api/seat/set-runtime/"))!.body)).toMatchObject({ runtime: "pi", dryRun: true });
|
||||
expect(JSON.parse(calls.find(c => c.url === "/api/provider/switch")!.body)).toMatchObject(clear ? { clearAccountHome: true } : { accountHome: "/accounts/pi" });
|
||||
});
|
||||
|
||||
it("switches to a selected account home without sending credential values", async () => {
|
||||
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api",
|
||||
"--reason", "Plan limit", "--model", "gpt-5.5", "--json"], runningDeps(port));
|
||||
@@ -195,7 +204,7 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
|
||||
seen.push({ url: req.url ?? "", method: req.method ?? "", body });
|
||||
const send = (code: number, obj: unknown) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
|
||||
const path = (req.url ?? "").split("?")[0];
|
||||
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: "codex" });
|
||||
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: path.includes("pi-seat") ? "pi" : "codex" });
|
||||
if (path?.startsWith("/api/seat/set-runtime/")) {
|
||||
const request = JSON.parse(body);
|
||||
if (path.includes("old-daemon")) return send(200, { ok: true, dryRun: true });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pi uses proper-lockfile with realpath:false. Redirect only the managed auth
|
||||
// link's lock operations, so seat aliases and the account owner use one lock.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { syncBuiltinESMExports } = require('node:module');
|
||||
// The runner injects this exact option. Consume only our preload before Pi can
|
||||
// launch tools, preserving any other Node options supplied by its wrapper.
|
||||
const preload = `--require=${JSON.stringify(__filename)}`;
|
||||
if (process.env.NODE_OPTIONS) {
|
||||
const remaining = process.env.NODE_OPTIONS.replace(preload, '').trim();
|
||||
if (remaining) process.env.NODE_OPTIONS = remaining;
|
||||
else delete process.env.NODE_OPTIONS;
|
||||
}
|
||||
let localLock, sharedLock;
|
||||
try {
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
if (!agentDir) return;
|
||||
const auth = path.join(agentDir, 'auth.json');
|
||||
const account = fs.readFileSync(path.join(agentDir, '.openrig-account'), 'utf8');
|
||||
const source = path.resolve(path.dirname(auth), fs.readlinkSync(auth));
|
||||
if (source !== path.resolve(account, 'auth.json')) return;
|
||||
localLock = path.resolve(auth + '.lock');
|
||||
// Match direct Pi's realpath:false pathname, even when the source is a link.
|
||||
sharedLock = source + '.lock';
|
||||
} catch {
|
||||
// Preparation validates selected credentials. Unrelated processes are untouched.
|
||||
return;
|
||||
}
|
||||
/** Map only the managed auth lock to the shared target; leave all other filesystem paths unchanged. */
|
||||
function lockPath(value) {
|
||||
return typeof value === 'string' && path.resolve(value) === localLock ? sharedLock : value;
|
||||
}
|
||||
for (const name of ['mkdir', 'rmdir', 'stat', 'utimes']) {
|
||||
for (const method of [name, name + 'Sync']) {
|
||||
const original = fs[method];
|
||||
fs[method] = function (file, ...args) { return original.call(this, lockPath(file), ...args); };
|
||||
}
|
||||
}
|
||||
syncBuiltinESMExports();
|
||||
@@ -115,6 +115,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface PiResumeFsOps {
|
||||
}
|
||||
|
||||
interface PiResumeOptions {
|
||||
/** Resolve the persisted/configured account without relying on live tmux metadata. */
|
||||
accountHomeForSession?: (session: string) => string | undefined;
|
||||
stateRootForSession?: (session: string) => string;
|
||||
seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
pollMs?: number;
|
||||
maxWaitMs?: number;
|
||||
@@ -93,7 +96,7 @@ export class PiResumeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const seat = piSeatPaths(this.paths.stateRoot, tmuxSessionName);
|
||||
const seat = piSeatPaths(this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, tmuxSessionName);
|
||||
this.fs.mkdirp(seat.agentDir);
|
||||
this.fs.mkdirp(seat.sessionsDir);
|
||||
|
||||
@@ -114,11 +117,23 @@ export class PiResumeAdapter {
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
let nodeId: string | undefined;
|
||||
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
|
||||
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
|
||||
catch { /* Account-free resume retains best-effort identity correction. */ }
|
||||
}
|
||||
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
|
||||
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
|
||||
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
|
||||
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
|
||||
}
|
||||
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
stateRoot: this.paths.stateRoot,
|
||||
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot,
|
||||
cwd,
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
@@ -134,14 +149,14 @@ export class PiResumeAdapter {
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
const command = this.runtime === "pi" && this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime })
|
||||
: cmd;
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome })
|
||||
: fallback;
|
||||
// Short commands retain the direct path; long commands exec from a private script.
|
||||
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
// This refusal precedes any input: retain the bare-command fallback when routing cannot fit.
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== cmd) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, cmd, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, code: "resume_failed", message: textResult.message };
|
||||
@@ -162,7 +177,7 @@ export class PiResumeAdapter {
|
||||
const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1);
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const { runnerStatePath } = piSeatPaths(this.paths.stateRoot, tmuxSessionName);
|
||||
const { runnerStatePath } = piSeatPaths(this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, tmuxSessionName);
|
||||
const state = this.fs.exists(runnerStatePath) ? parsePiRunnerState(this.readSafe(runnerStatePath)) : null;
|
||||
|
||||
if (state?.launchId === launchId) {
|
||||
|
||||
@@ -242,6 +242,8 @@ export function buildPiChildEnv(
|
||||
// ── Command construction ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PiRunnerLaunchOpts {
|
||||
/** Explicit prepared account, never inferred from the shell environment. */
|
||||
accountHome?: string;
|
||||
/** Explicit runtime; omitted keeps the original Pi command byte-for-byte. */
|
||||
runtime?: RunnerRuntime;
|
||||
/** Absolute path to the compiled runner entry (daemon dist). */
|
||||
@@ -313,6 +315,7 @@ export function buildPiRunnerCommand(opts: PiRunnerLaunchOpts): string {
|
||||
parts.push("--usage-facts");
|
||||
if (opts.openRouterCharge) parts.push("--openrouter-charge");
|
||||
}
|
||||
if (opts.accountHome && opts.runtime !== "omp") parts.push("--account-home", shellQuote(opts.accountHome));
|
||||
if (opts.launchWrapper?.length && opts.runtime !== "omp") {
|
||||
for (const arg of opts.launchWrapper) {
|
||||
parts.push("--launch-wrapper-arg", shellQuote(arg));
|
||||
|
||||
@@ -38,6 +38,34 @@ import {
|
||||
type PiRunnerState, type RunnerRuntime,
|
||||
} from "./pi-runner-protocol.js";
|
||||
|
||||
/** Bind the selected account's credentials while retaining the seat's own Pi resources. */
|
||||
export function preparePiAccount(agentDir: string, accountHome: string | undefined): void {
|
||||
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
||||
const marker = nodePath.join(agentDir, ".openrig-account");
|
||||
const auth = nodePath.join(agentDir, "auth.json");
|
||||
// Validate every explicit home, including the seat's own directory, before changing any link.
|
||||
if (accountHome) {
|
||||
const source = nodePath.join(accountHome, "auth.json");
|
||||
fs.accessSync(source, fs.constants.R_OK);
|
||||
if (!fs.statSync(source).isFile()) throw new Error("Selected Pi auth must be a file");
|
||||
}
|
||||
if (accountHome && fs.realpathSync(accountHome) !== fs.realpathSync(agentDir)) {
|
||||
const source = nodePath.join(accountHome, "auth.json");
|
||||
const current = fs.lstatSync(auth, { throwIfNoEntry: false });
|
||||
if (current && (!current.isSymbolicLink() || !fs.existsSync(marker))) {
|
||||
throw new Error("Existing Pi login is not a managed account link; use a confirmed account switch");
|
||||
}
|
||||
fs.rmSync(auth, { force: true });
|
||||
// Record ownership before linking so a crash between these writes is retryable.
|
||||
fs.writeFileSync(marker, accountHome, { mode: 0o600 });
|
||||
fs.symlinkSync(source, auth);
|
||||
} else if (!accountHome && fs.existsSync(marker)) {
|
||||
// A Pi login may have replaced the link since it was installed.
|
||||
if (fs.lstatSync(auth, { throwIfNoEntry: false })?.isSymbolicLink()) fs.rmSync(auth);
|
||||
fs.rmSync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submitted input boundaries ─────────────────────────────────────────────
|
||||
// Canonical TTY buffers can overflow before Node sees even the paste terminator.
|
||||
// Use Node's line editor in raw mode, with only paste framing handled here.
|
||||
@@ -974,6 +1002,7 @@ export class RunnerCore {
|
||||
// ── CLI entry ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RunnerArgs {
|
||||
accountHome?: string;
|
||||
runtime?: RunnerRuntime;
|
||||
sessionName: string;
|
||||
stateRoot: string;
|
||||
@@ -1011,6 +1040,7 @@ export function parseRunnerArgs(argv: string[]): RunnerArgs {
|
||||
args.runtime = runtime;
|
||||
break;
|
||||
}
|
||||
case "--account-home": args.accountHome = next(); break;
|
||||
case "--state-root": args.stateRoot = next(); break;
|
||||
case "--cwd": args.cwd = next(); break;
|
||||
case "--launch-id": args.launchId = next(); break;
|
||||
@@ -1048,6 +1078,9 @@ export function parseRunnerArgs(argv: string[]): RunnerArgs {
|
||||
throw new Error("an explicit trust flag is required: --approve or --no-approve");
|
||||
}
|
||||
if (args.sessionFile && args.forkRef) throw new Error("--session and --fork are mutually exclusive");
|
||||
if (args.accountHome !== undefined && ((args.runtime ?? "pi") !== "pi" || !nodePath.isAbsolute(args.accountHome) || /[\0\r\n]/.test(args.accountHome))) {
|
||||
throw new Error("--account-home requires an absolute Pi account directory");
|
||||
}
|
||||
if (args.launchWrapper) {
|
||||
if (args.runtime === "omp") {
|
||||
throw new Error("--launch-wrapper-arg is only supported for the Pi runtime");
|
||||
@@ -1229,6 +1262,14 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const paths = piSeatPaths(args.stateRoot, args.sessionName);
|
||||
fs.mkdirSync(paths.agentDir, { recursive: true });
|
||||
fs.mkdirSync(paths.sessionsDir, { recursive: true });
|
||||
if (runtime === "pi") {
|
||||
try { preparePiAccount(paths.agentDir, args.accountHome); }
|
||||
catch {
|
||||
console.error(`${PI_RUNNER_ERROR_MARKER} Pi account preparation failed for ${JSON.stringify(nodePath.join(args.accountHome ?? paths.agentDir, "auth.json"))}; provide a readable prepared account file and keep any existing seat login intact, then retry the account operation.`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { catchUpSince } = prepareRunnerSidecar(
|
||||
{
|
||||
@@ -1253,6 +1294,10 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
openrigHome: process.env.OPENRIG_HOME,
|
||||
openrigUrl: endpoint?.baseUrl ?? process.env.OPENRIG_URL,
|
||||
});
|
||||
if (runtime === "pi" && args.accountHome && fs.lstatSync(nodePath.join(paths.agentDir, "auth.json"), { throwIfNoEntry: false })?.isSymbolicLink()) {
|
||||
// Only selected accounts load the Node compatibility shim; unrelated launches are unchanged.
|
||||
childEnv.NODE_OPTIONS = `--require=${JSON.stringify(nodePath.resolve(import.meta.dirname, "../../assets/pi-account-lock.cjs"))}`;
|
||||
}
|
||||
const childArgs = buildPiChildArgs({
|
||||
sessionsDir: paths.sessionsDir,
|
||||
sessionName: args.sessionName,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { piAccountSwitchStateRoot } from "../domain/pi-account-state.js";
|
||||
// OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane).
|
||||
//
|
||||
// The adapter launches the OpenRig-owned pi-runner inside the seat's normal
|
||||
@@ -53,6 +54,7 @@ export interface PiRuntimeAdapterDeps {
|
||||
/** Root under which every Pi seat gets its isolated state dir (FR-7).
|
||||
* Typically <OPENRIG_HOME>/state/pi. */
|
||||
stateRoot: string;
|
||||
stateRootForSession?: (session: string) => string;
|
||||
/** Absolute path to the compiled pi-runner entry in the daemon dist. */
|
||||
runnerEntryPath: string;
|
||||
/** Trust posture for managed launches (BR-5 — always explicit; ambient
|
||||
@@ -78,6 +80,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
private seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
private fs: PiAdapterFsOps;
|
||||
protected stateRoot: string;
|
||||
private stateRootForSession: (session: string) => string;
|
||||
private runnerEntryPath: string;
|
||||
private trustPosture: "approve" | "no-approve";
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
@@ -94,18 +97,33 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
|
||||
this.fs = deps.fsOps;
|
||||
this.stateRoot = deps.stateRoot;
|
||||
this.stateRootForSession = deps.stateRootForSession ?? (() => this.stateRoot);
|
||||
this.runnerEntryPath = deps.runnerEntryPath;
|
||||
this.trustPosture = deps.trustPosture ?? "no-approve";
|
||||
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
this.newLaunchId = deps.newLaunchId ?? (() => randomUUID());
|
||||
}
|
||||
|
||||
/** Resolve canonical identity and isolated state for a staged or committed occupant. */
|
||||
private runnerLocation(binding: NodeBinding): { sessionName: string; stateRoot: string } {
|
||||
const staged = this.runtime === "pi" && binding.runtimeSwitchFrom;
|
||||
const sessionName = staged ? staged.session : binding.tmuxSession!;
|
||||
return { sessionName, stateRoot: staged && binding.launchGeneration
|
||||
? piAccountSwitchStateRoot(this.stateRoot, binding.launchGeneration) : this.stateRootForSession(sessionName) };
|
||||
}
|
||||
|
||||
/** Resolve files in the occupant’s state directory rather than its temporary staging pane. */
|
||||
private bindingPaths(binding: NodeBinding) {
|
||||
const { sessionName, stateRoot } = this.runnerLocation(binding);
|
||||
return piSeatPaths(stateRoot, sessionName);
|
||||
}
|
||||
|
||||
/** The pi-runner sidecar reader shape resume-token-capture consumes
|
||||
* (deriveResumeToken's piRunnerStateStore dep). */
|
||||
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string } {
|
||||
const state = this.readRunnerState(sessionName);
|
||||
if (state === null) {
|
||||
const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName);
|
||||
const { runnerStatePath } = piSeatPaths(this.stateRootForSession(sessionName), sessionName);
|
||||
return this.fs.exists(runnerStatePath)
|
||||
? { ok: false, reason: "parse_error" }
|
||||
: { ok: false, reason: "missing_sidecar" };
|
||||
@@ -123,9 +141,9 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
readRunnerSidecar(sessionName: string): { state: PiRunnerState | null; sessionsDir: string; entryPath: string; stateRoot: string } {
|
||||
return {
|
||||
state: this.readRunnerState(sessionName),
|
||||
sessionsDir: piSeatPaths(this.stateRoot, sessionName).sessionsDir,
|
||||
sessionsDir: piSeatPaths(this.stateRootForSession(sessionName), sessionName).sessionsDir,
|
||||
entryPath: this.runnerEntryPath,
|
||||
stateRoot: this.stateRoot,
|
||||
stateRoot: this.stateRootForSession(sessionName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +151,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
const results: InstalledResource[] = [];
|
||||
const sessionName = binding.tmuxSession;
|
||||
if (!sessionName) return results;
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, sessionName);
|
||||
const { agentDir } = this.bindingPaths(binding);
|
||||
const skillsDir = nodePath.join(agentDir, "skills");
|
||||
if (this.fs.exists(skillsDir) && this.fs.listFiles) {
|
||||
for (const file of this.fs.listFiles(skillsDir)) {
|
||||
@@ -146,7 +164,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
/** Conflict detection must inspect this seat's copy, not a Claude sibling. */
|
||||
skillTargetPath(tmuxSession: string | null, effectiveId: string): string | null {
|
||||
if (!tmuxSession) return null;
|
||||
return nodePath.join(piSeatPaths(this.stateRoot, tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
|
||||
return nodePath.join(piSeatPaths(this.stateRootForSession(tmuxSession), tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
|
||||
}
|
||||
|
||||
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
|
||||
@@ -201,7 +219,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
case "skill_install": {
|
||||
if (!binding.tmuxSession) throw new Error("No tmux session bound — cannot resolve the Pi seat state dir");
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
|
||||
const { agentDir } = this.bindingPaths(binding);
|
||||
const targetDir = nodePath.join(agentDir, "skills", nodePath.basename(nodePath.dirname(file.absolutePath)));
|
||||
this.fs.mkdirp(targetDir);
|
||||
this.fs.writeFile(nodePath.join(targetDir, nodePath.basename(file.path)), content);
|
||||
@@ -282,7 +300,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
const sessionName = binding.tmuxSession;
|
||||
const paths = piSeatPaths(this.stateRoot, sessionName);
|
||||
const location = this.runnerLocation(binding);
|
||||
const paths = this.bindingPaths(binding);
|
||||
this.fs.mkdirp(paths.agentDir);
|
||||
this.fs.mkdirp(paths.sessionsDir);
|
||||
|
||||
@@ -292,7 +311,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
// The prior record is read FIRST so the durable catch-up cursor
|
||||
// (lastEntryId, FR-5) survives the reset.
|
||||
const launchId = this.newLaunchId();
|
||||
const prior = this.readRunnerState(sessionName);
|
||||
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
|
||||
this.fs.writeFile(
|
||||
paths.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
@@ -304,11 +323,16 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
const accountHome = binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
|
||||
const preparedHome = accountHome === null && binding.nodeId
|
||||
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome: preparedHome ?? undefined,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName,
|
||||
stateRoot: this.stateRoot,
|
||||
sessionName: location.sessionName,
|
||||
stateRoot: location.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
|
||||
@@ -324,14 +348,14 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
const command = this.runtime === "pi" && this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime })
|
||||
: cmd;
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, accountHome })
|
||||
: fallback;
|
||||
// Stage long commands without leaving a shell above the live runner.
|
||||
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
// This refusal precedes any input: retain the bare-command fallback when routing cannot fit.
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== cmd) {
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, cmd, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
@@ -339,7 +363,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
// The runner writes the sidecar after its first successful get_state;
|
||||
// that sidecar (not pane content) is the token source of truth (FR-5).
|
||||
const state = await this.waitForRunnerReady(sessionName, launchId);
|
||||
const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot);
|
||||
if (!state.ok) return state.failure;
|
||||
|
||||
const sessionFile = state.value.sessionFile;
|
||||
@@ -383,7 +407,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
// make a stopped seat ready).
|
||||
const paneCommand = (await this.tmux.getPaneCommand(binding.tmuxSession)) ?? "";
|
||||
const atShell = SHELL_COMMANDS.has(paneCommand);
|
||||
const state = this.readRunnerState(binding.tmuxSession);
|
||||
const state = this.readRunnerState(this.runnerLocation(binding).sessionName, this.runnerLocation(binding).stateRoot);
|
||||
if (state?.exited) {
|
||||
return { ready: false, reason: `${this.runtime}-runner exited (code ${state.exited.code ?? "unknown"})`, code: "runner_exited" };
|
||||
}
|
||||
@@ -414,8 +438,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
private readRunnerState(sessionName: string): PiRunnerState | null {
|
||||
const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName);
|
||||
private readRunnerState(sessionName: string, stateRoot = this.stateRootForSession(sessionName)): PiRunnerState | null {
|
||||
const { runnerStatePath } = piSeatPaths(stateRoot, sessionName);
|
||||
if (!this.fs.exists(runnerStatePath)) return null;
|
||||
try {
|
||||
return parsePiRunnerState(this.fs.readFile(runnerStatePath));
|
||||
@@ -427,11 +451,12 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
private async waitForRunnerReady(
|
||||
sessionName: string,
|
||||
launchId: string,
|
||||
stateRoot = this.stateRootForSession(sessionName),
|
||||
): Promise<{ ok: true; value: PiRunnerState } | { ok: false; failure: HarnessLaunchResult }> {
|
||||
const pollMs = 250;
|
||||
const attempts = 60; // ~15s: runner boot + pi spawn + first get_state
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const state = this.readRunnerState(sessionName);
|
||||
const state = this.readRunnerState(sessionName, stateRoot);
|
||||
// Launch-attempt scoping: only THIS attempt's sidecar states count.
|
||||
// Stale ready/exited records from prior instances are ignored, and
|
||||
// stale pane markers in scrollback are never consulted here — the
|
||||
@@ -470,7 +495,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
* (a person's own Pi home, a shared file) would read this rig's guidance. */
|
||||
guidanceFileFor(binding: NodeBinding): { file: string | null; note?: string } {
|
||||
if (!binding.tmuxSession) return { file: null };
|
||||
const file = nodePath.join(piSeatPaths(this.stateRoot, binding.tmuxSession).agentDir, "AGENTS.md");
|
||||
const file = nodePath.join(this.bindingPaths(binding).agentDir, "AGENTS.md");
|
||||
const linked = guidanceFileSharedByLink(file, (path) => this.fs.linkInfo?.(path) ?? null);
|
||||
return linked ? { file: null, note: `${this.runtime}: ${linked} is a link, so this seat's managed guidance stays in its repository AGENTS.md` } : { file };
|
||||
}
|
||||
@@ -496,7 +521,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
if (entry.category === "skill") {
|
||||
if (!binding.tmuxSession) return false;
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
|
||||
const { agentDir } = this.bindingPaths(binding);
|
||||
const targetDir = nodePath.join(agentDir, "skills", entry.effectiveId);
|
||||
this.fs.mkdirp(targetDir);
|
||||
let isDir = false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { piAccountSwitchesSchema } from "./migrations/151_pi_account_switches.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -303,4 +304,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
outboxDispatchedIndexSchema,
|
||||
streamIntakeCursorSchema,
|
||||
nodeLaunchWrapperSchema,
|
||||
piAccountSwitchesSchema,
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Widen runtime switches to Pi while retaining history and interrupted cutovers. */
|
||||
export const piAccountSwitchesSchema: Migration = {
|
||||
name: "151_pi_account_switches.sql",
|
||||
sql: `
|
||||
CREATE TABLE seat_runtime_switches_pi (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
canonical_session TEXT NOT NULL,
|
||||
staging_session TEXT NOT NULL UNIQUE,
|
||||
predecessor_pane TEXT NOT NULL,
|
||||
successor_pane TEXT,
|
||||
predecessor_generation TEXT NOT NULL,
|
||||
successor_generation TEXT NOT NULL UNIQUE,
|
||||
from_runtime TEXT NOT NULL CHECK (from_runtime IN ('codex', 'claude-code', 'pi')),
|
||||
to_runtime TEXT NOT NULL CHECK (to_runtime IN ('codex', 'claude-code', 'pi')),
|
||||
phase TEXT NOT NULL DEFAULT 'preparing'
|
||||
CHECK (phase IN ('preparing', 'confirmed', 'committed', 'finished', 'abandoned')),
|
||||
reason TEXT NOT NULL,
|
||||
requested_by TEXT,
|
||||
plan_json TEXT NOT NULL,
|
||||
confirmation_note TEXT,
|
||||
confirmed_at TEXT,
|
||||
successor_resume_type TEXT,
|
||||
successor_resume_token TEXT,
|
||||
committed_at TEXT,
|
||||
-- When the switch should have finished (its successor's start plus the confirmation timeout); a waiting send says so.
|
||||
deadline_at TEXT NOT NULL,
|
||||
outcome TEXT,
|
||||
attention TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
CHECK ((phase IN ('committed', 'finished')) = (committed_at IS NOT NULL))
|
||||
);
|
||||
|
||||
INSERT INTO seat_runtime_switches_pi SELECT * FROM seat_runtime_switches;
|
||||
DROP TABLE seat_runtime_switches;
|
||||
ALTER TABLE seat_runtime_switches_pi RENAME TO seat_runtime_switches;
|
||||
CREATE UNIQUE INDEX idx_seat_runtime_switches_open ON seat_runtime_switches(node_id)
|
||||
WHERE phase IN ('preparing', 'confirmed', 'committed');
|
||||
CREATE INDEX idx_seat_runtime_switches_predecessor ON seat_runtime_switches(node_id, predecessor_generation)
|
||||
WHERE committed_at IS NOT NULL;
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
/** A staged Pi occupant must not change the current occupant's credentials or sidecar. */
|
||||
export function piAccountSwitchStateRoot(root: string, generation: string): string {
|
||||
return path.join(root, "account-switches", generation);
|
||||
}
|
||||
|
||||
/** The committed switch ledger keeps the chosen state directory stable across daemon restarts. */
|
||||
export function piAccountStateRoot(db: Database.Database, root: string, session: string): string {
|
||||
const row = db.prepare(`SELECT s.successor_generation FROM seat_runtime_switches s
|
||||
JOIN bindings b ON b.node_id = s.node_id JOIN nodes n ON n.id = s.node_id
|
||||
WHERE b.tmux_session = ? AND n.runtime = 'pi' AND s.to_runtime = 'pi' AND s.committed_at IS NOT NULL
|
||||
ORDER BY s.rowid DESC LIMIT 1`).get(session) as { successor_generation: string } | undefined;
|
||||
return row ? piAccountSwitchStateRoot(root, row.successor_generation) : root;
|
||||
}
|
||||
@@ -469,7 +469,7 @@ export class RigRepository {
|
||||
.get(nodeId) as { rig_id: string; env_json: string | null } | undefined;
|
||||
if (!row) return {};
|
||||
const env = withoutIgnoredRuntimeHomes(mergeSeatEnv(this.getRigEnv(row.rig_id), parseStoredSeatEnv(row.env_json)));
|
||||
for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code"] as const) {
|
||||
for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code", "pi"] as const) {
|
||||
const accountHome = seatAccountHome(this.db, nodeId, runtime);
|
||||
if (accountHome) env[RUNTIME_HOME_VAR[runtime]] = accountHome;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { seatSkillsPointerStartupFile } from "./seat-skills-pointer.js";
|
||||
import { readSeatEffort } from "./seat-effort.js";
|
||||
import type { ClaudeManagedBlockFile } from "./managed-blocks.js";
|
||||
|
||||
export type SwitchRuntime = "codex" | "claude-code";
|
||||
export type SwitchRuntime = "codex" | "claude-code" | "pi";
|
||||
|
||||
/** Where an effective target setting came from. `native_default` means OpenRig passes no flag. */
|
||||
export type RuntimeSwitchSettingSource = "explicit" | "agent_spec" | "native_default";
|
||||
@@ -133,7 +133,7 @@ export function planRuntimeSwitchMaterials(input: RuntimeSwitchMaterialsInput):
|
||||
|
||||
// Saved startup files are runtime-neutral except the seat-skills pointer and a Codex seat's catalog; those are rebuilt
|
||||
// for the target. Managed guidance the target projects isn't also delivered as a file (the launch's own rule).
|
||||
const sourceSeat = node.runtime === "codex" || node.runtime === "claude-code"
|
||||
const sourceSeat = node.runtime === "codex" || node.runtime === "claude-code" || node.runtime === "pi"
|
||||
? seatSkillPaths({ rigId: node.rigId, logicalId: node.logicalId, runtime: node.runtime }, input.openrigHome) : undefined;
|
||||
const pointer = seatSkillsPointerStartupFile();
|
||||
const projectedGuidance = new Set(plan.entries
|
||||
@@ -186,7 +186,7 @@ export function planRuntimeSwitchMaterials(input: RuntimeSwitchMaterialsInput):
|
||||
async prepare(binding, adapter) {
|
||||
const notes: string[] = [];
|
||||
if (runtime === "claude-code") notes.push(...ensureSeatSkillPlugin(seatSkills));
|
||||
if (skillLoadout) {
|
||||
if (skillLoadout && runtime !== "pi") {
|
||||
const reconcile = (selected: SkillLoadout) => reconcileSkillLoadout({ loadout: selected, runtime, cwd, apply: true,
|
||||
topologyOwner: input.canonicalSession, location: seatSkillLocation(seatSkills, runtime) });
|
||||
let projection = reconcile(skillLoadout);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js";
|
||||
import { ACCOUNT_HOME_PROVIDER_ENV, PI_ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js";
|
||||
import { isLaunchWrapperRuntime, validateLaunchWrapper } from "./launch-wrapper.js";
|
||||
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, statSync, symlinkSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -100,6 +100,11 @@ export class SeatLaunchEnvironment {
|
||||
return resolveLaunchWrapperArgv(wrapper, this.sessionEnv.PATH ?? "", this.daemonCwd);
|
||||
}
|
||||
|
||||
/** The persisted account selection, also used to decide whether a bare launch fallback is safe. */
|
||||
accountHome(nodeId: string, runtime: string, ignoreAccountHome = false): string | undefined {
|
||||
return this.selectedAccountHome?.(nodeId, runtime, ignoreAccountHome);
|
||||
}
|
||||
|
||||
private rigBin(): string {
|
||||
const cli = realpathSync(this.cliPath ?? pairedCli());
|
||||
accessSync(cli, constants.X_OK);
|
||||
@@ -126,7 +131,7 @@ export class SeatLaunchEnvironment {
|
||||
/** A Claude seat's own token step (claude-seat-token.ts), run before Claude in the staged subshell. A POSIX pane only:
|
||||
* a caller whose command comes back without it knows the token wasn't applied. */
|
||||
claudeTokenStep?: string } = {}): Promise<string> {
|
||||
const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime) : undefined);
|
||||
const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.accountHome(target.nodeId, target.runtime) : undefined);
|
||||
const clearing = target.accountHome === null;
|
||||
const wrapped = target.launchWrapper?.length ? target.launchWrapper : undefined;
|
||||
const configuredHome = clearing && target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime, true) : undefined;
|
||||
@@ -134,7 +139,9 @@ export class SeatLaunchEnvironment {
|
||||
const clearStep = clearing && homeVar ? `unset ${homeVar}; ${configuredHome !== undefined ? `export ${homeVar}=${shellQuote(configuredHome)}; ` : ""}` : "";
|
||||
const codexHome = (target.runtime === "codex" ? accountHome : undefined) ?? target.codexHome ?? this.codexHome;
|
||||
const searchPath = this.sessionEnv.PATH;
|
||||
const accountStep = clearStep + (accountHome ? `unset ${ACCOUNT_HOME_PROVIDER_ENV.join(" ")}; ` : "");
|
||||
const selectedPiHome = target.runtime === "pi" ? accountHome ?? configuredHome : undefined;
|
||||
const scrub = target.runtime === "pi" ? [...ACCOUNT_HOME_PROVIDER_ENV, ...PI_ACCOUNT_HOME_PROVIDER_ENV] : ACCOUNT_HOME_PROVIDER_ENV;
|
||||
const accountStep = clearStep + (accountHome || selectedPiHome ? `unset ${scrub.join(" ")}; ` : "");
|
||||
const codexEnv = target.codexCwd !== undefined
|
||||
? [searchPath ? `PATH=${shellQuote(searchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean)
|
||||
: [];
|
||||
@@ -178,6 +185,9 @@ export class SeatLaunchEnvironment {
|
||||
const env = publicSeatEnvironment({ OPENRIG_TRANSCRIPTS_LINES: "", OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS: "", ...this.sessionEnv, ...identity });
|
||||
if (target.codexCwd !== undefined && codexHome) env.CODEX_HOME = codexHome;
|
||||
if (accountHome && target.runtime === "claude-code") env.CLAUDE_CONFIG_DIR = accountHome;
|
||||
if (target.runtime === "pi") {
|
||||
if (selectedPiHome) env.PI_CODING_AGENT_DIR = selectedPiHome;
|
||||
}
|
||||
// Classic Claude may be an rc alias or function, not a PATH executable.
|
||||
// Its caller sources the staged command in a pane-shell subshell. Leading
|
||||
// assignments preserve shell lookup; /usr/bin/env would bypass it.
|
||||
|
||||
@@ -5,13 +5,16 @@ import type Database from "better-sqlite3";
|
||||
import { parseStoredSeatEnv, type SeatEnv } from "./seat-env.js";
|
||||
|
||||
/** The native variable that selects each runtime's home, and with it the account that home is logged in to. */
|
||||
export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" } as const;
|
||||
export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME", pi: "PI_CODING_AGENT_DIR" } as const;
|
||||
export type HomeRuntime = keyof typeof RUNTIME_HOME_VAR;
|
||||
|
||||
/** Provider environment excluded by an explicit isolated account-home selection. */
|
||||
export const ACCOUNT_HOME_PROVIDER_ENV = [...OUTRANKING_TOKEN_ENV, ...ANTHROPIC_PROFILE_ENV, "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_FEDERATION_RULE_ID", "OPENAI_IDENTITY_TOKEN_FILE", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_ORGANIZATION", "OPENAI_PROJECT", "OPENAI_PROJECT_ID", "AWS_BEARER_TOKEN_BEDROCK"] as const;
|
||||
|
||||
/** Additional provider keys excluded only when selecting a Pi account. */
|
||||
export const PI_ACCOUNT_HOME_PROVIDER_ENV = ["OPENROUTER_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY", "MISTRAL_API_KEY", "GEMINI_API_KEY"] as const;
|
||||
|
||||
export interface SeatRuntimeHome {
|
||||
/** The seat's own home: an absolute path its member, else its rig, declares in `env:`. */
|
||||
home?: string;
|
||||
@@ -59,10 +62,10 @@ export function seatRuntimeHome(db: Database.Database, nodeId: string | null | u
|
||||
}
|
||||
|
||||
/** The home a seat's launches and OpenRig's readers use: its own, else the daemon's explicit value. Undefined means the
|
||||
* runtime's native default. */
|
||||
* runtime's native default, except Pi which keeps its managed seat login and never inherits the daemon home. */
|
||||
export function effectiveRuntimeHome(db: Database.Database, nodeId: string | null | undefined, runtime: HomeRuntime,
|
||||
daemonHome: string | undefined, ignoreAccountHome = false): string | undefined {
|
||||
return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? daemonHome;
|
||||
return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? (runtime === "pi" ? undefined : daemonHome);
|
||||
}
|
||||
|
||||
/** Every distinct home seats declare for a runtime (absolute values only), for readers that find a record by an id that is
|
||||
@@ -90,7 +93,7 @@ export function declaredRuntimeHomes(db: Database.Database, runtime: HomeRuntime
|
||||
* ignored, so the launch leaves it out too and the runtime resolves as if the seat declared none. */
|
||||
export function withoutIgnoredRuntimeHomes(env: SeatEnv): SeatEnv {
|
||||
const out = { ...env };
|
||||
for (const name of Object.values(RUNTIME_HOME_VAR)) {
|
||||
for (const name of [RUNTIME_HOME_VAR.codex, RUNTIME_HOME_VAR["claude-code"]]) {
|
||||
const value = out[name];
|
||||
if (value !== undefined && !path.isAbsolute(value)) delete out[name];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "./pi-account-state.js";
|
||||
import { piSeatPaths } from "../adapters/pi-runner-protocol.js";
|
||||
import { readSeatEffort } from "./seat-effort.js";
|
||||
import { claudeSeatTokenRefProblem, claudeSeatTokenFileProblem, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
|
||||
import { isolatedClaudeAccountProblem } from "./claude-credential-precedence.js";
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { RUNTIME_HOME_VAR } from "./seat-runtime-home.js";
|
||||
import { RUNTIME_HOME_VAR, seatRuntimeHome } from "./seat-runtime-home.js";
|
||||
import { ulid } from "ulid";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
@@ -152,7 +156,17 @@ const COSTS = [
|
||||
"A second process runs on the target's account until cutover.",
|
||||
"Messages sent to the seat while it switches are held, not typed; rig send says the seat is switching and until when, and they need sending again afterwards.",
|
||||
"A failed switch can leave the target runtime's prepared files (its seat storage and projections).",
|
||||
"Codex and Claude Code only, single-pane seats only.",
|
||||
"Codex and Claude Code runtime switches, or Pi account-home changes; single-pane seats only.",
|
||||
];
|
||||
|
||||
const PI_ACCOUNT_COSTS = [
|
||||
"A new conversation: only the handover carries over; the previous conversation stays with the predecessor.",
|
||||
"Same address, different terminal: the seat's session and address stay, its pane and scrollback change.",
|
||||
"The runtime stays Pi. The seat keeps its model and saved effort choice unless explicitly changed; its launch wrapper is retained.",
|
||||
"A second process runs on the selected account until cutover.",
|
||||
"Messages sent to the seat while it switches are held, not typed; they need sending again afterwards.",
|
||||
"A failed switch can leave the successor's prepared state directory under account-switches.",
|
||||
"Pi account-home changes; single-pane seats only.",
|
||||
];
|
||||
|
||||
export class SeatRuntimeSwitchService {
|
||||
@@ -179,8 +193,8 @@ export class SeatRuntimeSwitchService {
|
||||
/** Validate and plan a successor-first runtime/account move, executing it under the seat's lifecycle guard unless dry-run. */
|
||||
async setRuntime(input: SetRuntimeInput): Promise<SetRuntimeResult> {
|
||||
const refuse = (code: string, message: string, guidance?: string): RuntimeSwitchRefusal => ({ ok: false, code, message, ...(guidance ? { guidance } : {}) });
|
||||
if (input.runtime !== "codex" && input.runtime !== "claude-code") {
|
||||
return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code.");
|
||||
if (input.runtime !== "codex" && input.runtime !== "claude-code" && input.runtime !== "pi") {
|
||||
return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code; Pi supports account-home changes on an existing Pi seat.");
|
||||
}
|
||||
const target: SwitchRuntime = input.runtime;
|
||||
if (input.clearAccountHome !== undefined && typeof input.clearAccountHome !== "boolean") return refuse("invalid_runtime_settings", "clearAccountHome must be true or false.");
|
||||
@@ -235,8 +249,18 @@ export class SeatRuntimeSwitchService {
|
||||
: open.phase === "abandoned" ? "whose staged successor hasn't been retired" : "in progress"} (${open.from_runtime} → ${open.to_runtime}, switch ${open.id}).`,
|
||||
`Inspect it with rig seat status ${seatRef}; finish an interrupted one with rig seat set-runtime ${seatRef} --recover.`), switchId: open.id };
|
||||
}
|
||||
if (node.runtime !== "codex" && node.runtime !== "claude-code") {
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can switch.`);
|
||||
if (node.runtime !== "codex" && node.runtime !== "claude-code" && node.runtime !== "pi") {
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can change runtime; Pi supports account-home changes.`);
|
||||
}
|
||||
if ((target === "pi" || node.runtime === "pi") && target !== node.runtime) {
|
||||
return refuse("runtime_switch_unsupported", "Pi supports account-home changes on an existing Pi seat; changing to or from Pi is not supported.");
|
||||
}
|
||||
if (target === "pi" && input.accountHome !== undefined) {
|
||||
try {
|
||||
const auth = nodePath.join(input.accountHome, "auth.json");
|
||||
if (!fs.statSync(auth).isFile()) throw new Error();
|
||||
fs.accessSync(auth, fs.constants.R_OK);
|
||||
} catch { return refuse("invalid_runtime_settings", `Pi account file ${JSON.stringify(nodePath.join(input.accountHome, "auth.json"))} must be readable; sign in to that home first, then retry. Nothing changed.`); }
|
||||
}
|
||||
const binding = sessionRegistry.getBindingForNode(node.id);
|
||||
const sessionName = binding?.tmuxSession ?? null;
|
||||
@@ -252,6 +276,11 @@ export class SeatRuntimeSwitchService {
|
||||
const problem = isolatedClaudeAccountProblem(input.accountHome, node.cwd, !!input.accountToken, input.accountToken?.kind === "api-key");
|
||||
if (problem) return refuse("invalid_runtime_settings", problem);
|
||||
}
|
||||
// An account change keeps this Pi seat's current model and effort unless explicitly changed.
|
||||
if (target === "pi") {
|
||||
explicit.model ??= node.model ?? undefined;
|
||||
explicit.effort ??= readSeatEffort(db, node.id)?.effective ?? node.effort ?? undefined;
|
||||
}
|
||||
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
|
||||
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
|
||||
const unsupported = !sessionName || !binding?.tmuxPane ? NO_PANE
|
||||
@@ -310,7 +339,7 @@ export class SeatRuntimeSwitchService {
|
||||
settings: materials.settings,
|
||||
permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" },
|
||||
dropped,
|
||||
costs: COSTS,
|
||||
costs: target === "pi" ? PI_ACCOUNT_COSTS : COSTS,
|
||||
notRun: materials.notRun.map((command) => `startup slash command not run by a switch: ${command}`),
|
||||
warnings: materials.warnings,
|
||||
};
|
||||
@@ -416,7 +445,22 @@ export class SeatRuntimeSwitchService {
|
||||
successorPane = pane;
|
||||
db.prepare("UPDATE seat_runtime_switches SET successor_pane = ?, updated_at = ? WHERE id = ?").run(pane, stamp(), id);
|
||||
},
|
||||
staged: { prepare: (binding, adapter) => materials.prepare(binding, adapter),
|
||||
staged: { prepare: (binding, adapter) => {
|
||||
if (target === "pi") {
|
||||
const base = nodePath.join(this.deps.openrigHome ?? getOpenRigHome(), "state", "pi");
|
||||
const source = piSeatPaths(piAccountStateRoot(db, base, sessionName), sessionName).agentDir;
|
||||
const destination = piSeatPaths(piAccountSwitchStateRoot(base, successorGeneration), sessionName).agentDir;
|
||||
if (fs.existsSync(source)) fs.cpSync(source, destination, { recursive: true, verbatimSymlinks: true,
|
||||
filter: file => file !== nodePath.join(source, "auth.json") && file !== nodePath.join(source, ".openrig-account") });
|
||||
// Clearing without a configured home returns to the original seat-local login, if it had one.
|
||||
const baseline = piSeatPaths(base, sessionName).agentDir;
|
||||
if (ctx.input.clearAccountHome && !seatRuntimeHome(db, node.id, "pi", true).home && !fs.existsSync(nodePath.join(baseline, ".openrig-account")) && fs.existsSync(nodePath.join(baseline, "auth.json"))) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.copyFileSync(nodePath.join(baseline, "auth.json"), nodePath.join(destination, "auth.json"));
|
||||
}
|
||||
}
|
||||
return materials.prepare(binding, adapter);
|
||||
},
|
||||
binding: { ...materials.bindingAdditions(), ...(ctx.input.accountHome !== undefined ? { accountHome: ctx.input.accountHome, accountToken: ctx.input.accountToken ?? null }
|
||||
: ctx.input.clearAccountHome || node.accountHome ? { accountHome: null, ...(node.accountHome ? { accountToken: null } : {}) } : {}), runtimeSwitchFrom: { runtime: node.runtime!, session: sessionName, pane: ctx.predecessorPane, generation: ctx.predecessorGeneration } } },
|
||||
});
|
||||
@@ -532,10 +576,11 @@ export class SeatRuntimeSwitchService {
|
||||
}
|
||||
const at = this.now().toISOString();
|
||||
const spec = materials.resolvedSpec;
|
||||
db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = NULL, advisor = ?, codex_config_profile = ?,
|
||||
db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = ?, advisor = ?, codex_config_profile = ?,
|
||||
${spec ? "resolved_spec_name = ?, resolved_spec_version = ?, resolved_spec_hash = ?, restore_policy = ?," : ""}
|
||||
occupant_lifecycle = 'active', continuity_outcome = 'fresh', handover_result = 'complete', previous_occupant = ?, handover_at = ? WHERE id = ?`)
|
||||
.run(target, materials.settings.model.value, materials.settings.effort.value, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
|
||||
.run(target, materials.settings.model.value, target === "pi" && ctx.input.effort === undefined ? node.effort ?? null : materials.settings.effort.value,
|
||||
target === "pi" && ctx.input.effort === undefined ? readSeatEffort(db, node.id)?.choice ?? null : null, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
|
||||
...(spec ? [spec.name, spec.version, spec.hash, spec.restorePolicy] : []), ctx.sessionName, at, node.id);
|
||||
db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?")
|
||||
.run(ctx.input.accountHome ?? null, ctx.input.accountHome !== undefined || node.accountHome ? 1 : 0,
|
||||
|
||||
@@ -44,10 +44,10 @@ export interface SeatStatus {
|
||||
previous_occupant: string | null;
|
||||
handover_at: string | null;
|
||||
restore_outcome: NodeInventoryEntry["restoreOutcome"];
|
||||
/** Claude and Codex seats: the home (and with it the login) the seat's launches use. Configured, not proof of
|
||||
/** Claude, Codex and Pi seats: the home (and with it the login) the seat's launches use. Configured, not proof of
|
||||
* which login a running process holds. `ignored` is a declared value that isn't an absolute path. */
|
||||
runtime_home?: {
|
||||
variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME";
|
||||
variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME" | "PI_CODING_AGENT_DIR";
|
||||
home: string | null;
|
||||
source: "member" | "rig" | "account-switch" | "daemon" | "native_default";
|
||||
ignored?: { source: "member" | "rig" | "account-switch"; value: string };
|
||||
@@ -215,10 +215,11 @@ export class SeatStatusService {
|
||||
const ignored = own.ignored ? { ignored: own.ignored } : {};
|
||||
if (own.home && own.source) return { runtime_home: { variable, home: own.home, source: own.source } };
|
||||
// The daemon's explicit value, as its launches pass it on; else the runtime's native default under HOME.
|
||||
const daemon = this.env[variable];
|
||||
const daemon = runtime === "pi" ? undefined : this.env[variable];
|
||||
if (daemon) return { runtime_home: { variable, home: daemon, source: "daemon", ...ignored } };
|
||||
const home = this.env.HOME;
|
||||
const native = home ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null;
|
||||
// Unconfigured Pi uses its managed seat directory, not the operator's ~/.pi/agent login.
|
||||
const native = home && runtime !== "pi" ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null;
|
||||
return { runtime_home: { variable, home: native, source: "native_default", ...ignored } };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { piAccountStateRoot } from "./domain/pi-account-state.js";
|
||||
import { EnvironmentStartup } from "./domain/environment-startup.js";
|
||||
import { EnvironmentPlatform } from "./domain/environment-platform.js";
|
||||
import { OpenShellDriver } from "./adapters/openshell-driver.js";
|
||||
import { isBoundTerminalSeat } from "./domain/terminal-seat.js";
|
||||
import { seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js";
|
||||
import { seatRuntimeHomeBySession, seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js";
|
||||
import { reportRestoreDeliveryFailure } from "./domain/compaction-restore-notice.js";
|
||||
import { ClaudeTokenUsage, claudeTokenUsageReader } from "./domain/provider/claude-token-usage.js";
|
||||
import type { ClaudeTokenIdentity } from "./domain/provider/provider-types.js";
|
||||
@@ -723,7 +724,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
});
|
||||
const { SeatLaunchEnvironment } = await import("./domain/seat-launch-environment.js");
|
||||
const seatLaunchEnvironment = new SeatLaunchEnvironment(tmuxAdapter, launchSessionEnv, process.cwd(), undefined, configuredCodexHome,
|
||||
(nodeId, runtime, ignoreAccountHome) => ignoreAccountHome
|
||||
(nodeId, runtime, ignoreAccountHome) => runtime === "pi"
|
||||
? effectiveRuntimeHome(db, nodeId, "pi", undefined, ignoreAccountHome)
|
||||
: ignoreAccountHome
|
||||
? effectiveRuntimeHome(db, nodeId, runtime as HomeRuntime, runtime === "codex" ? configuredCodexHome : configuredClaudeHome, true)
|
||||
: seatAccountHome(db, nodeId, runtime));
|
||||
const nodeLauncher = new NodeLauncher({
|
||||
@@ -768,6 +771,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// dist). Shared by the Pi runtime adapter, the resume adapter, and the
|
||||
// resume-token capture sidecar reader.
|
||||
const piStateRoot = nodePath.join(OPENRIG_HOME, "state", "pi");
|
||||
const piStateRootForSession = (session: string) => piAccountStateRoot(db, piStateRoot, session);
|
||||
const ompStateRoot = nodePath.join(OPENRIG_HOME, "state", "omp");
|
||||
const piRunnerEntryPath = nodePath.resolve(import.meta.dirname, "./adapters/pi-runner.js");
|
||||
// OPR.0.7.0.2: Pi seats report cost facts from persisted entries only while usage.pi_cost_facts is on;
|
||||
@@ -784,7 +788,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
tmuxAdapter,
|
||||
{ readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }) },
|
||||
{ stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath },
|
||||
{ seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
{ accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
);
|
||||
const ompResume = new OmpResumeAdapter(
|
||||
tmuxAdapter,
|
||||
@@ -985,7 +989,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
} });
|
||||
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
|
||||
// shape as the Codex adapter; seat isolation roots under piStateRoot.
|
||||
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const ompAdapter = new OmpRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
|
||||
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
|
||||
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
|
||||
|
||||
@@ -54,3 +54,35 @@ printf '%s|%s' "$CLAUDE_CONFIG_DIR" "$token"
|
||||
db.close(); rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("Pi down and existing-rig up retain the selected account home", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "pi-account-snapshot-"));
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const adapter = {
|
||||
runtime: "pi", launchHarness: async () => ({ ok: true }), checkReady: async () => ({ ready: true }),
|
||||
project: async () => ({ projected: [], skipped: [], failed: [] }),
|
||||
deliverStartup: async () => ({ delivered: [], failed: [] }),
|
||||
} as unknown as import("../src/domain/runtime-adapter.js").RuntimeAdapter;
|
||||
const { app, rigRepo, sessionRegistry, snapshotRepo } = createTestApp(db, { adapters: { pi: adapter } });
|
||||
const rig = rigRepo.createRig("pi-account-snapshot");
|
||||
const node = rigRepo.addNode(rig.id, "worker", { runtime: "pi", cwd: root });
|
||||
db.prepare("UPDATE nodes SET account_home = ? WHERE id = ?").run(root, node.id);
|
||||
const session = sessionRegistry.registerSession(node.id, "worker@pi-account-snapshot");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
db.prepare("UPDATE sessions SET restore_policy = 'relaunch_fresh' WHERE id = ?").run(session.id);
|
||||
const down = await app.request("/api/down", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rigId: rig.id, snapshot: true, force: true }) });
|
||||
expect(down.status).toBe(200);
|
||||
const stopped = await down.json();
|
||||
expect(stopped.errors).toEqual([]);
|
||||
expect(snapshotRepo.getSnapshot(stopped.snapshotId)!.data.nodes[0]).toMatchObject({ accountHome: root, runtime: "pi" });
|
||||
const up = await app.request("/api/up", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: rig.name, existing: true }) });
|
||||
expect(up.status).toBe(200);
|
||||
expect((await up.json()).status).toBe("restored");
|
||||
expect(rigRepo.getEffectiveSeatEnv(node.id).PI_CODING_AGENT_DIR).toBe(root);
|
||||
const status = await (await app.request("/api/seat/status/worker%40pi-account-snapshot")).json();
|
||||
expect(status.runtime_home).toMatchObject({ home: root, source: "account-switch" });
|
||||
} finally { db.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { piAccountSwitchesSchema } from "../../src/db/migrations/151_pi_account_switches.js";
|
||||
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
|
||||
@@ -160,7 +161,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -566,7 +566,7 @@ describe("GF2 — the COMPLETE production-altitude launch/restore matrix", () =>
|
||||
try {
|
||||
const { PiRuntimeAdapter } = await import("../src/adapters/pi-runtime-adapter.js");
|
||||
const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js");
|
||||
const piFs = { readFile: () => "{}", writeFile: () => {}, exists: () => true, mkdirp: () => {}, listFiles: () => [] };
|
||||
const piFs = { readFile: () => "{}", writeFile: () => {}, exists: (file: string) => !file.endsWith("/.openrig-account"), mkdirp: () => {}, listFiles: () => [] };
|
||||
for (const [posture, expectedTrust] of [["full_bypass", "approve"], ["floor", "no-approve"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new PiRuntimeAdapter({ tmux, fsOps: piFs as never, stateRoot: "/tmp/pi-state", runnerEntryPath: "/tmp/pi-runner.js", sleep: async () => {} });
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { preparePiAccount } from "../src/adapters/pi-runner.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
it("links only the selected account auth and removes its managed link when cleared", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-"));
|
||||
roots.push(root);
|
||||
const agent = path.join(root, "seat", "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(account);
|
||||
fs.writeFileSync(path.join(account, "auth.json"), JSON.stringify({ mistral: { type: "api_key", key: "test-key" } }));
|
||||
preparePiAccount(agent, account);
|
||||
expect(fs.realpathSync(path.join(agent, "auth.json"))).toBe(fs.realpathSync(path.join(account, "auth.json")));
|
||||
preparePiAccount(agent, undefined);
|
||||
expect(fs.existsSync(path.join(agent, "auth.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(account, "auth.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, ["wrapper", "arg with spaces", "--"]])("preserves the selected account and wrapper when resuming a Pi seat (%j)", async wrapper => {
|
||||
const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-resume-account-"));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig"), sessionFile = path.join(root, "session.jsonl"), account = path.join(root, "account");
|
||||
fs.writeFileSync(path.join(root, "wrapper"), "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
fs.writeFileSync(sessionFile, "{}");
|
||||
let launched = "";
|
||||
const identity: Record<string, string> = { OPENRIG_NODE_ID: "pi-node", OPENRIG_SESSION_NAME: "build@rig", OPENRIG_RUNTIME: "pi", OPENRIG_OCCUPANT_GENERATION: "generation" };
|
||||
const tmux = {
|
||||
getSessionEnv: async (_session: string, key: string) => identity[key],
|
||||
getPaneCommand: async () => "bash",
|
||||
sendShellCommand: async (_session: string, command: string) => {
|
||||
launched = command;
|
||||
fs.writeFileSync(path.join(root, "account-switches", "committed-generation", "build@rig/runner-state.json"), JSON.stringify({ ready: true, launchId: "attempt", sessionFile, updatedAt: "now" }));
|
||||
return { ok: true as const };
|
||||
},
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const resumedRoot = path.join(root, "account-switches", "committed-generation");
|
||||
const launch = new SeatLaunchEnvironment(tmux, { PATH: `${root}:/usr/bin`, OPENRIG_HOME: root }, root, cli, undefined,
|
||||
(node, runtime) => node === "pi-node" && runtime === "pi" ? account : undefined);
|
||||
const adapter = new PiResumeAdapter(tmux, {
|
||||
readFile: file => fs.readFileSync(file, "utf8"), writeFile: (file, text) => fs.writeFileSync(file, text),
|
||||
exists: fs.existsSync, mkdirp: file => fs.mkdirSync(file, { recursive: true }),
|
||||
}, { stateRoot: root, runnerEntryPath: "/runner.js" }, { stateRootForSession: () => resumedRoot, seatLaunchEnvironment: launch, newLaunchId: () => "attempt" });
|
||||
expect(await adapter.resume("build@rig", "pi_session_file", sessionFile, root, "mistral/devstral-latest", undefined, undefined, wrapper)).toMatchObject({ ok: true });
|
||||
expect(launched).toContain(`--account-home '${account}'`);
|
||||
expect(launched).toContain(`--state-root '${resumedRoot}'`);
|
||||
if (wrapper) expect(launched).toContain(`--launch-wrapper-arg '${root}/wrapper' --launch-wrapper-arg 'arg with spaces' --launch-wrapper-arg '--'`);
|
||||
let attempts = 0;
|
||||
tmux.sendShellCommand = async () => { attempts++; return { ok: false, code: "launch_path_too_long", message: "fixture bound" }; };
|
||||
expect(await adapter.resume("build@rig", "pi_session_file", sessionFile, root, "mistral/devstral-latest", undefined, undefined, wrapper))
|
||||
.toMatchObject({ ok: false, code: "resume_failed" });
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the previous credentials when a selected account cannot be read, and leaves unselected seat auth alone", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-"));
|
||||
roots.push(root);
|
||||
const agent = path.join(root, "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(agent);
|
||||
fs.mkdirSync(account);
|
||||
const auth = path.join(agent, "auth.json");
|
||||
fs.writeFileSync(auth, "seat-auth");
|
||||
preparePiAccount(agent, undefined);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
expect(() => preparePiAccount(agent, account)).toThrow();
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
preparePiAccount(agent, agent);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
const alias = path.join(root, "alias");
|
||||
fs.symlinkSync(agent, alias, "junction");
|
||||
preparePiAccount(agent, alias);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
});
|
||||
|
||||
it.each(["selected", "cleared"])("executes a %s Pi account environment without leaking inherited provider keys", async selection => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-env-"));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig");
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
const tmux = {
|
||||
getPaneCommand: async () => "bash",
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "pi-node" : "seat@rig",
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli, undefined,
|
||||
(_node, _runtime, clearing) => clearing ? "/pi/configured" : "/pi/selected");
|
||||
const command = await launch.command("seat@rig", "/usr/bin/env", {
|
||||
nodeId: "pi-node", runtime: "pi", ...(selection === "cleared" ? { accountHome: null } : {}),
|
||||
});
|
||||
const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: {
|
||||
PATH: "/usr/bin:/bin", PI_CODING_AGENT_DIR: "/pi/stale", MISTRAL_API_KEY: "fixture-mistral", GEMINI_API_KEY: "fixture-gemini", OPENROUTER_API_KEY: "fixture-openrouter", ZAI_API_KEY: "fixture-zai", KIMI_API_KEY: "fixture-kimi", MINIMAX_API_KEY: "fixture-minimax",
|
||||
} });
|
||||
expect(output).toContain(`PI_CODING_AGENT_DIR=/pi/${selection === "cleared" ? "configured" : "selected"}\n`);
|
||||
expect(output).not.toContain("fixture-");
|
||||
});
|
||||
|
||||
it("refuses to replace a seat login or unowned link, and preserves a login created after selection", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-owned-"));
|
||||
roots.push(root);
|
||||
const agent = path.join(root, "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(agent); fs.mkdirSync(account);
|
||||
fs.writeFileSync(path.join(account, "auth.json"), "selected-auth");
|
||||
const auth = path.join(agent, "auth.json"), marker = path.join(agent, ".openrig-account");
|
||||
fs.writeFileSync(auth, "seat-login");
|
||||
expect(() => preparePiAccount(agent, account)).toThrow("not a managed account link");
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-login");
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
fs.rmSync(auth); fs.symlinkSync(path.join(account, "auth.json"), auth);
|
||||
expect(() => preparePiAccount(agent, account)).toThrow("not a managed account link");
|
||||
expect(fs.lstatSync(auth).isSymbolicLink()).toBe(true);
|
||||
fs.rmSync(auth);
|
||||
preparePiAccount(agent, account);
|
||||
preparePiAccount(agent, account);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("selected-auth");
|
||||
fs.rmSync(auth); fs.writeFileSync(auth, "new-seat-login");
|
||||
preparePiAccount(agent, undefined);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("new-seat-login");
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["pi", "codex", "claude-code"])("scopes extra provider-key scrubbing to Pi and resets ambient selection (%s)", async runtime => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-scope-"));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig");
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
const tmux = {
|
||||
getPaneCommand: async () => "bash",
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "node" : "seat@rig",
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli);
|
||||
const inherited = { PATH: "/usr/bin:/bin", OPENROUTER_API_KEY: "custom-provider-key", OPENAI_API_KEY: "existing-scrub", PI_CODING_AGENT_DIR: "/ambient", OPENRIG_PI_ACCOUNT_HOME: "/stale" };
|
||||
const command = await launch.command("seat@rig", "/usr/bin/env", { nodeId: "node", runtime, accountHome: "/selected" });
|
||||
const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: inherited });
|
||||
expect(output).not.toContain("existing-scrub");
|
||||
if (runtime === "pi") {
|
||||
expect(output).not.toContain("custom-provider-key");
|
||||
expect(output).toContain("OPENRIG_PI_ACCOUNT_HOME=/stale\n");
|
||||
const unselected = await launch.command("seat@rig", "/usr/bin/env", { nodeId: "node", runtime });
|
||||
const ambient = execFileSync("/bin/sh", ["-c", unselected], { encoding: "utf8", env: inherited });
|
||||
expect(ambient).toContain("OPENRIG_PI_ACCOUNT_HOME=/stale\n");
|
||||
} else expect(output).toContain("OPENROUTER_API_KEY=custom-provider-key\n");
|
||||
});
|
||||
|
||||
it.each(["nu", "fish", "bash"])("retains the short account-free fallback (%s)", async shell => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const tmux = {
|
||||
getPaneCommand: async () => shell,
|
||||
getSessionEnv: async () => undefined,
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, {}, "/tmp");
|
||||
const command = await launch.command("seat@rig", "/usr/bin/env", { runtime: "pi" });
|
||||
expect(command).toBe("/usr/bin/env");
|
||||
const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: { OPENRIG_PI_ACCOUNT_HOME: "/unselected" } });
|
||||
expect(output).toContain("OPENRIG_PI_ACCOUNT_HOME=/unselected\n"); // The runner ignores this obsolete variable.
|
||||
expect(await launch.command("seat@rig", "/usr/bin/env", { runtime: "pi", accountHome: "/selected" })).toContain("no agent started");
|
||||
});
|
||||
|
||||
it("retries an interrupted initial account link without treating it as a person's own link", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-interrupted-")); roots.push(root);
|
||||
const agent = path.join(root, "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(account); fs.writeFileSync(path.join(account, "auth.json"), "synthetic-auth");
|
||||
const symlink = fs.symlinkSync;
|
||||
const interruption = vi.spyOn(fs, "symlinkSync").mockImplementation((...args) => {
|
||||
symlink(...args); throw new Error("simulated interruption immediately after linking");
|
||||
});
|
||||
try { expect(() => preparePiAccount(agent, account)).toThrow("simulated interruption"); }
|
||||
finally { interruption.mockRestore(); }
|
||||
preparePiAccount(agent, account);
|
||||
expect(fs.readFileSync(path.join(agent, "auth.json"), "utf8")).toBe("synthetic-auth");
|
||||
expect(fs.readFileSync(path.join(agent, ".openrig-account"), "utf8")).toBe(account);
|
||||
});
|
||||
|
||||
it.each([false, true])("resume tolerates unavailable tmux identity only without a chosen account (%s)", async chosen => {
|
||||
const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-resume-identity-")); roots.push(root);
|
||||
const sessionFile = path.join(root, "session.jsonl"); fs.writeFileSync(sessionFile, "{}");
|
||||
let dispatched = false;
|
||||
const tmux = {
|
||||
getSessionEnv: async () => { throw new Error("metadata unavailable"); },
|
||||
getPaneCommand: async () => "bash",
|
||||
sendShellCommand: async () => {
|
||||
dispatched = true;
|
||||
fs.writeFileSync(path.join(root, "seat@rig/runner-state.json"), JSON.stringify({ ready: true, launchId: "attempt", sessionFile, updatedAt: "now" }));
|
||||
return { ok: true as const };
|
||||
},
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, {}, root);
|
||||
const adapter = new PiResumeAdapter(tmux, {
|
||||
readFile: file => fs.readFileSync(file, "utf8"), writeFile: fs.writeFileSync,
|
||||
exists: fs.existsSync, mkdirp: file => fs.mkdirSync(file, { recursive: true }),
|
||||
}, { stateRoot: root, runnerEntryPath: "/runner.js" }, {
|
||||
seatLaunchEnvironment: launch, accountHomeForSession: () => chosen ? "/chosen" : undefined, newLaunchId: () => "attempt",
|
||||
});
|
||||
expect(await adapter.resume("seat@rig", "pi_session_file", sessionFile, root)).toMatchObject({ ok: !chosen });
|
||||
expect(dispatched).toBe(!chosen);
|
||||
});
|
||||
|
||||
it("reports a missing prepared auth file in the seat's own home without an uncaught lstat error", async () => {
|
||||
const { main } = await import("../src/adapters/pi-runner.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-own-account-")); roots.push(root);
|
||||
const agent = path.join(root, "seat", "agent");
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitCode = process.exitCode;
|
||||
try {
|
||||
await expect(main(["--session-name", "seat", "--state-root", root, "--cwd", root,
|
||||
"--launch-id", "attempt", "--no-approve", "--account-home", agent])).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(error.mock.calls.flat().join("\n")).toContain(path.join(agent, "auth.json"));
|
||||
expect(error.mock.calls.flat().join("\n")).toContain("Pi account preparation failed");
|
||||
expect(fs.existsSync(path.join(agent, ".openrig-account"))).toBe(false);
|
||||
expect(() => preparePiAccount(agent, undefined)).not.toThrow();
|
||||
} finally { process.exitCode = exitCode; error.mockRestore(); }
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { preparePiAccount } from "../src/adapters/pi-runner.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const lockModule = require.resolve("proper-lockfile");
|
||||
const lockfile = require("proper-lockfile");
|
||||
const shim = path.resolve(import.meta.dirname, "../assets/pi-account-lock.cjs");
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
it.each([[false, false], [true, false], [true, true]])("Pi's realpath:false lock across account aliases (shim: %s, linked source: %s)", async (useShim, linkedSource) => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-lock-"))); roots.push(root);
|
||||
const account = path.join(root, "account"), a = path.join(root, "a"), b = path.join(root, "b");
|
||||
fs.mkdirSync(account); const source = path.join(account, "auth.json");
|
||||
if (linkedSource) {
|
||||
fs.writeFileSync(path.join(root, "credentials.json"), "{}");
|
||||
fs.symlinkSync("../credentials.json", source);
|
||||
} else fs.writeFileSync(source, "{}");
|
||||
preparePiAccount(a, account); preparePiAccount(b, account);
|
||||
const env = (agentDir: string) => ({ ...process.env, NODE_OPTIONS: useShim ? `--require=${JSON.stringify(shim)}` : "", PI_CODING_AGENT_DIR: agentDir });
|
||||
const child = spawn(process.execPath, ["-e", `
|
||||
const fs = require('node:fs'), lock = require(${JSON.stringify(lockModule)});
|
||||
const auth = ${JSON.stringify(path.join(a, "auth.json"))};
|
||||
const release = lock.lockSync(auth, {realpath:false});
|
||||
fs.writeFileSync(auth, JSON.stringify({first:'refreshed'}));
|
||||
process.send('locked');
|
||||
process.on('message', () => { release(); process.disconnect(); });
|
||||
`], { env: env(a), stdio: ["ignore", "pipe", "pipe", "ipc"] });
|
||||
try {
|
||||
await Promise.race([once(child, "message"), once(child, "exit").then(() => { throw new Error("lock holder exited early"); })]);
|
||||
const probe = execFileSync(process.execPath, ["-e", `
|
||||
const lock = require(${JSON.stringify(lockModule)});
|
||||
(async () => {
|
||||
try { const release = await lock.lock(${JSON.stringify(path.join(b, "auth.json"))}, {realpath:false}); await release(); console.log('acquired'); }
|
||||
catch (e) { if(e.code !== 'ELOCKED') throw e; console.log('blocked'); }
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
`], { env: env(b), encoding: "utf8" });
|
||||
expect(probe.trim()).toBe(useShim ? "blocked" : "acquired");
|
||||
// Native direct use of the chosen account contends with the same lock too.
|
||||
if (useShim) expect(() => lockfile.lockSync(source, { realpath: false })).toThrow(/already being held/);
|
||||
else lockfile.lockSync(source, { realpath: false })();
|
||||
const exited = once(child, "exit"); child.send("release"); await exited;
|
||||
execFileSync(process.execPath, ["-e", `
|
||||
const fs = require('node:fs'), lock = require(${JSON.stringify(lockModule)});
|
||||
const auth = ${JSON.stringify(path.join(b, "auth.json"))};
|
||||
const release = lock.lockSync(auth, {realpath:false});
|
||||
const state = JSON.parse(fs.readFileSync(auth)); state.second = 'refreshed';
|
||||
fs.writeFileSync(auth, JSON.stringify(state)); release();
|
||||
`], { env: env(b) });
|
||||
expect(JSON.parse(fs.readFileSync(source, "utf8"))).toEqual({ first: "refreshed", second: "refreshed" });
|
||||
expect(fs.existsSync(source + ".lock")).toBe(false);
|
||||
expect(fs.existsSync(path.join(a, "auth.json.lock"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(b, "auth.json.lock"))).toBe(false);
|
||||
} finally { child.kill(); }
|
||||
}, 10000);
|
||||
|
||||
// Exercise the process boundary used by Pi's bash tool without provider access.
|
||||
it("consumes only its preload and leaves Node tools and nested unselected Pi untouched", () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-child-"))); roots.push(root);
|
||||
const account = path.join(root, "account"), agent = path.join(root, "agent");
|
||||
fs.mkdirSync(account); fs.writeFileSync(path.join(account, "auth.json"), "{}");
|
||||
preparePiAccount(agent, account);
|
||||
const nested = path.join(root, "nested");
|
||||
fs.mkdirSync(nested); fs.writeFileSync(path.join(nested, "auth.json"), "{}");
|
||||
const probe = `
|
||||
const fs = require('node:fs');
|
||||
const native = Function.prototype.toString.call(fs.mkdirSync);
|
||||
if (native.includes('lockPath')) throw new Error('filesystem was patched');
|
||||
if (process.env.NODE_OPTIONS !== '--no-warnings') throw new Error('other options lost');
|
||||
const lock = require(${JSON.stringify(lockModule)});
|
||||
lock.lockSync(${JSON.stringify(path.join(nested, "auth.json"))}, {realpath:false})();
|
||||
`;
|
||||
execFileSync(process.execPath, ["-e", `
|
||||
const {execFileSync} = require('node:child_process');
|
||||
for (const dir of [process.env.PI_CODING_AGENT_DIR, ${JSON.stringify(nested)}, '/missing/pi-home']) {
|
||||
execFileSync(process.execPath, ['-e', ${JSON.stringify(probe)}], {
|
||||
env: {...process.env, PI_CODING_AGENT_DIR: dir}
|
||||
});
|
||||
}
|
||||
`], { env: { ...process.env, PI_CODING_AGENT_DIR: agent,
|
||||
NODE_OPTIONS: `--no-warnings --require=${JSON.stringify(shim)}` } });
|
||||
// A wrapper may preload into an unrelated process itself: this must be harmless too.
|
||||
execFileSync(process.execPath, ["-e", probe], { env: { ...process.env,
|
||||
PI_CODING_AGENT_DIR: nested, NODE_OPTIONS: `--require=${JSON.stringify(shim)} --no-warnings` } });
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { piAccountSwitchesSchema } from "../src/db/migrations/151_pi_account_switches.js";
|
||||
|
||||
it("retains an interrupted runtime switch when upgrading to Pi-capable switches", () => {
|
||||
const db = createDb();
|
||||
try {
|
||||
migrate(db, ALL_MIGRATIONS.filter(migration => migration.name !== piAccountSwitchesSchema.name));
|
||||
db.exec("INSERT INTO rigs (id, name) VALUES ('rig', 'upgrade'); INSERT INTO nodes (id, rig_id, logical_id) VALUES ('seat', 'rig', 'dev.build');");
|
||||
const insert = db.prepare(`INSERT INTO seat_runtime_switches
|
||||
(id, node_id, canonical_session, staging_session, predecessor_pane, predecessor_generation, successor_generation, from_runtime, to_runtime, reason, plan_json, deadline_at)
|
||||
VALUES (?, 'seat', 'build@upgrade', ?, '%0', 'before', ?, 'codex', ?, 'allowance', '{}', '2026-11-03T03:00:00Z')`);
|
||||
insert.run("old", "staging-old", "after-old", "claude-code");
|
||||
const before = db.prepare("SELECT * FROM seat_runtime_switches WHERE id = 'old'").get();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
expect(db.prepare("SELECT * FROM seat_runtime_switches WHERE id = 'old'").get()).toEqual(before);
|
||||
expect(() => insert.run("duplicate", "staging-new", "after-new", "pi")).toThrow();
|
||||
db.exec("UPDATE seat_runtime_switches SET phase = 'abandoned' WHERE id = 'old'");
|
||||
insert.run("pi", "staging-new", "after-new", "pi");
|
||||
expect(db.prepare("SELECT to_runtime FROM seat_runtime_switches WHERE id = 'pi'").get()).toEqual({ to_runtime: "pi" });
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
expect(db.pragma("foreign_key_check")).toEqual([]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -54,7 +54,7 @@ else { process.stdin.resume(); console.error('Error: Unknown options: --name, --
|
||||
fileURLToPath(new URL("../src/adapters/pi-runner.ts", import.meta.url)),
|
||||
"--session-name", "fixture", "--state-root", stateRoot, "--cwd", temp,
|
||||
"--launch-id", "path-attempt", "--no-approve"], {
|
||||
env: { PATH: searchPath, HOME: temp, OPENRIG_HOME: path.join(temp, "home") },
|
||||
env: { OPENRIG_PI_ACCOUNT_HOME: "/ignored/ambient", PI_CODING_AGENT_DIR: "/ambient/must-not-be-used", PATH: searchPath, HOME: temp, OPENRIG_HOME: path.join(temp, "home") },
|
||||
detached: true, stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let output = "", ready = false, timedOut = false;
|
||||
@@ -93,6 +93,8 @@ else { process.stdin.resume(); console.error('Error: Unknown options: --name, --
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrap-child-")));
|
||||
const first = path.join(temp, "first"), second = path.join(temp, "second");
|
||||
fs.mkdirSync(first); fs.mkdirSync(second);
|
||||
const account = path.join(temp, "account"); fs.mkdirSync(account);
|
||||
fs.writeFileSync(path.join(account, "auth.json"), "synthetic-selected-account");
|
||||
const session = path.join(temp, "session.jsonl");
|
||||
fs.writeFileSync(session, "fixture\n");
|
||||
const piLogPath = path.join(temp, "pi-child-log.json");
|
||||
@@ -150,6 +152,7 @@ WRAPPER_SH_PID="$$" WRAPPER_PPID="$PPID" ${quote(process.execPath)} -e '
|
||||
wrapperPpid: Number(process.env.WRAPPER_PPID),
|
||||
wrapperArgs: argv.slice(0, sep),
|
||||
execArgv: argv.slice(sep + 1),
|
||||
auth: fs.readFileSync(require("node:path").join(process.env.PI_CODING_AGENT_DIR, "auth.json"), "utf8"),
|
||||
cwd: process.cwd(),
|
||||
tty: [0, 1, 2].map(fd => Boolean(tty.isatty(fd))),
|
||||
}));
|
||||
@@ -181,8 +184,9 @@ exec "$@"
|
||||
"--launch-wrapper-arg", "--",
|
||||
];
|
||||
|
||||
runnerArgv.push("--account-home", account);
|
||||
const child = spawn(process.execPath, runnerArgv, {
|
||||
env: { PATH: [first, second, "/usr/bin", "/bin"].join(path.delimiter), HOME: temp, OPENRIG_HOME: path.join(temp, "home"), OPENRIG_NODE_ID: "node-1" },
|
||||
env: { OPENRIG_PI_ACCOUNT_HOME: "/ignored/ambient", PI_CODING_AGENT_DIR: "/ambient/must-not-be-used", PATH: [first, second, "/usr/bin", "/bin"].join(path.delimiter), HOME: temp, OPENRIG_HOME: path.join(temp, "home"), OPENRIG_NODE_ID: "node-1" },
|
||||
detached: true, stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let output = "", ready = false, timedOut = false;
|
||||
@@ -205,6 +209,8 @@ exec "$@"
|
||||
|
||||
const wrapperRec = JSON.parse(fs.readFileSync(wrapperLogPath, "utf8"));
|
||||
const piRec = JSON.parse(fs.readFileSync(piLogPath, "utf8"));
|
||||
expect(wrapperRec.auth).toBe("synthetic-selected-account");
|
||||
expect(fs.realpathSync(path.join(seat.agentDir, "auth.json"))).toBe(fs.realpathSync(path.join(account, "auth.json")));
|
||||
const expectedPiPath = mode === "mise" ? fs.realpathSync(good) : fs.realpathSync(path.join(first, "pi"));
|
||||
const expectedChildArgs = buildPiChildArgs({
|
||||
sessionsDir: seat.sessionsDir,
|
||||
|
||||
@@ -42,6 +42,7 @@ function mockTmux(overrides?: {
|
||||
capturePaneContent: overrides?.capturePaneContent ?? vi.fn(async () => ""),
|
||||
hasSession: overrides?.hasSession ?? vi.fn(async () => true),
|
||||
getPaneCommand: overrides?.getPaneCommand ?? vi.fn(async () => "node"),
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "node-pi" : undefined,
|
||||
createSession: async () => ({ ok: true as const }),
|
||||
killSession: async () => ({ ok: true as const }),
|
||||
listSessions: async () => [],
|
||||
@@ -841,6 +842,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
const commandCalls: Array<{ session: string; cmd: string; target: unknown }> = [];
|
||||
const resolveLaunchWrapper = vi.fn((w: readonly string[]) => [`/resolved/bin/${w[0]}`, ...w.slice(1)]);
|
||||
const seatLaunchEnvironment = {
|
||||
accountHome: () => undefined,
|
||||
resolveLaunchWrapper,
|
||||
command: vi.fn(async (session: string, cmd: string, target: unknown) => {
|
||||
commandCalls.push({ session, cmd, target });
|
||||
@@ -947,6 +949,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
|
||||
it("PiRuntimeAdapter retains launch_path_too_long bare runner retry when wrapped because the raw runner command carries the exact same --launch-wrapper-arg flags", async () => {
|
||||
const seatLaunchEnvironment = {
|
||||
accountHome: () => undefined,
|
||||
resolveLaunchWrapper: vi.fn((w: readonly string[]) => [`/abs/bin/${w[0]}`, ...w.slice(1)]),
|
||||
command: vi.fn(async (_s: string, cmd: string) => `/usr/bin/env 'OPENRIG_RUNTIME=pi' ${cmd}`),
|
||||
} as never;
|
||||
@@ -1008,6 +1011,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
const commandCalls: Array<{ session: string; cmd: string; target: unknown }> = [];
|
||||
const resolveLaunchWrapper = vi.fn((w: readonly string[]) => [`/abs/bin/${w[0]}`, ...w.slice(1)]);
|
||||
const seatLaunchEnvironment = {
|
||||
accountHome: () => undefined,
|
||||
resolveLaunchWrapper,
|
||||
command: vi.fn(async (session: string, cmd: string, target: unknown) => {
|
||||
commandCalls.push({ session, cmd, target });
|
||||
@@ -1032,7 +1036,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
expect(commandCalls).toEqual([{
|
||||
session: SESSION,
|
||||
cmd: expect.stringContaining("--approve --model 'zai/glm-5.2' --thinking 'high' --session"),
|
||||
target: { runtime: "pi" },
|
||||
target: { runtime: "pi", nodeId: "node-pi", accountHome: undefined },
|
||||
}]);
|
||||
const expectedFlags = "--launch-wrapper-arg '/abs/bin/my-wrapper' --launch-wrapper-arg '--opt' --launch-wrapper-arg '--'";
|
||||
expect(sendShellCommand.mock.calls[0]![1]).toContain("node '/daemon-dist/adapters/pi-runner.js'");
|
||||
@@ -1094,3 +1098,40 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
expect(tooLongSend.mock.calls[1]![1]).toContain(expectedFlags);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["selected", "cleared"])("does not bypass a %s Pi account when a wrapped launch cannot be staged", async selection => {
|
||||
const fs = memFs();
|
||||
const accountHome = selection === "cleared" ? null : "/accounts/pi";
|
||||
const sendShellCommand = vi.fn(async (): Promise<TmuxResult> => ({ ok: false, code: "launch_path_too_long", message: "fixture bound" }));
|
||||
const command = vi.fn(async (_session: string, cmd: string) => `/usr/bin/env ${cmd}`);
|
||||
const adapter = new PiRuntimeAdapter({
|
||||
tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER,
|
||||
seatLaunchEnvironment: {
|
||||
accountHome: () => "/accounts/previous", resolveLaunchWrapper: () => ["/wrapper", "--"], command,
|
||||
} as never,
|
||||
});
|
||||
const result = await adapter.launchHarness({ tmuxSession: SESSION, nodeId: "node-pi", cwd: "/work", accountHome, launchWrapper: ["wrapper", "--"] } as never, { name: SESSION });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(command.mock.calls[0]).toEqual([SESSION, expect.stringContaining("--launch-wrapper-arg '/wrapper'"), expect.objectContaining({ accountHome })]);
|
||||
expect(sendShellCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stages a Pi account successor with canonical identity and a separate state directory", async () => {
|
||||
const fs = memFs();
|
||||
const predecessorPath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath;
|
||||
fs.files[predecessorPath] = readyState(SESSION_FILE, "old-launch");
|
||||
const stagedRoot = `${STATE_ROOT}/account-switches/new-generation`;
|
||||
const stagedPath = piSeatPaths(stagedRoot, SESSION).runnerStatePath;
|
||||
const token = `${stagedRoot}/${SESSION}/sessions/new.jsonl`;
|
||||
const sendShellCommand = vi.fn(async (target: string, text: string) => {
|
||||
expect(target).toBe("staging-pane");
|
||||
expect(text).toContain(`--session-name '${SESSION}' --state-root '${stagedRoot}'`);
|
||||
fs.files[stagedPath] = readyState(token, launchIdFrom(text));
|
||||
return { ok: true as const };
|
||||
});
|
||||
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER });
|
||||
const binding = { tmuxSession: "staging-pane", launchGeneration: "new-generation", cwd: "/work", runtimeSwitchFrom: { runtime: "pi", session: SESSION, pane: "%0", generation: "old-generation" } } as never;
|
||||
expect(await adapter.launchHarness(binding, { name: SESSION })).toMatchObject({ ok: true, resumeToken: token });
|
||||
expect(await adapter.checkReady(binding)).toMatchObject({ ready: true });
|
||||
expect(fs.files[predecessorPath]).toBe(readyState(SESSION_FILE, "old-launch"));
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe.skipIf(!hasTmux || process.platform === "win32")("Pi launch through nat
|
||||
const launchMode = mode.split("-")[0];
|
||||
const longTmp = mode.endsWith("long-tmpdir");
|
||||
const routing = mode.includes("-routing");
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-shell-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-shell-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "pi-fixture";
|
||||
const long = Array.from({ length: 14 }, () => "nested-directory-" + "x".repeat(35)).join(path.sep);
|
||||
@@ -181,7 +181,7 @@ setInterval(() => {}, 1000);
|
||||
|
||||
it.each(["fresh", "fork", "resume", "staged-long", "long-tmpdir-retry"] as const)("executes launch_wrapper in native tmux for Pi %s with real host runner and wrapped Pi child over pipe stdio", async mode => {
|
||||
const launchMode = mode === "staged-long" || mode === "long-tmpdir-retry" ? "fresh" : mode;
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrapper-native-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-w-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "pi-wrapped";
|
||||
const cwd = path.join(temp, "work dir with 'quotes'");
|
||||
@@ -305,7 +305,7 @@ await main();
|
||||
const wrapper = ["generic-wrapper", "arg with spaces", "quote's", "$UNEXPANDED;false", "", "--"];
|
||||
const model =
|
||||
mode === "staged-long" ? "provider/model-" + "m".repeat(1200)
|
||||
: mode === "long-tmpdir-retry" ? "provider/model-" + "m".repeat(300)
|
||||
: mode === "long-tmpdir-retry" ? "provider/model-" + "m".repeat(100)
|
||||
: "provider/model-short";
|
||||
|
||||
try {
|
||||
@@ -414,7 +414,7 @@ await main();
|
||||
}, 15_000);
|
||||
|
||||
it.each(["missing-wrapper", "failing-wrapper-exit-7", "non-exec-zero-exit", "long-tmpdir-too-long"] as const)("fails closed in native tmux on %s without starting bare Pi", async failure => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrapper-fail-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-f-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "pi-fail";
|
||||
const cwd = path.join(temp, "work");
|
||||
@@ -535,7 +535,7 @@ await main();
|
||||
}, 15_000);
|
||||
|
||||
it.each(["fresh", "fork", "resume"] as const)("executes launch_wrapper in native tmux for Codex %s with resolved interpreter+entry, intact pid, cwd, tty, env and exit code", async mode => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-wrapper-native-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "cx-w-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "codex-wrapped";
|
||||
const cwd = path.join(temp, "codex work 'quotes'");
|
||||
|
||||
@@ -123,7 +123,7 @@ it("export helper resolves executable from cwd when PATH has empty segments", ()
|
||||
});
|
||||
|
||||
|
||||
it.each(["nu", "nu.exe", "fish", "csh", "", "unknown"])("Pi preserves its existing command on an unsupported pane: %s", async shell => {
|
||||
it.each(["nu", "nu.exe", "fish", "csh", "", "unknown"])("Pi preserves its account-free command on an unsupported pane: %s", async shell => {
|
||||
const f = fixture(); f.tmux.getPaneCommand.mockResolvedValue(shell);
|
||||
const command = "node '/runner.js' --session '/history.jsonl'";
|
||||
expect(await f.launch.command("seat@rig", command, { runtime: "pi" })).toBe(command);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { claudeConfigRootFor, declaredRuntimeHomes, effectiveRuntimeHome, seatAccountHome, seatRuntimeHome, withoutRuntimeHomes } from "../src/domain/seat-runtime-home.js";
|
||||
import { claudeConfigRootFor, declaredRuntimeHomes, effectiveRuntimeHome, seatAccountHome, seatRuntimeHome, withoutRuntimeHomes, withoutIgnoredRuntimeHomes } from "../src/domain/seat-runtime-home.js";
|
||||
|
||||
// A seat's own runtime home comes from its stored `env:`: the member's value, else the rig's.
|
||||
const open: Database.Database[] = [];
|
||||
@@ -83,6 +83,16 @@ describe("withoutRuntimeHomes and claudeConfigRootFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Pi member homes and clears a stored selection back to the configured home", () => {
|
||||
const d = db({ rig: { PI_CODING_AGENT_DIR: "/pi/rig" }, members: { a: { PI_CODING_AGENT_DIR: "/pi/member" }, b: undefined } });
|
||||
d.exec("ALTER TABLE nodes ADD COLUMN account_home TEXT; ALTER TABLE nodes ADD COLUMN runtime TEXT");
|
||||
d.prepare("UPDATE nodes SET account_home = ?, runtime = 'pi' WHERE id = 'a'").run("/pi/account");
|
||||
expect(effectiveRuntimeHome(d, "a", "pi", "/pi/daemon")).toBe("/pi/account");
|
||||
expect(effectiveRuntimeHome(d, "a", "pi", "/pi/daemon", true)).toBe("/pi/member");
|
||||
expect(seatRuntimeHome(d, "b", "pi")).toEqual({ home: "/pi/rig", source: "rig" });
|
||||
expect(withoutRuntimeHomes({ PI_CODING_AGENT_DIR: "/pi/account", DEBUG: "1" })).toEqual({ DEBUG: "1" });
|
||||
});
|
||||
|
||||
describe("declaredRuntimeHomes", () => {
|
||||
it("lists each distinct absolute home declared on members and rigs, once", () => {
|
||||
const d = db({ rig: { CODEX_HOME: "/homes/shared" },
|
||||
@@ -91,3 +101,19 @@ describe("declaredRuntimeHomes", () => {
|
||||
expect(declaredRuntimeHomes(d, "claude-code")).toEqual(["/homes/claude"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("never selects an ambient daemon Pi home, including when clearing a stored selection", () => {
|
||||
const d = db({ members: { own: { PI_CODING_AGENT_DIR: "/pi/member" }, plain: undefined } });
|
||||
d.exec("ALTER TABLE nodes ADD COLUMN runtime TEXT; ALTER TABLE nodes ADD COLUMN account_home TEXT; UPDATE nodes SET runtime = 'pi', account_home = '/pi/selected';");
|
||||
expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient")).toBe("/pi/selected");
|
||||
expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient", true)).toBe("/pi/member");
|
||||
expect(effectiveRuntimeHome(d, "plain", "pi", "/pi/ambient", true)).toBeUndefined();
|
||||
d.exec("UPDATE nodes SET account_home = NULL;");
|
||||
expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient")).toBe("/pi/member");
|
||||
expect(effectiveRuntimeHome(d, "plain", "pi", "/pi/ambient")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves relative Pi homes for community harnesses", () => {
|
||||
expect(withoutIgnoredRuntimeHomes({ PI_CODING_AGENT_DIR: "relative/pi-home", CODEX_HOME: "relative/codex", DEBUG: "1" }))
|
||||
.toEqual({ PI_CODING_AGENT_DIR: "relative/pi-home", DEBUG: "1" });
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "../src/domain/pi-account-state.js";
|
||||
import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js";
|
||||
import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js";
|
||||
import { providerRoutes } from "../src/routes/provider.js";
|
||||
// Slice 16: a live seat moves between Codex and Claude Code, successor first. Real repositories on an in-memory
|
||||
// database; tmux and the runtime adapters are fakes that keep which pane each session holds, so the exchange, the
|
||||
@@ -87,7 +90,7 @@ describe("live seat runtime switch", () => {
|
||||
|
||||
const service = (overrides: Partial<ConstructorParameters<typeof SeatRuntimeSwitchService>[0]> = {}) => new SeatRuntimeSwitchService({
|
||||
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, tmuxAdapter: fakeTmux as unknown as TmuxAdapter, queueRepo,
|
||||
runtimeAdapters: { codex: adapter("codex"), "claude-code": adapter("claude-code") },
|
||||
runtimeAdapters: { codex: adapter("codex"), "claude-code": adapter("claude-code"), pi: adapter("pi") },
|
||||
occupantInvalidator: new DefaultOccupantInvalidator({ enforcer: { invalidateOccupant: vi.fn() }, contextUsage: { invalidateOccupantSidecar: vi.fn() }, queue: queueRepo }),
|
||||
activityOracle: { declareOccupantSwap }, openrigHome: home, readinessTimeoutMs: 1000, confirmPollMs: 1000,
|
||||
sleep: async (ms) => { clock += ms; await onSleep?.(); }, now: () => new Date(clock),
|
||||
@@ -132,6 +135,110 @@ describe("live seat runtime switch", () => {
|
||||
});
|
||||
afterEach(() => { db.close(); fs.rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
it("switches a Pi account only after confirmation, preserves its seat and selects durable isolated state", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max', codex_config_profile = NULL WHERE id = ?").run(nodeId);
|
||||
const base = path.join(home, "state", "pi");
|
||||
const original = piSeatPaths(base, SEAT).agentDir;
|
||||
fs.mkdirSync(original, { recursive: true });
|
||||
fs.writeFileSync(path.join(original, "auth.json"), "original-seat-login");
|
||||
fs.writeFileSync(path.join(original, "models.json"), "{\"providers\":{}}");
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login");
|
||||
const item = await queueRepo.create({ sourceSession: "orch-lead@seat-rig", destinationSession: SEAT, body: "review" });
|
||||
queueRepo.claim({ qitemId: item.qitemId, destinationSession: SEAT });
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(binding.accountHome).toBe(accountHome);
|
||||
expect(binding.launchWrapper).toEqual(["wrapper", "--"]);
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBeUndefined();
|
||||
expect(piAccountStateRoot(db, base, SEAT)).toBe(base);
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
const successor = piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT);
|
||||
expect(fs.readFileSync(path.join(successor.agentDir, "models.json"), "utf8")).toBe("{\"providers\":{}}");
|
||||
expect(fs.existsSync(path.join(successor.agentDir, "auth.json"))).toBe(false);
|
||||
fs.mkdirSync(successor.sessionsDir, { recursive: true });
|
||||
const token = path.join(successor.sessionsDir, "conversation.jsonl");
|
||||
fs.writeFileSync(token, "{}");
|
||||
fs.writeFileSync(successor.runnerStatePath, JSON.stringify({ ready: true, sessionFile: token, launchId: "successor", updatedAt: "now" }));
|
||||
return { ok: true, resumeType: "pi_session_file", resumeToken: token };
|
||||
});
|
||||
db.prepare("UPDATE nodes SET launch_wrapper = ? WHERE id = ?").run(JSON.stringify(["wrapper", "--"]), nodeId);
|
||||
const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Change budget" }))
|
||||
.toMatchObject({ ok: true, changed: true, from: "pi", to: "pi", claimsMoved: 1, recoveryPending: false });
|
||||
expect(nodeRow()).toMatchObject({ runtime: "pi", model: "mistral/devstral", effort: "high", effort_choice: "max" });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome);
|
||||
expect(piAccountStateRoot(db, base, SEAT)).toBe(piAccountSwitchStateRoot(base, openSwitch()!.successor_generation));
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxSession: SEAT, tmuxPane: "%5" });
|
||||
// A new adapter (as after a daemon restart) finds the committed occupant's sidecar, not the predecessor's.
|
||||
const reader = new PiRuntimeAdapter({ tmux: fakeTmux as unknown as TmuxAdapter, stateRoot: base,
|
||||
stateRootForSession: session => piAccountStateRoot(db, base, session), runnerEntryPath: "/runner.js",
|
||||
fsOps: { exists: fs.existsSync, readFile: p => fs.readFileSync(p, "utf8"), writeFile: fs.writeFileSync, mkdirp: p => fs.mkdirSync(p, { recursive: true }) } });
|
||||
expect(reader.readSessionFile(SEAT)).toMatchObject({ ok: true, sessionFile: expect.stringContaining("account-switches") });
|
||||
const rigId = (db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string }).rig_id;
|
||||
const snapshot = new SnapshotRepository(db).findLatestRestoreUsable(rigId);
|
||||
expect(snapshot?.kind).toBe("runtime-switch");
|
||||
expect(snapshot?.data.nodes.find(node => node.id === nodeId)).toMatchObject({ accountHome, runtime: "pi" });
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
});
|
||||
|
||||
it("an unconfirmed Pi switch preserves its old account and state root", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', account_home = '/accounts/old' WHERE id = ?").run(nodeId);
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); fs.writeFileSync(path.join(accountHome, "auth.json"), "{}");
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Try account", confirmTimeoutMs: 1 }))
|
||||
.toMatchObject({ ok: false, committed: false });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe("/accounts/old");
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxPane: "%0" });
|
||||
expect(piAccountStateRoot(db, home, SEAT)).toBe(home);
|
||||
});
|
||||
|
||||
it.each([true, false])("clears a Pi selection preserving source credentials and the original login (configured home: %s)", async configured => {
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login");
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', account_home = ?, env_json = ? WHERE id = ?")
|
||||
.run(accountHome, JSON.stringify(configured ? { PI_CODING_AGENT_DIR: "/accounts/configured" } : {}), nodeId);
|
||||
const base = path.join(home, "state", "pi");
|
||||
const original = piSeatPaths(base, SEAT).agentDir;
|
||||
fs.mkdirSync(original, { recursive: true });
|
||||
fs.writeFileSync(path.join(original, "auth.json"), "original-seat-login");
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(binding.accountHome).toBeNull();
|
||||
const stagedAuth = path.join(piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT).agentDir, "auth.json");
|
||||
if (configured) expect(fs.existsSync(stagedAuth)).toBe(false);
|
||||
else expect(fs.readFileSync(stagedAuth, "utf8")).toBe("original-seat-login");
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome);
|
||||
return { ok: true };
|
||||
});
|
||||
const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", clearAccountHome: true, reason: "Back to configured" })).toMatchObject({ ok: true, changed: true, accountHome: null });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(configured ? "/accounts/configured" : undefined);
|
||||
expect(fs.readFileSync(path.join(accountHome, "auth.json"), "utf8")).toBe("selected-login");
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", clearAccountHome: true, reason: "Already cleared" })).toMatchObject({ ok: true, changed: false });
|
||||
});
|
||||
|
||||
it("previews Pi account costs without claiming a runtime change or lost model and effort", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max' WHERE id = ?").run(nodeId);
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "synthetic-auth");
|
||||
const result = await service({ runtimeAdapters: { pi: adapter("pi") } }).setRuntime({
|
||||
seatRef: SEAT, runtime: "pi", accountHome, reason: "Change account", dryRun: true,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, dryRun: true, from: "pi", to: "pi" });
|
||||
if (!result.ok || !result.dryRun) throw new Error("Expected switch preview");
|
||||
expect(result.costs.join("\n")).toContain("keeps its model and saved effort choice");
|
||||
expect(result.costs.join("\n")).not.toMatch(/change_runtime|settings are cleared/);
|
||||
expect(result.settings.model.value).toBe("mistral/devstral");
|
||||
expect(result.settings.effort.value).toBe("max");
|
||||
});
|
||||
|
||||
it("refuses Pi homes without credentials before staging, and keeps cross-runtime Pi switches out of scope", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome: home, reason: "No auth", dryRun: true })).toMatchObject({ ok: false, code: "invalid_runtime_settings" });
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "codex", reason: "Change runtime", dryRun: true })).toMatchObject({ ok: false, code: "runtime_switch_unsupported" });
|
||||
expect(fakeTmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches a Codex seat to another account home on the same runtime, committing only after confirmation", async () => {
|
||||
const accountHome = path.join(home, "api-account");
|
||||
fs.mkdirSync(accountHome);
|
||||
@@ -244,19 +351,21 @@ describe("live seat runtime switch", () => {
|
||||
expect(nodeRow().runtime).toBe("codex");
|
||||
});
|
||||
|
||||
it("the provider-switch route plans the same-runtime account selection through the native switch", async () => {
|
||||
it.each(["codex", "pi"])("the provider-switch route plans a %s account selection and clear", async runtime => {
|
||||
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(runtime, nodeId);
|
||||
const accountHome = path.join(home, "target-account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "{}");
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
for (const [key, value] of Object.entries({ db, rigRepo, sessionRegistry, eventBus, queueRepo,
|
||||
discoveryRepo: new DiscoveryRepository(db), tmuxAdapter: fakeTmux, runtimeAdapters: { codex: adapter("codex") } })) c.set(key as never, value as never);
|
||||
discoveryRepo: new DiscoveryRepository(db), tmuxAdapter: fakeTmux, runtimeAdapters: { [runtime]: adapter(runtime) } })) c.set(key as never, value as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/provider", providerRoutes());
|
||||
const response = await app.request("/api/provider/switch", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seat: SEAT, toAccount: "api", accountHome, reason: "Use API budget", dryRun: true }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ ok: true, dryRun: true, from: "codex", to: "codex", accountHome });
|
||||
expect(await response.json()).toMatchObject({ ok: true, dryRun: true, from: runtime, to: runtime, accountHome });
|
||||
expect(openSwitch()).toBeUndefined();
|
||||
const cleared = await app.request("/api/provider/switch", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seat: SEAT, clearAccountHome: true, reason: "Return to configured login", dryRun: true }) });
|
||||
@@ -631,9 +740,9 @@ describe("live seat runtime switch", () => {
|
||||
guidance: expect.stringMatching(/start it first.*never started.*declare the other runtime.*rig spec.*doesn't carry this seat's edges, settings or queued work/s) });
|
||||
});
|
||||
it("a seat on another runtime", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
db.prepare("UPDATE nodes SET runtime = 'omp' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
.toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can switch/) });
|
||||
.toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can change runtime/) });
|
||||
});
|
||||
it("a target this daemon can't start", async () => {
|
||||
expect(await service({ runtimeAdapters: { codex: adapter("codex") } }).setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
@@ -689,6 +798,18 @@ describe("live seat runtime switch", () => {
|
||||
expect(openSwitch()).toMatchObject({ phase: "finished" });
|
||||
});
|
||||
|
||||
it.each(["preparing", "confirmed", "committed"] as const)("recovers a Pi account switch in phase %s from its durable decision", async phase => {
|
||||
record(phase);
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', account_home = ? WHERE id = ?").run(phase === "committed" ? "/accounts/new" : "/accounts/old", nodeId);
|
||||
db.exec("UPDATE seat_runtime_switches SET from_runtime = 'pi', to_runtime = 'pi'");
|
||||
panes.set(SEAT, phase === "preparing" ? "%0" : "%5");
|
||||
panes.set("openrig-switch-sw1", phase === "preparing" ? "%5" : "%0");
|
||||
expect(await service().recover(nodeId)).toMatchObject({ ok: true });
|
||||
expect(panes.get(SEAT)).toBe(phase === "committed" ? "%5" : "%0");
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(phase === "committed" ? "/accounts/new" : "/accounts/old");
|
||||
expect(piAccountStateRoot(db, home, SEAT)).toBe(phase === "committed" ? piAccountSwitchStateRoot(home, "g-next") : home);
|
||||
});
|
||||
|
||||
it("panes that match neither side: nothing is touched, and the seat's status says why", async () => {
|
||||
record("preparing");
|
||||
panes.set(SEAT, "%9");
|
||||
|
||||
@@ -129,12 +129,13 @@ describe("SeatStatusService", () => {
|
||||
own: rigRepo.addNode(rig.id, "dev.own", { runtime: "codex" }),
|
||||
rel: rigRepo.addNode(rig.id, "dev.rel", { runtime: "codex" }),
|
||||
plain: rigRepo.addNode(rig.id, "dev.plain", { runtime: "claude-code" }),
|
||||
pi: rigRepo.addNode(rig.id, "dev.pi", { runtime: "pi" }),
|
||||
term: rigRepo.addNode(rig.id, "dev.term", { runtime: "terminal" }),
|
||||
};
|
||||
for (const [name, node] of Object.entries(nodes)) sessionRegistry.registerSession(node.id, `dev-${name}@home-rig`);
|
||||
db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?").run(JSON.stringify({ CODEX_HOME: "/homes/own" }), nodes.own.id);
|
||||
db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?").run(JSON.stringify({ CODEX_HOME: "rel/codex" }), nodes.rel.id);
|
||||
const svc = new SeatStatusService({ rigRepo, env: { HOME: "/users/op", CODEX_HOME: "/daemon/codex" } });
|
||||
const svc = new SeatStatusService({ rigRepo, env: { HOME: "/users/op", CODEX_HOME: "/daemon/codex", PI_CODING_AGENT_DIR: "/daemon/pi-ignored" } });
|
||||
const status = (ref: string) => {
|
||||
const result = svc.getStatus(ref);
|
||||
if (!result.ok) throw new Error(result.code);
|
||||
@@ -144,6 +145,7 @@ describe("SeatStatusService", () => {
|
||||
expect(status("dev-rel@home-rig").runtime_home).toEqual({ variable: "CODEX_HOME", home: "/daemon/codex", source: "daemon",
|
||||
ignored: { source: "member", value: "rel/codex" } });
|
||||
expect(status("dev-plain@home-rig").runtime_home).toEqual({ variable: "CLAUDE_CONFIG_DIR", home: "/users/op/.claude", source: "native_default" });
|
||||
expect(status("dev-pi@home-rig").runtime_home).toEqual({ variable: "PI_CODING_AGENT_DIR", home: null, source: "native_default" });
|
||||
expect(status("dev-term@home-rig")).not.toHaveProperty("runtime_home");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user