@worca/app 1.5.0-rc.3 → 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,558 @@
|
|
|
1
|
+
// src/broker/service.mjs
|
|
2
|
+
// The broker's core (plans/credential-broker-design.html §6.4): credential
|
|
3
|
+
// resolution, the /p/<slot>/… proxy, key verification, and the /internal API the
|
|
4
|
+
// worca server calls. Both HTTP listeners (main.mjs) hand requests here; the key
|
|
5
|
+
// page (ui-server.mjs) uses the credential methods.
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import https from 'node:https';
|
|
8
|
+
import { seal, open, suffixOf, keyId } from './vault.mjs';
|
|
9
|
+
import { isAllowedPath, pathIssue } from './slots.mjs';
|
|
10
|
+
import { resolveToken, tokenFromHeaders, parseMintRequest, mintToken, safeEqual, normalizeBillTo } from './tokens.mjs';
|
|
11
|
+
import { createUsageTap, priceUsage } from './usage.mjs';
|
|
12
|
+
import { scrubText } from './scrub.mjs';
|
|
13
|
+
import { createLimits } from './limits.mjs';
|
|
14
|
+
import { createCopilotExchange, parseGithubSecret, refreshGithubToken } from './copilot.mjs';
|
|
15
|
+
|
|
16
|
+
export const MAX_REQUEST_BYTES = 32 << 20;
|
|
17
|
+
const MAX_ERROR_BODY = 64 << 10;
|
|
18
|
+
const CRED_CACHE_MS = 30_000;
|
|
19
|
+
const UPSTREAM_IDLE_MS = 10 * 60_000;
|
|
20
|
+
|
|
21
|
+
const REQUEST_HEADER_ALLOW = new Set([
|
|
22
|
+
'content-type', 'accept', 'anthropic-version', 'anthropic-beta', 'user-agent', 'openai-beta',
|
|
23
|
+
'http-referer', 'x-title', 'x-openrouter-title', 'x-openrouter-categories',
|
|
24
|
+
]);
|
|
25
|
+
const REQUEST_HEADER_ALLOW_PREFIX = ['x-stainless-'];
|
|
26
|
+
const RESPONSE_HEADER_ALLOW = new Set(['content-type', 'request-id', 'x-request-id', 'retry-after']);
|
|
27
|
+
const RESPONSE_HEADER_ALLOW_PREFIX = ['anthropic-ratelimit-', 'x-ratelimit-'];
|
|
28
|
+
|
|
29
|
+
/** Headers copied from the agent's request to the upstream: an allowlist (plus the slot's own), never a denylist. */
|
|
30
|
+
export function upstreamRequestHeaders(incoming, extra = []) {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [k, v] of Object.entries(incoming || {})) {
|
|
33
|
+
const key = k.toLowerCase();
|
|
34
|
+
if (REQUEST_HEADER_ALLOW.has(key) || REQUEST_HEADER_ALLOW_PREFIX.some((p) => key.startsWith(p)) || (extra && extra.includes(key))) out[key] = v;
|
|
35
|
+
}
|
|
36
|
+
// The usage tap reads the body as it streams: ask for it uncompressed.
|
|
37
|
+
out['accept-encoding'] = 'identity';
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Headers copied from the upstream's response back to the agent. */
|
|
42
|
+
export function agentResponseHeaders(incoming) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [k, v] of Object.entries(incoming || {})) {
|
|
45
|
+
const key = k.toLowerCase();
|
|
46
|
+
if (RESPONSE_HEADER_ALLOW.has(key) || RESPONSE_HEADER_ALLOW_PREFIX.some((p) => key.startsWith(p))) out[key] = v;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The error body a client of `protocol` understands, with a `worca-broker:` message. */
|
|
52
|
+
export function brokerError(protocol, status, type, message) {
|
|
53
|
+
const msg = `worca-broker: ${message}`;
|
|
54
|
+
const body = protocol === 'openai'
|
|
55
|
+
? { error: { message: msg, type, code: type } }
|
|
56
|
+
: { type: 'error', error: { type, message: msg } };
|
|
57
|
+
return { status, body };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sendJson(res, status, body, extra = {}) {
|
|
61
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
62
|
+
const s = JSON.stringify(body);
|
|
63
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(s), 'cache-control': 'no-store', ...extra });
|
|
64
|
+
res.end(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readBody(req, limit) {
|
|
68
|
+
return new Promise((resolveP, rejectP) => {
|
|
69
|
+
const parts = []; let n = 0;
|
|
70
|
+
req.on('data', (c) => { n += c.length; if (n > limit) { rejectP(Object.assign(new Error('too large'), { code: 'E_TOO_LARGE' })); req.destroy(); return; } parts.push(c); });
|
|
71
|
+
req.on('end', () => resolveP(Buffer.concat(parts)));
|
|
72
|
+
req.on('error', rejectP);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A human name for a slot in messages: "Anthropic API key". */
|
|
77
|
+
const slotName = (slot) => slot.label || slot.id;
|
|
78
|
+
|
|
79
|
+
/** The beta header a Claude subscription (OAuth) token needs on every API call. */
|
|
80
|
+
export const OAUTH_BETA = 'oauth-2025-04-20';
|
|
81
|
+
|
|
82
|
+
/** 'subscription' (sk-ant-oat…, from `claude setup-token`), 'github', or 'api-key'. Pure. */
|
|
83
|
+
export function credentialKind(slot, secret) {
|
|
84
|
+
if (slot && slot.auth === 'github-user') return 'github';
|
|
85
|
+
if (slot && slot.protocol === 'anthropic' && /^sk-ant-oat\d*-/.test(String(secret || ''))) return 'subscription';
|
|
86
|
+
return 'api-key';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Comma-joined betas without duplicates. Pure. */
|
|
90
|
+
export function mergeBetas(...lists) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const l of lists) for (const b of String(l || '').split(',').map((s) => s.trim()).filter(Boolean)) if (!out.includes(b)) out.push(b);
|
|
93
|
+
return out.join(',');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {{config:object, slots:object[], store:object, log?:(line:string)=>void, now?:()=>number,
|
|
98
|
+
* requestImpl?:(url:URL, opts:object)=>import('node:http').ClientRequest}} o
|
|
99
|
+
*/
|
|
100
|
+
export function createBrokerService({ config, slots, store, log = () => {}, now = Date.now, requestImpl, fetchImpl = globalThis.fetch }) {
|
|
101
|
+
const slotById = new Map(slots.map((s) => [s.id, s]));
|
|
102
|
+
const limits = createLimits({ store, config, now });
|
|
103
|
+
const credCache = new Map();
|
|
104
|
+
const doRequest = requestImpl || ((url, opts) => (url.protocol === 'https:' ? https : http).request(url, opts));
|
|
105
|
+
const exchanges = new Map();
|
|
106
|
+
/** The Copilot token exchange for a slot (one per slot: its fallback host and exchange URL). */
|
|
107
|
+
const copilotFor = (slot) => {
|
|
108
|
+
if (!exchanges.has(slot.id)) {
|
|
109
|
+
exchanges.set(slot.id, createCopilotExchange({
|
|
110
|
+
fetchImpl, now, defaultHost: new URL(slot.upstream).origin,
|
|
111
|
+
...(slot.exchangeUrl ? { exchangeUrl: slot.exchangeUrl } : {}),
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
return exchanges.get(slot.id);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Where a request for `slot` goes and the credential headers it carries. May throw (Copilot exchange). */
|
|
118
|
+
async function upstreamAuth(slot, secret, { force = false, incoming = {} } = {}) {
|
|
119
|
+
if (slot.auth === 'copilot') {
|
|
120
|
+
const t = await copilotFor(slot).token(secret, { force });
|
|
121
|
+
return { origin: t.host, headers: { authorization: `Bearer ${t.token}` }, secrets: [t.token] };
|
|
122
|
+
}
|
|
123
|
+
return { origin: new URL(slot.upstream).origin, headers: authHeaders(slot, secret, incoming), secrets: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const whereToAdd = (slot) => (config.mode === 'multi' && config.publicUrl
|
|
127
|
+
? `add one at ${config.publicUrl}`
|
|
128
|
+
: `set WORCA_BROKER_KEY_${slot.id.toUpperCase().replace(/-/g, '_')} on the broker`);
|
|
129
|
+
|
|
130
|
+
// ── credentials ─────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** {secret} (null secret for a keyless slot), or {missing:true}, or {error}. */
|
|
133
|
+
function resolveCredential(billTo, slot) {
|
|
134
|
+
if (slot.credential === 'none' || slot.auth === 'none') return { secret: null, row: null };
|
|
135
|
+
const ck = `${billTo}|${slot.id}`;
|
|
136
|
+
const hit = credCache.get(ck);
|
|
137
|
+
if (hit && now() - hit.at < CRED_CACHE_MS) return hit.value;
|
|
138
|
+
let value;
|
|
139
|
+
if (config.mode === 'single' || (slot.credential === 'operator' && config.allowTeamKeys)) {
|
|
140
|
+
const secret = config.singleKeys[slot.id];
|
|
141
|
+
value = secret ? { secret, row: null } : { missing: true };
|
|
142
|
+
} else if (slot.credential === 'operator') {
|
|
143
|
+
value = { missing: true };
|
|
144
|
+
} else {
|
|
145
|
+
const row = store.getCredential(billTo, slot.id);
|
|
146
|
+
if (!row) value = { missing: true };
|
|
147
|
+
else if (!config.vaultKey) value = { error: 'the broker has no vault key' };
|
|
148
|
+
else {
|
|
149
|
+
try { value = { secret: open(config.vaultKey, row, { billTo, slot: slot.id }), row }; }
|
|
150
|
+
catch { value = { error: 'this key cannot be decrypted (the vault key changed); enter it again' }; }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
credCache.set(ck, { at: now(), value });
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function authHeaders(slot, secret, incoming = {}) {
|
|
158
|
+
if (!secret || slot.auth === 'none') return {};
|
|
159
|
+
// A Claude subscription token goes as a Bearer with the OAuth beta the API requires
|
|
160
|
+
// for it (merged with the betas the CLI asked for), never as x-api-key.
|
|
161
|
+
if (credentialKind(slot, secret) === 'subscription') {
|
|
162
|
+
return { authorization: `Bearer ${secret}`, 'anthropic-beta': mergeBetas(incoming['anthropic-beta'], OAUTH_BETA) };
|
|
163
|
+
}
|
|
164
|
+
if (slot.auth === 'x-api-key') return { 'x-api-key': secret };
|
|
165
|
+
if (slot.auth === 'github-user') return { authorization: `token ${parseGithubSecret(secret).token}` };
|
|
166
|
+
return { authorization: `Bearer ${secret}` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One request to a slot's verify path with `secret`. {ok} or {ok:false, error}. */
|
|
170
|
+
function verifyCredential(slot, secret) {
|
|
171
|
+
if (!slot.verify) return Promise.resolve({ ok: true });
|
|
172
|
+
if (slot.auth === 'copilot') {
|
|
173
|
+
return copilotFor(slot).token(secret, { force: true }).then(() => ({ ok: true }), (err) => ({ ok: false, status: err.status, error: scrubText(err.message, [secret]) }));
|
|
174
|
+
}
|
|
175
|
+
// What to ask: the slot's verify request; a Claude subscription can't list models, so it
|
|
176
|
+
// gets the smallest possible message (one output token of the smallest model); GitHub: /user.
|
|
177
|
+
const sub = credentialKind(slot, secret) === 'subscription';
|
|
178
|
+
const spec = sub
|
|
179
|
+
? { method: 'POST', path: '/v1/messages', body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) }
|
|
180
|
+
: slot.auth === 'github-user' ? { method: 'GET', path: '/user' } : slot.verify;
|
|
181
|
+
return new Promise((resolveP) => {
|
|
182
|
+
const url = new URL(spec.path, slot.upstream);
|
|
183
|
+
const headers = { accept: 'application/json', 'accept-encoding': 'identity', ...authHeaders(slot, secret), ...(spec.body ? { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(spec.body)) } : {}) };
|
|
184
|
+
if (slot.protocol === 'anthropic') headers['anthropic-version'] = '2023-06-01';
|
|
185
|
+
if (slot.auth === 'github-user') headers['user-agent'] = 'worca-broker';
|
|
186
|
+
let req;
|
|
187
|
+
try { req = doRequest(url, { method: spec.method || 'GET', headers, timeout: 15_000 }); }
|
|
188
|
+
catch (err) { resolveP({ ok: false, error: `cannot reach ${url.host}: ${err.message}` }); return; }
|
|
189
|
+
req.on('timeout', () => req.destroy(new Error('timed out')));
|
|
190
|
+
req.on('error', (err) => resolveP({ ok: false, error: `cannot reach ${url.host}: ${err.message}` }));
|
|
191
|
+
req.on('response', (r) => {
|
|
192
|
+
const parts = []; let n = 0;
|
|
193
|
+
r.on('data', (c) => { if (n < MAX_ERROR_BODY) { parts.push(c); n += c.length; } });
|
|
194
|
+
r.on('end', () => {
|
|
195
|
+
if (r.statusCode >= 200 && r.statusCode < 300) { resolveP({ ok: true }); return; }
|
|
196
|
+
let detail = Buffer.concat(parts).toString('utf8');
|
|
197
|
+
try { const j = JSON.parse(detail); detail = j?.error?.message || j?.message || detail; } catch { /* text */ }
|
|
198
|
+
resolveP({ ok: false, status: r.statusCode, error: scrubText(`${r.statusCode}: ${String(detail).slice(0, 300)}`, [secret, parseGithubSecret(secret).token]) });
|
|
199
|
+
});
|
|
200
|
+
r.on('error', (err) => resolveP({ ok: false, error: err.message }));
|
|
201
|
+
});
|
|
202
|
+
req.end(spec.body);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Verify, then seal and store a person's credential. */
|
|
207
|
+
async function saveCredential(billTo, slotId, secret) {
|
|
208
|
+
const slot = slotById.get(slotId);
|
|
209
|
+
if (!slot || slot.credential !== 'per-person') return { ok: false, status: 404, error: 'unknown slot' };
|
|
210
|
+
if (!config.vaultKey) return { ok: false, status: 500, error: 'the broker has no vault key' };
|
|
211
|
+
const s = String(secret || '').trim();
|
|
212
|
+
if (s.length < 8 || s.length > 4096 || /\s/.test(s)) return { ok: false, status: 422, error: 'that does not look like a key: paste the whole value, without spaces' };
|
|
213
|
+
const v = await verifyCredential(slot, s);
|
|
214
|
+
if (!v.ok) return { ok: false, status: 422, error: `${slotName(slot)} was not accepted: ${v.error}` };
|
|
215
|
+
const kind = credentialKind(slot, s);
|
|
216
|
+
const suffix = suffixOf(slot.auth === 'github-user' ? parseGithubSecret(s).token : s);
|
|
217
|
+
store.putCredential({ billTo, slot: slotId, sealed: seal(config.vaultKey, s, { billTo, slot: slotId }), suffix, kind, verifiedAt: now(), now: now() });
|
|
218
|
+
credCache.delete(`${billTo}|${slotId}`);
|
|
219
|
+
log(`${new Date(now()).toISOString()} credential saved: ${billTo} ${slotId} (${kind})`);
|
|
220
|
+
return { ok: true, suffix, kind };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function testCredential(billTo, slotId) {
|
|
224
|
+
const slot = slotById.get(slotId);
|
|
225
|
+
if (!slot) return { ok: false, status: 404, error: 'unknown slot' };
|
|
226
|
+
const c = resolveCredential(billTo, slot);
|
|
227
|
+
if (c.missing) return { ok: false, status: 404, error: 'no key saved' };
|
|
228
|
+
if (c.error) return { ok: false, status: 409, error: c.error };
|
|
229
|
+
const v = await verifyCredential(slot, c.secret);
|
|
230
|
+
if (c.row) store.setVerify(billTo, slotId, { ok: v.ok, error: v.error, now: now() });
|
|
231
|
+
return v.ok ? { ok: true } : { ok: false, status: 422, error: v.error };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function deleteCredential(billTo, slotId) {
|
|
235
|
+
credCache.delete(`${billTo}|${slotId}`);
|
|
236
|
+
const n = store.deleteCredential(billTo, slotId);
|
|
237
|
+
if (n) log(`${new Date(now()).toISOString()} credential deleted: ${billTo} ${slotId}`);
|
|
238
|
+
return n > 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Status of every slot for one person. Never includes a secret. */
|
|
242
|
+
function slotStatus(billTo) {
|
|
243
|
+
const rows = config.mode === 'multi' ? new Map(store.listCredentials(billTo).map((r) => [r.slot, r])) : new Map();
|
|
244
|
+
return slots.map((slot) => {
|
|
245
|
+
const base = { id: slot.id, label: slot.label, protocol: slot.protocol, credential: slot.credential, keyHint: slot.keyHint || '', ...(slot.signIn ? { signIn: slot.signIn } : {}) };
|
|
246
|
+
if (slot.credential === 'none' || slot.auth === 'none') return { ...base, state: 'keyless' };
|
|
247
|
+
if (config.mode === 'single') {
|
|
248
|
+
const k = config.singleKeys[slot.id];
|
|
249
|
+
return { ...base, state: k ? 'set' : 'missing', ...(k ? { kind: credentialKind(slot, k) } : {}) };
|
|
250
|
+
}
|
|
251
|
+
if (slot.credential === 'operator') return { ...base, state: config.allowTeamKeys && config.singleKeys[slot.id] ? 'operator' : 'missing' };
|
|
252
|
+
const r = rows.get(slot.id);
|
|
253
|
+
if (!r) return { ...base, state: 'missing' };
|
|
254
|
+
const stale = config.vaultKey && r.key_id !== keyId(config.vaultKey);
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
state: stale ? 'invalid' : (r.verify_error ? 'invalid' : 'set'),
|
|
258
|
+
suffix: r.suffix, kind: r.kind || null, createdAt: r.created_at, updatedAt: r.updated_at, lastUsedAt: r.last_used_at,
|
|
259
|
+
verifiedAt: r.verified_at, verifyError: stale ? 'the vault key changed: enter this key again' : r.verify_error,
|
|
260
|
+
dailyUsd: r.daily_usd, monthlyUsd: r.monthly_usd,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Re-encrypt rows sealed with WORCA_BROKER_VAULT_KEY_OLD. Returns counts. */
|
|
266
|
+
function rotateVault() {
|
|
267
|
+
if (!config.vaultKey) return { rotated: 0, unreadable: 0 };
|
|
268
|
+
const cur = keyId(config.vaultKey);
|
|
269
|
+
let rotated = 0; let unreadable = 0;
|
|
270
|
+
for (const r of store.allCredentials()) {
|
|
271
|
+
if (r.key_id === cur) continue;
|
|
272
|
+
if (config.vaultKeyOld && r.key_id === keyId(config.vaultKeyOld)) {
|
|
273
|
+
try {
|
|
274
|
+
const secret = open(config.vaultKeyOld, r, { billTo: r.bill_to, slot: r.slot });
|
|
275
|
+
store.resealCredential(r.bill_to, r.slot, seal(config.vaultKey, secret, { billTo: r.bill_to, slot: r.slot }));
|
|
276
|
+
rotated++;
|
|
277
|
+
continue;
|
|
278
|
+
} catch { /* fall through */ }
|
|
279
|
+
}
|
|
280
|
+
unreadable++;
|
|
281
|
+
}
|
|
282
|
+
return { rotated, unreadable };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── proxy ───────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
async function handleProxy(req, res) {
|
|
288
|
+
const started = now();
|
|
289
|
+
const raw = String(req.url || '');
|
|
290
|
+
if (!raw.startsWith('/p/')) { sendJson(res, 404, { error: 'not found' }); return; }
|
|
291
|
+
const q = raw.indexOf('?');
|
|
292
|
+
const rawPath = q >= 0 ? raw.slice(0, q) : raw;
|
|
293
|
+
const query = q >= 0 ? raw.slice(q) : '';
|
|
294
|
+
const m = /^\/p\/([a-z][a-z0-9-]{1,31})(\/.*)?$/.exec(rawPath);
|
|
295
|
+
const slot = m ? slotById.get(m[1]) : null;
|
|
296
|
+
if (!slot) { const e = brokerError('anthropic', 404, 'not_found_error', 'unknown credential slot'); sendJson(res, e.status, e.body); return; }
|
|
297
|
+
const rest = m[2] || '/';
|
|
298
|
+
const fail = (status, type, message, extra) => { const e = brokerError(slot.protocol, status, type, message); sendJson(res, e.status, e.body, extra); };
|
|
299
|
+
// Every credential refusal is a 403, never a 401: the Claude Code CLI retries a 401 up
|
|
300
|
+
// to ten times with growing backoff (minutes of a hung spawn for a key that isn't
|
|
301
|
+
// there), and stops at once on a 403, printing our message. The body still says
|
|
302
|
+
// authentication_error, and recoverable-error.mjs reads `worca-broker:` as `auth`.
|
|
303
|
+
const refuseAuth = (message) => fail(403, 'authentication_error', message);
|
|
304
|
+
|
|
305
|
+
// The CLI's connectivity probe: answered here, never forwarded, no token needed.
|
|
306
|
+
if (req.method === 'HEAD' && rest === '/api/hello') { res.writeHead(200, { 'content-length': '0' }); res.end(); return; }
|
|
307
|
+
if (pathIssue(rest) || !isAllowedPath(slot, req.method, rest)) {
|
|
308
|
+
fail(403, 'permission_error', `${req.method} ${rest} is not allowed for slot ${slot.id}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const t = resolveToken(store, tokenFromHeaders(req.headers), now());
|
|
312
|
+
if (t.error) {
|
|
313
|
+
refuseAuth(t.error === 'missing' ? 'no token: this port only serves worca agents' : 'token expired or revoked');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const tok = t.row;
|
|
317
|
+
if (!tok.slots.includes(slot.id)) { fail(403, 'permission_error', `this token may not use slot ${slot.id}`); return; }
|
|
318
|
+
|
|
319
|
+
const cred = resolveCredential(tok.bill_to, slot);
|
|
320
|
+
if (cred.missing) { refuseAuth(`no ${slotName(slot)} for ${tok.bill_to}. ${capital(whereToAdd(slot))}`); return; }
|
|
321
|
+
if (cred.error) { refuseAuth(`${slotName(slot)} for ${tok.bill_to}: ${cred.error}`); return; }
|
|
322
|
+
// A Claude subscription belongs to one person. On a shared instance it is only used by a
|
|
323
|
+
// spawn under that person's own agent user; any other spawn could hand it to someone
|
|
324
|
+
// else, which for a subscription is account sharing, not just a wrong bill.
|
|
325
|
+
const subscription = credentialKind(slot, cred.secret) === 'subscription';
|
|
326
|
+
if (subscription && config.mode === 'multi' && !tok.isolated) {
|
|
327
|
+
refuseAuth(`${tok.bill_to}'s Claude subscription is only used by agents that run under their own user, and this spawn doesn't. ` +
|
|
328
|
+
'Use an API key on the key page, or ask the operator to run agents under their own users (docs/credential-broker.md)');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const admit = limits.acquire({ tokenRow: tok, slot, credentialRow: cred.row });
|
|
333
|
+
if (!admit.release) {
|
|
334
|
+
fail(admit.status, admit.kind === 'rate' ? 'rate_limit_error' : 'permission_error', admit.message,
|
|
335
|
+
admit.retryAfter ? { 'retry-after': String(admit.retryAfter) } : {});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let body;
|
|
340
|
+
try { body = await readBody(req, MAX_REQUEST_BYTES); }
|
|
341
|
+
catch (err) {
|
|
342
|
+
admit.release();
|
|
343
|
+
if (err.code === 'E_TOO_LARGE') fail(413, 'request_too_large', 'request body is too large');
|
|
344
|
+
else res.destroy();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const baseHeaders = { ...upstreamRequestHeaders(req.headers, slot.headers), 'content-length': String(body.length) };
|
|
349
|
+
// Copilot's gateway answers from a Chat Completions or a Messages endpoint on one slot.
|
|
350
|
+
const tapProtocol = /\/v1\/messages$/.test(rest) ? 'anthropic' : slot.protocol;
|
|
351
|
+
|
|
352
|
+
let settled = false;
|
|
353
|
+
const finish = (status, usage) => {
|
|
354
|
+
if (settled) return; settled = true;
|
|
355
|
+
admit.release();
|
|
356
|
+
// A subscription call has no per-call price: tokens are recorded, dollars are not.
|
|
357
|
+
const usd = usage && !subscription ? priceUsage(usage) : 0;
|
|
358
|
+
try {
|
|
359
|
+
store.insertUsage({
|
|
360
|
+
at: now(), billTo: tok.bill_to, slot: slot.id, spawnId: tok.spawn_id, runId: tok.run_id,
|
|
361
|
+
model: usage?.model ?? null, status, inputTokens: usage?.inputTokens, outputTokens: usage?.outputTokens,
|
|
362
|
+
cacheReadTokens: usage?.cacheReadTokens, cacheWriteTokens: usage?.cacheWriteTokens, usd, ms: now() - started,
|
|
363
|
+
plan: subscription ? 'subscription' : null,
|
|
364
|
+
});
|
|
365
|
+
store.addSpend(tok.hash, usd);
|
|
366
|
+
if (cred.row) store.markUsed(tok.bill_to, slot.id, now());
|
|
367
|
+
} catch (err) { log(`usage write failed: ${err.message}`); }
|
|
368
|
+
limits.invalidate(tok.bill_to, slot.id);
|
|
369
|
+
log(`${new Date(now()).toISOString()} ${tok.bill_to} ${slot.id} ${usage?.model || '-'} ${status} in=${usage?.inputTokens || 0} out=${usage?.outputTokens || 0} usd=${usd.toFixed(4)} ${now() - started}ms`);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
let up = null;
|
|
373
|
+
let authSecrets = []; // derived credentials (a Copilot token) to scrub along with the stored one
|
|
374
|
+
res.on('close', () => { if (!settled && up) up.destroy(); });
|
|
375
|
+
|
|
376
|
+
/** One attempt. Copilot gets exactly one retry with a fresh exchange on a 401 (its token expired early). */
|
|
377
|
+
const send = async (attempt) => {
|
|
378
|
+
let auth;
|
|
379
|
+
try { auth = await upstreamAuth(slot, cred.secret, { force: attempt > 0, incoming: req.headers }); }
|
|
380
|
+
catch (err) {
|
|
381
|
+
finish(err.status || 502, null);
|
|
382
|
+
if (err.status === 401 || err.status === 403) {
|
|
383
|
+
if (cred.row) { store.setVerify(tok.bill_to, slot.id, { ok: false, error: err.message, now: now() }); credCache.delete(`${tok.bill_to}|${slot.id}`); }
|
|
384
|
+
refuseAuth(`your ${slotName(slot)} no longer works (${err.message}). ${capital(whereToAdd(slot))}`);
|
|
385
|
+
} else fail(502, 'api_error', `upstream unreachable: ${err.message}`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
authSecrets = auth.secrets;
|
|
389
|
+
const target = new URL(rest + query, auth.origin);
|
|
390
|
+
if (target.origin !== auth.origin) { finish(403, null); fail(403, 'permission_error', 'destination refused'); return; }
|
|
391
|
+
try { up = doRequest(target, { method: req.method, headers: { ...baseHeaders, ...auth.headers }, timeout: UPSTREAM_IDLE_MS }); }
|
|
392
|
+
catch { finish(502, null); fail(502, 'api_error', 'upstream unreachable'); return; }
|
|
393
|
+
up.on('timeout', () => up.destroy(new Error('upstream idle timeout')));
|
|
394
|
+
up.on('error', () => {
|
|
395
|
+
finish(502, null);
|
|
396
|
+
if (!res.headersSent) fail(502, 'api_error', 'upstream unreachable');
|
|
397
|
+
else res.destroy();
|
|
398
|
+
});
|
|
399
|
+
up.on('response', (ur) => onResponse(ur, attempt));
|
|
400
|
+
up.end(body);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const onResponse = (ur, attempt) => {
|
|
404
|
+
const status = ur.statusCode || 502;
|
|
405
|
+
if (status === 401 && slot.auth === 'copilot' && attempt === 0) {
|
|
406
|
+
ur.resume();
|
|
407
|
+
copilotFor(slot).invalidate(cred.secret);
|
|
408
|
+
send(1);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (status >= 300 && status < 400) {
|
|
412
|
+
ur.resume();
|
|
413
|
+
finish(502, null);
|
|
414
|
+
fail(502, 'api_error', 'upstream redirect refused');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (status >= 400) {
|
|
418
|
+
const parts = []; let n = 0;
|
|
419
|
+
ur.on('data', (c) => { if (n < MAX_ERROR_BODY) { parts.push(c); n += c.length; } });
|
|
420
|
+
ur.on('end', () => {
|
|
421
|
+
const text = scrubText(Buffer.concat(parts).toString('utf8'), [cred.secret, ...authSecrets]);
|
|
422
|
+
finish(status, null);
|
|
423
|
+
if ((status === 401 || status === 403) && cred.row) {
|
|
424
|
+
store.setVerify(tok.bill_to, slot.id, { ok: false, error: `${status} from the provider`, now: now() });
|
|
425
|
+
credCache.delete(`${tok.bill_to}|${slot.id}`);
|
|
426
|
+
let detail = text;
|
|
427
|
+
try { const j = JSON.parse(text); detail = j?.error?.message || detail; } catch { /* text */ }
|
|
428
|
+
refuseAuth(`your ${slotName(slot)} was rejected by the provider (${String(detail).slice(0, 200)}). Replace it: ${whereToAdd(slot)}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const h = agentResponseHeaders(ur.headers);
|
|
432
|
+
h['content-length'] = String(Buffer.byteLength(text));
|
|
433
|
+
if (!res.headersSent) { res.writeHead(status, h); res.end(text); }
|
|
434
|
+
});
|
|
435
|
+
ur.on('error', () => { finish(status, null); res.destroy(); });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const tap = createUsageTap(tapProtocol, ur.headers['content-type']);
|
|
439
|
+
res.writeHead(status, agentResponseHeaders(ur.headers));
|
|
440
|
+
ur.on('data', (c) => {
|
|
441
|
+
tap.write(c);
|
|
442
|
+
if (!res.write(c)) { ur.pause(); res.once('drain', () => ur.resume()); }
|
|
443
|
+
});
|
|
444
|
+
ur.on('end', () => { res.end(); finish(status, tap.end()); });
|
|
445
|
+
ur.on('error', () => { finish(status, tap.end()); res.destroy(); });
|
|
446
|
+
};
|
|
447
|
+
await send(0);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── internal API (worca server → broker) ───────────────────────────────────
|
|
451
|
+
|
|
452
|
+
async function handleInternal(req, res) {
|
|
453
|
+
const auth = String(req.headers.authorization || '');
|
|
454
|
+
const presented = /^Bearer\s+(\S+)$/i.exec(auth)?.[1] || '';
|
|
455
|
+
if (!presented || !safeEqual(presented, config.secret)) { sendJson(res, 401, { error: 'unauthorized' }); return; }
|
|
456
|
+
const url = new URL(req.url, 'http://broker');
|
|
457
|
+
const p = url.pathname;
|
|
458
|
+
let body = null;
|
|
459
|
+
if (req.method === 'POST') {
|
|
460
|
+
try { const b = await readBody(req, 64 << 10); body = b.length ? JSON.parse(b.toString('utf8')) : {}; }
|
|
461
|
+
catch { sendJson(res, 400, { error: 'body must be JSON (at most 64 KB)' }); return; }
|
|
462
|
+
}
|
|
463
|
+
if (req.method === 'GET' && p === '/internal/info') {
|
|
464
|
+
sendJson(res, 200, {
|
|
465
|
+
version: config.version || null, mode: config.mode, publicUrl: config.publicUrl,
|
|
466
|
+
// upstream: the pinned origin, so worca can map a bridged model's base URL to its slot
|
|
467
|
+
// (routing only; it never learns a key). auth: 'copilot' marks the Copilot slot.
|
|
468
|
+
slots: slots.map((s) => ({ id: s.id, label: s.label, protocol: s.protocol, credential: s.credential, upstream: new URL(s.upstream).origin, auth: s.auth })),
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (req.method === 'POST' && p === '/internal/tokens') {
|
|
473
|
+
const r = parseMintRequest(body, { slotIds: [...slotById.keys()], maxTtlMs: config.tokenMaxTtlMs });
|
|
474
|
+
if (r.error) { sendJson(res, 400, { error: r.error }); return; }
|
|
475
|
+
if (config.mode === 'single') r.req.billTo = normalizeBillTo(r.req.billTo) || 'local';
|
|
476
|
+
sendJson(res, 200, mintToken(store, r.req, now()));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
let m = /^\/internal\/tokens\/([A-Za-z0-9._:-]{1,128})$/.exec(p);
|
|
480
|
+
if (req.method === 'DELETE' && m) { sendJson(res, 200, { revoked: store.revokeSpawn(m[1], now()) }); return; }
|
|
481
|
+
if (req.method === 'POST' && p === '/internal/tokens/revoke') {
|
|
482
|
+
const { issuer, exceptIssuer, runId, billTo } = body || {};
|
|
483
|
+
const f = { issuer: str(issuer), exceptIssuer: str(exceptIssuer), runId: str(runId), billTo: billTo ? normalizeBillTo(billTo) : null };
|
|
484
|
+
if (!f.issuer && !f.exceptIssuer && !f.runId && !f.billTo) { sendJson(res, 400, { error: 'name issuer, exceptIssuer, runId or billTo' }); return; }
|
|
485
|
+
sendJson(res, 200, { revoked: store.revokeWhere(f, now()) });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
m = /^\/internal\/people\/([^/]+)\/slots$/.exec(p);
|
|
489
|
+
if (req.method === 'GET' && m) {
|
|
490
|
+
const who = normalizeBillTo(decodeURIComponent(m[1]));
|
|
491
|
+
if (!who) { sendJson(res, 400, { error: 'bad person' }); return; }
|
|
492
|
+
sendJson(res, 200, { person: who, keyPage: config.publicUrl, slots: slotStatus(who) });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// "Push as me": the acting person's GitHub user token, for ONE git or gh call worca makes
|
|
496
|
+
// itself (never an agent). A GitHub App user token close to expiry is renewed first.
|
|
497
|
+
if (req.method === 'POST' && p === '/internal/github-token') {
|
|
498
|
+
const slot = [...slotById.values()].find((s) => s.auth === 'github-user');
|
|
499
|
+
if (!slot) { sendJson(res, 404, { error: '"push as me" is not set up on the broker (WORCA_BROKER_GITHUB_CLIENT_ID)', code: 'not_configured' }); return; }
|
|
500
|
+
const who = normalizeBillTo(body?.person);
|
|
501
|
+
if (!who || who === 'local') { sendJson(res, 400, { error: 'person must be a signed-in person\'s email' }); return; }
|
|
502
|
+
const c = resolveCredential(who, slot);
|
|
503
|
+
if (c.missing) { sendJson(res, 404, { error: `${who} has not connected GitHub${config.publicUrl ? ` (key page: ${config.publicUrl})` : ''}`, code: 'not_connected' }); return; }
|
|
504
|
+
if (c.error) { sendJson(res, 409, { error: c.error }); return; }
|
|
505
|
+
let g = parseGithubSecret(c.secret);
|
|
506
|
+
if (g.refreshToken && g.expiresAt && g.expiresAt - now() < 5 * 60_000) {
|
|
507
|
+
try {
|
|
508
|
+
const next = await refreshGithubToken({ refreshToken: g.refreshToken, clientId: slot.clientId, clientSecret: config.github?.clientSecret, fetchImpl, ...(slot.deviceBaseUrl ? { baseUrl: slot.deviceBaseUrl } : {}), now: now() });
|
|
509
|
+
g = parseGithubSecret(next);
|
|
510
|
+
store.putCredential({ billTo: who, slot: slot.id, sealed: seal(config.vaultKey, next, { billTo: who, slot: slot.id }), suffix: suffixOf(g.token), kind: 'github', verifiedAt: now(), now: now() });
|
|
511
|
+
credCache.delete(`${who}|${slot.id}`);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
store.setVerify(who, slot.id, { ok: false, error: err.message, now: now() });
|
|
514
|
+
credCache.delete(`${who}|${slot.id}`);
|
|
515
|
+
sendJson(res, 409, { error: err.message, code: 'expired' });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
store.markUsed(who, slot.id, now());
|
|
520
|
+
log(`${new Date(now()).toISOString()} github token handed to worca for ${who}`);
|
|
521
|
+
sendJson(res, 200, { token: g.token, expiresAt: g.expiresAt ? new Date(g.expiresAt).toISOString() : null });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (req.method === 'GET' && p === '/internal/usage/summary') {
|
|
525
|
+
const iso = (v) => (v && !Number.isNaN(Date.parse(v)) ? new Date(v).toISOString() : null);
|
|
526
|
+
sendJson(res, 200, { rows: store.summarizeUsage({ since: iso(url.searchParams.get('since')), until: iso(url.searchParams.get('until')) }) });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (req.method === 'GET' && p === '/internal/usage') {
|
|
530
|
+
const who = url.searchParams.get('billTo');
|
|
531
|
+
sendJson(res, 200, {
|
|
532
|
+
rows: store.queryUsage({
|
|
533
|
+
since: url.searchParams.get('since'), billTo: who ? normalizeBillTo(who) : null,
|
|
534
|
+
runId: url.searchParams.get('runId'), spawnId: url.searchParams.get('spawnId'), limit: url.searchParams.get('limit'),
|
|
535
|
+
}),
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
sendJson(res, 404, { error: 'not found' });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
slots, slotById, limits, fetchImpl,
|
|
544
|
+
resolveCredential, verifyCredential, saveCredential, testCredential, deleteCredential, slotStatus, rotateVault,
|
|
545
|
+
handleProxy, handleInternal,
|
|
546
|
+
/** Router for the private port: /internal/*, /healthz, /p/*. */
|
|
547
|
+
handlePrivate(req, res) {
|
|
548
|
+
const u = String(req.url || '');
|
|
549
|
+
if (!u.startsWith('/')) { sendJson(res, 400, { error: 'absolute-form requests are refused' }); return; }
|
|
550
|
+
if (req.method === 'GET' && (u === '/healthz' || u.startsWith('/healthz?'))) { sendJson(res, 200, { ok: true, version: config.version || null, mode: config.mode }); return; }
|
|
551
|
+
if (u.startsWith('/internal/')) { handleInternal(req, res).catch(() => sendJson(res, 500, { error: 'internal error' })); return; }
|
|
552
|
+
handleProxy(req, res).catch(() => { if (!res.headersSent) sendJson(res, 500, { error: 'internal error' }); else res.destroy(); });
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function str(v) { return typeof v === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(v) ? v : null; }
|
|
558
|
+
function capital(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|