From 5328ef52ead3203bdedee312d54c4f5bc35e4b6a Mon Sep 17 00:00:00 2001 From: Ryan Gapac Date: Tue, 22 Sep 2026 22:20:02 +0800 Subject: [PATCH 1/3] fix: report and resolve the key of the configured judgment backend /warden status called describeAuth() and /warden enable called ensureApiKey(ctx) with no backend, so an OpenRouter user saw "TypeSafe key: missing" beside a count of judgments, and enable opened the TypeSafe login and could not save consent. Both now pass typesafeBackend to pi-typesafe's backend-aware authState and ensureApiKey. The judge gate uses authState({ backend }).usable, which also honours a rejected key. backend.ts reads hosts and key variables from DECISIONS_BACKENDS instead of a local copy. Closes #57 --- CHANGELOG.md | 9 ++++++++- src/backend.ts | 37 ++++++++++++------------------------- src/extension.ts | 15 ++++++++------- tests/backend.test.ts | 26 +++++++++----------------- 4 files changed, 37 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc85a67..18fbfc50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,14 @@ How to keep this current: add the entry in the same pull request as the change, ## Unreleased - +### Fixed + +- `/warden status` and `/warden enable` now describe the key of the configured `typesafeBackend`. With `"openrouter"`, status said "TypeSafe key: missing" on the same line that counted judgments, and enable opened the TypeSafe login prompt, so consent could not be saved. Both call pi-typesafe's backend-aware `authState` and `ensureApiKey`; with no OpenRouter key, enable reports the variable to set instead of prompting (#57). +- The headless hint and the enable confirmation name the backend's own environment variable. + +### Changed + +- `src/backend.ts` reads hosts and key variables from pi-typesafe's `DECISIONS_BACKENDS` instead of keeping its own copy; `keyAvailable` is replaced by `authState({ backend }).usable`, which also honours a rejected key. Requires pi-typesafe 0.7.0. ## 0.39.1 diff --git a/src/backend.ts b/src/backend.ts index 8ca67647..a1ac19c8 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -1,50 +1,37 @@ import type { TypeSafeOptions } from "pi-typesafe"; -import { authState } from "pi-typesafe"; +import { DECISIONS_BACKENDS, DEFAULT_BACKEND } from "pi-typesafe"; /** The judgment backend that receives pi-warden's Jev requests. */ export type JudgmentBackend = "typesafe" | "openrouter"; -const BACKENDS: Record = { - typesafe: { host: "api.typesafe.ai" }, - openrouter: { host: "openrouter.ai", keyEnv: "OPENROUTER_API_KEY" }, -}; - -/** The host the consent disclosure names as the destination. */ +/** The host the consent disclosure names as the destination, without scheme: `api.typesafe.ai`, `openrouter.ai`. */ export function backendHost(backend: JudgmentBackend): string { - return BACKENDS[backend].host; + return new URL(DECISIONS_BACKENDS[backend].host).host; } -/** Adapt the consent text to the active backend by substituting the destination host. */ -export function disclosureFor(backend: JudgmentBackend, disclosure: string): string { - return backend === "typesafe" ? disclosure : disclosure.replace(BACKENDS.typesafe.host, BACKENDS[backend].host); +/** The environment variable that carries the backend's key, for messages that tell the user what to set. */ +export function keyEnvFor(backend: JudgmentBackend): string { + return DECISIONS_BACKENDS[backend].keyEnv ?? DECISIONS_BACKENDS[DEFAULT_BACKEND].keyEnv ?? "TYPESAFE_API_KEY"; } -/** - * Whether the right credential exists for the chosen backend. - * TypeSafe uses the pi-typesafe keystore; other backends use an environment variable. - */ -export function keyAvailable( - backend: JudgmentBackend, - env: Record, - keystoreUsable: () => boolean, -): boolean { - const keyEnv = BACKENDS[backend].keyEnv; - return keyEnv ? (env[keyEnv]?.trim() ?? "") !== "" : keystoreUsable(); +/** Adapt the consent text to the active backend by substituting the destination host. */ +export function disclosureFor(backend: JudgmentBackend, disclosure: string): string { + return backend === DEFAULT_BACKEND ? disclosure : disclosure.replace(backendHost(DEFAULT_BACKEND), backendHost(backend)); } /** * Build the options forwarded to `createTypeSafe`. - * The caller must gate on `keyAvailable` before calling `createTypeSafe`. + * The caller must gate on `authState({ backend }).usable` before calling `createTypeSafe`. */ export function judgeOptions(config: { maxRequests: number; timeoutMs: number; typesafeBackend: JudgmentBackend }): TypeSafeOptions { return { maxRequests: config.maxRequests, timeoutMs: config.timeoutMs, - ...(config.typesafeBackend === "typesafe" ? {} : { backend: config.typesafeBackend }), + ...(config.typesafeBackend === DEFAULT_BACKEND ? {} : { backend: config.typesafeBackend }), }; } /** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */ export function resolveBackend(raw: unknown): JudgmentBackend { - return raw === "typesafe" || raw === "openrouter" ? raw : "typesafe"; + return raw === "typesafe" || raw === "openrouter" ? raw : DEFAULT_BACKEND; } diff --git a/src/extension.ts b/src/extension.ts index 56656ff6..3bd7db7f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,7 @@ const MouseRegion: MouseRegionConstructor | undefined = (tuiModule as Partial<{ import { authState, createTypeSafe, describeAuth } from "pi-typesafe"; import type { TypeSafe } from "pi-typesafe"; import { ensureApiKey } from "pi-typesafe/ui"; -import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js"; +import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js"; import { ActionGuard } from "./action-guard.js"; import type { ToolCallRef } from "./action-guard.js"; import { ArmingTracker, unparseableArmingRules } from "./arming.js"; @@ -367,7 +367,7 @@ export default function wardenExtension(pi: ExtensionAPI): void { /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */ const judgeFor = (config: WardenConfig): TypeSafe | undefined => { if (!consentGiven(config) || budgetExhausted) return undefined; - if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable)) return undefined; + if (!authState({ backend: config.typesafeBackend }).usable) return undefined; return client ??= createTypeSafe(judgeOptions(config)); }; const noteError = (ctx: ExtensionContext, message: string, code: string | undefined) => { @@ -1666,7 +1666,7 @@ export default function wardenExtension(pi: ExtensionAPI): void { try { const config = configFor(ctx); if (action === "status") { - const auth = describeAuth(); + const auth = describeAuth(authState({ backend: config.typesafeBackend })); const source = consentSource(config); const usage = client?.getUsage(); const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", "); @@ -1750,15 +1750,16 @@ export default function wardenExtension(pi: ExtensionAPI): void { return; } if (action === "enable") { - if (!ctx.hasUI) { report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning"); return; } + if (!ctx.hasUI) { report(`Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and ${keyEnvFor(config.typesafeBackend)} explicitly.`, "warning"); return; } if (!await ctx.ui.confirm("Enable TypeSafe judgments for pi-warden?", disclosure)) return; - // One flow: consent, then a key if none is configured yet (hidden input, verified, stored for every pi-typesafe consumer). - const key = await ensureApiKey(ctx); + // One flow: consent, then a key if none is configured yet. TypeSafe prompts, verifies, and stores the key for every + // pi-typesafe consumer; any other backend has no login, so a missing key is reported with the variable to set. + const key = await ensureApiKey(ctx, { backend: config.typesafeBackend }); if (!key) { report("No key entered; pi-warden stays on pattern checks only. Run /warden enable again when you have a key from console.typesafe.ai.", "warning"); return; } const path = setUserSetting("typesafe", true); client = undefined; budgetExhausted = false; - report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : "key from TYPESAFE_API_KEY"}`}. This stays on in new sessions until /warden disable.`); + report(`TypeSafe judgments enabled and saved to ${path}${key.login ? `; key verified (${key.login.models} model${key.login.models === 1 ? "" : "s"}) and stored at ${key.login.path}` : ` using the ${key.source === "stored" ? "stored key" : `key from ${keyEnvFor(config.typesafeBackend)}`}`}. This stays on in new sessions until /warden disable.`); return; } if (action === "disable") { diff --git a/tests/backend.test.ts b/tests/backend.test.ts index 6b6f0819..30cfa4d2 100644 --- a/tests/backend.test.ts +++ b/tests/backend.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "../src/backend.js"; +import { DECISIONS_BACKENDS } from "pi-typesafe"; +import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "../src/backend.js"; test("resolveBackend: valid values pass through, junk falls back to typesafe", () => { assert.equal(resolveBackend("typesafe"), "typesafe"); @@ -30,22 +31,13 @@ test("disclosureFor: openrouter replaces the host in the disclosure", () => { assert.equal(result, "pi-warden sends to openrouter.ai: your latest request"); }); -test("keyAvailable: typesafe uses the keystore callback", () => { - const env: Record = {}; - assert.equal(keyAvailable("typesafe", env, () => true), true); - assert.equal(keyAvailable("typesafe", env, () => false), false); -}); - -test("keyAvailable: openrouter uses the OPENROUTER_API_KEY env var", () => { - const usable = () => true; - assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: "sk-or-abc" }, usable), true); - assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: " " }, usable), false, "whitespace-only is not a key"); - assert.equal(keyAvailable("openrouter", {}, usable), false, "missing env var"); - assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: "" }, usable), false, "empty string"); -}); - -test("keyAvailable: openrouter ignores the keystore", () => { - assert.equal(keyAvailable("openrouter", { OPENROUTER_API_KEY: "key" }, () => false), true, "keystore failure is irrelevant"); +test("backendHost and keyEnvFor come from pi-typesafe's registry, not a local copy", () => { + for (const backend of ["typesafe", "openrouter"] as const) { + assert.equal(backendHost(backend), new URL(DECISIONS_BACKENDS[backend].host).host); + assert.equal(keyEnvFor(backend), DECISIONS_BACKENDS[backend].keyEnv); + } + assert.equal(keyEnvFor("typesafe"), "TYPESAFE_API_KEY"); + assert.equal(keyEnvFor("openrouter"), "OPENROUTER_API_KEY"); }); test("judgeOptions: typesafe backend omits the backend field", () => { From 6bbf0eb7d21bcaad6b5498cd5cb2d0d4b1197d3a Mon Sep 17 00:00:00 2001 From: Ryan Gapac Date: Tue, 22 Sep 2026 22:33:47 +0800 Subject: [PATCH 2/3] chore: bump pi-typesafe to ^0.7.0 Backend-aware authState and ensureApiKey signatures. --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9944ce9..3b1850cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "pi-warden", - "version": "0.39.0", + "version": "0.39.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-warden", - "version": "0.39.0", + "version": "0.39.1", "license": "MIT", "dependencies": { - "pi-typesafe": "^0.6.2" + "pi-typesafe": "^0.7.0" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.87.0", @@ -2615,9 +2615,9 @@ } }, "node_modules/pi-typesafe": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/pi-typesafe/-/pi-typesafe-0.6.2.tgz", - "integrity": "sha512-N2pAaI5XeU6A4ox4NW8SDa9XZTCEMoWmGC6essALX6zvzkSXGmp5xFBVUoIqUjKDMaH8Cxq98xeANOEff0U9Sg==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/pi-typesafe/-/pi-typesafe-0.7.0.tgz", + "integrity": "sha512-423b/fuOywhEP2kqa900JAsFYEvDpbvSI6QYKYDl3/GKqXpAEEk6nrWzEQOpJoCydMj/zfSnfopIE5nBlypyEQ==", "license": "MIT", "dependencies": { "@typesafe-ai/sdk": "^0.6.0", diff --git a/package.json b/package.json index 32ee745f..cbeae19a 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,6 @@ "typescript": "^6.0.0" }, "dependencies": { - "pi-typesafe": "^0.6.2" + "pi-typesafe": "^0.7.0" } } From 898a7573b1151a5f5b8e82bedd63d58a36e5a807 Mon Sep 17 00:00:00 2001 From: Ryan Gapac Date: Tue, 22 Sep 2026 22:34:16 +0800 Subject: [PATCH 3/3] chore: version bump to 0.40.0, finalize CHANGELOG --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fbfc50..c76ea11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ How to keep this current: add the entry in the same pull request as the change, ## Unreleased + + +## 0.40.0 + ### Fixed - `/warden status` and `/warden enable` now describe the key of the configured `typesafeBackend`. With `"openrouter"`, status said "TypeSafe key: missing" on the same line that counted judgments, and enable opened the TypeSafe login prompt, so consent could not be saved. Both call pi-typesafe's backend-aware `authState` and `ensureApiKey`; with no OpenRouter key, enable reports the variable to set instead of prompting (#57). diff --git a/package-lock.json b/package-lock.json index 3b1850cc..263cbdef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-warden", - "version": "0.39.1", + "version": "0.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-warden", - "version": "0.39.1", + "version": "0.40.0", "license": "MIT", "dependencies": { "pi-typesafe": "^0.7.0" diff --git a/package.json b/package.json index cbeae19a..b2e1b705 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-warden", - "version": "0.39.1", + "version": "0.40.0", "description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.", "type": "module", "license": "MIT",