@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -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 +220 -174
  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 +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -2,34 +2,108 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import * as yaml from 'yaml';
5
- import { getUserAgentsDir, readMeta, updateMeta } from '../state.js';
5
+ import { getUserAgentsDir, readMeta, updateMeta, withMetaLock, writeMetaUnlocked } from '../state.js';
6
6
  function isRecord(value) {
7
7
  return !!value && typeof value === 'object' && !Array.isArray(value);
8
8
  }
9
- /** Leftover central `browser:` map from before per-device declarations. Empty when none remain. */
9
+ /**
10
+ * The leftover central `browser:` tombstone from before per-device declarations.
11
+ * Empty when none remain — and `agents sync` now drains it automatically via
12
+ * {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
13
+ * {@link profileRegistry} is the single source of truth. It survives only to
14
+ * fold in migration and to explain an as-yet-unclaimed profile in a resolve
15
+ * error (a profile hostable on a peer that has not synced yet), never as a
16
+ * parallel store callers read for resolution.
17
+ */
10
18
  export function centralBrowserProfiles() {
11
19
  const central = readMeta().browser;
12
20
  if (!central || Object.keys(central).length === 0)
13
21
  return {};
14
22
  return { ...central };
15
23
  }
24
+ /**
25
+ * Pick the central `browser:` entries this machine may fold into its device doc.
26
+ *
27
+ * A profile is claimable only when `canHostHere` accepts it — the endpoint or
28
+ * binary resolves to THIS machine — so a `cdp://localhost:*` profile owned by a
29
+ * peer is never claimed here (the logged-out-headless-browser bug this module
30
+ * exists to prevent). A name already declared locally with an IDENTICAL config
31
+ * is folded too, which just drops the redundant central copy. A name declared
32
+ * locally with a DIFFERENT config is a genuine conflict: `onConflict: 'throw'`
33
+ * (the explicit `claim` command) surfaces it; `onConflict: 'skip'` (automatic
34
+ * eviction on sync) leaves it central so a stray duplicate can never wedge a
35
+ * sync — an operator resolves it with an explicit claim.
36
+ */
37
+ function selectClaimableCentral(central, local, canHostHere, opts) {
38
+ const toClaim = {};
39
+ const skipped = [];
40
+ for (const [profileName, config] of Object.entries(central)) {
41
+ if (opts.name && profileName !== opts.name)
42
+ continue;
43
+ if (!canHostHere(config)) {
44
+ skipped.push(profileName);
45
+ continue;
46
+ }
47
+ const existing = local[profileName];
48
+ if (existing && !isDeepStrictEqual(existing, config)) {
49
+ if (opts.onConflict === 'throw') {
50
+ throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
51
+ `agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
52
+ }
53
+ skipped.push(profileName);
54
+ continue;
55
+ }
56
+ toClaim[profileName] = config;
57
+ }
58
+ return { toClaim, skipped };
59
+ }
60
+ /**
61
+ * Return `current` with `toClaim` moved out of the central `browser:` map and
62
+ * into this device's `deviceBrowser` doc. The central key is dropped entirely
63
+ * once drained, so the tombstone disappears rather than lingering as an empty
64
+ * map. Pure — the caller applies it under the meta lock.
65
+ */
66
+ function buildEvictedMeta(current, toClaim) {
67
+ const legacy = current;
68
+ const remaining = {};
69
+ for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
70
+ if (!(profileName in toClaim))
71
+ remaining[profileName] = config;
72
+ }
73
+ const { browser: _removed, ...withoutCentralBrowser } = legacy;
74
+ return {
75
+ ...withoutCentralBrowser,
76
+ ...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
77
+ deviceBrowser: { ...current.deviceBrowser, ...toClaim },
78
+ };
79
+ }
80
+ /**
81
+ * Commit a claim computed by the EXPLICIT path (`migrateCentralBrowserProfiles`).
82
+ * `toClaim` was selected from an unlocked snapshot — acceptable for that manual,
83
+ * deliberate command; the automatic path instead selects INSIDE the lock (see
84
+ * {@link autoEvictCentralBrowserProfiles}) so an unrelated concurrent write can
85
+ * never be clobbered by a stale selection.
86
+ */
87
+ function commitCentralClaim(toClaim) {
88
+ updateMeta((current) => buildEvictedMeta(current, toClaim));
89
+ }
16
90
  /**
17
91
  * Fold leftover central `browser:` entries into THIS device's declaration file.
18
92
  *
19
- * Never called implicitly, and that is the point. Every device can read the
20
- * central map, so an implicit claim races: whichever box happens to read first
21
- * claims the name, {@link profileKind} then reports it `identity`, and the
22
- * daemon tunnels to that box — which for a fleet-wide `cdp://localhost:*`
23
- * profile is a logged-out headless browser wearing a credentialed browser's
24
- * name. That is the exact bug this module exists to remove, and an implicit
25
- * migration would write it to disk as a stored fact.
93
+ * The EXPLICIT operator action (`agents browser profiles claim`), run on the
94
+ * machine that actually owns the browser. It is never called implicitly from a
95
+ * read, and that is the point: every device can read the central map, so an
96
+ * implicit claim races — whichever box reads first claims the name, {@link
97
+ * profileKind} then reports it `identity`, and the daemon tunnels to that box,
98
+ * which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
99
+ * browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
100
+ * closes that race for the automatic path (host-gated + non-throwing on sync);
101
+ * this one is the manual, throw-on-conflict counterpart.
26
102
  *
27
- * The claim is an explicit operator action (`agents browser profiles claim`),
28
- * run on the machine that actually owns the browser. `canHostHere` is supplied
29
- * by the command layer so this module stays a leaf — it must not import
30
- * `isProfileLaunchableHere` (that would cycle through chrome.ts → profiles.ts).
31
- * Only profiles this machine can host are claimed; the rest stay central,
32
- * undeclared, and fail loudly on resolve.
103
+ * `canHostHere` is supplied by the command layer so this module stays a leaf —
104
+ * it must not import `isProfileLaunchableHere` (that would cycle through
105
+ * chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
106
+ * rest stay central, undeclared, and fail loudly on resolve.
33
107
  */
34
108
  export function migrateCentralBrowserProfiles(canHostHere, name) {
35
109
  const meta = readMeta();
@@ -53,42 +127,64 @@ export function migrateCentralBrowserProfiles(canHostHere, name) {
53
127
  `(its browser/binary isn't installed here). Run this command on the machine that has that browser.`);
54
128
  }
55
129
  }
56
- const toClaim = {};
57
- const skipped = [];
58
- for (const [profileName, config] of Object.entries(central)) {
59
- if (name && profileName !== name)
60
- continue;
61
- if (!canHostHere(config)) {
62
- skipped.push(profileName);
63
- continue;
64
- }
65
- const existing = local[profileName];
66
- if (existing && !isDeepStrictEqual(existing, config)) {
67
- throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
68
- `agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
69
- }
70
- toClaim[profileName] = config;
71
- }
130
+ const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
131
+ name,
132
+ onConflict: 'throw',
133
+ });
72
134
  const claimed = Object.keys(toClaim).sort();
73
135
  skipped.sort();
74
136
  if (claimed.length === 0)
75
137
  return { claimed, skipped };
76
- updateMeta((current) => {
77
- const legacy = current;
78
- const remaining = {};
79
- for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
80
- if (!(profileName in toClaim))
81
- remaining[profileName] = config;
82
- }
83
- const { browser: _removed, ...withoutCentralBrowser } = legacy;
84
- return {
85
- ...withoutCentralBrowser,
86
- ...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
87
- deviceBrowser: { ...current.deviceBrowser, ...toClaim },
88
- };
89
- });
138
+ commitCentralClaim(toClaim);
90
139
  return { claimed, skipped };
91
140
  }
141
+ /**
142
+ * Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
143
+ * fold every lingering central `browser:` profile THIS machine can host into its
144
+ * device doc and clear it from central, so the tombstone drains itself on
145
+ * `agents sync` with no manual `agents browser profiles claim`.
146
+ *
147
+ * Safe to call implicitly, unlike a claim at registry-read time (which races —
148
+ * see the `profileRegistry does not claim central declarations` guard), but the
149
+ * CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
150
+ * boxes can both host the same profile (`canHostHere` is launchability, not
151
+ * ownership), so a box acting on a PRE-pull view of central could re-claim a
152
+ * profile a peer already drained and pushed — both writes land in different
153
+ * device files with no git conflict, and the profile flips identity->fungible.
154
+ * Running post-pull means central already reflects peers' drains, so the tombstone
155
+ * is gone locally before this box looks. The selection is also computed INSIDE
156
+ * the meta lock, and the path never throws — an unhostable profile or one that
157
+ * conflicts with an existing local declaration is left central for an explicit
158
+ * claim rather than wedging the sync. After it runs, {@link profileRegistry} is
159
+ * the single source of truth: a claimed profile lives in the device doc alone,
160
+ * never double-counted across the two stores.
161
+ */
162
+ export function autoEvictCentralBrowserProfiles(canHostHere) {
163
+ // Read fresh, select, and commit inside ONE meta-lock acquisition. Reading
164
+ // central before the lock and committing after (the manual path's shape) leaves
165
+ // a window where a concurrent write on THIS machine changes central/deviceBrowser
166
+ // between snapshot and commit, so the stale selection would be merged over the
167
+ // fresher value. Selecting from the state the lock just handed us closes that
168
+ // window. We write ONLY when something is claimed, so a no-op sync never touches
169
+ // any doc (writeMetaUnlocked would otherwise re-serialize the device doc every
170
+ // run). This mirrors updateMeta's own body, minus the unconditional write.
171
+ return withMetaLock(() => {
172
+ const meta = readMeta();
173
+ const central = meta.browser;
174
+ if (!central || Object.keys(central).length === 0)
175
+ return { claimed: [], skipped: [] };
176
+ const local = meta.deviceBrowser ?? {};
177
+ const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
178
+ onConflict: 'skip',
179
+ });
180
+ const claimed = Object.keys(toClaim).sort();
181
+ skipped.sort();
182
+ if (claimed.length === 0)
183
+ return { claimed, skipped };
184
+ writeMetaUnlocked(buildEvictedMeta(meta, toClaim));
185
+ return { claimed, skipped };
186
+ });
187
+ }
92
188
  /**
93
189
  * Every profile any device declares, keyed by name to all declaring devices.
94
190
  * Reads every `devices/<name>/agents.yaml`; declarations never overwrite.
@@ -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.
@@ -21,9 +23,9 @@
21
23
  * the attach paths ungated (RUSH-3064). {@link assertRemoteControlAllowed} remains
22
24
  * as a fast-fail CLI-side check so a refused `start` never auto-creates a profile.
23
25
  */
24
- /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
26
+ /** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation` and `markFleetRemote`. */
25
27
  export declare const FLEET_REMOTE_ENV = "AGENTS_FLEET_REMOTE";
26
- /** True when this process was dispatched to this machine by a fleet `--device` run. */
28
+ /** True when this process was dispatched to this machine by a fleet `--device` or `agents ssh … agents browser` run. */
27
29
  export declare function isFleetRemoteInvocation(env?: NodeJS.ProcessEnv): boolean;
28
30
  /**
29
31
  * Whether this machine allows other fleet machines to drive its browser. Reads
@@ -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
  }
@@ -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
+ }
@@ -28,6 +28,7 @@ import { startHostedWebhookReceivers } from '../daemon-webhooks.js';
28
28
  import { secretsBrokerSocketPath, brokerPidAlive } from '../secrets/agent.js';
29
29
  import { redactSecrets } from '../redact.js';
30
30
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from '../cli-entry.js';
31
+ import { localBinDir } from '../platform/posixpath.js';
31
32
  import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled, resolveBrowserTaskIdleMs } from '../device-config.js';
32
33
  import { reapTerminalRoutineProcesses } from '../routine-process-cleanup.js';
33
34
  import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
@@ -43,6 +44,7 @@ import { WatchdogService } from './watchdog-service.js';
43
44
  import { DeviceProbeService } from './device-probe-service.js';
44
45
  import { SelfHealService } from './self-heal-service.js';
45
46
  import { KeychainReapService } from './keychain-reap-service.js';
47
+ import { AuthSyncService } from './auth-sync-service.js';
46
48
  import { StateDirCheckService } from './state-dir-check-service.js';
47
49
  import { emit, emitRoutineEnd } from '../feed/events.js';
48
50
  import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
@@ -934,6 +936,10 @@ export async function runDaemon() {
934
936
  supervisor.register(new KeychainReapService());
935
937
  else
936
938
  log('INFO', 'Keychain-reap service disabled');
939
+ if (isEnabled('auth-sync'))
940
+ supervisor.register(new AuthSyncService());
941
+ else
942
+ log('INFO', 'Auth-sync service disabled');
937
943
  await supervisor.startAll({ log });
938
944
  activeServiceSupervisor = supervisor;
939
945
  // scheduler.enabled=false in this machine's device doc means NO routines fire
@@ -1800,10 +1806,23 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
1800
1806
  function daemonNodeBinDir() {
1801
1807
  return path.dirname(process.execPath);
1802
1808
  }
1809
+ /**
1810
+ * Login-shell user-bin dirs a service-manager-started daemon would otherwise
1811
+ * miss. systemd/launchd pin PATH and never source `~/.profile`, so they never
1812
+ * see `~/.rush/bin` (where `rush` lands) or `~/.local/bin` (XDG user-bin).
1813
+ * Monitor `notify` and dispatched `agents run` children inherit this PATH;
1814
+ * without these dirs the rush-backed owner channel fails with
1815
+ * `rush CLI not found on PATH` (PHNX-3075) while an interactive shell on the
1816
+ * same box succeeds. Uses the same HOME the manifest bakes (RUSH-2639).
1817
+ */
1818
+ function daemonUserBinDirs() {
1819
+ const home = serviceManifestHomeEnv().HOME;
1820
+ return [path.join(home, '.rush', 'bin'), localBinDir(home)];
1821
+ }
1803
1822
  /**
1804
1823
  * The full PATH value the daemon service manifest pins, in order: the directory
1805
- * of the `agents` shim itself FIRST, then the Node runtime dir, then the
1806
- * platform's system dirs.
1824
+ * of the `agents` shim itself FIRST, then the Node runtime dir, then login-shell
1825
+ * user-bin dirs (`~/.rush/bin`, `~/.local/bin`), then the platform's system dirs.
1807
1826
  *
1808
1827
  * The shim's own dir must lead so a scheduled `command` routine that shells out
1809
1828
  * to the bare name `agents` (`/bin/sh -c 'agents repo pull system'`) resolves the
@@ -1814,12 +1833,19 @@ function daemonNodeBinDir() {
1814
1833
  *
1815
1834
  * The Node runtime dir stays second so the shim's shebang (`#!/usr/bin/env node`)
1816
1835
  * still resolves the exact Node that installed the service — never an ancient
1817
- * system node or a pruned nvm version. Deduped across the whole list, so a
1836
+ * system node or a pruned nvm version. User-bin dirs sit after that pair so a
1837
+ * `~/.local/bin/agents` cannot shadow the daemon binary, but `rush` (and other
1838
+ * login-shell CLIs) still resolve. Deduped across the whole list, so a
1818
1839
  * Node/shim dir that already appears among the system dirs (e.g. a
1819
1840
  * `/usr/local/bin` install) never doubles.
1820
1841
  */
1821
1842
  function daemonPathValue(agentsBin, systemDirs) {
1822
- return [...new Set([path.dirname(agentsBin), daemonNodeBinDir(), ...systemDirs])].join(':');
1843
+ return [...new Set([
1844
+ path.dirname(agentsBin),
1845
+ daemonNodeBinDir(),
1846
+ ...daemonUserBinDirs(),
1847
+ ...systemDirs,
1848
+ ])].join(':');
1823
1849
  }
1824
1850
  /**
1825
1851
  * Build the argv to relaunch the `agents` CLI with the given subcommand args.
@@ -1313,10 +1313,17 @@ async function executeJobPlaced(config, deps, attempt) {
1313
1313
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
1314
1314
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
1315
1315
  // (command jobs branched out earlier, so config.agent is set on the non-workflow path.)
1316
+ // Custom harness/profile name (e.g. `deepseek`) stays on harnessName; the
1317
+ // HOST CLI is effectiveAgent. The loop path spawns in-process via runLoop
1318
+ // and never re-enters `agents run`, so it must stamp harnessName itself
1319
+ // (PHNX-2935) — same field commands/exec.ts sets on ExecOptions.
1320
+ const harnessName = !config.workflow && config.agent && isCustomHarnessName(config.agent)
1321
+ ? config.agent
1322
+ : undefined;
1316
1323
  const effectiveAgent = config.workflow
1317
1324
  ? 'claude'
1318
- : isCustomHarnessName(config.agent)
1319
- ? readProfile(config.agent).host.agent
1325
+ : harnessName
1326
+ ? readProfile(harnessName).host.agent
1320
1327
  : config.agent;
1321
1328
  if (!dispatchesViaAgentsRun(config)) {
1322
1329
  const { findUnifiedAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
@@ -1377,6 +1384,7 @@ async function executeJobPlaced(config, deps, attempt) {
1377
1384
  const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone, overlayHome);
1378
1385
  const execOptions = {
1379
1386
  agent: effectiveAgent,
1387
+ harnessName,
1380
1388
  // Routine-supported self-updating CLIs (Cursor/Droid) use one global
1381
1389
  // binary; a versioned shim would point at a nonexistent isolated install.
1382
1390
  version: isSelfUpdatingAgent(effectiveAgent) ? undefined : primaryVersion,
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index';
10
+ export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -72,6 +72,11 @@ export const DAEMON_SERVICES = [
72
72
  title: 'Session-index warm',
73
73
  description: 'Keeps this host\'s transcript index current so a locally-started session is discoverable within seconds.',
74
74
  },
75
+ {
76
+ id: 'auth-sync',
77
+ title: 'Auth bundle sync',
78
+ description: 'Pushes the reserved file-backed auth bundle (setup-tokens) to pinned fleet devices that do not yet have it.',
79
+ },
75
80
  ];
76
81
  /** Stable order of service IDs. */
77
82
  export const DAEMON_SERVICE_IDS = DAEMON_SERVICES.map((s) => s.id);