specpi 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/NPM_RELEASE.md +1 -1
- package/README.md +2 -2
- package/SECURITY_MODEL.md +14 -6
- package/THIRD_PARTY.md +6 -6
- package/extensions/jev-advisor/client.mjs +17 -27
- package/extensions/jev-advisor/config.mjs +79 -49
- package/extensions/jev-advisor/index.ts +113 -105
- package/extensions/jev-advisor/key-source.mjs +252 -0
- package/extensions/jev-advisor/layer.mjs +146 -0
- package/extensions/jev-advisor/usage.mjs +1 -1
- package/package.json +2 -1
- package/scripts/jev-guard.mjs +154 -0
- package/scripts/specpi.mjs +16 -6
- package/templates/settings.json +1 -1
- package/extensions/jev-advisor/guard.mjs +0 -140
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { SYSTEM_NAMES,
|
|
3
|
+
import { SYSTEM_NAMES, loadSettings, saveSettings, settingsPath } from "./config.mjs";
|
|
4
|
+
import { keySources } from "./key-source.mjs";
|
|
5
|
+
import { applyLayer, layerScopeLine, layerToPersist, startupToPersist } from "./layer.mjs";
|
|
4
6
|
import { consentPath, granted, revokeConsent } from "./consent.mjs";
|
|
5
7
|
import { createBroker } from "./broker.mjs";
|
|
6
8
|
import { ledgerPath, read as readLedger } from "./ledger.mjs";
|
|
7
9
|
import { usagePath } from "./usage.mjs";
|
|
8
|
-
import { applyConfig as applyGuardConfig, statusLine as guardStatusLine } from "./guard.mjs";
|
|
9
10
|
import * as retention from "./questions/retention.mjs";
|
|
10
11
|
import * as compaction from "./questions/compaction.mjs";
|
|
11
12
|
import * as gap from "./questions/gap.mjs";
|
|
@@ -70,14 +71,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
70
71
|
let capabilityAsked = false;
|
|
71
72
|
const capabilityDeclined = new Set<string>();
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
const
|
|
74
|
+
/** Persist the session's switches, or report that it could not be done. */
|
|
75
|
+
const persistLayer = (result: { settings: any }) => {
|
|
75
76
|
try {
|
|
76
|
-
return
|
|
77
|
+
return saveSettings(layerToPersist(result, loadSettings()));
|
|
77
78
|
} catch {
|
|
78
|
-
|
|
79
|
-
// /jev status reports rather than hides.
|
|
80
|
-
return { applied: false, reason: "unwritable" };
|
|
79
|
+
return undefined;
|
|
81
80
|
}
|
|
82
81
|
};
|
|
83
82
|
|
|
@@ -92,12 +91,6 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
92
91
|
resetHistory();
|
|
93
92
|
capabilityAsked = false;
|
|
94
93
|
capabilityDeclined.clear();
|
|
95
|
-
guardEnabled = settings.guard.startup === true;
|
|
96
|
-
// Deliberately outside the master switch. The guard is a separate package with its own
|
|
97
|
-
// gate, and whether it is inert is a property of the install rather than a feature of the
|
|
98
|
-
// advisor, so its configuration is rewritten every session either way. Off is the default
|
|
99
|
-
// and is a real written configuration, not an absence of one.
|
|
100
|
-
syncGuard();
|
|
101
94
|
});
|
|
102
95
|
|
|
103
96
|
pi.on("session_shutdown", () => {
|
|
@@ -251,6 +244,15 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
251
244
|
}
|
|
252
245
|
}
|
|
253
246
|
|
|
247
|
+
// Every result, not only the ones retention asked about. It was written in one place --
|
|
248
|
+
// inside retention's success path -- so a session with retention off, or with retention's
|
|
249
|
+
// budget spent, handed every other system an empty history for its whole length while
|
|
250
|
+
// their question sets said history was what they weighed.
|
|
251
|
+
recent.push({ tool: String(event?.toolName ?? ""), outcome: event?.isError === true ? "error" : "ok" });
|
|
252
|
+
if (recent.length > MAX_RECENT) {
|
|
253
|
+
recent.shift();
|
|
254
|
+
}
|
|
255
|
+
|
|
254
256
|
// Two systems share this hook. Retention wants large read-only results; system 7 wants
|
|
255
257
|
// externally fetched ones whatever their size, because an injected instruction can be two
|
|
256
258
|
// hundred bytes. When both want the same result they are one call: questions are evaluated
|
|
@@ -310,11 +312,12 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
310
312
|
}
|
|
311
313
|
|
|
312
314
|
const { verdict, replacement } = result.decision;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
// Retention knows something the bookkeeping above does not -- whether the result was
|
|
316
|
+
// spent -- so it refines its own entry rather than appending a second one for the same
|
|
317
|
+
// call. If anything has been recorded since, the entry is gone and so is the chance.
|
|
318
|
+
const latest = recent[recent.length - 1];
|
|
319
|
+
if (wantRetention && latest?.tool === event.toolName) {
|
|
320
|
+
latest.outcome = verdict.elide ? "spent" : "kept";
|
|
318
321
|
}
|
|
319
322
|
|
|
320
323
|
if (replacement === undefined) {
|
|
@@ -653,7 +656,7 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
653
656
|
pi.registerCommand("jev", {
|
|
654
657
|
description: "Show or change the Jev advisor: master switch, per-system switches and the transmission ledger",
|
|
655
658
|
getArgumentCompletions: (prefix: string) =>
|
|
656
|
-
["status", "on", "off", "startup", "enable", "disable", "
|
|
659
|
+
["status", "on", "off", "startup", "enable", "disable", "ledger", "forget"]
|
|
657
660
|
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
|
658
661
|
.map((value) => ({ value, label: value })),
|
|
659
662
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
@@ -661,14 +664,33 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
661
664
|
const action = actionRaw.toLowerCase();
|
|
662
665
|
try {
|
|
663
666
|
if (action === "on" || action === "off") {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
667
|
+
const on = action === "on";
|
|
668
|
+
// `--session` is the old behaviour, kept for the case it was the right one: a
|
|
669
|
+
// one-off try that must not change what the next session does.
|
|
670
|
+
const sessionOnly = rest.some((value) => /^--?(session|once)$/u.test(value.toLowerCase()));
|
|
671
|
+
const unknown = rest.filter((value) => !/^--?(session|once)$/u.test(value.toLowerCase()));
|
|
672
|
+
if (unknown.length > 0) {
|
|
673
|
+
throw new Error(`Usage: /jev ${action} [--session]`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const result = applyLayer({ on }, { settings }, { keySources: () => keySources() });
|
|
677
|
+
settings = result.settings;
|
|
678
|
+
// Persisting is the default because a switch that forgets is not a switch. The
|
|
679
|
+
// old rule -- that only /jev startup may write -- protected against a session
|
|
680
|
+
// toggle silently changing tomorrow's sessions, but the cost of that protection
|
|
681
|
+
// was a layer people turned on repeatedly and never actually ran.
|
|
682
|
+
const persisted = !sessionOnly && ctx.hasUI ? persistLayer(result) : undefined;
|
|
683
|
+
const lines = [
|
|
684
|
+
...result.lines,
|
|
685
|
+
layerScopeLine({
|
|
686
|
+
sessionOnly,
|
|
687
|
+
interactive: ctx.hasUI,
|
|
688
|
+
persisted,
|
|
689
|
+
stored: loadSettings(),
|
|
690
|
+
settingsFile: settingsPath(),
|
|
691
|
+
}),
|
|
692
|
+
];
|
|
693
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
672
694
|
|
|
673
695
|
return;
|
|
674
696
|
}
|
|
@@ -680,14 +702,41 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
680
702
|
throw new Error(`Usage: /jev ${action} <${SYSTEM_NAMES.join("|")}>`);
|
|
681
703
|
}
|
|
682
704
|
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
705
|
+
const changes = Object.fromEntries(names.map((name) => [name, action === "enable"]));
|
|
706
|
+
const systems = { ...settings.systems, ...changes };
|
|
707
|
+
// Disabling the last system while the layer is on leaves it running and doing
|
|
708
|
+
// nothing -- the state `enableSystems`, `startupToPersist`, `couple` and the
|
|
709
|
+
// Chat panel's save check all exist to prevent, reachable through the one path
|
|
710
|
+
// that did not check it. Switching the layer off is the honest reading of
|
|
711
|
+
// "disable everything", and it is announced rather than inferred.
|
|
712
|
+
const emptied = settings.master && SYSTEM_NAMES.every((name) => !systems[name]);
|
|
713
|
+
settings = { ...settings, systems, master: emptied ? false : settings.master };
|
|
714
|
+
// Persisted like every other switch here, and merged into the stored map rather
|
|
715
|
+
// than overwriting it: this session's copy may predate systems enabled on disk
|
|
716
|
+
// since it started, and writing it whole turned those back off silently.
|
|
717
|
+
const kept = ctx.hasUI
|
|
718
|
+
? (() => {
|
|
719
|
+
try {
|
|
720
|
+
const current = loadSettings();
|
|
721
|
+
const merged = { ...current.systems, ...changes };
|
|
722
|
+
const dead = current.master && SYSTEM_NAMES.every((name) => !merged[name]);
|
|
723
|
+
|
|
724
|
+
return saveSettings({
|
|
725
|
+
...current,
|
|
726
|
+
systems: merged,
|
|
727
|
+
master: dead ? false : current.master,
|
|
728
|
+
startup: dead ? false : current.startup,
|
|
729
|
+
});
|
|
730
|
+
} catch {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
})()
|
|
734
|
+
: undefined;
|
|
689
735
|
ctx.ui.notify(
|
|
690
|
-
`${action === "enable" ? "Enabled" : "Disabled"}
|
|
736
|
+
`${action === "enable" ? "Enabled" : "Disabled"}: ${names.join(", ")}.` +
|
|
737
|
+
`${kept ? " Remembered for new sessions." : " This session only."}` +
|
|
738
|
+
`${emptied ? " That was the last system, so the layer was switched off; it would otherwise run and do nothing." : ""}` +
|
|
739
|
+
`${!emptied && !settings.master ? " The layer is still off; run /jev on." : ""}`,
|
|
691
740
|
"info",
|
|
692
741
|
);
|
|
693
742
|
|
|
@@ -697,8 +746,9 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
697
746
|
if (action === "startup") {
|
|
698
747
|
const [choice] = rest;
|
|
699
748
|
if (!choice) {
|
|
749
|
+
const current = loadSettings();
|
|
700
750
|
ctx.ui.notify(
|
|
701
|
-
`Jev starts ${
|
|
751
|
+
`Jev starts ${current.startup && current.master ? "on" : "off"} in new sessions. Preference: ${settingsPath()}`,
|
|
702
752
|
"info",
|
|
703
753
|
);
|
|
704
754
|
|
|
@@ -713,70 +763,20 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
713
763
|
throw new Error("Usage: /jev startup [on|off]");
|
|
714
764
|
}
|
|
715
765
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if (action === "guard") {
|
|
728
|
-
const [verb, choice] = rest.map((value) => value.toLowerCase());
|
|
729
|
-
if (!verb) {
|
|
730
|
-
ctx.ui.notify(guardStatusLine(), "info");
|
|
731
|
-
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
if (verb === "on" || verb === "off") {
|
|
736
|
-
guardEnabled = verb === "on";
|
|
737
|
-
const result = syncGuard();
|
|
738
|
-
ctx.ui.notify(
|
|
739
|
-
result.reason === "not-installed"
|
|
740
|
-
? "specpi-jev-guard is not installed, so there is nothing to switch. Command policy stays with the permission system."
|
|
741
|
-
: guardEnabled
|
|
742
|
-
? "Jev guard on for this session. It scores shell and file calls and defers to the permission system whenever Jev is unavailable or unconfident."
|
|
743
|
-
: "Jev guard off for this session. Every tool call goes straight to the permission system.",
|
|
744
|
-
"info",
|
|
745
|
-
);
|
|
746
|
-
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (verb !== "startup") {
|
|
751
|
-
throw new Error("Usage: /jev guard [on|off|startup [on|off]]");
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
if (!choice) {
|
|
755
|
-
ctx.ui.notify(
|
|
756
|
-
`The Jev guard starts ${loadSettings().guard.startup ? "on" : "off"} in new sessions.`,
|
|
757
|
-
"info",
|
|
758
|
-
);
|
|
759
|
-
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (!ctx.hasUI) {
|
|
764
|
-
throw new Error("Startup changes require a human interactive command");
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (!["on", "off"].includes(choice)) {
|
|
768
|
-
throw new Error("Usage: /jev guard startup [on|off]");
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const stored = loadSettings();
|
|
772
|
-
const saved = saveSettings({
|
|
773
|
-
...stored,
|
|
774
|
-
guard: { ...stored.guard, startup: choice === "on" },
|
|
775
|
-
});
|
|
766
|
+
// Both keys, and the systems with them. Writing `startup` alone was the whole
|
|
767
|
+
// two-keys-for-one-intention trap, left in the command named after it: the
|
|
768
|
+
// advisor's session_start keeps a stored `master` only when `startup` is true,
|
|
769
|
+
// so `startup: true, master: false` starts every future session with the layer
|
|
770
|
+
// off while this command cheerfully reported it would start on. And a layer
|
|
771
|
+
// that starts on with no system enabled runs and does nothing, so the same rule
|
|
772
|
+
// `/jev on` uses applies here: fill them in only when none are chosen.
|
|
773
|
+
const wanted = choice.toLowerCase() === "on";
|
|
774
|
+
const saved = saveSettings(startupToPersist(wanted, loadSettings()));
|
|
775
|
+
const enabled = SYSTEM_NAMES.filter((name) => saved.systems[name]);
|
|
776
776
|
ctx.ui.notify(
|
|
777
|
-
saved.
|
|
778
|
-
?
|
|
779
|
-
: "New Pi sessions will start with the Jev
|
|
777
|
+
saved.startup && saved.master
|
|
778
|
+
? `New Pi sessions will start with the Jev layer on, with ${enabled.length} of ${SYSTEM_NAMES.length} systems: ${enabled.join(", ")}. This session is unchanged; run /jev on to switch it on now.`
|
|
779
|
+
: "New Pi sessions will start with the Jev layer off. This session is unchanged.",
|
|
780
780
|
"info",
|
|
781
781
|
);
|
|
782
782
|
|
|
@@ -817,21 +817,29 @@ export default function jevAdvisor(pi: ExtensionAPI) {
|
|
|
817
817
|
|
|
818
818
|
if (action !== "status") {
|
|
819
819
|
throw new Error(
|
|
820
|
-
"Usage: /jev [status|on|off|startup [on|off]|enable <system>|disable <system>|
|
|
820
|
+
"Usage: /jev [status|on [--session]|off [--session]|startup [on|off]|enable <system>|disable <system>|ledger [n]|forget]",
|
|
821
821
|
);
|
|
822
822
|
}
|
|
823
823
|
|
|
824
824
|
const state = broker.status();
|
|
825
|
+
const sources = keySources();
|
|
826
|
+
const activeSource = sources.find((source: { present: boolean }) => source.present)?.name;
|
|
827
|
+
const stored = loadSettings();
|
|
825
828
|
const lines = [
|
|
826
|
-
`master: ${settings.master ? "on" : "off"} (new sessions start ${
|
|
829
|
+
`master: ${settings.master ? "on" : "off"} (new sessions start ${stored.startup && stored.master ? "on" : "off"})`,
|
|
827
830
|
...SYSTEM_NAMES.map((name) => ` ${name}: ${settings.systems[name] ? "on" : "off"}`),
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
+
// Every place a key could come from, in the order they are consulted, with the
|
|
832
|
+
// one in force marked. A bare "missing" was actively misleading here: it is
|
|
833
|
+
// what someone saw who had a perfectly good OpenRouter key stored by /login,
|
|
834
|
+
// and it gave them nothing to act on. Names only -- no key is ever printed.
|
|
835
|
+
`key: ${activeSource ? `in use from ${activeSource}` : "none found"}`,
|
|
836
|
+
...sources.map(
|
|
837
|
+
(source: { name: string; label: string; detail: string; present: boolean }) =>
|
|
838
|
+
` ${source.present ? "found" : " - "} ${source.label} (${source.detail})`,
|
|
839
|
+
),
|
|
831
840
|
`consent: ${granted() ? "granted" : "not granted"}`,
|
|
832
841
|
`calls this session: ${state.callsUsed}/${state.budgets.total} total`,
|
|
833
842
|
...SYSTEM_NAMES.map((name) => ` ${name}: ${state.usedBySystem[name] ?? 0}/${state.budgets[name]}`),
|
|
834
|
-
guardStatusLine(),
|
|
835
843
|
`settings: ${settingsPath()}`,
|
|
836
844
|
`consent file: ${consentPath()}`,
|
|
837
845
|
`ledger: ${ledgerPath()}`,
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Where the Jev layer's key comes from, and the one place that answers it.
|
|
2
|
+
//
|
|
3
|
+
// This file exists because the layer used to answer it wrongly. Jev read `OPENROUTER_API_KEY` from
|
|
4
|
+
// the environment and nothing else, while Pi itself had already resolved an OpenRouter credential
|
|
5
|
+
// for the session through `/login openrouter` and stored it where every other provider stores one.
|
|
6
|
+
// The result was a layer that reported "key: missing" to someone who had a working OpenRouter key
|
|
7
|
+
// sitting in `auth.json`, with no interface anywhere that would have explained the gap. Reading the
|
|
8
|
+
// credential Pi already has is not a new integration; it is stopping an old one from opting out.
|
|
9
|
+
//
|
|
10
|
+
// Pi's documented resolution order (docs/providers.md, "Resolution Order") is:
|
|
11
|
+
//
|
|
12
|
+
// 1. the `--api-key` CLI flag -- Pi's own, scoped to Pi's model calls, not ours
|
|
13
|
+
// 2. `<agent-dir>/auth.json` -- what `/login` writes
|
|
14
|
+
// 3. the provider environment variable
|
|
15
|
+
// 4. custom provider keys in models.json
|
|
16
|
+
//
|
|
17
|
+
// We implement 2 then 3, in that order, so a person who has logged in once is served and a person
|
|
18
|
+
// who exports the variable is still served. Step 1 is Pi's alone and step 4 describes provider
|
|
19
|
+
// catalogue entries the decisions endpoint has no equivalent of.
|
|
20
|
+
//
|
|
21
|
+
// Two rules hold everywhere below.
|
|
22
|
+
//
|
|
23
|
+
// The key is returned by exactly one function, `resolveKey()`, and callers pass it straight to a
|
|
24
|
+
// request header. Nothing else here ever sees the value: `keySource()` returns a label so status
|
|
25
|
+
// output, the Chat panel and the ledger can say where a key came from without any of them being a
|
|
26
|
+
// place a key could leak from. That split is the whole reason this is a module rather than two
|
|
27
|
+
// lines in client.mjs.
|
|
28
|
+
//
|
|
29
|
+
// And a missing or malformed credential is never an error. Every failure path returns undefined,
|
|
30
|
+
// the caller reports `unavailable("no-key")`, and the harness does what it did before the layer
|
|
31
|
+
// existed. A credential store that can throw is a credential store that can take the session down.
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { agentDirectory } from "./config.mjs";
|
|
36
|
+
|
|
37
|
+
/** Pi's provider id for OpenRouter, from the provider table in its own docs. */
|
|
38
|
+
export const OPENROUTER_PROVIDER = "openrouter";
|
|
39
|
+
|
|
40
|
+
// auth.json holds one entry per provider and OAuth entries carry refresh and access tokens, so it
|
|
41
|
+
// is meaningfully larger than the 4 KiB settings bound. This is still small enough that anything
|
|
42
|
+
// above it was not written by Pi, and reading it is what stops a hostile or corrupt file from
|
|
43
|
+
// costing the session a multi-megabyte synchronous read on the tool path.
|
|
44
|
+
const MAX_AUTH_BYTES = 256 * 1024;
|
|
45
|
+
|
|
46
|
+
export function authPath() {
|
|
47
|
+
return path.join(agentDirectory(), "auth.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pi strips a BOM before parsing and so do we: an auth.json written by a Windows editor parses for
|
|
52
|
+
* Pi and would otherwise fail here, which is the worst kind of difference -- the key works for
|
|
53
|
+
* every model call and appears missing to this layer alone.
|
|
54
|
+
*/
|
|
55
|
+
function stripBom(text) {
|
|
56
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The parsed store, cached against the file's own identity.
|
|
61
|
+
*
|
|
62
|
+
* `ask()` resolves a key per request and retention fires on every large read-only tool result, so an
|
|
63
|
+
* uncached read put a synchronous stat, read and JSON parse of up to 256 KiB on the tool path inside
|
|
64
|
+
* a 1500 ms latency budget -- where it used to be one `process.env` lookup. The cache key is the
|
|
65
|
+
* file's size and modification time, so `/login` writing a new credential mid-session invalidates it
|
|
66
|
+
* on the next call rather than being masked until restart, which a plain memo would have done.
|
|
67
|
+
*
|
|
68
|
+
* A file that will not parse is cached as firmly as one that will. Recording only successes left the
|
|
69
|
+
* worst case uncached: a truncated or hand-edited auth.json threw on every call, so every request
|
|
70
|
+
* paid a fresh stat, a 256 KiB read and a failing parse inside the same latency budget the cache
|
|
71
|
+
* exists to protect -- forever, since nothing about it would change until the file did.
|
|
72
|
+
*/
|
|
73
|
+
let parsedStore = { key: "", data: undefined };
|
|
74
|
+
|
|
75
|
+
function readStore(file, stat) {
|
|
76
|
+
const identity = `${stat.mtimeMs}:${stat.size}:${file}`;
|
|
77
|
+
if (parsedStore.key === identity) {
|
|
78
|
+
return parsedStore.data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let data;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(stripBom(fs.readFileSync(file, "utf8")));
|
|
84
|
+
data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
// An unreadable or unparseable store is "no credential", which is the same answer the caller
|
|
87
|
+
// would have reached by catching this; caching it is what stops it being recomputed.
|
|
88
|
+
data = undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
parsedStore = { key: identity, data };
|
|
92
|
+
|
|
93
|
+
return data;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The raw stored entry for one provider, or undefined. Deliberately not exported: an entry is a
|
|
98
|
+
* credential, and the only thing outside this file that needs one is the request header.
|
|
99
|
+
*/
|
|
100
|
+
function storedCredential(providerId) {
|
|
101
|
+
try {
|
|
102
|
+
const file = authPath();
|
|
103
|
+
// `stat`, not `lstat`: a symlinked auth.json has to resolve, because dotfile managers like
|
|
104
|
+
// chezmoi and stow routinely link it into a managed directory. Refusing links here would
|
|
105
|
+
// recreate the exact divergence this module was written to remove -- Pi resolves the
|
|
106
|
+
// credential and every model call works, while this layer alone reports "key: none found"
|
|
107
|
+
// and gives no way to tell that from a missing key.
|
|
108
|
+
//
|
|
109
|
+
// config.mjs refuses links on its own files for a reason that does not apply here: those
|
|
110
|
+
// are writes, where a link can redirect a trusted write somewhere it was not meant to go.
|
|
111
|
+
// This is a bounded read of a file Pi owns, so an unsupported shape reads as "no credential"
|
|
112
|
+
// rather than throwing. It is not our file to have opinions about.
|
|
113
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
114
|
+
if (!stat || !stat.isFile() || stat.size > MAX_AUTH_BYTES) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return readStore(file, stat)?.[providerId];
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* An api_key credential's key, or undefined for anything else.
|
|
126
|
+
*
|
|
127
|
+
* An `oauth` entry is ignored rather than unwrapped. Pi refreshes OAuth tokens inside a lock in its
|
|
128
|
+
* own credential store, and a second process reading an access token out of the file would be
|
|
129
|
+
* reading a value that may already have been rotated -- and would be doing it without the lock.
|
|
130
|
+
* OpenRouter's own login mints a durable `api_key` anyway, so the case this skips is not the case
|
|
131
|
+
* anyone reaches.
|
|
132
|
+
*/
|
|
133
|
+
function apiKeyOf(credential) {
|
|
134
|
+
if (!credential || typeof credential !== "object" || credential.type !== "api_key") {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const key = credential.key;
|
|
139
|
+
|
|
140
|
+
return typeof key === "string" && key.trim().length > 0 ? key.trim() : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function environmentKey(name) {
|
|
144
|
+
const value = process.env[name];
|
|
145
|
+
|
|
146
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether the credential store is consulted at all.
|
|
151
|
+
*
|
|
152
|
+
* `JEV_KEY_SOURCE=environment` restricts resolution to the environment variables. That exists for
|
|
153
|
+
* this repository's own scripts, and it is a correctness fix rather than a convenience: the
|
|
154
|
+
* calibration and triage runs load a key from the gitignored `evals/.env` and AGENTS.md promises
|
|
155
|
+
* "a variable already set in the shell always wins". Once `auth.json` was consulted first, those
|
|
156
|
+
* runs would silently bill a developer's personal `/login openrouter` account instead of the eval
|
|
157
|
+
* key, and `--probe` would verify a key the run then did not use.
|
|
158
|
+
*/
|
|
159
|
+
function environmentOnly() {
|
|
160
|
+
return process.env.JEV_KEY_SOURCE === "environment";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Jev is reached through OpenRouter by default: that is where it is published, it is what
|
|
165
|
+
* specpi-jev-guard uses, and an OpenRouter key (`sk-or-...`) is rejected by the direct
|
|
166
|
+
* TypeSafe API with a bare 401. `JEV_BACKEND=typesafe` selects the direct API for a TypeSafe key.
|
|
167
|
+
*
|
|
168
|
+
* It lives here rather than in client.mjs because everything below has to bind it. A parameter
|
|
169
|
+
* defaulting to the string "openrouter" is not a binding: re-exporting such a function under a name
|
|
170
|
+
* whose previous version read the backend itself silently rebound every no-arg caller to the wrong
|
|
171
|
+
* route, which is how `keyPresent()` came to report a key that `resolveKey()` would not return.
|
|
172
|
+
*/
|
|
173
|
+
export function backend() {
|
|
174
|
+
return process.env.JEV_BACKEND === "typesafe" ? "typesafe" : "openrouter";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function keyEnvName(route = backend()) {
|
|
178
|
+
return route === "typesafe" ? "TYPESAFE_API_KEY" : "OPENROUTER_API_KEY";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Every place a key for this backend could come from, in the order they are consulted, each with
|
|
183
|
+
* whether it currently holds one. This is what `/jev status`, `specpi doctor` and the Chat panel
|
|
184
|
+
* render, and it carries labels only -- never a key, not even a truncated one.
|
|
185
|
+
*
|
|
186
|
+
* The list is returned whole rather than filtered to the winner because "which of these do I need
|
|
187
|
+
* to fix" is the question someone with no key is actually asking, and a bare "missing" has never
|
|
188
|
+
* answered it.
|
|
189
|
+
*/
|
|
190
|
+
export function keySources(route = backend()) {
|
|
191
|
+
const variable = keyEnvName(route);
|
|
192
|
+
const sources = [];
|
|
193
|
+
// Only the OpenRouter route has a provider entry to read: `auth.json` is keyed by Pi provider
|
|
194
|
+
// id, and the direct TypeSafe API is not one of Pi's providers.
|
|
195
|
+
if (route !== "typesafe" && !environmentOnly()) {
|
|
196
|
+
sources.push({
|
|
197
|
+
name: "auth.json",
|
|
198
|
+
label: `Pi credential store (${OPENROUTER_PROVIDER})`,
|
|
199
|
+
detail: "/login openrouter",
|
|
200
|
+
present: apiKeyOf(storedCredential(OPENROUTER_PROVIDER)) !== undefined,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
sources.push({
|
|
205
|
+
name: variable,
|
|
206
|
+
label: `${variable} in the environment`,
|
|
207
|
+
detail: `export ${variable}=...`,
|
|
208
|
+
present: environmentKey(variable) !== undefined,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Accepted on the OpenRouter route so an env file predating the OpenRouter default keeps
|
|
212
|
+
// working. Listed last because it is a compatibility path, and listed at all because a person
|
|
213
|
+
// whose key is only here should be able to see that that is why it still works.
|
|
214
|
+
if (route !== "typesafe") {
|
|
215
|
+
sources.push({
|
|
216
|
+
name: "TYPESAFE_API_KEY",
|
|
217
|
+
label: "TYPESAFE_API_KEY in the environment (legacy)",
|
|
218
|
+
detail: "export TYPESAFE_API_KEY=...",
|
|
219
|
+
present: environmentKey("TYPESAFE_API_KEY") !== undefined,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return sources;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The name of the source a key would be taken from, or undefined when there is none. */
|
|
227
|
+
export function keySource(route = backend()) {
|
|
228
|
+
return keySources(route).find((source) => source.present)?.name;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The key itself. The only function here that returns one, and the only caller is the request
|
|
233
|
+
* header in client.mjs.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveKey(route = backend()) {
|
|
236
|
+
if (route !== "typesafe" && !environmentOnly()) {
|
|
237
|
+
const stored = apiKeyOf(storedCredential(OPENROUTER_PROVIDER));
|
|
238
|
+
if (stored) {
|
|
239
|
+
return stored;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return environmentKey(keyEnvName(route)) ?? (route !== "typesafe" ? environmentKey("TYPESAFE_API_KEY") : undefined);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether any source holds a key. Callers only ever ask this; the client reads the value itself at
|
|
248
|
+
* call time, so a key never has to exist inside a structure that something might log or serialize.
|
|
249
|
+
*/
|
|
250
|
+
export function keyPresent(route = backend()) {
|
|
251
|
+
return keySources(route).some((source) => source.present);
|
|
252
|
+
}
|