@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.
- package/CHANGELOG.md +15 -0
- package/dist/commands/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +2 -2
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/ssh.js +23 -0
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +7 -12
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +2 -2
- package/dist/commands/view.js +33 -9
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +12 -0
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/devices/pending.d.ts +18 -0
- package/dist/lib/devices/pending.js +103 -0
- package/dist/lib/devices/sync.d.ts +21 -2
- package/dist/lib/devices/sync.js +26 -10
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/bundles.js +29 -20
- package/dist/lib/secrets/index.d.ts +11 -0
- package/dist/lib/secrets/index.js +18 -1
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/sync-umbrella.js +10 -6
- package/dist/lib/versions.d.ts +13 -4
- package/dist/lib/versions.js +27 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- 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
|
+
}
|
|
Binary file
|
|
@@ -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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/ssh-exec.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/lib/ssh-tunnel.d.ts
CHANGED
|
@@ -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;
|