@phnx-labs/agents-cli 1.22.103 → 1.22.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +2 -2
  4. package/dist/commands/computer.d.ts +100 -79
  5. package/dist/commands/computer.js +290 -830
  6. package/dist/commands/run-device-picker.d.ts +58 -0
  7. package/dist/commands/run-device-picker.js +222 -0
  8. package/dist/commands/setup-computer.js +20 -1
  9. package/dist/commands/setup-secrets.d.ts +2 -2
  10. package/dist/commands/setup-secrets.js +1 -1
  11. package/dist/commands/view.js +4 -6
  12. package/dist/lib/account-catalog.d.ts +22 -1
  13. package/dist/lib/account-catalog.js +72 -38
  14. package/dist/lib/accounting/usage.js +18 -14
  15. package/dist/lib/agent-spec/agents.d.ts +1 -0
  16. package/dist/lib/agent-spec/agents.js +1 -1
  17. package/dist/lib/browser/drivers/ssh.js +1 -1
  18. package/dist/lib/computer/context.d.ts +83 -0
  19. package/dist/lib/computer/context.js +91 -0
  20. package/dist/lib/computer/policy.d.ts +46 -0
  21. package/dist/lib/computer/policy.js +160 -0
  22. package/dist/lib/computer/record.d.ts +38 -0
  23. package/dist/lib/computer/record.js +86 -0
  24. package/dist/lib/computer/sessions-list.js +6 -6
  25. package/dist/lib/computer-client.d.ts +150 -0
  26. package/dist/lib/computer-client.js +222 -0
  27. package/dist/lib/exec.js +35 -1
  28. package/dist/lib/harness/adapters/claude.d.ts +37 -0
  29. package/dist/lib/harness/adapters/claude.js +69 -0
  30. package/dist/lib/helper-download.d.ts +1 -1
  31. package/dist/lib/helper-download.js +1 -1
  32. package/dist/lib/helper-versions.d.ts +9 -4
  33. package/dist/lib/helper-versions.js +8 -8
  34. package/dist/lib/installations/shims.js +8 -4
  35. package/dist/lib/menubar/download-menubar.d.ts +2 -1
  36. package/dist/lib/menubar/download-menubar.js +2 -1
  37. package/dist/lib/secrets-client.d.ts +3 -3
  38. package/dist/lib/secrets-client.js +15 -38
  39. package/dist/lib/session/db.js +1 -1
  40. package/dist/lib/sha256-asset.d.ts +2 -1
  41. package/dist/lib/sha256-asset.js +2 -1
  42. package/dist/lib/ssh-tunnel.d.ts +61 -0
  43. package/dist/lib/ssh-tunnel.js +105 -0
  44. package/dist/lib/summarizer/summarize.d.ts +2 -2
  45. package/dist/lib/summarizer/summarize.js +10 -3
  46. package/package.json +2 -3
  47. package/dist/commands/computer-actions.d.ts +0 -55
  48. package/dist/commands/computer-actions.js +0 -594
  49. package/dist/computer.d.ts +0 -2
  50. package/dist/computer.js +0 -7
  51. package/dist/lib/computer/actions.d.ts +0 -36
  52. package/dist/lib/computer/actions.js +0 -162
  53. package/dist/lib/computer/computer-rpc.d.ts +0 -39
  54. package/dist/lib/computer/computer-rpc.js +0 -447
  55. package/dist/lib/computer/des.d.ts +0 -1
  56. package/dist/lib/computer/des.js +0 -114
  57. package/dist/lib/computer/dispatch.d.ts +0 -10
  58. package/dist/lib/computer/dispatch.js +0 -133
  59. package/dist/lib/computer/download.d.ts +0 -54
  60. package/dist/lib/computer/download.js +0 -83
  61. package/dist/lib/computer/loop.d.ts +0 -62
  62. package/dist/lib/computer/loop.js +0 -98
  63. package/dist/lib/computer/model.d.ts +0 -44
  64. package/dist/lib/computer/model.js +0 -157
  65. package/dist/lib/computer/rfb-client.d.ts +0 -53
  66. package/dist/lib/computer/rfb-client.js +0 -562
  67. package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
  68. package/dist/lib/computer/ssh-tunnel.js +0 -584
@@ -0,0 +1,150 @@
1
+ /**
2
+ * computer-client.ts — the ONE process client through which agents-cli talks to
3
+ * the standalone `computer` CLI (PHNX-4075).
4
+ *
5
+ * This is the agents-owned half of the computer extraction, and it is
6
+ * deliberately small. agents-cli no longer carries a helper daemon, an RPC
7
+ * transport, an element cache, an RFB client, or an autonomous loop — the
8
+ * standalone engine owns all of it, exactly as `secrets` took the keychain
9
+ * engine (PHNX-3989) and `sessions` took the transcript engine (PHNX-4012).
10
+ * What stays here is what only the fleet CLI can know: which apps the
11
+ * permissions layer allows, which device a `--device` name resolves to, who the
12
+ * acting session is, and where an action must be recorded.
13
+ *
14
+ * THERE IS NO FALLBACK. A missing executable throws `COMPUTER_BIN_MISSING` with
15
+ * install guidance (DIST-1) rather than silently driving a bundled engine —
16
+ * agents-cli has none to drive, and a fallback would re-couple the two release
17
+ * trains this extraction exists to separate.
18
+ *
19
+ * Transport — inherited-fd passthrough, not request/response:
20
+ *
21
+ * The engine's ENVIRONMENT is inherited verbatim — no overlay. Transport
22
+ * selection (`COMPUTER_HELPER_TCP`, `COMPUTER_HELPER_VNC`,
23
+ * `COMPUTER_HELPER_SOCKET`) is the engine's: it opens the `--device` tunnel and
24
+ * hydrates its own endpoint AND the auth token that goes with it. Publishing a
25
+ * bare endpoint from here would hand the daemon a connection it then rejects
26
+ * with `auth_failed`.
27
+ *
28
+ * - stdio 0/1/2 are INHERITED. The engine owns the user's terminal: its
29
+ * stdout is the command's stdout, its `--json` is the command's `--json`,
30
+ * its prompts reach a real tty. agents-cli never re-formats engine output,
31
+ * which is what keeps the surface honest as the engine evolves.
32
+ * - fd 3 (`COMPUTER_CONTEXT_FD`) carries ONE JSON object — the consumer
33
+ * context built by `lib/computer/context.ts` — written and closed
34
+ * immediately, so the engine reads to EOF and proceeds.
35
+ * - fd 4 (`COMPUTER_EVENTS_FD`) carries NDJSON action events back: one JSON
36
+ * object per line, each an action the engine actually performed. agents-cli
37
+ * turns those into feed events and `sessions --computer` history
38
+ * (`lib/computer/record.ts`). The engine may emit none; it must never block
39
+ * on this pipe.
40
+ *
41
+ * Both fds are anonymous pipes on the child's side, the same shape
42
+ * `secrets-client.ts` settled on after a named FIFO wedged macOS reads. The
43
+ * context is pushed rather than pulled so the engine needs no callback into
44
+ * agents-cli — one direction each way, no reentrancy.
45
+ */
46
+ /** fd the engine reads its one-shot JSON context from. */
47
+ export declare const COMPUTER_CONTEXT_FD = 3;
48
+ /** fd the engine writes NDJSON action events to. */
49
+ export declare const COMPUTER_EVENTS_FD = 4;
50
+ export declare class ComputerClientError extends Error {
51
+ code: string;
52
+ constructor(code: string, message: string);
53
+ }
54
+ export declare function isComputerClientError(err: unknown): err is ComputerClientError;
55
+ export declare function isStandaloneComputer(bin: string): boolean;
56
+ /**
57
+ * Resolve the standalone executable. `COMPUTER_BIN` wins so a dev build can be
58
+ * driven without touching PATH.
59
+ *
60
+ * Resolution uses `findInPath`, which skips `~/.agents/.cache/shims`. That skip
61
+ * is load-bearing here for the same reason it is in `sessions-client.ts`: a
62
+ * leftover `computer` alias shim execs `agents computer`, and resolving it would
63
+ * recurse into this process (the 1.22.85 secrets fork bomb, agi-cli#3532).
64
+ */
65
+ export declare function resolveComputerBin(): string;
66
+ /** A `.js` bin is run through this runtime; a real executable is exec'd directly. */
67
+ export declare function invocation(bin: string): {
68
+ command: string;
69
+ prefix: string[];
70
+ };
71
+ /**
72
+ * One action the engine performed, as it appears on the NDJSON events fd.
73
+ *
74
+ * This is the engine's wire shape, not a translation of it: the engine emits
75
+ * `{event: "computer.action", command, invocationId, pid, targetPid, bundle,
76
+ * host, task, sessionId, launchId, actor}`. `command` — not `verb` — is the
77
+ * field that names the action, and it is what marks a line as an action event.
78
+ */
79
+ export interface ComputerActionEvent {
80
+ /** Always `computer.action` on this stream. */
81
+ event?: string;
82
+ /** The verb the engine ran (`click`, `type`, `screenshot`, …). */
83
+ command: string;
84
+ /** The engine's own id for this run — the grouping key for a session row. */
85
+ invocationId?: string;
86
+ /** The engine process's pid. */
87
+ pid?: number;
88
+ /** pid of the app the action targeted, when the engine resolved one. */
89
+ targetPid?: number;
90
+ /** Bundle id / app identifier the action targeted. */
91
+ bundle?: string;
92
+ /** The driven device for a `--device` invocation; absent when local. */
93
+ host?: string;
94
+ /** `run --task` description, only on the task marker. */
95
+ task?: string;
96
+ /** Identity, echoed back from the context this CLI handed the engine. */
97
+ sessionId?: string;
98
+ launchId?: string;
99
+ actor?: string;
100
+ /** Free-form detail the engine attaches (coordinates, text length, …). */
101
+ [key: string]: unknown;
102
+ }
103
+ /**
104
+ * Split a growing buffer into complete NDJSON lines. Pure so the framing rules —
105
+ * blank lines skipped, a non-JSON line dropped rather than crashing the CLI, a
106
+ * trailing partial line carried forward — are unit-testable without a spawn.
107
+ *
108
+ * A malformed line is dropped, not thrown: these events are telemetry riding
109
+ * alongside a user-visible action that already happened. Failing the command
110
+ * because its receipt was unreadable would be strictly worse than losing the
111
+ * receipt. The action itself already failed loud on its own channel if it failed.
112
+ */
113
+ export declare function parseEventLines(buffer: string): {
114
+ events: ComputerActionEvent[];
115
+ rest: string;
116
+ };
117
+ export interface RunComputerOptions {
118
+ /** argv handed to the standalone, after the program name. */
119
+ argv: string[];
120
+ /** The consumer context serialized onto fd 3. */
121
+ context: unknown;
122
+ /** Called once per action event the engine reports on fd 4. */
123
+ onEvent?: (event: ComputerActionEvent) => void;
124
+ /**
125
+ * Capture the engine's stdout instead of inheriting the terminal.
126
+ *
127
+ * Used only where agents-cli must READ an answer rather than show it — the
128
+ * `agents setup computer` wizard polling `status --json` for trust. Verbs
129
+ * never capture: re-printing engine output would make agents-cli a formatter
130
+ * for a surface it no longer owns.
131
+ */
132
+ capture?: boolean;
133
+ }
134
+ export interface RunComputerResult {
135
+ exitCode: number;
136
+ /** Engine stdout, only when `capture` was set. */
137
+ stdout: string;
138
+ }
139
+ /**
140
+ * Run the standalone engine with the consumer context on fd 3 and the action
141
+ * event stream on fd 4. Resolves with the engine's exit code; the caller
142
+ * propagates it so `agents computer` exits exactly as the engine did.
143
+ *
144
+ * Throws `COMPUTER_BIN_MISSING` when the standalone is not installed. Every
145
+ * other failure is the engine's own, reported on the inherited stderr.
146
+ */
147
+ export declare function runComputer(opts: RunComputerOptions): Promise<RunComputerResult>;
148
+ /** Test seam: drop the memoized bin so PATH fixtures can re-resolve. */
149
+ export declare function _resetComputerClientForTest(): void;
150
+ export declare const COMPUTER_INSTALL_HINT = "npm i -g @phnx-labs/computer-cli";
@@ -0,0 +1,222 @@
1
+ /**
2
+ * computer-client.ts — the ONE process client through which agents-cli talks to
3
+ * the standalone `computer` CLI (PHNX-4075).
4
+ *
5
+ * This is the agents-owned half of the computer extraction, and it is
6
+ * deliberately small. agents-cli no longer carries a helper daemon, an RPC
7
+ * transport, an element cache, an RFB client, or an autonomous loop — the
8
+ * standalone engine owns all of it, exactly as `secrets` took the keychain
9
+ * engine (PHNX-3989) and `sessions` took the transcript engine (PHNX-4012).
10
+ * What stays here is what only the fleet CLI can know: which apps the
11
+ * permissions layer allows, which device a `--device` name resolves to, who the
12
+ * acting session is, and where an action must be recorded.
13
+ *
14
+ * THERE IS NO FALLBACK. A missing executable throws `COMPUTER_BIN_MISSING` with
15
+ * install guidance (DIST-1) rather than silently driving a bundled engine —
16
+ * agents-cli has none to drive, and a fallback would re-couple the two release
17
+ * trains this extraction exists to separate.
18
+ *
19
+ * Transport — inherited-fd passthrough, not request/response:
20
+ *
21
+ * The engine's ENVIRONMENT is inherited verbatim — no overlay. Transport
22
+ * selection (`COMPUTER_HELPER_TCP`, `COMPUTER_HELPER_VNC`,
23
+ * `COMPUTER_HELPER_SOCKET`) is the engine's: it opens the `--device` tunnel and
24
+ * hydrates its own endpoint AND the auth token that goes with it. Publishing a
25
+ * bare endpoint from here would hand the daemon a connection it then rejects
26
+ * with `auth_failed`.
27
+ *
28
+ * - stdio 0/1/2 are INHERITED. The engine owns the user's terminal: its
29
+ * stdout is the command's stdout, its `--json` is the command's `--json`,
30
+ * its prompts reach a real tty. agents-cli never re-formats engine output,
31
+ * which is what keeps the surface honest as the engine evolves.
32
+ * - fd 3 (`COMPUTER_CONTEXT_FD`) carries ONE JSON object — the consumer
33
+ * context built by `lib/computer/context.ts` — written and closed
34
+ * immediately, so the engine reads to EOF and proceeds.
35
+ * - fd 4 (`COMPUTER_EVENTS_FD`) carries NDJSON action events back: one JSON
36
+ * object per line, each an action the engine actually performed. agents-cli
37
+ * turns those into feed events and `sessions --computer` history
38
+ * (`lib/computer/record.ts`). The engine may emit none; it must never block
39
+ * on this pipe.
40
+ *
41
+ * Both fds are anonymous pipes on the child's side, the same shape
42
+ * `secrets-client.ts` settled on after a named FIFO wedged macOS reads. The
43
+ * context is pushed rather than pulled so the engine needs no callback into
44
+ * agents-cli — one direction each way, no reentrancy.
45
+ */
46
+ import { spawn } from 'node:child_process';
47
+ import { realpathSync, existsSync } from 'node:fs';
48
+ import * as path from 'node:path';
49
+ import { findInPath } from './agent-spec/agents.js';
50
+ const INSTALL_HINT = 'npm i -g @phnx-labs/computer-cli';
51
+ /** fd the engine reads its one-shot JSON context from. */
52
+ export const COMPUTER_CONTEXT_FD = 3;
53
+ /** fd the engine writes NDJSON action events to. */
54
+ export const COMPUTER_EVENTS_FD = 4;
55
+ export class ComputerClientError extends Error {
56
+ code;
57
+ constructor(code, message) {
58
+ super(message);
59
+ this.code = code;
60
+ this.name = 'ComputerClientError';
61
+ }
62
+ }
63
+ export function isComputerClientError(err) {
64
+ return err instanceof ComputerClientError;
65
+ }
66
+ let cachedBin;
67
+ function computerEntrypoint(bin) {
68
+ if (!/\.(cmd|ps1)$/i.test(bin))
69
+ return bin;
70
+ const launcher = path.join(path.dirname(bin), 'node_modules', '@phnx-labs', 'computer-cli', 'bin', 'computer.cjs');
71
+ return existsSync(launcher) ? launcher : bin;
72
+ }
73
+ export function isStandaloneComputer(bin) {
74
+ let real = computerEntrypoint(bin);
75
+ try {
76
+ real = realpathSync(real);
77
+ }
78
+ catch { /* spawn reports missing explicit paths */ }
79
+ return !/\.(cmd|ps1)$/i.test(real) && !real.endsWith(path.join('dist', 'computer.js'));
80
+ }
81
+ /**
82
+ * Resolve the standalone executable. `COMPUTER_BIN` wins so a dev build can be
83
+ * driven without touching PATH.
84
+ *
85
+ * Resolution uses `findInPath`, which skips `~/.agents/.cache/shims`. That skip
86
+ * is load-bearing here for the same reason it is in `sessions-client.ts`: a
87
+ * leftover `computer` alias shim execs `agents computer`, and resolving it would
88
+ * recurse into this process (the 1.22.85 secrets fork bomb, agi-cli#3532).
89
+ */
90
+ export function resolveComputerBin() {
91
+ if (cachedBin)
92
+ return cachedBin;
93
+ const explicit = process.env.COMPUTER_BIN?.trim();
94
+ const resolved = explicit && explicit.length > 0 ? (isStandaloneComputer(explicit) ? explicit : null) : findInPath('computer', { accept: isStandaloneComputer });
95
+ if (!resolved) {
96
+ throw new ComputerClientError('COMPUTER_BIN_MISSING', 'The standalone `computer` CLI was not found. Install it with:\n' +
97
+ ` ${INSTALL_HINT}\n` +
98
+ 'or point $COMPUTER_BIN at its executable.');
99
+ }
100
+ cachedBin = computerEntrypoint(resolved);
101
+ return cachedBin;
102
+ }
103
+ /** A `.js` bin is run through this runtime; a real executable is exec'd directly. */
104
+ export function invocation(bin) {
105
+ if (/\.[mc]?js$/.test(bin))
106
+ return { command: process.execPath, prefix: [bin] };
107
+ return { command: bin, prefix: [] };
108
+ }
109
+ /**
110
+ * Split a growing buffer into complete NDJSON lines. Pure so the framing rules —
111
+ * blank lines skipped, a non-JSON line dropped rather than crashing the CLI, a
112
+ * trailing partial line carried forward — are unit-testable without a spawn.
113
+ *
114
+ * A malformed line is dropped, not thrown: these events are telemetry riding
115
+ * alongside a user-visible action that already happened. Failing the command
116
+ * because its receipt was unreadable would be strictly worse than losing the
117
+ * receipt. The action itself already failed loud on its own channel if it failed.
118
+ */
119
+ export function parseEventLines(buffer) {
120
+ const events = [];
121
+ const parts = buffer.split('\n');
122
+ const rest = parts.pop() ?? '';
123
+ for (const line of parts) {
124
+ const trimmed = line.trim();
125
+ if (!trimmed)
126
+ continue;
127
+ try {
128
+ const parsed = JSON.parse(trimmed);
129
+ if (parsed && typeof parsed === 'object' && typeof parsed.command === 'string') {
130
+ events.push(parsed);
131
+ }
132
+ }
133
+ catch {
134
+ // Unreadable receipt — see above.
135
+ }
136
+ }
137
+ return { events, rest };
138
+ }
139
+ /**
140
+ * Run the standalone engine with the consumer context on fd 3 and the action
141
+ * event stream on fd 4. Resolves with the engine's exit code; the caller
142
+ * propagates it so `agents computer` exits exactly as the engine did.
143
+ *
144
+ * Throws `COMPUTER_BIN_MISSING` when the standalone is not installed. Every
145
+ * other failure is the engine's own, reported on the inherited stderr.
146
+ */
147
+ export async function runComputer(opts) {
148
+ const bin = resolveComputerBin();
149
+ const { command, prefix } = invocation(bin);
150
+ const child = spawn(command, [...prefix, ...opts.argv], {
151
+ stdio: ['inherit', opts.capture ? 'pipe' : 'inherit', 'inherit', 'pipe', 'pipe'],
152
+ env: {
153
+ ...process.env,
154
+ COMPUTER_CONTEXT_FD: String(COMPUTER_CONTEXT_FD),
155
+ COMPUTER_EVENTS_FD: String(COMPUTER_EVENTS_FD),
156
+ },
157
+ });
158
+ const contextPipe = child.stdio[COMPUTER_CONTEXT_FD];
159
+ const eventsPipe = child.stdio[COMPUTER_EVENTS_FD];
160
+ if (contextPipe) {
161
+ // An engine that exits before reading the context (bad argv, `--help`)
162
+ // closes fd 3 and we get EPIPE. That is a normal race, not a failure.
163
+ contextPipe.on('error', () => { });
164
+ contextPipe.end(JSON.stringify(opts.context) + '\n');
165
+ }
166
+ let stdout = '';
167
+ if (opts.capture && child.stdout) {
168
+ child.stdout.setEncoding('utf-8');
169
+ child.stdout.on('data', (chunk) => {
170
+ stdout += chunk;
171
+ });
172
+ }
173
+ let pending = '';
174
+ const drained = new Promise((resolve) => {
175
+ if (!eventsPipe)
176
+ return resolve();
177
+ eventsPipe.setEncoding('utf-8');
178
+ eventsPipe.on('data', (chunk) => {
179
+ const { events, rest } = parseEventLines(pending + chunk);
180
+ pending = rest;
181
+ for (const event of events)
182
+ opts.onEvent?.(event);
183
+ });
184
+ eventsPipe.on('error', () => resolve());
185
+ eventsPipe.on('end', () => {
186
+ // A final line with no trailing newline still counts.
187
+ const { events } = parseEventLines(pending.endsWith('\n') ? pending : pending + '\n');
188
+ pending = '';
189
+ for (const event of events)
190
+ opts.onEvent?.(event);
191
+ resolve();
192
+ });
193
+ });
194
+ const exitCode = await new Promise((resolve, reject) => {
195
+ child.on('error', (err) => {
196
+ reject(new ComputerClientError('COMPUTER_SPAWN_FAILED', `Could not run \`${bin}\`: ${err.message}`));
197
+ });
198
+ child.on('close', (code, signal) => {
199
+ // A signalled child has no exit code; report the conventional 128+n so
200
+ // callers and shells see a non-zero status instead of a false success.
201
+ if (code == null)
202
+ return resolve(signal ? 128 + (osSignalNumber(signal) ?? 0) : 1);
203
+ resolve(code);
204
+ });
205
+ });
206
+ await drained;
207
+ return { exitCode, stdout };
208
+ }
209
+ /** Signal name → number for the 128+n exit convention. Only the ones a tunnelled
210
+ * or interrupted engine realistically dies on; anything else contributes 0 and
211
+ * still yields a non-zero status. */
212
+ function osSignalNumber(signal) {
213
+ const table = {
214
+ SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGPIPE: 13, SIGTERM: 15,
215
+ };
216
+ return table[signal];
217
+ }
218
+ /** Test seam: drop the memoized bin so PATH fixtures can re-resolve. */
219
+ export function _resetComputerClientForTest() {
220
+ cachedBin = undefined;
221
+ }
222
+ export const COMPUTER_INSTALL_HINT = INSTALL_HINT;
package/dist/lib/exec.js CHANGED
@@ -18,7 +18,7 @@ import { emit, createTimer, redactPrompt, redactArgs } from './feed/events.js';
18
18
  import { sanitizeProcessEnv } from './secrets-client.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
20
  import { expandLocalHome } from './project-root.js';
21
- import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
21
+ import { getShimsDir, getHistoryDir, getUserAgentsDir, getRuntimeStateDir } from './state.js';
22
22
  import { readCodexConfiguredModel } from './installations/shims.js';
23
23
  import { withInstallationLease } from './installations/launch-gate.js';
24
24
  import { getCliLaunch, getAgentsBinPath } from './cli-entry.js';
@@ -41,6 +41,7 @@ import { applyAddDirs } from './add-dir.js';
41
41
  import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
42
42
  import { applySystemResourcesAtRun } from './system-run-sync.js';
43
43
  import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
44
+ import { claudeWorkerLoginTrapPreflight } from './harness/adapters/claude.js';
44
45
  import { resolveConfigVersion } from './harness/exec-config-version.js';
45
46
  import { getAccountInfo } from './agents.js';
46
47
  import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
@@ -399,6 +400,13 @@ export function buildExecEnv(options) {
399
400
  result.AGENTS_PARENT_SESSION_ID = spawnerSessionId;
400
401
  }
401
402
  result.AGENTS_RUNTIME = resolveInteractive(options) ? 'terminal' : 'headless';
403
+ // The agent's own `secrets …` calls must hit the same store agents-cli reads
404
+ // for it: the process client points the standalone at the user agents dir
405
+ // (buildServeEnv, MIG-1), while a bare `secrets` defaults to ~/.secrets. Left
406
+ // unset, an agent's `secrets exec` ran against a second broker and a second
407
+ // event log, and an unlock in one was invisible in the other. An explicit
408
+ // SECRETS_HOME in the launching environment wins, matching buildServeEnv.
409
+ result.SECRETS_HOME = result.SECRETS_HOME ?? getUserAgentsDir();
402
410
  // Durable SessionStart metadata. The hook joins these launch facts to the
403
411
  // harness-provided real session id and writes them under the shared history
404
412
  // directory, so a later resume can restore the permission boundary without
@@ -1797,6 +1805,32 @@ async function spawnAgentLeased(options) {
1797
1805
  }
1798
1806
  }
1799
1807
  }
1808
+ // Fail loud before an INTERACTIVE claude run on a worker with no synced
1809
+ // credential falls through to Claude Code's "Select login method" screen — an
1810
+ // interactive OAuth on a headless box that never persists, the 10-minute
1811
+ // re-login loop (PHNX-3502 sibling). A worker authenticates from the
1812
+ // setup-token; when none resolves for the selected account, refuse with the
1813
+ // real fix instead of the useless login prompt. Headless already fails loud
1814
+ // (401), so the gate is interactive-only (the preflight enforces that).
1815
+ if (options.agent === 'claude') {
1816
+ const { versionHome } = resolveExecConfigHome(options);
1817
+ // A credential reaches the child if the worker setup-token resolves for the
1818
+ // selected account OR the caller passed an explicit --env override
1819
+ // (buildExecEnv merges options.env last, so it wins even the worker strip).
1820
+ const hasWorkerCredential = Boolean(options.env?.CLAUDE_CODE_OAUTH_TOKEN) ||
1821
+ (versionHome ? resolveClaudeSetupToken(versionHome) !== null : false);
1822
+ const loginTrap = claudeWorkerLoginTrapPreflight({
1823
+ agent: options.agent,
1824
+ interactive,
1825
+ deviceRole: selfConfiguredDeviceRole(),
1826
+ hasWorkerCredential,
1827
+ machine: machineId(),
1828
+ });
1829
+ if (loginTrap) {
1830
+ process.stderr.write(`\x1b[31m${loginTrap}\x1b[0m\n`);
1831
+ return { exitCode: 1, stdout: '', stderr: loginTrap };
1832
+ }
1833
+ }
1800
1834
  // Budget live kill-switch (issue #346). For headless runs we incrementally
1801
1835
  // parse stream-json usage off stdout, accumulate cost, and kill the child the
1802
1836
  // moment a configured cap is crossed — exactly like the --timeout path, but
@@ -1,2 +1,39 @@
1
+ import type { AgentId } from '../../types.js';
1
2
  import type { HarnessAdapter } from '../adapter.js';
3
+ import { type ConfiguredDeviceRole } from '../../device-config.js';
2
4
  export declare const claudeAdapter: HarnessAdapter;
5
+ /**
6
+ * Fail-loud preflight for the worker login-screen trap — the sibling of the
7
+ * PHNX-3502 fix. On an EXPLICIT `role: worker` device every Claude run
8
+ * authenticates from the synced `setup-token`, never an interactive login (owner
9
+ * rule / credential-management invariant 7). When NO setup-token resolves for the
10
+ * account this run selected, `applyExecConfigEnv` strips any ambient token and
11
+ * the harness launches with no credential. A HEADLESS run then fails loud with a
12
+ * 401 — but an INTERACTIVE dispatched TUI (`agents run claude --interactive
13
+ * --device <worker>`, the usual `--device auto` landing) instead drops to Claude
14
+ * Code's own "Select login method" screen. Answering it does an interactive OAuth
15
+ * on a headless box, minting a native login the worker path never reads and never
16
+ * syncs; Anthropic later expires it and the next run repeats the prompt — the
17
+ * 10-minute re-login loop the operator sees.
18
+ *
19
+ * This gate refuses that run BEFORE spawn with the real fix (pin or mint a
20
+ * durable account) instead of the useless login prompt. It is interactive-only
21
+ * because the headless 401 is already loud. Pure: the caller (spawnAgentLeased)
22
+ * resolves the inputs and renders the returned message, exactly like
23
+ * codexSandboxPreflight. Returns null when the run may proceed.
24
+ */
25
+ export declare function claudeWorkerLoginTrapPreflight(args: {
26
+ agent: AgentId;
27
+ interactive: boolean;
28
+ deviceRole?: ConfiguredDeviceRole;
29
+ /**
30
+ * Will a Claude credential actually reach the child at spawn? The caller ORs
31
+ * the resolved worker setup-token with an explicit `--env
32
+ * CLAUDE_CODE_OAUTH_TOKEN=…` override — `buildExecEnv` merges `options.env`
33
+ * LAST and unconditionally, so that override wins even the worker branch's
34
+ * strip and authenticates the run. Gating on the setup-token alone would
35
+ * falsely refuse that sanctioned escape hatch (forwarded across `--device`).
36
+ */
37
+ hasWorkerCredential: boolean;
38
+ machine?: string;
39
+ }): string | null;
@@ -111,6 +111,11 @@ if [ "\$(uname -s)" = "Linux" ] && [ -z "\${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ -
111
111
  fi
112
112
  `;
113
113
  },
114
+ // NOTE: the worker branch above strips the token and returns; a MISSING worker
115
+ // credential is caught before spawn by claudeWorkerLoginTrapPreflight (below),
116
+ // which fails loud for an interactive run instead of letting Claude Code fall
117
+ // through to its "Select login method" screen. A headless run keeps the
118
+ // strip-and-401 behavior.
114
119
  routineModeArgs(cmd, ctx) {
115
120
  const mode = ctx.mode;
116
121
  if (mode === 'edit') {
@@ -131,3 +136,67 @@ fi
131
136
  }
132
137
  },
133
138
  };
139
+ /**
140
+ * Fail-loud preflight for the worker login-screen trap — the sibling of the
141
+ * PHNX-3502 fix. On an EXPLICIT `role: worker` device every Claude run
142
+ * authenticates from the synced `setup-token`, never an interactive login (owner
143
+ * rule / credential-management invariant 7). When NO setup-token resolves for the
144
+ * account this run selected, `applyExecConfigEnv` strips any ambient token and
145
+ * the harness launches with no credential. A HEADLESS run then fails loud with a
146
+ * 401 — but an INTERACTIVE dispatched TUI (`agents run claude --interactive
147
+ * --device <worker>`, the usual `--device auto` landing) instead drops to Claude
148
+ * Code's own "Select login method" screen. Answering it does an interactive OAuth
149
+ * on a headless box, minting a native login the worker path never reads and never
150
+ * syncs; Anthropic later expires it and the next run repeats the prompt — the
151
+ * 10-minute re-login loop the operator sees.
152
+ *
153
+ * This gate refuses that run BEFORE spawn with the real fix (pin or mint a
154
+ * durable account) instead of the useless login prompt. It is interactive-only
155
+ * because the headless 401 is already loud. Pure: the caller (spawnAgentLeased)
156
+ * resolves the inputs and renders the returned message, exactly like
157
+ * codexSandboxPreflight. Returns null when the run may proceed.
158
+ */
159
+ export function claudeWorkerLoginTrapPreflight(args) {
160
+ if (args.agent !== 'claude')
161
+ return null;
162
+ // A headless run with no token fails loud with a 401 already; only an
163
+ // interactive run falls through to Claude Code's login screen.
164
+ if (!args.interactive)
165
+ return null;
166
+ // Gate ONLY an EXPLICIT `role: worker` box — not a headed box, and NOT an
167
+ // UNMARKED one. The owner rule guarantees a real worker holds no native login
168
+ // (it authenticates from the synced setup-token), so no token there genuinely
169
+ // means the login screen with nothing behind it. A headed box authenticates
170
+ // from its own native login (its login prompt is the correct first-run flow).
171
+ // An UNMARKED box is deliberately spared: it never receives a synced setup-token
172
+ // (auth-sync pushes only to `role=worker` peers), so it is typically an ordinary
173
+ // machine authenticating from a native login the operator just never marked —
174
+ // gating it would false-refuse every such laptop, a far larger surface than the
175
+ // marked-worker case this closes. `--device auto` lands on an explicit worker
176
+ // once any worker is marked in the fleet (filterAutoPool), which is the primary
177
+ // trap; a directly-named `--device <unmarked-box>` is left as it was on main
178
+ // (unprotected — a bootstrap-window residual, not a regression).
179
+ if (args.deviceRole !== 'worker')
180
+ return null;
181
+ // A credential will reach the child (worker setup-token, or an explicit
182
+ // --env CLAUDE_CODE_OAUTH_TOKEN override) — proceed.
183
+ if (args.hasWorkerCredential)
184
+ return null;
185
+ const where = args.machine ? `worker '${args.machine}'` : 'this worker';
186
+ return [
187
+ `No Claude worker credential is available on ${where} for the account this run selected.`,
188
+ `A worker authenticates from a synced setup-token, never an interactive login — so`,
189
+ `Claude Code's "Select login method" screen here would not persist (it is the source`,
190
+ `of the repeated re-login). Do one of these instead:`,
191
+ ``,
192
+ ` • Pin a live account for this run: agents run claude#<name> … (e.g. claude#work)`,
193
+ ` • Or set the fleet-wide default: agents accounts default claude <name>`,
194
+ ` • If that account has no token yet, mint it on a HEADED box (e.g. your laptop):`,
195
+ ` agents accounts login claude#<name>`,
196
+ ``,
197
+ // NB: keep this text clear of RATE_LIMIT_PATTERNS (exec.ts) — this string is
198
+ // returned as spawn stderr, which runWithFallback scans; a stray "rate limit"
199
+ // / "quota" phrase here would spuriously trigger an account-rotation fallback.
200
+ `See which accounts are LIVE (signed in, with headroom) with: agents accounts list`,
201
+ ].join('\n');
202
+ }
@@ -10,7 +10,7 @@
10
10
  * then verifies the code signature (Developer ID Team + notarization — and, for
11
11
  * the menu-bar helper, its designated requirement) before it is ever installed.
12
12
  *
13
- * `computer/download.ts` (ComputerHelper) and `menubar/download-menubar.ts`
13
+ * `menubar/download-menubar.ts`
14
14
  * (MenubarHelper) are thin per-helper wrappers over the primitives here — one
15
15
  * download+verify machinery, two specs — so a fix to the verify/download logic
16
16
  * lands for both helpers at once.
@@ -10,7 +10,7 @@
10
10
  * then verifies the code signature (Developer ID Team + notarization — and, for
11
11
  * the menu-bar helper, its designated requirement) before it is ever installed.
12
12
  *
13
- * `computer/download.ts` (ComputerHelper) and `menubar/download-menubar.ts`
13
+ * `menubar/download-menubar.ts`
14
14
  * (MenubarHelper) are thin per-helper wrappers over the primitives here — one
15
15
  * download+verify machinery, two specs — so a fix to the verify/download logic
16
16
  * lands for both helpers at once.
@@ -27,7 +27,7 @@
27
27
  * never be derived from `getCliVersion()`.
28
28
  */
29
29
  /** The helpers that have their own release train. */
30
- export type HelperName = 'menubar' | 'computer-mac' | 'computer-win';
30
+ export type HelperName = 'menubar';
31
31
  /** One helper's release identity. */
32
32
  export interface HelperRelease {
33
33
  /** Tag prefix — the release is `<tagPrefix>/v<version>`. */
@@ -43,9 +43,14 @@ export interface HelperRelease {
43
43
  *
44
44
  * `menubar` starts at 1.0.0 — the first build published under its own tag
45
45
  * rather than the CLI's. It is not "version 1 of the helper"; it is version 1
46
- * of its independent release train. The keychain helper moved with the
47
- * standalone `secrets` engine (PHNX-3989) — it downloads and verifies its own
48
- * helper release now, off this table entirely.
46
+ * of its independent release train.
47
+ *
48
+ * Two helper families have since left this table entirely, both for the same
49
+ * reason: their engine became a standalone CLI that resolves and verifies its
50
+ * OWN helper release. The keychain helper went with `secrets` (PHNX-3989); the
51
+ * macOS and Windows computer helpers went with `computer` (PHNX-4075). A floor
52
+ * recorded here for a helper this CLI never downloads would be a lying table —
53
+ * it would claim a distribution responsibility agents-cli no longer has.
49
54
  */
50
55
  export declare const HELPER_RELEASES: Readonly<Record<HelperName, HelperRelease>>;
51
56
  /** The release tag for one helper at one version, e.g. `menubar/v1.0.0`. */
@@ -31,17 +31,17 @@
31
31
  *
32
32
  * `menubar` starts at 1.0.0 — the first build published under its own tag
33
33
  * rather than the CLI's. It is not "version 1 of the helper"; it is version 1
34
- * of its independent release train. The keychain helper moved with the
35
- * standalone `secrets` engine (PHNX-3989) — it downloads and verifies its own
36
- * helper release now, off this table entirely.
34
+ * of its independent release train.
35
+ *
36
+ * Two helper families have since left this table entirely, both for the same
37
+ * reason: their engine became a standalone CLI that resolves and verifies its
38
+ * OWN helper release. The keychain helper went with `secrets` (PHNX-3989); the
39
+ * macOS and Windows computer helpers went with `computer` (PHNX-4075). A floor
40
+ * recorded here for a helper this CLI never downloads would be a lying table —
41
+ * it would claim a distribution responsibility agents-cli no longer has.
37
42
  */
38
43
  export const HELPER_RELEASES = {
39
44
  menubar: { tagPrefix: 'menubar', floor: '1.3.0' },
40
- 'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
41
- // The Windows helper is a bare .exe, not an .app bundle, so it does not share
42
- // helper-download.ts's zip/codesign/notarize machinery -- but it has the same
43
- // URL problem, so it shares the tag namespace and the floor.
44
- 'computer-win': { tagPrefix: 'computer-win', floor: '1.0.0' },
45
45
  };
46
46
  /** The release tag for one helper at one version, e.g. `menubar/v1.0.0`. */
47
47
  export function helperTag(helper, version) {
@@ -2189,11 +2189,15 @@ export function listShimFileNames() {
2189
2189
  }
2190
2190
  /**
2191
2191
  * Legacy command shims that are wrong even when their baked install is alive.
2192
- * `secrets` (PHNX-3989) and `sessions` (PHNX-4012): the shim `exec`s
2193
- * `agents <name>`, which is a passthrough to the STANDALONE binary — so the
2194
- * shim re-enters itself (the 1.22.85 fork bomb). Pruned unconditionally.
2192
+ * `secrets` (PHNX-3989), `sessions` (PHNX-4012) and `computer` (PHNX-4075): the
2193
+ * shim `exec`s `agents <name>`, which is a passthrough to the STANDALONE binary —
2194
+ * so the shim re-enters itself (the 1.22.85 fork bomb). Pruned unconditionally.
2195
+ *
2196
+ * `computer` is the sharpest of the three, because agents-cli USED to publish a
2197
+ * `computer` bin of its own: a machine that installed an older CLI has a real
2198
+ * shim on PATH under the name the standalone engine now owns.
2195
2199
  */
2196
- const LEGACY_SHIMS_ALWAYS_PRUNED = new Set(['secrets', 'sessions']);
2200
+ const LEGACY_SHIMS_ALWAYS_PRUNED = new Set(['secrets', 'sessions', 'computer']);
2197
2201
  /**
2198
2202
  * Prune a stale, orphaned shim: one that is NOT a managed agent shim and NOT a user
2199
2203
  * alias, whose baked `AGENTS_BIN` points at an install that no longer exists. These
@@ -2,7 +2,8 @@
2
2
  * On-demand download + verification of the macOS menu-bar helper
3
3
  * ("MenubarHelper.app").
4
4
  *
5
- * Mirrors the ComputerHelper download model (`../computer/download.ts`): the
5
+ * Mirrors the ComputerHelper download model the computer subsystem used before
6
+ * it was extracted (PHNX-4075): the
6
7
  * helper ships as a signed + notarized `.app` zipped as a GitHub release asset
7
8
  * on the helper's own `menubar/v<x.y.z>` tag, NOT the CLI's tag (see
8
9
  * `helper-versions.ts`). A fresh `npm i -g` machine whose tarball lacks a