@phnx-labs/agents-cli 1.20.87 → 1.20.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. package/package.json +1 -1
@@ -569,6 +569,12 @@ export function validateTrigger(trigger) {
569
569
  if (linear.label !== undefined && typeof linear.label !== 'string') {
570
570
  errors.push('trigger.label must be a string');
571
571
  }
572
+ if (linear.stateTo !== undefined && typeof linear.stateTo !== 'string') {
573
+ errors.push('trigger.stateTo must be a string');
574
+ }
575
+ if (linear.stateFrom !== undefined && typeof linear.stateFrom !== 'string') {
576
+ errors.push('trigger.stateFrom must be a string');
577
+ }
572
578
  return errors;
573
579
  }
574
580
  function isParseableDate(value) {
@@ -702,8 +708,71 @@ export function hasCompletedOneShotRun(config, now = new Date()) {
702
708
  export function shouldPurgeCompletedOneShotRoutine(config, now = new Date()) {
703
709
  return hasCompletedOneShotRun(config, now);
704
710
  }
711
+ function getPath(obj, path) {
712
+ const parts = path.split('.');
713
+ let current = obj;
714
+ for (const part of parts) {
715
+ if (current === null || current === undefined)
716
+ return undefined;
717
+ current = current[part];
718
+ }
719
+ return current;
720
+ }
721
+ /**
722
+ * Substitute `{{dotted.path}}` placeholders in a string using a webhook context.
723
+ * Missing values are replaced with an empty string.
724
+ */
725
+ export function substituteWebhookPrompt(prompt, context) {
726
+ return prompt.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
727
+ const value = getPath(context, rawPath.trim());
728
+ if (value === undefined || value === null)
729
+ return '';
730
+ return String(value);
731
+ });
732
+ }
733
+ /**
734
+ * Substitute `{{dotted.path}}` placeholders in a string destined for a SHELL,
735
+ * quoting every substituted value so payload content cannot break out of it.
736
+ *
737
+ * `run.command` is executed through a shell, and its context is built from an
738
+ * external webhook payload — `issue.title`, `issue.description`, and the GitHub
739
+ * `pull_request` fields are free text any outside contributor can set. Pasting
740
+ * those in raw (as {@link substituteWebhookPrompt} does, correctly, for prompts)
741
+ * turns an operator's `echo {{issue.title}}` into a command-injection sink.
742
+ *
743
+ * The template itself is operator-authored and stays unquoted, so pipes,
744
+ * redirects, and `&&` in the configured command keep working. Only the
745
+ * interpolated values are quoted.
746
+ *
747
+ * POSIX `sh` quoting: wrap in single quotes and close/escape/reopen for any
748
+ * embedded single quote. `exec` uses `cmd.exe` on Windows, which does not
749
+ * honour these rules — see `assertShellSubstitutionSupported`.
750
+ */
751
+ export function substituteWebhookCommand(command, context) {
752
+ return command.replace(/\{\{([^{}]+)\}\}/g, (_, rawPath) => {
753
+ const value = getPath(context, rawPath.trim());
754
+ if (value === undefined || value === null)
755
+ return "''";
756
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
757
+ });
758
+ }
759
+ /**
760
+ * Refuse a `run.command` carrying placeholders on a platform whose shell we
761
+ * cannot safely quote for. `child_process.exec` runs through `cmd.exe` on
762
+ * Windows, where POSIX single-quoting is not a quoting mechanism at all, so
763
+ * {@link substituteWebhookCommand} would not contain a hostile value.
764
+ *
765
+ * Fail loud rather than execute something we cannot prove is safe.
766
+ */
767
+ export function assertShellSubstitutionSupported(command, platform = process.platform) {
768
+ if (platform === 'win32' && /\{\{[^{}]+\}\}/.test(command)) {
769
+ throw new Error('run.command with {{…}} placeholders is not supported on Windows: the values come from an ' +
770
+ 'untrusted webhook payload and cmd.exe cannot be quoted safely. Use run.prompt, or a ' +
771
+ 'command with no placeholders.');
772
+ }
773
+ }
705
774
  /** Expand built-in and user-defined template variables in a job's prompt string. */
706
- export function resolveJobPrompt(config) {
775
+ export function resolveJobPrompt(config, context) {
707
776
  const now = new Date();
708
777
  const tz = config.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
709
778
  const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -725,6 +794,10 @@ export function resolveJobPrompt(config) {
725
794
  prompt = prompt.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
726
795
  }
727
796
  }
797
+ // Webhook-driven variables ({{issue.identifier}}, {{updatedFrom.state.name}}, ...)
798
+ if (context) {
799
+ prompt = substituteWebhookPrompt(prompt, context);
800
+ }
728
801
  // Last report (special handling). Only a COMPLETED run's report is injected —
729
802
  // a failed run's report.md is the agent's error text (e.g. a login prompt on
730
803
  // an auth failure), and feeding that into the next prompt poisons every
@@ -23,6 +23,11 @@ export interface RunResult {
23
23
  meta: RunMeta;
24
24
  reportPath: string | null;
25
25
  }
26
+ /** Agents the daemon can actually run, derived from the command table above
27
+ * so the `--agent` help and any validation can never drift from it. */
28
+ export declare const ROUTINE_AGENT_IDS: readonly string[];
29
+ /** Stable working directory for routine children, independent of the daemon's launch cwd. */
30
+ export declare function routineSpawnCwd(config: Pick<JobConfig, 'repo'>, configuredRoot?: string | undefined): string;
26
31
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
27
32
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
28
33
  export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent'>, runDir: string, overlayHome?: string): void;
@@ -63,7 +68,7 @@ export declare function dispatchesViaAgentsRun(config: Pick<JobConfig, 'workflow
63
68
  * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
64
69
  * with `agents run`.
65
70
  */
66
- export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, agent: AgentId, version: string | undefined, timezone?: string): Record<string, string>;
71
+ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, agent: AgentId, version: string | undefined, timezone?: string, overlayHome?: string): Record<string, string>;
67
72
  export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
68
73
  /**
69
74
  * Optional lifecycle callbacks for a detached routine run. The daemon passes an
@@ -80,5 +85,15 @@ export interface RoutineHooks {
80
85
  export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
81
86
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
82
87
  export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
88
+ /** Derive the final status of a detached run by reading the agent's stream-json
89
+ * tail. Detached children fire-and-forget, so we never see their exit code
90
+ * directly — but Claude's stream-json terminates with a `type: result` line
91
+ * that carries `is_error`. If we find it, the run completed cleanly (modulo
92
+ * agent-reported error). If not, the process likely died mid-stream and the
93
+ * caller should treat the run as failed. */
94
+ export declare function inferFinalStatusFromLog(stdoutPath: string, agent: AgentId): {
95
+ status: 'completed' | 'failed';
96
+ exitCode: number;
97
+ } | null;
83
98
  /** Scan all runs marked "running" and finalize any whose process has exited. */
84
99
  export declare function monitorRunningJobs(): void;
@@ -32,18 +32,37 @@ import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js
32
32
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
33
33
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
34
  import { machineId } from './machine-id.js';
35
+ import { isSelfUpdatingAgent } from './agents.js';
36
+ import { expandLocalHome, getProjectRoot } from './project-root.js';
35
37
  /** CLI command templates per agent, with {prompt} as a placeholder. */
36
38
  const AGENT_COMMANDS = {
37
39
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
38
40
  codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
39
41
  gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
42
+ cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
40
43
  kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
41
44
  droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
42
45
  };
46
+ /** Agents the daemon can actually run, derived from the command table above
47
+ * so the `--agent` help and any validation can never drift from it. */
48
+ export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(AGENT_COMMANDS));
43
49
  const ROUTINE_TRANSCRIPT_SPECS = {
44
50
  claude: [{ root: ['.claude', 'projects'], ext: '.jsonl' }],
45
51
  codex: [{ root: ['.codex', 'sessions'], ext: '.jsonl' }],
52
+ cursor: [{ root: ['.cursor', 'projects'], ext: '.jsonl' }],
46
53
  };
54
+ /** Stable working directory for routine children, independent of the daemon's launch cwd. */
55
+ export function routineSpawnCwd(config, configuredRoot = getProjectRoot()) {
56
+ if (!config.repo)
57
+ return os.homedir();
58
+ const [owner, repo] = config.repo.split('/');
59
+ if (configuredRoot) {
60
+ const root = path.resolve(expandLocalHome(configuredRoot));
61
+ if (path.basename(root) === owner)
62
+ return path.join(root, repo);
63
+ }
64
+ return path.join(os.homedir(), 'src', 'github.com', owner, repo);
65
+ }
47
66
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
48
67
  export function buildJobCommand(config, resolvedPrompt) {
49
68
  // Workflow branch: delegate to `agents run <workflow>` which handles subagent
@@ -133,6 +152,20 @@ export function buildJobCommand(config, resolvedPrompt) {
133
152
  }
134
153
  appendModelAndReasoning(cmd, config);
135
154
  }
155
+ if (config.agent === 'cursor') {
156
+ // cursor-agent supports --plan, but the capability registry has not been
157
+ // upgraded yet. RUSH-2101 tracks adding that read-only mode after PR #1721.
158
+ const cursorMode = resolveHeadlessMode('cursor', mode, false, `routine ${config.name}`);
159
+ if (cursorMode === 'skip') {
160
+ cmd.push('-f');
161
+ }
162
+ else {
163
+ // The configured cwd is the user's workspace trust decision. --trust is
164
+ // narrower than --yolo/-f because it does not bypass tool permissions.
165
+ cmd.push('--trust');
166
+ }
167
+ appendModelAndReasoning(cmd, config);
168
+ }
136
169
  if (config.agent === 'kimi') {
137
170
  // kimi daemon jobs always run headless via `--prompt`, which cannot be
138
171
  // combined with any startup-mode flag (--plan/--auto/--yolo all abort with
@@ -385,7 +418,7 @@ export function dispatchesViaAgentsRun(config) {
385
418
  * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
386
419
  * with `agents run`.
387
420
  */
388
- export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
421
+ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayHome) {
389
422
  const execEnv = buildExecEnv({
390
423
  agent,
391
424
  version,
@@ -415,6 +448,11 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
415
448
  // provisioned box does. Drop it here so a routine always uses the login of the
416
449
  // machine it runs on.
417
450
  delete out.CLAUDE_CODE_OAUTH_TOKEN;
451
+ if (agent === 'cursor' && overlayHome) {
452
+ // prepareJobHome links this host's Cursor auth file here. Pin XDG_CONFIG_HOME
453
+ // to the overlay so an ambient value cannot bypass the routine sandbox.
454
+ out.XDG_CONFIG_HOME = path.join(overlayHome, '.config');
455
+ }
418
456
  if (timezone)
419
457
  out.TZ = timezone;
420
458
  return out;
@@ -424,14 +462,14 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
424
462
  * Rate-limit scanning uses only this attempt's log (not prior failover output).
425
463
  * The attempt log is also appended into `combinedLogPath` for a continuous trail.
426
464
  */
427
- function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
465
+ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath, cwd = os.homedir()) {
428
466
  // Isolate this attempt's output so detectRateLimit never sees prior attempts.
429
467
  fs.writeFileSync(attemptLogPath, '', { mode: 0o600 });
430
468
  const stdoutFd = fs.openSync(attemptLogPath, 'a', 0o600);
431
469
  return new Promise((resolve) => {
432
470
  const child = spawn(cmd[0], cmd.slice(1), {
433
471
  stdio: ['ignore', stdoutFd, stdoutFd],
434
- ...backgroundSpawnOptions({ fdStdio: true }),
472
+ ...backgroundSpawnOptions({ cwd, fdStdio: true }),
435
473
  env,
436
474
  });
437
475
  let settled = false;
@@ -579,7 +617,7 @@ export async function executeJob(config, deps) {
579
617
  const runDir = getRunDir(config.name, runId);
580
618
  fs.mkdirSync(runDir, { recursive: true });
581
619
  const baseEnv = injectRoutineActor(useSandbox
582
- ? buildSpawnEnv(overlayHome)
620
+ ? buildSpawnEnv(overlayHome, config.env)
583
621
  : { ...process.env }, config);
584
622
  // Workflows run via `agents run <workflow>` which delegates to claude under the hood.
585
623
  // Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
@@ -621,16 +659,20 @@ export async function executeJob(config, deps) {
621
659
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
622
660
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
623
661
  if (config.loop) {
624
- const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone);
662
+ const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone, overlayHome);
625
663
  const execOptions = {
626
664
  agent: effectiveAgent,
627
- version: primaryVersion,
665
+ // Routine-supported self-updating CLIs (Cursor/Droid) use one global
666
+ // binary; a versioned shim would point at a nonexistent isolated install.
667
+ version: isSelfUpdatingAgent(effectiveAgent) ? undefined : primaryVersion,
628
668
  prompt: resolvedPrompt,
629
669
  mode: normalizeMode(config.mode),
630
670
  effort: config.effort,
631
671
  env: spawnEnv,
632
672
  json: true,
633
673
  headless: true,
674
+ modeWarningContext: `routine ${config.name}`,
675
+ modeWarningState: {},
634
676
  ...(config.config?.model ? { model: config.config.model } : {}),
635
677
  ...(config.allow?.dirs ? {
636
678
  addDirs: config.allow.dirs
@@ -681,12 +723,12 @@ export async function executeJob(config, deps) {
681
723
  e.TZ = config.timezone;
682
724
  return e;
683
725
  })()
684
- : buildRoutineSpawnEnv(baseEnv, attemptAgent, attemptVersion, config.timezone);
726
+ : buildRoutineSpawnEnv(baseEnv, attemptAgent, attemptVersion, config.timezone, overlayHome);
685
727
  // Remaining timeout budget shared across failover attempts.
686
728
  const elapsed = Date.now() - Date.parse(meta.startedAt);
687
729
  const remaining = Math.max(1_000, timeoutMs - (Number.isFinite(elapsed) ? elapsed : 0));
688
730
  const attemptLogPath = path.join(runDir, `stdout.attempt-${i}.log`);
689
- const attempt = await spawnJobAttempt(cmd, spawnEnv, attemptLogPath, remaining, stdoutPath);
731
+ const attempt = await spawnJobAttempt(cmd, spawnEnv, attemptLogPath, remaining, stdoutPath, routineSpawnCwd(config));
690
732
  meta.pid = attempt.pid;
691
733
  writeRunMeta(meta);
692
734
  if (attempt.status === 'timeout') {
@@ -941,7 +983,7 @@ async function executeCommandJobForeground(config) {
941
983
  const result = await new Promise((resolve) => {
942
984
  const child = spawn(cmd[0], cmd.slice(1), {
943
985
  stdio: ['ignore', stdoutFd, stdoutFd],
944
- ...backgroundSpawnOptions({ fdStdio: true }),
986
+ ...backgroundSpawnOptions({ cwd: routineSpawnCwd(config), fdStdio: true }),
945
987
  env,
946
988
  });
947
989
  meta.pid = child.pid || null;
@@ -1057,7 +1099,7 @@ export async function executeJobDetached(config, hooks) {
1057
1099
  const stdoutPath = path.join(runDir, 'stdout.log');
1058
1100
  const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
1059
1101
  const baseEnv = injectRoutineActor(useSandbox
1060
- ? buildSpawnEnv(overlayHome)
1102
+ ? buildSpawnEnv(overlayHome, config.env)
1061
1103
  : { ...process.env }, config);
1062
1104
  const spawnEnv = dispatchesViaAgentsRun(config)
1063
1105
  ? (() => {
@@ -1067,7 +1109,7 @@ export async function executeJobDetached(config, hooks) {
1067
1109
  return e;
1068
1110
  })()
1069
1111
  // Non-command path only: config.agent is always set here (command/workflow branch earlier).
1070
- : buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone);
1112
+ : buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone, overlayHome);
1071
1113
  const effectiveAgent = config.workflow ? 'claude' : config.agent;
1072
1114
  const meta = {
1073
1115
  jobName: config.name,
@@ -1106,7 +1148,7 @@ export async function executeJobDetached(config, hooks) {
1106
1148
  }
1107
1149
  const child = spawn(cmd[0], cmd.slice(1), {
1108
1150
  stdio: ['ignore', stdoutFd, stdoutFd],
1109
- ...backgroundSpawnOptions({ fdStdio: true }),
1151
+ ...backgroundSpawnOptions({ cwd: routineSpawnCwd(config), fdStdio: true }),
1110
1152
  env: spawnEnv,
1111
1153
  });
1112
1154
  let settled = false;
@@ -1204,7 +1246,7 @@ function executeCommandJobDetached(config, hooks) {
1204
1246
  };
1205
1247
  const child = spawn(cmd[0], cmd.slice(1), {
1206
1248
  stdio: ['ignore', stdoutFd, stdoutFd],
1207
- ...backgroundSpawnOptions({ fdStdio: true }),
1249
+ ...backgroundSpawnOptions({ cwd: routineSpawnCwd(config), fdStdio: true }),
1208
1250
  env,
1209
1251
  });
1210
1252
  // Record the real terminal status ourselves — the daemon stays alive after this
@@ -1262,7 +1304,7 @@ export function extractReport(stdoutPath, agentType) {
1262
1304
  for (const line of lines) {
1263
1305
  try {
1264
1306
  const parsed = JSON.parse(line);
1265
- if (agentType === 'claude') {
1307
+ if (agentType === 'claude' || agentType === 'cursor') {
1266
1308
  if (parsed.type === 'assistant' && parsed.message?.content) {
1267
1309
  for (const block of parsed.message.content) {
1268
1310
  if (block.type === 'text' && block.text) {
@@ -1298,7 +1340,7 @@ export function extractReport(stdoutPath, agentType) {
1298
1340
  * that carries `is_error`. If we find it, the run completed cleanly (modulo
1299
1341
  * agent-reported error). If not, the process likely died mid-stream and the
1300
1342
  * caller should treat the run as failed. */
1301
- function inferFinalStatusFromLog(stdoutPath, agent) {
1343
+ export function inferFinalStatusFromLog(stdoutPath, agent) {
1302
1344
  if (!fs.existsSync(stdoutPath))
1303
1345
  return null;
1304
1346
  try {
@@ -1309,7 +1351,7 @@ function inferFinalStatusFromLog(stdoutPath, agent) {
1309
1351
  for (let i = lines.length - 1, scanned = 0; i >= 0 && scanned < 20; i--, scanned++) {
1310
1352
  try {
1311
1353
  const parsed = JSON.parse(lines[i]);
1312
- if (agent === 'claude' && parsed.type === 'result') {
1354
+ if ((agent === 'claude' || agent === 'cursor') && parsed.type === 'result') {
1313
1355
  return parsed.is_error
1314
1356
  ? { status: 'failed', exitCode: 1 }
1315
1357
  : { status: 'completed', exitCode: 0 };
@@ -21,6 +21,8 @@ export declare function buildSpawnEnv(overlayHome: string, extraEnv?: Record<str
21
21
  export declare function getJobHomePath(name: string): string;
22
22
  /** Create a fresh overlay HOME for a job, including agent config and allowed-dir symlinks. */
23
23
  export declare function prepareJobHome(config: JobConfig): string;
24
+ /** Link this host's Cursor login and CLI config into the disposable overlay. */
25
+ export declare function generateCursorConfig(overlayHome: string): void;
24
26
  /** Remove a job's overlay HOME directory entirely. */
25
27
  export declare function cleanJobHome(name: string): void;
26
28
  /** Symlink allowed directories into the overlay HOME, skipping paths outside the real HOME. */
@@ -100,11 +100,49 @@ export function prepareJobHome(config) {
100
100
  else if (config.agent === 'gemini') {
101
101
  generateGeminiConfig(overlayHome, config);
102
102
  }
103
+ else if (config.agent === 'cursor') {
104
+ generateCursorConfig(overlayHome);
105
+ }
103
106
  if (config.allow?.dirs) {
104
107
  symlinkAllowedDirs(overlayHome, config.allow.dirs);
105
108
  }
106
109
  return overlayHome;
107
110
  }
111
+ /** Link this host's Cursor login and CLI config into the disposable overlay. */
112
+ export function generateCursorConfig(overlayHome) {
113
+ const realConfigHome = process.env.XDG_CONFIG_HOME || path.join(resolveRealHome(), '.config');
114
+ const realAuth = path.join(realConfigHome, 'cursor', 'auth.json');
115
+ if (!fs.existsSync(realAuth))
116
+ return;
117
+ const overlayCursorDir = path.join(overlayHome, '.config', 'cursor');
118
+ fs.mkdirSync(overlayCursorDir, { recursive: true });
119
+ const overlayAuth = path.join(overlayCursorDir, 'auth.json');
120
+ if (process.platform === 'win32') {
121
+ // File symlinks need Developer Mode on Windows. A same-volume hard link
122
+ // shares the credential inode without copying its contents to another host.
123
+ try {
124
+ fs.linkSync(realAuth, overlayAuth);
125
+ }
126
+ catch { /* cross-volume or link creation refused: Cursor will fail auth loudly */ }
127
+ }
128
+ else {
129
+ fs.symlinkSync(realAuth, overlayAuth);
130
+ }
131
+ // Setting XDG_CONFIG_HOME makes Cursor look for cli-config.json beside auth.json.
132
+ // This file carries account identity (authId, displayName, email, userId) as
133
+ // well as preferences, so it must remain linked rather than copied.
134
+ const realCliConfig = path.join(resolveRealHome(), '.cursor', 'cli-config.json');
135
+ if (fs.existsSync(realCliConfig)) {
136
+ const overlayCliConfig = path.join(overlayCursorDir, 'cli-config.json');
137
+ try {
138
+ if (process.platform === 'win32')
139
+ fs.linkSync(realCliConfig, overlayCliConfig);
140
+ else
141
+ fs.symlinkSync(realCliConfig, overlayCliConfig);
142
+ }
143
+ catch { /* cross-volume or link creation refused: Cursor will fail auth loudly */ }
144
+ }
145
+ }
108
146
  /** Remove a job's overlay HOME directory entirely. */
109
147
  export function cleanJobHome(name) {
110
148
  const overlayHome = getJobHomePath(name);
@@ -47,20 +47,19 @@ const keychainStore = {
47
47
  delete: deleteKeychainToken,
48
48
  list: listKeychainItems,
49
49
  };
50
- // The file store auto-provisions a machine-local passphrase on Linux (the
51
- // existing headless-libsecret fallback) but NEVER on macOS: a file-backed
52
- // bundle on a Mac must be unlocked with an explicit AGENTS_SECRETS_PASSPHRASE
53
- // supplied per run, so the box holds ciphertext only. assertFileBackendUsable()
54
- // enforces that the passphrase is present before we touch the store.
55
- const FILE_ALLOW_AUTO_PROVISION = process.platform !== 'darwin';
50
+ // The file store auto-provisions a stable machine-local passphrase on EVERY
51
+ // platform (macOS included) a 0600 key file, encryption-at-rest with the same
52
+ // posture as an SSH key so `agents secrets` "just works" with no passphrase to
53
+ // set, type, or remember, and no Touch ID. Set AGENTS_SECRETS_PASSPHRASE to opt
54
+ // into an off-disk key.
56
55
  const fileItemStore = {
57
56
  has: (item) => fileStore.has(item),
58
- get: (item) => fileStore.get(item, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }),
57
+ get: (item) => fileStore.get(item),
59
58
  getBatch: (items) => {
60
59
  const out = new Map();
61
60
  for (const item of items) {
62
61
  try {
63
- out.set(item, fileStore.get(item, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }));
62
+ out.set(item, fileStore.get(item));
64
63
  }
65
64
  catch {
66
65
  // Missing/undecryptable item — absent from the map, mirroring
@@ -69,10 +68,10 @@ const fileItemStore = {
69
68
  }
70
69
  return out;
71
70
  },
72
- set: (item, value) => fileStore.set(item, value, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION }),
71
+ set: (item, value) => fileStore.set(item, value),
73
72
  setBatch: (items) => {
74
73
  for (const [item, value] of items) {
75
- fileStore.set(item, value, { allowAutoProvision: FILE_ALLOW_AUTO_PROVISION });
74
+ fileStore.set(item, value);
76
75
  }
77
76
  },
78
77
  delete: (item) => fileStore.delete(item),
@@ -116,24 +115,6 @@ export function bundleBackend(name) {
116
115
  }
117
116
  return 'keychain';
118
117
  }
119
- /**
120
- * Guard a file-backed bundle operation. On macOS the file store must be
121
- * unlocked with an explicit passphrase (env or interactive prompt) — we refuse
122
- * to silently auto-provision a machine-local key there, so a remote/headless
123
- * Mac cannot decrypt on its own. Linux keeps the existing auto-provision
124
- * behavior, so this is a no-op there.
125
- */
126
- function assertFileBackendUsable(name) {
127
- if (process.platform !== 'darwin')
128
- return;
129
- if (process.env.AGENTS_SECRETS_PASSPHRASE && process.env.AGENTS_SECRETS_PASSPHRASE.length > 0)
130
- return;
131
- if (process.stdin.isTTY)
132
- return;
133
- throw new Error(`File-backed bundle '${name}' needs AGENTS_SECRETS_PASSPHRASE to be set on macOS ` +
134
- `(no biometry prompt is available headlessly). Set it for this run, e.g.\n` +
135
- ` AGENTS_SECRETS_PASSPHRASE=… agents secrets exec ${name} -- <command>`);
136
- }
137
118
  function assertVaultBackendUsable(name) {
138
119
  if (getVaultSession().loggedIn)
139
120
  return;
@@ -286,8 +267,6 @@ export function readBundleIfDecryptable(name) {
286
267
  export function readBundle(name) {
287
268
  validateBundleName(name);
288
269
  const backend = bundleBackend(name);
289
- if (backend === 'file')
290
- assertFileBackendUsable(name);
291
270
  if (backend === 'vault')
292
271
  assertVaultBackendUsable(name);
293
272
  let json;
@@ -395,8 +374,6 @@ export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOv
395
374
  function prepareBundleWrite(bundle) {
396
375
  validateBundleName(bundle.name);
397
376
  const backend = bundle.backend ?? 'keychain';
398
- if (backend === 'file')
399
- assertFileBackendUsable(bundle.name);
400
377
  if (backend === 'vault')
401
378
  assertVaultBackendUsable(bundle.name);
402
379
  for (const key of Object.keys(bundle.vars)) {
@@ -1177,8 +1154,6 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1177
1154
  `never raises a Touch ID sheet on its own.`);
1178
1155
  }
1179
1156
  }
1180
- if (backend === 'file')
1181
- assertFileBackendUsable(name);
1182
1157
  if (backend === 'vault')
1183
1158
  assertVaultBackendUsable(name);
1184
1159
  const store = itemStore(backend);