@worca/app 1.5.0 → 1.6.0-rc.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/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// src/core/broker-client.mjs
|
|
2
|
+
// worca's side of the credential broker (plans/credential-broker-design.html §6.1).
|
|
3
|
+
// With WORCA_BROKER_URL set, worca holds no model credential: every claude spawn
|
|
4
|
+
// gets a short-lived token from the broker instead (claude-runner.mjs), and the
|
|
5
|
+
// broker adds the person's real key on the way to the provider.
|
|
6
|
+
//
|
|
7
|
+
// WORCA_BROKER_URL the broker's INTERNAL address (http://broker:8080). Unset = off.
|
|
8
|
+
// WORCA_BROKER_SECRET(_FILE) the shared secret for the broker's /internal API
|
|
9
|
+
// WORCA_BROKER_SYSTEM_BILL_TO who pays for work no signed-in person caused
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
/** This server process's identity towards the broker: tokens issued under an older one are revoked at boot. */
|
|
14
|
+
export const BOOT_ID = `srv-${randomUUID()}`;
|
|
15
|
+
const INFO_TTL_MS = 60_000;
|
|
16
|
+
const CALL_TIMEOUT_MS = 10_000;
|
|
17
|
+
|
|
18
|
+
/** Whether the broker is on (pure: reads only `env`). */
|
|
19
|
+
export function brokerEnabled(env = process.env) {
|
|
20
|
+
return typeof env.WORCA_BROKER_URL === 'string' && env.WORCA_BROKER_URL.trim() !== '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** {url, secret, error}: the broker's address without a trailing slash, and the secret. */
|
|
24
|
+
export function brokerConfig(env = process.env, readFile = readFileSync) {
|
|
25
|
+
if (!brokerEnabled(env)) return null;
|
|
26
|
+
let url = String(env.WORCA_BROKER_URL).trim().replace(/\/+$/, '');
|
|
27
|
+
let error = null;
|
|
28
|
+
try {
|
|
29
|
+
const u = new URL(url);
|
|
30
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') error = 'WORCA_BROKER_URL must be http(s)';
|
|
31
|
+
else if (u.pathname !== '/' || u.search || u.username) error = 'WORCA_BROKER_URL must be an origin, e.g. http://broker:8080';
|
|
32
|
+
url = u.origin;
|
|
33
|
+
} catch { error = 'WORCA_BROKER_URL is not a URL'; }
|
|
34
|
+
let secret = null;
|
|
35
|
+
const file = String(env.WORCA_BROKER_SECRET_FILE || '').trim();
|
|
36
|
+
if (file) {
|
|
37
|
+
try { secret = String(readFile(file, 'utf8')).trim() || null; } catch (err) { error ||= `WORCA_BROKER_SECRET_FILE cannot be read: ${err.code || err.message}`; }
|
|
38
|
+
} else if (typeof env.WORCA_BROKER_SECRET === 'string') secret = env.WORCA_BROKER_SECRET.trim() || null;
|
|
39
|
+
if (!secret) error ||= 'WORCA_BROKER_SECRET (or WORCA_BROKER_SECRET_FILE) is required with WORCA_BROKER_URL';
|
|
40
|
+
return { url, secret, error };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let _cfg = null;
|
|
44
|
+
function cfg() {
|
|
45
|
+
if (!_cfg) _cfg = brokerConfig();
|
|
46
|
+
return _cfg;
|
|
47
|
+
}
|
|
48
|
+
/** Test seam: forget cached config and info. */
|
|
49
|
+
export function resetBrokerClient() { _cfg = null; _info = null; }
|
|
50
|
+
|
|
51
|
+
async function call(method, path, body, { timeoutMs = CALL_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
52
|
+
const c = cfg();
|
|
53
|
+
if (!c) throw new Error('the credential broker is not configured');
|
|
54
|
+
if (c.error) throw new Error(c.error);
|
|
55
|
+
const ac = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = await fetchImpl(`${c.url}${path}`, {
|
|
60
|
+
method,
|
|
61
|
+
headers: { authorization: `Bearer ${c.secret}`, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) },
|
|
62
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
63
|
+
signal: ac.signal,
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new Error(`cannot reach the credential broker at ${c.url}: ${err.name === 'AbortError' ? 'timed out' : (err.cause?.code || err.message)}`);
|
|
67
|
+
} finally { clearTimeout(timer); }
|
|
68
|
+
let data = null;
|
|
69
|
+
try { data = await res.json(); } catch { data = null; }
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const why = res.status === 401 ? 'the broker refused worca\'s secret (WORCA_BROKER_SECRET differs between worca and the broker)' : (data?.error || `HTTP ${res.status}`);
|
|
72
|
+
throw Object.assign(new Error(`credential broker: ${why}`), { status: res.status });
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let _info = null;
|
|
78
|
+
/** GET /internal/info, cached for a minute. */
|
|
79
|
+
export async function brokerInfo({ force = false } = {}) {
|
|
80
|
+
if (!force && _info && Date.now() - _info.at < INFO_TTL_MS) return _info.value;
|
|
81
|
+
const value = await call('GET', '/internal/info');
|
|
82
|
+
_info = { at: Date.now(), value };
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The last info fetched, without a network call (null before the first). */
|
|
87
|
+
export function cachedBrokerInfo() { return _info ? _info.value : null; }
|
|
88
|
+
|
|
89
|
+
/** Mint a spawn token. Returns {token, expiresAt, spawnId}. */
|
|
90
|
+
export async function mintSpawnToken({ billTo, slots, runId = null, threadId = null, kind = 'phase', ttlSec, budgetUsd, isolated = false } = {}) {
|
|
91
|
+
const spawnId = `sp-${randomUUID()}`;
|
|
92
|
+
const body = { billTo, slots, spawnId, kind, issuer: BOOT_ID, isolated: isolated === true };
|
|
93
|
+
if (runId) body.runId = String(runId);
|
|
94
|
+
if (threadId) body.threadId = String(threadId);
|
|
95
|
+
if (ttlSec) body.ttlSec = ttlSec;
|
|
96
|
+
if (budgetUsd != null) body.budgetUsd = budgetUsd;
|
|
97
|
+
const r = await call('POST', '/internal/tokens', body);
|
|
98
|
+
return { ...r, spawnId };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Revoke one spawn's token. Never throws: the token also expires on its own. */
|
|
102
|
+
export async function revokeSpawnToken(spawnId) {
|
|
103
|
+
if (!spawnId) return;
|
|
104
|
+
try { await call('DELETE', `/internal/tokens/${encodeURIComponent(spawnId)}`); }
|
|
105
|
+
catch (err) { console.warn(`[worca] broker: could not revoke a spawn token (it expires on its own): ${err.message}`); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Revoke every token an earlier worca process issued (their spawns died with it). */
|
|
109
|
+
export async function revokeStaleTokens() {
|
|
110
|
+
return call('POST', '/internal/tokens/revoke', { exceptIssuer: BOOT_ID });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* "Push as me": `person`'s GitHub user token for ONE git or gh call worca makes itself.
|
|
115
|
+
* Rejects with .code 'not_connected' | 'not_configured' | 'expired' when there is none.
|
|
116
|
+
*/
|
|
117
|
+
export async function personGithubToken(person) {
|
|
118
|
+
try {
|
|
119
|
+
return await call('POST', '/internal/github-token', { person });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// call() folds the broker's {error, code} into the message; recover the code.
|
|
122
|
+
const m = /not connected|not set up|renew|sign in again/i.exec(err.message || '');
|
|
123
|
+
err.code = !m ? err.code : /not set up/i.test(m[0]) ? 'not_configured' : /not connected/i.test(m[0]) ? 'not_connected' : 'expired';
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Slot status for one person: {person, keyPage, slots:[{id,label,state,…}]}. */
|
|
129
|
+
export async function personSlots(person) {
|
|
130
|
+
return call('GET', `/internal/people/${encodeURIComponent(String(person || '').toLowerCase())}/slots`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Usage rows, newest first. */
|
|
134
|
+
export async function brokerUsage({ since, billTo, runId } = {}) {
|
|
135
|
+
const q = new URLSearchParams();
|
|
136
|
+
if (since) q.set('since', since);
|
|
137
|
+
if (billTo) q.set('billTo', billTo);
|
|
138
|
+
if (runId) q.set('runId', runId);
|
|
139
|
+
return call('GET', `/internal/usage${q.size ? `?${q}` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Spend per person and slot in a window: {rows:[{billTo, slot, usd, requests, runs, …}]}. */
|
|
143
|
+
export async function brokerUsageSummary({ since, until } = {}) {
|
|
144
|
+
const q = new URLSearchParams();
|
|
145
|
+
if (since) q.set('since', since);
|
|
146
|
+
if (until) q.set('until', until);
|
|
147
|
+
return call('GET', `/internal/usage/summary${q.size ? `?${q}` : ''}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Summary rows (one per person and slot) folded into one row per person, most spend first.
|
|
152
|
+
* Pure. `local` (single mode, or work no signed-in person caused) stays its own row.
|
|
153
|
+
*/
|
|
154
|
+
export function foldUsageByPerson(rows = []) {
|
|
155
|
+
const by = new Map();
|
|
156
|
+
for (const r of rows) {
|
|
157
|
+
const p = by.get(r.billTo) || { person: r.billTo, usd: 0, requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, lastAt: null, slots: [] };
|
|
158
|
+
p.usd += Number(r.usd) || 0;
|
|
159
|
+
p.requests += Number(r.requests) || 0;
|
|
160
|
+
p.inputTokens += Number(r.inputTokens) || 0;
|
|
161
|
+
p.outputTokens += Number(r.outputTokens) || 0;
|
|
162
|
+
p.cacheReadTokens += Number(r.cacheReadTokens) || 0;
|
|
163
|
+
if (!p.lastAt || (r.lastAt && r.lastAt > p.lastAt)) p.lastAt = r.lastAt || p.lastAt;
|
|
164
|
+
p.slots.push({ slot: r.slot, plan: r.plan || 'api', usd: Number(r.usd) || 0, requests: Number(r.requests) || 0 });
|
|
165
|
+
by.set(r.billTo, p);
|
|
166
|
+
}
|
|
167
|
+
return [...by.values()].sort((a, b) => b.usd - a.usd || b.requests - a.requests);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The broker proxy URL for a slot. */
|
|
171
|
+
export function slotBaseUrl(slot, env = process.env) {
|
|
172
|
+
const c = brokerConfig(env);
|
|
173
|
+
return c ? `${c.url}/p/${slot}` : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The slot a broker proxy URL names, or null when `url` is not this broker's. */
|
|
177
|
+
export function slotOfBaseUrl(url, env = process.env) {
|
|
178
|
+
const c = brokerConfig(env);
|
|
179
|
+
if (!c || typeof url !== 'string') return null;
|
|
180
|
+
const m = new RegExp(`^${c.url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/p/([a-z][a-z0-9-]{1,31})/?$`).exec(url.trim());
|
|
181
|
+
return m ? m[1] : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Boot check (plans/credential-broker-design.html §6.7): reach the broker (retrying
|
|
186
|
+
* while it starts), then revoke tokens a previous worca process left behind.
|
|
187
|
+
* Resolves with the broker's info; rejects with a one-line reason.
|
|
188
|
+
*/
|
|
189
|
+
export async function connectBroker({ waitMs = 60_000, log = console.log } = {}) {
|
|
190
|
+
const c = cfg();
|
|
191
|
+
if (c?.error) throw new Error(c.error);
|
|
192
|
+
const deadline = Date.now() + waitMs;
|
|
193
|
+
let lastErr;
|
|
194
|
+
for (;;) {
|
|
195
|
+
try {
|
|
196
|
+
const info = await brokerInfo({ force: true });
|
|
197
|
+
try { await revokeStaleTokens(); } catch (err) { log(`[worca] broker: could not revoke older tokens: ${err.message}`); }
|
|
198
|
+
return info;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
lastErr = err;
|
|
201
|
+
if (err.status === 401 || Date.now() >= deadline) break;
|
|
202
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`cannot reach the credential broker: ${lastErr?.message || 'unknown error'}`);
|
|
206
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/core/broker-guard.mjs
|
|
2
|
+
// Guarantee K1 (plans/credential-broker-design.html §6.8): with the credential
|
|
3
|
+
// broker on, no model credential may exist anywhere an agent can reach. worca
|
|
4
|
+
// refuses to start while one does, and names each finding by WHERE it is — never by
|
|
5
|
+
// its value. The pure part takes everything as input; the boot wrapper gathers it.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** Env vars that authenticate a model provider. Empty values (as Compose sets them) don't count. */
|
|
10
|
+
export const MODEL_CREDENTIAL_ENV_KEYS = Object.freeze([
|
|
11
|
+
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
|
|
12
|
+
'OPENAI_API_KEY', 'OPENROUTER_API_KEY',
|
|
13
|
+
'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK',
|
|
14
|
+
'GOOGLE_APPLICATION_CREDENTIALS', 'ANTHROPIC_FOUNDRY_API_KEY', 'AZURE_OPENAI_API_KEY',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, … — but not *_MAX_OUTPUT_TOKENS (same rule as ask/model-proposal.mjs).
|
|
18
|
+
const SECRET_ENV_RE = /(^|_)(TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?)(_|$)/i;
|
|
19
|
+
const SECRET_HEADER_RE = /authorization|api[-_]?key|token|secret|cookie|password/i;
|
|
20
|
+
const LOOPBACK_RE =/^https?:\/\/(127\.\d+\.\d+\.\d+|localhost|\[::1\])(:\d+)?(\/|$)/i;
|
|
21
|
+
|
|
22
|
+
const describe = (v) => `(set, ${String(v).length} chars)`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{env?:Record<string,string|undefined>, files?:{path:string, kind:'login'|'settings'|'secret', content?:string}[],
|
|
26
|
+
* models?:object[], providers?:Record<string,object>, brokerUrl?:string}} o
|
|
27
|
+
* @returns {string[]} findings, one line each
|
|
28
|
+
*/
|
|
29
|
+
export function findLocalCredentials({ env = {}, files = [], models = [], providers = {}, brokerUrl = '', slotOrigins = [] } = {}) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const k of MODEL_CREDENTIAL_ENV_KEYS) {
|
|
32
|
+
const v = env[k];
|
|
33
|
+
if (typeof v === 'string' && v.trim()) out.push(`env ${k} ${describe(v.trim())}`);
|
|
34
|
+
}
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
if (f.kind === 'login') out.push(`${f.path}: a stored Claude Code sign-in`);
|
|
37
|
+
else if (f.kind === 'secret') out.push(`${f.path}: a mounted API key`);
|
|
38
|
+
else if (f.kind === 'settings' && /"apiKeyHelper"\s*:/.test(f.content || '')) out.push(`${f.path}: an apiKeyHelper`);
|
|
39
|
+
}
|
|
40
|
+
const brokerPrefix = brokerUrl ? `${brokerUrl.replace(/\/+$/, '')}/p/` : null;
|
|
41
|
+
for (const m of models || []) {
|
|
42
|
+
const id = JSON.stringify(m?.id ?? '?');
|
|
43
|
+
for (const [k, v] of Object.entries(m?.env || {})) {
|
|
44
|
+
if (typeof v !== 'string' || !v.trim()) continue;
|
|
45
|
+
if (k === 'ANTHROPIC_BASE_URL') {
|
|
46
|
+
if (!(brokerPrefix && v.trim().startsWith(brokerPrefix))) out.push(`catalog model ${id}: env ANTHROPIC_BASE_URL routes around the broker`);
|
|
47
|
+
} else if (SECRET_ENV_RE.test(k)) {
|
|
48
|
+
out.push(`catalog model ${id}: env ${k} ${/^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(v.trim()) ? `= ${v.trim()}` : describe(v.trim())}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const up = m?.upstream;
|
|
52
|
+
if (up && typeof up === 'object') {
|
|
53
|
+
if (typeof up.apiKey === 'string' && up.apiKey.trim()) out.push(`catalog model ${id}: upstream.apiKey`);
|
|
54
|
+
// A remote endpoint is fine when a broker slot pins its origin (the bridge goes there
|
|
55
|
+
// through the broker); a keyless local one is reached directly and holds no key.
|
|
56
|
+
if (typeof up.baseUrl === 'string' && up.baseUrl.trim() && !LOOPBACK_RE.test(up.baseUrl.trim()) && !isPrivateUrl(up.baseUrl)) {
|
|
57
|
+
let origin = null;
|
|
58
|
+
try { origin = new URL(up.baseUrl).origin; } catch { /* reported below */ }
|
|
59
|
+
if (!origin || !slotOrigins.includes(origin)) {
|
|
60
|
+
out.push(`catalog model ${id}: upstream.baseUrl ${origin ? new URL(origin).host : up.baseUrl} has no credential slot on the broker (add one to WORCA_BROKER_SLOTS_FILE)`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [h, v] of Object.entries(up.headers || {})) {
|
|
64
|
+
if (SECRET_HEADER_RE.test(h) && typeof v === 'string' && v.trim()) out.push(`catalog model ${id}: upstream.headers ${h}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const [name, p] of Object.entries(providers || {})) {
|
|
69
|
+
for (const k of ['apiKey', 'githubToken']) {
|
|
70
|
+
if (p && typeof p[k] === 'string' && p[k].trim()) out.push(`providers.${name}.${k}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPrivateUrl(u) {
|
|
77
|
+
try {
|
|
78
|
+
const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
79
|
+
if (h === 'host.docker.internal' || h.endsWith('.internal') || h.endsWith('.local') || !h.includes('.')) return true;
|
|
80
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(h);
|
|
81
|
+
if (!m) return false;
|
|
82
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
83
|
+
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31);
|
|
84
|
+
} catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The files the guard looks at under each HOME that agents or the server run with. */
|
|
88
|
+
export function credentialFiles(homes, { exists = existsSync, read = (p) => readFileSync(p, 'utf8') } = {}) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const home of [...new Set(homes.filter(Boolean))]) {
|
|
91
|
+
const dir = process.env.CLAUDE_CONFIG_DIR && home === process.env.HOME ? process.env.CLAUDE_CONFIG_DIR : join(home, '.claude');
|
|
92
|
+
const login = join(dir, '.credentials.json');
|
|
93
|
+
if (exists(login)) out.push({ path: login, kind: 'login' });
|
|
94
|
+
const settings = join(dir, 'settings.json');
|
|
95
|
+
if (exists(settings)) {
|
|
96
|
+
let content = '';
|
|
97
|
+
try { content = read(settings); } catch { content = ''; }
|
|
98
|
+
out.push({ path: settings, kind: 'settings', content });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (exists('/run/secrets/anthropic_api_key')) out.push({ path: '/run/secrets/anthropic_api_key', kind: 'secret' });
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The one-paragraph refusal the server prints before exiting 78. */
|
|
106
|
+
export function guardMessage(findings, keyPage) {
|
|
107
|
+
return [
|
|
108
|
+
'worca: the credential broker is on, but model credentials are still reachable by agents:',
|
|
109
|
+
...findings.map((f) => `worca: ${f}`),
|
|
110
|
+
`worca: remove them from worca (they belong in the broker${keyPage ? `: ${keyPage}` : ''}), then restart.`,
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/core/broker-routing.mjs
|
|
2
|
+
// Which credential-broker slot a model spends from (plans/credential-broker-design.html
|
|
3
|
+
// §5.3). The broker's slots each pin one provider origin; a model reaches a slot by:
|
|
4
|
+
// - Copilot -> the broker's Copilot slot (auth 'copilot')
|
|
5
|
+
// - a bridged provider -> the slot whose pinned origin equals the base URL's origin
|
|
6
|
+
// (api.openai.com -> openai, openrouter.ai -> openrouter, a
|
|
7
|
+
// gateway named in the broker's slots file -> that slot)
|
|
8
|
+
// - a keyless local endpoint (llama.cpp, Ollama, LM Studio on this machine or network)
|
|
9
|
+
// -> no slot: it holds no key, the bridge reaches it directly
|
|
10
|
+
// - anything else -> the anthropic slot, or the slot its ANTHROPIC_BASE_URL names
|
|
11
|
+
// Synchronous: reads the broker info cached at boot and on every spawn (broker-client.mjs).
|
|
12
|
+
import { brokerEnabled, cachedBrokerInfo, slotOfBaseUrl } from './broker-client.mjs';
|
|
13
|
+
import { isLocalBaseUrl } from './model-env.mjs';
|
|
14
|
+
import { findBridgedEntry } from './bridge/registry.mjs';
|
|
15
|
+
import { providerConfig, listGlobalModels } from './settings.mjs';
|
|
16
|
+
import { listPluginModels } from './plugin-models.mjs';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE = Object.freeze({ openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com' });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* {slot, prefix} for a base URL whose origin a (non-Copilot) slot pins, else null. Pure.
|
|
22
|
+
* One host can serve both APIs (a gateway): a slot of the wanted `protocol` wins.
|
|
23
|
+
*/
|
|
24
|
+
export function slotForBaseUrl(baseUrl, slots = [], protocol = null) {
|
|
25
|
+
let u;
|
|
26
|
+
try { u = new URL(String(baseUrl || '').trim()); } catch { return null; }
|
|
27
|
+
const same = slots.filter((x) => x.auth !== 'copilot' && x.auth !== 'github-user' && x.upstream === u.origin);
|
|
28
|
+
const s = (protocol && same.find((x) => x.protocol === protocol)) || same[0];
|
|
29
|
+
if (!s) return null;
|
|
30
|
+
return { slot: s.id, prefix: u.pathname.replace(/\/+$/, '') };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a bridged entry's upstream reaches its provider with the broker on. Pure given `slots`.
|
|
35
|
+
* @param {{provider:string, baseUrl?:string}} upstream
|
|
36
|
+
* @returns {{slot:string, prefix:string, copilot?:boolean}|{keyless:true}|{error:string}}
|
|
37
|
+
*/
|
|
38
|
+
export function routeUpstream(upstream, { slots = [], providerBaseUrl = null } = {}) {
|
|
39
|
+
if (!upstream) return { slot: 'anthropic', prefix: '' };
|
|
40
|
+
if (upstream.provider === 'copilot') {
|
|
41
|
+
const s = slots.find((x) => x.auth === 'copilot');
|
|
42
|
+
return s ? { slot: s.id, prefix: '', copilot: true } : { error: 'the credential broker has no GitHub Copilot slot' };
|
|
43
|
+
}
|
|
44
|
+
const base = upstream.baseUrl || providerBaseUrl || DEFAULT_BASE[upstream.provider] || null;
|
|
45
|
+
if (!base) return { error: `provider ${upstream.provider} has no base URL` };
|
|
46
|
+
// A slot that pins this origin wins, even for a local address: the operator put that
|
|
47
|
+
// server behind the broker (a llama-server with --api-key, say). Otherwise a local
|
|
48
|
+
// endpoint holds no key and is reached directly.
|
|
49
|
+
const protocol = upstream.api === 'anthropic' || upstream.provider === 'anthropic' ? 'anthropic' : 'openai';
|
|
50
|
+
const hit = slotForBaseUrl(base, slots, protocol);
|
|
51
|
+
if (hit) return hit;
|
|
52
|
+
if (isLocalBaseUrl(base)) return { keyless: true };
|
|
53
|
+
let host = base;
|
|
54
|
+
try { host = new URL(base).host; } catch { /* keep */ }
|
|
55
|
+
return { error: `no credential slot for ${host}: the broker's operator adds it to WORCA_BROKER_SLOTS_FILE` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The broker's slots from the cached info ([] before the first contact). */
|
|
59
|
+
export function brokerSlots() {
|
|
60
|
+
return cachedBrokerInfo()?.slots || [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** routeUpstream against the live broker info, with the provider's own base URL as the fallback. */
|
|
64
|
+
export function routeBridgedUpstream(upstream) {
|
|
65
|
+
let providerBaseUrl = null;
|
|
66
|
+
try { providerBaseUrl = upstream && upstream.provider !== 'copilot' ? (providerConfig(upstream.provider).baseUrl || null) : null; } catch { /* none */ }
|
|
67
|
+
return routeUpstream(upstream, { slots: brokerSlots(), providerBaseUrl });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every non-empty `model` string anywhere in a run's manifest (v1 stepper or v2 graph). Pure. */
|
|
71
|
+
export function manifestModels(manifest, out = new Set(), depth = 0) {
|
|
72
|
+
if (!manifest || typeof manifest !== 'object' || depth > 8) return out;
|
|
73
|
+
if (Array.isArray(manifest)) { for (const x of manifest) manifestModels(x, out, depth + 1); return out; }
|
|
74
|
+
for (const [k, v] of Object.entries(manifest)) {
|
|
75
|
+
if (k === 'model' && typeof v === 'string' && v.trim()) out.add(v.trim());
|
|
76
|
+
else if (v && typeof v === 'object') manifestModels(v, out, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which credentials a set of models needs, and which of them a person lacks. Pure.
|
|
83
|
+
* @param {string[]} modelIds
|
|
84
|
+
* @param {(id:string)=>({slot:string}|{keyless:true}|{error:string}|null)} slotOf
|
|
85
|
+
* @param {{id:string,label:string,state:string}[]} status the person's slots (broker)
|
|
86
|
+
* @returns {{missing:{slot:string,label:string,state:string,models:string[]}[], errors:string[]}}
|
|
87
|
+
*/
|
|
88
|
+
export function missingCredentials(modelIds, slotOf, status = []) {
|
|
89
|
+
const need = new Map();
|
|
90
|
+
const errors = [];
|
|
91
|
+
for (const id of new Set(modelIds)) {
|
|
92
|
+
const r = slotOf(id);
|
|
93
|
+
if (!r || r.keyless) continue;
|
|
94
|
+
if (r.error) { errors.push(`${id}: ${r.error}`); continue; }
|
|
95
|
+
if (!need.has(r.slot)) need.set(r.slot, []);
|
|
96
|
+
need.get(r.slot).push(id);
|
|
97
|
+
}
|
|
98
|
+
const byId = new Map(status.map((s) => [s.id, s]));
|
|
99
|
+
const missing = [];
|
|
100
|
+
for (const [slot, models] of need) {
|
|
101
|
+
const s = byId.get(slot);
|
|
102
|
+
if (s && ['set', 'keyless', 'operator'].includes(s.state)) continue;
|
|
103
|
+
missing.push({ slot, label: s?.label || slot, state: s?.state || 'missing', models });
|
|
104
|
+
}
|
|
105
|
+
return { missing, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One sentence naming what's missing, for a refusal. */
|
|
109
|
+
export function describeMissing({ missing, errors }, keyPage) {
|
|
110
|
+
const parts = missing.map((m) => `${m.label} (${m.state === 'invalid' ? 'rejected by the provider' : 'not added'}; needed by ${m.models.join(', ')})`);
|
|
111
|
+
const where = keyPage ? ` Add them on the key page (${keyPage}), then start again.` : '';
|
|
112
|
+
return [
|
|
113
|
+
parts.length ? `missing credentials: ${parts.join('; ')}.${where}` : '',
|
|
114
|
+
errors.length ? `models that can't reach a credential: ${errors.join('; ')}.` : '',
|
|
115
|
+
].filter(Boolean).join(' ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The slot a catalog model spends from, for badges and the start-of-run check.
|
|
120
|
+
* @returns {{slot:string}|{keyless:true}|{error:string}|null} null with the broker off
|
|
121
|
+
*/
|
|
122
|
+
export function modelSlot(modelId) {
|
|
123
|
+
if (!brokerEnabled()) return null;
|
|
124
|
+
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
125
|
+
if (!id) return null;
|
|
126
|
+
const bridged = findBridgedEntry(id);
|
|
127
|
+
if (bridged) {
|
|
128
|
+
const r = routeBridgedUpstream(bridged.upstream);
|
|
129
|
+
return r.slot ? { slot: r.slot } : r;
|
|
130
|
+
}
|
|
131
|
+
const lc = id.toLowerCase();
|
|
132
|
+
let entry = null;
|
|
133
|
+
try { entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc) || listPluginModels().find((m) => m.id.toLowerCase() === lc) || null; } catch { entry = null; }
|
|
134
|
+
const base = entry && entry.env && typeof entry.env.ANTHROPIC_BASE_URL === 'string' ? entry.env.ANTHROPIC_BASE_URL : null;
|
|
135
|
+
if (!base) return { slot: 'anthropic' };
|
|
136
|
+
const s = slotOfBaseUrl(base);
|
|
137
|
+
return s ? { slot: s } : { error: 'this model routes around the credential broker' };
|
|
138
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/core/claude-auth.mjs
|
|
2
|
+
// "Did this Claude failure happen because the CLI is signed out?" — for the
|
|
3
|
+
// surfaces that only learn it after the spawn (Ask Worca, run overview, model
|
|
4
|
+
// Test). The CLI's own error text is not reliable: signed out, a first-party
|
|
5
|
+
// model id can fail as `[claude-code:unrecognized_model]` instead of "Not
|
|
6
|
+
// logged in". So a failed run on a model the CLI serves itself (not routed to
|
|
7
|
+
// an endpoint or the bridge) asks preflight's probeClaudeAuth, which is cheap
|
|
8
|
+
// (remembered 60 s) and says 'signed-out' only when `claude auth status` does.
|
|
9
|
+
import { isClaudeSignedOutError, probeClaudeAuth } from './preflight.mjs';
|
|
10
|
+
import { modelHasBaseUrlRouting } from './config.mjs';
|
|
11
|
+
import { configuredClaudeBin } from './onboarding.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Never throws.
|
|
15
|
+
* @param {{message?:string, model?:string, bin?:string, probe?:typeof probeClaudeAuth, routed?:(model:string)=>boolean}} o
|
|
16
|
+
* `probe` / `routed` are injectable for tests.
|
|
17
|
+
* @returns {Promise<boolean>}
|
|
18
|
+
*/
|
|
19
|
+
export async function failedBecauseSignedOut({
|
|
20
|
+
message, model, bin = configuredClaudeBin(), probe = probeClaudeAuth, routed = modelHasBaseUrlRouting,
|
|
21
|
+
} = {}) {
|
|
22
|
+
try {
|
|
23
|
+
if (isClaudeSignedOutError(message)) return true;
|
|
24
|
+
if (model && routed(model)) return false; // the endpoint / bridge authenticates, not the CLI sign-in
|
|
25
|
+
// Fresh, not the remembered answer: a failure is rare, and a stale one would
|
|
26
|
+
// misname it for up to a minute after signing in or out.
|
|
27
|
+
return (await probe({ bin, force: true })).state === 'signed-out';
|
|
28
|
+
} catch { return false; }
|
|
29
|
+
}
|