@phnx-labs/agents-cli 1.22.25 → 1.22.27

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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -64,6 +64,92 @@ async function waitForSocket(_socketPath, timeoutMs) {
64
64
  }
65
65
  throw new Error('Timeout waiting for browser daemon socket');
66
66
  }
67
+ /**
68
+ * One long-lived connection to the existing browser daemon. Requests are
69
+ * serialized so the daemon's newline-delimited responses always map to the
70
+ * caller that produced them, while the process and socket stay warm between
71
+ * actions.
72
+ */
73
+ export class BrowserIPCConnection {
74
+ socket;
75
+ buffer = '';
76
+ pending;
77
+ tail = Promise.resolve();
78
+ closed = false;
79
+ constructor(socket) {
80
+ this.socket = socket;
81
+ socket.on('data', (data) => this.handleData(data));
82
+ socket.on('error', (error) => this.fail(error));
83
+ socket.on('close', () => {
84
+ this.fail(new Error('Browser daemon connection closed'));
85
+ });
86
+ }
87
+ request(request) {
88
+ const result = this.tail.then(() => this.requestOnce(request));
89
+ this.tail = result.then(() => undefined, () => undefined);
90
+ return result;
91
+ }
92
+ async close() {
93
+ await this.tail;
94
+ if (this.closed || this.socket.destroyed)
95
+ return;
96
+ await new Promise((resolve) => {
97
+ this.socket.once('close', resolve);
98
+ this.socket.end();
99
+ });
100
+ }
101
+ requestOnce(request) {
102
+ if (this.closed || this.socket.destroyed) {
103
+ return Promise.reject(new Error('Browser daemon connection is closed'));
104
+ }
105
+ return new Promise((resolve, reject) => {
106
+ this.pending = { resolve, reject };
107
+ this.socket.write(`${JSON.stringify(request)}\n`, (error) => {
108
+ if (!error)
109
+ return;
110
+ if (this.pending?.reject === reject)
111
+ this.pending = undefined;
112
+ reject(error);
113
+ });
114
+ });
115
+ }
116
+ handleData(data) {
117
+ this.buffer += data.toString();
118
+ while (true) {
119
+ const index = this.buffer.indexOf('\n');
120
+ if (index === -1)
121
+ return;
122
+ const line = this.buffer.slice(0, index);
123
+ this.buffer = this.buffer.slice(index + 1);
124
+ if (!line.trim())
125
+ continue;
126
+ const pending = this.pending;
127
+ if (!pending) {
128
+ this.socket.destroy();
129
+ this.fail(new Error('Browser daemon sent an unexpected response'));
130
+ return;
131
+ }
132
+ this.pending = undefined;
133
+ try {
134
+ pending.resolve(JSON.parse(line));
135
+ }
136
+ catch {
137
+ const error = new Error('Browser daemon returned invalid JSON');
138
+ pending.reject(error);
139
+ this.socket.destroy();
140
+ this.fail(error);
141
+ }
142
+ }
143
+ }
144
+ fail(error) {
145
+ if (this.closed)
146
+ return;
147
+ this.closed = true;
148
+ const pending = this.pending;
149
+ this.pending = undefined;
150
+ pending?.reject(error);
151
+ }
152
+ }
67
153
  export class BrowserIPCServer {
68
154
  server = null;
69
155
  service;
@@ -527,33 +613,35 @@ async function reconcileDaemonVersion(socketPath) {
527
613
  export async function sendIPCRequest(request, opts = {}) {
528
614
  return sendRawIPCRequest(request, opts);
529
615
  }
616
+ /**
617
+ * Open one connection that can serve many browser operations. This uses the
618
+ * same daemon readiness and version-reconciliation path as sendIPCRequest;
619
+ * only the client process and IPC socket lifetime differ.
620
+ */
621
+ export async function connectBrowserIPC(opts = {}) {
622
+ const autoStartDaemon = opts.autoStartDaemon ?? true;
623
+ await prepareIPC('status', opts);
624
+ return new Promise((resolve, reject) => {
625
+ const socket = net.createConnection(getIpcEndpoint());
626
+ const onError = (error) => {
627
+ socket.destroy();
628
+ if (!autoStartDaemon && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
629
+ reject(new BrowserDaemonNotRunningError());
630
+ return;
631
+ }
632
+ reject(new Error(`IPC error: ${error.message}`));
633
+ };
634
+ socket.once('error', onError);
635
+ socket.once('connect', () => {
636
+ socket.off('error', onError);
637
+ resolve(new BrowserIPCConnection(socket));
638
+ });
639
+ });
640
+ }
530
641
  async function sendRawIPCRequest(request, opts = {}) {
531
- const socketPath = getSocketPath();
532
642
  const endpoint = getIpcEndpoint();
533
643
  const autoStartDaemon = opts.autoStartDaemon ?? true;
534
- if (!(await isDaemonReachable())) {
535
- if (!autoStartDaemon) {
536
- throw new BrowserDaemonNotRunningError();
537
- }
538
- if (!IS_WINDOWS) {
539
- await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
540
- await fs.promises.chmod(path.dirname(socketPath), 0o700);
541
- }
542
- startDaemon();
543
- if (!(await isDaemonReachable())) {
544
- await waitForSocket(socketPath, 6000);
545
- }
546
- if (!(await isDaemonReachable())) {
547
- throw new Error('Failed to start browser daemon');
548
- }
549
- await new Promise((r) => setTimeout(r, 300));
550
- }
551
- // Before serving a real request, make sure the daemon isn't running stale
552
- // code. Skips the internal `version` probe (avoids recursion) and callers
553
- // that opt out of auto-start. No-ops once reconciled or when versions match.
554
- if (request.action !== 'version' && autoStartDaemon) {
555
- await reconcileDaemonVersion(socketPath);
556
- }
644
+ await prepareIPC(request.action, opts);
557
645
  return new Promise((resolve, reject) => {
558
646
  const socket = net.createConnection(endpoint);
559
647
  let buffer = '';
@@ -583,3 +671,30 @@ async function sendRawIPCRequest(request, opts = {}) {
583
671
  });
584
672
  });
585
673
  }
674
+ async function prepareIPC(action, opts) {
675
+ const socketPath = getSocketPath();
676
+ const autoStartDaemon = opts.autoStartDaemon ?? true;
677
+ if (!(await isDaemonReachable())) {
678
+ if (!autoStartDaemon) {
679
+ throw new BrowserDaemonNotRunningError();
680
+ }
681
+ if (!IS_WINDOWS) {
682
+ await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
683
+ await fs.promises.chmod(path.dirname(socketPath), 0o700);
684
+ }
685
+ startDaemon();
686
+ if (!(await isDaemonReachable())) {
687
+ await waitForSocket(socketPath, 6000);
688
+ }
689
+ if (!(await isDaemonReachable())) {
690
+ throw new Error('Failed to start browser daemon');
691
+ }
692
+ await new Promise((r) => setTimeout(r, 300));
693
+ }
694
+ // Before serving a real request, make sure the daemon isn't running stale
695
+ // code. Skips the internal `version` probe (avoids recursion) and callers
696
+ // that opt out of auto-start. No-ops once reconciled or when versions match.
697
+ if (action !== 'version' && autoStartDaemon) {
698
+ await reconcileDaemonVersion(socketPath);
699
+ }
700
+ }
@@ -26,6 +26,17 @@ export declare function getProfileDownloadsDir(name: string): string;
26
26
  export declare function getProfileSessionsDir(name: string, task: string): string;
27
27
  export declare function listProfiles(): Promise<BrowserProfile[]>;
28
28
  export declare function getProfile(name: string): Promise<BrowserProfile | null>;
29
+ /**
30
+ * True when `profile` can actually launch or attach on THIS machine.
31
+ *
32
+ * A remote (`ssh://`) profile runs its browser on the far host, so a local
33
+ * binary check doesn't apply — assume launchable. A local profile must have its
34
+ * configured browser/binary present on disk here; a `default` profile
35
+ * auto-created on a different OS (e.g. a macOS Chrome path reused on Linux — the
36
+ * "Custom binary not found" failure) fails this check so the caller re-detects
37
+ * for the current machine.
38
+ */
39
+ export declare function isProfileLaunchableHere(profile: BrowserProfile): boolean;
29
40
  /**
30
41
  * Resolve the profile `agents browser start` uses when no `--profile` is given.
31
42
  *
@@ -105,7 +105,7 @@ export async function getProfile(name) {
105
105
  * "Custom binary not found" failure) fails this check so the caller re-detects
106
106
  * for the current machine.
107
107
  */
108
- function isProfileLaunchableHere(profile) {
108
+ export function isProfileLaunchableHere(profile) {
109
109
  const remote = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('ssh://'));
110
110
  if (remote)
111
111
  return true;
@@ -0,0 +1,14 @@
1
+ import type { Readable, Writable } from 'stream';
2
+ export interface BrowserIPCStreamOptions {
3
+ input: Readable;
4
+ output: Writable;
5
+ task?: string;
6
+ actor: string;
7
+ launchId?: string;
8
+ autoStartDaemon?: boolean;
9
+ }
10
+ /**
11
+ * Read browser IPC requests as NDJSON and write one NDJSON response per line.
12
+ * The Node process and daemon connection stay alive until input closes.
13
+ */
14
+ export declare function runBrowserIPCStream(options: BrowserIPCStreamOptions): Promise<void>;
@@ -0,0 +1,71 @@
1
+ import { createInterface } from 'readline';
2
+ import { connectBrowserIPC } from './ipc.js';
3
+ import { assertRemoteControlAllowed } from './remote-control.js';
4
+ function parseRequest(line) {
5
+ const parsed = JSON.parse(line);
6
+ if (!parsed ||
7
+ typeof parsed !== 'object' ||
8
+ Array.isArray(parsed) ||
9
+ typeof parsed.action !== 'string') {
10
+ throw new Error('Each input line must be a JSON object with an action');
11
+ }
12
+ return parsed;
13
+ }
14
+ function writeResponse(output, response) {
15
+ output.write(`${JSON.stringify(response)}\n`);
16
+ }
17
+ function writeErrorResponse(output, error) {
18
+ writeResponse(output, {
19
+ ok: false,
20
+ error: error instanceof Error ? error.message : String(error),
21
+ });
22
+ }
23
+ /**
24
+ * Read browser IPC requests as NDJSON and write one NDJSON response per line.
25
+ * The Node process and daemon connection stay alive until input closes.
26
+ */
27
+ export async function runBrowserIPCStream(options) {
28
+ const client = await connectBrowserIPC({ autoStartDaemon: options.autoStartDaemon });
29
+ const lines = createInterface({ input: options.input, crlfDelay: Infinity });
30
+ let defaultTask = options.task;
31
+ try {
32
+ for await (const line of lines) {
33
+ if (!line.trim())
34
+ continue;
35
+ let request;
36
+ try {
37
+ request = parseRequest(line);
38
+ }
39
+ catch (error) {
40
+ writeErrorResponse(options.output, error);
41
+ continue;
42
+ }
43
+ if (request.action === 'start') {
44
+ try {
45
+ assertRemoteControlAllowed();
46
+ }
47
+ catch (error) {
48
+ writeErrorResponse(options.output, error);
49
+ continue;
50
+ }
51
+ request = {
52
+ ...request,
53
+ taskName: request.taskName ?? defaultTask,
54
+ actor: request.actor ?? options.actor,
55
+ launchId: request.launchId ?? options.launchId,
56
+ };
57
+ }
58
+ else if (!request.task && defaultTask) {
59
+ request = { ...request, task: defaultTask };
60
+ }
61
+ const response = await client.request(request);
62
+ if (response.task)
63
+ defaultTask = response.task;
64
+ writeResponse(options.output, response);
65
+ }
66
+ }
67
+ finally {
68
+ lines.close();
69
+ await client.close();
70
+ }
71
+ }
@@ -0,0 +1,27 @@
1
+ import type { Meta } from '../types.js';
2
+ /** Why the owner sink cannot deliver from this box. */
3
+ export type OwnerSinkReason = 'rush-not-on-path' | 'rush-signed-out';
4
+ export interface OwnerSinkStatus {
5
+ /** Owner delivery is configured for this fleet (humans.yaml / notify.owner).
6
+ * When false, no finding is emitted — an un-opted-in box is not "broken". */
7
+ configured: boolean;
8
+ /** This box can actually deliver an owner notification right now. */
9
+ reachable: boolean;
10
+ /** Resolved owner channel (e.g. `imessage`). */
11
+ channel?: string;
12
+ /** Resolved transport after `notify.transports` mapping (usually === channel). */
13
+ transport?: string;
14
+ /** Set only when `reachable` is false. */
15
+ reason?: OwnerSinkReason;
16
+ }
17
+ /**
18
+ * Probe whether THIS box can deliver an owner notification right now. Returns
19
+ * `configured:false` (and the caller emits no finding) when the fleet has no
20
+ * owner channel configured — an un-opted-in box is not broken. When configured,
21
+ * reports whether the resolved transport can actually deliver from here.
22
+ *
23
+ * Only the concrete rush-backed failure the lane hits is reported as unreachable;
24
+ * non-rush transports (desktop / mailbox / …) deliver locally and are treated as
25
+ * reachable rather than probed, so this never invents a critical it cannot back.
26
+ */
27
+ export declare function probeOwnerSink(meta: Meta): Promise<OwnerSinkStatus>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Owner-delivery-sink reachability probe (RUSH-2262).
3
+ *
4
+ * The feed/notify owner-delivery lane (`agents notify`, `agents feed post
5
+ * --level important` / `--blocked`) reaches the owner over the rush-backed owner
6
+ * channel (iMessage via `rush message send`). That transport can only deliver
7
+ * from a context that BOTH finds the `rush` CLI on PATH and can read its
8
+ * keychain-bound session. So a headless Linux fleet box (no rush) or a non-GUI
9
+ * SSH session on a mac (login keychain locked) structurally cannot escalate —
10
+ * and the failure is silent until a block is filed, surfacing only as the
11
+ * after-the-fact `owner failed: …` line. `agents doctor` had no signal for it,
12
+ * which is exactly the gap RUSH-2258 / RUSH-2262 flagged.
13
+ *
14
+ * This probes the SAME transport the lane uses, from the SAME context doctor runs
15
+ * in, so `agents doctor` can fail loud when this box cannot reach the owner. It is
16
+ * deliberately honest about context: `rush whoami` is what tells a real signed-in
17
+ * session apart from a keychain that is present but unreadable HERE. The session
18
+ * token is a keychain item, NOT `~/.rush/user.yaml` (RUSH-2262), so this never
19
+ * reads that file — checking it is the mistake that made a signed-in box look
20
+ * signed out.
21
+ *
22
+ * `agents notify --dry-run` is NOT this probe: it short-circuits before the
23
+ * `which rush` preflight (`providers/rush.ts`), so it reports `ok:true` on a box
24
+ * with no rush at all. Resolvability (does the envelope build?) and reachability
25
+ * (can this box actually deliver?) are different questions; this answers the
26
+ * second.
27
+ */
28
+ import { execFile } from 'child_process';
29
+ import { promisify } from 'util';
30
+ import { readOwnerDest } from './send.js';
31
+ import { RUSH_CHANNELS } from './providers/rush.js';
32
+ const execFileAsync = promisify(execFile);
33
+ async function rushOnPath() {
34
+ try {
35
+ await execFileAsync('which', ['rush'], { timeout: 4000 });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /** Signed-in state from `rush whoami`, read with THIS context's keychain access.
43
+ * 'unknown' (a timeout or output we can't classify) is treated as reachable by
44
+ * the caller — a slow or unexpected probe must not cry wolf and paint a false
45
+ * critical. Only a definitive "not logged in" is reported as signed out. */
46
+ async function rushSignedIn() {
47
+ const classify = (s) => {
48
+ const out = s.toLowerCase();
49
+ if (/not (logged in|signed in)/.test(out))
50
+ return 'no';
51
+ if (/logged in as|session:\s*valid/.test(out))
52
+ return 'yes';
53
+ return 'unknown';
54
+ };
55
+ try {
56
+ const { stdout, stderr } = await execFileAsync('rush', ['whoami'], { timeout: 5000 });
57
+ return classify(`${stdout}\n${stderr}`);
58
+ }
59
+ catch (err) {
60
+ const e = err;
61
+ if (e.killed)
62
+ return 'unknown'; // timed out — do not conclude signed-out
63
+ // A signed-out rush commonly exits non-zero; trust an explicit message only.
64
+ return classify(`${e.stdout ?? ''}\n${e.stderr ?? ''}`);
65
+ }
66
+ }
67
+ /**
68
+ * Probe whether THIS box can deliver an owner notification right now. Returns
69
+ * `configured:false` (and the caller emits no finding) when the fleet has no
70
+ * owner channel configured — an un-opted-in box is not broken. When configured,
71
+ * reports whether the resolved transport can actually deliver from here.
72
+ *
73
+ * Only the concrete rush-backed failure the lane hits is reported as unreachable;
74
+ * non-rush transports (desktop / mailbox / …) deliver locally and are treated as
75
+ * reachable rather than probed, so this never invents a critical it cannot back.
76
+ */
77
+ export async function probeOwnerSink(meta) {
78
+ const dest = readOwnerDest(meta);
79
+ if (!dest)
80
+ return { configured: false, reachable: false };
81
+ const channel = dest.channel;
82
+ const transport = meta.notify?.transports?.[channel] ?? channel;
83
+ if (RUSH_CHANNELS.includes(transport)) {
84
+ if (!(await rushOnPath())) {
85
+ return { configured: true, reachable: false, channel, transport, reason: 'rush-not-on-path' };
86
+ }
87
+ if ((await rushSignedIn()) === 'no') {
88
+ return { configured: true, reachable: false, channel, transport, reason: 'rush-signed-out' };
89
+ }
90
+ return { configured: true, reachable: true, channel, transport };
91
+ }
92
+ return { configured: true, reachable: true, channel, transport };
93
+ }
@@ -1,3 +1,5 @@
1
+ /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
2
+ export declare function isValidClaudeSetupToken(value: string): boolean;
1
3
  /** The per-account key an email maps to inside the `auth` bundle. */
2
4
  export declare function claudeAccountTokenKey(account: string): string;
3
5
  /** Signed-in account email for a version home, from `.claude.json` (no keychain). */
@@ -11,6 +11,24 @@ import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/
11
11
  * multi-account fleet.
12
12
  */
13
13
  const AUTH_BUNDLE = 'auth';
14
+ /**
15
+ * A well-formed Claude OAuth setup-token: the `sk-ant-oat01-` prefix followed by
16
+ * token-safe characters only, on a single line. `claude setup-token` mints exactly
17
+ * this; nothing else is a token.
18
+ *
19
+ * The provisioning capture bug behind #1767 stored the raw `claude setup-token`
20
+ * TTY stream — the welcome banner, ANSI control sequences, box-drawing art, and the
21
+ * token buried inside — under a per-account key instead of the parsed value. That
22
+ * blob is not a token: injected as `Authorization: Bearer <blob>` it made Anthropic
23
+ * reject every request and the run crash. agents-cli only *consumes* these tokens
24
+ * (the mint itself is the Rush Cloud / mint-auth path), so this is the boundary
25
+ * where a corrupt bundle entry must be caught before it reaches the auth header.
26
+ */
27
+ const SETUP_TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]+$/;
28
+ /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
29
+ export function isValidClaudeSetupToken(value) {
30
+ return SETUP_TOKEN_RE.test(value);
31
+ }
14
32
  /** The per-account key an email maps to inside the `auth` bundle. */
15
33
  export function claudeAccountTokenKey(account) {
16
34
  const slug = account
@@ -55,7 +73,15 @@ export function resolveClaudeSetupToken(home) {
55
73
  return null;
56
74
  const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
57
75
  const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
58
- return v.length > 0 ? v : null;
76
+ if (v.length === 0)
77
+ return null;
78
+ // Reject a malformed stored value (e.g. a captured setup-token TTY blob, #1767)
79
+ // rather than let it become an `Authorization: Bearer <blob>` header that
80
+ // Anthropic rejects and crashes the run. A corrupt entry is treated as no
81
+ // usable file-based token, so the caller falls back to the normal login.
82
+ if (!isValidClaudeSetupToken(v))
83
+ return null;
84
+ return v;
59
85
  }
60
86
  catch {
61
87
  return null;
@@ -186,6 +186,8 @@ export interface CrabboxRunOptions extends CrabboxOptions {
186
186
  onData?: (chunk: string) => void;
187
187
  /** Force a full remote resync before running. */
188
188
  fullResync?: boolean;
189
+ /** Refresh an existing lease with this idle window before running. */
190
+ renewIdleTimeoutSecs?: number;
189
191
  }
190
192
  /**
191
193
  * Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
@@ -485,6 +485,8 @@ export function crabboxRun(slug, remoteCmd, opts = {}) {
485
485
  export function crabboxRunScript(slug, script, opts = {}) {
486
486
  findCrabbox();
487
487
  const args = ['run', '--id', slug, '--reclaim'];
488
+ if (opts.renewIdleTimeoutSecs !== undefined)
489
+ args.push('--idle-timeout', `${opts.renewIdleTimeoutSecs}s`);
488
490
  if (opts.fullResync)
489
491
  args.push('--full-resync');
490
492
  args.push('--script-stdin');
@@ -233,7 +233,9 @@ export function isExpiredPoolStray(box, opts) {
233
233
  return false;
234
234
  if (box.expiresAt === null || box.expiresAt > nowSecs)
235
235
  return false; // unexpired ⇒ reusable
236
- if (box.lastTouchedAt !== null && nowSecs - box.lastTouchedAt < graceSecs)
236
+ if (box.lastTouchedAt === null)
237
+ return false; // unknown age is never reap-safe
238
+ if (nowSecs - box.lastTouchedAt < graceSecs)
237
239
  return false; // maybe active
238
240
  return true;
239
241
  }
@@ -329,9 +331,13 @@ export async function leaseAndRun(opts) {
329
331
  let exitCode = null;
330
332
  let toreDown = false;
331
333
  try {
334
+ const renewIdleTimeoutSecs = reused && !opts.reuseBox && box.idleTimeoutSecs !== null
335
+ ? box.idleTimeoutSecs
336
+ : undefined;
332
337
  exitCode = await crabboxRunScript(box.slug, script, {
333
338
  secretsBundle: opts.secretsBundle,
334
339
  onData: opts.onData,
340
+ renewIdleTimeoutSecs,
335
341
  });
336
342
  }
337
343
  finally {
@@ -20,6 +20,7 @@ import { executeJobDetached, monitorRunningJobs } from './runner.js';
20
20
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
21
  import { runCatchup } from './catchup.js';
22
22
  import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
23
+ import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from './routine-notify-owner.js';
23
24
  import { BrowserService } from './browser/service.js';
24
25
  import { BrowserIPCServer } from './browser/ipc.js';
25
26
  import { redactSecrets } from './redact.js';
@@ -516,6 +517,16 @@ export async function runDaemon() {
516
517
  notifyRoutineFinish(final);
517
518
  }
518
519
  catch { /* best-effort */ }
520
+ // RUSH-2288: a failed/timed-out routine also reaches the OWNER's phone
521
+ // (in-process owner channel stack), not just the local desktop. Green
522
+ // runs are silent — the builder returns early. Async + swallowed so a
523
+ // delivery hiccup never blocks the finish path.
524
+ void notifyOwnerRoutineFinish(final)
525
+ .then((r) => {
526
+ if (r.attempts.length && !r.delivered)
527
+ log('WARN', `Owner failure-notify for '${config.name}' reached no channel (tried: ${r.attempts.map((a) => a.channel).join(', ')})`);
528
+ })
529
+ .catch(() => { });
519
530
  },
520
531
  });
521
532
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
@@ -531,6 +542,15 @@ export async function runDaemon() {
531
542
  notifyRoutineStartFailed(config, message);
532
543
  }
533
544
  catch { /* best-effort */ }
545
+ // RUSH-2288: the pre-spawn failure (e.g. auth_failed) is exactly the one the
546
+ // per-routine `agents notify` prompt can never send — its agent never ran —
547
+ // so the daemon reaches the owner directly.
548
+ void notifyOwnerRoutineStartFailed(config, message)
549
+ .then((r) => {
550
+ if (r.attempts.length && !r.delivered)
551
+ log('WARN', `Owner start-failure notify for '${config.name}' reached no channel (tried: ${r.attempts.map((a) => a.channel).join(', ')})`);
552
+ })
553
+ .catch(() => { });
534
554
  }
535
555
  };
536
556
  let scheduler = null;
@@ -50,6 +50,8 @@ export interface SshHostKeyOptions {
50
50
  /** Managed known_hosts path override (tests). Defaults to the CLI-managed store. */
51
51
  knownHostsFile?: string;
52
52
  }
53
+ /** OpenSSH argv that makes an explicit device key authoritative. */
54
+ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
53
55
  /**
54
56
  * Build the argv (after the `ssh` program name) and the environment overlay
55
57
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
@@ -82,6 +82,12 @@ export function wrapRemoteCommand(device, cmd) {
82
82
  }
83
83
  return joined;
84
84
  }
85
+ /** OpenSSH argv that makes an explicit device key authoritative. */
86
+ export function deviceIdentityArgs(device) {
87
+ return device.auth?.method === 'key' && device.auth.identityFile
88
+ ? ['-i', device.auth.identityFile, '-o', 'IdentitiesOnly=yes']
89
+ : [];
90
+ }
85
91
  /**
86
92
  * Build the argv (after the `ssh` program name) and the environment overlay
87
93
  * for connecting to a device. For password auth this points `SSH_ASKPASS` at
@@ -118,6 +124,7 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
118
124
  }
119
125
  else {
120
126
  args.push('-o', 'BatchMode=yes');
127
+ args.push(...deviceIdentityArgs(device));
121
128
  }
122
129
  // An interactive login (no remote command) needs a real tty.
123
130
  if (!remote)
@@ -1,6 +1,8 @@
1
1
  import type { AgentId } from '../types.js';
2
2
  import type { DuplicateVersionHook } from '../hooks.js';
3
3
  import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
4
+ import type { OwnerSinkStatus } from '../channels/owner-sink.js';
5
+ import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
4
6
  import type { SyncStatusRow, OrphanRow } from '../drift.js';
5
7
  import type { FetchStatusMarker } from '../auto-pull.js';
6
8
  import type { VersionResourceReport } from '../doctor-diff.js';
@@ -10,7 +12,7 @@ export type FindingSeverity = 'critical' | 'warning';
10
12
  * the JSON consumer group by kind. */
11
13
  /** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
12
14
  * below owns it, and a second copy in these comments is a fourth place to drift. */
13
- export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli"];
15
+ export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
14
16
  /**
15
17
  * The severity each kind is emitted with - the SINGLE source of truth, read by
16
18
  * the builders below and asserted against both prose rubrics by
@@ -101,11 +103,18 @@ export interface LocalFindingInputs {
101
103
  platform: NodeJS.Platform;
102
104
  policy: string | null;
103
105
  };
106
+ /** Read-only Windows OpenSSH AuthorizedKeysFile/content/ACL audit. */
107
+ windowsSshEnrollment?: WindowsSshEnrollmentAudit | null;
104
108
  /** `<agent>@<version>` keys whose home is an isolated copy. Their findings are
105
109
  * never collapsed across versions: the agent-wide `agents doctor <agent> --fix`
106
110
  * sweep deliberately skips isolated copies, so a collapsed row would print a
107
111
  * remediation that does not fix them. */
108
112
  isolatedVersions?: string[];
113
+ /** Whether the feed/notify owner-delivery lane can reach the owner from this box
114
+ * (RUSH-2262). Collected by `probeOwnerSink` in the command (it spawns the real
115
+ * `rush` transport, so it stays out of this pure module). Absent → no probe ran
116
+ * → no finding. */
117
+ ownerSink?: OwnerSinkStatus;
109
118
  }
110
119
  /**
111
120
  * Fold this machine's signals into findings. Missing hooks/plugins and unwired