@phnx-labs/agents-cli 1.20.74 → 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 +1055 -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 +83 -13
- 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 +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- 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.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- 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 +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- 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.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- 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 +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- 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/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- 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.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -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 +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- 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 +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- 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 +6 -2
- package/dist/lib/overdue.js +10 -36
- 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/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 +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- 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 +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- 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/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -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 +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- 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 +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -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.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- 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 +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -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
package/dist/lib/routines.js
CHANGED
|
@@ -15,6 +15,7 @@ import { safeJoin, isSafeSegmentName } from './paths.js';
|
|
|
15
15
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
16
16
|
import { ALL_AGENT_IDS } from './agents.js';
|
|
17
17
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
18
|
+
export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
|
|
18
19
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
19
20
|
export const GITHUB_TRIGGER_EVENTS = [
|
|
20
21
|
'pull_request',
|
|
@@ -84,6 +85,39 @@ export function jobRunsOnThisDevice(config) {
|
|
|
84
85
|
const self = machineId();
|
|
85
86
|
return config.devices.some((d) => normalizeHost(d) === self);
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the effective host strategy for a job.
|
|
90
|
+
* Bare `host:` without an explicit strategy implies `host` (back-compat with
|
|
91
|
+
* pre-hostStrategy YAML). Otherwise default to `local`.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveHostStrategy(config) {
|
|
94
|
+
if (config.hostStrategy)
|
|
95
|
+
return config.hostStrategy;
|
|
96
|
+
if (config.host)
|
|
97
|
+
return 'host';
|
|
98
|
+
return 'local';
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse a CLI `--placement` value into a HostStrategy, or null when empty.
|
|
102
|
+
* Throws a human-readable Error for unknown values.
|
|
103
|
+
*/
|
|
104
|
+
export function parseHostStrategy(raw) {
|
|
105
|
+
if (raw === undefined || raw === null || raw === '')
|
|
106
|
+
return null;
|
|
107
|
+
const v = raw.trim().toLowerCase();
|
|
108
|
+
if (HOST_STRATEGIES.includes(v))
|
|
109
|
+
return v;
|
|
110
|
+
throw new Error(`Invalid placement '${raw}'. Use one of: ${HOST_STRATEGIES.join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Strategies that dispatch the job body off the firing machine. Without a
|
|
114
|
+
* `devices` pin every daemon in the fleet would fire and each would dispatch
|
|
115
|
+
* once — N× duplicate runs. Callers pin to this machine when the user did not
|
|
116
|
+
* set an explicit allowlist.
|
|
117
|
+
*/
|
|
118
|
+
export function placementRequiresFiringPin(strategy) {
|
|
119
|
+
return strategy === 'host' || strategy === 'fleet' || strategy === 'cloud';
|
|
120
|
+
}
|
|
87
121
|
/**
|
|
88
122
|
* Return null when the job may run here; otherwise return a structured,
|
|
89
123
|
* human-friendly eligibility failure. Centralizes the message/suggestion
|
|
@@ -419,24 +453,48 @@ export function validateJob(config) {
|
|
|
419
453
|
if (config.device !== undefined) {
|
|
420
454
|
errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
|
|
421
455
|
}
|
|
456
|
+
if (config.hostStrategy !== undefined) {
|
|
457
|
+
if (!HOST_STRATEGIES.includes(config.hostStrategy)) {
|
|
458
|
+
errors.push(`hostStrategy must be one of: ${HOST_STRATEGIES.join(', ')}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const strategy = resolveHostStrategy(config);
|
|
422
462
|
if (config.host !== undefined) {
|
|
423
463
|
if (typeof config.host !== 'string' || config.host.trim() === '') {
|
|
424
464
|
errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
|
|
425
465
|
}
|
|
426
|
-
|
|
427
|
-
|
|
466
|
+
}
|
|
467
|
+
if (strategy === 'host' && (!config.host || config.host.trim() === '')) {
|
|
468
|
+
errors.push("hostStrategy: host requires host: (set via --run-on or host: in YAML)");
|
|
469
|
+
}
|
|
470
|
+
// Remote placement (host/fleet/cloud) can't carry workflow/loop/command yet —
|
|
471
|
+
// those live on the firing machine.
|
|
472
|
+
if (strategy === 'host' || strategy === 'fleet' || strategy === 'cloud') {
|
|
428
473
|
if (config.workflow) {
|
|
429
|
-
errors.push(
|
|
474
|
+
errors.push(`${strategy} placement can't be combined with workflow: yet — run the workflow locally or convert it to a plain prompt`);
|
|
430
475
|
}
|
|
431
476
|
if (config.loop) {
|
|
432
|
-
errors.push(
|
|
477
|
+
errors.push(`${strategy} placement can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)`);
|
|
433
478
|
}
|
|
434
479
|
if (config.command) {
|
|
435
|
-
errors.push(
|
|
480
|
+
errors.push(`${strategy} placement can't be combined with command: yet (a plain shell command has no agent to place remotely)`);
|
|
436
481
|
}
|
|
437
482
|
}
|
|
438
|
-
if (config.remoteCwd !== undefined &&
|
|
439
|
-
errors.push('remoteCwd only applies to host
|
|
483
|
+
if (config.remoteCwd !== undefined && strategy !== 'host' && strategy !== 'fleet') {
|
|
484
|
+
errors.push('remoteCwd only applies to host/fleet-placed routines — set hostStrategy: host|fleet, or drop it');
|
|
485
|
+
}
|
|
486
|
+
if (config.source !== undefined) {
|
|
487
|
+
if (!config.source || typeof config.source !== 'object') {
|
|
488
|
+
errors.push('source must be an object');
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
if (config.source.kind !== 'project') {
|
|
492
|
+
errors.push("source.kind must be 'project'");
|
|
493
|
+
}
|
|
494
|
+
if (typeof config.source.projectPath !== 'string' || config.source.projectPath.trim() === '') {
|
|
495
|
+
errors.push('source.projectPath must be a non-empty absolute path');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
440
498
|
}
|
|
441
499
|
if (config.devices !== undefined) {
|
|
442
500
|
if (!Array.isArray(config.devices)) {
|
|
@@ -451,6 +509,15 @@ export function validateJob(config) {
|
|
|
451
509
|
}
|
|
452
510
|
}
|
|
453
511
|
}
|
|
512
|
+
// Off-box placement without a devices pin fires on every fleet daemon and
|
|
513
|
+
// each dispatches once (RUSH-1980). Enforce the pin at validation so hand
|
|
514
|
+
// edits and devices --clear cannot re-open the hole.
|
|
515
|
+
if (placementRequiresFiringPin(strategy)) {
|
|
516
|
+
if (!config.devices || config.devices.length === 0) {
|
|
517
|
+
errors.push(`hostStrategy: ${strategy} requires devices: [<name>] to pin which daemon may fire ` +
|
|
518
|
+
'(otherwise every fleet daemon dispatches once). Pin to one machine, e.g. devices: [this-host]');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
454
521
|
return errors;
|
|
455
522
|
}
|
|
456
523
|
/** Validate a job trigger block, returning a list of human-readable errors. */
|
|
@@ -536,8 +603,11 @@ export function resolveJobPrompt(config) {
|
|
|
536
603
|
prompt = prompt.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
|
537
604
|
}
|
|
538
605
|
}
|
|
539
|
-
// Last report (special handling)
|
|
540
|
-
|
|
606
|
+
// Last report (special handling). Only a COMPLETED run's report is injected —
|
|
607
|
+
// a failed run's report.md is the agent's error text (e.g. a login prompt on
|
|
608
|
+
// an auth failure), and feeding that into the next prompt poisons every
|
|
609
|
+
// subsequent run until a human intervenes.
|
|
610
|
+
const latestRun = getLatestCompletedRun(config.name);
|
|
541
611
|
if (latestRun) {
|
|
542
612
|
const reportPath = path.join(getJobRunsDir(config.name), latestRun.runId, 'report.md');
|
|
543
613
|
if (fs.existsSync(reportPath)) {
|
|
@@ -595,6 +665,20 @@ export function getLatestRun(jobName) {
|
|
|
595
665
|
const runs = listRuns(jobName);
|
|
596
666
|
return runs.length > 0 ? runs[runs.length - 1] : null;
|
|
597
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* Get the most recent COMPLETED run for a job, or null if none has completed.
|
|
670
|
+
* Used to resolve `{last_report}` so a failed run's error text (e.g. an auth
|
|
671
|
+
* login prompt written into report.md) can never be injected into the next
|
|
672
|
+
* run's prompt.
|
|
673
|
+
*/
|
|
674
|
+
export function getLatestCompletedRun(jobName) {
|
|
675
|
+
const runs = listRuns(jobName);
|
|
676
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
677
|
+
if (runs[i].status === 'completed')
|
|
678
|
+
return runs[i];
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
598
682
|
/** Persist run metadata to its run directory as meta.json. */
|
|
599
683
|
export function writeRunMeta(meta) {
|
|
600
684
|
ensureAgentsDir();
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -77,9 +77,19 @@ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, ag
|
|
|
77
77
|
* failover across healthy same-agent accounts (RUSH-1016).
|
|
78
78
|
*/
|
|
79
79
|
export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
|
|
80
|
+
/**
|
|
81
|
+
* Optional lifecycle callbacks for a detached routine run. The daemon passes an
|
|
82
|
+
* `onFinish` that fires the branded finish/output notification (RUSH-2030) — it
|
|
83
|
+
* runs from the in-process settle() when the child exits, the only seam that
|
|
84
|
+
* observes the live running→terminal transition (the monitor tick would already
|
|
85
|
+
* see a finalized record and skip it). Never let a hook throw into finalization.
|
|
86
|
+
*/
|
|
87
|
+
export interface RoutineHooks {
|
|
88
|
+
/** Called once with the finalized meta when the run reaches a terminal state. */
|
|
89
|
+
onFinish?: (meta: RunMeta) => void;
|
|
90
|
+
}
|
|
80
91
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
81
|
-
|
|
82
|
-
export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
|
|
92
|
+
export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
|
|
83
93
|
/** Extract the final assistant message from a stream-JSON log file as a markdown report. */
|
|
84
94
|
export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
|
|
85
95
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|
package/dist/lib/runner.js
CHANGED
|
@@ -22,13 +22,17 @@ import { getRunsDir } from './state.js';
|
|
|
22
22
|
import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
|
|
23
23
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
24
24
|
import { createTimer, maybeRotate, redactPrompt } from './events.js';
|
|
25
|
-
import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, } from './exec.js';
|
|
25
|
+
import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
|
|
26
26
|
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, isVersionInstalled, resolveVersion } from './versions.js';
|
|
31
|
-
import {
|
|
30
|
+
import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
31
|
+
import { claudeHomeHasOwnCredential } from './agents.js';
|
|
32
|
+
import { resolveAccountSetupToken } from './secrets/account-token.js';
|
|
33
|
+
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
34
|
+
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
35
|
+
import { machineId } from './machine-id.js';
|
|
32
36
|
/** CLI command templates per agent, with {prompt} as a placeholder. */
|
|
33
37
|
const AGENT_COMMANDS = {
|
|
34
38
|
claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
|
|
@@ -287,6 +291,19 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
|
|
|
287
291
|
pinned: true,
|
|
288
292
|
};
|
|
289
293
|
}
|
|
294
|
+
// Account pin: resolve the login identity to the version slot that holds it and
|
|
295
|
+
// run pinned — no rotation, no failover onto other accounts — so concurrent
|
|
296
|
+
// unattended routines never share (and mutually revoke) one single-use OAuth
|
|
297
|
+
// credential (RUSH-1957). Falls through to the strategy only when the account
|
|
298
|
+
// is not signed in on this box, with a loud warning rather than a silent stall.
|
|
299
|
+
if (config.account) {
|
|
300
|
+
const version = await resolveAccountVersion(agent, config.account);
|
|
301
|
+
if (version) {
|
|
302
|
+
return { chain: [{ agent, version }], rotation: null, pinned: true };
|
|
303
|
+
}
|
|
304
|
+
process.stderr.write(`[agents] routine ${config.name}: account '${config.account}' is not signed in for ${agent}; ` +
|
|
305
|
+
`falling back to strategy — pin a signed-in account to stop OAuth-rotation revocation\n`);
|
|
306
|
+
}
|
|
290
307
|
const strategy = getConfiguredRunStrategy(agent, cwd);
|
|
291
308
|
let version;
|
|
292
309
|
let rotation = null;
|
|
@@ -383,6 +400,29 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
|
|
|
383
400
|
if (v !== undefined)
|
|
384
401
|
out[k] = v;
|
|
385
402
|
}
|
|
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
|
+
}
|
|
386
426
|
if (timezone)
|
|
387
427
|
out.TZ = timezone;
|
|
388
428
|
return out;
|
|
@@ -483,11 +523,19 @@ export async function executeJob(config, deps) {
|
|
|
483
523
|
if (eligibility) {
|
|
484
524
|
throw new Error(eligibility.message);
|
|
485
525
|
}
|
|
486
|
-
//
|
|
487
|
-
// version selection / sandbox / spawn
|
|
488
|
-
// `routines run`, catchup) follow the
|
|
489
|
-
|
|
490
|
-
|
|
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
|
+
}
|
|
491
539
|
}
|
|
492
540
|
// Command-mode: run a plain shell command directly (no agent, no rotation,
|
|
493
541
|
// no pinning, no sandbox overlay). Reuses the run-record machinery so
|
|
@@ -536,6 +584,25 @@ export async function executeJob(config, deps) {
|
|
|
536
584
|
exitCode: null,
|
|
537
585
|
};
|
|
538
586
|
writeRunMeta(meta);
|
|
587
|
+
// Auth preflight: if the last live probe rejected this (agent, version)'s
|
|
588
|
+
// token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
|
|
589
|
+
// before spawning instead of producing a doomed run + poisoned report. Cache-
|
|
590
|
+
// only (the daemon refreshes it periodically); fail OPEN on any non-revoked or
|
|
591
|
+
// missing verdict so a stale/absent probe or a network blip never blocks a
|
|
592
|
+
// run, and agents with no live probe (codex/gemini/grok) are never blocked.
|
|
593
|
+
const preflightVersion = launch.chain[0]?.version;
|
|
594
|
+
if (preflightVersion) {
|
|
595
|
+
const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
|
|
596
|
+
if (health && isDeadVerdict(health.verdict)) {
|
|
597
|
+
const reason = `auth_preflight: ${health.verdict}`;
|
|
598
|
+
process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
|
|
599
|
+
finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
|
|
600
|
+
writeRunMeta(meta);
|
|
601
|
+
timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
|
|
602
|
+
archiveRoutineTranscripts(meta, runDir, overlayHome);
|
|
603
|
+
return { meta, reportPath: null };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
539
606
|
const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
|
|
540
607
|
// Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
|
|
541
608
|
if (config.loop) {
|
|
@@ -616,6 +683,21 @@ export async function executeJob(config, deps) {
|
|
|
616
683
|
return { meta, reportPath };
|
|
617
684
|
}
|
|
618
685
|
if (attempt.status === 'completed') {
|
|
686
|
+
// Exit code alone is unreliable for auth: a logged-out Claude can exit 0
|
|
687
|
+
// with a `result` event carrying is_error:true (terminal_reason
|
|
688
|
+
// "completed"). Consult the SAME structural signal the detached path uses
|
|
689
|
+
// so both paths agree — raw text is deliberately NOT used here, so a
|
|
690
|
+
// genuinely-completed run that merely mentions an auth phrase stays a
|
|
691
|
+
// success (processFailed:false).
|
|
692
|
+
if (isAuthFailureFromLog(attempt.logText, effectiveAgent, { processFailed: false })) {
|
|
693
|
+
const reason = authFailureReason(attempt.logText) ?? 'authentication_failed';
|
|
694
|
+
finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, { errorMessage: `auth_failed: ${reason}` });
|
|
695
|
+
writeRunMeta(meta);
|
|
696
|
+
timer.end({ status: 'failed', exitCode: meta.exitCode ?? undefined, runId, error: `auth_failed: ${reason}` });
|
|
697
|
+
// Never persist the login-error text as the report.
|
|
698
|
+
archiveRoutineTranscripts(meta, runDir, overlayHome);
|
|
699
|
+
return { meta, reportPath: null };
|
|
700
|
+
}
|
|
619
701
|
finalizeRunMeta(meta, 'completed', 0);
|
|
620
702
|
writeRunMeta(meta);
|
|
621
703
|
timer.end({ status: 'completed', exitCode: 0, runId });
|
|
@@ -633,18 +715,36 @@ export async function executeJob(config, deps) {
|
|
|
633
715
|
fs.appendFileSync(stdoutPath, `\n[agents] ${label} hit rate/usage limit — failover → ${nextLabel}\n`);
|
|
634
716
|
continue;
|
|
635
717
|
}
|
|
718
|
+
// Auth failure — the agent is logged out / token revoked. Unlike a rate
|
|
719
|
+
// limit it is not self-healing by failover (every chain entry on the same
|
|
720
|
+
// account fails identically), so rate-limit is classified first (above) and
|
|
721
|
+
// auth only when NOT rate-limited. Classified so the failure is visible and,
|
|
722
|
+
// critically, so the login-error text is never persisted as the report.
|
|
723
|
+
const authFailed = !rateLimited && (isAuthFailureFromLog(attempt.logText, effectiveAgent, { processFailed: true }) ||
|
|
724
|
+
(attempt.error ? detectAuthFailure(attempt.error) : false));
|
|
636
725
|
if (attempt.error) {
|
|
637
726
|
process.stderr.write(`[agents] routine ${config.name}: spawn failed for ${label}: ${attempt.error}\n`);
|
|
638
727
|
}
|
|
639
|
-
|
|
728
|
+
const authReason = authFailed
|
|
729
|
+
? (authFailureReason(attempt.logText)
|
|
730
|
+
?? (attempt.error ? authFailureReason(attempt.error) : null)
|
|
731
|
+
?? 'authentication_failed')
|
|
732
|
+
: null;
|
|
733
|
+
const failureErrorMessage = authReason
|
|
734
|
+
? `auth_failed: ${authReason}`
|
|
735
|
+
: (attempt.error ?? undefined);
|
|
736
|
+
finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, failureErrorMessage ? { errorMessage: failureErrorMessage } : undefined);
|
|
640
737
|
writeRunMeta(meta);
|
|
641
738
|
timer.end({
|
|
642
739
|
status: 'failed',
|
|
643
740
|
exitCode: meta.exitCode ?? undefined,
|
|
644
741
|
runId,
|
|
645
|
-
...(
|
|
742
|
+
...(failureErrorMessage ? { error: failureErrorMessage } : {}),
|
|
646
743
|
});
|
|
647
|
-
|
|
744
|
+
// On auth failure the last assistant text IS the login error — never persist
|
|
745
|
+
// it as report.md; it would otherwise be injected into the next run's prompt
|
|
746
|
+
// via {last_report}.
|
|
747
|
+
const reportPath = authFailed ? null : extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
|
|
648
748
|
archiveRoutineTranscripts(meta, runDir, overlayHome);
|
|
649
749
|
return { meta, reportPath };
|
|
650
750
|
}
|
|
@@ -654,6 +754,86 @@ export async function executeJob(config, deps) {
|
|
|
654
754
|
timer.end({ status: 'failed', exitCode: 1, runId });
|
|
655
755
|
return { meta, reportPath: null };
|
|
656
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
|
+
}
|
|
657
837
|
async function executeJobOnHost(config, opts) {
|
|
658
838
|
if (config.workflow) {
|
|
659
839
|
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
|
|
@@ -796,24 +976,44 @@ async function executeCommandJobForeground(config) {
|
|
|
796
976
|
});
|
|
797
977
|
return { meta, reportPath: null };
|
|
798
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
|
+
}
|
|
799
988
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
800
|
-
|
|
801
|
-
export async function executeJobDetached(config) {
|
|
989
|
+
export async function executeJobDetached(config, hooks) {
|
|
802
990
|
const eligibility = checkJobDeviceEligibility(config);
|
|
803
991
|
if (eligibility) {
|
|
804
992
|
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
805
993
|
throw new Error(eligibility.message);
|
|
806
994
|
}
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
+
}
|
|
811
1011
|
}
|
|
812
1012
|
// Command-mode: fire a plain shell command detached (no agent, no rotation,
|
|
813
1013
|
// no pinning, no sandbox overlay). Still writes a run record so the daemon,
|
|
814
1014
|
// list/runs, and overdue tracking keep working.
|
|
815
1015
|
if (config.command) {
|
|
816
|
-
return executeCommandJobDetached(config);
|
|
1016
|
+
return executeCommandJobDetached(config, hooks);
|
|
817
1017
|
}
|
|
818
1018
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
819
1019
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
@@ -876,10 +1076,29 @@ export async function executeJobDetached(config) {
|
|
|
876
1076
|
finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
|
|
877
1077
|
writeRunMeta(meta);
|
|
878
1078
|
archiveRoutineTranscripts(meta, runDir, overlayHome);
|
|
879
|
-
|
|
1079
|
+
// Never persist the report on an auth failure — the last assistant text is
|
|
1080
|
+
// the login error, which would poison the next run's {last_report} prompt.
|
|
1081
|
+
const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
|
|
1082
|
+
if (status !== 'timeout' && !isAuthFailure)
|
|
880
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);
|
|
881
1087
|
};
|
|
882
1088
|
child.on('exit', (code) => {
|
|
1089
|
+
let logText = '';
|
|
1090
|
+
try {
|
|
1091
|
+
logText = fs.readFileSync(stdoutPath, 'utf-8');
|
|
1092
|
+
}
|
|
1093
|
+
catch { /* log unreadable */ }
|
|
1094
|
+
// processFailed gates the raw-text fallback so a COMPLETED run (exit 0) that
|
|
1095
|
+
// merely mentions an auth phrase is never misclassified; the structural
|
|
1096
|
+
// marker still catches an exit-0 auth failure on its own.
|
|
1097
|
+
if (isAuthFailureFromLog(logText, effectiveAgent, { processFailed: (code ?? 1) !== 0 })) {
|
|
1098
|
+
const reason = authFailureReason(logText) ?? 'authentication_failed';
|
|
1099
|
+
settle('failed', code ?? 1, `auth_failed: ${reason}`);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
883
1102
|
const inferred = inferFinalStatusFromLog(stdoutPath, effectiveAgent);
|
|
884
1103
|
if (inferred) {
|
|
885
1104
|
settle(inferred.status, inferred.exitCode);
|
|
@@ -911,7 +1130,7 @@ export async function executeJobDetached(config) {
|
|
|
911
1130
|
* un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
|
|
912
1131
|
* `monitorRunningJobs` reaps the record on the next tick.
|
|
913
1132
|
*/
|
|
914
|
-
function executeCommandJobDetached(config) {
|
|
1133
|
+
function executeCommandJobDetached(config, hooks) {
|
|
915
1134
|
const runId = generateRunId();
|
|
916
1135
|
const runDir = getRunDir(config.name, runId);
|
|
917
1136
|
fs.mkdirSync(runDir, { recursive: true });
|
|
@@ -956,6 +1175,9 @@ function executeCommandJobDetached(config) {
|
|
|
956
1175
|
settled = true;
|
|
957
1176
|
finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
|
|
958
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);
|
|
959
1181
|
};
|
|
960
1182
|
child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
|
|
961
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
|
+
}
|