fix: four real bugs found by end-to-end manual testing

Ran the full CLI surface against a freshly generated throwaway seed
(profile -> agent unlock -> all 8 entry kinds -> reveal/sinks -> tokens ->
audit -> backup -> relay sync -> seed-only recovery). Four defects that no
unit test had caught:

- clipboard sink hung the CLI forever. xclip/wl-copy fork a resident
  process to own the X/Wayland selection; it inherited our stdio, so any
  reader of the CLI's stdout blocked indefinitely, and waiting for process
  exit never returned. Now detached with ignored stdio, success declared
  once stdin is flushed.
- 'vault import' read the mnemonic straight from the env, so it failed
  whenever the vault was unlocked through the agent -- the normal path.
- 'vault import' never wrote anything; it only printed a summary. It now
  restores into the active vault, refuses to overwrite a non-empty vault
  without --yes, and keeps the old behavior behind --inspect.
- A corrupt/foreign vault file surfaced the raw codec error "invalid token
  encoding". It now names the actual likely causes.

Also documented the variadic-option limitation that a trailing flag is
lost (commander stops collecting at the next dash-prefixed token): a
quoted command spec now splits on whitespace, no shell involved.

Regression tests for all four. 151 core + 44 CLI tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 15:56:34 -04:00
1 parent 274bc0e1b8
commit dcb8fc9219
6 files changed
+195 -24

No files matched your search

+57 -15
View File
@@ -11,7 +11,7 @@ import { Command } from "commander";
import process from "node:process";
import { entryMetadata, resolveEntry } from "./refs.js";
import { materializeSecret } from "./secrets.js";
import { clipboardSink, execSink, stdinSink } from "./sinks.js";
import { clipboardSink, execSink, parseCommandSpec, stdinSink } from "./sinks.js";
import { capabilities } from "./capabilities.js";
import { openVault, saveVault, type OpenedVault } from "./vaultFile.js";
import {
@@ -51,6 +51,7 @@ import {
addNostrKeyEntry,
} from "@seedpass/core";
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js";
import { loadConfig, saveConfig } from "./configFile.js";
@@ -538,8 +539,14 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.command("use <refOrQuery>")
.description("deliver a secret to a sink without printing it")
.option("--clipboard", "copy to the system clipboard")
.option("--exec <cmd...>", "run a command with SEEDPASS_SECRET in its env")
.option("--stdin-to <cmd...>", "pipe the secret to a command's stdin")
.option(
"--exec <cmd...>",
'run a command with SEEDPASS_SECRET in its env (quote it to include flags: --exec "cmd -f")',
)
.option(
"--stdin-to <cmd...>",
'pipe the secret to a command\'s stdin (quote it to include flags: --stdin-to "wc -c")',
)
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
.action(
async (
@@ -564,11 +571,11 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
if (cmdOpts.clipboard) {
result = await clipboardSink(secret.value);
} else if (cmdOpts.exec) {
const [cmd, ...args] = cmdOpts.exec;
result = await execSink(secret.value, cmd!, args);
const [cmd, args] = parseCommandSpec(cmdOpts.exec);
result = await execSink(secret.value, cmd, args);
} else {
const [cmd, ...args] = cmdOpts.stdinTo!;
result = await stdinSink(secret.value, cmd!, args);
const [cmd, args] = parseCommandSpec(cmdOpts.stdinTo!);
result = await stdinSink(secret.value, cmd, args);
}
io.out(
JSON.stringify({
@@ -1030,19 +1037,54 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
vaultCmd
.command("import <srcFile>")
.description("read a portable backup and print its reference summary")
.action(async (srcFile: string) => {
.description("restore a portable backup into the active vault")
.option("--inspect", "verify and summarize without writing")
.option("--yes", "confirm replacing a vault that already has entries")
.action(async (srcFile: string, o: { inspect?: boolean; yes?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const app = new AppDir(resolveAppDir(opts.appDir));
const raw = await readFile(srcFile);
const mnemonic = process.env["SEEDPASS_MNEMONIC"];
const index = await importBackup(new Uint8Array(raw), {
...(mnemonic && { mnemonic }),
// Encrypted backups need the seed; plaintext ones do not, so only
// resolve when required (and use the agent, not just the env).
const isEncrypted = !/"encryption_mode"\s*:\s*"none"/.test(raw.toString("utf8"));
const mnemonic = isEncrypted ? await resolveMnemonic(app, opts) : undefined;
const parsed = await importBackup(new Uint8Array(raw), {
...(mnemonic !== undefined && { mnemonic }),
});
const entries = (index["entries"] ?? {}) as Record<string, { label?: string }>;
const index = parseVaultIndex(parsed);
const entryCount = Object.keys(index.entries).length;
if (o.inspect) {
io.out(
JSON.stringify({
inspected: srcFile,
schema_version: index.schema_version,
entry_count: entryCount,
written: false,
}),
);
return;
}
const targetMnemonic = mnemonic ?? (await resolveMnemonic(app, opts));
const fp = await currentFingerprint(app, opts);
const vaultPath = opts.vault ?? join(app.profileDir(fp), INDEX_FILENAME);
if (existsSync(vaultPath) && !o.yes) {
const current = await openVault(vaultPath, targetMnemonic);
if (Object.keys(current.index.entries).length > 0) {
throw new Error(
"refusing to replace a non-empty vault without --yes " +
"(use --inspect to check the backup first)",
);
}
}
await saveVault({ index, mnemonic: targetMnemonic, path: vaultPath });
io.out(
JSON.stringify({
imported: srcFile,
schema_version: index["schema_version"],
entry_count: Object.keys(entries).length,
into: vaultPath,
schema_version: index.schema_version,
entry_count: entryCount,
}),
);
});