Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.1.2

- **A push can pin the remote `secrets` to the consumer's state root (`remoteSecretsHome`), so a bundle lands where the consumer reads it.** agents-cli runs its local standalone under `SECRETS_HOME=~/.agents` (MIG-1) and reads pushed bundles from that same root on the receiving box, but every remote invocation of `pushBundleToHost` — the `import`, the read-back verify, literal restoration — ran with no env prologue, so the remote defaulted to `~/.secrets`, the import reported success, and the consumer's daemon never saw the bundle (observed 2026-09-07: zion logged `pushed __cursor__ (1 key(s)) to yosemite-m0` while the worker kept answering `no readable durable key on this box yet`; the file store is keyed per root, so the two roots cannot even share ciphertext). `PushBundleOptions.remoteSecretsHome` names that root; `remoteStateRootEnv` turns a leading `~/` into `$HOME/` (POSIX) or `$env:USERPROFILE\` (PowerShell), emitted in the expanding quote form (`SECRETS_HOME` joins `PATH` as an expanding env key), and the env rides `buildRemoteFileImportCommand`, `buildWindowsStdinImportCommand`, `remoteSecretsRaw[Async]` (`env` option) and `verifyRemoteKeychainPush[Async]`. Unset keeps today's behaviour. Source: `src/lib/secrets/push.ts`, `src/lib/secrets/remote.ts`, `src/lib/transports/ssh.ts`, and their tests.

## 0.1.1

- **Bundle names accept the reserved `__<name>__` shape, so a consumer-managed store moves through the ordinary bundle transport.** agents-cli keeps one `__<harness>__` file-backed store per harness for durable worker credentials (`CURSOR_API_KEY_<accountId>`, `OPENAI_API_KEY_<accountId>`, …) and pushes it to worker devices with the same `pushBundleToHost` every other bundle uses — which reads the store through `readAndResolveBundleEnv` and imports it remotely with `secrets import <bundle> --from - --backend file`. Both ends ran `validateBundleName`, whose pattern rejected any name starting with `_`, so every push of a `__cursor__` store failed before the SSH hop with `Invalid bundle name`, surfaced to the daemon only as `Secrets operation failed (OPERATION_FAILED)`, and no worker ever received a Cursor, Codex or Grok worker key. `BUNDLE_NAME_PATTERN` now also matches exactly `__<name>__` (a bare leading `__` is still invalid); `create`, `rename`, `add` and `describe` refuse that shape when typed by hand (`assertNotReservedShape`), since the store belongs to the consumer that manages it — a hand `rename --force` onto it would purge its items. Source: `src/lib/secrets/bundles.ts`, `src/lib/secrets/__tests__/bundles.test.ts`, `src/commands/secrets.ts`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@phnx-labs/secrets-cli",
"version": "0.1.1",
"version": "0.1.2",
"description": "Portable secret bundles, native stores and provider-backed injection",
"type": "module",
"bin": {
Expand Down
24 changes: 24 additions & 0 deletions src/lib/secrets/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe('planPushTransport — which transport a backend/OS pair selects', () =
kind: 'remote-secrets',
args: ['import', 'apple.com', '--from', '-'],
input: RESOLVED.dotenv,
env: {},
});
});

Expand Down Expand Up @@ -144,6 +145,29 @@ describe('planPushTransport — which transport a backend/OS pair selects', () =
expect(t.input).toBe(RESOLVED.dotenv);
});

it('pins the remote secrets to the consumer state root on every transport when remoteSecretsHome is named', () => {
// agents-cli runs its local standalone under SECRETS_HOME=~/.agents and reads
// pushed bundles from that root on the worker; a push without this landed in
// the remote's default ~/.secrets, where the consumer never looked.
const file = planPushTransport(RESOLVED, '__cursor__', 'push-test-linux', { remoteBackend: 'file', operation: 'push.test', remoteSecretsHome: '~/.agents' });
if (file.kind !== 'ssh') throw new Error('unreachable');
expect(file.remoteCmd).toContain('export SECRETS_HOME="$HOME/.agents"; secrets import');
expect(file.remoteCmd).not.toContain('SECRETS_PASSPHRASE');

const keychain = planPushTransport(RESOLVED, 'apple.com', 'push-test-linux', { remoteBackend: 'keychain', operation: 'push.test', remoteSecretsHome: '~/.agents' });
if (keychain.kind !== 'remote-secrets') throw new Error('unreachable');
expect(keychain.env).toEqual({ SECRETS_HOME: '$HOME/.agents' });

const win = planPushTransport(RESOLVED, 'apple.com', 'push-test-win', { remoteBackend: 'keychain', operation: 'push.test', remoteSecretsHome: '~/.agents' });
if (win.kind !== 'ssh') throw new Error('unreachable');
expect(win.remoteCmd).toContain('-EncodedCommand ');

// Unset: no prologue, the remote keeps its own default root.
const bare = plan('push-test-linux', 'file');
if (bare.kind !== 'ssh') throw new Error('unreachable');
expect(bare.remoteCmd).not.toContain('SECRETS_HOME');
});

it('drops a caller-supplied passphrase rather than forwarding it (PHNX-2371)', () => {
const t = planPushTransport(RESOLVED, 'apple.com', 'push-test-linux', {
remoteBackend: 'file',
Expand Down
34 changes: 28 additions & 6 deletions src/lib/secrets/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
buildWindowsStdinImportCommand,
resolveRemoteOs,
resolveRemoteOsAsync,
remoteStateRootEnv,
type SshExecResult,
} from '../transports/ssh.js';
import {
Expand Down Expand Up @@ -92,6 +93,15 @@ export interface PushBundleOptions {
literalValues?: Record<string, string>;
/** Per-SSH-operation deadline. Async daemon callers must set this explicitly. */
timeoutMs?: number;
/**
* State root (`SECRETS_HOME`) the remote `secrets` runs under for the import,
* the read-back verify and any literal restoration. Remote-relative: a leading
* `~/` resolves against the remote user's home on both shells
* (`remoteStateRootEnv`). A consumer that runs its local standalone under its
* own root (agents-cli: `~/.agents`) must name that root here, or the bundle
* lands in the remote's default `~/.secrets` where the consumer never reads it.
*/
remoteSecretsHome?: string;
}

export interface PushBundleResult {
Expand Down Expand Up @@ -173,7 +183,7 @@ export type PushTransport =
*/
| { kind: 'ssh'; remoteCmd: string; input: string }
/** The OS-aware `secrets` wrapper, the READ inverse's own path. */
| { kind: 'remote-secrets'; args: string[]; input: string };
| { kind: 'remote-secrets'; args: string[]; input: string; env: Record<string, string> };

/** Choose the transport for one push. Pure: registry read in, plan out. */
export function planPushTransport(
Expand All @@ -183,6 +193,7 @@ export function planPushTransport(
opts: PushBundleOptions,
): PushTransport {
const powershell = isPowershellTarget(host);
const env = remoteStateRootEnv(opts.remoteSecretsHome, resolveRemoteOs(host));
if (opts.remoteBackend === 'file') {
// Both file-backend paths build a POSIX `bash -lc` command. Refuse a Windows
// target cleanly rather than emit broken PowerShell (fail loud at the
Expand All @@ -193,6 +204,7 @@ export function planPushTransport(
const { remoteCmd, input } = buildRemoteFileImportCommand(bundle, resolved.dotenv, {
force: opts.force,
policyNever: opts.policyNever,
env,
});
return { kind: 'ssh', remoteCmd, input };
}
Expand All @@ -204,7 +216,7 @@ export function planPushTransport(
// the wire over ssh stdin.
return {
kind: 'ssh',
remoteCmd: buildWindowsStdinImportCommand(bundle, { force: opts.force, policyNever: opts.policyNever }),
remoteCmd: buildWindowsStdinImportCommand(bundle, { force: opts.force, policyNever: opts.policyNever, env }),
input: resolved.dotenv,
};
}
Expand All @@ -219,6 +231,7 @@ export function planPushTransport(
...(opts.policyNever ? ['--policy', 'never', '--i-understand'] : []),
],
input: resolved.dotenv,
env,
};
}

Expand All @@ -240,14 +253,18 @@ export function pushResolvedBundleToHost(

const plan = planPushTransport(resolved, bundle, host, opts);
if (plan.kind === 'refuse') return fail(plan.message);
// The consumer state root rides EVERY remote invocation below, not just the
// import: a verify or literal step run under the remote's default root would
// read (or write) a different store than the one the import filled.
const env = remoteStateRootEnv(opts.remoteSecretsHome, resolveRemoteOs(host));
// Every ssh below rides the credential-transport posture: the managed host key
// is pinned (a changed key is refused). There is no multiplexing to disable —
// every connection is its own (REMOTE-1, `credentialTransportSshOpts`). The
// `remote-secrets` branch and the read-back/policy/literal follow-ups pass
// `secret: true` for the same posture.
const res: SshExecResult = plan.kind === 'ssh'
? sshExec(host, plan.remoteCmd, { input: plan.input, timeoutMs: opts.timeoutMs, hostKeyOpts: credentialTransportSshOpts(host).hostKeyOpts })
: remoteSecretsRaw(host, plan.args, { input: plan.input, timeoutMs: opts.timeoutMs, osLookupName: host, secret: true });
: remoteSecretsRaw(host, plan.args, { input: plan.input, timeoutMs: opts.timeoutMs, osLookupName: host, secret: true, env: plan.env });

if (res.code === null) {
return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
Expand All @@ -264,7 +281,7 @@ export function pushResolvedBundleToHost(
// - file: a forwarded SECRETS_PASSPHRASE keys ciphertext to a secret
// the destination daemon does not hold (PHNX-2371).
// Read the bundle back the way a later resolve will and FAIL LOUDLY.
const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs, env });
if (!verdict.ok) {
if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
Expand All @@ -281,12 +298,12 @@ export function pushResolvedBundleToHost(
}

for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs, env });
if (removed.code !== 0) {
const msg = (removed.stderr || removed.stdout || '').trim();
return fail(`pushed '${bundle}' but could not replace transported ${step.key}${msg ? `: ${msg}` : ''}`);
}
const literal = remoteSecretsRaw(host, step.addArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
const literal = remoteSecretsRaw(host, step.addArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs, env });
if (literal.code !== 0) {
const msg = (literal.stderr || literal.stdout || '').trim();
return fail(`pushed '${bundle}' but could not preserve literal ${step.key}${msg ? `: ${msg}` : ''}`);
Expand Down Expand Up @@ -331,6 +348,7 @@ export async function pushResolvedBundleToHostAsync(
await resolveRemoteOsAsync(host, { timeoutMs: opts.timeoutMs });
const plan = planPushTransport(resolved, bundle, host, opts);
if (plan.kind === 'refuse') return fail(plan.message);
const env = remoteStateRootEnv(opts.remoteSecretsHome, resolveRemoteOs(host));
const res = plan.kind === 'ssh'
? await sshExecAsync(host, plan.remoteCmd, {
input: plan.input,
Expand All @@ -341,6 +359,7 @@ export async function pushResolvedBundleToHostAsync(
input: plan.input,
timeoutMs: opts.timeoutMs,
osLookupName: host,
env: plan.env,
secret: true,
});

Expand All @@ -352,6 +371,7 @@ export async function pushResolvedBundleToHostAsync(

const verdict = await verifyRemoteKeychainPushAsync(host, bundle, Object.keys(resolved.env), {
osLookupName: host,
env,
secret: true,
timeoutMs: opts.timeoutMs,
});
Expand All @@ -373,6 +393,7 @@ export async function pushResolvedBundleToHostAsync(
for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
const removed = await remoteSecretsRawAsync(host, step.removeArgs, {
osLookupName: host,
env,
secret: true,
timeoutMs: opts.timeoutMs,
});
Expand All @@ -382,6 +403,7 @@ export async function pushResolvedBundleToHostAsync(
}
const literal = await remoteSecretsRawAsync(host, step.addArgs, {
osLookupName: host,
env,
secret: true,
timeoutMs: opts.timeoutMs,
});
Expand Down
21 changes: 12 additions & 9 deletions src/lib/secrets/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface RemoteSecretsRawOptions {
osLookupName?: string;
secret?: boolean;
timeoutMs?: number;
/** Env scoped to the remote invocation (e.g. the consumer state root, `remoteStateRootEnv`). */
env?: Record<string, string>;
}

/** The host part of an ssh target (`user@host` -> `host`) for known_hosts matching. */
Expand Down Expand Up @@ -143,7 +145,7 @@ export function remoteSecretsRaw(
args: string[],
opts: RemoteSecretsRawOptions = {},
): SshExecResult {
const remoteCmd = buildRemoteSecretsInvocation(args, osForTarget(target, opts.osLookupName));
const remoteCmd = buildRemoteSecretsInvocation(args, osForTarget(target, opts.osLookupName), opts.env);
// `secret: true` pins the managed host key; `-tt` allocates a PTY. They compose.
const posture = opts.secret ? credentialTransportSshOpts(target) : {};
const conn = opts.tty ? { ...posture, extraSshArgs: ['-tt'] } : posture;
Expand All @@ -162,7 +164,7 @@ export async function remoteSecretsRawAsync(
): Promise<SshExecResult> {
// Prime the OS cache off the event loop so the sync command build below never blocks.
await resolveRemoteOsAsync(opts.osLookupName ?? target, { timeoutMs: opts.timeoutMs });
const remoteCmd = buildRemoteSecretsInvocation(args, osForTarget(target, opts.osLookupName));
const remoteCmd = buildRemoteSecretsInvocation(args, osForTarget(target, opts.osLookupName), opts.env);
const posture = opts.secret ? credentialTransportSshOpts(target) : {};
const conn = opts.tty ? { ...posture, extraSshArgs: ['-tt'] } : posture;
return sshExecAsync(target, remoteCmd, {
Expand Down Expand Up @@ -356,12 +358,12 @@ export function verifyRemoteKeychainPush(
target: string,
bundle: string,
pushedKeys: string[],
opts: { osLookupName?: string; secret?: boolean; timeoutMs?: number } = {},
opts: { osLookupName?: string; secret?: boolean; timeoutMs?: number; env?: Record<string, string> } = {},
): RemoteKeychainWriteVerification {
const remoteCmd = buildRemoteSecretsInvocation(
['export', bundle, '--plaintext', '--format', 'json'],
osForTarget(target, opts.osLookupName),
{ SECRETS_REMOTE_TRANSPORT: '1' },
{ ...opts.env, SECRETS_REMOTE_TRANSPORT: '1' },
);
const res: SshExecResult = sshExec(target, remoteCmd, {
timeoutMs: opts.timeoutMs ?? REMOTE_TIMEOUT_MS,
Expand All @@ -375,13 +377,13 @@ export async function verifyRemoteKeychainPushAsync(
target: string,
bundle: string,
pushedKeys: string[],
opts: { osLookupName?: string; secret?: boolean; timeoutMs?: number } = {},
opts: { osLookupName?: string; secret?: boolean; timeoutMs?: number; env?: Record<string, string> } = {},
): Promise<RemoteKeychainWriteVerification> {
await resolveRemoteOsAsync(opts.osLookupName ?? target, { timeoutMs: opts.timeoutMs });
const remoteCmd = buildRemoteSecretsInvocation(
['export', bundle, '--plaintext', '--format', 'json'],
osForTarget(target, opts.osLookupName),
{ SECRETS_REMOTE_TRANSPORT: '1' },
{ ...opts.env, SECRETS_REMOTE_TRANSPORT: '1' },
);
const res = await sshExecAsync(target, remoteCmd, {
timeoutMs: opts.timeoutMs ?? REMOTE_TIMEOUT_MS,
Expand Down Expand Up @@ -434,14 +436,15 @@ function evaluateRemoteKeychainPushResult(
export function buildRemoteFileImportCommand(
bundle: string,
dotenv: string,
opts: { force?: boolean; policyNever?: boolean } = {},
opts: { force?: boolean; policyNever?: boolean; env?: Record<string, string> } = {},
): { remoteCmd: string; input: string } {
const args = [
'import', bundle, '--from', '-', '--backend', 'file',
...(opts.force ? ['--force'] : []),
...(opts.policyNever ? ['--policy', 'never', '--i-understand'] : []),
];
// POSIX-only path (planPushTransport refuses a Windows file-backend target).
// No env prologue — SECRETS_PASSPHRASE stays unset on the remote.
return { remoteCmd: buildRemoteSecretsInvocation(args), input: dotenv };
// The only env prologue is the consumer state root (`remoteStateRootEnv`);
// SECRETS_PASSPHRASE stays unset on the remote.
return { remoteCmd: buildRemoteSecretsInvocation(args, undefined, opts.env), input: dotenv };
}
43 changes: 43 additions & 0 deletions src/lib/transports/ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
posixEnvExports,
buildRemoteSecretsInvocation,
buildWindowsStdinImportCommand,
remoteStateRootEnv,
windowsSecretsScript,
resolveRemoteOs,
resolveRemoteOsAsync,
rememberRemoteOs,
Expand Down Expand Up @@ -215,6 +217,47 @@ describe('posixEnvExports', () => {
.toBe('export SECRETS_REMOTE_TRANSPORT=1');
expect(posixEnvExports({ EVIL: '$(rm -rf /)' })).toBe("export EVIL='$(rm -rf /)'");
expect(posixEnvExports({ PATH: '$HOME/bin:$PATH' })).toBe('export PATH="$HOME/bin:$PATH"');
// The consumer state root is the other expanding key: it is always
// home-relative on the remote, so `$HOME` must resolve there, not here.
expect(posixEnvExports({ SECRETS_HOME: '$HOME/.agents' })).toBe('export SECRETS_HOME="$HOME/.agents"');
});
});

describe('remoteStateRootEnv — the consumer state root a push runs the remote secrets under', () => {
it('is empty when no root is named, so the remote keeps its own default', () => {
expect(remoteStateRootEnv(undefined)).toEqual({});
expect(remoteStateRootEnv('')).toEqual({});
expect(remoteStateRootEnv(' ', 'linux')).toEqual({});
});

it('resolves a leading ~/ against the REMOTE home on each shell and passes absolute roots through', () => {
expect(remoteStateRootEnv('~/.agents', 'linux')).toEqual({ SECRETS_HOME: '$HOME/.agents' });
expect(remoteStateRootEnv('~/.agents', 'darwin')).toEqual({ SECRETS_HOME: '$HOME/.agents' });
expect(remoteStateRootEnv('~/.agents', 'windows')).toEqual({ SECRETS_HOME: '$env:USERPROFILE\\.agents' });
expect(remoteStateRootEnv('~/a/b', 'windows')).toEqual({ SECRETS_HOME: '$env:USERPROFILE\\a\\b' });
expect(remoteStateRootEnv('/srv/agents', 'linux')).toEqual({ SECRETS_HOME: '/srv/agents' });
});

it('refuses anything but a plain path, because the value is emitted in the expanding quote form', () => {
// Double quotes escape `\` and `"` only: `$(…)` and a backtick would still
// run on the remote as a side effect of setting the env, before `secrets`
// is even invoked. The gate is the charset, not the escaping.
for (const bad of ['$(id)', '~/.agents$(touch /tmp/x)', '`id`', '~/a b', "~/a'b", '~/a"b', 'relative/path', '~', '$HOME/.agents', '~/.agents;id']) {
expect(() => remoteStateRootEnv(bad, 'linux'), bad).toThrow(/Invalid remote state root/);
expect(() => remoteStateRootEnv(bad, 'windows'), bad).toThrow(/Invalid remote state root/);
}
expect(remoteStateRootEnv('C:/agents', 'windows')).toEqual({ SECRETS_HOME: 'C:/agents' });
});

it('rides the POSIX invocation as an expanding export and the PowerShell script as a double-quoted env line', () => {
const posix = buildRemoteSecretsInvocation(['import', '__cursor__', '--from', '-', '--backend', 'file'], 'linux', remoteStateRootEnv('~/.agents', 'linux'));
expect(posix).toContain('export SECRETS_HOME="$HOME/.agents"; secrets import');
const ps = windowsSecretsScript({ args: ['import', 'apple.com', '--from', '-'], env: remoteStateRootEnv('~/.agents', 'windows') });
expect(ps).toContain('$env:SECRETS_HOME = "$env:USERPROFILE\\.agents"');
// A non-expanding key stays a single-quoted literal on both shells.
expect(windowsSecretsScript({ args: ['status'], env: { SECRETS_REMOTE_TRANSPORT: '1' } })).toContain("$env:SECRETS_REMOTE_TRANSPORT = '1'");
const stdin = buildWindowsStdinImportCommand('apple.com', { env: remoteStateRootEnv('~/.agents', 'windows') });
expect(decodePowershell(stdin.split('-EncodedCommand ')[1])).toContain('$env:SECRETS_HOME = "$env:USERPROFILE\\.agents"');
});
});

Expand Down
Loading
Loading