@phnx-labs/agents-cli 1.22.69 → 1.22.71

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 (130) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -9
  3. package/dist/bootstrap.js +4 -4
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +2 -2
  6. package/dist/commands/import.js +2 -2
  7. package/dist/commands/models.js +2 -2
  8. package/dist/commands/permissions.js +2 -2
  9. package/dist/commands/repo.js +2 -2
  10. package/dist/commands/rules.js +1 -1
  11. package/dist/commands/send.js +9 -3
  12. package/dist/commands/sessions-export.d.ts +5 -1
  13. package/dist/commands/sessions-export.js +100 -24
  14. package/dist/commands/sessions-import.d.ts +2 -1
  15. package/dist/commands/sessions-import.js +85 -21
  16. package/dist/commands/traces.js +1 -1
  17. package/dist/lib/account-capabilities.js +0 -2
  18. package/dist/lib/account-registry.d.ts +3 -3
  19. package/dist/lib/account-registry.js +25 -7
  20. package/dist/lib/accounting/usage-sync.d.ts +1 -1
  21. package/dist/lib/accounting/usage-sync.js +3 -3
  22. package/dist/lib/acp/client.d.ts +1 -1
  23. package/dist/lib/acp/client.js +12 -1
  24. package/dist/lib/acp/harnesses.js +1 -1
  25. package/dist/lib/add-dir.js +0 -2
  26. package/dist/lib/agent-cli-commands.js +0 -2
  27. package/dist/lib/agent-spec/agents.d.ts +1 -1
  28. package/dist/lib/agent-spec/agents.js +2 -83
  29. package/dist/lib/browser/ipc.d.ts +34 -0
  30. package/dist/lib/browser/ipc.js +149 -20
  31. package/dist/lib/browser/remote-control.d.ts +6 -3
  32. package/dist/lib/browser/remote-control.js +6 -3
  33. package/dist/lib/browser/service.d.ts +4 -1
  34. package/dist/lib/browser/service.js +7 -1
  35. package/dist/lib/browser/types.d.ts +3 -1
  36. package/dist/lib/channels/registry.d.ts +6 -0
  37. package/dist/lib/channels/send.d.ts +11 -2
  38. package/dist/lib/channels/send.js +11 -2
  39. package/dist/lib/cloud/rush.d.ts +10 -2
  40. package/dist/lib/cloud/rush.js +15 -9
  41. package/dist/lib/daemon/auth-sync-service.js +1 -1
  42. package/dist/lib/daemon/browser-task-reap-service.js +1 -1
  43. package/dist/lib/daemon/daemon.js +41 -11
  44. package/dist/lib/daemon/heartbeat-service.js +3 -3
  45. package/dist/lib/daemon/keychain-reap-service.js +1 -1
  46. package/dist/lib/daemon/runner.d.ts +18 -1
  47. package/dist/lib/daemon/runner.js +237 -80
  48. package/dist/lib/daemon/self-heal-service.js +13 -3
  49. package/dist/lib/daemon/self-update-service.d.ts +174 -0
  50. package/dist/lib/daemon/self-update-service.js +353 -0
  51. package/dist/lib/daemon/state-dir-check-service.js +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +1 -1
  53. package/dist/lib/daemon/watchdog-service.js +4 -4
  54. package/dist/lib/daemon-services.d.ts +1 -1
  55. package/dist/lib/daemon-services.js +5 -0
  56. package/dist/lib/device-config.d.ts +12 -1
  57. package/dist/lib/device-config.js +63 -13
  58. package/dist/lib/exec-bounded.d.ts +52 -0
  59. package/dist/lib/exec-bounded.js +113 -0
  60. package/dist/lib/exec.d.ts +2 -2
  61. package/dist/lib/exec.js +3 -34
  62. package/dist/lib/feed/events.d.ts +22 -14
  63. package/dist/lib/feed/events.js +84 -44
  64. package/dist/lib/feed-broadcast.d.ts +12 -29
  65. package/dist/lib/feed-broadcast.js +28 -27
  66. package/dist/lib/fleet-shared-state.d.ts +12 -5
  67. package/dist/lib/fleet-shared-state.js +50 -20
  68. package/dist/lib/fs-atomic.d.ts +11 -0
  69. package/dist/lib/fs-atomic.js +60 -0
  70. package/dist/lib/hooks/install.js +0 -87
  71. package/dist/lib/hosts/reconcile.d.ts +11 -4
  72. package/dist/lib/hosts/reconcile.js +31 -5
  73. package/dist/lib/installations/strategies.js +1 -1
  74. package/dist/lib/mcp-registry.js +0 -13
  75. package/dist/lib/mcp.js +2 -2
  76. package/dist/lib/model-tiers.js +1 -1
  77. package/dist/lib/models.js +0 -63
  78. package/dist/lib/notify.d.ts +11 -0
  79. package/dist/lib/notify.js +17 -4
  80. package/dist/lib/owner-message.d.ts +46 -3
  81. package/dist/lib/owner-message.js +26 -6
  82. package/dist/lib/permissions-registry.d.ts +0 -2
  83. package/dist/lib/permissions-registry.js +3 -50
  84. package/dist/lib/permissions.d.ts +3 -17
  85. package/dist/lib/permissions.js +4 -73
  86. package/dist/lib/project-resources.d.ts +12 -0
  87. package/dist/lib/project-resources.js +129 -0
  88. package/dist/lib/routine-process-cleanup.d.ts +2 -2
  89. package/dist/lib/routine-process-cleanup.js +45 -34
  90. package/dist/lib/rush-session.d.ts +19 -0
  91. package/dist/lib/rush-session.js +24 -0
  92. package/dist/lib/secrets/drivers/rush.js +2 -1
  93. package/dist/lib/secrets/reaper.d.ts +2 -2
  94. package/dist/lib/secrets/reaper.js +13 -10
  95. package/dist/lib/secrets/reserved-sync.d.ts +1 -1
  96. package/dist/lib/secrets/reserved-sync.js +4 -4
  97. package/dist/lib/self-update.d.ts +21 -8
  98. package/dist/lib/self-update.js +54 -31
  99. package/dist/lib/session/cloud.js +2 -1
  100. package/dist/lib/session/sync/backend.d.ts +61 -0
  101. package/dist/lib/session/sync/backend.js +89 -0
  102. package/dist/lib/session/sync/managed-config.d.ts +29 -0
  103. package/dist/lib/session/sync/managed-config.js +23 -0
  104. package/dist/lib/session/sync/managed-key.d.ts +45 -0
  105. package/dist/lib/session/sync/managed-key.js +128 -0
  106. package/dist/lib/session/sync/net-client.d.ts +65 -0
  107. package/dist/lib/session/sync/net-client.js +117 -0
  108. package/dist/lib/session/sync/provision.d.ts +19 -0
  109. package/dist/lib/session/sync/provision.js +38 -0
  110. package/dist/lib/session/sync/r2.d.ts +5 -2
  111. package/dist/lib/session/sync/r2.js +5 -2
  112. package/dist/lib/session/sync/worker-template.d.ts +6 -0
  113. package/dist/lib/session/sync/worker-template.js +847 -0
  114. package/dist/lib/sink-format.d.ts +34 -0
  115. package/dist/lib/sink-format.js +17 -0
  116. package/dist/lib/staleness/detectors/permissions.js +0 -20
  117. package/dist/lib/staleness/writers/commands.js +1 -1
  118. package/dist/lib/staleness/writers/hooks.js +2 -2
  119. package/dist/lib/subagents-registry.js +2 -12
  120. package/dist/lib/subagents.d.ts +0 -10
  121. package/dist/lib/subagents.js +0 -37
  122. package/dist/lib/tmux/orphan-reap.js +6 -4
  123. package/dist/lib/tmux/session.js +4 -1
  124. package/dist/lib/traces/classify.d.ts +8 -1
  125. package/dist/lib/traces/insights.d.ts +13 -1
  126. package/dist/lib/traces/insights.js +78 -3
  127. package/dist/lib/traces/sync.js +8 -3
  128. package/dist/lib/traces/worker-template.js +9 -5
  129. package/dist/lib/types.d.ts +1 -1
  130. package/package.json +1 -1
@@ -27,8 +27,11 @@ import { listLocalTranscripts, objectKey, SYNC_AGENTS } from '../lib/session/syn
27
27
  import { machineId } from '../lib/machine-id.js';
28
28
  import { getHistoryDir } from '../lib/state.js';
29
29
  import { isSyncConfigured, loadR2Config } from '../lib/session/sync/config.js';
30
- import { resolveSyncEncKey, generateSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
30
+ import { resolveSyncEncKey, generateSyncEncKey, isTranscriptEnvelope, } from '../lib/session/sync/transcript-crypto.js';
31
31
  import { R2Client } from '../lib/session/sync/r2.js';
32
+ import { resolveSessionsBackend } from '../lib/session/sync/backend.js';
33
+ import { SessionsHttpClient } from '../lib/session/sync/net-client.js';
34
+ import { resolveManagedBackupKey } from '../lib/session/sync/managed-key.js';
32
35
  import { buildRecord, makeHeader, mergeRecords, serializeBundle, writeBundleFile, specForAgent, } from '../lib/session/bundle.js';
33
36
  import { knownSecretValuesFromEnv } from '../lib/redact.js';
34
37
  import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
@@ -42,7 +45,8 @@ export function registerSessionsExportCommand(sessionsCmd) {
42
45
  .option('-o, --output <path>', 'Write the bundle to this file')
43
46
  .option('--stdout', 'Write the bundle to stdout (for piping into `sessions import -`)')
44
47
  .option('--encrypt', 'Seal each transcript body with AES-256-GCM before writing')
45
- .option('--to-r2', 'Back the selected sessions up to Cloudflare R2 (requires the r2.backups bundle) instead of a local file');
48
+ .option('--to-r2', 'Back the selected sessions up off-box (managed Phoenix store when signed in; your own r2.backups bucket with --byo) instead of a local file')
49
+ .option('--byo', 'With --to-r2: force your own r2.backups bucket instead of the managed Phoenix store');
46
50
  setHelpSections(cmd, {
47
51
  examples: `# Bundle the last week of sessions to a file
48
52
  agents sessions export --since 7d -o week.bundle
@@ -53,17 +57,22 @@ agents sessions export 4f8a2b1c 9d3e7a55 -o pair.bundle
53
57
  # Encrypt + pipe straight into another machine over SSH
54
58
  agents sessions export --since 7d --stdout --encrypt | agents ssh boxB 'agents sessions import - --decrypt <key>'
55
59
 
56
- # Back the last month up off-box to Cloudflare R2 (encrypted with the shared key)
57
- agents sessions export --since 30d --to-r2`,
60
+ # Back the last month up off-box (managed Phoenix store — no bucket to set up)
61
+ agents sessions export --since 30d --to-r2
62
+
63
+ # Or to your own r2.backups bucket (R2_SYNC_ENC_KEY keeps it zero-knowledge)
64
+ agents sessions export --since 30d --to-r2 --byo`,
58
65
  notes: `Selection uses the same flags as 'agents sessions' (--since, -n/--limit, --all,
59
66
  -a/--agent, --no-redact). Bundles are self-describing NDJSON: a header line + one
60
67
  line per transcript file. Secrets are redacted by default. Dir-shaped sessions
61
68
  (Kimi) carry all their files. Restore with 'agents sessions import'.
62
69
 
63
- --to-r2 uploads each session to the r2.backups bucket instead of a local file,
64
- one encrypted object per transcript keyed by machine/agent/session. Bodies are
65
- sealed with the shared R2_SYNC_ENC_KEY when present. Restore on any box on the
66
- same bundle with 'agents sessions import --from-r2'.`,
70
+ --to-r2 backs each session up off-box, one encrypted object per transcript keyed
71
+ by machine/agent/session. When you are signed in ('agents auth login') it uploads
72
+ to the MANAGED Phoenix store — no Cloudflare or r2.backups bucket to set up — and
73
+ every body is sealed with a per-account key (mandatory; never plaintext). --byo
74
+ forces your own r2.backups bucket instead; with R2_SYNC_ENC_KEY, neither Phoenix
75
+ nor the storage provider can decrypt it. Restore with 'agents sessions import --from-r2'.`,
67
76
  });
68
77
  cmd.action(async (selectors, _options, command) => {
69
78
  await runExport(selectors, command);
@@ -71,13 +80,42 @@ same bundle with 'agents sessions import --from-r2'.`,
71
80
  }
72
81
  async function runExport(selectors, command) {
73
82
  const g = command.optsWithGlobals();
74
- // --to-r2 preflight: reject the impossible --host combo and fail loud when the
75
- // backup target is not configured, rather than silently producing a local file.
76
- // isSyncConfigured() is consulted only on the --to-r2 path (it reads the keychain).
83
+ // --to-r2 preflight. MANAGED-FIRST: a signed-in user backs up to the managed
84
+ // Phoenix store with no bucket to set up; --byo (or an unauthenticated user
85
+ // with an r2.backups bundle) uses their own bucket. isSyncConfigured() is
86
+ // consulted ONLY on the BYO path (it reads the keychain); the managed path
87
+ // authenticates with the Phoenix session and skips it entirely.
88
+ let sessionsClient;
89
+ let managedClient;
90
+ let managedBackupUserId;
91
+ if (g.byo && !g.toR2) {
92
+ process.stderr.write(chalk.red('--byo is only valid with --to-r2.\n'));
93
+ process.exit(1);
94
+ }
77
95
  if (g.toR2) {
78
- const gateErr = r2ExportGateError(g, isSyncConfigured());
79
- if (gateErr) {
80
- process.stderr.write(chalk.red(gateErr + '\n'));
96
+ if (g.host && g.host.length > 0) {
97
+ process.stderr.write(chalk.red("--to-r2 backs up THIS machine's sessions; it cannot be combined with --device.\n"));
98
+ process.exit(1);
99
+ }
100
+ try {
101
+ const backend = resolveSessionsBackend({ byo: g.byo });
102
+ if (backend.kind === 'managed') {
103
+ managedClient = new SessionsHttpClient({ baseUrl: backend.baseUrl, userId: backend.userId, token: backend.token });
104
+ sessionsClient = managedClient;
105
+ managedBackupUserId = backend.userId;
106
+ }
107
+ else {
108
+ // Preserve the explicit BYO gate used by the on-demand backup path and
109
+ // daemon sync cycle. resolveSessionsBackend already loaded the bundle,
110
+ // so this is a cached, non-prompting verification.
111
+ const gateErr = r2ExportGateError(g, isSyncConfigured());
112
+ if (gateErr)
113
+ throw new Error(gateErr);
114
+ sessionsClient = new R2Client(backend.r2);
115
+ }
116
+ }
117
+ catch (err) {
118
+ process.stderr.write(chalk.red(`Session backup: ${err.message}\n`));
81
119
  process.exit(1);
82
120
  }
83
121
  }
@@ -142,9 +180,23 @@ async function runExport(selectors, command) {
142
180
  process.exit(1);
143
181
  }
144
182
  // 4. Resolve encryption key (opt-in) + redaction (default on via parent --no-redact).
145
- // An R2 backup uses the fleet-shared R2_SYNC_ENC_KEY (an ephemeral key would be
146
- // unrecoverable on a fresh box), so it never takes the resolveExportKey path.
147
- const encryptKey = g.toR2 ? resolveR2BackupKey() : g.encrypt ? resolveExportKey() : null;
183
+ // A BYO R2 backup uses the fleet-shared R2_SYNC_ENC_KEY (an ephemeral key would
184
+ // be unrecoverable on a fresh box). A MANAGED backup resolves a per-account DEK
185
+ // that is MANDATORY and never null — the managed path never uploads plaintext —
186
+ // minting + escrowing one on first use so a fresh box recovers it with zero setup.
187
+ let encryptKey;
188
+ if (g.toR2 && managedBackupUserId) {
189
+ encryptKey = await resolveManagedBackupKey(managedClient, managedBackupUserId);
190
+ }
191
+ else if (g.toR2) {
192
+ encryptKey = resolveR2BackupKey();
193
+ }
194
+ else if (g.encrypt) {
195
+ encryptKey = resolveExportKey();
196
+ }
197
+ else {
198
+ encryptKey = null;
199
+ }
148
200
  const redact = g.redact !== false;
149
201
  // Value-aware redaction: mask live credential values already in the
150
202
  // environment (e.g. an injected secrets bundle) verbatim, whatever their
@@ -172,7 +224,7 @@ async function runExport(selectors, command) {
172
224
  records,
173
225
  });
174
226
  if (g.toR2) {
175
- await uploadToR2(header, records);
227
+ await uploadToR2(header, records, sessionsClient);
176
228
  return;
177
229
  }
178
230
  emitBundle(header, records, g);
@@ -205,13 +257,28 @@ export function r2ExportGateError(g, isConfigured) {
205
257
  }
206
258
  return null;
207
259
  }
208
- export async function uploadToR2(header, records) {
260
+ export async function uploadToR2(header, records, resolvedClient) {
261
+ // The resolved client (managed HTTP or BYO R2) is passed in by the command.
262
+ // The no-client overload preserves the original BYO-only signature for the
263
+ // direct unit test (loadR2Config() from the r2.backups bundle).
209
264
  let client;
210
- try {
211
- client = new R2Client(loadR2Config());
265
+ if (resolvedClient) {
266
+ client = resolvedClient;
267
+ }
268
+ else {
269
+ try {
270
+ client = new R2Client(loadR2Config());
271
+ }
272
+ catch (err) {
273
+ process.stderr.write(chalk.red(`R2 backup: ${err.message}\n`));
274
+ process.exit(1);
275
+ }
212
276
  }
213
- catch (err) {
214
- process.stderr.write(chalk.red(`R2 backup: ${err.message}\n`));
277
+ const encryptionError = client.kind === 'managed'
278
+ ? managedUploadEncryptionError(header, records)
279
+ : null;
280
+ if (encryptionError) {
281
+ process.stderr.write(chalk.red(`${encryptionError}\n`));
215
282
  process.exit(1);
216
283
  }
217
284
  let uploaded = 0;
@@ -234,7 +301,16 @@ export async function uploadToR2(header, records) {
234
301
  uploaded++;
235
302
  }
236
303
  process.stderr.write(chalk.green(`Backed up ${header.sessions} session${header.sessions === 1 ? '' : 's'} ` +
237
- `(${uploaded} object${uploaded === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ', UNENCRYPTED'}) → R2.\n`));
304
+ `(${uploaded} object${uploaded === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ', UNENCRYPTED'}) → ` +
305
+ `${client.kind === 'managed' ? 'managed Phoenix store' : 'R2'}.\n`));
306
+ }
307
+ /** Fail-closed guard at the managed transport boundary, not only at record construction. */
308
+ export function managedUploadEncryptionError(header, records) {
309
+ if (!header.encrypted ||
310
+ records.some(record => !record.encrypted || !isTranscriptEnvelope(record.body))) {
311
+ return 'Managed session backups require AES-256-GCM envelopes; refusing to upload plaintext.';
312
+ }
313
+ return null;
238
314
  }
239
315
  /** R2 object key for one record — dir-shaped agents key by relKey, file-shaped by session. */
240
316
  export function r2KeyForRecord(rec) {
@@ -1,4 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import { type SessionsBackupClient } from '../lib/session/sync/net-client.js';
2
3
  import { type ParsedBundle } from '../lib/session/bundle.js';
3
4
  export declare function registerSessionsImportCommand(sessionsCmd: Command): void;
4
5
  /**
@@ -16,4 +17,4 @@ export declare function registerSessionsImportCommand(sessionsCmd: Command): voi
16
17
  * the command and its test drive the same decision.
17
18
  */
18
19
  export declare function r2ImportGateError(fromR2: boolean, isConfigured: boolean): string | null;
19
- export declare function pullFromR2(): Promise<ParsedBundle>;
20
+ export declare function pullFromR2(resolvedClient?: SessionsBackupClient): Promise<ParsedBundle>;
@@ -11,9 +11,12 @@
11
11
  import * as fs from 'fs';
12
12
  import chalk from 'chalk';
13
13
  import { isSyncConfigured, loadR2Config } from '../lib/session/sync/config.js';
14
- import { resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
14
+ import { isTranscriptEnvelope, resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
15
15
  import { R2Client } from '../lib/session/sync/r2.js';
16
16
  import { SESSIONS_PREFIX } from '../lib/session/sync/agents.js';
17
+ import { resolveSessionsBackend } from '../lib/session/sync/backend.js';
18
+ import { SessionsHttpClient } from '../lib/session/sync/net-client.js';
19
+ import { resolveManagedBackupKey } from '../lib/session/sync/managed-key.js';
17
20
  import { parseBundle, planImport, writeImport, mergeRecords, makeHeader, } from '../lib/session/bundle.js';
18
21
  import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
19
22
  import { setHelpSections } from '../lib/help.js';
@@ -25,7 +28,8 @@ export function registerSessionsImportCommand(sessionsCmd) {
25
28
  .option('--overwrite', 'Replace local files that differ from the bundle (default: keep local)')
26
29
  .option('--decrypt [key]', 'Decrypt an encrypted bundle (key optional if the r2.backups sync key is configured)')
27
30
  .option('--from-host <target...>', 'Pull sessions live from remote peer(s) over SSH instead of a file (repeatable)')
28
- .option('--from-r2', 'Restore session backups from Cloudflare R2 (requires the r2.backups bundle)');
31
+ .option('--from-r2', 'Restore session backups from the off-box store (managed Phoenix store when signed in; your own r2.backups bucket with --byo)')
32
+ .option('--byo', 'With --from-r2: restore from your own r2.backups bucket instead of the managed Phoenix store');
29
33
  setHelpSections(cmd, {
30
34
  examples: `# Preview what a bundle would restore
31
35
  agents sessions import week.bundle --dry-run
@@ -46,9 +50,12 @@ they show up in 'agents sessions' tagged with that machine and never overwrite
46
50
  your own local sessions. Byte-exact duplicates are skipped. --from-host reuses
47
51
  the same SSH transport as the cross-machine listing (no R2, no daemon).
48
52
 
49
- --from-r2 downloads every session backup in the r2.backups bucket and restores
50
- it through the same placement, using the shared R2_SYNC_ENC_KEY to decrypt (or
51
- pass --decrypt <key>). It is the inverse of 'sessions export --to-r2'.`,
53
+ --from-r2 downloads every session backup from the off-box store and restores it
54
+ through the same placement. When signed in it restores from the MANAGED Phoenix
55
+ store and decrypts with your per-account key (recovered automatically, even on a
56
+ fresh box); --byo restores from your own r2.backups bucket with its shared
57
+ R2_SYNC_ENC_KEY (or pass --decrypt <key>). It is the inverse of
58
+ 'sessions export --to-r2'.`,
52
59
  });
53
60
  cmd.action(async (bundlePath, options, command) => {
54
61
  const g = command.optsWithGlobals();
@@ -58,13 +65,39 @@ pass --decrypt <key>). It is the inverse of 'sessions export --to-r2'.`,
58
65
  async function runImport(bundlePath, options, g, command) {
59
66
  // 1. Obtain the bundle — from R2, remote peer(s), stdin, or a file.
60
67
  let bundle;
68
+ // A managed restore recovers the per-account DEK (from the local cache or the
69
+ // Worker escrow) and decrypts with it; BYO falls back to the r2.backups key.
70
+ let managedDecryptKey;
71
+ if (options.byo && !options.fromR2) {
72
+ process.stderr.write(chalk.red('--byo is only valid with --from-r2.\n'));
73
+ process.exit(1);
74
+ }
61
75
  if (options.fromR2) {
62
- const gateErr = r2ImportGateError(true, isSyncConfigured());
63
- if (gateErr) {
64
- process.stderr.write(chalk.red(gateErr + '\n'));
76
+ let client;
77
+ let managedClient;
78
+ let managedUserId;
79
+ try {
80
+ const backend = resolveSessionsBackend({ byo: options.byo });
81
+ if (backend.kind === 'managed') {
82
+ managedClient = new SessionsHttpClient({ baseUrl: backend.baseUrl, userId: backend.userId, token: backend.token });
83
+ client = managedClient;
84
+ managedUserId = backend.userId;
85
+ }
86
+ else {
87
+ const gateErr = r2ImportGateError(true, isSyncConfigured());
88
+ if (gateErr)
89
+ throw new Error(gateErr);
90
+ client = new R2Client(backend.r2);
91
+ }
92
+ }
93
+ catch (err) {
94
+ process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
65
95
  process.exit(1);
66
96
  }
67
- bundle = await pullFromR2();
97
+ bundle = await pullFromR2(client);
98
+ if (managedUserId) {
99
+ managedDecryptKey = await resolveManagedBackupKey(managedClient, managedUserId);
100
+ }
68
101
  }
69
102
  else if (options.fromHost && options.fromHost.length > 0) {
70
103
  bundle = await pullForImport(options.fromHost, bundlePath, g, command);
@@ -98,8 +131,12 @@ async function runImport(bundlePath, options, g, command) {
98
131
  process.exit(1);
99
132
  }
100
133
  }
101
- // 3. Resolve the decryption key if the bundle is encrypted.
102
- const decryptKey = bundle.header.encrypted ? resolveDecryptKey(options.decrypt) : null;
134
+ // 3. Resolve the decryption key if the bundle is encrypted. A managed restore
135
+ // already recovered the per-account DEK; otherwise fall back to an explicit
136
+ // --decrypt <key> or the shared r2.backups key.
137
+ const decryptKey = bundle.header.encrypted
138
+ ? (managedDecryptKey ?? resolveDecryptKey(options.decrypt))
139
+ : null;
103
140
  // 4. Plan.
104
141
  let plan;
105
142
  try {
@@ -193,21 +230,33 @@ export function r2ImportGateError(fromR2, isConfigured) {
193
230
  }
194
231
  return null;
195
232
  }
196
- export async function pullFromR2() {
233
+ export async function pullFromR2(resolvedClient) {
234
+ // The resolved client (managed HTTP or BYO R2) is passed in by the command; the
235
+ // no-client overload preserves the original BYO-only signature (loadR2Config)
236
+ // for the direct unit test. The managed client's list() ignores the prefix and
237
+ // enumerates the whole owner namespace, so one call site serves both.
197
238
  let client;
198
239
  let bucket;
199
- try {
200
- const cfg = loadR2Config();
201
- bucket = cfg.bucket;
202
- client = new R2Client(cfg);
240
+ if (resolvedClient) {
241
+ client = resolvedClient;
242
+ bucket = resolvedClient.kind === 'managed' ? 'managed' : 'byo';
203
243
  }
204
- catch (err) {
205
- process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
206
- process.exit(1);
244
+ else {
245
+ try {
246
+ const cfg = loadR2Config();
247
+ bucket = cfg.bucket;
248
+ client = new R2Client(cfg);
249
+ }
250
+ catch (err) {
251
+ process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
252
+ process.exit(1);
253
+ }
207
254
  }
208
255
  let keys;
209
256
  try {
210
- keys = await client.list(SESSIONS_PREFIX);
257
+ keys = client.kind === 'managed'
258
+ ? await client.list()
259
+ : await client.list(SESSIONS_PREFIX);
211
260
  }
212
261
  catch (err) {
213
262
  process.stderr.write(chalk.red(`R2 restore: listing failed: ${err.message}\n`));
@@ -239,6 +288,19 @@ export async function pullFromR2() {
239
288
  skipped++;
240
289
  continue;
241
290
  }
291
+ // Managed transcripts are mandatory AES-256-GCM (SES-51). A plaintext object
292
+ // in the managed store is a contract violation — an older/buggy writer or
293
+ // tampering — so fail loud rather than importing readable plaintext. Check
294
+ // the header AND every record body (matching the Worker's per-record
295
+ // isEncryptedManagedBundle boundary guard), never just the header flag.
296
+ if (client.kind === 'managed') {
297
+ const plaintext = !parsed.header.encrypted
298
+ || parsed.records.some(rec => !rec.encrypted || !isTranscriptEnvelope(rec.body));
299
+ if (plaintext) {
300
+ process.stderr.write(chalk.red(`R2 restore: managed backup ${key} is not encrypted — managed transcripts are always sealed; refusing to import plaintext.\n`));
301
+ process.exit(1);
302
+ }
303
+ }
242
304
  records.push(...parsed.records);
243
305
  if (parsed.header.encrypted)
244
306
  encryptedAny = true;
@@ -250,7 +312,9 @@ export async function pullFromR2() {
250
312
  }
251
313
  const deduped = mergeRecords([records]);
252
314
  if (deduped.length === 0) {
253
- process.stderr.write(chalk.red(`No session backups found in R2 bucket '${bucket}'.\n`));
315
+ process.stderr.write(chalk.red(bucket === 'managed'
316
+ ? 'No session backups found in the managed store.\n'
317
+ : `No session backups found in R2 bucket '${bucket}'.\n`));
254
318
  process.exit(1);
255
319
  }
256
320
  const header = makeHeader({
@@ -182,7 +182,7 @@ export function registerTracesCommands(program) {
182
182
  .description("Sync this device's derived, redacted trajectories to your Phoenix account");
183
183
  const setupCmd = tracesCmd
184
184
  .command('setup')
185
- .description('Provision the private Cloudflare Worker and R2 bucket for traces')
185
+ .description('(advanced) Provision a self-hosted Cloudflare Worker + R2 bucket for traces — NOT required; `agents auth login` syncs to the managed endpoint with zero setup')
186
186
  .option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
187
187
  .option('--worker <name>', 'Worker name', DEFAULT_WORKER_NAME)
188
188
  .option('--bucket <name>', 'R2 bucket name', DEFAULT_BUCKET_NAME)
@@ -35,10 +35,8 @@ export const NATIVE_ACCOUNT_CAPABILITIES = {
35
35
  copilot: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
36
36
  openclaw: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
37
37
  amp: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
38
- kiro: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
39
38
  goose: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
40
39
  hermes: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
41
- pi: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
42
40
  warp: { inspection: 'none', scope: 'unsupported', status: 'unsupported' },
43
41
  };
44
42
  export function nativeAccountCapability(agent) {
@@ -54,12 +54,12 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'device
54
54
  * running before the body, so callers that only need a native lookup must be
55
55
  * able to omit it.
56
56
  */
57
- export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
57
+ export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument, preferAgent?: AgentId): UnifiedAccount | null;
58
58
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
59
59
  /** Create or replace the version-independent label for one native identity. */
60
60
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
61
- export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
62
- export declare function unbindAccount(nameOrId: string, target: string): void;
61
+ export declare function bindAccount(nameOrId: string, target: string, preferAgent?: AgentId): UnifiedAccount;
62
+ export declare function unbindAccount(nameOrId: string, target: string, preferAgent?: AgentId): void;
63
63
  /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
64
  * the fleet-shared central bindings (PHNX-3315). */
65
65
  export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
@@ -159,9 +159,18 @@ export function listNativeAccounts(meta) {
159
159
  * running before the body, so callers that only need a native lookup must be
160
160
  * able to omit it.
161
161
  */
162
- export function findUnifiedAccount(nameOrId, meta, doc) {
162
+ export function findUnifiedAccount(nameOrId, meta, doc, preferAgent) {
163
163
  const needle = nameOrId.toLowerCase();
164
- const native = listNativeAccounts(meta).find(account => account.id === nameOrId || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
164
+ const matches = listNativeAccounts(meta).filter(account => account.id === nameOrId || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
165
+ // `identityLabel` defaults to the login's own identifier (the email), so ONE
166
+ // selector legitimately matches several harnesses: `muqsitnawaz@gmail.com` is a
167
+ // claude login AND a codex login. Un-scoped, `.find()` returned whichever row the
168
+ // merged store happened to order first, so `agents run claude#<email>` died with
169
+ // "Account 'personal' is a codex login and cannot authenticate the claude harness"
170
+ // while that identity's own claude login sat right there. Prefer the harness being
171
+ // launched; fall back to the first match so a management lookup with no harness in
172
+ // hand (rename/remove/view) behaves exactly as before.
173
+ const native = matches.find(account => account.agent === preferAgent) ?? matches[0];
165
174
  if (native)
166
175
  return native;
167
176
  const provider = findAccount(nameOrId, doc ?? readAccountRegistry());
@@ -253,9 +262,13 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
253
262
  });
254
263
  return { ...matches[0], name: resolvedLabel, identityLabel, scope: rowScope };
255
264
  }
256
- export function bindAccount(nameOrId, target) {
265
+ export function bindAccount(nameOrId, target, preferAgent) {
257
266
  const meta = readMeta();
258
- const account = findUnifiedAccount(nameOrId, meta);
267
+ // Scope to the harness being bound to: a bare identity selector matches every
268
+ // harness it is signed into, so without this the binding could persist against
269
+ // a different harness's row than the caller validated (the attach command
270
+ // resolves `targetAgent` and passes it here).
271
+ const account = findUnifiedAccount(nameOrId, meta, undefined, preferAgent);
259
272
  if (!account)
260
273
  throw new Error(`Unknown account '${nameOrId}'.`);
261
274
  // A binding follows its account: one that targets a device-scoped native login
@@ -275,9 +288,11 @@ export function bindAccount(nameOrId, target) {
275
288
  }
276
289
  return account;
277
290
  }
278
- export function unbindAccount(nameOrId, target) {
291
+ export function unbindAccount(nameOrId, target, preferAgent) {
279
292
  const meta = readMeta();
280
- const account = findUnifiedAccount(nameOrId, meta);
293
+ // Same scoping as bindAccount: detach the row on the harness the caller means,
294
+ // not whichever the store happened to order first for a colliding identity.
295
+ const account = findUnifiedAccount(nameOrId, meta, undefined, preferAgent);
281
296
  if (!account)
282
297
  throw new Error(`Unknown account '${nameOrId}'.`);
283
298
  const inCentral = meta.accounts?.bindings?.[target] === account.id;
@@ -527,7 +542,10 @@ export function resolveSpawnAccount(explicit, agent, version, meta, opts = {}) {
527
542
  const selection = resolveAccountSelection(explicit, agent, meta, { useDefault: opts.useDefault, target });
528
543
  if (!selection)
529
544
  return null;
530
- const unified = findUnifiedAccount(selection.id, meta);
545
+ // Scope the lookup to the harness being launched: a bare identity selector
546
+ // (`claude#muqsitnawaz@gmail.com`) matches every harness that identity is signed
547
+ // into, and only this one can authenticate the spawn.
548
+ const unified = findUnifiedAccount(selection.id, meta, undefined, agent);
531
549
  if (!unified) {
532
550
  // A stale per-harness default is a preference, not a hard requirement: the
533
551
  // machine stays runnable by falling back to balanced rotation. Bindings and
@@ -28,7 +28,7 @@ export interface PublishUsageSnapshotResult {
28
28
  path: string | null;
29
29
  }
30
30
  /** Publish this headed device's stable usage snapshot into its owned store file. */
31
- export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): PublishUsageSnapshotResult;
31
+ export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): Promise<PublishUsageSnapshotResult>;
32
32
  export interface ConsumeUsageSnapshotsOptions {
33
33
  userAgentsDir?: string;
34
34
  cachePath?: string;
@@ -8,12 +8,12 @@
8
8
  * There is deliberately no device-to-device SSH in this module or its tick.
9
9
  */
10
10
  import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
11
- import { readFleetSharedDeviceStates, updateFleetSharedDeviceState, } from '../fleet-shared-state.js';
11
+ import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
12
12
  import { getUserAgentsDir } from '../state.js';
13
13
  import { machineId, normalizeHost } from '../session/sync/config.js';
14
14
  import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
15
15
  /** Publish this headed device's stable usage snapshot into its owned store file. */
16
- export function publishUsageSnapshotToSharedStore(options = {}) {
16
+ export async function publishUsageSnapshotToSharedStore(options = {}) {
17
17
  const result = {
18
18
  published: false,
19
19
  changed: false,
@@ -32,7 +32,7 @@ export function publishUsageSnapshotToSharedStore(options = {}) {
32
32
  return result;
33
33
  }
34
34
  try {
35
- const write = updateFleetSharedDeviceState(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
35
+ const write = await updateFleetSharedDeviceStateAsync(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
36
36
  result.published = true;
37
37
  result.changed = write.changed;
38
38
  result.path = write.path;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { type SessionNotification } from '@zed-industries/agent-client-protocol';
13
13
  import type { AgentId } from '../types.js';
14
- import type { ExecMode } from '../exec.js';
14
+ import { type ExecMode } from '../exec.js';
15
15
  export interface AcpRunOptions {
16
16
  agent: AgentId;
17
17
  prompt: string;
@@ -15,6 +15,7 @@ import { promises as fs } from 'fs';
15
15
  import * as path from 'path';
16
16
  import { ClientSideConnection, ndJsonStream, } from '@zed-industries/agent-client-protocol';
17
17
  import { getAcpSpec, supportsAcp } from './harnesses.js';
18
+ import { buildExecEnv } from '../exec.js';
18
19
  const PROTOCOL_VERSION = 1;
19
20
  /**
20
21
  * Runs a single prompt turn against an ACP-capable agent and streams updates
@@ -25,10 +26,20 @@ export async function runAcp(opts) {
25
26
  throw new Error(`Agent '${opts.agent}' does not support ACP. Use direct exec instead.`);
26
27
  }
27
28
  const spec = getAcpSpec(opts.agent);
29
+ // Build the harness's exec env the SAME way `agents run` does, instead of
30
+ // handing it a raw process.env. buildExecEnv is what injects the per-account
31
+ // `CLAUDE_CODE_OAUTH_TOKEN` on a worker device (gated by device role: a
32
+ // headed/personal box still defers to its native login). Without this, an ACP
33
+ // launch inherited no setup-token and fell through to the version home's
34
+ // copied native `.credentials.json` — which expires in ~15h and 401s once its
35
+ // refresh chain dies, exactly the "logged out on a worker" report (PHNX-3681).
36
+ // A verified precedence test shows the env token wins over a present
37
+ // `.credentials.json`, so injecting here is sufficient — the stale file no
38
+ // longer decides the session.
28
39
  const child = spawn(spec.command, spec.args, {
29
40
  cwd: opts.cwd,
30
41
  stdio: ['pipe', 'pipe', 'inherit'],
31
- env: process.env,
42
+ env: buildExecEnv({ agent: opts.agent, cwd: opts.cwd, mode: opts.mode, effort: 'auto', interactive: true }),
32
43
  });
33
44
  const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
34
45
  const client = buildClient(opts);
@@ -52,7 +52,7 @@ export const ACP_HARNESSES = {
52
52
  },
53
53
  // antigravity: no documented ACP support (May 2026).
54
54
  // goose: ACP over HTTP via `goosed`, not a clean stdio subcommand.
55
- // copilot, kiro: excluded for now (not installed in the reference environment,
55
+ // copilot: excluded for now (not installed in the reference environment,
56
56
  // no local verification possible).
57
57
  // amp: not on the ACP agents list.
58
58
  };
@@ -33,11 +33,9 @@ export const ADD_DIR_STRATEGY = {
33
33
  // No multi-root CLI surface today (single --dir / project path).
34
34
  opencode: 'none',
35
35
  gemini: 'none',
36
- pi: 'none',
37
36
  openclaw: 'none',
38
37
  copilot: 'none',
39
38
  amp: 'none',
40
- kiro: 'none',
41
39
  goose: 'none',
42
40
  antigravity: 'none',
43
41
  droid: 'none',
@@ -14,11 +14,9 @@ export const AGENT_CLI_COMMANDS = [
14
14
  'gemini',
15
15
  'cursor-agent',
16
16
  'opencode',
17
- 'omp',
18
17
  'openclaw',
19
18
  'copilot',
20
19
  'amp',
21
- 'kiro-cli',
22
20
  'goose',
23
21
  'agy',
24
22
  'grok',
@@ -104,7 +104,7 @@ export declare const MANAGED_AGENT_IDS: AgentId[];
104
104
  * `curl … | sh` / `brew install` script that carries NO version token — the
105
105
  * installer can only ever fetch the *current* release, and the binary then keeps
106
106
  * itself up to date in place (droid, grok, antigravity, cursor, hermes,
107
- * kiro, goose). There is no semver to pin, so agents-cli must not model these as
107
+ * goose). There is no semver to pin, so agents-cli must not model these as
108
108
  * having multiple installable version-homes the way it does for npm-packaged
109
109
  * agents (claude, codex, kimi, …).
110
110
  *