@kal-elsam/kairo-runtime 0.26.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 +12 -0
- package/package.json +1 -1
- 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,18 @@ 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
|
+
|
|
8
20
|
## 0.26.0 — 2026-09-19 (Kairo Runtime)
|
|
9
21
|
|
|
10
22
|
Minor release. ASK is now ready for every real automatic adapter.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.26.
|
|
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",
|
|
@@ -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.26.
|
|
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.26.
|
|
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
|
}
|