@kal-elsam/kairo-runtime 0.25.0 → 0.26.1
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 +29 -0
- package/package.json +1 -1
- package/src/global/conversation/project-strategy.js +12 -4
- package/src/global/intelligence/opencode-ask-agent.js +74 -0
- package/src/global/intelligence/quick-ask.js +118 -9
- package/src/global/observability/claude-entitlement-store.js +167 -0
- package/src/global/observability/claude-model-entitlement.js +185 -0
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
- package/src/global/paths.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.26.1 — 2026-09-19 (Kairo Runtime)
|
|
9
|
+
|
|
10
|
+
Patch release. Claude model entitlement probe + cache (no routing change yet).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Live Claude per-model entitlement probe (`claude -p hi --model … --output-format
|
|
15
|
+
json`) with fail-closed classification (allowed / denied / unverified) and a
|
|
16
|
+
`~/.harness/claude-entitlement.json` cache invalidated by subscription type
|
|
17
|
+
change or a 7-day per-entry TTL. Pure additive — recommendation and automatic
|
|
18
|
+
pools are unchanged until the next increment wires entitlement into the catalog.
|
|
19
|
+
|
|
20
|
+
## 0.26.0 — 2026-09-19 (Kairo Runtime)
|
|
21
|
+
|
|
22
|
+
Minor release. ASK is now ready for every real automatic adapter.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- ASK can now call OpenCode Go/Zen too — via Kairo's own real,
|
|
27
|
+
verified read-only agent ("kairo-ask"), idempotently ensured in the
|
|
28
|
+
user's global `opencode.json` (merged non-destructively). OpenCode's
|
|
29
|
+
real CLI has no flag-driven read-only mode, so this is what makes a
|
|
30
|
+
genuinely portable, config-independent guarantee possible. Live-
|
|
31
|
+
verified: it answers correctly and genuinely refuses to write a file
|
|
32
|
+
when asked directly.
|
|
33
|
+
- Every real automatic PROJECT TEAM adapter (Codex, Claude, Cursor,
|
|
34
|
+
OpenCode Go, OpenCode Zen) is now ASK-capable — a role's real
|
|
35
|
+
assignment routes to ASK regardless of which one it lands on.
|
|
36
|
+
|
|
8
37
|
## 0.25.0 — 2026-09-19 (Kairo Runtime)
|
|
9
38
|
|
|
10
39
|
Minor release. Cursor joins ASK.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Kal-elSam/harness#readme",
|
|
@@ -26,10 +26,18 @@ import { computeRoleEvaluations } from "../intelligence/capability-scoring.js";
|
|
|
26
26
|
// here would be a menu item Kairo can't actually run. Exported: ASK mode's
|
|
27
27
|
// own real-time routing (service.js's planAsk) needs this exact same real
|
|
28
28
|
// constraint when it tries to route a plain question through a PROJECT
|
|
29
|
-
// TEAM role
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
29
|
+
// TEAM role.
|
|
30
|
+
//
|
|
31
|
+
// Pure CAPABILITY (can askProvider invoke this adapter's CLI at all?),
|
|
32
|
+
// never a cost-risk judgment — OpenCode Zen's real PAYG billing risk is
|
|
33
|
+
// deliberately NOT re-litigated here; that's checkCandidate's own job
|
|
34
|
+
// (it hard-excludes opencode-zen unconditionally), and the real
|
|
35
|
+
// eligibility this module receives already reflects that exclusion. Both
|
|
36
|
+
// opencode-go and opencode-zen genuinely run through the same real
|
|
37
|
+
// askOpencode call (Kairo's own read-only agent — see
|
|
38
|
+
// intelligence/opencode-ask-agent.js), same as real execution routing
|
|
39
|
+
// already treats capability and cost-risk as two separate layers.
|
|
40
|
+
export const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude", "cursor", "opencode-go", "opencode-zen"]);
|
|
33
41
|
|
|
34
42
|
/**
|
|
35
43
|
* The Bootstrap Analyst as a temporary, read-only WORKFLOW — deliberately
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
5
|
+
|
|
6
|
+
// OpenCode's real CLI (`opencode run --help`) has no flag-driven read-only
|
|
7
|
+
// mode — its permission model lives only in opencode.json (verified via
|
|
8
|
+
// its own published schema, https://opencode.ai/config.json, and a real
|
|
9
|
+
// working precedent already present in this machine's own global config:
|
|
10
|
+
// gentle-ai/sdd's "explore" agent, which uses this exact same
|
|
11
|
+
// bash/edit/task/write-deny shape to stay read-only). So a genuinely
|
|
12
|
+
// portable read-only ASK call needs Kairo to own one real agent entry in
|
|
13
|
+
// the user's GLOBAL opencode.json — never per-project (would pollute
|
|
14
|
+
// every repo it touches) — merged in non-destructively and never
|
|
15
|
+
// overwriting anything else already there.
|
|
16
|
+
export const KAIRO_ASK_AGENT_NAME = "kairo-ask";
|
|
17
|
+
|
|
18
|
+
// No "model" field on purpose — the real model comes from askOpencode's
|
|
19
|
+
// own `--model` CLI flag per call, exactly like askCodex/askClaude/
|
|
20
|
+
// askCursor already do; this agent only ever fixes the PERMISSION shape.
|
|
21
|
+
// No "read" key: the same real, working "explore" agent precedent above
|
|
22
|
+
// carries no explicit "read" entry either — omitted means allowed.
|
|
23
|
+
//
|
|
24
|
+
// Deliberately NO "__managed_by" marker (unlike the sibling "explore"
|
|
25
|
+
// agent's convention this was modeled on) — verified live that it isn't
|
|
26
|
+
// a real AgentConfig property at all: the real opencode.ai/config.json
|
|
27
|
+
// schema never defines it, and a live `opencode run` call with it present
|
|
28
|
+
// failed outright ("Unsupported parameter(s): `__managed_by`" from the
|
|
29
|
+
// real upstream API, which apparently receives it verbatim). Ownership is
|
|
30
|
+
// tracked by the unique key name (KAIRO_ASK_AGENT_NAME) instead.
|
|
31
|
+
export const KAIRO_ASK_AGENT_CONFIG = Object.freeze({
|
|
32
|
+
description: "Kairo's own real, read-only agent for ASK mode — investigates and answers, never edits, writes, or runs shell commands.",
|
|
33
|
+
hidden: true,
|
|
34
|
+
mode: "primary",
|
|
35
|
+
permission: Object.freeze({ bash: "deny", edit: "deny", task: "deny", write: "deny" })
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function deepEqual(a, b) {
|
|
39
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveOpencodeConfigPath(homeDir = homedir()) {
|
|
43
|
+
return join(homeDir, ".config", "opencode", "opencode.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Idempotently ensures the real, global opencode.json carries Kairo's own
|
|
48
|
+
* read-only "kairo-ask" agent — merged non-destructively (every other real
|
|
49
|
+
* key, including every other agent, is preserved byte-for-byte) and only
|
|
50
|
+
* ever written when actually missing or drifted, never on every call.
|
|
51
|
+
* A missing config file is created fresh with just $schema + this agent —
|
|
52
|
+
* never treated as an error (a real, common first-run state).
|
|
53
|
+
* @param {{homeDir?: string, readFileImpl?: Function, writeAtomicJsonImpl?: Function}} [deps]
|
|
54
|
+
* @returns {Promise<{changed: boolean, configPath: string}>}
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureKairoAskAgent({
|
|
57
|
+
homeDir = homedir(), readFileImpl = readFile, writeAtomicJsonImpl = writeAtomicJson
|
|
58
|
+
} = {}) {
|
|
59
|
+
const configPath = resolveOpencodeConfigPath(homeDir);
|
|
60
|
+
let config = { $schema: "https://opencode.ai/config.json" };
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFileImpl(configPath, "utf8");
|
|
63
|
+
config = JSON.parse(raw);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error?.code !== "ENOENT") throw error;
|
|
66
|
+
}
|
|
67
|
+
const existing = config.agent?.[KAIRO_ASK_AGENT_NAME];
|
|
68
|
+
if (deepEqual(existing, KAIRO_ASK_AGENT_CONFIG)) {
|
|
69
|
+
return { changed: false, configPath };
|
|
70
|
+
}
|
|
71
|
+
const updated = { ...config, agent: { ...(config.agent ?? {}), [KAIRO_ASK_AGENT_NAME]: KAIRO_ASK_AGENT_CONFIG } };
|
|
72
|
+
await writeAtomicJsonImpl(configPath, updated);
|
|
73
|
+
return { changed: true, configPath };
|
|
74
|
+
}
|
|
@@ -3,6 +3,8 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js";
|
|
6
|
+
import { ensureKairoAskAgent, KAIRO_ASK_AGENT_NAME } from "./opencode-ask-agent.js";
|
|
7
|
+
import { toRuntimeModelRef } from "./transport-registry.js";
|
|
6
8
|
|
|
7
9
|
// A real, read-only question -> answer call — no task, no plan, no
|
|
8
10
|
// approval gate. This spends real provider usage (unlike the zero-cost
|
|
@@ -166,6 +168,98 @@ function askCursor({ question, model, cwd, spawn, timeoutMs, env }) {
|
|
|
166
168
|
});
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
// Same real scrubbing principle as the others — OPENCODE_API_KEY is
|
|
172
|
+
// opencode's own documented real auth env var (types.js's
|
|
173
|
+
// OPENCODE_API_KEY_ENV).
|
|
174
|
+
const OPENCODE_SAFE_ENV_KEYS = Object.freeze([
|
|
175
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
|
|
176
|
+
"TMPDIR", "TERM", "OPENCODE_API_KEY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
177
|
+
"http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
|
|
178
|
+
]);
|
|
179
|
+
function buildOpencodeExecutionEnv(sourceEnv = process.env) {
|
|
180
|
+
const env = Object.create(null);
|
|
181
|
+
for (const key of OPENCODE_SAFE_ENV_KEYS) {
|
|
182
|
+
if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
|
|
183
|
+
}
|
|
184
|
+
return env;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* OpenCode's real CLI has no flag-driven read-only mode (verified via
|
|
189
|
+
* `opencode run --help`) — its permission model lives only in
|
|
190
|
+
* opencode.json, so this always runs against Kairo's own real, verified
|
|
191
|
+
* read-only agent (see opencode-ask-agent.js's own doc — live-verified
|
|
192
|
+
* both that it genuinely blocks a real write attempt and that
|
|
193
|
+
* `--agent`/`--model` compose correctly), ensured to exist in the user's
|
|
194
|
+
* global config before every call (cheap idempotent check — no real
|
|
195
|
+
* write unless actually missing or drifted).
|
|
196
|
+
*
|
|
197
|
+
* Verified live (`opencode run --agent kairo-ask --format json`): the
|
|
198
|
+
* real NDJSON stream emits `type: "text"` events carrying the real
|
|
199
|
+
* answer in `part.text` (possibly across multiple steps — accumulated in
|
|
200
|
+
* order) and a real `type: "error"` event on failure, both handled
|
|
201
|
+
* per-line as chunks arrive, mirroring the same idle-reset principle as
|
|
202
|
+
* every other ask call here.
|
|
203
|
+
* @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args
|
|
204
|
+
*/
|
|
205
|
+
async function askOpencode({ question, model, cwd, spawn, timeoutMs, env, ensureAgent = ensureKairoAskAgent }) {
|
|
206
|
+
try {
|
|
207
|
+
await ensureAgent();
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return unknown(`could not ensure Kairo's read-only OpenCode agent: ${error?.message ?? error}`);
|
|
210
|
+
}
|
|
211
|
+
const args = ["run", "--agent", KAIRO_ASK_AGENT_NAME, "--format", "json"];
|
|
212
|
+
if (model) args.push("--model", model);
|
|
213
|
+
args.push(question);
|
|
214
|
+
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
let child;
|
|
217
|
+
try {
|
|
218
|
+
child = spawn("opencode", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
resolve(unknown(error?.message ?? error));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let buffer = "";
|
|
224
|
+
const answerParts = [];
|
|
225
|
+
let realError = null;
|
|
226
|
+
let stderr = "";
|
|
227
|
+
let finished = false;
|
|
228
|
+
const clearIdleTimer = armIdleTimeout(child, timeoutMs, () => finish(unknown(`opencode run idle-timed out after ${timeoutMs}ms with no output`)));
|
|
229
|
+
function finish(result) {
|
|
230
|
+
if (finished) return;
|
|
231
|
+
finished = true;
|
|
232
|
+
clearIdleTimer();
|
|
233
|
+
try { child.kill?.(); } catch { /* best effort */ }
|
|
234
|
+
resolve(result);
|
|
235
|
+
}
|
|
236
|
+
function handleLine(line) {
|
|
237
|
+
let parsed;
|
|
238
|
+
try { parsed = JSON.parse(line); } catch { return; }
|
|
239
|
+
if (parsed?.type === "text" && typeof parsed?.part?.text === "string") {
|
|
240
|
+
answerParts.push(parsed.part.text);
|
|
241
|
+
} else if (parsed?.type === "error") {
|
|
242
|
+
realError = parsed.error?.data?.message ?? parsed.error?.name ?? "opencode run returned a real error event";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
child.stdout?.on("data", (chunk) => {
|
|
246
|
+
buffer += String(chunk);
|
|
247
|
+
const lines = buffer.split("\n");
|
|
248
|
+
buffer = lines.pop() ?? "";
|
|
249
|
+
for (const line of lines) if (line.trim()) handleLine(line.trim());
|
|
250
|
+
});
|
|
251
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
252
|
+
child.once?.("error", (error) => finish(unknown(error?.message ?? error)));
|
|
253
|
+
child.once?.("close", (code) => {
|
|
254
|
+
if (buffer.trim()) handleLine(buffer.trim());
|
|
255
|
+
if (realError) return finish(unknown(realError));
|
|
256
|
+
const answer = answerParts.join("").trim();
|
|
257
|
+
if (!answer) return finish(unknown(stderr.trim() || `opencode run exited ${code} with no real text output`));
|
|
258
|
+
finish({ status: "answered", answer, error: null });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
169
263
|
/** @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args */
|
|
170
264
|
async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
|
|
171
265
|
let outDir;
|
|
@@ -222,24 +316,39 @@ async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
|
|
|
222
316
|
|
|
223
317
|
/**
|
|
224
318
|
* Asks the given provider a real, read-only question and returns its real
|
|
225
|
-
* answer text
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
319
|
+
* answer text — every real automatic PROJECT TEAM adapter today (Codex,
|
|
320
|
+
* Claude, Cursor, OpenCode Go, OpenCode Zen); any other provider yields
|
|
321
|
+
* an honest "unsupported" result rather than a guess. OpenCode's real CLI
|
|
322
|
+
* has no flag-driven read-only mode (verified via `opencode run --help`:
|
|
323
|
+
* `--auto` only ever loosens permissions further, never restricts them),
|
|
324
|
+
* so it always runs against Kairo's own real, verified read-only agent
|
|
325
|
+
* instead (see opencode-ask-agent.js) — a project/user-config-independent
|
|
326
|
+
* guarantee, never relying on whatever the local opencode.json happens to
|
|
327
|
+
* already allow.
|
|
232
328
|
* @param {object} args
|
|
233
|
-
* @param {"codex"|"claude"|"cursor"} args.provider
|
|
329
|
+
* @param {"codex"|"claude"|"cursor"|"opencode-go"|"opencode-zen"} args.provider
|
|
234
330
|
* @param {string} args.question
|
|
235
331
|
* @param {string|null} [args.model]
|
|
236
332
|
* @param {string} args.cwd
|
|
237
333
|
*/
|
|
238
334
|
export async function askProvider({
|
|
239
|
-
provider, question, model = null, cwd, spawn = defaultSpawn, timeoutMs = DEFAULT_TIMEOUT_MS, sourceEnv = process.env
|
|
335
|
+
provider, question, model = null, cwd, spawn = defaultSpawn, timeoutMs = DEFAULT_TIMEOUT_MS, sourceEnv = process.env,
|
|
336
|
+
ensureOpencodeAskAgent = ensureKairoAskAgent
|
|
240
337
|
}) {
|
|
241
338
|
if (provider === "claude") return askClaude({ question, model, cwd, spawn, timeoutMs, env: buildClaudeExecutionEnv(sourceEnv) });
|
|
242
339
|
if (provider === "codex") return askCodex({ question, model, cwd, spawn, timeoutMs, env: buildCodexExecutionEnv(sourceEnv) });
|
|
243
340
|
if (provider === "cursor") return askCursor({ question, model, cwd, spawn, timeoutMs, env: buildCursorExecutionEnv(sourceEnv) });
|
|
341
|
+
if (provider === "opencode-go" || provider === "opencode-zen") {
|
|
342
|
+
// The real catalog stores bare model ids (see opencode-models.js's
|
|
343
|
+
// normalizeModel) — the CLI needs the real, fully-qualified
|
|
344
|
+
// "opencode-go/<id>" (or "opencode/<id>" for Zen) ref to
|
|
345
|
+
// deterministically route to the intended product, exactly like
|
|
346
|
+
// service.js's executePlan already does for real task execution.
|
|
347
|
+
const runtimeModel = model ? toRuntimeModelRef(provider === "opencode-go" ? "go" : "zen", model) : null;
|
|
348
|
+
return askOpencode({
|
|
349
|
+
question, model: runtimeModel, cwd, spawn, timeoutMs, env: buildOpencodeExecutionEnv(sourceEnv),
|
|
350
|
+
ensureAgent: ensureOpencodeAskAgent
|
|
351
|
+
});
|
|
352
|
+
}
|
|
244
353
|
return { status: "unsupported", answer: null, error: `ASK is not supported for provider "${provider}" yet.` };
|
|
245
354
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Disk cache for Claude per-model entitlement probes. Mirrors the
|
|
2
|
+
// artificial-analysis-models.js pattern (read→null on any failure, mkdir +
|
|
3
|
+
// writeAtomicJson, fetchedAt / ageLabel) — not usage-store.js, whose
|
|
4
|
+
// whitelist is for real billing providers.
|
|
5
|
+
|
|
6
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { harnessHomePaths } from "../paths.js";
|
|
9
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
10
|
+
import { ENTITLEMENT } from "./claude-model-entitlement.js";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ENTITLEMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function ageLabel(fetchedAtIso, nowMs = Date.now()) {
|
|
15
|
+
const fetchedAt = new Date(fetchedAtIso ?? "").getTime();
|
|
16
|
+
if (!Number.isFinite(fetchedAt)) return null;
|
|
17
|
+
const hours = (nowMs - fetchedAt) / 3_600_000;
|
|
18
|
+
if (hours < 1) return "<1h";
|
|
19
|
+
if (hours < 48) return `${Math.round(hours)}h`;
|
|
20
|
+
return `${Math.round(hours / 24)}d`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPersistableStatus(status) {
|
|
24
|
+
return status === ENTITLEMENT.ALLOWED || status === ENTITLEMENT.DENIED;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emptyDoc(subscriptionType, fetchedAt = new Date().toISOString()) {
|
|
28
|
+
return {
|
|
29
|
+
subscriptionType: subscriptionType ?? null,
|
|
30
|
+
fetchedAt,
|
|
31
|
+
models: Object.create(null)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} homeDir
|
|
37
|
+
* @param {object} [deps]
|
|
38
|
+
* @returns {Promise<object|null>}
|
|
39
|
+
*/
|
|
40
|
+
export async function readClaudeEntitlementCache(homeDir, deps = {}) {
|
|
41
|
+
const read = deps.readFile ?? readFile;
|
|
42
|
+
try {
|
|
43
|
+
const raw = await read(harnessHomePaths(homeDir).claudeEntitlementPath, "utf8");
|
|
44
|
+
const doc = JSON.parse(raw);
|
|
45
|
+
if (!doc || typeof doc !== "object") return null;
|
|
46
|
+
if (typeof doc.fetchedAt !== "string") return null;
|
|
47
|
+
if (!doc.models || typeof doc.models !== "object" || Array.isArray(doc.models)) return null;
|
|
48
|
+
return doc;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} homeDir
|
|
56
|
+
* @param {object} doc
|
|
57
|
+
* @param {object} [deps]
|
|
58
|
+
*/
|
|
59
|
+
export async function writeClaudeEntitlementCache(homeDir, doc, deps = {}) {
|
|
60
|
+
const mkdirImpl = deps.mkdir ?? mkdir;
|
|
61
|
+
const writeJson = deps.writeAtomicJson ?? writeAtomicJson;
|
|
62
|
+
const path = harnessHomePaths(homeDir).claudeEntitlementPath;
|
|
63
|
+
await mkdirImpl(dirname(path), { recursive: true });
|
|
64
|
+
await writeJson(path, doc);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure resolver: map catalog ids → live entitlement view from cache.
|
|
69
|
+
* Invalidates the entire cache when subscriptionType differs.
|
|
70
|
+
*
|
|
71
|
+
* @param {{
|
|
72
|
+
* cache: object|null,
|
|
73
|
+
* subscriptionType: string|null,
|
|
74
|
+
* catalogIds: string[],
|
|
75
|
+
* now?: number,
|
|
76
|
+
* ttlMs?: number
|
|
77
|
+
* }} options
|
|
78
|
+
* @returns {Record<string, { status: string, reason: string|null, age: string|null, probedAt: string|null }>}
|
|
79
|
+
*/
|
|
80
|
+
export function resolveClaudeEntitlements({
|
|
81
|
+
cache,
|
|
82
|
+
subscriptionType,
|
|
83
|
+
catalogIds = [],
|
|
84
|
+
now = Date.now(),
|
|
85
|
+
ttlMs = DEFAULT_ENTITLEMENT_TTL_MS
|
|
86
|
+
} = {}) {
|
|
87
|
+
const usable = cache
|
|
88
|
+
&& typeof cache === "object"
|
|
89
|
+
&& cache.subscriptionType === subscriptionType
|
|
90
|
+
&& cache.models
|
|
91
|
+
&& typeof cache.models === "object"
|
|
92
|
+
? cache
|
|
93
|
+
: null;
|
|
94
|
+
|
|
95
|
+
const resolved = Object.create(null);
|
|
96
|
+
for (const modelId of catalogIds) {
|
|
97
|
+
const entry = usable?.models?.[modelId] ?? null;
|
|
98
|
+
if (!entry || !isPersistableStatus(entry.status)) {
|
|
99
|
+
resolved[modelId] = {
|
|
100
|
+
status: ENTITLEMENT.UNVERIFIED,
|
|
101
|
+
reason: null,
|
|
102
|
+
age: null,
|
|
103
|
+
probedAt: null
|
|
104
|
+
};
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const probedAtMs = new Date(entry.probedAt ?? "").getTime();
|
|
109
|
+
if (!Number.isFinite(probedAtMs) || now - probedAtMs > ttlMs) {
|
|
110
|
+
resolved[modelId] = {
|
|
111
|
+
status: ENTITLEMENT.UNVERIFIED,
|
|
112
|
+
reason: null,
|
|
113
|
+
age: ageLabel(entry.probedAt, now),
|
|
114
|
+
probedAt: entry.probedAt ?? null
|
|
115
|
+
};
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
resolved[modelId] = {
|
|
120
|
+
status: entry.status,
|
|
121
|
+
reason: entry.reason ?? null,
|
|
122
|
+
age: ageLabel(entry.probedAt, now),
|
|
123
|
+
probedAt: entry.probedAt
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Merge fresh probe results into a cache doc. Discards status "unknown"
|
|
131
|
+
* (and unverified) — only allowed/denied evidence is persisted.
|
|
132
|
+
*
|
|
133
|
+
* @param {object|null} cache
|
|
134
|
+
* @param {{ subscriptionType: string|null, catalogIds?: string[], results: Array<{ modelId: string, status: string, reason?: string|null, probedAt?: string }> }} payload
|
|
135
|
+
*/
|
|
136
|
+
export function mergeEntitlementResults(cache, { subscriptionType, results = [] } = {}) {
|
|
137
|
+
const base = cache
|
|
138
|
+
&& typeof cache === "object"
|
|
139
|
+
&& cache.subscriptionType === subscriptionType
|
|
140
|
+
&& cache.models
|
|
141
|
+
&& typeof cache.models === "object"
|
|
142
|
+
? {
|
|
143
|
+
subscriptionType: cache.subscriptionType,
|
|
144
|
+
fetchedAt: cache.fetchedAt,
|
|
145
|
+
models: { ...cache.models }
|
|
146
|
+
}
|
|
147
|
+
: emptyDoc(subscriptionType);
|
|
148
|
+
|
|
149
|
+
let newestProbedAt = base.fetchedAt;
|
|
150
|
+
for (const result of results) {
|
|
151
|
+
if (!result || typeof result.modelId !== "string") continue;
|
|
152
|
+
if (result.status === "unknown" || !isPersistableStatus(result.status)) continue;
|
|
153
|
+
const probedAt = typeof result.probedAt === "string"
|
|
154
|
+
? result.probedAt
|
|
155
|
+
: new Date().toISOString();
|
|
156
|
+
base.models[result.modelId] = {
|
|
157
|
+
status: result.status,
|
|
158
|
+
reason: result.reason ?? null,
|
|
159
|
+
probedAt
|
|
160
|
+
};
|
|
161
|
+
if (!newestProbedAt || probedAt > newestProbedAt) newestProbedAt = probedAt;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
base.fetchedAt = newestProbedAt ?? new Date().toISOString();
|
|
165
|
+
base.subscriptionType = subscriptionType ?? null;
|
|
166
|
+
return base;
|
|
167
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Live per-model Claude entitlement probe. Kept separate from
|
|
2
|
+
// claude-models.js on purpose: that module is sync, free, and pure, and
|
|
3
|
+
// three of its four callers sit on hot paths. This module is the only
|
|
4
|
+
// place that interprets the real `claude -p … --output-format json`
|
|
5
|
+
// response for account access — fail-closed, never inventing allowed.
|
|
6
|
+
|
|
7
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
8
|
+
import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js";
|
|
9
|
+
|
|
10
|
+
export const ENTITLEMENT = Object.freeze({
|
|
11
|
+
ALLOWED: "allowed",
|
|
12
|
+
DENIED: "denied",
|
|
13
|
+
UNVERIFIED: "unverified"
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const DENIED_ERROR_CODES = new Set(["credits_required"]);
|
|
17
|
+
const DENIED_HTTP_STATUSES = new Set([402, 403, 429]);
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
|
+
const PROBE_ARGS_PREFIX = Object.freeze(["-p", "hi", "--model"]);
|
|
20
|
+
const PROBE_ARGS_SUFFIX = Object.freeze(["--output-format", "json"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pure classifier for a parsed Claude CLI `--output-format json` result.
|
|
24
|
+
* The only place that interprets the real JSON shape for entitlement.
|
|
25
|
+
*
|
|
26
|
+
* @param {object|null|undefined} parsed
|
|
27
|
+
* @returns {{ status: string, reason: string|null }}
|
|
28
|
+
*/
|
|
29
|
+
export function classifyClaudeEntitlementResponse(parsed) {
|
|
30
|
+
if (!parsed || typeof parsed !== "object") {
|
|
31
|
+
return { status: ENTITLEMENT.UNVERIFIED, reason: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isError = parsed.is_error === true;
|
|
35
|
+
const status = parsed.api_error_status;
|
|
36
|
+
const code = parsed.api_error_code;
|
|
37
|
+
const message = typeof parsed.result === "string" && parsed.result.trim()
|
|
38
|
+
? parsed.result
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
isError
|
|
43
|
+
&& (DENIED_ERROR_CODES.has(code) || DENIED_HTTP_STATUSES.has(status))
|
|
44
|
+
) {
|
|
45
|
+
return { status: ENTITLEMENT.DENIED, reason: message };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (parsed.is_error === false && (status === null || status === undefined)) {
|
|
49
|
+
return { status: ENTITLEMENT.ALLOWED, reason: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { status: ENTITLEMENT.UNVERIFIED, reason: message };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function probeArgv(modelId) {
|
|
56
|
+
return [...PROBE_ARGS_PREFIX, modelId, ...PROBE_ARGS_SUFFIX];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unverifiedResult(modelId, reason = null) {
|
|
60
|
+
return {
|
|
61
|
+
modelId,
|
|
62
|
+
status: ENTITLEMENT.UNVERIFIED,
|
|
63
|
+
reason: reason == null ? null : String(reason),
|
|
64
|
+
probedAt: new Date().toISOString()
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Probe a single Claude model id via the measured CLI shape.
|
|
70
|
+
* @param {{ modelId: string, spawn?: typeof defaultSpawn, cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number }} options
|
|
71
|
+
*/
|
|
72
|
+
export async function probeClaudeModelEntitlement({
|
|
73
|
+
modelId,
|
|
74
|
+
spawn = defaultSpawn,
|
|
75
|
+
cwd = process.cwd(),
|
|
76
|
+
env = process.env,
|
|
77
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (typeof modelId !== "string" || !modelId) {
|
|
80
|
+
return unverifiedResult(modelId ?? "", "modelId is required");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let child;
|
|
84
|
+
try {
|
|
85
|
+
child = spawn("claude", probeArgv(modelId), {
|
|
86
|
+
cwd,
|
|
87
|
+
env: buildClaudeExecutionEnv(env),
|
|
88
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return unverifiedResult(modelId, error?.message ?? error);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
let stdout = "";
|
|
96
|
+
let stderr = "";
|
|
97
|
+
let finished = false;
|
|
98
|
+
const timer = setTimeout(
|
|
99
|
+
() => finish(unverifiedResult(modelId, `claude entitlement probe timed out after ${timeoutMs}ms`)),
|
|
100
|
+
timeoutMs
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
function finish(result) {
|
|
104
|
+
if (finished) return;
|
|
105
|
+
finished = true;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
try { child.kill?.(); } catch { /* best effort */ }
|
|
108
|
+
resolve(result);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
112
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
113
|
+
child.once?.("error", (error) => finish(unverifiedResult(modelId, error?.message ?? error)));
|
|
114
|
+
child.once?.("close", (code, signal) => {
|
|
115
|
+
if (signal) {
|
|
116
|
+
return finish(unverifiedResult(
|
|
117
|
+
modelId,
|
|
118
|
+
`claude entitlement probe was killed by signal ${signal}${stderr ? `: ${stderr.trim()}` : ""}`
|
|
119
|
+
));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let parsed = null;
|
|
123
|
+
try {
|
|
124
|
+
const trimmed = String(stdout ?? "").trim();
|
|
125
|
+
parsed = trimmed ? JSON.parse(trimmed) : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return finish(unverifiedResult(
|
|
128
|
+
modelId,
|
|
129
|
+
`claude entitlement probe returned invalid JSON${stderr ? `: ${stderr.trim()}` : ""}`
|
|
130
|
+
));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const classified = classifyClaudeEntitlementResponse(parsed);
|
|
134
|
+
// A non-zero exit with a classifiable JSON body still trusts the body —
|
|
135
|
+
// the measured denied probe exits 0, but broken/unknown shapes stay
|
|
136
|
+
// unverified regardless of exit code. Never promote a failed spawn to
|
|
137
|
+
// allowed just because exit was 0 with empty stdout (parsed null →
|
|
138
|
+
// unverified above).
|
|
139
|
+
if (classified.status === ENTITLEMENT.ALLOWED && code !== 0) {
|
|
140
|
+
return finish(unverifiedResult(
|
|
141
|
+
modelId,
|
|
142
|
+
`claude entitlement probe exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`
|
|
143
|
+
));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
finish({
|
|
147
|
+
modelId,
|
|
148
|
+
status: classified.status,
|
|
149
|
+
reason: classified.reason,
|
|
150
|
+
probedAt: new Date().toISOString()
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Probe many model ids sequentially (never Promise.all). Caps at maxProbes.
|
|
158
|
+
* @param {{
|
|
159
|
+
* modelIds: string[],
|
|
160
|
+
* maxProbes?: number,
|
|
161
|
+
* onProgress?: (event: { modelId: string, index: number, total: number, result: object }) => void,
|
|
162
|
+
* spawn?: typeof defaultSpawn,
|
|
163
|
+
* cwd?: string,
|
|
164
|
+
* env?: NodeJS.ProcessEnv,
|
|
165
|
+
* timeoutMs?: number
|
|
166
|
+
* }} options
|
|
167
|
+
*/
|
|
168
|
+
export async function probeClaudeModelEntitlements({
|
|
169
|
+
modelIds = [],
|
|
170
|
+
maxProbes = 12,
|
|
171
|
+
onProgress = null,
|
|
172
|
+
...probeOpts
|
|
173
|
+
} = {}) {
|
|
174
|
+
const ids = Array.isArray(modelIds) ? modelIds.slice(0, Math.max(0, maxProbes)) : [];
|
|
175
|
+
const results = [];
|
|
176
|
+
for (let index = 0; index < ids.length; index += 1) {
|
|
177
|
+
const modelId = ids[index];
|
|
178
|
+
const result = await probeClaudeModelEntitlement({ modelId, ...probeOpts });
|
|
179
|
+
results.push(result);
|
|
180
|
+
if (typeof onProgress === "function") {
|
|
181
|
+
onProgress({ modelId, index, total: ids.length, result });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
@@ -89,7 +89,7 @@ export async function readCodexModels({
|
|
|
89
89
|
child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
|
|
90
90
|
|
|
91
91
|
writeRequest(child, 1, "initialize", {
|
|
92
|
-
clientInfo: { name: "kairo", title: "Kairo", version: "0.
|
|
92
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.26.1" },
|
|
93
93
|
capabilities: {}
|
|
94
94
|
});
|
|
95
95
|
});
|
|
@@ -151,7 +151,7 @@ export async function readCodexUsage({
|
|
|
151
151
|
child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
|
|
152
152
|
|
|
153
153
|
writeRequest(child, 1, "initialize", {
|
|
154
|
-
clientInfo: { name: "kairo", title: "Kairo", version: "0.
|
|
154
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.26.1" },
|
|
155
155
|
capabilities: {}
|
|
156
156
|
});
|
|
157
157
|
});
|
package/src/global/paths.js
CHANGED
|
@@ -28,6 +28,7 @@ export function harnessHomePaths(homeDir) {
|
|
|
28
28
|
worktreesDir: join(root, "worktrees"),
|
|
29
29
|
usageDir: join(root, "usage"),
|
|
30
30
|
modelIntelligencePath: join(root, "model-intelligence.json"),
|
|
31
|
+
claudeEntitlementPath: join(root, "claude-entitlement.json"),
|
|
31
32
|
huggingfaceLeaderboardPath: join(root, "huggingface-leaderboard.json")
|
|
32
33
|
};
|
|
33
34
|
}
|