@phnx-labs/agents-cli 1.20.32 → 1.20.33

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 (64) hide show
  1. package/dist/commands/commands.js +3 -3
  2. package/dist/commands/computer-actions.js +1 -0
  3. package/dist/commands/cost.js +2 -2
  4. package/dist/commands/doctor.js +2 -2
  5. package/dist/commands/exec.js +56 -1
  6. package/dist/commands/hooks.js +3 -3
  7. package/dist/commands/inspect.js +13 -17
  8. package/dist/commands/mcp.js +3 -3
  9. package/dist/commands/permissions.js +3 -3
  10. package/dist/commands/rules.js +2 -2
  11. package/dist/commands/sessions.js +18 -1
  12. package/dist/commands/skills.js +3 -3
  13. package/dist/commands/sync.js +2 -2
  14. package/dist/commands/teams.js +7 -12
  15. package/dist/commands/usage.js +2 -2
  16. package/dist/commands/utils.d.ts +8 -0
  17. package/dist/commands/utils.js +20 -0
  18. package/dist/commands/versions.js +2 -2
  19. package/dist/commands/view.js +23 -9
  20. package/dist/commands/workflows.js +3 -3
  21. package/dist/index.js +12 -0
  22. package/dist/lib/agent-spec/index.d.ts +18 -0
  23. package/dist/lib/agent-spec/index.js +35 -0
  24. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  25. package/dist/lib/agent-spec/primitives.js +57 -0
  26. package/dist/lib/agent-spec/provider.d.ts +2 -0
  27. package/dist/lib/agent-spec/provider.js +9 -0
  28. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  29. package/dist/lib/agent-spec/resolve.js +174 -0
  30. package/dist/lib/agent-spec/types.d.ts +57 -0
  31. package/dist/lib/agent-spec/types.js +18 -0
  32. package/dist/lib/crabbox/cli.d.ts +98 -0
  33. package/dist/lib/crabbox/cli.js +218 -0
  34. package/dist/lib/crabbox/lease.d.ts +41 -0
  35. package/dist/lib/crabbox/lease.js +73 -0
  36. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  37. package/dist/lib/crabbox/runtimes.js +109 -0
  38. package/dist/lib/hosts/dispatch.d.ts +27 -10
  39. package/dist/lib/hosts/dispatch.js +55 -19
  40. package/dist/lib/hosts/option.d.ts +14 -0
  41. package/dist/lib/hosts/option.js +19 -0
  42. package/dist/lib/hosts/passthrough.d.ts +30 -0
  43. package/dist/lib/hosts/passthrough.js +141 -0
  44. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  45. package/dist/lib/hosts/remote-cmd.js +56 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/secrets/bundles.js +29 -20
  48. package/dist/lib/secrets/index.d.ts +11 -0
  49. package/dist/lib/secrets/index.js +18 -1
  50. package/dist/lib/secrets/linux.d.ts +14 -0
  51. package/dist/lib/secrets/linux.js +21 -0
  52. package/dist/lib/session/active.d.ts +8 -0
  53. package/dist/lib/session/active.js +18 -1
  54. package/dist/lib/session/provenance.d.ts +56 -0
  55. package/dist/lib/session/provenance.js +157 -0
  56. package/dist/lib/ssh-exec.d.ts +22 -0
  57. package/dist/lib/ssh-exec.js +59 -2
  58. package/dist/lib/ssh-tunnel.d.ts +0 -5
  59. package/dist/lib/ssh-tunnel.js +65 -8
  60. package/dist/lib/versions.d.ts +2 -4
  61. package/dist/lib/versions.js +7 -20
  62. package/package.json +2 -1
  63. package/dist/lib/agent-spec.d.ts +0 -36
  64. package/dist/lib/agent-spec.js +0 -157
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared `--host` option registrar. Every command that can run on a remote host
3
+ * declares the flag through here, so its spelling, help text, and companions
4
+ * (`--remote-cwd`, `--no-tty`, `--any`) stay identical everywhere and show up in
5
+ * each command's `--help`.
6
+ *
7
+ * The flags are consumed centrally by `maybeRunOnHost` (passthrough.ts) *before*
8
+ * commander parses, so for a real remote run the local action never sees them.
9
+ * Registering them here still matters: it documents the flag and keeps the local
10
+ * fall-through (e.g. `--host <this-machine>`) from erroring on an unknown option.
11
+ */
12
+ /** Attach the standard `--host` flag family to a command and return it (chainable). */
13
+ export function addHostOption(cmd) {
14
+ return cmd
15
+ .option('-H, --host <name>', 'Run this command on a registered host (or user@host) over SSH instead of locally. See `agents hosts`.')
16
+ .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
+ .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
18
+ .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
19
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Generic `--host` passthrough — the single choke point that runs an allowlisted
3
+ * `agents <command>` on a remote host instead of locally, so read-only and
4
+ * config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
5
+ * and the team lifecycle (`teams …`) all gain remote support with no per-command
6
+ * code. Called once from `index.ts` before commander parses; returns `true` when
7
+ * it handled the invocation (the local command must then NOT run).
8
+ *
9
+ * Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
10
+ * choke point already used everywhere, and it gives auth + encryption + host-key
11
+ * trust for free. Read-only commands stream synchronously (`sshStream`); the one
12
+ * long-running case — `teams start --watch` — dispatches detached so the remote
13
+ * supervisor outlives a dropped connection.
14
+ *
15
+ * `run` and `sessions` are deliberately absent from the table below: they own
16
+ * richer `--host` handling in their own command actions (detached run dispatch;
17
+ * multi-host session fan-out) and must fall through to it.
18
+ */
19
+ /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
20
+ export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
21
+ /**
22
+ * Route `agents <command> … --host <name>` to a remote if the command is
23
+ * host-routable and a `--host` was given. Returns `false` (run locally) when
24
+ * there is no `--host`, the command isn't in the table, or the target is this
25
+ * very machine.
26
+ *
27
+ * @param command the resolved subcommand name (`process.argv`'s first non-flag).
28
+ * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
29
+ */
30
+ export declare function maybeRunOnHost(command: string, allArgs: string[]): Promise<boolean>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Generic `--host` passthrough — the single choke point that runs an allowlisted
3
+ * `agents <command>` on a remote host instead of locally, so read-only and
4
+ * config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
5
+ * and the team lifecycle (`teams …`) all gain remote support with no per-command
6
+ * code. Called once from `index.ts` before commander parses; returns `true` when
7
+ * it handled the invocation (the local command must then NOT run).
8
+ *
9
+ * Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
10
+ * choke point already used everywhere, and it gives auth + encryption + host-key
11
+ * trust for free. Read-only commands stream synchronously (`sshStream`); the one
12
+ * long-running case — `teams start --watch` — dispatches detached so the remote
13
+ * supervisor outlives a dropped connection.
14
+ *
15
+ * `run` and `sessions` are deliberately absent from the table below: they own
16
+ * richer `--host` handling in their own command actions (detached run dispatch;
17
+ * multi-host session fan-out) and must fall through to it.
18
+ */
19
+ import chalk from 'chalk';
20
+ import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
+ import { resolveHost, resolveHostByCap } from './registry.js';
22
+ import { sshTargetFor } from './types.js';
23
+ import { dispatchAgentsCommand } from './dispatch.js';
24
+ import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
+ import { machineId } from '../session/sync/config.js';
26
+ const REMOTE_PASSTHROUGH = {
27
+ view: {},
28
+ usage: {},
29
+ cost: {},
30
+ doctor: {},
31
+ inspect: {},
32
+ list: {},
33
+ sync: { nonInteractive: ['--yes'] },
34
+ teams: {},
35
+ };
36
+ /** `--no-tty` is stripped like the routing flags but carries no value. */
37
+ const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
38
+ /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
39
+ export function flagValue(args, long, short) {
40
+ for (let i = 0; i < args.length; i++) {
41
+ const a = args[i];
42
+ if (a === `--${long}` || (short && a === `-${short}`))
43
+ return args[i + 1];
44
+ if (a.startsWith(`--${long}=`))
45
+ return a.slice(long.length + 3);
46
+ if (short && a.startsWith(`-${short}=`))
47
+ return a.slice(short.length + 2);
48
+ if (short && new RegExp(`^-${short}(.+)`).test(a))
49
+ return a.slice(2);
50
+ }
51
+ return undefined;
52
+ }
53
+ /** Synthesize a `Host` for a raw `user@host` / bare-alias target (not enrolled). */
54
+ function syntheticHost(target) {
55
+ const at = target.indexOf('@');
56
+ if (at !== -1) {
57
+ return { name: target, provider: 'local', source: 'inline', user: target.slice(0, at), address: target.slice(at + 1) };
58
+ }
59
+ // Bare name: ssh resolves it from ~/.ssh/config, or connects to it as a hostname.
60
+ return { name: target, provider: 'local', source: 'ssh-config' };
61
+ }
62
+ /** Resolve a `--host` value to a Host: enrolled name → capability tag → raw target. */
63
+ async function resolveTargetHost(name, any) {
64
+ const enrolled = await resolveHost(name);
65
+ if (enrolled)
66
+ return enrolled;
67
+ try {
68
+ return await resolveHostByCap(name, any);
69
+ }
70
+ catch (e) {
71
+ // "Multiple hosts tagged …" is actionable — surface it. "No host tagged" falls
72
+ // through to treating the value as a literal ssh target.
73
+ if (e instanceof Error && e.message.startsWith('Multiple hosts'))
74
+ throw e;
75
+ }
76
+ assertValidSshTarget(name); // rejects injection / flag-smuggling before it reaches ssh
77
+ return syntheticHost(name);
78
+ }
79
+ /**
80
+ * Route `agents <command> … --host <name>` to a remote if the command is
81
+ * host-routable and a `--host` was given. Returns `false` (run locally) when
82
+ * there is no `--host`, the command isn't in the table, or the target is this
83
+ * very machine.
84
+ *
85
+ * @param command the resolved subcommand name (`process.argv`'s first non-flag).
86
+ * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
87
+ */
88
+ export async function maybeRunOnHost(command, allArgs) {
89
+ const spec = REMOTE_PASSTHROUGH[command];
90
+ if (!spec)
91
+ return false;
92
+ const hostName = flagValue(allArgs, 'host', 'H');
93
+ if (!hostName)
94
+ return false;
95
+ // Running against your own machine is just a local run — skip the SSH round-trip.
96
+ // `machineId()` is the same self-identifier the device registry and session
97
+ // sync use (lowercased short hostname); compare case-insensitively.
98
+ if (hostName.toLowerCase() === machineId())
99
+ return false;
100
+ const remoteCwd = flagValue(allArgs, 'remote-cwd');
101
+ const any = allArgs.includes('--any');
102
+ let host;
103
+ try {
104
+ host = await resolveTargetHost(hostName, any);
105
+ }
106
+ catch (e) {
107
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
108
+ process.exitCode = 1;
109
+ return true;
110
+ }
111
+ const target = sshTargetFor(host);
112
+ // Interactive only when our own stdout is a terminal and the caller didn't opt
113
+ // out — otherwise force the command's non-interactive path so no half-drawn
114
+ // picker is piped into a file or another program.
115
+ const interactive = !!process.stdout.isTTY && !allArgs.includes('--no-tty');
116
+ let forwarded = stripRoutingFlags(allArgs, STRIP_SPECS);
117
+ if (!interactive && spec.nonInteractive)
118
+ forwarded = [...forwarded, ...spec.nonInteractive];
119
+ // The one long-running case: keep the remote team supervisor alive past a
120
+ // disconnect by dispatching it detached (nohup), still streaming live.
121
+ const isWatchedTeamStart = command === 'teams' && forwarded[1] === 'start' && forwarded.includes('--watch');
122
+ if (isWatchedTeamStart) {
123
+ try {
124
+ const { exitCode } = await dispatchAgentsCommand(host, { forwardedArgs: forwarded, remoteCwd });
125
+ process.exitCode = exitCode && exitCode > 0 ? exitCode : 0;
126
+ }
127
+ catch (e) {
128
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
129
+ process.exitCode = 1;
130
+ }
131
+ return true;
132
+ }
133
+ const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd);
134
+ const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
135
+ if (code === 255) {
136
+ console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
137
+ chalk.gray(' Check: agents hosts check ' + host.name));
138
+ }
139
+ process.exitCode = code;
140
+ return true;
141
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pure argv helpers for `--host` passthrough — build the remote `agents …`
3
+ * invocation and strip the local-only routing flags before forwarding.
4
+ *
5
+ * Kept free of any SSH/process side effects so the two-layer quoting and the
6
+ * flag-stripping edge cases (glued short forms, `=value`, variadic) are unit
7
+ * testable without a live host. The transport itself lives in `ssh-exec.ts`
8
+ * (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
9
+ */
10
+ /** A flag to strip from a forwarded argv, with whether it consumes a value. */
11
+ export interface StripSpec {
12
+ /** Long form without leading dashes, e.g. `host`, `remote-cwd`. */
13
+ long: string;
14
+ /** Optional single-letter short form without the dash, e.g. `H`. */
15
+ short?: string;
16
+ /** True when the flag takes a following value token (`--host <name>`). */
17
+ takesValue: boolean;
18
+ }
19
+ /**
20
+ * Remove routing flags (and their values) from a command's args, leaving the
21
+ * rest untouched and in order so they forward verbatim to the remote binary.
22
+ * Handles every form commander accepts: `--host h`, `--host=h`, `-H h`, `-H=h`,
23
+ * and the glued short form `-Hh`.
24
+ *
25
+ * @param args the command's args (already past the command name).
26
+ */
27
+ export declare function stripRoutingFlags(args: string[], specs: StripSpec[]): string[];
28
+ /** The routing flags every `--host`-capable command shares. */
29
+ export declare const HOST_ROUTING_SPECS: StripSpec[];
30
+ /**
31
+ * Build the single command string for `ssh <target> <cmd>`. The forwarded args
32
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
33
+ * quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
34
+ * PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
35
+ */
36
+ export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure argv helpers for `--host` passthrough — build the remote `agents …`
3
+ * invocation and strip the local-only routing flags before forwarding.
4
+ *
5
+ * Kept free of any SSH/process side effects so the two-layer quoting and the
6
+ * flag-stripping edge cases (glued short forms, `=value`, variadic) are unit
7
+ * testable without a live host. The transport itself lives in `ssh-exec.ts`
8
+ * (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
9
+ */
10
+ import { shellQuote } from '../ssh-exec.js';
11
+ /**
12
+ * Remove routing flags (and their values) from a command's args, leaving the
13
+ * rest untouched and in order so they forward verbatim to the remote binary.
14
+ * Handles every form commander accepts: `--host h`, `--host=h`, `-H h`, `-H=h`,
15
+ * and the glued short form `-Hh`.
16
+ *
17
+ * @param args the command's args (already past the command name).
18
+ */
19
+ export function stripRoutingFlags(args, specs) {
20
+ const out = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ const a = args[i];
23
+ const spec = specs.find((s) => {
24
+ if (a === `--${s.long}` || a.startsWith(`--${s.long}=`))
25
+ return true;
26
+ if (s.short && (a === `-${s.short}` || a.startsWith(`-${s.short}=`) || new RegExp(`^-${s.short}.+`).test(a)))
27
+ return true;
28
+ return false;
29
+ });
30
+ if (!spec) {
31
+ out.push(a);
32
+ continue;
33
+ }
34
+ // Consume a separate value token only for the exact-match (space-separated) forms.
35
+ const isExact = a === `--${spec.long}` || (spec.short && a === `-${spec.short}`);
36
+ if (spec.takesValue && isExact && i + 1 < args.length)
37
+ i++;
38
+ }
39
+ return out;
40
+ }
41
+ /** The routing flags every `--host`-capable command shares. */
42
+ export const HOST_ROUTING_SPECS = [
43
+ { long: 'host', short: 'H', takesValue: true },
44
+ { long: 'remote-cwd', takesValue: true },
45
+ ];
46
+ /**
47
+ * Build the single command string for `ssh <target> <cmd>`. The forwarded args
48
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
49
+ * quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
50
+ * PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
51
+ */
52
+ export function buildRemoteAgentsInvocation(forwardedArgs, remoteCwd) {
53
+ const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
54
+ const withCwd = remoteCwd ? `cd ${shellQuote(remoteCwd)} && ${inner}` : inner;
55
+ return `bash -lc ${shellQuote(withCwd)}`;
56
+ }
@@ -21,7 +21,7 @@ import * as fs from 'fs';
21
21
  import * as os from 'os';
22
22
  import * as path from 'path';
23
23
  import * as yaml from 'yaml';
24
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
24
+ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, keychainUsesFileFallback, listKeychainItems, parseBundleValue, resolveRef, secretsKeychainItem, setKeychainToken, } from './index.js';
25
25
  import { fileStore } from './filestore.js';
26
26
  import { emit } from '../events.js';
27
27
  import { readMeta } from '../state.js';
@@ -379,25 +379,34 @@ export function listBundles() {
379
379
  // prompt instead of N. Bundle metadata items carry user-presence ACLs (same
380
380
  // as secret values), so a naive loop over readBundle() spawns a fresh
381
381
  // LAContext per item — meaning N biometric prompts for `secrets list`.
382
- let keychainServices = [];
383
- try {
384
- keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
385
- }
386
- catch {
387
- keychainServices = [];
388
- }
389
- const keychainNames = keychainServices
390
- .map((s) => s.slice(BUNDLE_META_PREFIX.length))
391
- .filter((n) => BUNDLE_NAME_PATTERN.test(n));
392
- if (keychainNames.length > 0) {
393
- const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
394
- for (const name of keychainNames) {
395
- const json = fetched.get(bundleMetaItem(name));
396
- if (json === undefined)
397
- continue;
398
- const bundle = parseBundleMeta(name, json, 'keychain');
399
- if (bundle)
400
- out.push(bundle);
382
+ //
383
+ // SKIP this entirely when the keychain backend is routing to the encrypted
384
+ // file store (Linux headless / locked-collection fallback): there,
385
+ // listKeychainItems() returns the SAME items the file enumeration below
386
+ // reads, so running both would list every file-backed bundle twice — once
387
+ // mislabeled `keychain`, once correctly `[file]`. Under the fallback the
388
+ // file store is the single source of truth, so the block below covers all.
389
+ if (!keychainUsesFileFallback()) {
390
+ let keychainServices = [];
391
+ try {
392
+ keychainServices = listKeychainItems(BUNDLE_META_PREFIX);
393
+ }
394
+ catch {
395
+ keychainServices = [];
396
+ }
397
+ const keychainNames = keychainServices
398
+ .map((s) => s.slice(BUNDLE_META_PREFIX.length))
399
+ .filter((n) => BUNDLE_NAME_PATTERN.test(n));
400
+ if (keychainNames.length > 0) {
401
+ const fetched = getKeychainTokens(keychainNames.map(bundleMetaItem));
402
+ for (const name of keychainNames) {
403
+ const json = fetched.get(bundleMetaItem(name));
404
+ if (json === undefined)
405
+ continue;
406
+ const bundle = parseBundleMeta(name, json, 'keychain');
407
+ if (bundle)
408
+ out.push(bundle);
409
+ }
401
410
  }
402
411
  }
403
412
  // File-backed bundles live in the encrypted-file store. Enumeration is a
@@ -86,6 +86,17 @@ export declare function getKeychainTokens(items: string[]): Map<string, string>;
86
86
  export declare function setKeychainToken(item: string, value: string): void;
87
87
  /** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
88
88
  export declare function deleteKeychainToken(item: string): boolean;
89
+ /**
90
+ * True when the active keychain backend transparently routes reads/writes to
91
+ * the encrypted-file store instead of the OS credential store. This only
92
+ * happens on Linux under the headless / locked-collection fallback
93
+ * (src/lib/secrets/linux.ts); macOS and the test backend always return false.
94
+ *
95
+ * Callers that ALSO enumerate the file store directly (e.g. `listBundles`)
96
+ * use this to avoid double-counting: under the fallback `listKeychainItems`
97
+ * and the direct file enumeration return the same items.
98
+ */
99
+ export declare function keychainUsesFileFallback(): boolean;
89
100
  /** Enumerate keychain/keyring item names starting with the given prefix. */
90
101
  export declare function listKeychainItems(prefix: string): string[];
91
102
  /**
@@ -23,7 +23,7 @@ import { execFileSync, spawnSync } from 'child_process';
23
23
  import * as fs from 'fs';
24
24
  import * as os from 'os';
25
25
  import * as path from 'path';
26
- import { linuxBackend } from './linux.js';
26
+ import { linuxBackend, usesFileFallback as linuxUsesFileFallback } from './linux.js';
27
27
  import { getKeychainHelperPath } from './install-helper.js';
28
28
  const SERVICE_PREFIX = 'agents-cli';
29
29
  const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
@@ -278,6 +278,23 @@ export function deleteKeychainToken(item) {
278
278
  stdio: ['ignore', 'pipe', 'pipe'],
279
279
  }).status === 0;
280
280
  }
281
+ /**
282
+ * True when the active keychain backend transparently routes reads/writes to
283
+ * the encrypted-file store instead of the OS credential store. This only
284
+ * happens on Linux under the headless / locked-collection fallback
285
+ * (src/lib/secrets/linux.ts); macOS and the test backend always return false.
286
+ *
287
+ * Callers that ALSO enumerate the file store directly (e.g. `listBundles`)
288
+ * use this to avoid double-counting: under the fallback `listKeychainItems`
289
+ * and the direct file enumeration return the same items.
290
+ */
291
+ export function keychainUsesFileFallback() {
292
+ if (backend)
293
+ return false;
294
+ if (isLinux())
295
+ return linuxUsesFileFallback();
296
+ return false;
297
+ }
281
298
  /** Enumerate keychain/keyring item names starting with the given prefix. */
282
299
  export function listKeychainItems(prefix) {
283
300
  if (backend)
@@ -18,6 +18,20 @@
18
18
  */
19
19
  import type { KeychainBackend } from './index.js';
20
20
  export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
21
+ /**
22
+ * True when secret operations currently route to the encrypted-file store
23
+ * instead of the Secret Service (the headless / locked-collection fallback).
24
+ *
25
+ * Runs the same `preflight()` decision every read and write uses, so it can't
26
+ * drift from where bytes actually land. `preflight()` throws only in the
27
+ * interactive / no-secret-tool / no-passphrase case — where nothing is stored
28
+ * — so treat that as "not on the file path".
29
+ *
30
+ * `listBundles()` needs this: under the fallback the keychain enumeration
31
+ * (`linuxBackend.list`) and the file enumeration read the SAME store, so
32
+ * without this signal every file-backed bundle would be listed twice.
33
+ */
34
+ export declare function usesFileFallback(): boolean;
21
35
  /** secret-tool lookup attributes:
22
36
  * service=agents-cli account=<user> item=<itemName> */
23
37
  export declare function hasSecretToolToken(item: string): boolean;
@@ -86,6 +86,27 @@ function preflight() {
86
86
  }
87
87
  return 'secret-tool';
88
88
  }
89
+ /**
90
+ * True when secret operations currently route to the encrypted-file store
91
+ * instead of the Secret Service (the headless / locked-collection fallback).
92
+ *
93
+ * Runs the same `preflight()` decision every read and write uses, so it can't
94
+ * drift from where bytes actually land. `preflight()` throws only in the
95
+ * interactive / no-secret-tool / no-passphrase case — where nothing is stored
96
+ * — so treat that as "not on the file path".
97
+ *
98
+ * `listBundles()` needs this: under the fallback the keychain enumeration
99
+ * (`linuxBackend.list`) and the file enumeration read the SAME store, so
100
+ * without this signal every file-backed bundle would be listed twice.
101
+ */
102
+ export function usesFileFallback() {
103
+ try {
104
+ return preflight() === 'file';
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
89
110
  // ---------- secret-tool ops with fallback ----------
90
111
  /** secret-tool lookup attributes:
91
112
  * service=agents-cli account=<user> item=<itemName> */
@@ -1,4 +1,5 @@
1
1
  import { type SessionActivity, type AwaitingReason, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
2
+ import { type SessionProvenance } from './provenance.js';
2
3
  export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
3
4
  export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required';
4
5
  export interface ActiveSession {
@@ -30,6 +31,13 @@ export interface ActiveSession {
30
31
  status: ActiveStatus;
31
32
  /** How many live PIDs resolve to this same session (subagents/forks). 1 unless collapsed. */
32
33
  pidCount?: number;
34
+ /**
35
+ * Where the process actually lives — machine host, local vs SSH, tmux pane,
36
+ * and whether a rail exists to type back into it. Read from the process env
37
+ * (`/proc/<pid>/environ` on Linux, `ps eww` on macOS) during enrichment.
38
+ * Absent for cloud sessions (no local pid) and any pid whose env is unreadable.
39
+ */
40
+ provenance?: SessionProvenance;
33
41
  teamName?: string;
34
42
  agentId?: string;
35
43
  cloudProvider?: string;
@@ -29,6 +29,7 @@ import { latestSessionFileForCwd } from './db.js';
29
29
  import { extractSessionTopic } from './prompt.js';
30
30
  import { readSessionTail } from './tail.js';
31
31
  import { inferSessionState } from './state.js';
32
+ import { detectProvenance } from './provenance.js';
32
33
  const execFileAsync = promisify(execFile);
33
34
  const HOME = os.homedir();
34
35
  const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
@@ -536,7 +537,23 @@ export async function getActiveSessions(opts = {}) {
536
537
  if (s.pid)
537
538
  knownPids.add(s.pid);
538
539
  const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
539
- return dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
540
+ const merged = dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
541
+ await enrichProvenance(merged);
542
+ return merged;
543
+ }
544
+ /**
545
+ * Attach provenance (host / local-vs-SSH / tmux pane / reply rail) to every
546
+ * session that has a live pid. Mutates in place. Runs after dedupe so we probe
547
+ * each session once, not once per fork pid. Probes run in parallel — each is a
548
+ * single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
549
+ * undefined rather than blocking the listing.
550
+ */
551
+ async function enrichProvenance(sessions) {
552
+ await Promise.all(sessions.map(async (s) => {
553
+ if (s.provenance || !s.pid)
554
+ return;
555
+ s.provenance = await detectProvenance(s.pid);
556
+ }));
540
557
  }
541
558
  /**
542
559
  * Collapse rows that resolve to the *same* session — a session with many
@@ -0,0 +1,56 @@
1
+ export interface SshOrigin {
2
+ clientIp: string;
3
+ clientPort: number;
4
+ serverIp: string;
5
+ serverPort: number;
6
+ }
7
+ export interface MuxLocation {
8
+ kind: 'tmux' | 'screen';
9
+ /** tmux server socket path (first comma-field of $TMUX). Undefined for screen. */
10
+ socket?: string;
11
+ /** Exact pane id from $TMUX_PANE, e.g. '%3' — the send-keys target. */
12
+ pane?: string;
13
+ /** screen session name from $STY, e.g. '12345.pts-0.host'. */
14
+ session?: string;
15
+ }
16
+ /** How the feed can type back into a session, derived from rails that exist today. */
17
+ export type ReplyRail = {
18
+ rail: 'tmux';
19
+ target: string;
20
+ socket?: string;
21
+ } | null;
22
+ export interface SessionProvenance {
23
+ /** Machine the process runs on — os.hostname(). Drives HOSTS grouping. */
24
+ host: string;
25
+ /** 'ssh' when SSH_CONNECTION is present in the process env, else 'local'. */
26
+ transport: 'local' | 'ssh';
27
+ /** Populated when transport === 'ssh'. */
28
+ ssh?: SshOrigin;
29
+ /** TERM_PROGRAM: 'iTerm.app', 'vscode', 'WezTerm', 'tmux', 'Apple_Terminal', … */
30
+ term?: string;
31
+ /** Multiplexer the process sits inside, from $TMUX / $STY. */
32
+ mux?: MuxLocation;
33
+ /** Whether an existing rail can type back into this session (see module doc). */
34
+ reply: ReplyRail;
35
+ }
36
+ /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
37
+ export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY"];
38
+ /** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
39
+ export declare function parseProcEnviron(buf: string): Record<string, string>;
40
+ /**
41
+ * Pull known env vars out of a macOS `ps eww` command+env line. For each
42
+ * `KEY=` match we consume the declared number of tokens (default 1), so
43
+ * SSH_CONNECTION's internal spaces survive while a following unknown var
44
+ * (e.g. `PWD=…`) is not swallowed into the previous value.
45
+ */
46
+ export declare function extractKnownEnv(text: string, keys: readonly string[]): Record<string, string>;
47
+ /** `<client_ip> <client_port> <server_ip> <server_port>` → structured origin. */
48
+ export declare function parseSshConnection(value: string): SshOrigin | undefined;
49
+ /** Build a SessionProvenance from a raw env map + the local hostname. Pure. */
50
+ export declare function deriveProvenance(env: Record<string, string>, hostname: string): SessionProvenance;
51
+ /**
52
+ * Resolve provenance for a live pid. Returns undefined when the process env
53
+ * can't be read (process gone, foreign uid, unsupported platform) — we never
54
+ * fabricate a 'local' answer we can't back with the env.
55
+ */
56
+ export declare function detectProvenance(pid: number): Promise<SessionProvenance | undefined>;