@phnx-labs/agents-cli 1.20.31 → 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 (74) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/commands/commands.js +3 -3
  3. package/dist/commands/computer-actions.js +1 -0
  4. package/dist/commands/cost.js +2 -2
  5. package/dist/commands/doctor.js +2 -2
  6. package/dist/commands/exec.js +56 -1
  7. package/dist/commands/hooks.js +3 -3
  8. package/dist/commands/inspect.js +13 -17
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/permissions.js +3 -3
  11. package/dist/commands/rules.js +2 -2
  12. package/dist/commands/sessions.js +18 -1
  13. package/dist/commands/skills.js +3 -3
  14. package/dist/commands/ssh.js +23 -0
  15. package/dist/commands/sync.js +2 -2
  16. package/dist/commands/teams.js +7 -12
  17. package/dist/commands/usage.js +2 -2
  18. package/dist/commands/utils.d.ts +8 -0
  19. package/dist/commands/utils.js +20 -0
  20. package/dist/commands/versions.js +2 -2
  21. package/dist/commands/view.js +33 -9
  22. package/dist/commands/workflows.js +3 -3
  23. package/dist/index.js +12 -0
  24. package/dist/lib/agent-spec/index.d.ts +18 -0
  25. package/dist/lib/agent-spec/index.js +35 -0
  26. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  27. package/dist/lib/agent-spec/primitives.js +57 -0
  28. package/dist/lib/agent-spec/provider.d.ts +2 -0
  29. package/dist/lib/agent-spec/provider.js +9 -0
  30. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  31. package/dist/lib/agent-spec/resolve.js +174 -0
  32. package/dist/lib/agent-spec/types.d.ts +57 -0
  33. package/dist/lib/agent-spec/types.js +18 -0
  34. package/dist/lib/crabbox/cli.d.ts +98 -0
  35. package/dist/lib/crabbox/cli.js +218 -0
  36. package/dist/lib/crabbox/lease.d.ts +41 -0
  37. package/dist/lib/crabbox/lease.js +73 -0
  38. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  39. package/dist/lib/crabbox/runtimes.js +109 -0
  40. package/dist/lib/daemon.js +32 -0
  41. package/dist/lib/devices/pending.d.ts +18 -0
  42. package/dist/lib/devices/pending.js +103 -0
  43. package/dist/lib/devices/sync.d.ts +21 -2
  44. package/dist/lib/devices/sync.js +26 -10
  45. package/dist/lib/hosts/dispatch.d.ts +27 -10
  46. package/dist/lib/hosts/dispatch.js +55 -19
  47. package/dist/lib/hosts/option.d.ts +14 -0
  48. package/dist/lib/hosts/option.js +19 -0
  49. package/dist/lib/hosts/passthrough.d.ts +30 -0
  50. package/dist/lib/hosts/passthrough.js +141 -0
  51. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  52. package/dist/lib/hosts/remote-cmd.js +56 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  54. package/dist/lib/secrets/bundles.js +29 -20
  55. package/dist/lib/secrets/index.d.ts +11 -0
  56. package/dist/lib/secrets/index.js +18 -1
  57. package/dist/lib/secrets/linux.d.ts +14 -0
  58. package/dist/lib/secrets/linux.js +21 -0
  59. package/dist/lib/session/active.d.ts +8 -0
  60. package/dist/lib/session/active.js +18 -1
  61. package/dist/lib/session/provenance.d.ts +56 -0
  62. package/dist/lib/session/provenance.js +157 -0
  63. package/dist/lib/ssh-exec.d.ts +22 -0
  64. package/dist/lib/ssh-exec.js +59 -2
  65. package/dist/lib/ssh-tunnel.d.ts +0 -5
  66. package/dist/lib/ssh-tunnel.js +65 -8
  67. package/dist/lib/state.d.ts +2 -0
  68. package/dist/lib/state.js +2 -0
  69. package/dist/lib/sync-umbrella.js +10 -6
  70. package/dist/lib/versions.d.ts +13 -4
  71. package/dist/lib/versions.js +27 -20
  72. package/package.json +2 -1
  73. package/dist/lib/agent-spec.d.ts +0 -36
  74. package/dist/lib/agent-spec.js +0 -157
@@ -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>;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Session provenance — where an active agent process actually lives.
3
+ *
4
+ * `detectHost()` in active.ts walks the ppid chain to name the *terminal app*
5
+ * (iterm / code / tmux). That answers "what UI is above it" but not the three
6
+ * things the Agent Feed needs to group and route:
7
+ *
8
+ * 1. Which machine — os.hostname(), for the HOSTS sidebar.
9
+ * 2. Local vs SSH — is SSH_CONNECTION in the process env?
10
+ * 3. Exact tmux pane — TMUX_PANE ('%3'), the send-keys target.
11
+ *
12
+ * All three are inherited env vars, so we read them straight off the running
13
+ * process (no cooperation from the agent needed): `/proc/<pid>/environ` on
14
+ * Linux, `ps eww` on macOS. The read is best-effort — a process we can't stat
15
+ * (gone, or owned by another uid) yields `undefined`, never a guess.
16
+ *
17
+ * `reply` is a read-only hint, not a send channel: it reports whether a rail
18
+ * that can type back into this session exists today (tmux pane => addressable;
19
+ * inherited/ignored stdin => null). The feed uses it to decide whether to show
20
+ * a Send box. Actually delivering the keystrokes is Gap 2 (pty/tmux send-keys).
21
+ */
22
+ import * as os from 'os';
23
+ import { execFile } from 'child_process';
24
+ import { promisify } from 'util';
25
+ import { readFile } from 'fs/promises';
26
+ const execFileAsync = promisify(execFile);
27
+ /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
28
+ export const PROVENANCE_ENV_KEYS = [
29
+ 'SSH_CONNECTION',
30
+ 'SSH_TTY',
31
+ 'TMUX',
32
+ 'TMUX_PANE',
33
+ 'TERM_PROGRAM',
34
+ 'STY',
35
+ ];
36
+ /** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
37
+ export function parseProcEnviron(buf) {
38
+ const env = {};
39
+ for (const pair of buf.split('\0')) {
40
+ if (!pair)
41
+ continue;
42
+ const eq = pair.indexOf('=');
43
+ if (eq <= 0)
44
+ continue;
45
+ env[pair.slice(0, eq)] = pair.slice(eq + 1);
46
+ }
47
+ return env;
48
+ }
49
+ /**
50
+ * How many whitespace-separated tokens each key's value spans. macOS
51
+ * `ps eww -o command=` space-joins the env after the command, so a value that
52
+ * itself contains spaces (SSH_CONNECTION is four fields) can't be recovered by
53
+ * boundary-guessing when the next token is an *unknown* var. Every provenance
54
+ * key except SSH_CONNECTION is a single token, so we read exactly its arity.
55
+ */
56
+ const ENV_VALUE_TOKENS = { SSH_CONNECTION: 4 };
57
+ /**
58
+ * Pull known env vars out of a macOS `ps eww` command+env line. For each
59
+ * `KEY=` match we consume the declared number of tokens (default 1), so
60
+ * SSH_CONNECTION's internal spaces survive while a following unknown var
61
+ * (e.g. `PWD=…`) is not swallowed into the previous value.
62
+ */
63
+ export function extractKnownEnv(text, keys) {
64
+ const alt = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
65
+ const boundary = new RegExp(`(?:^|\\s)(${alt})=`, 'g');
66
+ const env = {};
67
+ let m;
68
+ while ((m = boundary.exec(text)) !== null) {
69
+ const key = m[1];
70
+ const rest = text.slice(m.index + m[0].length);
71
+ const tokens = rest.split(/\s+/);
72
+ const want = ENV_VALUE_TOKENS[key] ?? 1;
73
+ env[key] = tokens.slice(0, want).join(' ');
74
+ }
75
+ return env;
76
+ }
77
+ /** `<client_ip> <client_port> <server_ip> <server_port>` → structured origin. */
78
+ export function parseSshConnection(value) {
79
+ const parts = value.trim().split(/\s+/);
80
+ if (parts.length < 4)
81
+ return undefined;
82
+ const clientPort = parseInt(parts[1], 10);
83
+ const serverPort = parseInt(parts[3], 10);
84
+ if (!Number.isFinite(clientPort) || !Number.isFinite(serverPort))
85
+ return undefined;
86
+ return { clientIp: parts[0], clientPort, serverIp: parts[2], serverPort };
87
+ }
88
+ /** Build a SessionProvenance from a raw env map + the local hostname. Pure. */
89
+ export function deriveProvenance(env, hostname) {
90
+ const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
91
+ let mux;
92
+ if (env.TMUX) {
93
+ mux = {
94
+ kind: 'tmux',
95
+ socket: env.TMUX.split(',')[0] || undefined,
96
+ pane: env.TMUX_PANE || undefined,
97
+ };
98
+ }
99
+ else if (env.STY) {
100
+ mux = { kind: 'screen', session: env.STY };
101
+ }
102
+ // A tmux pane is the one rail that lets an external process type into an
103
+ // already-running interactive agent (`tmux send-keys -t <pane>`). Everything
104
+ // else (inherited stdin from `agents run`, ignored stdin from teams) is not
105
+ // externally addressable without relaunching under a pty/tmux rail.
106
+ const reply = mux?.kind === 'tmux' && mux.pane
107
+ ? { rail: 'tmux', target: mux.pane, socket: mux.socket }
108
+ : null;
109
+ return {
110
+ host: hostname,
111
+ transport: ssh ? 'ssh' : 'local',
112
+ ssh,
113
+ term: env.TERM_PROGRAM || undefined,
114
+ mux,
115
+ reply,
116
+ };
117
+ }
118
+ /** Read a process's environment. Linux: /proc. macOS: `ps eww`. Best-effort. */
119
+ async function readProcEnv(pid) {
120
+ if (process.platform === 'linux') {
121
+ try {
122
+ const buf = await readFile(`/proc/${pid}/environ`, 'utf8');
123
+ return parseProcEnviron(buf);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ if (process.platform === 'darwin') {
130
+ try {
131
+ const { stdout } = await execFileAsync('ps', ['eww', '-p', String(pid), '-o', 'command='], {
132
+ encoding: 'utf8',
133
+ maxBuffer: 1024 * 1024,
134
+ });
135
+ if (!stdout.trim())
136
+ return undefined;
137
+ return extractKnownEnv(stdout, PROVENANCE_ENV_KEYS);
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+ /**
146
+ * Resolve provenance for a live pid. Returns undefined when the process env
147
+ * can't be read (process gone, foreign uid, unsupported platform) — we never
148
+ * fabricate a 'local' answer we can't back with the env.
149
+ */
150
+ export async function detectProvenance(pid) {
151
+ if (!pid || pid < 1)
152
+ return undefined;
153
+ const env = await readProcEnv(pid);
154
+ if (!env)
155
+ return undefined;
156
+ return deriveProvenance(env, os.hostname());
157
+ }
@@ -19,6 +19,7 @@ export declare function assertValidSshTarget(host: string): void;
19
19
  export declare function shellQuote(s: string): string;
20
20
  /** Hardened ssh options applied to every connection. */
21
21
  export declare const SSH_OPTS: readonly string[];
22
+ export declare function controlOpts(): string[];
22
23
  export interface SshExecOptions {
23
24
  /** Piped to the remote command's stdin (never interpolated into the shell). */
24
25
  input?: string;
@@ -26,6 +27,8 @@ export interface SshExecOptions {
26
27
  timeoutMs?: number;
27
28
  /** Extra ssh flags inserted before the target (e.g. `-tt`). */
28
29
  extraSshArgs?: string[];
30
+ /** Reuse a persistent control socket across calls (see `controlOpts`). */
31
+ multiplex?: boolean;
29
32
  }
30
33
  export interface SshExecResult {
31
34
  /** Remote exit status, or null if ssh itself failed / timed out. */
@@ -43,3 +46,22 @@ export interface SshExecResult {
43
46
  export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
44
47
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
45
48
  export declare function sshReachable(target: string, timeoutMs?: number): boolean;
49
+ export interface SshStreamOptions {
50
+ /**
51
+ * Allocate a remote pseudo-terminal (`ssh -tt`) so an interactive remote
52
+ * command (a picker, a prompt) renders live on the local terminal. Callers
53
+ * pass this when the *local* process is itself a TTY; piped/scripted callers
54
+ * leave it off and forward a non-interactive invocation instead.
55
+ */
56
+ tty?: boolean;
57
+ /** Reuse a persistent control socket across calls (see `controlOpts`). */
58
+ multiplex?: boolean;
59
+ }
60
+ /**
61
+ * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
62
+ * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
63
+ * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
64
+ * until the remote command exits and returns its exit code (255 is ssh's own
65
+ * connection-layer failure; any other non-zero is the remote command's code).
66
+ */
67
+ export declare function sshStream(target: string, remoteCmd: string, opts?: SshStreamOptions): number;
@@ -8,6 +8,9 @@
8
8
  * canonical definition; `commands/secrets.ts` re-exports it.
9
9
  */
10
10
  import { spawnSync } from 'child_process';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { getCacheDir } from './state.js';
11
14
  /**
12
15
  * SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
13
16
  * The strict allowlist blocks shell metacharacters so a target can't be
@@ -32,6 +35,42 @@ export const SSH_OPTS = [
32
35
  '-o', 'BatchMode=yes',
33
36
  '-o', 'ConnectTimeout=10',
34
37
  ];
38
+ /**
39
+ * OpenSSH connection-multiplexing options. The first connection to a host opens
40
+ * a control socket; subsequent connections (even from a *separate* `agents`
41
+ * invocation) reuse it, skipping the TCP+auth handshake — so repeated
42
+ * `--host <name>` calls to the same box feel local instead of paying ~100-300ms
43
+ * each. `ControlPersist=60s` keeps the master alive briefly after the last
44
+ * client exits. `%C` (a short fixed-length hash of local-host/remote/port/user)
45
+ * keeps the socket path well under macOS's 104-char `sun_path` limit.
46
+ *
47
+ * The socket directory is created lazily; if ssh can't open the control socket
48
+ * it falls back to a normal connection (multiplexing is an optimisation, never a
49
+ * requirement), so this can never make a reachable host unreachable.
50
+ */
51
+ let controlDirEnsured = false;
52
+ export function controlOpts() {
53
+ // OpenSSH on Windows has no ControlMaster/ControlPath (unix-socket) support —
54
+ // passing those options makes ssh error out. Multiplexing is a pure latency
55
+ // optimisation, so on Windows we simply skip it and use a fresh connection.
56
+ if (process.platform === 'win32')
57
+ return [];
58
+ const dir = path.join(getCacheDir(), 'ssh');
59
+ if (!controlDirEnsured) {
60
+ try {
61
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
62
+ }
63
+ catch {
64
+ /* best-effort — ssh degrades to a fresh connection if the dir is missing */
65
+ }
66
+ controlDirEnsured = true;
67
+ }
68
+ return [
69
+ '-o', 'ControlMaster=auto',
70
+ '-o', `ControlPath=${path.join(dir, 'cm-%C')}`,
71
+ '-o', 'ControlPersist=60s',
72
+ ];
73
+ }
35
74
  /**
36
75
  * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
37
76
  *
@@ -40,7 +79,8 @@ export const SSH_OPTS = [
40
79
  */
41
80
  export function sshExec(target, remoteCmd, opts = {}) {
42
81
  assertValidSshTarget(target);
43
- const args = [...SSH_OPTS, ...(opts.extraSshArgs ?? []), target, remoteCmd];
82
+ const mux = opts.multiplex ? controlOpts() : [];
83
+ const args = [...SSH_OPTS, ...mux, ...(opts.extraSshArgs ?? []), target, remoteCmd];
44
84
  const res = spawnSync('ssh', args, {
45
85
  input: opts.input,
46
86
  encoding: 'utf-8',
@@ -57,5 +97,22 @@ export function sshExec(target, remoteCmd, opts = {}) {
57
97
  }
58
98
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
59
99
  export function sshReachable(target, timeoutMs = 10000) {
60
- return sshExec(target, 'true', { timeoutMs }).code === 0;
100
+ return sshExec(target, 'true', { timeoutMs, multiplex: true }).code === 0;
101
+ }
102
+ /**
103
+ * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
104
+ * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
105
+ * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
106
+ * until the remote command exits and returns its exit code (255 is ssh's own
107
+ * connection-layer failure; any other non-zero is the remote command's code).
108
+ */
109
+ export function sshStream(target, remoteCmd, opts = {}) {
110
+ assertValidSshTarget(target);
111
+ const mux = opts.multiplex ? controlOpts() : [];
112
+ const tty = opts.tty ? ['-tt'] : [];
113
+ const args = [...SSH_OPTS, ...mux, ...tty, target, remoteCmd];
114
+ const res = spawnSync('ssh', args, { stdio: 'inherit' });
115
+ if (typeof res.status === 'number')
116
+ return res.status;
117
+ return 255; // spawn error / signal — treat as a connection-layer failure
61
118
  }
@@ -90,11 +90,6 @@ export declare function buildPushScript(): string;
90
90
  export declare function buildRegisterTaskScript(port: number, taskName: string): string;
91
91
  /** PowerShell that unregisters the task and stops any running daemon process. */
92
92
  export declare function buildUnregisterTaskScript(taskName: string): string;
93
- /**
94
- * `setup --host`: push the exe, then register + start the LOGON task. Both hops
95
- * go through `sshExec` (BatchMode key auth — the same hardening the browser
96
- * driver and `agents ssh` use). Throws with the remote stderr on any failure.
97
- */
98
93
  export declare function setupRemoteHelper(name: string): Promise<{
99
94
  target: string;
100
95
  taskName: string;