diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc85a67..c76ea11c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ How to keep this current: add the entry in the same pull request as the change, +## 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). +- 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 ### Changed diff --git a/package-lock.json b/package-lock.json index d9944ce9..263cbdef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "pi-warden", - "version": "0.39.0", + "version": "0.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-warden", - "version": "0.39.0", + "version": "0.40.0", "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..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", @@ -81,6 +81,6 @@ "typescript": "^6.0.0" }, "dependencies": { - "pi-typesafe": "^0.6.2" + "pi-typesafe": "^0.7.0" } } 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", () => {