@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -2,11 +2,13 @@
2
2
  * Consent gate for driving THIS machine's browser from another fleet machine.
3
3
  *
4
4
  * `browser --device <device>` routes a browser command to `<device>` over SSH (the
5
- * fleet passthrough) and runs `agents browser ...` there. Every such remote
6
- * invocation carries the {@link FLEET_REMOTE_ENV} marker, set once at the fleet
7
- * dispatch site (`maybeRunOnHost`). A machine only accepts being driven when its
8
- * owner has opted in via `agents browser remote-control on` (the device-scope
9
- * `browser.remote-control` config key, never synced).
5
+ * fleet passthrough) and runs `agents browser ...` there. `agents ssh <device>
6
+ * agents browser …` is the same remote drive through the ssh wrapper. Every such
7
+ * remote invocation carries the {@link FLEET_REMOTE_ENV} marker, set at the fleet
8
+ * dispatch site (`maybeRunOnHost` / `streamAgentsOnHost`) and on the ssh wrapper
9
+ * (`buildSshInvocation` via `markFleetRemote`). A machine only accepts being driven
10
+ * when its owner has opted in via `agents browser remote-control on` (the
11
+ * device-scope `browser.remote-control` config key, never synced).
10
12
  *
11
13
  * Local invocations (no marker) are never gated — this only governs cross-machine
12
14
  * drives. Read-only queries are not gated.
@@ -22,9 +24,9 @@
22
24
  * as a fast-fail CLI-side check so a refused `start` never auto-creates a profile.
23
25
  */
24
26
  import { getConfigValue } from '../device-config.js';
25
- /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
27
+ /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation` and `markFleetRemote`. */
26
28
  export const FLEET_REMOTE_ENV = 'AGENTS_FLEET_REMOTE';
27
- /** True when this process was dispatched to this machine by a fleet `--device` run. */
29
+ /** True when this process was dispatched to this machine by a fleet `--device` or `agents ssh … agents browser` run. */
28
30
  export function isFleetRemoteInvocation(env = process.env) {
29
31
  return env[FLEET_REMOTE_ENV] === '1';
30
32
  }
@@ -21,6 +21,7 @@ import { emit } from '../feed/events.js';
21
21
  import { resolveActor } from '../actor.js';
22
22
  import { recordBrowserSession } from '../session/db.js';
23
23
  import { sshExecAsync } from '../ssh-exec.js';
24
+ import { resolveFfmpeg } from './ffmpeg.js';
24
25
  /**
25
26
  * Canonical form for comparing a requested URL against what CDP reports for a
26
27
  * live target. `new URL('https://example.com').href` is `https://example.com/`,
@@ -1130,38 +1131,29 @@ export class BrowserService {
1130
1131
  await fs.promises.mkdir(recordingsDir, { recursive: true });
1131
1132
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
1132
1133
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
1133
- const { spawn } = await import('child_process');
1134
- // CDP `Page.startScreencast` delivers frames at a VARIABLE cadence (paints
1135
- // are event-driven, not clocked). Feeding those into a fixed `-framerate`
1136
- // input made ffmpeg assume every frame was 1/fps apart, so a page that
1137
- // painted slower/faster than `fps` played back at the wrong speed. Instead
1138
- // stamp each frame with its wall-clock arrival time
1139
- // (`-use_wallclock_as_timestamps 1`) and keep those timings on output
1140
- // (`-vsync vfr`), so playback duration matches the real capture.
1141
- const ffmpeg = spawn('ffmpeg', [
1134
+ const [{ spawn }, ffmpegPath] = await Promise.all([
1135
+ import('child_process'),
1136
+ resolveFfmpeg(),
1137
+ ]);
1138
+ // The frame pump below clocks the latest CDP paint into stdin at exactly
1139
+ // `fps`, including repeated frames while the page is static. Declare that
1140
+ // cadence to image2pipe so frame count / fps is the real capture duration.
1141
+ const ffmpeg = spawn(ffmpegPath, [
1142
1142
  '-loglevel', 'error',
1143
1143
  '-f', 'image2pipe',
1144
- '-use_wallclock_as_timestamps', '1',
1144
+ '-framerate', String(fps),
1145
1145
  '-i', '-',
1146
1146
  '-c:v', 'libvpx-vp9',
1147
1147
  '-b:v', '1M',
1148
1148
  '-pix_fmt', 'yuv420p',
1149
- '-vsync', 'vfr',
1150
1149
  '-y',
1151
1150
  outputPath,
1152
1151
  ], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
1153
- // Wait for the spawn to confirm (or fail) before we wire CDP frames into a
1154
- // dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
1155
- // instead of a silently empty .webm.
1152
+ // Wait for the spawn to confirm before wiring CDP frames into a dead pipe.
1156
1153
  await new Promise((resolve, reject) => {
1157
1154
  const onError = (err) => {
1158
1155
  ffmpeg.off('spawn', onSpawn);
1159
- if (err.code === 'ENOENT') {
1160
- reject(new Error('ffmpeg not found on PATH — install via `brew install ffmpeg`'));
1161
- }
1162
- else {
1163
- reject(err);
1164
- }
1156
+ reject(err);
1165
1157
  };
1166
1158
  const onSpawn = () => {
1167
1159
  ffmpeg.off('error', onError);
@@ -1181,13 +1173,33 @@ export class BrowserService {
1181
1173
  });
1182
1174
  const ffmpegStderr = () => stderrBuf;
1183
1175
  ffmpeg.on('error', () => { });
1184
- // 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
1185
- const everyNthFrame = Math.max(1, Math.round(30 / fps));
1186
- await conn.cdp.send('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame }, sessionId);
1187
- const frameHandler = (params) => {
1176
+ let frameCount = 0;
1177
+ let latestFrame;
1178
+ let stdinBlocked = false;
1179
+ let stdinError;
1180
+ ffmpeg.stdin?.on('drain', () => { stdinBlocked = false; });
1181
+ ffmpeg.stdin?.on('error', (error) => { stdinError = error; });
1182
+ const writeFrame = (frame) => {
1183
+ const stdin = ffmpeg.stdin;
1184
+ if (!stdin || stdin.destroyed || stdinError || stdinBlocked)
1185
+ return false;
1186
+ stdinBlocked = !stdin.write(frame);
1187
+ frameCount += 1;
1188
+ return true;
1189
+ };
1190
+ const frameIntervalMs = 1000 / fps;
1191
+ let resolveFirstFrame;
1192
+ const firstFrame = new Promise((resolve) => { resolveFirstFrame = resolve; });
1193
+ const frameHandler = (params, eventSessionId) => {
1194
+ if (eventSessionId !== sessionId)
1195
+ return;
1188
1196
  const p = params;
1189
1197
  try {
1190
- ffmpeg.stdin?.write(Buffer.from(p.data, 'base64'));
1198
+ latestFrame = Buffer.from(p.data, 'base64');
1199
+ if (frameCount === 0) {
1200
+ if (writeFrame(latestFrame))
1201
+ resolveFirstFrame();
1202
+ }
1191
1203
  }
1192
1204
  catch {
1193
1205
  // ffmpeg exited; ignore writes
@@ -1195,7 +1207,77 @@ export class BrowserService {
1195
1207
  // Must ack every frame or CDP stops sending.
1196
1208
  conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
1197
1209
  };
1210
+ // CDP emits on paints, so a static page can legitimately produce only one
1211
+ // event in 15 seconds. Retain the latest real frame and clock it into the
1212
+ // encoder at --fps; new paints replace it, while quiet time still advances
1213
+ // the WebM timeline instead of collapsing to a one-frame, 0.04s artifact.
1214
+ const framePump = setInterval(() => {
1215
+ if (!latestFrame)
1216
+ return;
1217
+ try {
1218
+ writeFrame(latestFrame);
1219
+ }
1220
+ catch {
1221
+ // ffmpeg exited; recordStop reports its exit code and stderr.
1222
+ }
1223
+ }, frameIntervalMs);
1224
+ // Register BEFORE startScreencast. Chrome may emit its first frame before
1225
+ // the command response; missing that frame also misses its ack and stalls
1226
+ // the entire stream with ffmpeg's stdin still empty (PHNX-2600).
1198
1227
  conn.cdp.on('Page.screencastFrame', frameHandler);
1228
+ try {
1229
+ await conn.cdp.send('Page.enable', {}, sessionId);
1230
+ await conn.cdp.send('Page.startScreencast', {
1231
+ format: 'jpeg',
1232
+ quality: 60,
1233
+ // Chrome's frames are paint-driven, not a 30fps clock. Asking for
1234
+ // every third paint can suppress the only stored frame on a static
1235
+ // page, so receive every paint and enforce --fps when writing stdin.
1236
+ everyNthFrame: 1,
1237
+ maxFramesInFlight: 1,
1238
+ // A static/low-paint page may produce no new compositor frame after
1239
+ // startScreencast. Ask Chrome to send its stored last frame so the
1240
+ // first ack can bootstrap the stream instead of leaving ffmpeg stdin
1241
+ // empty forever (confirmed live against Chrome 151 / PHNX-2600).
1242
+ sendLastFrame: true,
1243
+ }, sessionId);
1244
+ await new Promise((resolve, reject) => {
1245
+ const timer = setTimeout(() => reject(new Error('Chrome produced no screencast frame within 5s; recording was not started.')), 5000);
1246
+ firstFrame.then(() => {
1247
+ clearTimeout(timer);
1248
+ resolve();
1249
+ });
1250
+ });
1251
+ }
1252
+ catch (error) {
1253
+ conn.cdp.off('Page.screencastFrame', frameHandler);
1254
+ clearInterval(framePump);
1255
+ try {
1256
+ await conn.cdp.send('Page.stopScreencast', {}, sessionId);
1257
+ }
1258
+ catch { /* session may be gone */ }
1259
+ try {
1260
+ ffmpeg.stdin?.end();
1261
+ }
1262
+ catch { /* process may already be gone */ }
1263
+ await new Promise((resolve) => {
1264
+ if (ffmpeg.exitCode !== null)
1265
+ return resolve();
1266
+ const timer = setTimeout(() => {
1267
+ try {
1268
+ ffmpeg.kill('SIGKILL');
1269
+ }
1270
+ catch { /* already gone */ }
1271
+ resolve();
1272
+ }, 1000);
1273
+ ffmpeg.once('exit', () => {
1274
+ clearTimeout(timer);
1275
+ resolve();
1276
+ });
1277
+ });
1278
+ await fs.promises.rm(outputPath, { force: true });
1279
+ throw error;
1280
+ }
1199
1281
  const durationMs = durationSec * 1000;
1200
1282
  const maxBytes = maxMb * 1024 * 1024;
1201
1283
  const state = {
@@ -1206,9 +1288,12 @@ export class BrowserService {
1206
1288
  maxBytes,
1207
1289
  ffmpeg,
1208
1290
  ffmpegStderr,
1291
+ encoderError: () => stdinError,
1292
+ get frameCount() { return frameCount; },
1209
1293
  sessionId,
1210
1294
  conn,
1211
1295
  frameHandler,
1296
+ framePump,
1212
1297
  durationTimer: setTimeout(() => {
1213
1298
  this.recordStop(taskId, 'duration-cap').catch(() => { });
1214
1299
  }, durationMs),
@@ -1242,6 +1327,7 @@ export class BrowserService {
1242
1327
  rec.stopReason = reason;
1243
1328
  clearTimeout(rec.durationTimer);
1244
1329
  clearInterval(rec.sizeCheckInterval);
1330
+ clearInterval(rec.framePump);
1245
1331
  rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
1246
1332
  try {
1247
1333
  await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
@@ -1291,6 +1377,13 @@ export class BrowserService {
1291
1377
  `${rec.outputPath}; the file is likely corrupt or empty.` +
1292
1378
  (err ? ` ffmpeg: ${err.slice(-800)}` : ''));
1293
1379
  }
1380
+ const encoderError = rec.encoderError?.();
1381
+ if (encoderError) {
1382
+ throw new Error(`ffmpeg stopped accepting recording frames at ${rec.outputPath}: ${encoderError.message}`);
1383
+ }
1384
+ if (rec.frameCount === 0) {
1385
+ throw new Error(`Chrome delivered no frames for the recording at ${rec.outputPath}; the output is empty.`);
1386
+ }
1294
1387
  let bytes = 0;
1295
1388
  try {
1296
1389
  const st = await fs.promises.stat(rec.outputPath);
@@ -1995,6 +2088,7 @@ export class BrowserService {
1995
2088
  rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
1996
2089
  }
1997
2090
  catch { /* socket may be gone */ }
2091
+ clearInterval(rec.framePump);
1998
2092
  try {
1999
2093
  rec.ffmpeg.stdin?.end();
2000
2094
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
3
+ * setup-tokens. Usage/probe reads authenticate with these instead of Claude
4
+ * Code's ACL-bound login item, so they never pop Touch ID. Keyed strictly
5
+ * per-account (`CLAUDE_CODE_OAUTH_TOKEN_<slug>` from the account email) — never a
6
+ * bare key, so one account's token can't be misapplied to another in a
7
+ * multi-account fleet.
8
+ */
9
+ /** Alias of the secrets-layer reserved name so mint/seed shares one source of truth. */
10
+ export declare const AUTH_BUNDLE = "auth";
1
11
  /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
2
12
  export declare function isValidClaudeSetupToken(value: string): boolean;
3
13
  /** The per-account key an email maps to inside the `auth` bundle. */
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/bundles.js';
4
+ import { AUTH_BUNDLE_NAME, ReservedBundleWrongBackendError, bundleBackend, bundleExists, readAndResolveBundleEnv, } from './secrets/bundles.js';
5
5
  import { fileStoreItemPath } from './secrets/filestore.js';
6
6
  import { secretsKeychainItem } from './secrets/index.js';
7
7
  /**
@@ -12,7 +12,8 @@ import { secretsKeychainItem } from './secrets/index.js';
12
12
  * bare key, so one account's token can't be misapplied to another in a
13
13
  * multi-account fleet.
14
14
  */
15
- const AUTH_BUNDLE = 'auth';
15
+ /** Alias of the secrets-layer reserved name so mint/seed shares one source of truth. */
16
+ export const AUTH_BUNDLE = AUTH_BUNDLE_NAME;
16
17
  /**
17
18
  * A well-formed Claude OAuth setup-token: the `sk-ant-oat01-` prefix followed by
18
19
  * token-safe characters only, on a single line. `claude setup-token` mints exactly
@@ -82,8 +83,15 @@ export function resolveClaudeSetupToken(home) {
82
83
  const email = readClaudeAccountEmail(home);
83
84
  if (!email)
84
85
  return null;
85
- if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
86
+ if (!bundleExists(AUTH_BUNDLE))
86
87
  return null;
88
+ const backend = bundleBackend(AUTH_BUNDLE);
89
+ if (backend !== 'file') {
90
+ // SEC-GAP-3: a keychain/vault-backed `auth` used to return null here, so
91
+ // usage/probe fell through to the interactive login (Touch ID) with no
92
+ // hint that the seeded setup-token was being ignored.
93
+ throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, backend);
94
+ }
87
95
  const cacheKey = home ?? os.homedir();
88
96
  const item = secretsKeychainItem(AUTH_BUNDLE, claudeAccountTokenKey(email));
89
97
  const credentialPath = fileStoreItemPath(item);
@@ -110,7 +118,9 @@ export function resolveClaudeSetupToken(home) {
110
118
  // than return a token whose current file fingerprint was never observed.
111
119
  return null;
112
120
  }
113
- catch {
121
+ catch (err) {
122
+ if (err instanceof ReservedBundleWrongBackendError)
123
+ throw err;
114
124
  return null;
115
125
  }
116
126
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Config drift: has THIS box drained its device-scoped state into its own
3
+ * `devices/<host>/agents.yaml`, or is it still carrying per-box state in the
4
+ * shared top-level `agents.yaml`?
5
+ *
6
+ * PHNX-3315 P1 heals a frozen top-level header and drains the central `browser:`
7
+ * tombstone; P2 folds `fleet.discovery` / `fleet.ignored`, the `hosts:` registry,
8
+ * and device-scoped native `accounts:` out of central and into the device doc.
9
+ * Each fold-and-delete migration is idempotent and runs on config access / daemon
10
+ * boot / install — so a converged box shows NO drift. A box that has NOT run the
11
+ * fold yet (stale checkout, never re-synced) still holds those blocks centrally,
12
+ * and until now that was INVISIBLE: `agents sync status` only reported per-agent
13
+ * "N missing", so an un-drained box was discovered the hard way — as a mystery
14
+ * `agents repo pull` conflict when two boxes rewrote the same shared map.
15
+ *
16
+ * This detector is READ-ONLY and must NOT trigger the migration (that would drain
17
+ * the very leak it is trying to surface): it reads the raw top-level user file
18
+ * directly, never `readMeta()` (system-merged) and never the migration hook.
19
+ */
20
+ export interface ConfigDrift {
21
+ /** Top-level `agents.yaml` header != the current META_HEADER (the P1 case). */
22
+ staleHeader: boolean;
23
+ /**
24
+ * Labels of central blocks that should have folded into this box's device doc
25
+ * but still linger — the P1 `browser` tombstone and the P2 `fleet` / `hosts` /
26
+ * `accounts` device-scoped writers. Empty on a drained box.
27
+ */
28
+ centralLeaks: string[];
29
+ }
30
+ /**
31
+ * Inspect this box's on-disk state for config drift. Pure read — no migration, no
32
+ * writes. Mirrors the gather step of {@link migrateDeviceConfigStores} (plus the
33
+ * browser tombstone) so a leak here is exactly what a fold would drain.
34
+ */
35
+ export declare function detectConfigDrift(): ConfigDrift;
36
+ /** True when either drift class is present. */
37
+ export declare function hasConfigDrift(d: ConfigDrift): boolean;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Config drift: has THIS box drained its device-scoped state into its own
3
+ * `devices/<host>/agents.yaml`, or is it still carrying per-box state in the
4
+ * shared top-level `agents.yaml`?
5
+ *
6
+ * PHNX-3315 P1 heals a frozen top-level header and drains the central `browser:`
7
+ * tombstone; P2 folds `fleet.discovery` / `fleet.ignored`, the `hosts:` registry,
8
+ * and device-scoped native `accounts:` out of central and into the device doc.
9
+ * Each fold-and-delete migration is idempotent and runs on config access / daemon
10
+ * boot / install — so a converged box shows NO drift. A box that has NOT run the
11
+ * fold yet (stale checkout, never re-synced) still holds those blocks centrally,
12
+ * and until now that was INVISIBLE: `agents sync status` only reported per-agent
13
+ * "N missing", so an un-drained box was discovered the hard way — as a mystery
14
+ * `agents repo pull` conflict when two boxes rewrote the same shared map.
15
+ *
16
+ * This detector is READ-ONLY and must NOT trigger the migration (that would drain
17
+ * the very leak it is trying to surface): it reads the raw top-level user file
18
+ * directly, never `readMeta()` (system-merged) and never the migration hook.
19
+ */
20
+ import { hasStaleMetaHeader, readTopLevelUserMeta } from './state.js';
21
+ function isMap(v) {
22
+ return !!v && typeof v === 'object' && !Array.isArray(v);
23
+ }
24
+ /**
25
+ * Inspect this box's on-disk state for config drift. Pure read — no migration, no
26
+ * writes. Mirrors the gather step of {@link migrateDeviceConfigStores} (plus the
27
+ * browser tombstone) so a leak here is exactly what a fold would drain.
28
+ */
29
+ export function detectConfigDrift() {
30
+ const staleHeader = hasStaleMetaHeader();
31
+ const raw = readTopLevelUserMeta();
32
+ const centralLeaks = [];
33
+ if (raw) {
34
+ // P1: the leftover central `browser:` tombstone (should fold into deviceBrowser).
35
+ if (isMap(raw.browser) && Object.keys(raw.browser).length > 0) {
36
+ centralLeaks.push('browser');
37
+ }
38
+ // P2: fleet.discovery / fleet.ignored, and the short-lived per-device config
39
+ // store under fleet.devices.<name>.config — all fold into the device doc.
40
+ const fleet = isMap(raw.fleet) ? raw.fleet : undefined;
41
+ if (fleet) {
42
+ if (isMap(fleet.discovery) && Object.keys(fleet.discovery).length > 0) {
43
+ centralLeaks.push('fleet.discovery');
44
+ }
45
+ if (Array.isArray(fleet.ignored) && fleet.ignored.length > 0) {
46
+ centralLeaks.push('fleet.ignored');
47
+ }
48
+ if (isMap(fleet.devices)) {
49
+ const anyDeviceConfig = Object.values(fleet.devices).some((ov) => isMap(ov) && isMap(ov.config) && Object.keys(ov.config).length > 0);
50
+ if (anyDeviceConfig)
51
+ centralLeaks.push('fleet.devices.*.config');
52
+ }
53
+ }
54
+ // P2: the central `hosts:` registry (should fold into deviceHosts).
55
+ if (isMap(raw.hosts) && Object.keys(raw.hosts).length > 0) {
56
+ centralLeaks.push('hosts');
57
+ }
58
+ // P2: central native accounts marked scope:'device' — identity PII that
59
+ // belongs in the device doc, off the git-tracked shared file. Fleet-shared /
60
+ // version-scoped identities stay central and are NOT a leak.
61
+ const accounts = isMap(raw.accounts) ? raw.accounts : undefined;
62
+ const native = accounts && isMap(accounts.native) ? accounts.native : undefined;
63
+ if (native && Object.values(native).some((a) => isMap(a) && a.scope === 'device')) {
64
+ centralLeaks.push('accounts (device-scoped)');
65
+ }
66
+ }
67
+ return { staleHeader, centralLeaks };
68
+ }
69
+ /** True when either drift class is present. */
70
+ export function hasConfigDrift(d) {
71
+ return d.staleHeader || d.centralLeaks.length > 0;
72
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
3
+ *
4
+ * Each daemon that has a local file-backed `auth` bundle pushes it to pinned
5
+ * fleet devices that do not yet have it. The destination auto-provisions its
6
+ * own machine-local key; no passphrase is forwarded. Single-executor per
7
+ * destination: this process only writes the remote's own store.
8
+ */
9
+ import { BasePeriodicService, type DaemonContext } from './service.js';
10
+ import type { DaemonServiceId } from '../daemon-services.js';
11
+ export declare class AuthSyncService extends BasePeriodicService {
12
+ readonly id: DaemonServiceId;
13
+ readonly intervalMs: number;
14
+ readonly deadlineMs: number;
15
+ readonly startupDelayMs = 60000;
16
+ protected onStart(_ctx: DaemonContext): Promise<void>;
17
+ protected onStop(): Promise<void>;
18
+ protected onTick(ctx: DaemonContext): Promise<void>;
19
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
3
+ *
4
+ * Each daemon that has a local file-backed `auth` bundle pushes it to pinned
5
+ * fleet devices that do not yet have it. The destination auto-provisions its
6
+ * own machine-local key; no passphrase is forwarded. Single-executor per
7
+ * destination: this process only writes the remote's own store.
8
+ */
9
+ import { BasePeriodicService } from './service.js';
10
+ const AUTH_SYNC_TICK_MS = 15 * 60_000;
11
+ const AUTH_SYNC_DEADLINE_MS = 2 * 60_000;
12
+ const AUTH_SYNC_KICKOFF_MS = 60_000;
13
+ export class AuthSyncService extends BasePeriodicService {
14
+ id = 'auth-sync';
15
+ intervalMs = AUTH_SYNC_TICK_MS;
16
+ deadlineMs = AUTH_SYNC_DEADLINE_MS;
17
+ startupDelayMs = AUTH_SYNC_KICKOFF_MS;
18
+ async onStart(_ctx) {
19
+ // No connections to open — each tick re-reads the local bundle + registry.
20
+ }
21
+ async onStop() {
22
+ // Nothing to release — the supervisor's timer teardown is the only cleanup.
23
+ }
24
+ async onTick(ctx) {
25
+ const { syncReservedAuthBundle } = await import('../secrets/reserved-sync.js');
26
+ const result = syncReservedAuthBundle();
27
+ if (result.pushed.length > 0) {
28
+ ctx.log('INFO', `auth-sync: pushed auth to ${result.pushed.join(', ')}`);
29
+ }
30
+ for (const err of result.errors) {
31
+ ctx.log('WARN', `auth-sync: ${err.device}: ${err.message}`);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,14 @@
1
+ /** Abandoned browser-task cleanup under supervision. */
2
+ import { BrowserService } from '../browser/service.js';
3
+ import type { DaemonServiceId } from '../daemon-services.js';
4
+ import { BasePeriodicService, type DaemonContext } from './service.js';
5
+ export declare class BrowserTaskReapService extends BasePeriodicService {
6
+ private readonly browserService;
7
+ readonly id: DaemonServiceId;
8
+ readonly intervalMs: number;
9
+ readonly deadlineMs: number;
10
+ constructor(browserService: BrowserService);
11
+ protected onStart(_ctx: DaemonContext): Promise<void>;
12
+ protected onStop(): Promise<void>;
13
+ protected onTick(ctx: DaemonContext): Promise<void>;
14
+ }
@@ -0,0 +1,26 @@
1
+ /** Abandoned browser-task cleanup under supervision. */
2
+ import { resolveBrowserTaskIdleMs } from '../device-config.js';
3
+ import { BasePeriodicService } from './service.js';
4
+ const BROWSER_TASK_REAP_TICK_MS = 5 * 60_000;
5
+ const BROWSER_TASK_REAP_DEADLINE_MS = 2 * 60_000;
6
+ export class BrowserTaskReapService extends BasePeriodicService {
7
+ browserService;
8
+ id = 'browser-task-reap';
9
+ intervalMs = BROWSER_TASK_REAP_TICK_MS;
10
+ deadlineMs = BROWSER_TASK_REAP_DEADLINE_MS;
11
+ constructor(browserService) {
12
+ super();
13
+ this.browserService = browserService;
14
+ }
15
+ async onStart(_ctx) { }
16
+ async onStop() { }
17
+ async onTick(ctx) {
18
+ const result = await this.browserService.reapAbandoned({ idleMs: resolveBrowserTaskIdleMs() });
19
+ if (result.closed.length > 0) {
20
+ ctx.log('INFO', `Browser-task reaper: closed ${result.closed.length} task(s)`);
21
+ for (const closed of result.closed) {
22
+ ctx.log('INFO', ` ${closed.task} (${closed.reason}, profile ${closed.profile})`);
23
+ }
24
+ }
25
+ }
26
+ }