@phnx-labs/agents-cli 1.20.77 → 1.20.78

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 (70) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +29 -3
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +4 -2
  9. package/dist/commands/secrets.js +8 -2
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +118 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +15 -1
  15. package/dist/commands/sessions-picker.js +58 -4
  16. package/dist/commands/sessions.d.ts +95 -1
  17. package/dist/commands/sessions.js +224 -26
  18. package/dist/commands/ssh.js +9 -1
  19. package/dist/index.js +3 -5
  20. package/dist/lib/agents.d.ts +0 -21
  21. package/dist/lib/agents.js +0 -37
  22. package/dist/lib/auto-pull.d.ts +16 -8
  23. package/dist/lib/auto-pull.js +23 -28
  24. package/dist/lib/daemon.d.ts +9 -41
  25. package/dist/lib/daemon.js +16 -117
  26. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  27. package/dist/lib/devices/fleet-divergence.js +188 -0
  28. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  29. package/dist/lib/devices/fleet-inventory.js +57 -0
  30. package/dist/lib/devices/health-report.d.ts +10 -2
  31. package/dist/lib/devices/health-report.js +32 -1
  32. package/dist/lib/git.d.ts +13 -0
  33. package/dist/lib/git.js +102 -4
  34. package/dist/lib/hosts/remote-cmd.js +2 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  39. package/dist/lib/runner.js +29 -26
  40. package/dist/lib/sandbox.js +0 -16
  41. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  46. package/dist/lib/secrets/agent.d.ts +2 -0
  47. package/dist/lib/secrets/agent.js +26 -14
  48. package/dist/lib/secrets/bundles.d.ts +1 -1
  49. package/dist/lib/secrets/bundles.js +16 -8
  50. package/dist/lib/secrets/scope.d.ts +26 -0
  51. package/dist/lib/secrets/scope.js +29 -0
  52. package/dist/lib/secrets/session-store.d.ts +10 -0
  53. package/dist/lib/secrets/session-store.js +64 -11
  54. package/dist/lib/session/active.d.ts +7 -0
  55. package/dist/lib/session/active.js +38 -3
  56. package/dist/lib/session/db.d.ts +37 -0
  57. package/dist/lib/session/db.js +112 -4
  58. package/dist/lib/session/digest.js +126 -21
  59. package/dist/lib/session/discover.d.ts +15 -2
  60. package/dist/lib/session/discover.js +19 -11
  61. package/dist/lib/session/origin-machine.d.ts +18 -0
  62. package/dist/lib/session/origin-machine.js +34 -0
  63. package/dist/lib/session/state.d.ts +1 -0
  64. package/dist/lib/session/state.js +1 -1
  65. package/dist/lib/session/sync/config.js +2 -2
  66. package/dist/lib/smart-launch.d.ts +86 -0
  67. package/dist/lib/smart-launch.js +172 -0
  68. package/package.json +1 -1
  69. package/dist/lib/secrets/account-token.d.ts +0 -20
  70. package/dist/lib/secrets/account-token.js +0 -64
@@ -1,6 +1,7 @@
1
1
  import { type DeviceStats } from './health.js';
2
2
  import { type HostAuthSummary } from '../auth-health.js';
3
3
  import type { OnlineState } from './reachability.js';
4
+ import { type FleetInventory } from './fleet-divergence.js';
4
5
  export interface FleetCliStatus {
5
6
  installed: boolean;
6
7
  path: string | null;
@@ -40,9 +41,14 @@ export interface FleetHealthRow {
40
41
  * an offline row. Sourced from the registry's tailscale snapshot / reachability
41
42
  * verdict. Undefined when never recorded. */
42
43
  lastSeen?: string;
44
+ /** This host's self-reported harness inventory (resources / agent versions /
45
+ * repo state) from its `doctor --json` `fleet` field, for cross-device
46
+ * divergence detection (RUSH-2027). Undefined for an unreachable box or an
47
+ * older CLI that doesn't emit it. */
48
+ inventory?: FleetInventory;
43
49
  }
44
50
  export interface FleetWarning {
45
- kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
51
+ kind: 'unreachable' | 'drift' | 'cli' | 'version-skew' | 'divergence';
46
52
  devices: string[];
47
53
  message: string;
48
54
  }
@@ -53,7 +59,9 @@ export interface FleetHealthReport {
53
59
  hasWarnings: boolean;
54
60
  hasDrift: boolean;
55
61
  }
56
- export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date): FleetHealthReport;
62
+ export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date, opts?: {
63
+ self?: string;
64
+ }): FleetHealthReport;
57
65
  export declare function renderFleetWarnings(report: FleetHealthReport): string[];
58
66
  export declare function renderFleetMatrix(report: FleetHealthReport): string[];
59
67
  /**
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
3
3
  import { fmtBytes, headroom } from './health.js';
4
4
  import { formatCheckedAge } from '../auth-health.js';
5
+ import { compareFleetInventories } from './fleet-divergence.js';
5
6
  function driftRows(row) {
6
7
  return row.sync.filter((s) => s.status !== 'fresh');
7
8
  }
@@ -12,7 +13,7 @@ function installedCliCount(row) {
12
13
  total: statuses.length,
13
14
  };
14
15
  }
15
- export function buildFleetHealthReport(rows, now = new Date()) {
16
+ export function buildFleetHealthReport(rows, now = new Date(), opts = {}) {
16
17
  const warnings = [];
17
18
  const unreachable = rows
18
19
  .filter((r) => r.error || r.skipped)
@@ -60,6 +61,25 @@ export function buildFleetHealthReport(rows, now = new Date()) {
60
61
  message: `agents-cli version skew: ${Array.from(versions.keys()).sort().join(', ')}`,
61
62
  });
62
63
  }
64
+ // Cross-device harness divergence (RUSH-2027): compare every device's
65
+ // self-reported inventory against the local baseline and roll the findings up
66
+ // into one warning per affected device. Only runs when a baseline (`self`) is
67
+ // named and at least one device carries an inventory — an older-CLI fleet with
68
+ // no `fleet` field simply produces no divergence warning.
69
+ if (opts.self && rows.some((r) => r.inventory)) {
70
+ const divergence = compareFleetInventories(rows.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), opts.self);
71
+ const byDevice = new Map();
72
+ for (const d of divergence.divergences)
73
+ byDevice.set(d.device, (byDevice.get(d.device) ?? 0) + 1);
74
+ for (const device of Array.from(byDevice.keys()).sort()) {
75
+ const n = byDevice.get(device);
76
+ warnings.push({
77
+ kind: 'divergence',
78
+ devices: [device],
79
+ message: `${device} diverges from ${opts.self} (${n} resource/version/repo gap${n === 1 ? '' : 's'})`,
80
+ });
81
+ }
82
+ }
63
83
  return {
64
84
  generatedAt: now.toISOString(),
65
85
  devices: rows,
@@ -326,6 +346,17 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
326
346
  .join(' · ');
327
347
  items.push({ glyph: 'warn', subject: 'version skew', detail: summary, fix: 'agents upgrade --fleet' });
328
348
  }
349
+ // 4) Cross-device harness divergence — one item per diverged box (RUSH-2027).
350
+ for (const w of report.warnings) {
351
+ if (w.kind !== 'divergence')
352
+ continue;
353
+ items.push({
354
+ glyph: 'warn',
355
+ subject: w.devices[0] ?? 'fleet',
356
+ detail: w.message.replace(`${w.devices[0]} `, ''),
357
+ fix: `agents apply ${w.devices[0] ?? ''}`.trim(),
358
+ });
359
+ }
329
360
  return items;
330
361
  }
331
362
  /** Right-aligned `<content>` on the same line as `left`, clamped so it never
package/dist/lib/git.d.ts CHANGED
@@ -86,6 +86,19 @@ export declare function clonePackage(source: string): Promise<{
86
86
  }>;
87
87
  /** Get the short commit hash (8 chars) of the latest commit in a repo. */
88
88
  export declare function getRepoCommit(repoPath: string): Promise<string>;
89
+ /** Compact, self-contained state of a git repo — branch, short HEAD, and a
90
+ * dirty flag — for cross-device comparison (RUSH-2027). Synchronous and
91
+ * best-effort: a non-repo or unreadable path yields `null` fields, never a
92
+ * throw, so a device's `doctor --json` payload always serializes. */
93
+ export interface RepoStateSnapshot {
94
+ branch: string | null;
95
+ head: string | null;
96
+ dirty: boolean;
97
+ }
98
+ /** Read {@link RepoStateSnapshot} for `repoPath` using plumbing commands so the
99
+ * result is stable across git versions and never mutates the tree. Returns null
100
+ * when the path is not a git worktree. */
101
+ export declare function readRepoState(repoPath: string): RepoStateSnapshot | null;
89
102
  /**
90
103
  * Get the current GitHub username using gh CLI.
91
104
  * Returns null if gh is not installed or user is not authenticated.
package/dist/lib/git.js CHANGED
@@ -334,6 +334,34 @@ export async function getRepoCommit(repoPath) {
334
334
  return 'unknown';
335
335
  }
336
336
  }
337
+ /** Read {@link RepoStateSnapshot} for `repoPath` using plumbing commands so the
338
+ * result is stable across git versions and never mutates the tree. Returns null
339
+ * when the path is not a git worktree. */
340
+ export function readRepoState(repoPath) {
341
+ const runGit = (args) => {
342
+ try {
343
+ return execFileSync('git', ['-C', repoPath, ...args], {
344
+ stdio: ['ignore', 'pipe', 'ignore'],
345
+ })
346
+ .toString()
347
+ .trim();
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ };
353
+ // Gate on a real worktree first; `rev-parse --is-inside-work-tree` prints
354
+ // `true` only inside one, and returns non-zero (→ null) otherwise.
355
+ if (runGit(['rev-parse', '--is-inside-work-tree']) !== 'true')
356
+ return null;
357
+ const branchRaw = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
358
+ const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null; // detached → null
359
+ const headRaw = runGit(['rev-parse', 'HEAD']);
360
+ const head = headRaw ? headRaw.slice(0, 8) : null;
361
+ const porcelain = runGit(['status', '--porcelain']);
362
+ const dirty = porcelain != null && porcelain.length > 0;
363
+ return { branch, head, dirty };
364
+ }
337
365
  /**
338
366
  * Get the current GitHub username using gh CLI.
339
367
  * Returns null if gh is not installed or user is not authenticated.
@@ -813,6 +841,39 @@ export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
813
841
  export async function pullRepo(dir) {
814
842
  try {
815
843
  const git = simpleGit(dir);
844
+ // A rebase left in progress by an earlier run must be reported as itself.
845
+ // Without this the dirty-tree guard below claims "Blocked by local changes",
846
+ // which is both wrong and actively harmful advice mid-rebase on a detached
847
+ // HEAD. Checked BEFORE commitOwnDeviceMeta so we never commit into one.
848
+ // Ask git where the state dirs live rather than assuming `<dir>/.git/` is a
849
+ // directory. In a worktree `.git` is a FILE containing `gitdir: <path>`, so
850
+ // path.join(dir, '.git', 'rebase-merge') can never exist and the check would
851
+ // silently never fire. `rev-parse --git-path` resolves both layouts.
852
+ const gitPath = async (name) => {
853
+ try {
854
+ const raw = (await git.raw(['rev-parse', '--git-path', name])).trim();
855
+ return path.isAbsolute(raw) ? raw : path.join(dir, raw);
856
+ }
857
+ catch {
858
+ return null;
859
+ }
860
+ };
861
+ const [rebaseMerge, rebaseApply] = await Promise.all([
862
+ gitPath('rebase-merge'),
863
+ gitPath('rebase-apply'),
864
+ ]);
865
+ const rebaseInProgress = (rebaseMerge !== null && fs.existsSync(rebaseMerge)) ||
866
+ (rebaseApply !== null && fs.existsSync(rebaseApply));
867
+ if (rebaseInProgress) {
868
+ return {
869
+ success: false,
870
+ commit: '',
871
+ error: `A previous rebase is still in progress — finish or abort it, then pull again.\n\n` +
872
+ ` cd ${displayHomePath(dir)} && git status\n` +
873
+ ` git rebase --continue # after resolving\n` +
874
+ ` git rebase --abort # to discard the attempt`,
875
+ };
876
+ }
816
877
  // Commit this machine's own device-meta first so a per-machine pin change
817
878
  // never wedges the pull. Genuine edits elsewhere still block below.
818
879
  await commitOwnDeviceMeta(dir);
@@ -825,12 +886,15 @@ export async function pullRepo(dir) {
825
886
  };
826
887
  }
827
888
  const branch = status.current || 'main';
828
- await git.fetch('origin');
829
889
  // Resolve the upstream ref to fast-forward against. Prefer the local
830
890
  // branch's tracking config; otherwise ask origin for its default branch.
831
891
  let tracking = status.tracking;
832
892
  if (!tracking) {
893
+ // No tracking config: fetch origin so its HEAD is known, then ask which
894
+ // branch it points at. This path only ever concerns origin — a branch with
895
+ // no upstream has no other remote to consult.
833
896
  try {
897
+ await git.fetch('origin');
834
898
  await git.raw(['remote', 'set-head', 'origin', '--auto']);
835
899
  const sym = await git.raw(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
836
900
  tracking = sym.trim();
@@ -839,6 +903,22 @@ export async function pullRepo(dir) {
839
903
  tracking = `origin/${branch}`;
840
904
  }
841
905
  }
906
+ // Split the remote-tracking ref (<remote>/<branch>) into its parts and pull
907
+ // THOSE. Hardcoding 'origin' while comparing against `tracking` is how a
908
+ // branch tracking e.g. upstream/main silently 'succeeded': revparse saw a
909
+ // difference, the pull fetched origin/main (already current), and pullRepo
910
+ // returned success having moved nothing — the same "reported ok, pulled
911
+ // nothing" failure this change exists to remove. Branch names may contain
912
+ // slashes, so split on the FIRST separator only.
913
+ const sep = tracking.indexOf('/');
914
+ const remoteName = sep > 0 ? tracking.slice(0, sep) : 'origin';
915
+ const remoteBranch = sep > 0 ? tracking.slice(sep + 1) : branch;
916
+ // Bare fetch: updates every remote, so the revparse below sees a fresh ref
917
+ // whichever one the branch tracks. Deliberately argument-less — simple-git's
918
+ // fetchTask only forwards a remote when BOTH remote and branch are passed,
919
+ // so `fetch(remoteName)` would silently drop the argument and do exactly
920
+ // this anyway. Saying so beats an inert argument that reads as targeted.
921
+ await git.fetch();
842
922
  const localRef = await git.revparse(['HEAD']);
843
923
  const remoteRef = await git.revparse([tracking]).catch(() => null);
844
924
  if (!remoteRef) {
@@ -853,13 +933,31 @@ export async function pullRepo(dir) {
853
933
  };
854
934
  }
855
935
  try {
856
- await git.merge(['--ff-only', tracking]);
936
+ // Rebase, not --ff-only. Fast-forward refuses ANY divergence, conflict or
937
+ // not, so a single local commit — including the one commitOwnDeviceMeta
938
+ // makes just above — permanently wedged the pull with nothing actually in
939
+ // conflict. Every device carries its own devices/<host>/ path, so those
940
+ // replay cleanly. Matches syncRepoGit (below) and this function's own doc.
941
+ //
942
+ // Pull the RESOLVED tracking ref, not `branch`: when the local branch has
943
+ // no tracking config the block above falls back to origin's default head,
944
+ // which may be named differently (local `main` vs origin `master`). Using
945
+ // `branch` there asks origin for a ref it does not have.
946
+ assertValidBranchName(remoteBranch);
947
+ await git.pull(remoteName, remoteBranch, { '--rebase': 'true' });
857
948
  }
858
- catch {
949
+ catch (err) {
950
+ // Abort so the tree is restored, matching the atomicity --ff-only gave us.
951
+ // Without this a conflict leaves the repo detached, mid-rebase, with
952
+ // conflict markers written into live config (this repo is ~/.agents —
953
+ // agents.yaml and AGENTS.md are in it), and every later pull misreports
954
+ // the cause. `agents sync` reaches this path unattended across the fleet,
955
+ // so a wedged checkout would be worse than the bug this fixes.
956
+ await git.raw(['rebase', '--abort']).catch(() => { });
859
957
  return {
860
958
  success: false,
861
959
  commit: '',
862
- error: `Blocked by local commits. Push or reset them before pulling.\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}`,
960
+ error: `Rebase onto ${tracking} hit a conflict the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
863
961
  };
864
962
  }
865
963
  installGithooksSymlinks(dir);
@@ -116,6 +116,8 @@ export const RUN_OPTION_FORWARDING = {
116
116
  tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
117
117
  copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
118
118
  authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
119
+ // Deprecated alias for --device auto; resolved on the launching box before SSH.
120
+ smart: 'local-only',
119
121
  };
120
122
  /** Actionable messages for value-aware rejections, keyed by attribute name. */
121
123
  export const RUN_OPTION_REJECT_MESSAGES = {
@@ -8,6 +8,8 @@
8
8
  <string>com.phnx-labs.agents-menubar</string>
9
9
  <key>CFBundleName</key>
10
10
  <string>Agents Menu Bar</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
11
13
  <key>CFBundlePackageType</key>
12
14
  <string>APPL</string>
13
15
  <key>CFBundleShortVersionString</key>
@@ -3,9 +3,22 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ Vd5nfogg74zSRNspluv4uvDecUA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
- <dict/>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
19
+ </data>
20
+ </dict>
21
+ </dict>
9
22
  <key>rules</key>
10
23
  <dict>
11
24
  <key>^Resources/</key>
@@ -27,9 +27,7 @@ import { loadTask as loadHostTask } from './hosts/tasks.js';
27
27
  import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { walkForFiles } from './fs-walk.js';
30
- import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
31
- import { claudeHomeHasOwnCredential } from './agents.js';
32
- import { resolveAccountSetupToken } from './secrets/account-token.js';
30
+ import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
33
31
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
34
32
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
35
33
  import { machineId } from './machine-id.js';
@@ -400,29 +398,12 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
400
398
  if (v !== undefined)
401
399
  out[k] = v;
402
400
  }
403
- // A headless routine should authenticate the rotation-pinned account via its
404
- // long-lived, NON-rotating setup-token, not the interactive OAuth session.
405
- // Claude Code's interactive session uses single-use rotating refresh tokens: one
406
- // fleet machine refreshing invalidates that account on every other machine, so
407
- // they 401 and log out (Claude Code #25609 / #56339) — the fleet-wide daily
408
- // logout. If the daemon injected this account's per-account
409
- // CLAUDE_CODE_OAUTH_TOKEN_<slug> (from a headless-readable no-ACL bundle), use it;
410
- // that also works on macOS, where the drop path below is inert
411
- // (claudeHomeHasOwnCredential is false on darwin, agents.ts).
412
- //
413
- // Fallback (RUSH-1979): with no per-account token, keep the prior behavior — drop
414
- // the single ambient CLAUDE_CODE_OAUTH_TOKEN when the pinned account has its own
415
- // on-disk credential (Linux) so it isn't shadowed; keep it otherwise.
416
- if (agent === 'claude' && version && out.CLAUDE_CONFIG_DIR) {
417
- const home = getVersionHomePath('claude', version);
418
- const accountToken = resolveAccountSetupToken(baseEnv, home);
419
- if (accountToken) {
420
- out.CLAUDE_CODE_OAUTH_TOKEN = accountToken;
421
- }
422
- else if (claudeHomeHasOwnCredential(home)) {
423
- delete out.CLAUDE_CODE_OAUTH_TOKEN;
424
- }
425
- }
401
+ // No Claude token is injected here. A routine authenticates through the pinned
402
+ // account's own CLAUDE_CONFIG_DIR login (buildExecEnv points it at the
403
+ // per-account version home), identical to interactive `agents run`. Claude
404
+ // Code's interactive session refreshes itself per-device; keeping the daemon
405
+ // out of the credential entirely is what avoids the fleet-wide rotation logout
406
+ // a shared/rotating token was the cause, not the fix.
426
407
  if (timezone)
427
408
  out.TZ = timezone;
428
409
  return out;
@@ -1063,6 +1044,28 @@ export async function executeJobDetached(config, hooks) {
1063
1044
  completedAt: null,
1064
1045
  exitCode: null,
1065
1046
  };
1047
+ // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1048
+ // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1049
+ // login. If the last live probe rejected that (agent, version)'s token, the run
1050
+ // is guaranteed to 401 — fail fast with a re-login hint instead of spawning a
1051
+ // doomed run + poisoned report. Cache-only; fails OPEN on any non-dead/missing
1052
+ // verdict, and agents with no live probe (codex/gemini/grok) are never blocked.
1053
+ const preflightVersion = launch.chain[0]?.version;
1054
+ if (preflightVersion) {
1055
+ const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1056
+ if (health && isDeadVerdict(health.verdict)) {
1057
+ const reason = `auth_preflight: ${health.verdict}`;
1058
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1059
+ try {
1060
+ fs.closeSync(stdoutFd);
1061
+ }
1062
+ catch { /* already closed */ }
1063
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1064
+ writeRunMeta(meta);
1065
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1066
+ return meta;
1067
+ }
1068
+ }
1066
1069
  const child = spawn(cmd[0], cmd.slice(1), {
1067
1070
  stdio: ['ignore', stdoutFd, stdoutFd],
1068
1071
  ...backgroundSpawnOptions({ fdStdio: true }),
@@ -44,12 +44,6 @@ const ENV_ALLOWLIST = [
44
44
  'VISUAL',
45
45
  'NO_COLOR',
46
46
  'FORCE_COLOR',
47
- // Headless Claude auth: the routines daemon injects CLAUDE_CODE_OAUTH_TOKEN
48
- // from the `claude` secrets bundle (see daemon.ts). Without this allowlist
49
- // entry, sandboxed routine spawns drop the token and look "unconfigured".
50
- // Intentionally NOT ANTHROPIC_API_KEY / other provider secrets — those stay
51
- // stripped (see tests/sandbox.test.ts "does not include sensitive env vars").
52
- 'CLAUDE_CODE_OAUTH_TOKEN',
53
47
  ];
54
48
  /** Tools safe to grant as wildcards (no filesystem access). */
55
49
  const SAFE_TOOLS = {
@@ -75,16 +69,6 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
75
69
  env[key] = process.env[key];
76
70
  }
77
71
  }
78
- // Per-account Claude setup-tokens: the daemon injects a CLAUDE_CODE_OAUTH_TOKEN_<slug>
79
- // for each account so a routine authenticates its rotation-pinned account via a
80
- // long-lived, non-rotating token (see runner.ts buildRoutineSpawnEnv). Forward every
81
- // such key by prefix — same trust tier as CLAUDE_CODE_OAUTH_TOKEN above; still no API
82
- // keys or other provider secrets.
83
- for (const key of Object.keys(process.env)) {
84
- if (key.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && process.env[key]) {
85
- env[key] = process.env[key];
86
- }
87
- }
88
72
  if (extraEnv) {
89
73
  Object.assign(env, extraEnv);
90
74
  }
@@ -8,6 +8,8 @@
8
8
  <string>Agents CLI</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Agents CLI</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
11
13
  <key>CFBundleExecutable</key>
12
14
  <string>Agents CLI</string>
13
15
  <key>CFBundlePackageType</key>
@@ -3,9 +3,21 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ Vd5nfogg74zSRNspluv4uvDecUA=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
13
  <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
19
+ </data>
20
+ </dict>
9
21
  <key>embedded.provisionprofile</key>
10
22
  <dict>
11
23
  <key>hash2</key>
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import * as net from 'net';
27
27
  import type { SecretsBundle } from './bundles.js';
28
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
29
+ export { GLOBAL_HARNESS, bundleScopeChain };
28
30
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
29
31
  export declare const DEFAULT_TTL_MS: number;
30
32
  /**
@@ -34,8 +34,14 @@ import { isAlive } from '../platform/process.js';
34
34
  import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
+ import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
37
38
  import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
39
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
40
+ // Re-exported so callers already reaching for agent.js keep one obvious home for
41
+ // the scope vocabulary; the definitions live in the leaf module scope.ts because
42
+ // agent.ts and session-store.ts import each other and a cyclic `const` read can
43
+ // hit the ESM temporal dead zone at runtime.
44
+ export { GLOBAL_HARNESS, bundleScopeChain };
39
45
  /** Bumped when the wire protocol changes; a client that pings a mismatched
40
46
  * server kills and respawns it rather than talking a stale dialect. */
41
47
  const PROTOCOL_VERSION = 2;
@@ -133,7 +139,7 @@ function onDarwin() {
133
139
  function rehydrateStore(now = Date.now()) {
134
140
  const store = new Map();
135
141
  for (const { name, entry } of rehydrateSessions(now)) {
136
- const harness = entry.harness || 'cli';
142
+ const harness = entry.harness || GLOBAL_HARNESS;
137
143
  store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
138
144
  }
139
145
  return store;
@@ -309,17 +315,23 @@ export function handleAgentRequest(store, req, now = Date.now()) {
309
315
  // the broker is running pre-upgrade code and should be restarted.
310
316
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
311
317
  case 'get': {
312
- const key = scopedBundleKey(req.name, req.harness || 'cli');
313
- const e = store.get(key);
314
- if (!e || now >= e.expiresAt) {
315
- if (e)
316
- store.delete(key); // drop expired on read
317
- return { ok: true, cmd: 'get', hit: false };
318
+ // Walk own-harness global so a `--for` grant wins over a global one and
319
+ // an unscoped unlock serves every harness (bundleScopeChain).
320
+ for (const scope of bundleScopeChain(req.harness)) {
321
+ const key = scopedBundleKey(req.name, scope);
322
+ const e = store.get(key);
323
+ if (!e)
324
+ continue;
325
+ if (now >= e.expiresAt) {
326
+ store.delete(key);
327
+ continue;
328
+ } // drop expired on read
329
+ return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
318
330
  }
319
- return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
331
+ return { ok: true, cmd: 'get', hit: false };
320
332
  }
321
333
  case 'load':
322
- const harness = req.harness || 'cli';
334
+ const harness = req.harness || GLOBAL_HARNESS;
323
335
  store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
324
336
  return { ok: true, cmd: 'load' };
325
337
  case 'lock': {
@@ -834,7 +846,7 @@ function syncClient(sub, timeout) {
834
846
  * null if the agent isn't running / doesn't hold this bundle / anything fails
835
847
  * (soft — caller falls through to the real keychain). macOS only.
836
848
  */
837
- export function agentGetSync(name, harness = 'cli') {
849
+ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
838
850
  if (!agentSocketExists())
839
851
  return null;
840
852
  const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
@@ -914,7 +926,7 @@ export function agentEvictSync(name) {
914
926
  // so a cache hit never runs checkForUpdates() or forks a detached sync.
915
927
  /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
916
928
  * cache hit. Exit 0 = hit, 3 = miss or broker down. */
917
- export async function runAgentGetSync(name, harness = 'cli') {
929
+ export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
918
930
  const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
919
931
  if (r?.ok === true && r.cmd === 'get' && r.hit) {
920
932
  // Trailing newline: the parent reads the LAST line (see lastLine), so the
@@ -1066,7 +1078,7 @@ export function clampHoldMs(v) {
1066
1078
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
1067
1079
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
1068
1080
  */
1069
- export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = 'cli') {
1081
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1070
1082
  if (!onDarwin())
1071
1083
  return;
1072
1084
  const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
@@ -1136,12 +1148,12 @@ export async function runAgentLoadFromStdin() {
1136
1148
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1137
1149
  return;
1138
1150
  }
1139
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? 'cli');
1151
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
1140
1152
  if (!loaded)
1141
1153
  process.exitCode = 1; // transport failed — did NOT load
1142
1154
  }
1143
1155
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
1144
- export async function agentLoad(name, bundle, env, ttlMs, harness = 'cli') {
1156
+ export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
1145
1157
  const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1146
1158
  return r?.ok === true && r.cmd === 'load';
1147
1159
  }
@@ -301,7 +301,7 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
301
301
  *
302
302
  * A read in a macOS headless context resolves broker-only (agentOnly) and fails
303
303
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
304
- * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
304
+ * the per-caller broker-only pattern used across the headless secrets readers.
305
305
  */
306
306
  export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
307
307
  stdin?: boolean;