@phnx-labs/agents-cli 1.20.34 → 1.20.36
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 +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +196 -91
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +183 -23
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +22 -0
- package/dist/lib/hosts/tasks.js +29 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
package/dist/lib/runner.js
CHANGED
|
@@ -16,6 +16,7 @@ import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
|
|
|
16
16
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
17
17
|
import { createTimer, maybeRotate, redactPrompt } from './events.js';
|
|
18
18
|
import { normalizeMode } from './exec.js';
|
|
19
|
+
import { backgroundSpawnOptions } from './platform/process.js';
|
|
19
20
|
/** CLI command templates per agent, with {prompt} as a placeholder. */
|
|
20
21
|
const AGENT_COMMANDS = {
|
|
21
22
|
claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
|
|
@@ -152,8 +153,16 @@ function appendModelAndReasoning(cmd, config) {
|
|
|
152
153
|
function generateRunId() {
|
|
153
154
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
154
155
|
}
|
|
155
|
-
/**
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Execute a job synchronously (waits for completion or timeout before resolving).
|
|
158
|
+
*
|
|
159
|
+
* When `config.loop` is set the job is routed through the loop driver (`runLoop`
|
|
160
|
+
* from loop.ts) instead of a single spawn — same driver as `agents run --loop` and
|
|
161
|
+
* workflow `loop:` blocks (issue #400). The optional `deps` parameter provides
|
|
162
|
+
* injectable seams (runIteration, sleep, writeCheckpoint) used by tests; production
|
|
163
|
+
* callers omit it and get the defaults.
|
|
164
|
+
*/
|
|
165
|
+
export async function executeJob(config, deps) {
|
|
157
166
|
maybeRotate();
|
|
158
167
|
const timer = createTimer('agent.run', {
|
|
159
168
|
agent: config.agent,
|
|
@@ -164,14 +173,11 @@ export async function executeJob(config) {
|
|
|
164
173
|
schedule: config.schedule,
|
|
165
174
|
});
|
|
166
175
|
const resolvedPrompt = resolveJobPrompt(config);
|
|
167
|
-
const cmd = buildJobCommand(config, resolvedPrompt);
|
|
168
176
|
const useSandbox = config.sandbox !== false;
|
|
169
177
|
const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
|
|
170
178
|
const runId = generateRunId();
|
|
171
179
|
const runDir = getRunDir(config.name, runId);
|
|
172
180
|
fs.mkdirSync(runDir, { recursive: true });
|
|
173
|
-
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
174
|
-
const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
|
|
175
181
|
let spawnEnv = useSandbox ? buildSpawnEnv(overlayHome) : { ...process.env };
|
|
176
182
|
if (config.timezone) {
|
|
177
183
|
spawnEnv.TZ = config.timezone;
|
|
@@ -192,10 +198,46 @@ export async function executeJob(config) {
|
|
|
192
198
|
};
|
|
193
199
|
writeRunMeta(meta);
|
|
194
200
|
const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
|
|
201
|
+
// Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
|
|
202
|
+
if (config.loop) {
|
|
203
|
+
const execOptions = {
|
|
204
|
+
agent: effectiveAgent,
|
|
205
|
+
version: config.version,
|
|
206
|
+
prompt: resolvedPrompt,
|
|
207
|
+
mode: normalizeMode(config.mode),
|
|
208
|
+
effort: config.effort,
|
|
209
|
+
env: spawnEnv,
|
|
210
|
+
json: true,
|
|
211
|
+
headless: true,
|
|
212
|
+
...(config.config?.model ? { model: config.config.model } : {}),
|
|
213
|
+
...(config.allow?.dirs ? {
|
|
214
|
+
addDirs: config.allow.dirs
|
|
215
|
+
.filter((d) => !d.startsWith('-'))
|
|
216
|
+
.map((d) => d.replace(/^~/, os.homedir())),
|
|
217
|
+
} : {}),
|
|
218
|
+
};
|
|
219
|
+
const { runLoop } = await import('./loop.js');
|
|
220
|
+
const loopResult = await runLoop(execOptions, config.loop, {
|
|
221
|
+
runId,
|
|
222
|
+
runDir,
|
|
223
|
+
agent: effectiveAgent,
|
|
224
|
+
version: config.version,
|
|
225
|
+
}, deps);
|
|
226
|
+
meta.status = loopResult.stoppedBy === 'error' ? 'failed' : 'completed';
|
|
227
|
+
meta.completedAt = new Date().toISOString();
|
|
228
|
+
meta.exitCode = loopResult.stoppedBy === 'error' ? 1 : 0;
|
|
229
|
+
writeRunMeta(meta);
|
|
230
|
+
timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
|
|
231
|
+
return { meta, reportPath: null };
|
|
232
|
+
}
|
|
233
|
+
// Single-shot path (no loop): build the command, open a log file, and spawn once.
|
|
234
|
+
const cmd = buildJobCommand(config, resolvedPrompt);
|
|
235
|
+
const stdoutPath = path.join(runDir, 'stdout.log');
|
|
236
|
+
const stdoutFd = fs.openSync(stdoutPath, 'w', 0o600);
|
|
195
237
|
return new Promise((resolve) => {
|
|
196
238
|
const child = spawn(cmd[0], cmd.slice(1), {
|
|
197
239
|
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
198
|
-
|
|
240
|
+
...backgroundSpawnOptions({ fdStdio: true }),
|
|
199
241
|
env: spawnEnv,
|
|
200
242
|
});
|
|
201
243
|
// Mark startup time (time from function call to process spawn)
|
|
@@ -290,7 +332,7 @@ export async function executeJobDetached(config) {
|
|
|
290
332
|
};
|
|
291
333
|
const child = spawn(cmd[0], cmd.slice(1), {
|
|
292
334
|
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
293
|
-
|
|
335
|
+
...backgroundSpawnOptions({ fdStdio: true }),
|
|
294
336
|
env: spawnEnv,
|
|
295
337
|
});
|
|
296
338
|
child.unref();
|
package/dist/lib/scheduler.js
CHANGED
|
@@ -17,12 +17,17 @@ export class JobScheduler {
|
|
|
17
17
|
loadAll() {
|
|
18
18
|
const configs = listJobs();
|
|
19
19
|
for (const config of configs) {
|
|
20
|
-
|
|
20
|
+
// Trigger-only jobs (no cron schedule) fire via the webhook receiver,
|
|
21
|
+
// not the cron loop — skip them here.
|
|
22
|
+
if (config.enabled && config.schedule) {
|
|
21
23
|
this.schedule(config);
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
schedule(config) {
|
|
28
|
+
// A schedule-less (trigger-only) job has nothing to hand to croner.
|
|
29
|
+
if (!config.schedule)
|
|
30
|
+
return;
|
|
26
31
|
this.unschedule(config.name);
|
|
27
32
|
// catch: true — a throw from one job's callback should not kill the
|
|
28
33
|
// whole cron loop. Each invocation of onTrigger is already wrapped in
|
|
@@ -48,13 +48,20 @@ export interface VarMeta {
|
|
|
48
48
|
* - `always`: asks every time. Never auto-held — only an explicit `agents
|
|
49
49
|
* secrets unlock` ever holds it; every other read pops Touch ID. Opt a
|
|
50
50
|
* high-value bundle into this when you want to confirm every single read.
|
|
51
|
+
* - `never`: stored WITHOUT the biometry access control — reads are fully
|
|
52
|
+
* silent (no Touch ID, no broker). The least-safe tier: any code running as
|
|
53
|
+
* the user reads it with no user-presence check. Reserved for low-sensitivity,
|
|
54
|
+
* automation-only credentials. Writing a `never` item needs the signed helper's
|
|
55
|
+
* `set-no-acl` path (see keychain-helper.swift); an older pinned helper rejects
|
|
56
|
+
* it loudly rather than silently downgrading to `always`.
|
|
51
57
|
*
|
|
52
58
|
* The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
|
|
53
59
|
* under the legacy `tier` key (`session` == `daily`, `biometry` == explicit
|
|
54
|
-
* `always`, absent == inherit the default) so bundles stay
|
|
55
|
-
* CLI versions on synced machines. The user-facing
|
|
60
|
+
* `always`, `none` == `never`, absent == inherit the default) so bundles stay
|
|
61
|
+
* readable across mixed CLI versions on synced machines. The user-facing
|
|
62
|
+
* vocabulary is `policy`/`always`/`daily`/`never`.
|
|
56
63
|
*/
|
|
57
|
-
export type SecretsPolicy = 'always' | 'daily';
|
|
64
|
+
export type SecretsPolicy = 'always' | 'daily' | 'never';
|
|
58
65
|
/** A named set of environment variable definitions backed by various secret providers. */
|
|
59
66
|
export interface SecretsBundle {
|
|
60
67
|
name: string;
|
|
@@ -131,7 +138,54 @@ export interface ResolveBundleOptions {
|
|
|
131
138
|
* needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
|
|
132
139
|
*/
|
|
133
140
|
noAgent?: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Inject only this subset of keys from the bundle. Keys not in this list are
|
|
143
|
+
* silently excluded from the returned env map. An error is thrown if any
|
|
144
|
+
* requested key is absent from the bundle (fail-loud, never silent skip).
|
|
145
|
+
* When absent or empty, all keys are injected (original behaviour).
|
|
146
|
+
*/
|
|
147
|
+
keys?: string[];
|
|
148
|
+
/**
|
|
149
|
+
* When true, skip the pre-run expiry check and inject keys even if their
|
|
150
|
+
* `expires` date is in the past. By default any expired key (or a key whose
|
|
151
|
+
* bundle-level expiry has passed) aborts the run before Touch ID is popped.
|
|
152
|
+
*/
|
|
153
|
+
allowExpired?: boolean;
|
|
134
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Apply the --keys subset + expiry gate to an already-resolved snapshot from
|
|
157
|
+
* the secrets-agent fast-path. The agent stores the FULL bundle env, so a
|
|
158
|
+
* naive fast-path return would silently defeat --keys and inject expired
|
|
159
|
+
* values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
160
|
+
* `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
|
|
161
|
+
*
|
|
162
|
+
* Exported for tests; production callers reach it via the fast-path branch in
|
|
163
|
+
* `readAndResolveBundleEnv`.
|
|
164
|
+
*/
|
|
165
|
+
export declare function filterAgentHitBySubsetAndExpiry(hit: {
|
|
166
|
+
bundle: SecretsBundle;
|
|
167
|
+
env: Record<string, string>;
|
|
168
|
+
}, opts: ResolveBundleOptions): {
|
|
169
|
+
bundle: SecretsBundle;
|
|
170
|
+
env: Record<string, string>;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Guard for remote-bundle callers (`bundle@host` / `--host`) — the SSH
|
|
174
|
+
* resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
|
|
175
|
+
* yet. Silently applying them would inject the full remote env or an expired
|
|
176
|
+
* value, defeating the least-privilege intent, so we fail loud.
|
|
177
|
+
*
|
|
178
|
+
* Exported so `agents run --secrets bundle@host` and `agents secrets exec
|
|
179
|
+
* --host` share the exact same error text; the tests exercise this helper
|
|
180
|
+
* directly instead of driving the whole CLI.
|
|
181
|
+
*/
|
|
182
|
+
export declare function assertRemoteBundleFlagsUnsupported(bundleName: string, host: string, opts: {
|
|
183
|
+
keys?: string[];
|
|
184
|
+
allowExpired?: boolean;
|
|
185
|
+
}, flagLabels: {
|
|
186
|
+
keysFlag: string;
|
|
187
|
+
allowExpiredFlag: string;
|
|
188
|
+
}): void;
|
|
135
189
|
export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
|
|
136
190
|
/**
|
|
137
191
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
@@ -193,7 +247,9 @@ export declare function renameBundle(oldName: string, newName: string, opts?: Re
|
|
|
193
247
|
* `import` / `remove` / `delete`. Pass the bundle's resolved backend
|
|
194
248
|
* (`bundle.backend ?? 'keychain'`).
|
|
195
249
|
*/
|
|
196
|
-
export declare function bundleItemStore(backend: SecretsBackend | undefined
|
|
250
|
+
export declare function bundleItemStore(backend: SecretsBackend | undefined, opts?: {
|
|
251
|
+
noAcl?: boolean;
|
|
252
|
+
}): {
|
|
197
253
|
set(item: string, value: string): void;
|
|
198
254
|
delete(item: string): boolean;
|
|
199
255
|
get(item: string): string;
|
|
@@ -257,6 +257,8 @@ function parsePolicy(raw) {
|
|
|
257
257
|
return 'daily';
|
|
258
258
|
if (raw === 'always' || raw === 'biometry')
|
|
259
259
|
return 'always';
|
|
260
|
+
if (raw === 'never' || raw === 'none')
|
|
261
|
+
return 'never';
|
|
260
262
|
return undefined;
|
|
261
263
|
}
|
|
262
264
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
@@ -314,9 +316,14 @@ export function writeBundle(bundle) {
|
|
|
314
316
|
backend: backend === 'file' ? 'file' : undefined,
|
|
315
317
|
// Wire format: persist the policy under the legacy `tier` token so older CLI
|
|
316
318
|
// versions on other synced machines keep reading it — `daily`⇒`session`,
|
|
317
|
-
// explicit `always`⇒`biometry`. An absent policy omits the
|
|
318
|
-
// and resolves to the configured default (`daily`) on read.
|
|
319
|
-
|
|
319
|
+
// explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
|
|
320
|
+
// token entirely and resolves to the configured default (`daily`) on read.
|
|
321
|
+
// An older CLI that doesn't know `none` reads it as undefined and falls back
|
|
322
|
+
// to its own default — safe, since it also lacks the no-ACL write path.
|
|
323
|
+
tier: bundle.policy === 'daily' ? 'session'
|
|
324
|
+
: bundle.policy === 'always' ? 'biometry'
|
|
325
|
+
: bundle.policy === 'never' ? 'none'
|
|
326
|
+
: undefined,
|
|
320
327
|
created_at: bundle.created_at,
|
|
321
328
|
updated_at: bundle.updated_at,
|
|
322
329
|
last_used: bundle.last_used,
|
|
@@ -324,7 +331,11 @@ export function writeBundle(bundle) {
|
|
|
324
331
|
meta,
|
|
325
332
|
};
|
|
326
333
|
const json = JSON.stringify(payload);
|
|
327
|
-
|
|
334
|
+
// A `never` bundle's metadata is stored without the biometry ACL too, so
|
|
335
|
+
// `view` and the metadata half of a read resolve silently — the whole point
|
|
336
|
+
// of the tier. On an un-updated pinned helper this write fails loudly (the
|
|
337
|
+
// no-ACL command is missing) rather than silently landing an ACL'd item.
|
|
338
|
+
itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
|
|
328
339
|
emit('secrets.set', { bundle: bundle.name });
|
|
329
340
|
}
|
|
330
341
|
export function deleteBundle(name) {
|
|
@@ -504,6 +515,87 @@ function stampLastUsed(bundle) {
|
|
|
504
515
|
// Swallow — telemetry must never block secret resolution.
|
|
505
516
|
}
|
|
506
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* Abort if any of the selected keys has an `expires` date in the past.
|
|
520
|
+
* Bundle-level expiry is not a concept today (expiry is per-key via `meta`),
|
|
521
|
+
* so we iterate only the per-key meta entries.
|
|
522
|
+
*/
|
|
523
|
+
function assertNotExpired(bundle, selectedKeys, allowExpired) {
|
|
524
|
+
if (allowExpired)
|
|
525
|
+
return;
|
|
526
|
+
if (!bundle.meta)
|
|
527
|
+
return;
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
for (const key of selectedKeys) {
|
|
530
|
+
const meta = bundle.meta[key];
|
|
531
|
+
if (!meta?.expires)
|
|
532
|
+
continue;
|
|
533
|
+
// expires is 'YYYY-MM-DD'; treat as end-of-day UTC.
|
|
534
|
+
const expiry = new Date(meta.expires + 'T23:59:59Z').getTime();
|
|
535
|
+
if (expiry < now) {
|
|
536
|
+
throw new Error(`Bundle '${bundle.name}' key '${key}' expired on ${meta.expires}. ` +
|
|
537
|
+
`Rotate it with: agents secrets rotate ${bundle.name} ${key}` +
|
|
538
|
+
` (or pass --allow-expired to skip this check).`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Resolve the requested key subset against a bundle's `vars` map. Throws a
|
|
544
|
+
* fail-loud error listing available keys if any requested key is absent. When
|
|
545
|
+
* `requested` is undefined or empty, every key in the bundle is selected.
|
|
546
|
+
*/
|
|
547
|
+
function selectRequestedKeys(bundle, requested) {
|
|
548
|
+
const req = requested?.length ? requested : undefined;
|
|
549
|
+
if (req) {
|
|
550
|
+
const missing = req.filter((k) => !(k in bundle.vars));
|
|
551
|
+
if (missing.length > 0) {
|
|
552
|
+
const available = Object.keys(bundle.vars).join(', ') || '(none)';
|
|
553
|
+
throw new Error(`Bundle '${bundle.name}' does not contain key(s): ${missing.join(', ')}. Available: ${available}.`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return new Set(req ?? Object.keys(bundle.vars));
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Apply the --keys subset + expiry gate to an already-resolved snapshot from
|
|
560
|
+
* the secrets-agent fast-path. The agent stores the FULL bundle env, so a
|
|
561
|
+
* naive fast-path return would silently defeat --keys and inject expired
|
|
562
|
+
* values. Mirrors the slow-path pre-checks in `resolveBundleEnv` /
|
|
563
|
+
* `readAndResolveBundleEnv` and returns a new env whose keys match the subset.
|
|
564
|
+
*
|
|
565
|
+
* Exported for tests; production callers reach it via the fast-path branch in
|
|
566
|
+
* `readAndResolveBundleEnv`.
|
|
567
|
+
*/
|
|
568
|
+
export function filterAgentHitBySubsetAndExpiry(hit, opts) {
|
|
569
|
+
const selectedKeys = selectRequestedKeys(hit.bundle, opts.keys);
|
|
570
|
+
assertNotExpired(hit.bundle, [...selectedKeys], opts.allowExpired ?? false);
|
|
571
|
+
// When no subset was requested, return the cached env untouched — same
|
|
572
|
+
// reference the agent handed back, so no per-call allocation on the hot path.
|
|
573
|
+
if (!opts.keys?.length)
|
|
574
|
+
return hit;
|
|
575
|
+
const env = {};
|
|
576
|
+
for (const key of selectedKeys) {
|
|
577
|
+
if (key in hit.env)
|
|
578
|
+
env[key] = hit.env[key];
|
|
579
|
+
}
|
|
580
|
+
return { bundle: hit.bundle, env };
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Guard for remote-bundle callers (`bundle@host` / `--host`) — the SSH
|
|
584
|
+
* resolver in `remoteResolveEnv` does not thread --keys or --allow-expired
|
|
585
|
+
* yet. Silently applying them would inject the full remote env or an expired
|
|
586
|
+
* value, defeating the least-privilege intent, so we fail loud.
|
|
587
|
+
*
|
|
588
|
+
* Exported so `agents run --secrets bundle@host` and `agents secrets exec
|
|
589
|
+
* --host` share the exact same error text; the tests exercise this helper
|
|
590
|
+
* directly instead of driving the whole CLI.
|
|
591
|
+
*/
|
|
592
|
+
export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
|
|
593
|
+
const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
|
|
594
|
+
if (!hasKeys && !opts.allowExpired)
|
|
595
|
+
return;
|
|
596
|
+
throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
|
|
597
|
+
`Drop the flag or resolve the bundle locally.`);
|
|
598
|
+
}
|
|
507
599
|
// Walk the bundle and produce a flat env map. Every keychain: ref is gathered
|
|
508
600
|
// into a single batch read so macOS shows ONE Touch ID prompt for the whole
|
|
509
601
|
// bundle — including the metadata fetch that already happened in readBundle
|
|
@@ -513,9 +605,14 @@ function stampLastUsed(bundle) {
|
|
|
513
605
|
// resolved inline and never reach the keychain.
|
|
514
606
|
export function resolveBundleEnv(bundle, _opts = {}) {
|
|
515
607
|
stampLastUsed(bundle);
|
|
608
|
+
// Key-subset validation and expiry pre-check.
|
|
609
|
+
const selectedKeys = selectRequestedKeys(bundle, _opts.keys);
|
|
610
|
+
assertNotExpired(bundle, [...selectedKeys], _opts.allowExpired ?? false);
|
|
516
611
|
const parsedByKey = new Map();
|
|
517
612
|
const keychainItemsToFetch = [];
|
|
518
613
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
614
|
+
if (!selectedKeys.has(key))
|
|
615
|
+
continue;
|
|
519
616
|
const parsed = parseBundleValue(raw);
|
|
520
617
|
parsedByKey.set(key, parsed);
|
|
521
618
|
if ('ref' in parsed && parsed.ref.provider === 'keychain') {
|
|
@@ -528,6 +625,8 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
528
625
|
: new Map();
|
|
529
626
|
const env = {};
|
|
530
627
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
628
|
+
if (!selectedKeys.has(key))
|
|
629
|
+
continue;
|
|
531
630
|
const parsed = parsedByKey.get(key);
|
|
532
631
|
if ('literal' in parsed) {
|
|
533
632
|
env[key] = parsed.literal;
|
|
@@ -582,15 +681,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
582
681
|
if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
583
682
|
const hit = agentGetSync(name);
|
|
584
683
|
if (hit) {
|
|
585
|
-
|
|
684
|
+
// The agent stores the FULL bundle env. Apply the same subset filter and
|
|
685
|
+
// expiry gate as the slow path — without this, `--secrets-keys X` would
|
|
686
|
+
// silently inject every key and an expired key would flow through after
|
|
687
|
+
// the first cache-populating run.
|
|
688
|
+
const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
|
|
689
|
+
stampLastUsed(filtered.bundle);
|
|
586
690
|
emit('secrets.get', {
|
|
587
691
|
bundle: name,
|
|
588
692
|
caller: opts.caller,
|
|
589
693
|
status: 'success',
|
|
590
694
|
source: 'agent',
|
|
591
|
-
keyCount: Object.keys(
|
|
695
|
+
keyCount: Object.keys(filtered.env).length,
|
|
592
696
|
});
|
|
593
|
-
return
|
|
697
|
+
return filtered;
|
|
594
698
|
}
|
|
595
699
|
}
|
|
596
700
|
if (backend === 'file')
|
|
@@ -651,11 +755,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
651
755
|
for (const key of Object.keys(bundle.vars)) {
|
|
652
756
|
validateEnvKey(key);
|
|
653
757
|
}
|
|
758
|
+
// Key-subset validation and expiry pre-check (mirrors resolveBundleEnv logic).
|
|
759
|
+
const selectedKeys = selectRequestedKeys(bundle, opts.keys);
|
|
760
|
+
assertNotExpired(bundle, [...selectedKeys], opts.allowExpired ?? false);
|
|
654
761
|
stampLastUsed(bundle);
|
|
655
762
|
const parsedByKey = new Map();
|
|
656
763
|
const keychainKeys = [];
|
|
657
764
|
const kindCounts = {};
|
|
658
765
|
for (const [key, raw] of Object.entries(bundle.vars)) {
|
|
766
|
+
if (!selectedKeys.has(key))
|
|
767
|
+
continue;
|
|
659
768
|
const p = parseBundleValue(raw);
|
|
660
769
|
parsedByKey.set(key, p);
|
|
661
770
|
const kind = 'literal' in p ? 'literal' : p.ref.provider;
|
|
@@ -664,7 +773,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
664
773
|
keychainKeys.push(key);
|
|
665
774
|
}
|
|
666
775
|
}
|
|
667
|
-
const keys =
|
|
776
|
+
const keys = [...selectedKeys].sort();
|
|
668
777
|
keychainKeys.sort();
|
|
669
778
|
const emitReadAudit = (status, err) => {
|
|
670
779
|
emit('secrets.get', {
|
|
@@ -681,6 +790,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
681
790
|
try {
|
|
682
791
|
const env = {};
|
|
683
792
|
for (const [key] of Object.entries(bundle.vars)) {
|
|
793
|
+
if (!selectedKeys.has(key))
|
|
794
|
+
continue;
|
|
684
795
|
const p = parsedByKey.get(key);
|
|
685
796
|
if ('literal' in p) {
|
|
686
797
|
env[key] = p.literal;
|
|
@@ -749,7 +860,7 @@ export function rotateBundleSecret(bundle, key, opts) {
|
|
|
749
860
|
}
|
|
750
861
|
const shortId = raw.slice('keychain:'.length);
|
|
751
862
|
const item = secretsKeychainItem(bundle.name, shortId);
|
|
752
|
-
itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue);
|
|
863
|
+
itemStore(bundle.backend ?? 'keychain').set(item, opts.newValue, { noAcl: bundlePolicy(bundle) === 'never' });
|
|
753
864
|
if (opts.clearMeta) {
|
|
754
865
|
if (bundle.meta)
|
|
755
866
|
delete bundle.meta[key];
|
|
@@ -817,7 +928,7 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
817
928
|
const shortId = raw.slice('keychain:'.length);
|
|
818
929
|
const newItem = secretsKeychainItem(newName, shortId);
|
|
819
930
|
const value = store.get(oldItem);
|
|
820
|
-
store.set(newItem, value);
|
|
931
|
+
store.set(newItem, value, { noAcl: bundlePolicy(source) === 'never' });
|
|
821
932
|
}
|
|
822
933
|
// writeBundle preserves source.created_at, refreshes updated_at, and keeps
|
|
823
934
|
// the source backend (spread carries source.backend).
|
|
@@ -837,8 +948,16 @@ export function renameBundle(oldName, newName, opts = {}) {
|
|
|
837
948
|
* `import` / `remove` / `delete`. Pass the bundle's resolved backend
|
|
838
949
|
* (`bundle.backend ?? 'keychain'`).
|
|
839
950
|
*/
|
|
840
|
-
export function bundleItemStore(backend) {
|
|
841
|
-
|
|
951
|
+
export function bundleItemStore(backend, opts) {
|
|
952
|
+
const store = itemStore(backend ?? 'keychain');
|
|
953
|
+
// `never`-policy bundles write their per-key values without the biometry ACL
|
|
954
|
+
// (same rationale as the metadata write in writeBundle). Wrap `set` so every
|
|
955
|
+
// value the add/import paths write inherits the no-ACL flag; reads, deletes,
|
|
956
|
+
// and existence checks are ACL-independent and pass through untouched.
|
|
957
|
+
if (opts?.noAcl) {
|
|
958
|
+
return { ...store, set: (item, value) => store.set(item, value, { noAcl: true }) };
|
|
959
|
+
}
|
|
960
|
+
return store;
|
|
842
961
|
}
|
|
843
962
|
// Iterate all keychain-backed keys in a bundle for cleanup on rm/unset.
|
|
844
963
|
export function keychainItemsForBundle(bundle) {
|
|
@@ -74,9 +74,12 @@ export declare const fileStore: {
|
|
|
74
74
|
/** File-only KeychainBackend (exported for tests; the Linux backend uses these
|
|
75
75
|
* ops with auto-provision allowed). */
|
|
76
76
|
export declare const fileBackend: KeychainBackend;
|
|
77
|
+
/** Resolved passphrase directory (exported for integration tests). */
|
|
78
|
+
export declare function resolvePassphraseDir(): string;
|
|
77
79
|
/** Test-only: reset module state (file dir + cached passphrase). */
|
|
78
80
|
export declare function _resetFileStoreForTest(opts?: {
|
|
79
81
|
fileDir?: string | null;
|
|
82
|
+
passphraseDir?: string | null;
|
|
80
83
|
passphrase?: string | null;
|
|
81
84
|
}): void;
|
|
82
85
|
export {};
|
|
@@ -28,6 +28,7 @@ import * as path from 'path';
|
|
|
28
28
|
import { encodePwshBase64 } from '../pwsh.js';
|
|
29
29
|
// ---------- file store location ----------
|
|
30
30
|
let fileDirOverride = null;
|
|
31
|
+
let passphraseDirOverride = null;
|
|
31
32
|
let cachedPassphrase = null;
|
|
32
33
|
let warnedAutoPassphrase = false;
|
|
33
34
|
export function fileDir() {
|
|
@@ -106,29 +107,40 @@ function readPassphraseFromTty() {
|
|
|
106
107
|
fs.closeSync(fd);
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
110
|
+
/**
|
|
111
|
+
* Directory for the auto-provisioned machine-local passphrase. Kept outside
|
|
112
|
+
* `fileDir()` so a scan of the encrypted store never co-locates key + ciphertext.
|
|
113
|
+
*/
|
|
114
|
+
function passphraseDir() {
|
|
115
|
+
return passphraseDirOverride ?? path.join(os.homedir(), '.agents', '.secrets-key');
|
|
116
|
+
}
|
|
117
|
+
function ensurePassphraseDir() {
|
|
118
|
+
fs.mkdirSync(passphraseDir(), { recursive: true, mode: 0o700 });
|
|
119
|
+
}
|
|
120
|
+
/** Path of the auto-provisioned machine-local passphrase (not an `.enc` item). */
|
|
112
121
|
function passphraseFilePath() {
|
|
122
|
+
return path.join(passphraseDir(), 'passphrase');
|
|
123
|
+
}
|
|
124
|
+
/** Legacy co-located path — read-only for machines provisioned before #479. */
|
|
125
|
+
function legacyPassphraseFilePath() {
|
|
113
126
|
return path.join(fileDir(), '.passphrase');
|
|
114
127
|
}
|
|
115
128
|
/** True if a machine-local passphrase has already been provisioned. */
|
|
116
129
|
export function machinePassphraseExists() {
|
|
117
|
-
|
|
118
|
-
return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
130
|
+
return readMachinePassphrase() !== null;
|
|
123
131
|
}
|
|
124
132
|
function readMachinePassphrase() {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
for (const fp of [passphraseFilePath(), legacyPassphraseFilePath()]) {
|
|
134
|
+
try {
|
|
135
|
+
const p = fs.readFileSync(fp, 'utf8').trim();
|
|
136
|
+
if (p.length > 0)
|
|
137
|
+
return p;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// try next location
|
|
141
|
+
}
|
|
131
142
|
}
|
|
143
|
+
return null;
|
|
132
144
|
}
|
|
133
145
|
/**
|
|
134
146
|
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
@@ -146,7 +158,7 @@ function provisionMachinePassphrase() {
|
|
|
146
158
|
const existing = readMachinePassphrase();
|
|
147
159
|
if (existing)
|
|
148
160
|
return existing;
|
|
149
|
-
|
|
161
|
+
ensurePassphraseDir();
|
|
150
162
|
const generated = randomBytes(32).toString('base64');
|
|
151
163
|
const fp = passphraseFilePath();
|
|
152
164
|
try {
|
|
@@ -317,9 +329,23 @@ export const fileBackend = {
|
|
|
317
329
|
delete: fileDelete,
|
|
318
330
|
list: fileList,
|
|
319
331
|
};
|
|
332
|
+
/** Resolved passphrase directory (exported for integration tests). */
|
|
333
|
+
export function resolvePassphraseDir() {
|
|
334
|
+
return passphraseDir();
|
|
335
|
+
}
|
|
320
336
|
/** Test-only: reset module state (file dir + cached passphrase). */
|
|
321
337
|
export function _resetFileStoreForTest(opts = {}) {
|
|
322
338
|
fileDirOverride = opts.fileDir ?? null;
|
|
339
|
+
if (opts.passphraseDir !== undefined) {
|
|
340
|
+
passphraseDirOverride = opts.passphraseDir;
|
|
341
|
+
}
|
|
342
|
+
else if (opts.fileDir) {
|
|
343
|
+
// Hermetic sibling when only the store dir is overridden (linux.test.ts).
|
|
344
|
+
passphraseDirOverride = path.resolve(opts.fileDir, '..', `${path.basename(opts.fileDir)}-key`);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
passphraseDirOverride = null;
|
|
348
|
+
}
|
|
323
349
|
cachedPassphrase = opts.passphrase ?? null;
|
|
324
350
|
warnedAutoPassphrase = false;
|
|
325
351
|
}
|
|
@@ -103,8 +103,16 @@ export declare function getKeychainToken(item: string): string;
|
|
|
103
103
|
* lookups — no biometric prompt path on those platforms.
|
|
104
104
|
*/
|
|
105
105
|
export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
106
|
-
/** Store or update a secret value in the keychain/keyring. Device-local;
|
|
107
|
-
|
|
106
|
+
/** Store or update a secret value in the keychain/keyring. Device-local;
|
|
107
|
+
* biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
|
|
108
|
+
* item WITHOUT the biometry access control so later reads are fully silent — it
|
|
109
|
+
* routes through the signed helper's `set-no-acl` path. A pinned helper that
|
|
110
|
+
* predates that path rejects the unknown command (exit 2) and this throws,
|
|
111
|
+
* rather than silently falling back to an ACL'd `set` (which would behave like
|
|
112
|
+
* `always`). Ignored by the Linux/Windows/test backends, which have no ACL. */
|
|
113
|
+
export declare function setKeychainToken(item: string, value: string, opts?: {
|
|
114
|
+
noAcl?: boolean;
|
|
115
|
+
}): void;
|
|
108
116
|
/** Delete a keychain/keyring item. Returns true if it existed. Never prompts for biometry. */
|
|
109
117
|
export declare function deleteKeychainToken(item: string): boolean;
|
|
110
118
|
/**
|
|
@@ -140,6 +148,39 @@ export declare function listLegacyKeychainItems(prefix: string): string[];
|
|
|
140
148
|
* concept, so the call is a no-op there.
|
|
141
149
|
*/
|
|
142
150
|
export declare function migrateKeychainItem(item: string): boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Enumerate data-protection items whose service starts with `prefix` that live
|
|
153
|
+
* under a NON-concrete access group — pre-#279 "orphans" filed under the implicit
|
|
154
|
+
* default group (the literal `2HTP252L87.*`) that the pinned-group queries can't
|
|
155
|
+
* see. Attributes only: never decrypts, never prompts. macOS only — Linux/Windows
|
|
156
|
+
* and the test backend have no access-group concept, so this returns [].
|
|
157
|
+
*/
|
|
158
|
+
export declare function listOrphanedKeychainItems(prefix: string): string[];
|
|
159
|
+
/** Outcome of re-homing one orphaned keychain item. */
|
|
160
|
+
export interface OrphanMigrationResult {
|
|
161
|
+
item: string;
|
|
162
|
+
status: 'ok' | 'warn' | 'fail';
|
|
163
|
+
detail?: string;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Parse the `migrate-orphans` helper summary (one record per line):
|
|
167
|
+
* OK <service> re-homed
|
|
168
|
+
* WARN <service> <detail> pinned copy written but orphan not removed
|
|
169
|
+
* FAIL <service> <detail> could not re-home (orphan left intact)
|
|
170
|
+
* Unknown lines are ignored. Exported for unit testing without a keychain.
|
|
171
|
+
*/
|
|
172
|
+
export declare function parseOrphanMigrationOutput(stdout: string): OrphanMigrationResult[];
|
|
173
|
+
/**
|
|
174
|
+
* Re-home every pre-#279 orphaned data-protection item under `prefix` into the
|
|
175
|
+
* concrete access group, behind a SINGLE Touch ID prompt for the whole batch.
|
|
176
|
+
* The helper reads each orphan by its exact persistent ref, adds the pinned copy
|
|
177
|
+
* (add-before-delete: a failed add leaves the orphan intact), then deletes the
|
|
178
|
+
* orphan by ref. Returns one result per item. macOS only — no-op elsewhere.
|
|
179
|
+
*
|
|
180
|
+
* Throws on Touch ID cancellation (exit 4) so callers can distinguish "user
|
|
181
|
+
* aborted" from "nothing to do" (empty array).
|
|
182
|
+
*/
|
|
183
|
+
export declare function migrateOrphanedKeychainItems(prefix: string): OrphanMigrationResult[];
|
|
143
184
|
/** Options controlling how secret refs are resolved. */
|
|
144
185
|
export interface ResolveOptions {
|
|
145
186
|
/** Translate a short keychain ID to a fully namespaced item name. */
|