@phnx-labs/agents-cli 1.20.76 → 1.20.77
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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/lib/runner.js
CHANGED
|
@@ -29,6 +29,7 @@ import { backgroundSpawnOptions } from './platform/process.js';
|
|
|
29
29
|
import { walkForFiles } from './fs-walk.js';
|
|
30
30
|
import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
31
31
|
import { claudeHomeHasOwnCredential } from './agents.js';
|
|
32
|
+
import { resolveAccountSetupToken } from './secrets/account-token.js';
|
|
32
33
|
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
33
34
|
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
34
35
|
import { machineId } from './machine-id.js';
|
|
@@ -399,18 +400,28 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
|
|
|
399
400
|
if (v !== undefined)
|
|
400
401
|
out[k] = v;
|
|
401
402
|
}
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
+
}
|
|
414
425
|
}
|
|
415
426
|
if (timezone)
|
|
416
427
|
out.TZ = timezone;
|
|
@@ -512,11 +523,19 @@ export async function executeJob(config, deps) {
|
|
|
512
523
|
if (eligibility) {
|
|
513
524
|
throw new Error(eligibility.message);
|
|
514
525
|
}
|
|
515
|
-
//
|
|
516
|
-
// version selection / sandbox / spawn
|
|
517
|
-
// `routines run`, catchup) follow the
|
|
518
|
-
|
|
519
|
-
|
|
526
|
+
// Placement (hostStrategy / bare host:) — body may run on another machine
|
|
527
|
+
// over SSH or in the cloud; local version selection / sandbox / spawn then
|
|
528
|
+
// do not apply. Sync callers (manual `routines run`, catchup) follow the
|
|
529
|
+
// remote run to completion when possible.
|
|
530
|
+
{
|
|
531
|
+
const { resolvePlacementTarget } = await import('./routines-placement.js');
|
|
532
|
+
const target = resolvePlacementTarget(config);
|
|
533
|
+
if (target.mode === 'host') {
|
|
534
|
+
return executeJobOnHost({ ...config, host: target.host }, { detached: false });
|
|
535
|
+
}
|
|
536
|
+
if (target.mode === 'cloud') {
|
|
537
|
+
return executeJobOnCloud(config, { detached: false });
|
|
538
|
+
}
|
|
520
539
|
}
|
|
521
540
|
// Command-mode: run a plain shell command directly (no agent, no rotation,
|
|
522
541
|
// no pinning, no sandbox overlay). Reuses the run-record machinery so
|
|
@@ -735,6 +754,86 @@ export async function executeJob(config, deps) {
|
|
|
735
754
|
timer.end({ status: 'failed', exitCode: 1, runId });
|
|
736
755
|
return { meta, reportPath: null };
|
|
737
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* Dispatch a routine to the agent's native cloud provider (or the configured
|
|
759
|
+
* default). Writes a local run record with `cloudTaskId` so list/runs still
|
|
760
|
+
* work; does not wait for cloud completion on the detached path.
|
|
761
|
+
*/
|
|
762
|
+
async function executeJobOnCloud(config, opts) {
|
|
763
|
+
if (config.workflow) {
|
|
764
|
+
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'workflow:'.`);
|
|
765
|
+
}
|
|
766
|
+
if (config.loop) {
|
|
767
|
+
throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'loop:'.`);
|
|
768
|
+
}
|
|
769
|
+
if (config.command) {
|
|
770
|
+
throw new Error(`Routine '${config.name}' uses 'command:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'command:'.`);
|
|
771
|
+
}
|
|
772
|
+
if (!config.agent) {
|
|
773
|
+
throw new Error(`Routine '${config.name}' hostStrategy: cloud requires an agent`);
|
|
774
|
+
}
|
|
775
|
+
const { resolveProvider } = await import('./cloud/registry.js');
|
|
776
|
+
const { insertTask } = await import('./cloud/store.js');
|
|
777
|
+
const provider = resolveProvider(undefined, config.agent);
|
|
778
|
+
const timer = createTimer('agent.run', {
|
|
779
|
+
agent: config.agent,
|
|
780
|
+
jobName: config.name,
|
|
781
|
+
mode: config.mode,
|
|
782
|
+
placement: 'cloud',
|
|
783
|
+
...redactPrompt(config.prompt),
|
|
784
|
+
schedule: config.schedule,
|
|
785
|
+
});
|
|
786
|
+
const runId = generateRunId();
|
|
787
|
+
const runDir = getRunDir(config.name, runId);
|
|
788
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
789
|
+
const meta = {
|
|
790
|
+
jobName: config.name,
|
|
791
|
+
runId,
|
|
792
|
+
agent: config.agent,
|
|
793
|
+
pid: null,
|
|
794
|
+
spawnedAt: Date.now(),
|
|
795
|
+
status: 'running',
|
|
796
|
+
startedAt: new Date().toISOString(),
|
|
797
|
+
completedAt: null,
|
|
798
|
+
exitCode: null,
|
|
799
|
+
};
|
|
800
|
+
writeRunMeta(meta);
|
|
801
|
+
try {
|
|
802
|
+
const task = await provider.dispatch({
|
|
803
|
+
prompt: resolveJobPrompt(config),
|
|
804
|
+
agent: config.agent,
|
|
805
|
+
repo: config.repo,
|
|
806
|
+
timeout: config.timeout,
|
|
807
|
+
model: config.config?.model,
|
|
808
|
+
});
|
|
809
|
+
try {
|
|
810
|
+
insertTask(task);
|
|
811
|
+
}
|
|
812
|
+
catch { /* store is best-effort */ }
|
|
813
|
+
meta.cloudTaskId = task.id;
|
|
814
|
+
meta.cloudProvider = task.provider;
|
|
815
|
+
// Terminal cloud responses (e.g. antigravity) finalize immediately.
|
|
816
|
+
// Async providers leave the run `running` with the cloud task id for
|
|
817
|
+
// the user to follow via `agents cloud status`.
|
|
818
|
+
if (task.status === 'completed') {
|
|
819
|
+
finalizeRunMeta(meta, 'completed', 0);
|
|
820
|
+
}
|
|
821
|
+
else if (task.status === 'failed' || task.status === 'cancelled') {
|
|
822
|
+
finalizeRunMeta(meta, 'failed', 1, { errorMessage: task.summary ?? `cloud ${task.status}` });
|
|
823
|
+
}
|
|
824
|
+
// Non-terminal statuses stay `running` for both detached and sync paths;
|
|
825
|
+
// the user follows via `agents cloud status <id>`.
|
|
826
|
+
writeRunMeta(meta);
|
|
827
|
+
timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
|
|
828
|
+
return { meta, reportPath: null };
|
|
829
|
+
}
|
|
830
|
+
catch (err) {
|
|
831
|
+
finalizeRunMeta(meta, 'failed', 1, { errorMessage: err.message });
|
|
832
|
+
writeRunMeta(meta);
|
|
833
|
+
timer.end({ status: 'failed', exitCode: 1, runId, error: err.message });
|
|
834
|
+
throw err;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
738
837
|
async function executeJobOnHost(config, opts) {
|
|
739
838
|
if (config.workflow) {
|
|
740
839
|
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
|
|
@@ -877,24 +976,44 @@ async function executeCommandJobForeground(config) {
|
|
|
877
976
|
});
|
|
878
977
|
return { meta, reportPath: null };
|
|
879
978
|
}
|
|
979
|
+
/** Invoke a lifecycle hook without letting a caller error break run finalization. */
|
|
980
|
+
function safeHook(fn) {
|
|
981
|
+
if (!fn)
|
|
982
|
+
return;
|
|
983
|
+
try {
|
|
984
|
+
fn();
|
|
985
|
+
}
|
|
986
|
+
catch { /* hooks are best-effort */ }
|
|
987
|
+
}
|
|
880
988
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
881
|
-
|
|
882
|
-
export async function executeJobDetached(config) {
|
|
989
|
+
export async function executeJobDetached(config, hooks) {
|
|
883
990
|
const eligibility = checkJobDeviceEligibility(config);
|
|
884
991
|
if (eligibility) {
|
|
885
992
|
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
886
993
|
throw new Error(eligibility.message);
|
|
887
994
|
}
|
|
888
|
-
//
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
995
|
+
// Placement (hostStrategy / bare host:) — dispatch off-box and return; the
|
|
996
|
+
// monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
|
|
997
|
+
// Either way the in-process onFinish hook does not fire for off-box routines
|
|
998
|
+
// (the monitor tick observes an already-finalized record), so notify-desktop
|
|
999
|
+
// sends the finish notification only for local detached runs below (RUSH-2030).
|
|
1000
|
+
{
|
|
1001
|
+
const { resolvePlacementTarget } = await import('./routines-placement.js');
|
|
1002
|
+
const target = resolvePlacementTarget(config);
|
|
1003
|
+
if (target.mode === 'host') {
|
|
1004
|
+
const { meta } = await executeJobOnHost({ ...config, host: target.host }, { detached: true });
|
|
1005
|
+
return meta;
|
|
1006
|
+
}
|
|
1007
|
+
if (target.mode === 'cloud') {
|
|
1008
|
+
const { meta } = await executeJobOnCloud(config, { detached: true });
|
|
1009
|
+
return meta;
|
|
1010
|
+
}
|
|
892
1011
|
}
|
|
893
1012
|
// Command-mode: fire a plain shell command detached (no agent, no rotation,
|
|
894
1013
|
// no pinning, no sandbox overlay). Still writes a run record so the daemon,
|
|
895
1014
|
// list/runs, and overdue tracking keep working.
|
|
896
1015
|
if (config.command) {
|
|
897
|
-
return executeCommandJobDetached(config);
|
|
1016
|
+
return executeCommandJobDetached(config, hooks);
|
|
898
1017
|
}
|
|
899
1018
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
900
1019
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
@@ -962,6 +1081,9 @@ export async function executeJobDetached(config) {
|
|
|
962
1081
|
const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
|
|
963
1082
|
if (status !== 'timeout' && !isAuthFailure)
|
|
964
1083
|
extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
|
|
1084
|
+
// Fire the finish/output notification AFTER the report is written so the hook
|
|
1085
|
+
// can read report.md (RUSH-2030). Best-effort; never breaks finalization.
|
|
1086
|
+
safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
|
|
965
1087
|
};
|
|
966
1088
|
child.on('exit', (code) => {
|
|
967
1089
|
let logText = '';
|
|
@@ -1008,7 +1130,7 @@ export async function executeJobDetached(config) {
|
|
|
1008
1130
|
* un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
|
|
1009
1131
|
* `monitorRunningJobs` reaps the record on the next tick.
|
|
1010
1132
|
*/
|
|
1011
|
-
function executeCommandJobDetached(config) {
|
|
1133
|
+
function executeCommandJobDetached(config, hooks) {
|
|
1012
1134
|
const runId = generateRunId();
|
|
1013
1135
|
const runDir = getRunDir(config.name, runId);
|
|
1014
1136
|
fs.mkdirSync(runDir, { recursive: true });
|
|
@@ -1053,6 +1175,9 @@ function executeCommandJobDetached(config) {
|
|
|
1053
1175
|
settled = true;
|
|
1054
1176
|
finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
|
|
1055
1177
|
writeRunMeta(meta);
|
|
1178
|
+
// Finish notification (RUSH-2030). For command routines the threshold only
|
|
1179
|
+
// surfaces failures, decided in routine-notify.ts. Best-effort.
|
|
1180
|
+
safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
|
|
1056
1181
|
};
|
|
1057
1182
|
child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
|
|
1058
1183
|
child.on('error', (err) => {
|
package/dist/lib/sandbox.js
CHANGED
|
@@ -75,6 +75,16 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
|
|
|
75
75
|
env[key] = process.env[key];
|
|
76
76
|
}
|
|
77
77
|
}
|
|
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
|
+
}
|
|
78
88
|
if (extraEnv) {
|
|
79
89
|
Object.assign(env, extraEnv);
|
|
80
90
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
|
|
2
|
+
export declare function accountTokenKey(account: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Read the signed-in account email for a Claude version home from
|
|
5
|
+
* `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
|
|
6
|
+
* the canonical resolver uses (agents.ts getAccountInfo): the shim-set
|
|
7
|
+
* `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
|
|
8
|
+
* home-level `<home>/.claude.json` (an account signed in via the IDE / direct
|
|
9
|
+
* binary without the shim writes there). Returns null when neither has a usable
|
|
10
|
+
* email.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readClaudeAccountEmail(home: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the per-account setup-token for the account pinned to `home`, looking
|
|
15
|
+
* it up in the provided env (the daemon injects the `claude` bundle's
|
|
16
|
+
* `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
|
|
17
|
+
* account, or no matching per-account token is present — callers then leave the
|
|
18
|
+
* existing ambient/interactive credential untouched (a safe no-op).
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveAccountSetupToken(env: Record<string, string | undefined>, home: string): string | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Per-account Claude setup-token resolution.
|
|
2
|
+
//
|
|
3
|
+
// A headless/unattended Claude run should authenticate with the account's
|
|
4
|
+
// long-lived `claude setup-token` (exported as a per-account
|
|
5
|
+
// `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env var), NOT the interactive OAuth session.
|
|
6
|
+
// Claude Code's interactive session uses single-use ROTATING refresh tokens: when
|
|
7
|
+
// one machine refreshes, the server invalidates the old refresh token globally, so
|
|
8
|
+
// every other machine on that account 401s and gets logged out (Claude Code
|
|
9
|
+
// #25609 / #56339). The 1-year setup-token sits earlier in the auth precedence
|
|
10
|
+
// (`CLAUDE_CODE_OAUTH_TOKEN`, before subscription login) and does not participate
|
|
11
|
+
// in that rotation — so per-account setup-tokens keep the fleet signed in.
|
|
12
|
+
//
|
|
13
|
+
// The per-account env key encodes the account email. The convention (verified
|
|
14
|
+
// against live bundles, e.g. `muqsit@getrush.ai` →
|
|
15
|
+
// `CLAUDE_CODE_OAUTH_TOKEN_MUQSIT_AT_GETRUSH_DOT_AI`): upper-case, `@`→`_AT_`,
|
|
16
|
+
// `.`→`_DOT_`, any other non-alphanumeric → `_`.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
/** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
|
|
20
|
+
export function accountTokenKey(account) {
|
|
21
|
+
const slug = account
|
|
22
|
+
.trim()
|
|
23
|
+
.toUpperCase()
|
|
24
|
+
.replace(/@/g, '_AT_')
|
|
25
|
+
.replace(/\./g, '_DOT_')
|
|
26
|
+
.replace(/[^A-Z0-9_]/g, '_');
|
|
27
|
+
return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read the signed-in account email for a Claude version home from
|
|
31
|
+
* `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
|
|
32
|
+
* the canonical resolver uses (agents.ts getAccountInfo): the shim-set
|
|
33
|
+
* `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
|
|
34
|
+
* home-level `<home>/.claude.json` (an account signed in via the IDE / direct
|
|
35
|
+
* binary without the shim writes there). Returns null when neither has a usable
|
|
36
|
+
* email.
|
|
37
|
+
*/
|
|
38
|
+
export function readClaudeAccountEmail(home) {
|
|
39
|
+
for (const p of [path.join(home, '.claude', '.claude.json'), path.join(home, '.claude.json')]) {
|
|
40
|
+
try {
|
|
41
|
+
const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
|
|
42
|
+
if (typeof email === 'string' && email.trim().length > 0)
|
|
43
|
+
return email.trim();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Missing/unreadable/malformed at this location — try the next.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the per-account setup-token for the account pinned to `home`, looking
|
|
53
|
+
* it up in the provided env (the daemon injects the `claude` bundle's
|
|
54
|
+
* `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
|
|
55
|
+
* account, or no matching per-account token is present — callers then leave the
|
|
56
|
+
* existing ambient/interactive credential untouched (a safe no-op).
|
|
57
|
+
*/
|
|
58
|
+
export function resolveAccountSetupToken(env, home) {
|
|
59
|
+
const email = readClaudeAccountEmail(home);
|
|
60
|
+
if (!email)
|
|
61
|
+
return null;
|
|
62
|
+
const token = env[accountTokenKey(email)];
|
|
63
|
+
return typeof token === 'string' && token.trim().length > 0 ? token.trim() : null;
|
|
64
|
+
}
|
|
@@ -69,12 +69,14 @@ export interface StoredBundle {
|
|
|
69
69
|
env: Record<string, string>;
|
|
70
70
|
/** epoch ms; the entry is gone once Date.now() passes this. */
|
|
71
71
|
expiresAt: number;
|
|
72
|
+
harness: string;
|
|
72
73
|
}
|
|
73
74
|
/** One unlocked bundle as reported by `status`. */
|
|
74
75
|
export interface AgentStatusEntry {
|
|
75
76
|
name: string;
|
|
76
77
|
expiresAt: number;
|
|
77
78
|
keyCount: number;
|
|
79
|
+
harness: string;
|
|
78
80
|
}
|
|
79
81
|
/**
|
|
80
82
|
* Read the current broker capability token, or null if none is present. Clients
|
|
@@ -106,10 +108,12 @@ export type Request = {
|
|
|
106
108
|
} | {
|
|
107
109
|
cmd: 'get';
|
|
108
110
|
name: string;
|
|
111
|
+
harness?: string;
|
|
109
112
|
token?: string;
|
|
110
113
|
} | {
|
|
111
114
|
cmd: 'load';
|
|
112
115
|
name: string;
|
|
116
|
+
harness?: string;
|
|
113
117
|
bundle: SecretsBundle;
|
|
114
118
|
env: Record<string, string>;
|
|
115
119
|
ttlMs: number;
|
|
@@ -166,6 +170,7 @@ export type Response = {
|
|
|
166
170
|
* exiting. Pure + exported for unit testing.
|
|
167
171
|
*/
|
|
168
172
|
export declare function realBundleCount(store: Map<string, StoredBundle>): number;
|
|
173
|
+
export declare function scopedBundleKey(name: string, harness: string): string;
|
|
169
174
|
export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
|
|
170
175
|
/**
|
|
171
176
|
* Decide whether a `watch-lock` helper line should wipe the in-memory store.
|
|
@@ -270,7 +275,7 @@ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
|
|
|
270
275
|
* null if the agent isn't running / doesn't hold this bundle / anything fails
|
|
271
276
|
* (soft — caller falls through to the real keychain). macOS only.
|
|
272
277
|
*/
|
|
273
|
-
export declare function agentGetSync(name: string): {
|
|
278
|
+
export declare function agentGetSync(name: string, harness?: string): {
|
|
274
279
|
bundle: SecretsBundle;
|
|
275
280
|
env: Record<string, string>;
|
|
276
281
|
} | null;
|
|
@@ -308,7 +313,7 @@ export declare function agentReachableSync(): boolean;
|
|
|
308
313
|
export declare function agentEvictSync(name: string): void;
|
|
309
314
|
/** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
|
|
310
315
|
* cache hit. Exit 0 = hit, 3 = miss or broker down. */
|
|
311
|
-
export declare function runAgentGetSync(name: string): Promise<number>;
|
|
316
|
+
export declare function runAgentGetSync(name: string, harness?: string): Promise<number>;
|
|
312
317
|
/** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
|
|
313
318
|
* our protocol, 3 = nothing there. Deliberately does NOT gate on
|
|
314
319
|
* PROTOCOL_VERSION: this only decides whether the auto-cache may take the
|
|
@@ -387,7 +392,7 @@ export declare function clampHoldMs(v: unknown): number;
|
|
|
387
392
|
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
388
393
|
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
389
394
|
*/
|
|
390
|
-
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
|
|
395
|
+
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
|
|
391
396
|
/**
|
|
392
397
|
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
393
398
|
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
@@ -402,7 +407,7 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
|
|
|
402
407
|
*/
|
|
403
408
|
export declare function runAgentLoadFromStdin(): Promise<void>;
|
|
404
409
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
405
|
-
export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
|
|
410
|
+
export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
|
|
406
411
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
407
412
|
* wiped, or 0 when no broker is running. */
|
|
408
413
|
export declare function agentLock(name?: string): Promise<number>;
|
|
@@ -38,7 +38,7 @@ import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
|
|
|
38
38
|
import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
|
|
39
39
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
40
40
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
41
|
-
const PROTOCOL_VERSION =
|
|
41
|
+
const PROTOCOL_VERSION = 2;
|
|
42
42
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
43
43
|
export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
|
44
44
|
/**
|
|
@@ -133,7 +133,8 @@ function onDarwin() {
|
|
|
133
133
|
function rehydrateStore(now = Date.now()) {
|
|
134
134
|
const store = new Map();
|
|
135
135
|
for (const { name, entry } of rehydrateSessions(now)) {
|
|
136
|
-
|
|
136
|
+
const harness = entry.harness || 'cli';
|
|
137
|
+
store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
|
|
137
138
|
}
|
|
138
139
|
return store;
|
|
139
140
|
}
|
|
@@ -291,11 +292,14 @@ export async function uninstallSecretsAgentService() {
|
|
|
291
292
|
*/
|
|
292
293
|
export function realBundleCount(store) {
|
|
293
294
|
let n = 0;
|
|
294
|
-
for (const
|
|
295
|
-
if (!name.startsWith(META_CACHE_PREFIX))
|
|
295
|
+
for (const e of store.values())
|
|
296
|
+
if (!e.bundle.name.startsWith(META_CACHE_PREFIX))
|
|
296
297
|
n++;
|
|
297
298
|
return n;
|
|
298
299
|
}
|
|
300
|
+
export function scopedBundleKey(name, harness) {
|
|
301
|
+
return `${harness}:${name}`;
|
|
302
|
+
}
|
|
299
303
|
export function handleAgentRequest(store, req, now = Date.now()) {
|
|
300
304
|
switch (req.cmd) {
|
|
301
305
|
case 'ping':
|
|
@@ -305,20 +309,26 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
305
309
|
// the broker is running pre-upgrade code and should be restarted.
|
|
306
310
|
return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
|
|
307
311
|
case 'get': {
|
|
308
|
-
const
|
|
312
|
+
const key = scopedBundleKey(req.name, req.harness || 'cli');
|
|
313
|
+
const e = store.get(key);
|
|
309
314
|
if (!e || now >= e.expiresAt) {
|
|
310
315
|
if (e)
|
|
311
|
-
store.delete(
|
|
316
|
+
store.delete(key); // drop expired on read
|
|
312
317
|
return { ok: true, cmd: 'get', hit: false };
|
|
313
318
|
}
|
|
314
319
|
return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
|
|
315
320
|
}
|
|
316
321
|
case 'load':
|
|
317
|
-
|
|
322
|
+
const harness = req.harness || 'cli';
|
|
323
|
+
store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
|
|
318
324
|
return { ok: true, cmd: 'load' };
|
|
319
325
|
case 'lock': {
|
|
320
326
|
if (req.name) {
|
|
321
|
-
|
|
327
|
+
let wiped = 0;
|
|
328
|
+
for (const [key, entry] of store)
|
|
329
|
+
if (entry.bundle.name === req.name && store.delete(key))
|
|
330
|
+
wiped++;
|
|
331
|
+
return { ok: true, cmd: 'lock', wiped };
|
|
322
332
|
}
|
|
323
333
|
const wiped = store.size;
|
|
324
334
|
store.clear();
|
|
@@ -329,9 +339,9 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
329
339
|
for (const [name, e] of store) {
|
|
330
340
|
if (now >= e.expiresAt)
|
|
331
341
|
continue;
|
|
332
|
-
if (name.startsWith(META_CACHE_PREFIX))
|
|
333
|
-
continue; // internal list cache
|
|
334
|
-
entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
|
|
342
|
+
if (e.bundle.name.startsWith(META_CACHE_PREFIX))
|
|
343
|
+
continue; // internal list cache
|
|
344
|
+
entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
|
|
335
345
|
}
|
|
336
346
|
return { ok: true, cmd: 'status', entries };
|
|
337
347
|
}
|
|
@@ -824,10 +834,10 @@ function syncClient(sub, timeout) {
|
|
|
824
834
|
* null if the agent isn't running / doesn't hold this bundle / anything fails
|
|
825
835
|
* (soft — caller falls through to the real keychain). macOS only.
|
|
826
836
|
*/
|
|
827
|
-
export function agentGetSync(name) {
|
|
837
|
+
export function agentGetSync(name, harness = 'cli') {
|
|
828
838
|
if (!agentSocketExists())
|
|
829
839
|
return null;
|
|
830
|
-
const r = syncClient([SYNC_GET_CMD, name], SYNC_GET_TIMEOUT_MS);
|
|
840
|
+
const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
|
|
831
841
|
if (!r || r.status !== 0 || !r.stdout)
|
|
832
842
|
return null;
|
|
833
843
|
try {
|
|
@@ -904,8 +914,8 @@ export function agentEvictSync(name) {
|
|
|
904
914
|
// so a cache hit never runs checkForUpdates() or forks a detached sync.
|
|
905
915
|
/** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
|
|
906
916
|
* cache hit. Exit 0 = hit, 3 = miss or broker down. */
|
|
907
|
-
export async function runAgentGetSync(name) {
|
|
908
|
-
const r = await request({ cmd: 'get', name }, SOCKET_GET_TIMEOUT_MS);
|
|
917
|
+
export async function runAgentGetSync(name, harness = 'cli') {
|
|
918
|
+
const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
|
|
909
919
|
if (r?.ok === true && r.cmd === 'get' && r.hit) {
|
|
910
920
|
// Trailing newline: the parent reads the LAST line (see lastLine), so the
|
|
911
921
|
// payload must terminate the stream even if anything ever precedes it.
|
|
@@ -1056,10 +1066,10 @@ export function clampHoldMs(v) {
|
|
|
1056
1066
|
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
1057
1067
|
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
1058
1068
|
*/
|
|
1059
|
-
export function agentAutoLoadSync(name, bundle, env, ttlMs) {
|
|
1069
|
+
export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = 'cli') {
|
|
1060
1070
|
if (!onDarwin())
|
|
1061
1071
|
return;
|
|
1062
|
-
const payload = JSON.stringify({ name, bundle, env, ttlMs });
|
|
1072
|
+
const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
|
|
1063
1073
|
// Broker actually LISTENING → deterministic synchronous warm (bounded; the read
|
|
1064
1074
|
// already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
|
|
1065
1075
|
// ping, NOT mere socket-file existence: a broker that died leaving its socket
|
|
@@ -1126,13 +1136,13 @@ export async function runAgentLoadFromStdin() {
|
|
|
1126
1136
|
process.exitCode = 1; // broker couldn't be brought up — did NOT load
|
|
1127
1137
|
return;
|
|
1128
1138
|
}
|
|
1129
|
-
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
|
|
1139
|
+
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? 'cli');
|
|
1130
1140
|
if (!loaded)
|
|
1131
1141
|
process.exitCode = 1; // transport failed — did NOT load
|
|
1132
1142
|
}
|
|
1133
1143
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
1134
|
-
export async function agentLoad(name, bundle, env, ttlMs) {
|
|
1135
|
-
const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
|
|
1144
|
+
export async function agentLoad(name, bundle, env, ttlMs, harness = 'cli') {
|
|
1145
|
+
const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
|
|
1136
1146
|
return r?.ok === true && r.cmd === 'load';
|
|
1137
1147
|
}
|
|
1138
1148
|
/** Wipe one bundle (or all if name omitted) from the broker. Returns the count
|
|
@@ -198,6 +198,12 @@ export interface ResolveBundleOptions {
|
|
|
198
198
|
* about to read the bundle.
|
|
199
199
|
*/
|
|
200
200
|
caller?: string;
|
|
201
|
+
/** Harness type whose unlock may be reused (claude, codex, kimi, ...). */
|
|
202
|
+
agent?: string;
|
|
203
|
+
/** Human duration rendered in the Touch ID prompt. */
|
|
204
|
+
duration?: string;
|
|
205
|
+
/** Explicitly permit this agent request to raise interactive authentication. */
|
|
206
|
+
interactiveUnlock?: boolean;
|
|
201
207
|
/**
|
|
202
208
|
* Skip the secrets-agent fast-path and read straight from the keychain
|
|
203
209
|
* (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
|
|
@@ -272,15 +278,19 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
|
|
|
272
278
|
* True when the current process is a background / non-interactive context that
|
|
273
279
|
* must NEVER raise a Keychain biometry prompt on the interactive user's screen —
|
|
274
280
|
* a prompt nobody is watching. Two signals, either sufficient:
|
|
275
|
-
* - `AGENTS_RUNTIME` is `headless` or `
|
|
276
|
-
*
|
|
277
|
-
*
|
|
281
|
+
* - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
|
|
282
|
+
* launch, interactive included, and inherited by everything spawned beneath
|
|
283
|
+
* one (set on the child env by `agents run --headless`, scheduled routines,
|
|
284
|
+
* teammates, and interactive runs — see exec.ts:430, runner.ts,
|
|
285
|
+
* teams/agents.ts).
|
|
278
286
|
* - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
|
|
279
287
|
* stdio is redirected to a log — e.g. a release script run in the
|
|
280
288
|
* background as `( ... ) >log 2>&1 </dev/null`).
|
|
281
289
|
* `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
|
|
282
|
-
* even in a non-TTY context. An
|
|
283
|
-
*
|
|
290
|
+
* even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
|
|
291
|
+
* PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
|
|
292
|
+
* prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
|
|
293
|
+
* broker-only — the agent, not the human, is the caller there.
|
|
284
294
|
*
|
|
285
295
|
* Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
|
|
286
296
|
* broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
|
|
@@ -293,7 +303,10 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
|
|
|
293
303
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
294
304
|
* the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
|
|
295
305
|
*/
|
|
296
|
-
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform
|
|
306
|
+
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
|
|
307
|
+
stdin?: boolean;
|
|
308
|
+
stdout?: boolean;
|
|
309
|
+
}): boolean;
|
|
297
310
|
/**
|
|
298
311
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
299
312
|
*
|
|
@@ -311,6 +324,7 @@ export declare function readAndResolveBundleEnv(name: string, opts?: ResolveBund
|
|
|
311
324
|
bundle: SecretsBundle;
|
|
312
325
|
env: Record<string, string>;
|
|
313
326
|
};
|
|
327
|
+
export declare function humanUnlockDuration(ms: number): string;
|
|
314
328
|
export declare function keychainRef(key: string): string;
|
|
315
329
|
/** Options for rotateBundleSecret. */
|
|
316
330
|
export interface RotateOptions {
|