@phnx-labs/agents-cli 1.20.74 → 1.20.76
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.
- package/CHANGELOG.md +893 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +31 -3
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +58 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +1 -32
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +37 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ export const loadApply = async () => (await import('../../commands/apply.js')).r
|
|
|
35
35
|
export const loadCheck = async () => (await import('../../commands/check.js')).registerCheckCommand;
|
|
36
36
|
export const loadStatus = async () => (await import('../../commands/status.js')).registerStatusCommand;
|
|
37
37
|
export const loadProfiles = async () => (await import('../../commands/profiles.js')).registerProfilesCommands;
|
|
38
|
+
export const loadHarness = async () => (await import('../../commands/harness.js')).registerHarnessCommands;
|
|
38
39
|
export const loadSecrets = async () => (await import('../../commands/secrets.js')).registerSecretsCommands;
|
|
39
40
|
export const loadLogin = async () => (await import('../../commands/login.js')).registerLoginCommands;
|
|
40
41
|
export const loadWallet = async () => (await import('../../commands/wallet.js')).registerWalletCommands;
|
|
@@ -146,6 +147,8 @@ export const COMMAND_LOADERS = {
|
|
|
146
147
|
status: [loadStatus],
|
|
147
148
|
profile: [loadProfiles],
|
|
148
149
|
profiles: [loadProfiles],
|
|
150
|
+
harness: [loadHarness],
|
|
151
|
+
harnesses: [loadHarness],
|
|
149
152
|
secrets: [loadSecrets],
|
|
150
153
|
login: [loadLogin],
|
|
151
154
|
logout: [loadLogin],
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -165,7 +165,7 @@ export declare function getDriveDir(): string;
|
|
|
165
165
|
export declare function getTrashDir(): string;
|
|
166
166
|
/** Path to local session indexer storage (~/.agents/.history/sessions/). */
|
|
167
167
|
export declare function getSessionsDir(): string;
|
|
168
|
-
/** Path to the session index database (~/.agents/.history/sessions.db). */
|
|
168
|
+
/** Path to the session index database (~/.agents/.history/sessions/sessions.db). */
|
|
169
169
|
export declare function getSessionsDbPath(): string;
|
|
170
170
|
/** Path to teams config + registry (~/.agents/teams/). */
|
|
171
171
|
export declare function getTeamsDir(): string;
|
package/dist/lib/state.js
CHANGED
|
@@ -27,7 +27,6 @@ import * as path from 'path';
|
|
|
27
27
|
import * as os from 'os';
|
|
28
28
|
import * as yaml from 'yaml';
|
|
29
29
|
import { ensureLockTarget, atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
30
|
-
import { SEEDED_REGISTRIES } from './types.js';
|
|
31
30
|
import { machineId } from './machine-id.js';
|
|
32
31
|
const HOME = process.env.HOME ?? os.homedir();
|
|
33
32
|
/**
|
|
@@ -376,7 +375,7 @@ export function getDriveDir() { return DRIVE_DIR; }
|
|
|
376
375
|
export function getTrashDir() { return TRASH_DIR; }
|
|
377
376
|
/** Path to local session indexer storage (~/.agents/.history/sessions/). */
|
|
378
377
|
export function getSessionsDir() { return SESSIONS_DIR; }
|
|
379
|
-
/** Path to the session index database (~/.agents/.history/sessions.db). */
|
|
378
|
+
/** Path to the session index database (~/.agents/.history/sessions/sessions.db). */
|
|
380
379
|
export function getSessionsDbPath() { return SESSIONS_DB_PATH; }
|
|
381
380
|
/** Path to teams config + registry (~/.agents/teams/). */
|
|
382
381
|
export function getTeamsDir() { return TEAMS_DIR; }
|
|
@@ -680,29 +679,6 @@ function overlayMachineLocal(meta) {
|
|
|
680
679
|
}
|
|
681
680
|
return meta;
|
|
682
681
|
}
|
|
683
|
-
function applyRegistrySeeds(meta) {
|
|
684
|
-
const seeded = new Set(meta.seededPresets || []);
|
|
685
|
-
let changed = false;
|
|
686
|
-
for (const [type, presets] of Object.entries(SEEDED_REGISTRIES)) {
|
|
687
|
-
for (const [name, config] of Object.entries(presets)) {
|
|
688
|
-
const key = `${type}.${name}`;
|
|
689
|
-
if (seeded.has(key))
|
|
690
|
-
continue;
|
|
691
|
-
if (!meta.registries)
|
|
692
|
-
meta.registries = { mcp: {}, skill: {} };
|
|
693
|
-
if (!meta.registries[type])
|
|
694
|
-
meta.registries[type] = {};
|
|
695
|
-
if (!meta.registries[type][name]) {
|
|
696
|
-
meta.registries[type][name] = { ...config };
|
|
697
|
-
}
|
|
698
|
-
seeded.add(key);
|
|
699
|
-
changed = true;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
if (changed)
|
|
703
|
-
meta.seededPresets = [...seeded];
|
|
704
|
-
return changed;
|
|
705
|
-
}
|
|
706
682
|
/**
|
|
707
683
|
* One-shot migration: move agents.yaml from system repo to user repo.
|
|
708
684
|
* Idempotent — no-ops if user file already exists or system file absent.
|
|
@@ -813,17 +789,10 @@ export function readMeta() {
|
|
|
813
789
|
};
|
|
814
790
|
}
|
|
815
791
|
overlayMachineLocal(meta);
|
|
816
|
-
if (applyRegistrySeeds(meta)) {
|
|
817
|
-
writeMeta(meta);
|
|
818
|
-
return rememberMeta(meta);
|
|
819
|
-
}
|
|
820
792
|
return rememberMeta(meta);
|
|
821
793
|
}
|
|
822
794
|
const meta = createDefaultMeta();
|
|
823
795
|
overlayMachineLocal(meta);
|
|
824
|
-
if (applyRegistrySeeds(meta)) {
|
|
825
|
-
writeMeta(meta);
|
|
826
|
-
}
|
|
827
796
|
return rememberMeta(meta);
|
|
828
797
|
}
|
|
829
798
|
/** Serialize and write agents.yaml to the user repo, invalidating the in-memory cache. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureProcessStartTime } from '../platform/index.js';
|
|
1
2
|
import { AgentType } from './parsers.js';
|
|
2
3
|
/**
|
|
3
4
|
* Compute the Lowest Common Ancestor (LCA) of multiple file paths.
|
|
@@ -26,18 +27,25 @@ export type { AgentType } from './parsers.js';
|
|
|
26
27
|
*/
|
|
27
28
|
export declare function buildSentinelCommand(cmd: string[], exitCodePath: string): string;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* Env for a locally-spawned teammate. Freezes ONE actor for the whole spawn
|
|
31
|
+
* tree: actorEnv(resolveActor()) stamps AGENTS_ACTOR* onto the child env, so the
|
|
32
|
+
* teammate's inner `agents run` reads it via inheritedActor and short-circuits
|
|
33
|
+
* computeActor instead of re-resolving — every teammate under one orchestrator
|
|
34
|
+
* shares the orchestrator's single frozen actor (see actor.ts). Precedence:
|
|
35
|
+
* process env < actor < the teammate's --env overrides, so an explicit override
|
|
36
|
+
* still wins. Single source of truth shared by launchProcess() and its test.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildTeammateSpawnEnv(envOverrides: Record<string, string> | null): NodeJS.ProcessEnv;
|
|
39
|
+
/**
|
|
40
|
+
* Re-exported from `platform/process.ts`, which owns the one implementation.
|
|
35
41
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
42
|
+
* This module used to carry its own near-identical copy, and that copy had no
|
|
43
|
+
* Windows branch — it fell through to `ps`, which does not exist there, so
|
|
44
|
+
* `captureProcessStartTime` always returned null and the pid-reuse guard at
|
|
45
|
+
* `stop()` was silently inert on Windows. That is exactly how `agents teams stop`
|
|
46
|
+
* ends up SIGKILLing an unrelated process group once the OS recycles a pid.
|
|
39
47
|
*/
|
|
40
|
-
export
|
|
48
|
+
export { captureProcessStartTime };
|
|
41
49
|
/**
|
|
42
50
|
* Reasoning-intensity knob. Passed through to `agents run --effort`, which
|
|
43
51
|
* translates it into per-agent reasoning flags (claude --effort, codex
|
|
@@ -130,6 +138,7 @@ export declare class AgentProcess {
|
|
|
130
138
|
startedAt: Date;
|
|
131
139
|
completedAt: Date | null;
|
|
132
140
|
parentSessionId: string | null;
|
|
141
|
+
actor: string | null;
|
|
133
142
|
cloudSessionId: string | null;
|
|
134
143
|
cloudProvider: string | null;
|
|
135
144
|
prUrl: string | null;
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* dependency scheduling via --after, per-teammate model/effort overrides, and
|
|
8
8
|
* multiple permission modes (plan, edit, full).
|
|
9
9
|
*/
|
|
10
|
-
import { spawn, execSync
|
|
10
|
+
import { spawn, execSync } from 'child_process';
|
|
11
11
|
import { getAgentsInvocation } from '../daemon.js';
|
|
12
12
|
import * as fs from 'fs/promises';
|
|
13
13
|
import * as fsSync from 'fs';
|
|
@@ -15,7 +15,7 @@ import * as path from 'path';
|
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
17
|
import { resolveAgentsDir } from './persistence.js';
|
|
18
|
-
import { findExecutable } from '../platform/index.js';
|
|
18
|
+
import { findExecutable, captureProcessStartTime } from '../platform/index.js';
|
|
19
19
|
import { normalizeEvents } from './parsers.js';
|
|
20
20
|
import { debug } from './debug.js';
|
|
21
21
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
|
|
@@ -23,6 +23,7 @@ import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
|
|
|
23
23
|
import { AGENTS, getAccountInfo } from '../agents.js';
|
|
24
24
|
import { resolveVersion, isVersionInstalled, verifyInstalledBinaryLaunches } from '../versions.js';
|
|
25
25
|
import { sanitizeProcessEnv } from '../secrets/bundles.js';
|
|
26
|
+
import { resolveActor, actorEnv } from '../actor.js';
|
|
26
27
|
import { recordRunName } from '../session/run-names.js';
|
|
27
28
|
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
28
29
|
import { resolveHost } from '../hosts/registry.js';
|
|
@@ -169,42 +170,31 @@ export function buildSentinelCommand(cmd, exitCodePath) {
|
|
|
169
170
|
return `${cmd.map(shSingleQuote).join(' ')}; echo $? > ${shSingleQuote(exitCodePath)}`;
|
|
170
171
|
}
|
|
171
172
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* Linux: field 22 of /proc/<pid>/stat (starttime in clock ticks since boot).
|
|
180
|
-
* macOS: output of `ps -o lstart= -p <pid>` (start time in human format).
|
|
181
|
-
* Returns null on any error so callers can skip the guard rather than crash.
|
|
173
|
+
* Env for a locally-spawned teammate. Freezes ONE actor for the whole spawn
|
|
174
|
+
* tree: actorEnv(resolveActor()) stamps AGENTS_ACTOR* onto the child env, so the
|
|
175
|
+
* teammate's inner `agents run` reads it via inheritedActor and short-circuits
|
|
176
|
+
* computeActor instead of re-resolving — every teammate under one orchestrator
|
|
177
|
+
* shares the orchestrator's single frozen actor (see actor.ts). Precedence:
|
|
178
|
+
* process env < actor < the teammate's --env overrides, so an explicit override
|
|
179
|
+
* still wins. Single source of truth shared by launchProcess() and its test.
|
|
182
180
|
*/
|
|
183
|
-
export function
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const lastParen = stat.lastIndexOf(')');
|
|
190
|
-
if (lastParen < 0)
|
|
191
|
-
return null;
|
|
192
|
-
const tail = stat.slice(lastParen + 2);
|
|
193
|
-
const fields = tail.split(' ');
|
|
194
|
-
// After comm we are at field 3; starttime is field 22, so index 19 here.
|
|
195
|
-
return fields[19] || null;
|
|
196
|
-
}
|
|
197
|
-
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
198
|
-
encoding: 'utf-8',
|
|
199
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
200
|
-
});
|
|
201
|
-
const trimmed = out.trim();
|
|
202
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
181
|
+
export function buildTeammateSpawnEnv(envOverrides) {
|
|
182
|
+
return {
|
|
183
|
+
...sanitizeProcessEnv(process.env),
|
|
184
|
+
...actorEnv(resolveActor()),
|
|
185
|
+
...(envOverrides ?? {}),
|
|
186
|
+
};
|
|
207
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Re-exported from `platform/process.ts`, which owns the one implementation.
|
|
190
|
+
*
|
|
191
|
+
* This module used to carry its own near-identical copy, and that copy had no
|
|
192
|
+
* Windows branch — it fell through to `ps`, which does not exist there, so
|
|
193
|
+
* `captureProcessStartTime` always returned null and the pid-reuse guard at
|
|
194
|
+
* `stop()` was silently inert on Windows. That is exactly how `agents teams stop`
|
|
195
|
+
* ends up SIGKILLing an unrelated process group once the OS recycles a pid.
|
|
196
|
+
*/
|
|
197
|
+
export { captureProcessStartTime };
|
|
208
198
|
/** Agent types the team runner supports. */
|
|
209
199
|
const TEAM_AGENT_TYPES = ['codex', 'cursor', 'gemini', 'claude', 'opencode', 'grok', 'antigravity', 'kimi', 'droid'];
|
|
210
200
|
// Suffix appended to all prompts to ensure agents provide a summary
|
|
@@ -479,6 +469,12 @@ export class AgentProcess {
|
|
|
479
469
|
startedAt = new Date();
|
|
480
470
|
completedAt = null;
|
|
481
471
|
parentSessionId = null;
|
|
472
|
+
// Frozen actor (resolveActor().id) this teammate runs under. Stamped onto the
|
|
473
|
+
// local spawn env via actorEnv (buildTeammateSpawnEnv) so the teammate's inner
|
|
474
|
+
// `agents run` inherits one actor for the whole tree instead of re-resolving,
|
|
475
|
+
// and persisted so the record shows who ran it. Set from the resolved actor at
|
|
476
|
+
// construction; loadFromDisk restores the persisted value.
|
|
477
|
+
actor = null;
|
|
482
478
|
cloudSessionId = null;
|
|
483
479
|
cloudProvider = null;
|
|
484
480
|
prUrl = null;
|
|
@@ -564,6 +560,7 @@ export class AgentProcess {
|
|
|
564
560
|
this.completedAt = completedAt;
|
|
565
561
|
this.baseDir = baseDir;
|
|
566
562
|
this.parentSessionId = parentSessionId;
|
|
563
|
+
this.actor = resolveActor().id;
|
|
567
564
|
this.cloudSessionId = cloudSessionId;
|
|
568
565
|
this.cloudProvider = cloudProvider;
|
|
569
566
|
this.prUrl = prUrl;
|
|
@@ -628,6 +625,7 @@ export class AgentProcess {
|
|
|
628
625
|
duration: this.duration(),
|
|
629
626
|
mode: this.mode,
|
|
630
627
|
parent_session_id: this.parentSessionId,
|
|
628
|
+
actor: this.actor,
|
|
631
629
|
workspace_dir: this.workspaceDir,
|
|
632
630
|
cloud_session_id: this.cloudSessionId,
|
|
633
631
|
cloud_provider: this.cloudProvider,
|
|
@@ -884,6 +882,7 @@ export class AgentProcess {
|
|
|
884
882
|
started_at: this.startedAt.toISOString(),
|
|
885
883
|
completed_at: this.completedAt?.toISOString() || null,
|
|
886
884
|
parent_session_id: this.parentSessionId,
|
|
885
|
+
actor: this.actor,
|
|
887
886
|
cloud_session_id: this.cloudSessionId,
|
|
888
887
|
cloud_provider: this.cloudProvider,
|
|
889
888
|
pr_url: this.prUrl,
|
|
@@ -948,6 +947,10 @@ export class AgentProcess {
|
|
|
948
947
|
? meta.task_type
|
|
949
948
|
: null, meta.cloud_repo || null, meta.cloud_branch || null, meta.worktree_name || null, meta.worktree_path || null, meta.profile_name || null);
|
|
950
949
|
agent.startTime = typeof meta.start_time === 'string' ? meta.start_time : null;
|
|
950
|
+
// The persisted actor is the truth for a reload; the constructor set it to
|
|
951
|
+
// THIS process's resolved actor, which is wrong for a teammate someone else
|
|
952
|
+
// ran. Legacy teammates predating the field carry no actor -> null.
|
|
953
|
+
agent.actor = meta.actor ?? null;
|
|
951
954
|
// Distributed-team fields: set post-construction (like startTime) so the
|
|
952
955
|
// constructor signature stays fixed. Null on every pre-existing teammate.
|
|
953
956
|
agent.hostName = meta.host_name || null;
|
|
@@ -1556,9 +1559,7 @@ export class AgentManager {
|
|
|
1556
1559
|
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
1557
1560
|
cwd: agent.cwd || undefined,
|
|
1558
1561
|
detached: true,
|
|
1559
|
-
env: agent.envOverrides
|
|
1560
|
-
? { ...sanitizeProcessEnv(process.env), ...agent.envOverrides }
|
|
1561
|
-
: sanitizeProcessEnv(process.env),
|
|
1562
|
+
env: buildTeammateSpawnEnv(agent.envOverrides),
|
|
1562
1563
|
});
|
|
1563
1564
|
await new Promise((resolve, reject) => {
|
|
1564
1565
|
childProcess.once('spawn', resolve);
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -689,6 +689,26 @@ export interface BrandConfig {
|
|
|
689
689
|
/** False to keep the config but stop minting/using the brand. */
|
|
690
690
|
enabled: boolean;
|
|
691
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* An actor -- a responsible entity behind a run (a human today, a top-level
|
|
694
|
+
* agent later). Keyed in the `actors:` map by a short slug. Every field is
|
|
695
|
+
* optional enrichment over what `tailscale whois` already resolves: pin a
|
|
696
|
+
* preferred git email, add a github handle, or override the display name.
|
|
697
|
+
* `login` is the tailnet login-name to match against (defaults to the map key).
|
|
698
|
+
* See lib/actor.ts.
|
|
699
|
+
*/
|
|
700
|
+
export interface ActorConfig {
|
|
701
|
+
/** 'human' (default) or 'agent'. Only humans get personal git credit. */
|
|
702
|
+
kind?: 'human' | 'agent';
|
|
703
|
+
/** Display + git author name. Overrides the tailnet DisplayName. */
|
|
704
|
+
name?: string;
|
|
705
|
+
/** Git author email. Overrides the tailnet login email. */
|
|
706
|
+
email?: string;
|
|
707
|
+
/** GitHub handle, for PR attribution. */
|
|
708
|
+
github?: string;
|
|
709
|
+
/** Tailnet login-name this entry matches. Defaults to the map key. */
|
|
710
|
+
login?: string;
|
|
711
|
+
}
|
|
692
712
|
/** Top-level structure of ~/.agents/.system/agents.yaml -- the CLI's persistent state. */
|
|
693
713
|
export interface Meta {
|
|
694
714
|
agents?: Partial<Record<AgentId, string>>;
|
|
@@ -765,8 +785,23 @@ export interface Meta {
|
|
|
765
785
|
*/
|
|
766
786
|
brands?: Record<string, BrandConfig>;
|
|
767
787
|
/**
|
|
768
|
-
*
|
|
769
|
-
*
|
|
788
|
+
* Actors keyed by slug -- who is behind a run. Enriches or overrides the
|
|
789
|
+
* identity `tailscale whois` resolves (git email, github handle, display
|
|
790
|
+
* name, human vs agent). See lib/actor.ts.
|
|
791
|
+
*/
|
|
792
|
+
actors?: Record<string, ActorConfig>;
|
|
793
|
+
/**
|
|
794
|
+
* Removal tombstones for SEEDED_REGISTRIES presets, keyed like `skill.hermes`.
|
|
795
|
+
*
|
|
796
|
+
* Seeded presets are resolved in memory by `getRegistries` (see
|
|
797
|
+
* `offeredSeeds`) rather than written into agents.yaml — persisting them from
|
|
798
|
+
* the read path dirtied this git-tracked file and deadlocked
|
|
799
|
+
* `agents repo pull` (RUSH-1925). A key listed here means the user ran
|
|
800
|
+
* `registry remove` on that preset, so it is no longer offered.
|
|
801
|
+
*
|
|
802
|
+
* Entries written by the pre-RUSH-1925 seeding also appear here; those files
|
|
803
|
+
* carry the registry in their own `registries:` block too, which takes
|
|
804
|
+
* precedence, so the preset stays exactly as configured.
|
|
770
805
|
*/
|
|
771
806
|
seededPresets?: string[];
|
|
772
807
|
/**
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -151,6 +151,22 @@ export declare function deriveUsageStatusFromSnapshot(snapshot: UsageSnapshot |
|
|
|
151
151
|
export declare function formatUsageStatusBadge(usageStatus: 'available' | 'rate_limited' | 'out_of_credits' | null | undefined): string;
|
|
152
152
|
/** Format a multi-line usage section for detailed agent views. */
|
|
153
153
|
export declare function formatUsageSection(usage: UsageInfo): string[];
|
|
154
|
+
/**
|
|
155
|
+
* The access token to use for a READ-ONLY Claude usage fetch, or null when the
|
|
156
|
+
* stored token is within the refresh leeway.
|
|
157
|
+
*
|
|
158
|
+
* Returns null instead of refreshing on purpose. Claude's refresh token is
|
|
159
|
+
* single-use and rotates server-side on every refresh; with one account signed
|
|
160
|
+
* into several machines, refreshing here would stampede that one token and
|
|
161
|
+
* silently invalidate every other holder — the RUSH-1822 failure, except in the
|
|
162
|
+
* usage path (fired in the background by the SWR cache and by `agents run`'s
|
|
163
|
+
* default "balanced" rotation on every unpinned run) rather than the health
|
|
164
|
+
* probe. So a usage read must never rotate: a near-expiry token yields "no usage
|
|
165
|
+
* right now" instead of a fleet-wide logout. Mirrors {@link probeClaudeStatus};
|
|
166
|
+
* the single legitimate refresh belongs to the actual claude run, never a read.
|
|
167
|
+
* Pure — unit-tested.
|
|
168
|
+
*/
|
|
169
|
+
export declare function claudeUsageAccessTokenNoRefresh(oauth: Pick<ClaudeOauthCredentials, 'accessToken' | 'expiresAt'>): string | null;
|
|
154
170
|
/** Raw quota bucket from the Kimi /usages response (numbers arrive as strings). */
|
|
155
171
|
interface KimiUsageQuota {
|
|
156
172
|
limit?: string | number | null;
|
|
@@ -242,8 +258,24 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
|
|
|
242
258
|
* Without step 2 the live usage fetch got no token on Linux, so `agents view`
|
|
243
259
|
* (run remotely over SSH by `--host`) rendered no usage bars even though the
|
|
244
260
|
* account + plan — read from the plaintext `.claude.json` — showed fine.
|
|
261
|
+
*
|
|
262
|
+
* `opts.accessTokenCache` opts INTO the no-ACL access-token cache (the Touch ID
|
|
263
|
+
* storm fix, see the cache helpers above). It is OFF by default so every caller
|
|
264
|
+
* keeps the full ACL-read credential — the cached copy deliberately omits the
|
|
265
|
+
* refresh token, so callers that refresh (`isClaudeAuthValid` ->
|
|
266
|
+
* `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
|
|
267
|
+
* for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
|
|
268
|
+
* access-token-only consumers (the usage fetch and the auth-health probe) opt in.
|
|
269
|
+
*/
|
|
270
|
+
export declare function loadClaudeOauth(home?: string, opts?: {
|
|
271
|
+
accessTokenCache?: boolean;
|
|
272
|
+
}): Promise<ClaudeOauthCredentials | null>;
|
|
273
|
+
/**
|
|
274
|
+
* Save Claude OAuth credentials to the system keychain/keyring.
|
|
275
|
+
* Reads the existing payload, merges the new OAuth fields, and writes back.
|
|
276
|
+
* Exported for regression tests; not part of the public command surface.
|
|
245
277
|
*/
|
|
246
|
-
export declare function
|
|
278
|
+
export declare function saveClaudeOauth(home: string | undefined, credentials: ClaudeOauthCredentials): Promise<boolean>;
|
|
247
279
|
/**
|
|
248
280
|
* Derive the Keychain service name for a Claude home directory.
|
|
249
281
|
* Managed (non-default) homes get a hash suffix for isolation.
|
package/dist/lib/usage.js
CHANGED
|
@@ -17,7 +17,7 @@ import { promisify } from 'util';
|
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import { decodeJwtPayload, decryptDroidAuthPayload } from './agents.js';
|
|
19
19
|
import { walkForFiles } from './fs-walk.js';
|
|
20
|
-
import { getKeychainToken, setKeychainToken, deleteKeychainToken, } from './secrets/index.js';
|
|
20
|
+
import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden, } from './secrets/index.js';
|
|
21
21
|
import { getCacheDir } from './state.js';
|
|
22
22
|
const execFileAsync = promisify(execFile);
|
|
23
23
|
const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
@@ -375,10 +375,33 @@ async function getCodexUsageInfo(options) {
|
|
|
375
375
|
return { snapshot: null, error: null };
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* The access token to use for a READ-ONLY Claude usage fetch, or null when the
|
|
380
|
+
* stored token is within the refresh leeway.
|
|
381
|
+
*
|
|
382
|
+
* Returns null instead of refreshing on purpose. Claude's refresh token is
|
|
383
|
+
* single-use and rotates server-side on every refresh; with one account signed
|
|
384
|
+
* into several machines, refreshing here would stampede that one token and
|
|
385
|
+
* silently invalidate every other holder — the RUSH-1822 failure, except in the
|
|
386
|
+
* usage path (fired in the background by the SWR cache and by `agents run`'s
|
|
387
|
+
* default "balanced" rotation on every unpinned run) rather than the health
|
|
388
|
+
* probe. So a usage read must never rotate: a near-expiry token yields "no usage
|
|
389
|
+
* right now" instead of a fleet-wide logout. Mirrors {@link probeClaudeStatus};
|
|
390
|
+
* the single legitimate refresh belongs to the actual claude run, never a read.
|
|
391
|
+
* Pure — unit-tested.
|
|
392
|
+
*/
|
|
393
|
+
export function claudeUsageAccessTokenNoRefresh(oauth) {
|
|
394
|
+
if (claudeAccessTokenNeedsRefresh(oauth.expiresAt ?? null))
|
|
395
|
+
return null;
|
|
396
|
+
const token = oauth.accessToken?.trim();
|
|
397
|
+
return token ? token : null;
|
|
398
|
+
}
|
|
378
399
|
/** Fetch Claude usage via the Anthropic OAuth usage API. */
|
|
379
400
|
async function getClaudeUsageInfo(options) {
|
|
380
401
|
try {
|
|
381
|
-
|
|
402
|
+
// Opt into the no-ACL access-token cache: this is the every-60s watchdog hot
|
|
403
|
+
// path and usage needs only the access token, so it kills the Touch ID storm.
|
|
404
|
+
const oauth = await loadClaudeOauth(options?.home, { accessTokenCache: true });
|
|
382
405
|
if (!oauth?.accessToken) {
|
|
383
406
|
return { snapshot: null, error: null };
|
|
384
407
|
}
|
|
@@ -387,7 +410,8 @@ async function getClaudeUsageInfo(options) {
|
|
|
387
410
|
if (!isClaudeUsageOrgMatch(requestedOrgId, liveOrgId)) {
|
|
388
411
|
return { snapshot: null, error: null };
|
|
389
412
|
}
|
|
390
|
-
|
|
413
|
+
// Read-only: never refresh a single-use token just to read usage (RUSH-1822).
|
|
414
|
+
const accessToken = claudeUsageAccessTokenNoRefresh(oauth);
|
|
391
415
|
if (!accessToken) {
|
|
392
416
|
return { snapshot: null, error: null };
|
|
393
417
|
}
|
|
@@ -627,7 +651,10 @@ async function getDroidUsageInfo(options) {
|
|
|
627
651
|
}
|
|
628
652
|
/** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
|
|
629
653
|
export async function probeClaudeStatus(home, cliVersion) {
|
|
630
|
-
|
|
654
|
+
// Opt into the no-ACL access-token cache: the daemon warms this probe every ~3
|
|
655
|
+
// min per account and it never refreshes (access token only), so caching is safe
|
|
656
|
+
// and stops it from adding to the Touch ID storm.
|
|
657
|
+
const oauth = await loadClaudeOauth(home, { accessTokenCache: true });
|
|
631
658
|
const accessToken = oauth?.accessToken?.trim();
|
|
632
659
|
if (!accessToken)
|
|
633
660
|
return { status: null, token: 'missing' };
|
|
@@ -862,6 +889,103 @@ function parseClaudeOauthPayload(raw) {
|
|
|
862
889
|
return null;
|
|
863
890
|
}
|
|
864
891
|
}
|
|
892
|
+
// ── No-ACL cache for Claude's OAuth token (kills the macOS Touch ID storm) ──
|
|
893
|
+
//
|
|
894
|
+
// The source item `Claude Code-credentials-<hash>` is ACL-bound to Claude Code's
|
|
895
|
+
// own process, so every read agents-cli makes (via `/usr/bin/security`) pops
|
|
896
|
+
// Touch ID. The Factory watchdog polls `agents view --json` every 60s per agent,
|
|
897
|
+
// and each poll that crosses the 2-minute usage cache fires a background refresh
|
|
898
|
+
// -> loadClaudeOauth -> keychain read -> a biometric prompt. With many agents and
|
|
899
|
+
// accounts that is a prompt every couple of minutes, per account.
|
|
900
|
+
//
|
|
901
|
+
// Fix: after one real (prompting) read, cache the ACCESS token in a device-local
|
|
902
|
+
// NO-ACL keychain item — the same `set-no-acl` mechanism secrets/session-store.ts
|
|
903
|
+
// uses for unlocked bundles, whose reads never prompt — and serve every later read
|
|
904
|
+
// from it until the token's own `expiresAt`. The ACL read then happens at most once
|
|
905
|
+
// per token lifetime (~a few times a day), shared across every agent process.
|
|
906
|
+
//
|
|
907
|
+
// Security posture: only the short-lived access token is cached, never the refresh
|
|
908
|
+
// token, and the entry dies at the token's expiry (capped below). This mirrors the
|
|
909
|
+
// no-ACL posture session-store.ts already accepts for held bundles.
|
|
910
|
+
const CLAUDE_OAUTH_CACHE_PREFIX = 'agents-cli.claude-oauth-cache.';
|
|
911
|
+
/** Hard cap so a token with a distant or absent `expiresAt` can't pin a stale entry. */
|
|
912
|
+
const CLAUDE_OAUTH_CACHE_MAX_TTL_MS = 8 * 60 * 60 * 1000;
|
|
913
|
+
/** The no-ACL cache item name for a Claude keychain service (hashed to stay tidy). */
|
|
914
|
+
function claudeOauthCacheItem(service) {
|
|
915
|
+
const hash = createHash('sha256').update(service).digest('hex').slice(0, 16);
|
|
916
|
+
return `${CLAUDE_OAUTH_CACHE_PREFIX}${hash}`;
|
|
917
|
+
}
|
|
918
|
+
/** Whether the no-ACL cache is meaningful: macOS (where the source read prompts) or
|
|
919
|
+
* whenever a test keychain backend is installed, so the path is exercisable on CI.
|
|
920
|
+
* Off macOS in production the source read is prompt-free, so there is nothing to cache. */
|
|
921
|
+
function claudeOauthCacheActive() {
|
|
922
|
+
return process.platform === 'darwin' || isKeychainBackendOverridden();
|
|
923
|
+
}
|
|
924
|
+
/** Read the cached access token (prompt-free). Null on miss, or when the cache entry
|
|
925
|
+
* or the token itself has expired — in which case the stale entry is dropped. */
|
|
926
|
+
function readCachedClaudeOauth(service) {
|
|
927
|
+
try {
|
|
928
|
+
const entry = JSON.parse(getKeychainToken(claudeOauthCacheItem(service)));
|
|
929
|
+
if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken)
|
|
930
|
+
return null;
|
|
931
|
+
const now = Date.now();
|
|
932
|
+
const tokenExpired = typeof entry.expiresAt === 'number' && entry.expiresAt > 0 && now >= entry.expiresAt;
|
|
933
|
+
if (now >= entry.cacheExpiresAt || tokenExpired) {
|
|
934
|
+
try {
|
|
935
|
+
deleteKeychainToken(claudeOauthCacheItem(service));
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
/* best-effort eviction */
|
|
939
|
+
}
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
return {
|
|
943
|
+
accessToken: entry.accessToken,
|
|
944
|
+
refreshToken: null,
|
|
945
|
+
expiresAt: entry.expiresAt ?? null,
|
|
946
|
+
scopes: entry.scopes ?? null,
|
|
947
|
+
subscriptionType: entry.subscriptionType ?? null,
|
|
948
|
+
rateLimitTier: entry.rateLimitTier ?? null,
|
|
949
|
+
organizationUuid: entry.organizationUuid ?? null,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
/** Populate the no-ACL cache after a real (prompting) source read. Best-effort — the
|
|
957
|
+
* cache is an optimization, so any failure just means the next read prompts again. */
|
|
958
|
+
function writeCachedClaudeOauth(service, creds) {
|
|
959
|
+
if (!creds.accessToken)
|
|
960
|
+
return;
|
|
961
|
+
try {
|
|
962
|
+
const now = Date.now();
|
|
963
|
+
const cap = now + CLAUDE_OAUTH_CACHE_MAX_TTL_MS;
|
|
964
|
+
const tokenExp = typeof creds.expiresAt === 'number' && creds.expiresAt > now ? creds.expiresAt : cap;
|
|
965
|
+
const entry = {
|
|
966
|
+
accessToken: creds.accessToken,
|
|
967
|
+
expiresAt: creds.expiresAt ?? null,
|
|
968
|
+
scopes: creds.scopes ?? null,
|
|
969
|
+
subscriptionType: creds.subscriptionType ?? null,
|
|
970
|
+
rateLimitTier: creds.rateLimitTier ?? null,
|
|
971
|
+
organizationUuid: creds.organizationUuid ?? null,
|
|
972
|
+
cacheExpiresAt: Math.min(tokenExp, cap),
|
|
973
|
+
};
|
|
974
|
+
setKeychainToken(claudeOauthCacheItem(service), JSON.stringify(entry), { noAcl: true });
|
|
975
|
+
}
|
|
976
|
+
catch {
|
|
977
|
+
/* best-effort — cache is an optimization */
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
/** Evict the no-ACL cache so a source rotation or sign-out is reflected immediately. */
|
|
981
|
+
function deleteCachedClaudeOauth(service) {
|
|
982
|
+
try {
|
|
983
|
+
deleteKeychainToken(claudeOauthCacheItem(service));
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
/* best-effort — cache is an optimization */
|
|
987
|
+
}
|
|
988
|
+
}
|
|
865
989
|
/**
|
|
866
990
|
* Load a version home's Claude OAuth credential from the two stores Claude Code
|
|
867
991
|
* uses, tried in order:
|
|
@@ -878,19 +1002,48 @@ function parseClaudeOauthPayload(raw) {
|
|
|
878
1002
|
* Without step 2 the live usage fetch got no token on Linux, so `agents view`
|
|
879
1003
|
* (run remotely over SSH by `--host`) rendered no usage bars even though the
|
|
880
1004
|
* account + plan — read from the plaintext `.claude.json` — showed fine.
|
|
1005
|
+
*
|
|
1006
|
+
* `opts.accessTokenCache` opts INTO the no-ACL access-token cache (the Touch ID
|
|
1007
|
+
* storm fix, see the cache helpers above). It is OFF by default so every caller
|
|
1008
|
+
* keeps the full ACL-read credential — the cached copy deliberately omits the
|
|
1009
|
+
* refresh token, so callers that refresh (`isClaudeAuthValid` ->
|
|
1010
|
+
* `getClaudeAccessToken`) or export the full blob (`readClaudeCredentialsBlob`
|
|
1011
|
+
* for Rush Cloud dispatch) must NOT pass it. Only the read-only, high-frequency
|
|
1012
|
+
* access-token-only consumers (the usage fetch and the auth-health probe) opt in.
|
|
881
1013
|
*/
|
|
882
|
-
export async function loadClaudeOauth(home) {
|
|
1014
|
+
export async function loadClaudeOauth(home, opts) {
|
|
883
1015
|
// The OS keychain/keyring step is macOS/Linux-only. Windows skips straight
|
|
884
1016
|
// to the .credentials.json fallback — the Claude CLI has no keychain
|
|
885
|
-
// integration there and stores its OAuth token in that file too.
|
|
886
|
-
|
|
1017
|
+
// integration there and stores its OAuth token in that file too. An injected
|
|
1018
|
+
// test backend is the exception: it makes the keychain path exercisable
|
|
1019
|
+
// anywhere, so the platform check must yield to it exactly as the inner
|
|
1020
|
+
// claudeOauthCacheActive() gate does — otherwise this whole block is dead on
|
|
1021
|
+
// a Windows runner and the tests below it read an empty home.
|
|
1022
|
+
if (process.platform === 'darwin' || process.platform === 'linux' || isKeychainBackendOverridden()) {
|
|
1023
|
+
const service = getClaudeKeychainService(home);
|
|
1024
|
+
// Serve the no-ACL cache first (opt-in, macOS/test only) so the ACL-gated read
|
|
1025
|
+
// below — the one that pops Touch ID — happens at most once per token lifetime
|
|
1026
|
+
// instead of on every usage refresh. Off unless the caller opts in, because the
|
|
1027
|
+
// cached copy drops the refresh token (see the doc comment above).
|
|
1028
|
+
const cacheActive = opts?.accessTokenCache === true && claudeOauthCacheActive();
|
|
1029
|
+
if (cacheActive) {
|
|
1030
|
+
const cached = readCachedClaudeOauth(service);
|
|
1031
|
+
if (cached)
|
|
1032
|
+
return cached;
|
|
1033
|
+
}
|
|
887
1034
|
try {
|
|
888
|
-
const fromKeychain = parseClaudeOauthPayload(getKeychainToken(
|
|
889
|
-
if (fromKeychain)
|
|
1035
|
+
const fromKeychain = parseClaudeOauthPayload(getKeychainToken(service));
|
|
1036
|
+
if (fromKeychain) {
|
|
1037
|
+
if (cacheActive)
|
|
1038
|
+
writeCachedClaudeOauth(service, fromKeychain);
|
|
890
1039
|
return fromKeychain;
|
|
1040
|
+
}
|
|
891
1041
|
}
|
|
892
1042
|
catch {
|
|
893
1043
|
// No keychain item, or no reachable keyring (headless Linux) — fall through.
|
|
1044
|
+
// Evict any stale no-ACL cache so a sign-out/deletion isn't masked.
|
|
1045
|
+
if (cacheActive)
|
|
1046
|
+
deleteCachedClaudeOauth(service);
|
|
894
1047
|
}
|
|
895
1048
|
}
|
|
896
1049
|
const credsPath = path.join(home ?? os.homedir(), '.claude', '.credentials.json');
|
|
@@ -907,10 +1060,15 @@ export async function loadClaudeOauth(home) {
|
|
|
907
1060
|
/**
|
|
908
1061
|
* Save Claude OAuth credentials to the system keychain/keyring.
|
|
909
1062
|
* Reads the existing payload, merges the new OAuth fields, and writes back.
|
|
1063
|
+
* Exported for regression tests; not part of the public command surface.
|
|
910
1064
|
*/
|
|
911
|
-
async function saveClaudeOauth(home, credentials) {
|
|
912
|
-
// Windows not yet supported
|
|
913
|
-
|
|
1065
|
+
export async function saveClaudeOauth(home, credentials) {
|
|
1066
|
+
// Windows not yet supported. An injected test backend is the exception, for
|
|
1067
|
+
// the same reason as the loadClaudeOauth guard above: it makes the keychain
|
|
1068
|
+
// path exercisable anywhere, and without it this returns before the rotated
|
|
1069
|
+
// credential is written OR the no-ACL cache is evicted — so a cached reader
|
|
1070
|
+
// keeps serving the pre-rotation access token.
|
|
1071
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux' && !isKeychainBackendOverridden()) {
|
|
914
1072
|
return false;
|
|
915
1073
|
}
|
|
916
1074
|
try {
|
|
@@ -944,6 +1102,8 @@ async function saveClaudeOauth(home, credentials) {
|
|
|
944
1102
|
// Entry might not exist, ignore
|
|
945
1103
|
}
|
|
946
1104
|
setKeychainToken(service, payloadJson);
|
|
1105
|
+
// A new credential rotation means any cached access token is stale.
|
|
1106
|
+
deleteCachedClaudeOauth(service);
|
|
947
1107
|
return true;
|
|
948
1108
|
}
|
|
949
1109
|
catch {
|