@phnx-labs/agents-cli 1.20.40 → 1.20.42
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 +17 -0
- package/dist/commands/computer-actions.d.ts +2 -0
- package/dist/commands/computer-actions.js +60 -1
- package/dist/commands/computer.d.ts +2 -2
- package/dist/commands/computer.js +4 -4
- package/dist/commands/exec.js +2 -0
- package/dist/commands/focus.d.ts +31 -0
- package/dist/commands/focus.js +150 -0
- package/dist/commands/go.d.ts +34 -11
- package/dist/commands/go.js +50 -65
- package/dist/commands/secrets.js +49 -10
- package/dist/commands/sessions.d.ts +9 -0
- package/dist/commands/sessions.js +77 -20
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +52 -0
- package/dist/lib/exec.js +150 -0
- package/dist/lib/hooks/cache.d.ts +1 -1
- package/dist/lib/hooks/cache.js +4 -2
- package/dist/lib/hosts/option.js +1 -0
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +14 -4
- package/dist/lib/hosts/remote-cmd.d.ts +7 -1
- package/dist/lib/hosts/remote-cmd.js +8 -1
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/secrets/agent.d.ts +18 -7
- package/dist/lib/secrets/agent.js +32 -15
- package/dist/lib/secrets/bundles.d.ts +8 -6
- package/dist/lib/secrets/bundles.js +14 -8
- package/dist/lib/secrets/remote.js +14 -0
- package/dist/lib/secrets/sync.js +13 -0
- package/dist/lib/session/active.d.ts +47 -3
- package/dist/lib/session/active.js +132 -10
- package/dist/lib/session/db.js +45 -31
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +9 -2
- package/dist/lib/session/viewing-in.d.ts +54 -0
- package/dist/lib/session/viewing-in.js +155 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +32 -10
- package/dist/lib/ssh-tunnel.d.ts +1 -1
- package/dist/lib/ssh-tunnel.js +3 -3
- package/dist/lib/tmux/session.d.ts +46 -0
- package/dist/lib/tmux/session.js +84 -2
- package/dist/lib/types.d.ts +3 -3
- package/package.json +1 -1
package/dist/lib/exec.js
CHANGED
|
@@ -19,6 +19,8 @@ import { getShimsDir } from './state.js';
|
|
|
19
19
|
import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
|
|
20
20
|
import { mailboxDir, isValidMailboxId } from './mailbox.js';
|
|
21
21
|
import { composeWin32CommandLine } from './platform/index.js';
|
|
22
|
+
import { isTmuxInstalled } from './tmux/binary.js';
|
|
23
|
+
import { shellQuote } from './ssh-exec.js';
|
|
22
24
|
/**
|
|
23
25
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
24
26
|
*
|
|
@@ -753,6 +755,131 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
753
755
|
});
|
|
754
756
|
});
|
|
755
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* Decide whether to run an interactive agent INSIDE a detached tmux session on
|
|
760
|
+
* the shared socket (then attach the current TTY) instead of a bare spawn.
|
|
761
|
+
*
|
|
762
|
+
* tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
|
|
763
|
+
* `agents sessions --active` can tell co-located agents apart, and lets `agents
|
|
764
|
+
* focus` re-attach a live session without forking it. Pure so the gate is unit-
|
|
765
|
+
* tested independently of the (side-effecting) spawn.
|
|
766
|
+
*
|
|
767
|
+
* All five guards must pass:
|
|
768
|
+
* - interactive — a headless `-p` run has no TTY to attach; keep bare spawn.
|
|
769
|
+
* - not Windows — no tmux path on win32.
|
|
770
|
+
* - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
|
|
771
|
+
* - not --raw — explicit opt-out.
|
|
772
|
+
* - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
|
|
773
|
+
* - tmux installed — otherwise there is nothing to wrap with.
|
|
774
|
+
*/
|
|
775
|
+
export function shouldWrapInTmux(ctx) {
|
|
776
|
+
if (!ctx.interactive)
|
|
777
|
+
return false;
|
|
778
|
+
if (ctx.platform === 'win32')
|
|
779
|
+
return false;
|
|
780
|
+
if (ctx.inTmux)
|
|
781
|
+
return false;
|
|
782
|
+
if (ctx.raw)
|
|
783
|
+
return false;
|
|
784
|
+
if (ctx.noTmuxEnv)
|
|
785
|
+
return false;
|
|
786
|
+
if (!ctx.tmuxAvailable)
|
|
787
|
+
return false;
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Build the shell command that runs an agent inside a tmux pane with the exact
|
|
792
|
+
* env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
|
|
793
|
+
* K=V … <agent> <args…>` so:
|
|
794
|
+
* - `env` materializes the full agent env INTO the pane, independent of the
|
|
795
|
+
* (possibly stale, shared) tmux server environment — additive, so tmux's own
|
|
796
|
+
* $TMUX / $TMUX_PANE still reach the agent for provenance detection;
|
|
797
|
+
* - `exec` replaces the shell so the agent is the pane's leaf process (clean
|
|
798
|
+
* `#{pane_pid}`, clean signal delivery on detach/kill).
|
|
799
|
+
* Keys are filtered to valid identifiers so exported shell functions
|
|
800
|
+
* (`BASH_FUNC_*%%`) can't make `env` choke.
|
|
801
|
+
*/
|
|
802
|
+
export function buildTmuxAgentCommand(executable, args, env) {
|
|
803
|
+
const envPrefix = Object.entries(env)
|
|
804
|
+
.filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
|
|
805
|
+
.map(([k, v]) => `${k}=${shellQuote(String(v))}`)
|
|
806
|
+
.join(' ');
|
|
807
|
+
const agentCmd = [executable, ...args].map(shellQuote).join(' ');
|
|
808
|
+
return `exec env ${envPrefix} ${agentCmd}`;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Run an interactive agent inside a detached tmux session on the shared socket,
|
|
812
|
+
* attach the current TTY, and propagate the wrapped agent's exit code.
|
|
813
|
+
*
|
|
814
|
+
* Lifecycle:
|
|
815
|
+
* 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
|
|
816
|
+
* on (global), and returns the pane id.
|
|
817
|
+
* 2. A per-session `pane-died` hook detaches the attach client the instant the
|
|
818
|
+
* AGENT pane exits, so attach returns instead of parking on a dead pane. The
|
|
819
|
+
* hook is guarded on `#{hook_pane}` so it fires ONLY for the agent pane —
|
|
820
|
+
* user-created splits (Ctrl-b " / %) that the user exits are closed in place
|
|
821
|
+
* (`kill-pane`) instead of tearing down the whole client, so exiting one
|
|
822
|
+
* split leaves the agent running full-window rather than kicking you out.
|
|
823
|
+
* 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
|
|
824
|
+
* headless active-scan attributes it, then attach the TTY (blocking).
|
|
825
|
+
* 4. On return: if the pane is dead the agent exited — read its status, tear the
|
|
826
|
+
* session down, return that code. If the pane is still alive the user detached
|
|
827
|
+
* (Ctrl-b d) — return 0 and LEAVE the session for `agents focus` to re-attach.
|
|
828
|
+
*/
|
|
829
|
+
async function runInTmux(options, executable, args) {
|
|
830
|
+
const { createSession, killSession, paneExitStatus, setSessionHook, slugifyName } = await import('./tmux/session.js');
|
|
831
|
+
const { getDefaultSocketPath } = await import('./tmux/paths.js');
|
|
832
|
+
const { attachTmux, runTmux } = await import('./tmux/binary.js');
|
|
833
|
+
const socket = getDefaultSocketPath();
|
|
834
|
+
const cwd = options.cwd || process.cwd();
|
|
835
|
+
const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
|
|
836
|
+
const name = slugifyName(`ag-${options.agent}-${idSeed}`);
|
|
837
|
+
const cmd = buildTmuxAgentCommand(executable, args, buildExecEnv(options));
|
|
838
|
+
const labels = { agent: options.agent };
|
|
839
|
+
if (options.sessionId)
|
|
840
|
+
labels.sessionId = options.sessionId;
|
|
841
|
+
const meta = await createSession({ name, cmd, cwd, socket, source: 'cli', labels });
|
|
842
|
+
const pane = meta.pane;
|
|
843
|
+
if (pane) {
|
|
844
|
+
// When the AGENT pane dies, detach the client (don't kill) so the session
|
|
845
|
+
// survives just long enough to read the dead pane's exit status below. The
|
|
846
|
+
// `#{hook_pane}` guard scopes this to the agent pane only: if the user splits
|
|
847
|
+
// the window and exits one of THEIR panes, the else-branch `kill-pane` closes
|
|
848
|
+
// that split in place instead of detaching everyone (the pane-died hook runs
|
|
849
|
+
// in the dead pane's context, so bare `kill-pane` targets it). Without the
|
|
850
|
+
// guard, exiting any split kicked the user clean out of tmux.
|
|
851
|
+
await setSessionHook(name, 'pane-died', `if -F '#{==:#{hook_pane},${pane}}' 'detach-client -s =${name}' 'kill-pane'`, socket);
|
|
852
|
+
// Record the agent's OS pid (the pane leaf, thanks to `exec`) WITH its tmux
|
|
853
|
+
// pane so the active-scan attributes it exactly and shows the %pane.
|
|
854
|
+
let panePid = 0;
|
|
855
|
+
try {
|
|
856
|
+
const r = await runTmux({ socket, args: ['display-message', '-pt', pane, '-p', '#{pane_pid}'], throwOnError: false });
|
|
857
|
+
panePid = parseInt(r.stdout.trim(), 10) || 0;
|
|
858
|
+
}
|
|
859
|
+
catch { /* best-effort */ }
|
|
860
|
+
writePidSessionEntry({
|
|
861
|
+
pid: panePid,
|
|
862
|
+
agent: options.agent,
|
|
863
|
+
sessionId: options.sessionId,
|
|
864
|
+
cwd,
|
|
865
|
+
tmuxPane: pane,
|
|
866
|
+
startedAtMs: Date.now(),
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
// The agent could exit before we attach (fast failure). Don't attach to an
|
|
870
|
+
// already-dead pane — read its status directly and tear down.
|
|
871
|
+
const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
872
|
+
if (!before.dead) {
|
|
873
|
+
await attachTmux({ socket, args: ['attach-session', '-t', name] });
|
|
874
|
+
}
|
|
875
|
+
const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
|
|
876
|
+
if (after.dead) {
|
|
877
|
+
await killSession(name, socket).catch(() => { });
|
|
878
|
+
return { exitCode: after.status ?? 0, stderr: '' };
|
|
879
|
+
}
|
|
880
|
+
// Pane still alive → the user detached; keep the session for `agents focus`.
|
|
881
|
+
return { exitCode: 0, stderr: '' };
|
|
882
|
+
}
|
|
756
883
|
/**
|
|
757
884
|
* Spawn an agent process and return its exit code plus a tee'd copy of stderr.
|
|
758
885
|
*
|
|
@@ -800,6 +927,29 @@ async function spawnAgent(options) {
|
|
|
800
927
|
command: executable,
|
|
801
928
|
args: redactArgs(args.slice(0, 10)),
|
|
802
929
|
});
|
|
930
|
+
// Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket
|
|
931
|
+
// tmux session (then attach this TTY) so it gets a unique, addressable %pane.
|
|
932
|
+
// Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the
|
|
933
|
+
// bare spawn below. See shouldWrapInTmux / runInTmux.
|
|
934
|
+
if (shouldWrapInTmux({
|
|
935
|
+
interactive,
|
|
936
|
+
platform: process.platform,
|
|
937
|
+
inTmux: !!process.env.TMUX,
|
|
938
|
+
raw: options.raw === true,
|
|
939
|
+
noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
|
|
940
|
+
tmuxAvailable: isTmuxInstalled(),
|
|
941
|
+
})) {
|
|
942
|
+
timer.mark('startup');
|
|
943
|
+
try {
|
|
944
|
+
const result = await runInTmux(options, executable, args);
|
|
945
|
+
timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
|
|
946
|
+
return result;
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
timer.end({ error: err.message, exitCode: -1, status: 'error' });
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
803
953
|
return new Promise((resolve, reject) => {
|
|
804
954
|
// Interactive mode inherits all stdio so the CLI owns the TTY (TUI
|
|
805
955
|
// rendering, raw-mode keystrokes, colored output). Headless mode pipes
|
|
@@ -5,7 +5,7 @@ import type { HookCache, HookCacheConfig } from '../types.js';
|
|
|
5
5
|
* Returns null if the value is missing or unparseable.
|
|
6
6
|
*/
|
|
7
7
|
export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
|
|
8
|
-
/** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
|
|
8
|
+
/** Parse "30s" | "5m" | "1h" | "7d" | plain seconds. Returns seconds, or null on failure. */
|
|
9
9
|
export declare function parseDuration(d: number | string | undefined): number | null;
|
|
10
10
|
/**
|
|
11
11
|
* Reject hook names that could escape the shims directory when interpolated
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -51,19 +51,21 @@ function parseShorthand(s) {
|
|
|
51
51
|
return null;
|
|
52
52
|
return { ttl: ttlSec, key: 'global', prefetch };
|
|
53
53
|
}
|
|
54
|
-
/** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
|
|
54
|
+
/** Parse "30s" | "5m" | "1h" | "7d" | plain seconds. Returns seconds, or null on failure. */
|
|
55
55
|
export function parseDuration(d) {
|
|
56
56
|
if (d == null)
|
|
57
57
|
return null;
|
|
58
58
|
if (typeof d === 'number')
|
|
59
59
|
return Number.isFinite(d) && d > 0 ? Math.floor(d) : null;
|
|
60
|
-
const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i);
|
|
60
|
+
const m = d.trim().match(/^(\d+)\s*(s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)?$/i);
|
|
61
61
|
if (!m)
|
|
62
62
|
return null;
|
|
63
63
|
const value = parseInt(m[1], 10);
|
|
64
64
|
if (!Number.isFinite(value) || value <= 0)
|
|
65
65
|
return null;
|
|
66
66
|
const unit = (m[2] || 's').toLowerCase();
|
|
67
|
+
if (unit.startsWith('d'))
|
|
68
|
+
return value * 86400;
|
|
67
69
|
if (unit.startsWith('h'))
|
|
68
70
|
return value * 3600;
|
|
69
71
|
if (unit.startsWith('m'))
|
package/dist/lib/hosts/option.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export function addHostOption(cmd) {
|
|
14
14
|
return cmd
|
|
15
15
|
.option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
|
|
16
|
+
.option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`).')
|
|
16
17
|
.option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
|
|
17
18
|
.option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
|
|
18
19
|
.option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
23
|
-
* host-routable and a `--host` was given. Returns
|
|
24
|
-
*
|
|
25
|
-
* very machine.
|
|
23
|
+
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
24
|
+
* `false` (run locally) when neither flag is present, the command isn't in the
|
|
25
|
+
* table, or the target is this very machine.
|
|
26
26
|
*
|
|
27
27
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
28
28
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
@@ -80,9 +80,9 @@ async function resolveTargetHost(name, any) {
|
|
|
80
80
|
}
|
|
81
81
|
/**
|
|
82
82
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
83
|
-
* host-routable and a `--host` was given. Returns
|
|
84
|
-
*
|
|
85
|
-
* very machine.
|
|
83
|
+
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
84
|
+
* `false` (run locally) when neither flag is present, the command isn't in the
|
|
85
|
+
* table, or the target is this very machine.
|
|
86
86
|
*
|
|
87
87
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
88
88
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|
|
@@ -91,7 +91,17 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
91
91
|
const spec = REMOTE_PASSTHROUGH[command];
|
|
92
92
|
if (!spec)
|
|
93
93
|
return false;
|
|
94
|
-
|
|
94
|
+
// `--device` is a first-class alias of `--host` (mirrors `agents run`); the
|
|
95
|
+
// device registry is the source of truth for machine identity. Reject a
|
|
96
|
+
// conflicting pair rather than silently preferring one — same rule as run.
|
|
97
|
+
const hostFlag = flagValue(allArgs, 'host', 'H');
|
|
98
|
+
const deviceFlag = flagValue(allArgs, 'device');
|
|
99
|
+
if (hostFlag && deviceFlag && hostFlag !== deviceFlag) {
|
|
100
|
+
console.error(chalk.red('Conflicting --host/--device values — pass just one.'));
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const hostName = hostFlag ?? deviceFlag;
|
|
95
105
|
if (!hostName)
|
|
96
106
|
return false;
|
|
97
107
|
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
@@ -25,7 +25,13 @@ export interface StripSpec {
|
|
|
25
25
|
* @param args the command's args (already past the command name).
|
|
26
26
|
*/
|
|
27
27
|
export declare function stripRoutingFlags(args: string[], specs: StripSpec[]): string[];
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The routing flags every `--host`-capable command shares. `--device` is a
|
|
30
|
+
* first-class alias of `--host` (the device registry is the source of truth for
|
|
31
|
+
* machine identity — see `agents devices`), mirroring `agents run --device`.
|
|
32
|
+
* Both are stripped before forwarding so the alias never leaks to the remote
|
|
33
|
+
* binary (which would re-trigger routing).
|
|
34
|
+
*/
|
|
29
35
|
export declare const HOST_ROUTING_SPECS: StripSpec[];
|
|
30
36
|
/**
|
|
31
37
|
* Build the single command string for `ssh <target> <cmd>`. The forwarded args
|
|
@@ -38,9 +38,16 @@ export function stripRoutingFlags(args, specs) {
|
|
|
38
38
|
}
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The routing flags every `--host`-capable command shares. `--device` is a
|
|
43
|
+
* first-class alias of `--host` (the device registry is the source of truth for
|
|
44
|
+
* machine identity — see `agents devices`), mirroring `agents run --device`.
|
|
45
|
+
* Both are stripped before forwarding so the alias never leaks to the remote
|
|
46
|
+
* binary (which would re-trigger routing).
|
|
47
|
+
*/
|
|
42
48
|
export const HOST_ROUTING_SPECS = [
|
|
43
49
|
{ long: 'host', short: 'H', takesValue: true },
|
|
50
|
+
{ long: 'device', takesValue: true },
|
|
44
51
|
{ long: 'remote-cwd', takesValue: true },
|
|
45
52
|
];
|
|
46
53
|
/**
|
|
@@ -77,7 +77,7 @@ export function menubarServiceInstalled() {
|
|
|
77
77
|
* Locate the source `.app` shipped alongside the compiled JS.
|
|
78
78
|
* 1. dist/lib/menubar/MenubarHelper.app — npm install layout (sibling of this file)
|
|
79
79
|
* 2. <repo>/bin/MenubarHelper.app — raw working tree (tsx/dev)
|
|
80
|
-
* 3.
|
|
80
|
+
* 3. apps/cli/menubar/dist/MenubarHelper.app — fresh local build
|
|
81
81
|
*/
|
|
82
82
|
function sourceAppPath() {
|
|
83
83
|
const candidates = [];
|
|
@@ -85,7 +85,7 @@ function sourceAppPath() {
|
|
|
85
85
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
86
86
|
candidates.push(path.join(here, APP_BUNDLE_NAME));
|
|
87
87
|
candidates.push(path.resolve(here, '..', '..', '..', 'bin', APP_BUNDLE_NAME));
|
|
88
|
-
candidates.push(path.resolve(here, '..', '..', '..', '
|
|
88
|
+
candidates.push(path.resolve(here, '..', '..', '..', 'menubar', 'dist', APP_BUNDLE_NAME));
|
|
89
89
|
}
|
|
90
90
|
catch {
|
|
91
91
|
/* import.meta.url unavailable */
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* trust boundary the keychain already concedes (docs/secrets.md: the ACL is
|
|
17
17
|
* user-presence, not code-identity — any same-user process can pop the prompt
|
|
18
18
|
* and read), minus the visible prompt. We bound it with: explicit per-bundle
|
|
19
|
-
* opt-in (nothing is held unless you `unlock` it), an absolute TTL,
|
|
20
|
-
* on
|
|
19
|
+
* opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
|
|
20
|
+
* auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
|
|
21
|
+
* NOT a wipe (the login password already gates it). Nothing ever touches disk.
|
|
21
22
|
*
|
|
22
23
|
* macOS only: Linux libsecret has no biometry prompt, so there's nothing to
|
|
23
24
|
* deduplicate — every entry point here no-ops off darwin.
|
|
@@ -30,7 +31,7 @@ export declare const DEFAULT_TTL_MS: number;
|
|
|
30
31
|
* The broker holds the resolved bundle-metadata array (names/policy/timestamps,
|
|
31
32
|
* NO resolved secret values beyond the literals already in metadata) keyed by a
|
|
32
33
|
* hash of the current keychain bundle name-set, so the second and later
|
|
33
|
-
* `secrets list` within the
|
|
34
|
+
* `secrets list` within the hold window read metadata without a Touch ID
|
|
34
35
|
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
35
36
|
* changes the key and misses the cache automatically — no active invalidation.
|
|
36
37
|
* The '!' sentinel can never collide with a real bundle name
|
|
@@ -43,7 +44,7 @@ export declare const META_CACHE_PREFIX = "!meta:";
|
|
|
43
44
|
* code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
|
|
44
45
|
* with bundles still unlocked wipes them from memory, so the next reader falls
|
|
45
46
|
* back to a direct keychain read and re-prompts for Touch ID. Deferring the
|
|
46
|
-
* restart until the cache is idle (TTL-expired /
|
|
47
|
+
* restart until the cache is idle (TTL-expired / slept) means an
|
|
47
48
|
* in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
|
|
48
49
|
* next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
|
|
49
50
|
* cache on every bump and produced a recurring Touch ID storm.
|
|
@@ -141,10 +142,20 @@ export type Response = {
|
|
|
141
142
|
*/
|
|
142
143
|
export declare function realBundleCount(store: Map<string, StoredBundle>): number;
|
|
143
144
|
export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
|
|
145
|
+
/**
|
|
146
|
+
* Decide whether a `watch-lock` helper line should wipe the in-memory store.
|
|
147
|
+
* The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
|
|
148
|
+
* sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
|
|
149
|
+
* password, and with the ~7d hold, re-authing after every lock would defeat the
|
|
150
|
+
* point. Logout needs no line — it tears down the launchd session and kills the
|
|
151
|
+
* broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
|
|
152
|
+
* has direct regression coverage (the inline stdout handler isn't unit-testable).
|
|
153
|
+
*/
|
|
154
|
+
export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
|
|
144
155
|
/**
|
|
145
156
|
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
146
157
|
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
147
|
-
* sweeps expired entries, wipes on
|
|
158
|
+
* sweeps expired entries, wipes on sleep, and self-exits when idle.
|
|
148
159
|
*/
|
|
149
160
|
export declare function runSecretsAgent(opts?: {
|
|
150
161
|
service?: boolean;
|
|
@@ -171,7 +182,7 @@ export declare function agentGetSync(name: string): {
|
|
|
171
182
|
export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] | null;
|
|
172
183
|
/**
|
|
173
184
|
* Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
|
|
174
|
-
* the next `secrets list` within the
|
|
185
|
+
* the next `secrets list` within the hold window renders without a prompt.
|
|
175
186
|
* Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
|
|
176
187
|
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
177
188
|
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
@@ -179,7 +190,7 @@ export declare function agentGetMetaSync(nameSetHash: string): SecretsBundle[] |
|
|
|
179
190
|
export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: SecretsBundle[], ttlMs: number): void;
|
|
180
191
|
/** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
|
|
181
192
|
* broker is the mechanism that delivers the `daily` default policy (one Touch ID
|
|
182
|
-
* per ~
|
|
193
|
+
* per ~7d), so auto-caching is ON by default; opt out with
|
|
183
194
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
184
195
|
export declare function secretsAgentAutoEnabled(): boolean;
|
|
185
196
|
/**
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* trust boundary the keychain already concedes (docs/secrets.md: the ACL is
|
|
17
17
|
* user-presence, not code-identity — any same-user process can pop the prompt
|
|
18
18
|
* and read), minus the visible prompt. We bound it with: explicit per-bundle
|
|
19
|
-
* opt-in (nothing is held unless you `unlock` it), an absolute TTL,
|
|
20
|
-
* on
|
|
19
|
+
* opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
|
|
20
|
+
* auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
|
|
21
|
+
* NOT a wipe (the login password already gates it). Nothing ever touches disk.
|
|
21
22
|
*
|
|
22
23
|
* macOS only: Linux libsecret has no biometry prompt, so there's nothing to
|
|
23
24
|
* deduplicate — every entry point here no-ops off darwin.
|
|
@@ -35,13 +36,13 @@ import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
|
35
36
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
36
37
|
const PROTOCOL_VERSION = 1;
|
|
37
38
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
38
|
-
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; //
|
|
39
|
+
export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
|
39
40
|
/**
|
|
40
41
|
* Reserved store-key prefix for the `secrets list` metadata snapshot cache.
|
|
41
42
|
* The broker holds the resolved bundle-metadata array (names/policy/timestamps,
|
|
42
43
|
* NO resolved secret values beyond the literals already in metadata) keyed by a
|
|
43
44
|
* hash of the current keychain bundle name-set, so the second and later
|
|
44
|
-
* `secrets list` within the
|
|
45
|
+
* `secrets list` within the hold window read metadata without a Touch ID
|
|
45
46
|
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
46
47
|
* changes the key and misses the cache automatically — no active invalidation.
|
|
47
48
|
* The '!' sentinel can never collide with a real bundle name
|
|
@@ -59,7 +60,7 @@ const SWEEP_INTERVAL_MS = 30 * 1000;
|
|
|
59
60
|
* code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
|
|
60
61
|
* with bundles still unlocked wipes them from memory, so the next reader falls
|
|
61
62
|
* back to a direct keychain read and re-prompts for Touch ID. Deferring the
|
|
62
|
-
* restart until the cache is idle (TTL-expired /
|
|
63
|
+
* restart until the cache is idle (TTL-expired / slept) means an
|
|
63
64
|
* in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
|
|
64
65
|
* next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
|
|
65
66
|
* cache on every bump and produced a recurring Touch ID storm.
|
|
@@ -301,10 +302,22 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Decide whether a `watch-lock` helper line should wipe the in-memory store.
|
|
307
|
+
* The helper emits `LOCK` on screen-lock / screensaver and `SLEEP` on system
|
|
308
|
+
* sleep. We wipe on SLEEP only: a bare screen-lock is already gated by the login
|
|
309
|
+
* password, and with the ~7d hold, re-authing after every lock would defeat the
|
|
310
|
+
* point. Logout needs no line — it tears down the launchd session and kills the
|
|
311
|
+
* broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
|
|
312
|
+
* has direct regression coverage (the inline stdout handler isn't unit-testable).
|
|
313
|
+
*/
|
|
314
|
+
export function shouldWipeOnWatchEvent(chunk) {
|
|
315
|
+
return /\bSLEEP\b/.test(chunk);
|
|
316
|
+
}
|
|
304
317
|
/**
|
|
305
318
|
* Run the broker in the foreground. Spawned detached by ensureAgentRunning via
|
|
306
319
|
* `agents secrets _agent-run`. Holds the store in memory, serves the socket,
|
|
307
|
-
* sweeps expired entries, wipes on
|
|
320
|
+
* sweeps expired entries, wipes on sleep, and self-exits when idle.
|
|
308
321
|
*/
|
|
309
322
|
export async function runSecretsAgent(opts = {}) {
|
|
310
323
|
if (!onDarwin())
|
|
@@ -351,9 +364,9 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
351
364
|
// this value for the process lifetime; getCliVersionFresh re-reads on disk.
|
|
352
365
|
const runningVersion = getCliVersion();
|
|
353
366
|
// "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
|
|
354
|
-
// the internal `secrets list` metadata cache (#524). Otherwise a
|
|
367
|
+
// the internal `secrets list` metadata cache (#524). Otherwise a 7d-TTL list
|
|
355
368
|
// cache would keep the store non-empty and (a) block the persistent broker
|
|
356
|
-
// from self-healing onto a freshly-installed version for up to a
|
|
369
|
+
// from self-healing onto a freshly-installed version for up to a week (#435's
|
|
357
370
|
// gate is size===0), and (b) stop a one-off broker from ever idle-exiting. The
|
|
358
371
|
// metadata cache is a disposable list snapshot — wiping it on upgrade/idle
|
|
359
372
|
// costs at most one extra prompt on the next `secrets list`.
|
|
@@ -450,15 +463,19 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
450
463
|
});
|
|
451
464
|
});
|
|
452
465
|
sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
|
|
453
|
-
// Auto-lock on
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
466
|
+
// Auto-lock on sleep. The signed helper emits LOCK / SLEEP lines; we wipe
|
|
467
|
+
// everything on SLEEP (and, implicitly, logout — that tears down the launchd
|
|
468
|
+
// session and kills this in-memory broker). A bare screen-lock is deliberately
|
|
469
|
+
// NOT a wipe: with the ~7d hold, re-prompting after every lock would defeat the
|
|
470
|
+
// point, and a locked screen is already gated by the login password. If the
|
|
471
|
+
// installed helper predates watch-lock (exits non-zero immediately), we fall
|
|
472
|
+
// back to TTL-only and log nothing — the unlock already warned when
|
|
473
|
+
// lock_on_sleep couldn't be armed.
|
|
457
474
|
try {
|
|
458
475
|
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
459
476
|
watcher.stdout?.setEncoding('utf-8');
|
|
460
477
|
watcher.stdout?.on('data', (chunk) => {
|
|
461
|
-
if (
|
|
478
|
+
if (shouldWipeOnWatchEvent(chunk)) {
|
|
462
479
|
store.clear();
|
|
463
480
|
emptySince = Date.now();
|
|
464
481
|
}
|
|
@@ -590,7 +607,7 @@ export function agentGetMetaSync(nameSetHash) {
|
|
|
590
607
|
}
|
|
591
608
|
/**
|
|
592
609
|
* Fire-and-forget: populate the broker with a freshly-read metadata snapshot so
|
|
593
|
-
* the next `secrets list` within the
|
|
610
|
+
* the next `secrets list` within the hold window renders without a prompt.
|
|
594
611
|
* Stored as an ordinary entry (placeholder bundle, snapshot in env) under the
|
|
595
612
|
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
596
613
|
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
@@ -604,7 +621,7 @@ export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
|
|
|
604
621
|
}
|
|
605
622
|
/** True unless `secrets.agent.auto` is explicitly disabled in agents.yaml. The
|
|
606
623
|
* broker is the mechanism that delivers the `daily` default policy (one Touch ID
|
|
607
|
-
* per ~
|
|
624
|
+
* per ~7d), so auto-caching is ON by default; opt out with
|
|
608
625
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
609
626
|
export function secretsAgentAutoEnabled() {
|
|
610
627
|
try {
|
|
@@ -40,11 +40,13 @@ export interface VarMeta {
|
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* A bundle's prompt policy — how often macOS asks for Touch ID to read it:
|
|
43
|
-
* - `daily` (default): ask once, then hold it silently for up to ~
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* - `daily` (default): ask once, then hold it silently for up to ~7 days.
|
|
44
|
+
* (Historical name — the window is now a rolling ~1 week, not one calendar day.)
|
|
45
|
+
* Eligible for the secrets-agent — the first real keychain read auto-loads it
|
|
46
|
+
* (auto-cache is on by default) so concurrent runs read it silently, or `unlock`
|
|
47
|
+
* it explicitly. Held from that unlock (not refreshed on use); re-asks sooner
|
|
48
|
+
* after sleep, logout, or `agents secrets lock`. A bare screen-lock does NOT
|
|
49
|
+
* drop it (the login password already gates a locked screen).
|
|
48
50
|
* - `always`: asks every time. Never auto-held — only an explicit `agents
|
|
49
51
|
* secrets unlock` ever holds it; every other read pops Touch ID. Opt a
|
|
50
52
|
* high-value bundle into this when you want to confirm every single read.
|
|
@@ -107,7 +109,7 @@ export declare function bundleExists(name: string): boolean;
|
|
|
107
109
|
export declare function readBundle(name: string): SecretsBundle;
|
|
108
110
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
109
111
|
* policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
|
|
110
|
-
* ID per ~
|
|
112
|
+
* ID per ~7d) unless the user explicitly opts back into prompt-every-time with
|
|
111
113
|
* `always`. Best-effort: an unreadable config falls back to the `daily` default. */
|
|
112
114
|
export declare function secretsDefaultPolicy(): SecretsPolicy;
|
|
113
115
|
/** The effective prompt policy of a bundle (absent ⇒ the configured default). */
|
|
@@ -263,7 +263,7 @@ function parsePolicy(raw) {
|
|
|
263
263
|
}
|
|
264
264
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
265
265
|
* policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
|
|
266
|
-
* ID per ~
|
|
266
|
+
* ID per ~7d) unless the user explicitly opts back into prompt-every-time with
|
|
267
267
|
* `always`. Best-effort: an unreadable config falls back to the `daily` default. */
|
|
268
268
|
export function secretsDefaultPolicy() {
|
|
269
269
|
try {
|
|
@@ -336,13 +336,13 @@ export function writeBundle(bundle) {
|
|
|
336
336
|
// of the tier. On an un-updated pinned helper this write fails loudly (the
|
|
337
337
|
// no-ACL command is missing) rather than silently landing an ACL'd item.
|
|
338
338
|
itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
|
|
339
|
-
emit('secrets.set', { bundle: bundle.name });
|
|
339
|
+
emit('secrets.set', { module: 'secrets', bundle: bundle.name });
|
|
340
340
|
}
|
|
341
341
|
export function deleteBundle(name) {
|
|
342
342
|
validateBundleName(name);
|
|
343
343
|
const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
|
|
344
344
|
if (deleted) {
|
|
345
|
-
emit('secrets.delete', { bundle: name });
|
|
345
|
+
emit('secrets.delete', { module: 'secrets', bundle: name });
|
|
346
346
|
}
|
|
347
347
|
return deleted;
|
|
348
348
|
}
|
|
@@ -414,11 +414,15 @@ export function listBundles() {
|
|
|
414
414
|
// so the getKeychainTokens batch below pops Touch ID on every `secrets
|
|
415
415
|
// list` — the broker/`daily` mechanism only ever covered value reads, not
|
|
416
416
|
// this listing. Serve a broker-cached metadata snapshot when one is held,
|
|
417
|
-
// so only the first list per ~
|
|
417
|
+
// so only the first list per ~7d prompts. The cache key is a hash of the
|
|
418
418
|
// current keychain name-set (enumerated silently above): add / remove /
|
|
419
419
|
// rename a bundle and the key changes, so the stale snapshot is never
|
|
420
|
-
// served
|
|
421
|
-
//
|
|
420
|
+
// served. A same-name metadata edit (e.g. `secrets policy <b> always`)
|
|
421
|
+
// does NOT change the key, so the POLICY column in `secrets list` can lag
|
|
422
|
+
// by up to the hold window (~7d) until the next name-set change or `lock`.
|
|
423
|
+
// This is cosmetic only — enforcement always reads the bundle's live
|
|
424
|
+
// policy (readBundle), never this snapshot, and `secrets view <b>` shows
|
|
425
|
+
// the fresh value immediately. Values are never cached here; metadata only.
|
|
422
426
|
const useAgent = process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
423
427
|
!isKeychainBackendOverridden() &&
|
|
424
428
|
secretsAgentAutoEnabled();
|
|
@@ -444,7 +448,7 @@ export function listBundles() {
|
|
|
444
448
|
}
|
|
445
449
|
for (const bundle of keychainBundles)
|
|
446
450
|
out.push(bundle);
|
|
447
|
-
// Populate the broker for the rest of the
|
|
451
|
+
// Populate the broker for the rest of the hold window (fire-and-forget).
|
|
448
452
|
if (useAgent && keychainBundles.length > 0) {
|
|
449
453
|
agentAutoLoadMetaSync(nameSetHash, keychainBundles, DEFAULT_TTL_MS);
|
|
450
454
|
}
|
|
@@ -688,6 +692,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
688
692
|
const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
|
|
689
693
|
stampLastUsed(filtered.bundle);
|
|
690
694
|
emit('secrets.get', {
|
|
695
|
+
module: 'secrets',
|
|
691
696
|
bundle: name,
|
|
692
697
|
caller: opts.caller,
|
|
693
698
|
status: 'success',
|
|
@@ -777,6 +782,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
777
782
|
keychainKeys.sort();
|
|
778
783
|
const emitReadAudit = (status, err) => {
|
|
779
784
|
emit('secrets.get', {
|
|
785
|
+
module: 'secrets',
|
|
780
786
|
bundle: bundle.name,
|
|
781
787
|
caller: opts.caller,
|
|
782
788
|
status,
|
|
@@ -939,7 +945,7 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
939
945
|
store.delete(oldItem);
|
|
940
946
|
}
|
|
941
947
|
deleteBundle(oldName);
|
|
942
|
-
emit('secrets.rename', { from: oldName, to: newName });
|
|
948
|
+
emit('secrets.rename', { module: 'secrets', from: oldName, to: newName });
|
|
943
949
|
}
|
|
944
950
|
/**
|
|
945
951
|
* The store (keychain or encrypted file) that carries a bundle's items. The
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
|
+
import { emit } from '../events.js';
|
|
20
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
21
22
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
22
23
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
@@ -136,5 +137,18 @@ export async function remoteResolveEnv(target, bundle) {
|
|
|
136
137
|
for (const [k, v] of Object.entries(parsed)) {
|
|
137
138
|
env[k] = typeof v === 'string' ? v : String(v);
|
|
138
139
|
}
|
|
140
|
+
// The remote host audits its own `secrets export` read; this emit records the
|
|
141
|
+
// event on the INITIATING host too (values were pulled into this process and
|
|
142
|
+
// injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
|
|
143
|
+
// Values never enter the payload — only the bundle, target host, and count.
|
|
144
|
+
emit('secrets.get', {
|
|
145
|
+
module: 'secrets',
|
|
146
|
+
bundle,
|
|
147
|
+
caller: 'remote resolve',
|
|
148
|
+
source: 'remote',
|
|
149
|
+
host: target,
|
|
150
|
+
status: 'success',
|
|
151
|
+
keyCount: Object.keys(env).length,
|
|
152
|
+
});
|
|
139
153
|
return env;
|
|
140
154
|
}
|