Throw on a denied agent request instead of returning a quiet zero

Every owner-op client method checks `ok` and throws, and none of those checks
was tested. Removing one does not produce an error — it produces whatever the
failed response coerces to, and for each of these that is a value which reads
as a legitimate outcome:

  lock()           -> 0, printed as "0 profiles locked" while the seeds are
                     still resident and the user has been told the vault is
                     locked
  highRiskLock()   -> false, indistinguishable from "there was nothing to
                     lock"
  highRiskStatus() -> { unlocked: false, expires_at: NaN }

A refusal that looks like a successful no-op is worse than an error, because
nobody retries it. Forced by pointing SEEDPASS_AGENT_CAP at the wrong
capability, which is also the shape of the real failure: a caller that has
lost owner authority.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 23:49:01 -04:00
1 parent d188005b10
commit a9e31215ea
1 file changed
+31
@@ -712,6 +712,37 @@ describe("the wire protocol refuses shapes, not just values", () => {
});
});
describe("a denied request is not a quiet zero", () => {
it("throws instead of reporting a false success", async () => {
// Every owner-op client method checks `ok` and throws. Drop that check
// and the method returns whatever the failed response coerces to, which
// for each of these is a value that reads as a legitimate outcome:
//
// lock() -> 0 "0 profiles locked" — but the seeds are
// still resident, and the user was told the
// vault was locked.
// highRiskLock() -> false indistinguishable from "there was nothing
// to lock".
// highRiskStatus() -> { unlocked: false, expires_at: NaN }
//
// A refusal that looks like a successful no-op is worse than an error,
// because nobody retries it.
const { d, sock } = await daemonAt(() => FROZEN);
const previous = process.env["SEEDPASS_AGENT_CAP"];
process.env["SEEDPASS_AGENT_CAP"] = "not-the-capability";
try {
const client = new AgentClient(sock);
await expect(client.lock()).rejects.toThrow(/owner capability/);
await expect(client.highRiskLock(FINGERPRINT)).rejects.toThrow(/owner capability/);
await expect(client.highRiskStatus(FINGERPRINT)).rejects.toThrow(/owner capability/);
} finally {
if (previous === undefined) delete process.env["SEEDPASS_AGENT_CAP"];
else process.env["SEEDPASS_AGENT_CAP"] = previous;
await d.stop();
}
});
});
describe("starting a daemon over an existing socket", () => {
it("refuses to replace a LIVE agent", async () => {
// Without this check the second daemon deletes the socket and listens in