@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Interactive provisioning flow for cross-machine session sync. Collects R2
3
+ * credentials, writes the `r2.backups` bundle (via the pure lib helper), probes
4
+ * connectivity, and opts the machine into the `session-sync` beta. Shared by
5
+ * `agents setup` (offered, opt-in) and `agents sessions sync --setup` (explicit).
6
+ *
7
+ * The prompt/UI lives here in the command layer; the credential-writing and
8
+ * connectivity logic is the pure, unit-tested `lib/session/sync/provision.ts`.
9
+ */
10
+ import chalk from 'chalk';
11
+ import ora from 'ora';
12
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
13
+ import { isSyncConfigured } from '../lib/session/sync/config.js';
14
+ import { writeSyncBundle, probeR2Connectivity, readStoredEncKey } from '../lib/session/sync/provision.js';
15
+ import { setBetaEnabled } from '../lib/beta.js';
16
+ const SYNC_BETA = 'session-sync';
17
+ const nonEmpty = (v) => (v.trim().length > 0 ? true : 'required');
18
+ /**
19
+ * Run the guided session-sync setup. Never throws for expected outcomes
20
+ * (non-interactive shell, user declines, cancels, or a failed probe) — returns
21
+ * quietly so a first-run `agents setup` is never blocked by the optional step.
22
+ */
23
+ export async function promptAndProvisionSessionSync(opts = {}) {
24
+ if (!isInteractiveTerminal()) {
25
+ if (opts.explicit)
26
+ console.error(chalk.red('Session-sync setup needs an interactive terminal.'));
27
+ return;
28
+ }
29
+ const alreadyConfigured = isSyncConfigured();
30
+ const { confirm, input, password } = await import('@inquirer/prompts');
31
+ try {
32
+ if (!opts.explicit) {
33
+ if (alreadyConfigured)
34
+ return; // first-run setup: nothing to do
35
+ const want = await confirm({
36
+ message: 'Set up cross-machine session sync (encrypted transcripts via Cloudflare R2)?',
37
+ default: false,
38
+ });
39
+ if (!want)
40
+ return;
41
+ }
42
+ else if (alreadyConfigured) {
43
+ const reconfigure = await confirm({
44
+ message: 'Session sync is already configured. Re-enter R2 credentials?',
45
+ default: false,
46
+ });
47
+ if (!reconfigure) {
48
+ const key = readStoredEncKey();
49
+ if (key) {
50
+ console.log(chalk.dim('\nShared encryption key (paste on every other machine you sync):'));
51
+ console.log(' ' + chalk.cyan(key));
52
+ }
53
+ return;
54
+ }
55
+ }
56
+ console.log(chalk.dim('\nR2 credentials — Cloudflare dashboard > R2 > Manage API Tokens (needs read+write):'));
57
+ const accountId = (await input({ message: 'R2 account ID:', validate: nonEmpty })).trim();
58
+ const bucketName = (await input({ message: 'R2 bucket name:', validate: nonEmpty })).trim();
59
+ const accessKeyId = (await password({ message: 'R2 access key ID:', mask: true })).trim();
60
+ const secretAccessKey = (await password({ message: 'R2 secret access key:', mask: true })).trim();
61
+ const endpoint = (await input({
62
+ message: 'S3 endpoint override (blank = Cloudflare R2):',
63
+ default: '',
64
+ })).trim();
65
+ // Encryption key: the first machine mints one; every other machine pastes it
66
+ // so the whole fabric shares a single key (and can decrypt each other).
67
+ const firstMachine = await confirm({ message: 'Is this the FIRST machine in your sync fabric?', default: true });
68
+ let encKey;
69
+ if (!firstMachine) {
70
+ encKey = (await password({
71
+ message: 'Paste the shared R2_SYNC_ENC_KEY from your first machine:',
72
+ mask: true,
73
+ validate: nonEmpty,
74
+ })).trim();
75
+ }
76
+ const { encKeyAction } = writeSyncBundle({
77
+ accountId, bucketName, accessKeyId, secretAccessKey,
78
+ endpoint: endpoint || undefined,
79
+ encKey: encKey || undefined,
80
+ });
81
+ const spinner = ora('Validating R2 read+write...').start();
82
+ const probe = await probeR2Connectivity();
83
+ if (!probe.ok) {
84
+ spinner.fail(`R2 probe failed: ${probe.error}`);
85
+ console.log(chalk.yellow('Credentials were saved to the r2.backups bundle but not verified. Fix them and re-run ' +
86
+ '`agents sessions sync --setup`, or rotate one key with `agents secrets rotate r2.backups <KEY>`.'));
87
+ return; // do not auto-enable sync against credentials that don't work
88
+ }
89
+ spinner.succeed('R2 read+write verified.');
90
+ setBetaEnabled([SYNC_BETA], true);
91
+ console.log(chalk.green('\nSession sync configured and enabled') + chalk.dim(' (the daemon syncs every ~90s).'));
92
+ if (encKeyAction === 'generated') {
93
+ const key = readStoredEncKey();
94
+ console.log(chalk.bold('\nShared encryption key — paste this on every OTHER machine you sync:'));
95
+ console.log(' ' + chalk.cyan(key ?? '(unavailable)'));
96
+ console.log(chalk.dim('Anyone with this key can decrypt your transcripts. Keep it secret.'));
97
+ }
98
+ console.log(chalk.dim('\nSync now with: agents sessions sync'));
99
+ }
100
+ catch (err) {
101
+ if (isPromptCancelled(err)) {
102
+ console.log(chalk.yellow('\nSession-sync setup cancelled.'));
103
+ return;
104
+ }
105
+ throw err;
106
+ }
107
+ }
@@ -4,6 +4,8 @@
4
4
  * Lists every installed agent with the best available usage snapshot:
5
5
  * - claude: live OAuth API call (cached for 2 minutes)
6
6
  * - codex: parsed from latest session log's rate_limits event
7
+ * - kimi: live Kimi Code /usages API call (cached for 2 minutes)
8
+ * - droid: live Factory billing/limits API call (cached for 2 minutes)
7
9
  * - others: marked as "not exposed by CLI" (Gemini, OpenCode, Cursor, etc.
8
10
  * don't publish per-account usage today)
9
11
  */
@@ -3,8 +3,13 @@ import chalk from 'chalk';
3
3
  import { ALL_AGENT_IDS, AGENTS, getAccountInfo, agentLabel, resolveAgentName, formatAgentError, } from '../lib/agents.js';
4
4
  import { listInstalledVersions, getGlobalDefault, getVersionHomePath } from '../lib/versions.js';
5
5
  import { formatUsageSection, getUsageInfoForIdentity } from '../lib/usage.js';
6
- /** Agents whose CLI surfaces usage data we can read today. */
7
- const USAGE_SUPPORTED = new Set(['claude', 'codex']);
6
+ /**
7
+ * Agents whose CLI surfaces usage data we can read today. Kept in sync with the
8
+ * live/last-seen sources `getUsageInfo` dispatches on in `../lib/usage.js`
9
+ * (claude, codex, kimi, droid) — an agent with a usage source but missing here
10
+ * would wrongly print "does not publish usage data" for a signed-in account.
11
+ */
12
+ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
8
13
  export function registerUsageCommand(program) {
9
14
  addHostOption(program.command('usage [agent]'))
10
15
  .description('Show rate-limit / quota usage per agent')
@@ -39,7 +39,7 @@ export interface ViewJsonVersion {
39
39
  currency: string;
40
40
  } | null;
41
41
  windows: Array<{
42
- key: 'session' | 'week' | 'sonnet_week';
42
+ key: 'session' | 'week' | 'sonnet_week' | 'month';
43
43
  usedPercent: number;
44
44
  resetsAt: string | null;
45
45
  }>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `agents webhook` — localhost receiver for signed public webhook ingress.
3
+ *
4
+ * The receiver intentionally binds localhost by default. Public exposure is a
5
+ * separate `agents funnel up <host>` step so the HTTP process can be tested and
6
+ * rotated without changing the Tailscale Funnel config.
7
+ */
8
+ import type { Command } from 'commander';
9
+ export declare function registerWebhookCommand(program: Command): void;
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
3
+ import { startWebhookServer } from '../lib/triggers/webhook.js';
4
+ const DEFAULT_HOST = '127.0.0.1';
5
+ const DEFAULT_PORT = 8787;
6
+ function positiveInt(value, fallback) {
7
+ const parsed = Number.parseInt(value ?? '', 10);
8
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
9
+ }
10
+ function readWebhookSecrets(bundleName) {
11
+ const { env } = readAndResolveBundleEnv(bundleName, {
12
+ caller: 'webhook serve',
13
+ });
14
+ const secrets = {};
15
+ if (env.GITHUB_WEBHOOK_SECRET)
16
+ secrets.github = env.GITHUB_WEBHOOK_SECRET;
17
+ if (env.LINEAR_WEBHOOK_SECRET)
18
+ secrets.linear = env.LINEAR_WEBHOOK_SECRET;
19
+ if (!secrets.github && !secrets.linear) {
20
+ throw new Error(`Bundle '${bundleName}' must contain GITHUB_WEBHOOK_SECRET or LINEAR_WEBHOOK_SECRET.`);
21
+ }
22
+ return secrets;
23
+ }
24
+ function waitForListening(server) {
25
+ if (server.listening)
26
+ return Promise.resolve();
27
+ return new Promise((resolve, reject) => {
28
+ const cleanup = () => {
29
+ server.off('listening', onListening);
30
+ server.off('error', onError);
31
+ };
32
+ const onListening = () => {
33
+ cleanup();
34
+ resolve();
35
+ };
36
+ const onError = (err) => {
37
+ cleanup();
38
+ reject(err);
39
+ };
40
+ server.once('listening', onListening);
41
+ server.once('error', onError);
42
+ });
43
+ }
44
+ export function registerWebhookCommand(program) {
45
+ const webhook = program
46
+ .command('webhook')
47
+ .description('Run a localhost signed webhook receiver for routine triggers.');
48
+ webhook
49
+ .command('serve')
50
+ .description('Receive signed GitHub/Linear webhooks on /hooks/<source> and fire matching routines.')
51
+ .requiredOption('--secrets-bundle <name>', 'agents secrets bundle containing GITHUB_WEBHOOK_SECRET and/or LINEAR_WEBHOOK_SECRET')
52
+ .option('--host <addr>', `Bind address (default ${DEFAULT_HOST})`, DEFAULT_HOST)
53
+ .option('-p, --port <n>', `Local port (default ${DEFAULT_PORT})`, String(DEFAULT_PORT))
54
+ .option('--rate-limit <n>', 'Accepted deliveries per source per minute', '60')
55
+ .action(async (opts) => {
56
+ let secrets;
57
+ try {
58
+ secrets = readWebhookSecrets(opts.secretsBundle);
59
+ }
60
+ catch (err) {
61
+ console.error(chalk.red(err.message));
62
+ process.exit(1);
63
+ }
64
+ const port = positiveInt(opts.port, DEFAULT_PORT);
65
+ const rateLimit = positiveInt(opts.rateLimit, 60);
66
+ try {
67
+ const server = startWebhookServer({
68
+ host: opts.host ?? DEFAULT_HOST,
69
+ port,
70
+ secrets,
71
+ rateLimitPerMinute: rateLimit,
72
+ onDelivery: (webhook, fired) => {
73
+ console.log(`${new Date().toISOString()} ${webhook.source}:${webhook.event} ` +
74
+ `${fired.length ? `fired ${fired.map((f) => f.jobName).join(', ')}` : 'no match'}`);
75
+ },
76
+ });
77
+ await waitForListening(server);
78
+ const address = server.address();
79
+ const bound = typeof address === 'object' && address ? address.port : port;
80
+ console.log(`${chalk.green('agents webhook')} ${chalk.dim('→')} ${chalk.cyan(`http://${opts.host ?? DEFAULT_HOST}:${bound}`)}`);
81
+ console.log(chalk.dim('signed · localhost by default · endpoints: /hooks/github, /hooks/linear · Ctrl-C to stop'));
82
+ const shutdown = () => {
83
+ server.close(() => process.exit(0));
84
+ };
85
+ process.on('SIGINT', shutdown);
86
+ process.on('SIGTERM', shutdown);
87
+ }
88
+ catch (err) {
89
+ console.error(chalk.red(`Could not start webhook receiver: ${err.message}`));
90
+ process.exit(1);
91
+ }
92
+ });
93
+ }
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -174,6 +174,8 @@ Run and dispatch:
174
174
  defaults Configure run defaults by agent/version selector
175
175
  teams Coordinate multiple agents on shared work
176
176
  routines Run agents on a cron schedule (scheduler auto-starts)
177
+ webhook Receive signed GitHub/Linear webhooks for trigger routines
178
+ funnel Expose a webhook receiver through Tailscale Funnel
177
179
  sessions Browse, search, and replay past runs (live-search in TTY; grouped by workspace)
178
180
  logs [id] Show a run's log — host-dispatch task or session; -f to follow
179
181
  browser Automate a browser — navigate, click, screenshot, console, network
@@ -702,7 +704,10 @@ async function registerAllEagerCommands() {
702
704
  await reg(loadLogs);
703
705
  await reg(loadEvents);
704
706
  await reg(loadAudit);
707
+ await reg(loadWebhook);
708
+ await reg(loadFunnel);
705
709
  await reg(loadFeed);
710
+ await reg(loadMailboxes);
706
711
  await reg(loadSsh);
707
712
  registerJobsCronAliasCommand(program, 'jobs');
708
713
  registerJobsCronAliasCommand(program, 'cron');
@@ -875,7 +880,7 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1') {
875
880
  // Bumping the suffix re-runs migrations for every user; binary releases that
876
881
  // don't change the schema must NOT re-run (they would destroy user content
877
882
  // when migration steps overlap with user-authored paths). See issue #20.
878
- const sentinelValue = 'v12';
883
+ const sentinelValue = 'v13';
879
884
  let needRun = true;
880
885
  try {
881
886
  if (fs.existsSync(sentinel) && fs.readFileSync(sentinel, 'utf-8').trim() === sentinelValue) {
@@ -130,6 +130,48 @@ export interface AccountInfo {
130
130
  }
131
131
  /** Return the email address associated with the agent's auth config, or null. */
132
132
  export declare function getAccountEmail(agentId: AgentId, home?: string): Promise<string | null>;
133
+ /** Decrypted contents of Droid's auth.v2.file (subset we consume). */
134
+ export interface DroidAuthPayload {
135
+ access_token?: string;
136
+ active_organization_id?: string | null;
137
+ }
138
+ /**
139
+ * Factory Droid stores its OAuth credential encrypted at ~/.factory/auth.v2.file
140
+ * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
141
+ * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
142
+ * device binding — the key is on disk — so we can decrypt locally with no
143
+ * network call. Every failure (missing key file — e.g. a keyring-v2/legacy
144
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
145
+ * Never throws. Shared by account identity below and the Droid usage fetcher
146
+ * in usage.ts.
147
+ */
148
+ export declare function decryptDroidAuthPayload(base: string): DroidAuthPayload | null;
149
+ /**
150
+ * Antigravity (`agy`) stores its OAuth token via the Go keyring library
151
+ * (zalando/go-keyring), which is platform-split:
152
+ *
153
+ * - macOS: login keychain, service `gemini`, account `antigravity` — no file.
154
+ * - Linux with Secret Service (libsecret / gnome-keyring): attributes
155
+ * service=`gemini`, username=`antigravity` (go-keyring's Secret Service
156
+ * mapping of service+user). Prefer this over the file when a keyring
157
+ * daemon is running.
158
+ * - Linux without Secret Service: file fallback at
159
+ * `~/.gemini/antigravity-cli/antigravity-oauth-token`.
160
+ *
161
+ * Probe the OS keyring for existence after the file check. On macOS,
162
+ * `security find-generic-password` without `-w` is metadata-only (never
163
+ * prompts). On Linux, `secret-tool lookup` exit 0 means the item exists
164
+ * (stdout is the secret — discarded, never logged). Cached per process —
165
+ * the keyring is account-global, so one probe covers every installed version.
166
+ * Returns false when the platform has no probe (Windows) or the tool is
167
+ * missing. Guard with `AGENTS_NO_KEYCHAIN_PROBE=1` for hermetic tests.
168
+ */
169
+ export declare function antigravityOsKeyringProbe(platform?: NodeJS.Platform): {
170
+ cmd: string;
171
+ args: string[];
172
+ } | null;
173
+ /** @internal test hook — clear the per-process keyring probe cache. */
174
+ export declare function __resetAntigravityKeychainCacheForTest(): void;
133
175
  export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
134
176
  /**
135
177
  * Determine when the agent was last used by checking session file mtimes,
@@ -147,6 +189,8 @@ export declare function resolveLastActive(agentId: AgentId, base: string, config
147
189
  * Used during init to show approximate session count to user.
148
190
  */
149
191
  export declare function countSessionFiles(agentId: AgentId): number;
192
+ /** Decode the payload section of a JWT token without verifying its signature. */
193
+ export declare function decodeJwtPayload(token: string): Record<string, any> | null;
150
194
  /** Register an MCP server with an agent's CLI via `mcp add`. */
151
195
  export declare function registerMcp(agentId: AgentId, name: string, command: string, scope?: 'user' | 'project', transport?: string, options?: {
152
196
  home?: string;
@@ -984,15 +984,12 @@ function resolveAccountCredentialPath(base, ...segments) {
984
984
  * (AES-256-GCM, format `ivB64:tagB64:ctB64`) with the 32-byte key base64-stored
985
985
  * in ~/.factory/auth.v2.key. On the keyfile-v2 source there is no OS-keychain /
986
986
  * device binding — the key is on disk — so we can decrypt locally with no
987
- * network call. The decrypted JSON credential's `access_token` is a WorkOS JWT
988
- * carrying an `email` claim (plus org_id / role). We decode the claim WITHOUT
989
- * verifying `exp`: the email is stable identity for display, not an
990
- * authorization decision, so an expired token still yields the right address.
991
- * Every failure (missing key file — e.g. a keyring-v2/legacy login with no
992
- * on-disk key, a bad GCM tag, malformed JSON, or no email claim) returns null so
993
- * the caller falls back to the file-presence signed-in signal. Never throws.
987
+ * network call. Every failure (missing key file e.g. a keyring-v2/legacy
988
+ * login with no on-disk key, a bad GCM tag, or malformed JSON) returns null.
989
+ * Never throws. Shared by account identity below and the Droid usage fetcher
990
+ * in usage.ts.
994
991
  */
995
- function decryptDroidCredential(base) {
992
+ export function decryptDroidAuthPayload(base) {
996
993
  const filePath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.file');
997
994
  const keyPath = resolveAccountCredentialPath(base, '.factory', 'auth.v2.key');
998
995
  if (!filePath || !keyPath)
@@ -1012,46 +1009,97 @@ function decryptDroidCredential(base) {
1012
1009
  decipher.final(),
1013
1010
  ]).toString('utf-8');
1014
1011
  const cred = JSON.parse(plaintext);
1015
- const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1016
- if (!claims)
1017
- return null;
1018
- return {
1019
- email: typeof claims.email === 'string' ? claims.email : null,
1020
- orgId: normalizeIdentityPart(claims.org_id ?? cred.active_organization_id),
1021
- role: typeof claims.role === 'string' ? claims.role : null,
1022
- };
1012
+ return cred && typeof cred === 'object' ? cred : null;
1023
1013
  }
1024
1014
  catch {
1025
1015
  return null;
1026
1016
  }
1027
1017
  }
1018
+ /**
1019
+ * Derive Droid account identity from the decrypted credential. The
1020
+ * `access_token` is a WorkOS JWT carrying an `email` claim (plus org_id /
1021
+ * role). We decode the claim WITHOUT verifying `exp`: the email is stable
1022
+ * identity for display, not an authorization decision, so an expired token
1023
+ * still yields the right address. Returns null when the credential can't be
1024
+ * decrypted or has no decodable claims, so the caller falls back to the
1025
+ * file-presence signed-in signal.
1026
+ */
1027
+ function decryptDroidCredential(base) {
1028
+ const cred = decryptDroidAuthPayload(base);
1029
+ const claims = typeof cred?.access_token === 'string' ? decodeJwtPayload(cred.access_token) : null;
1030
+ if (!claims)
1031
+ return null;
1032
+ return {
1033
+ email: typeof claims.email === 'string' ? claims.email : null,
1034
+ orgId: normalizeIdentityPart(claims.org_id ?? cred?.active_organization_id),
1035
+ role: typeof claims.role === 'string' ? claims.role : null,
1036
+ };
1037
+ }
1028
1038
  let cachedAgyKeychainSignedIn;
1029
1039
  /**
1030
- * Antigravity (`agy`, a Codeium/Windsurf-based CLI) stores its OAuth token in
1031
- * the macOS keychain — service `gemini`, account `antigravity` — NOT a file.
1032
- * The file path (`antigravity-oauth-token`) only exists on Linux, where the Go
1033
- * keyring falls back to disk. Probe the keychain for existence (metadata only;
1034
- * `-w` omitted so it never prompts). Cached per process — the keychain is
1035
- * account-global, so one probe covers every installed version. Returns false on
1036
- * non-macOS (the file path handles those).
1040
+ * Antigravity (`agy`) stores its OAuth token via the Go keyring library
1041
+ * (zalando/go-keyring), which is platform-split:
1042
+ *
1043
+ * - macOS: login keychain, service `gemini`, account `antigravity` no file.
1044
+ * - Linux with Secret Service (libsecret / gnome-keyring): attributes
1045
+ * service=`gemini`, username=`antigravity` (go-keyring's Secret Service
1046
+ * mapping of service+user). Prefer this over the file when a keyring
1047
+ * daemon is running.
1048
+ * - Linux without Secret Service: file fallback at
1049
+ * `~/.gemini/antigravity-cli/antigravity-oauth-token`.
1050
+ *
1051
+ * Probe the OS keyring for existence after the file check. On macOS,
1052
+ * `security find-generic-password` without `-w` is metadata-only (never
1053
+ * prompts). On Linux, `secret-tool lookup` exit 0 means the item exists
1054
+ * (stdout is the secret — discarded, never logged). Cached per process —
1055
+ * the keyring is account-global, so one probe covers every installed version.
1056
+ * Returns false when the platform has no probe (Windows) or the tool is
1057
+ * missing. Guard with `AGENTS_NO_KEYCHAIN_PROBE=1` for hermetic tests.
1037
1058
  */
1059
+ export function antigravityOsKeyringProbe(platform = process.platform) {
1060
+ if (platform === 'darwin') {
1061
+ return {
1062
+ cmd: 'security',
1063
+ args: ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'],
1064
+ };
1065
+ }
1066
+ if (platform === 'linux') {
1067
+ // go-keyring secret_service attributes: "service" + "username" (not
1068
+ // "account" — that flag is the macOS security(1) spelling of the same user).
1069
+ return {
1070
+ cmd: 'secret-tool',
1071
+ args: ['lookup', 'service', 'gemini', 'username', 'antigravity'],
1072
+ };
1073
+ }
1074
+ return null;
1075
+ }
1076
+ /** @internal test hook — clear the per-process keyring probe cache. */
1077
+ export function __resetAntigravityKeychainCacheForTest() {
1078
+ cachedAgyKeychainSignedIn = undefined;
1079
+ }
1038
1080
  async function antigravityKeychainSignedIn() {
1039
- if (cachedAgyKeychainSignedIn !== undefined)
1040
- return cachedAgyKeychainSignedIn;
1041
- // Test isolation: the real macOS keychain can't be sandboxed per-test, so
1042
- // allow suites asserting "signed out" to opt out of the probe (same spirit as
1043
- // AGENTS_REAL_HOME). Not cached, so tests can toggle it.
1081
+ // Test isolation first (before cache): real OS keyrings can't be sandboxed
1082
+ // per-test. Same spirit as AGENTS_REAL_HOME. Not cached, so tests can toggle.
1044
1083
  if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
1045
1084
  return false;
1046
- if (process.platform !== 'darwin') {
1085
+ if (cachedAgyKeychainSignedIn !== undefined)
1086
+ return cachedAgyKeychainSignedIn;
1087
+ const probe = antigravityOsKeyringProbe();
1088
+ if (!probe) {
1047
1089
  cachedAgyKeychainSignedIn = false;
1048
1090
  return false;
1049
1091
  }
1050
1092
  try {
1051
- await execFileAsync('security', ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'], { timeout: 3000 });
1093
+ // Discard stdout: Linux secret-tool lookup prints the secret value.
1094
+ await execFileAsync(probe.cmd, probe.args, {
1095
+ timeout: 3000,
1096
+ // encoding so stdout is a string we can drop without ever logging it
1097
+ encoding: 'utf8',
1098
+ });
1052
1099
  cachedAgyKeychainSignedIn = true;
1053
1100
  }
1054
1101
  catch {
1102
+ // Missing tool (ENOENT), missing item, locked collection, timeout → signed out.
1055
1103
  cachedAgyKeychainSignedIn = false;
1056
1104
  }
1057
1105
  return cachedAgyKeychainSignedIn;
@@ -1282,10 +1330,12 @@ export async function getAccountInfo(agentId, home) {
1282
1330
  // Antigravity (`agy`) stores a consumer Google OAuth grant (access +
1283
1331
  // refresh token, no id_token) — presence of a refresh token is the only
1284
1332
  // signed-in signal we can derive without a network call. Storage is
1285
- // platform-split: on Linux it's a file at
1286
- // ~/.gemini/antigravity-cli/antigravity-oauth-token; on macOS the Go
1287
- // keyring puts it in the keychain (service 'gemini', account
1288
- // 'antigravity'), so no file exists check both.
1333
+ // platform-split via go-keyring:
1334
+ // - file ~/.gemini/antigravity-cli/antigravity-oauth-token (Linux
1335
+ // fallback when no Secret Service is available)
1336
+ // - macOS keychain / Linux libsecret (service gemini + user
1337
+ // antigravity) when a keyring daemon is present
1338
+ // Check the file first, then the OS keyring probe.
1289
1339
  const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
1290
1340
  if (tokenPath) {
1291
1341
  const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
@@ -1524,7 +1574,7 @@ export function countSessionFiles(agentId) {
1524
1574
  return count;
1525
1575
  }
1526
1576
  /** Decode the payload section of a JWT token without verifying its signature. */
1527
- function decodeJwtPayload(token) {
1577
+ export function decodeJwtPayload(token) {
1528
1578
  const payload = token.split('.')[1];
1529
1579
  if (!payload)
1530
1580
  return null;
@@ -9,7 +9,7 @@ import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
9
9
  // is the shared hardened baseline (BatchMode + ConnectTimeout + keepalive) —
10
10
  // reuse it so the raw-ssh spawns below fail fast on an unreachable host instead
11
11
  // of hanging on the default ~127s TCP timeout, rather than re-listing options.
12
- import { shellQuote, SSH_OPTS } from '../../ssh-exec.js';
12
+ import { shellQuote, SSH_OPTS, assertValidSshTarget } from '../../ssh-exec.js';
13
13
  export { shellQuote };
14
14
  // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
15
15
  // the single ssh-tunnel helper. Calling it with no options preserves this
@@ -292,6 +292,12 @@ export function buildKillCmd(remoteOs, port) {
292
292
  return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
293
293
  }
294
294
  export async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
295
+ // `user`/`host` derive from an ssh:// profile endpoint (git-tracked user
296
+ // config). Validate the composed target before it reaches the raw `ssh` spawn:
297
+ // an endpoint like `ssh://-Fattack@victim` would otherwise place `-Fattack` at
298
+ // the target position, where OpenSSH parses it as `-F <file>` and an
299
+ // attacker-supplied ssh config's ProxyCommand yields local code execution.
300
+ assertValidSshTarget(`${user}@${host}`);
295
301
  const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
296
302
  return new Promise((resolve, reject) => {
297
303
  // Capture stderr so a real launch failure (missing exe, auth denied, bad
@@ -362,7 +368,18 @@ export function killRemoteBrowser(user, host, remoteOs, port) {
362
368
  return runSSHCommand(user, host, buildKillCmd(remoteOs, port));
363
369
  }
364
370
  function runSSHCommand(user, host, cmd) {
365
- return new Promise((resolve) => {
371
+ return new Promise((resolve, reject) => {
372
+ // Reject an option-injecting target (e.g. a `-`-leading user from a crafted
373
+ // ssh:// profile) before it reaches the raw `ssh` spawn. Reject rather than
374
+ // throw synchronously so `killRemoteBrowser(...).catch()` stays best-effort.
375
+ // See ensureRemoteBrowser.
376
+ try {
377
+ assertValidSshTarget(`${user}@${host}`);
378
+ }
379
+ catch (err) {
380
+ reject(err);
381
+ return;
382
+ }
366
383
  // Reuse the shared hardened baseline so a hung TCP SYN to an unreachable
367
384
  // host is bounded by ConnectTimeout (the local 3s kill below only bounds a
368
385
  // connected-but-slow command, not the connect itself). Options precede the
@@ -432,11 +432,12 @@ export class BrowserIPCServer {
432
432
  return { ok: true };
433
433
  }
434
434
  case 'set-download-path': {
435
- if (!request.task || !request.downloadPath) {
436
- return { ok: false, error: 'Task and download path required' };
435
+ if (!request.task) {
436
+ return { ok: false, error: 'Task required' };
437
437
  }
438
- await this.service.setDownloadPath(request.task, request.downloadPath, request.tabId);
439
- return { ok: true };
438
+ // downloadPath optional: omitted → service defaults to the profile's downloads dir.
439
+ const resolved = await this.service.setDownloadPath(request.task, request.downloadPath, request.tabId);
440
+ return { ok: true, downloadPath: resolved };
440
441
  }
441
442
  case 'wait-download': {
442
443
  if (!request.task) {
@@ -11,6 +11,19 @@ export declare const DEFAULT_BROWSER_PROFILE_NAME = "default";
11
11
  export declare function getConfiguredDefaultProfileName(): string | undefined;
12
12
  export declare function getBrowserRuntimeDir(): string;
13
13
  export declare function getProfileRuntimeDir(name: string): string;
14
+ /**
15
+ * Default destination for browser downloads for a profile. Set browser-global at
16
+ * connect time (see BrowserService), so downloads land here even when the agent
17
+ * never calls `browser download --path`. Keyed by the same composite name as the
18
+ * runtime dir, so every profile is one self-contained tree.
19
+ */
20
+ export declare function getProfileDownloadsDir(name: string): string;
21
+ /**
22
+ * Per-profile directory for a task's captured artifacts (screenshots, PDFs,
23
+ * recordings). Replaces the legacy global `browser/sessions/<task>/` root — the
24
+ * one-shot migration in migrate.ts folds old captures into here.
25
+ */
26
+ export declare function getProfileSessionsDir(name: string, task: string): string;
14
27
  export declare function listProfiles(): Promise<BrowserProfile[]>;
15
28
  export declare function getProfile(name: string): Promise<BrowserProfile | null>;
16
29
  /**
@@ -19,6 +19,23 @@ export function getBrowserRuntimeDir() {
19
19
  export function getProfileRuntimeDir(name) {
20
20
  return path.join(getBrowserRuntimeDir(), name);
21
21
  }
22
+ /**
23
+ * Default destination for browser downloads for a profile. Set browser-global at
24
+ * connect time (see BrowserService), so downloads land here even when the agent
25
+ * never calls `browser download --path`. Keyed by the same composite name as the
26
+ * runtime dir, so every profile is one self-contained tree.
27
+ */
28
+ export function getProfileDownloadsDir(name) {
29
+ return path.join(getProfileRuntimeDir(name), 'downloads');
30
+ }
31
+ /**
32
+ * Per-profile directory for a task's captured artifacts (screenshots, PDFs,
33
+ * recordings). Replaces the legacy global `browser/sessions/<task>/` root — the
34
+ * one-shot migration in migrate.ts folds old captures into here.
35
+ */
36
+ export function getProfileSessionsDir(name, task) {
37
+ return path.join(getProfileRuntimeDir(name), 'sessions', task);
38
+ }
22
39
  function configToProfile(name, config) {
23
40
  validateRemoteBrowserBinaries(config);
24
41
  return {
@@ -226,7 +226,18 @@ export declare class BrowserService {
226
226
  timeout?: number;
227
227
  tabHint?: string;
228
228
  }): Promise<void>;
229
- setDownloadPath(taskId: string, downloadPath: string, tabHint?: string): Promise<void>;
229
+ /**
230
+ * Point the browser's default download destination at the profile's downloads
231
+ * dir, browser-global, at connect time. Without this a download the agent never
232
+ * explicitly routed (`browser download --path`) falls to Chromium's own default
233
+ * — for an attached user browser, wherever that browser was last configured,
234
+ * which is how downloads used to escape into random locations. Sent on the root
235
+ * session (no sessionId) so every current and future tab inherits it. Best
236
+ * effort: a remote CDP endpoint that doesn't expose the Browser domain must not
237
+ * fail the whole connect.
238
+ */
239
+ private applyDefaultDownloadBehavior;
240
+ setDownloadPath(taskId: string, downloadPath?: string, tabHint?: string): Promise<string>;
230
241
  waitForDownload(taskId: string, timeout?: number): Promise<string>;
231
242
  private findTaskBySession;
232
243
  shutdown(): Promise<void>;