@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
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
|
|
40
40
|
import { spawn } from 'node:child_process';
|
|
41
41
|
import { createInterface } from 'node:readline';
|
|
42
|
-
import { prepareModelEnv, envFlag, describeModelEnv } from './model-env.mjs';
|
|
42
|
+
import { prepareModelEnv, envFlag, describeModelEnv, withProviderModesOff } from './model-env.mjs';
|
|
43
43
|
import { effectiveDebugSpawn } from './settings.mjs';
|
|
44
44
|
import { classifyError, strongestClass } from './recoverable-error.mjs';
|
|
45
|
+
import { bridgeEvents } from './bridge/telemetry.mjs';
|
|
45
46
|
import { explainUnspawnableClaude, resolveClaudeBin } from './preflight.mjs';
|
|
46
47
|
import { hostGuardEnabled, hostGuardHookEntry, hostGuardSystemPrompt } from './host-guard.mjs';
|
|
47
48
|
// The offline classifier and the shape normalizer the mock ask role answers
|
|
@@ -54,6 +55,12 @@ import { dirname, join } from 'node:path';
|
|
|
54
55
|
import { tmpdir } from 'node:os';
|
|
55
56
|
import { stripGithubCredentials } from './github-credentials.mjs';
|
|
56
57
|
import { agentIdentity, agentSpawn, killAgentGroup, shareWithAgent } from './agent-user.mjs';
|
|
58
|
+
import { agentIdentityFor } from './agent-pool.mjs';
|
|
59
|
+
import { brokerEnabled, brokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl, slotOfBaseUrl } from './broker-client.mjs';
|
|
60
|
+
import { resolveBillTo, normalizeBillTo, currentOwner } from './billing.mjs';
|
|
61
|
+
import { redactSecrets, redactDeep } from './redact.mjs';
|
|
62
|
+
import { MODEL_CREDENTIAL_ENV_KEYS } from './broker-guard.mjs';
|
|
63
|
+
import { modelSlot } from './broker-routing.mjs';
|
|
57
64
|
|
|
58
65
|
const DEFAULT_BIN = process.env.WORCA_CLAUDE_BIN || process.env.ORCH_CLAUDE_BIN || 'claude';
|
|
59
66
|
|
|
@@ -163,6 +170,39 @@ function spawnFailure(bin, err, prefix) {
|
|
|
163
170
|
// line as stderr streams (see rlErr) and stamped on the error as `errorClass`.
|
|
164
171
|
const STDERR_DETAIL_MAX = 2000;
|
|
165
172
|
|
|
173
|
+
// stderr lines the CLI prints on spawns that go on to succeed, which are never
|
|
174
|
+
// the cause of a failure. `[claude-code:unrecognized_model]` fires on EVERY spawn
|
|
175
|
+
// whose model id the CLI does not know — every bridged or endpoint-routed catalog
|
|
176
|
+
// id — so as exit detail it masked the real cause (a 429 carried on the stdout
|
|
177
|
+
// result) and classified null, which kept the rate-limit retry from running.
|
|
178
|
+
// Such a line is still streamed as a stderr event; it only stops being evidence.
|
|
179
|
+
export const BENIGN_STDERR_PATTERNS = Object.freeze([
|
|
180
|
+
/^\[claude-code:unrecognized_model\]/,
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/** Whether a stderr line is a known-benign CLI notice (BENIGN_STDERR_PATTERNS). */
|
|
184
|
+
export function isBenignStderrLine(line) {
|
|
185
|
+
const t = String(line ?? '').trim();
|
|
186
|
+
return !!t && BENIGN_STDERR_PATTERNS.some((re) => re.test(t));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A bridged spawn's base URL names its catalog id and run tag (bridge/server.mjs
|
|
190
|
+
// bridgeBaseUrl: …/m/<id>[/r/<tag>]). Null for any other endpoint.
|
|
191
|
+
const BRIDGE_PATH_RE = /\/m\/([^/?#]+)(?:\/r\/([^/?#]+))?\/?$/;
|
|
192
|
+
function bridgeSpawnKey(modelEnv) {
|
|
193
|
+
const url = modelEnv && typeof modelEnv.ANTHROPIC_BASE_URL === 'string' ? modelEnv.ANTHROPIC_BASE_URL : '';
|
|
194
|
+
const m = /^https?:\/\/127\.0\.0\.1:\d+\//.test(url) ? BRIDGE_PATH_RE.exec(url) : null;
|
|
195
|
+
if (!m) return null;
|
|
196
|
+
try {
|
|
197
|
+
return { catalogId: decodeURIComponent(m[1]).toLowerCase(), tag: m[2] ? decodeURIComponent(m[2]) : '' };
|
|
198
|
+
} catch { return null; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The CLI reports an upstream API failure as an assistant text block ("API Error:
|
|
202
|
+
// Request rejected (429) · …"), usually repeated in the is_error result. The
|
|
203
|
+
// assistant copy is kept as the fallback detail for an exit without a result.
|
|
204
|
+
const API_ERROR_TEXT_RE = /^API Error\b/;
|
|
205
|
+
|
|
166
206
|
/**
|
|
167
207
|
* Translate a pipeline "effort" level into claude CLI argv additions. This is
|
|
168
208
|
* the ONE place that knows the CLI surface for effort.
|
|
@@ -400,6 +440,13 @@ export async function runClaude(o = {}) {
|
|
|
400
440
|
// A pipeline agent (phases.mjs): runs as WORCA_AGENT_USER when the container set one
|
|
401
441
|
// up (agent-user.mjs). Server-side helpers and Ask Worca leave it unset.
|
|
402
442
|
asAgent,
|
|
443
|
+
// Credential broker (broker-client.mjs): who pays for this spawn, what kind it is (sets
|
|
444
|
+
// its token's lifetime), and the run/thread it belongs to. All optional: the person
|
|
445
|
+
// otherwise comes from the async context (billing.mjs). Ignored with the broker off.
|
|
446
|
+
billTo,
|
|
447
|
+
spawnKind,
|
|
448
|
+
runId,
|
|
449
|
+
threadId,
|
|
403
450
|
bin = DEFAULT_BIN,
|
|
404
451
|
} = o;
|
|
405
452
|
|
|
@@ -417,7 +464,7 @@ export async function runClaude(o = {}) {
|
|
|
417
464
|
return runMock({ cwd, systemPrompt, prompt, onEvent, signal, resumeSessionId, workspaceWriteTargets, permissionMode });
|
|
418
465
|
}
|
|
419
466
|
|
|
420
|
-
return runReal({
|
|
467
|
+
return (brokerEnabled() ? runViaBroker : runReal)({
|
|
421
468
|
cwd,
|
|
422
469
|
systemPrompt,
|
|
423
470
|
prompt,
|
|
@@ -447,9 +494,120 @@ export async function runClaude(o = {}) {
|
|
|
447
494
|
addDirs,
|
|
448
495
|
argvInlineLimit,
|
|
449
496
|
asAgent,
|
|
497
|
+
billTo,
|
|
498
|
+
spawnKind,
|
|
499
|
+
runId,
|
|
500
|
+
threadId,
|
|
450
501
|
});
|
|
451
502
|
}
|
|
452
503
|
|
|
504
|
+
// ── Credential broker ────────────────────────────────────────────────────────
|
|
505
|
+
// With WORCA_BROKER_URL set (plans/credential-broker-design.html §5.2), worca holds
|
|
506
|
+
// no model credential. Each spawn gets its own short-lived token from the broker and
|
|
507
|
+
// talks to `<broker>/p/<slot>`; the broker adds the paying person's key on the way
|
|
508
|
+
// out. The token is revoked when the process exits, and never survives in anything
|
|
509
|
+
// worca stores: events and error text pass through redactSecrets.
|
|
510
|
+
|
|
511
|
+
/** Error in the recovery classes the orchestrator already knows (auth pauses, never retries blindly). */
|
|
512
|
+
function brokerSpawnError(message, errorClass = 'auth') {
|
|
513
|
+
const err = new Error(`worca-broker: ${message}`);
|
|
514
|
+
err.errorClass = errorClass;
|
|
515
|
+
return err;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isLoopbackUrl(v) {
|
|
519
|
+
try {
|
|
520
|
+
const h = new URL(v).hostname.replace(/^\[|\]$/g, '');
|
|
521
|
+
return h === '127.0.0.1' || h === 'localhost' || h === '::1';
|
|
522
|
+
} catch { return false; }
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Which broker slot a spawn's model env routes to: {slot}, {bridge:true}, or {error}. */
|
|
526
|
+
export function brokerRouteFor(modelEnv, env = process.env) {
|
|
527
|
+
const base = modelEnv && typeof modelEnv.ANTHROPIC_BASE_URL === 'string' ? modelEnv.ANTHROPIC_BASE_URL.trim() : '';
|
|
528
|
+
if (!base) return { slot: 'anthropic' };
|
|
529
|
+
const slot = slotOfBaseUrl(base, env);
|
|
530
|
+
if (slot) return { slot };
|
|
531
|
+
// worca's own in-process bridge (bridge/server.mjs): it reaches a keyless local
|
|
532
|
+
// endpoint itself; the broker guard refuses any bridged entry that holds a key.
|
|
533
|
+
if (isLoopbackUrl(base)) return { bridge: true };
|
|
534
|
+
let host = base;
|
|
535
|
+
try { host = new URL(base).host; } catch { /* keep the raw value */ }
|
|
536
|
+
return { error: `this model routes to ${host} directly; with the credential broker on, a model must use a broker slot (set its credential in Settings › Models)` };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const SPAWN_TTL_SEC = { aux: 600, test: 600, ask: 7200, phase: 86400 };
|
|
540
|
+
|
|
541
|
+
async function runViaBroker(opts) {
|
|
542
|
+
const route = brokerRouteFor(opts.modelEnv);
|
|
543
|
+
if (route.error) throw brokerSpawnError(route.error);
|
|
544
|
+
const onEvent = opts.onEvent;
|
|
545
|
+
const redactingOnEvent = (e) => onEvent(redactDeep(e));
|
|
546
|
+
|
|
547
|
+
let info;
|
|
548
|
+
try { info = await brokerInfo(); }
|
|
549
|
+
catch (err) { throw brokerSpawnError(err.message, 'network'); }
|
|
550
|
+
|
|
551
|
+
// A bridged model (OpenAI, OpenRouter, Copilot, a gateway): the CLI still talks to worca's
|
|
552
|
+
// loopback bridge, which translates, but it presents THIS spawn's broker token and the
|
|
553
|
+
// bridge forwards it to the model's slot. A keyless local endpoint needs no token.
|
|
554
|
+
let bridgeSlot = null;
|
|
555
|
+
if (route.bridge) {
|
|
556
|
+
const ms = modelSlot(opts.model);
|
|
557
|
+
if (ms && ms.error) throw brokerSpawnError(ms.error);
|
|
558
|
+
if (!ms || ms.keyless) {
|
|
559
|
+
try {
|
|
560
|
+
const r = await runReal({ ...opts, onEvent: redactingOnEvent });
|
|
561
|
+
return { ...r, text: redactSecrets(r.text) };
|
|
562
|
+
} catch (err) { if (err && typeof err.message === 'string') err.message = redactSecrets(err.message); throw err; }
|
|
563
|
+
}
|
|
564
|
+
bridgeSlot = ms.slot;
|
|
565
|
+
}
|
|
566
|
+
let billTo = resolveBillTo(opts.billTo);
|
|
567
|
+
if (info.mode === 'multi' && (!billTo || billTo === 'local')) {
|
|
568
|
+
billTo = normalizeBillTo(process.env.WORCA_BROKER_SYSTEM_BILL_TO);
|
|
569
|
+
if (!billTo) throw brokerSpawnError('this action has no signed-in person to bill it to. Start it from the web UI, or set WORCA_BROKER_SYSTEM_BILL_TO for work nobody in particular starts');
|
|
570
|
+
}
|
|
571
|
+
const kind = ['aux', 'test', 'ask', 'phase'].includes(opts.spawnKind) ? opts.spawnKind
|
|
572
|
+
: (opts.permissionMode === 'dontAsk' ? 'ask' : 'phase');
|
|
573
|
+
// Whether this spawn runs where no other person's agent can read it: an agent spawn under
|
|
574
|
+
// the paying person's own pool user (not a resumed run's starter's), or a server-side spawn
|
|
575
|
+
// when agents run under their own users (they can't read the server's processes). The
|
|
576
|
+
// broker uses a personal Claude subscription only for such spawns.
|
|
577
|
+
const owner = normalizeBillTo(currentOwner()) || billTo;
|
|
578
|
+
const isolated = opts.asAgent
|
|
579
|
+
? owner === billTo && !!agentIdentityFor(owner)?.dedicated
|
|
580
|
+
: !!agentIdentity();
|
|
581
|
+
let minted;
|
|
582
|
+
try {
|
|
583
|
+
minted = await mintSpawnToken({
|
|
584
|
+
billTo: billTo || 'local', slots: [bridgeSlot || route.slot], kind, ttlSec: SPAWN_TTL_SEC[kind],
|
|
585
|
+
runId: opts.runId || null, threadId: opts.threadId || null, isolated,
|
|
586
|
+
});
|
|
587
|
+
} catch (err) {
|
|
588
|
+
throw brokerSpawnError(`cannot get a token for this spawn: ${err.message}`, err.status === 401 ? 'auth' : 'network');
|
|
589
|
+
}
|
|
590
|
+
const modelEnv = bridgeSlot
|
|
591
|
+
// Bridged: keep the bridge URL (and the rest of the resolved env); swap the bridge's own
|
|
592
|
+
// secret for the spawn token.
|
|
593
|
+
? { ...opts.modelEnv, ANTHROPIC_AUTH_TOKEN: minted.token }
|
|
594
|
+
: withProviderModesOff({
|
|
595
|
+
ENABLE_TOOL_SEARCH: 'true',
|
|
596
|
+
...(opts.modelEnv || {}),
|
|
597
|
+
ANTHROPIC_BASE_URL: slotBaseUrl(route.slot),
|
|
598
|
+
ANTHROPIC_AUTH_TOKEN: minted.token,
|
|
599
|
+
});
|
|
600
|
+
try {
|
|
601
|
+
const r = await runReal({ ...opts, modelEnv, onEvent: redactingOnEvent });
|
|
602
|
+
return { ...r, text: redactSecrets(r.text) };
|
|
603
|
+
} catch (err) {
|
|
604
|
+
if (err && typeof err.message === 'string') err.message = redactSecrets(err.message);
|
|
605
|
+
throw err;
|
|
606
|
+
} finally {
|
|
607
|
+
revokeSpawnToken(minted.spawnId);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
453
611
|
// ── Real execution ───────────────────────────────────────────────────────────
|
|
454
612
|
|
|
455
613
|
/** Pure argv builder for the headless claude spawn (exported for tests).
|
|
@@ -601,7 +759,7 @@ export function stageClaudeInvocation(opts, { bin = DEFAULT_BIN, limit = ARGV_IN
|
|
|
601
759
|
return { ...plan, dir };
|
|
602
760
|
}
|
|
603
761
|
|
|
604
|
-
function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, disallowedTools, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, addDirs, argvInlineLimit, asAgent }) {
|
|
762
|
+
function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, model, effort, onEvent, signal, bin, resumeSessionId, mcpConfigPath, mcpServerGrants, permissionRules, envScrub, envAllowlist, modelEnv, disallowedTools, tools, strictMcpConfig, settingSources, disableSlashCommands, includePartialMessages, maxTurns, maxBudgetUsd, appendSubagentSystemPrompt, addDirs, argvInlineLimit, asAgent }) { // billTo/spawnKind/runId/threadId are consumed by runViaBroker
|
|
605
763
|
return new Promise((resolveP, rejectP) => {
|
|
606
764
|
// Per-model routing env (design §4.4), prepared BEFORE argv: reserved keys
|
|
607
765
|
// are re-dropped here defensively — the write path already rejects them, so
|
|
@@ -713,6 +871,14 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
713
871
|
// No GitHub credential reaches claude, in any guardrail tier, from a per-project allowlist or a
|
|
714
872
|
// model env alike (src/core/github-credentials.mjs): pushes and PRs are worca's own calls.
|
|
715
873
|
spawnEnv = stripGithubCredentials(spawnEnv ?? process.env);
|
|
874
|
+
// The broker's own secret never reaches an agent. With the broker on, neither does any
|
|
875
|
+
// ambient model credential (the boot guard refuses them; this is the second line): the
|
|
876
|
+
// spawn's broker token is the only one it holds, and it wins over nothing.
|
|
877
|
+
delete spawnEnv.WORCA_BROKER_SECRET;
|
|
878
|
+
delete spawnEnv.WORCA_BROKER_SECRET_FILE;
|
|
879
|
+
if (brokerEnabled()) {
|
|
880
|
+
for (const k of MODEL_CREDENTIAL_ENV_KEYS) if (k !== 'ANTHROPIC_AUTH_TOKEN' || !safeModelEnv?.ANTHROPIC_AUTH_TOKEN) delete spawnEnv[k];
|
|
881
|
+
}
|
|
716
882
|
|
|
717
883
|
// Opt-in spawn diagnostics (WORCA_DEBUG_SPAWN, default off — byte-identical spawn
|
|
718
884
|
// path when unset). Everything here is derived from values already computed above
|
|
@@ -735,7 +901,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
735
901
|
|
|
736
902
|
// Agent isolation (agent-user.mjs): the same command under the agent's uid, via sudo, in its
|
|
737
903
|
// own process group so a stuck agent can still be SIGKILLed through sudo.
|
|
738
|
-
const agentId = asAgent ?
|
|
904
|
+
const agentId = asAgent ? agentIdentityFor(currentOwner()) : null; // the owner's pool user (agent-pool.mjs)
|
|
739
905
|
let child;
|
|
740
906
|
try {
|
|
741
907
|
let file = resolved.bin;
|
|
@@ -771,6 +937,19 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
771
937
|
// STDOUT and exits non-zero with EMPTY stderr. Capture that text so a
|
|
772
938
|
// non-zero exit surfaces the real cause instead of an opaque "no stderr".
|
|
773
939
|
let errorDetail = '';
|
|
940
|
+
let apiErrorText = ''; // the last "API Error: …" assistant text (API_ERROR_TEXT_RE)
|
|
941
|
+
// A bridged spawn: the in-process bridge records the upstream's own reason
|
|
942
|
+
// (bridge/telemetry.mjs 'failure'), matched on this spawn's catalog id + tag
|
|
943
|
+
// as model-test.mjs does. The last fallback before the CLI's bare notice, so
|
|
944
|
+
// a CLI that exits without an API Error line still names the real cause.
|
|
945
|
+
let bridgeFailureText = '';
|
|
946
|
+
const bridgeKey = bridgeSpawnKey(modelEnv);
|
|
947
|
+
const onBridgeFailure = (e) => {
|
|
948
|
+
if (e && e.message && String(e.catalogId || '').toLowerCase() === bridgeKey.catalogId && (e.tag || '') === bridgeKey.tag) {
|
|
949
|
+
bridgeFailureText = String(e.message);
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
if (bridgeKey) bridgeEvents.on('failure', onBridgeFailure);
|
|
774
953
|
let settled = false;
|
|
775
954
|
|
|
776
955
|
const onAbort = () => {
|
|
@@ -800,6 +979,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
800
979
|
if (settled) return;
|
|
801
980
|
settled = true;
|
|
802
981
|
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
982
|
+
if (bridgeKey) bridgeEvents.off('failure', onBridgeFailure);
|
|
803
983
|
cleanupStaged();
|
|
804
984
|
fn(arg);
|
|
805
985
|
};
|
|
@@ -822,7 +1002,10 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
822
1002
|
if (evt?.type === 'system' && evt?.subtype === 'init' && typeof evt.session_id === 'string') {
|
|
823
1003
|
safeEmit(onEvent, { type: 'session', sessionId: evt.session_id });
|
|
824
1004
|
}
|
|
825
|
-
if (evt?.type === 'assistant' && text)
|
|
1005
|
+
if (evt?.type === 'assistant' && text) {
|
|
1006
|
+
assistantText += text;
|
|
1007
|
+
if (API_ERROR_TEXT_RE.test(text.trim())) apiErrorText = text.trim();
|
|
1008
|
+
}
|
|
826
1009
|
if (evt?.type === 'result' && typeof evt.result === 'string') resultText += evt.result;
|
|
827
1010
|
// Remember the most specific error text we see, for the non-zero-exit path.
|
|
828
1011
|
if (evt?.type === 'result' && evt.is_error) {
|
|
@@ -867,7 +1050,7 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
867
1050
|
// Classify BEFORE buffering: the class must see every line ever printed —
|
|
868
1051
|
// an early 401 or session-limit notice followed by hundreds of KB of MCP
|
|
869
1052
|
// chatter would otherwise scroll past both the trim and the tail cap.
|
|
870
|
-
stderrClass = strongestClass(stderrClass, classifyError(line));
|
|
1053
|
+
if (!isBenignStderrLine(line)) stderrClass = strongestClass(stderrClass, classifyError(line));
|
|
871
1054
|
stderrBuf += line + '\n'; // still the source of the exit-code detail
|
|
872
1055
|
// Rolling tail: bound memory against chatty MCP servers. Trim at 4x the
|
|
873
1056
|
// cap down to 2x — amortized, and the kept tail always exceeds
|
|
@@ -893,8 +1076,12 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
893
1076
|
return;
|
|
894
1077
|
}
|
|
895
1078
|
if (code !== 0) {
|
|
896
|
-
|
|
897
|
-
|
|
1079
|
+
// Benign notices are not evidence (BENIGN_STDERR_PATTERNS): stderr feeds
|
|
1080
|
+
// the detail only when something else is left, else the stream's own
|
|
1081
|
+
// error wins. A notice alone still beats the opaque "no stderr".
|
|
1082
|
+
const fromStderr = stderrBuf.split('\n').filter((l) => !isBenignStderrLine(l)).join('\n').trim();
|
|
1083
|
+
const streamDetail = errorDetail || apiErrorText || bridgeFailureText;
|
|
1084
|
+
const raw = fromStderr || streamDetail || stderrBuf.trim() || 'no stderr';
|
|
898
1085
|
// Tail, not head: the terminal cause sits at the END of a long stderr.
|
|
899
1086
|
const detail = raw.length > STDERR_DETAIL_MAX ? `… ${raw.slice(-STDERR_DETAIL_MAX)}` : raw;
|
|
900
1087
|
const err = new Error(`${bin} exited with code ${code}: ${detail}`);
|
|
@@ -903,7 +1090,11 @@ function runReal({ cwd, systemPrompt, prompt, allowedTools, permissionMode, mode
|
|
|
903
1090
|
// stdout errorDetail. classifyError() returns this stamp verbatim, so
|
|
904
1091
|
// the tail cap above can never starve recovery — or flip an early auth
|
|
905
1092
|
// failure into 'network' because connection chatter filled the tail.
|
|
906
|
-
|
|
1093
|
+
// A stream-borne API error (a 429 in the result) counts even when real
|
|
1094
|
+
// stderr chatter fed the message.
|
|
1095
|
+
err.errorClass = fromStderr
|
|
1096
|
+
? strongestClass(stderrClass, streamDetail ? classifyError(streamDetail) : null)
|
|
1097
|
+
: classifyError(raw);
|
|
907
1098
|
// Mark the origin channel so the orchestrator can tag its `error` log
|
|
908
1099
|
// line with stream:'err' without sniffing the message. Absent when the
|
|
909
1100
|
// detail came from the stdout `result` envelope (the common case — see
|
package/src/core/config.mjs
CHANGED
|
@@ -25,6 +25,7 @@ const readSettingsHideStored = () => { const s = readSettings(); return typeof s
|
|
|
25
25
|
import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
|
|
26
26
|
// Team policy defaults (team-policy design §6, §8): read from the discovery CACHE only (a leaf module).
|
|
27
27
|
import { policyCatalogModels, teamDefault } from './policy/cache.mjs';
|
|
28
|
+
import { PREDEFINED_LIST_PRICES } from './list-prices.mjs';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Recompute the agent step list FRESH from the layered registry (repo agents/ +
|
|
@@ -281,6 +282,10 @@ function composeCatalog(projectCustom = [], { projectDir = null } = {}) {
|
|
|
281
282
|
export function modelHasBaseUrlRouting(modelId) {
|
|
282
283
|
const id = typeof modelId === 'string' ? modelId.trim() : '';
|
|
283
284
|
if (!id) return false;
|
|
285
|
+
// With the credential broker on, EVERY model is routed (to <broker>/p/<slot>), and the
|
|
286
|
+
// broker, not a CLI sign-in, authenticates it (claude-auth.mjs must never call a broker
|
|
287
|
+
// failure "signed out").
|
|
288
|
+
if (typeof process.env.WORCA_BROKER_URL === 'string' && process.env.WORCA_BROKER_URL.trim()) return true;
|
|
284
289
|
const lc = id.toLowerCase();
|
|
285
290
|
const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
286
291
|
if (entry) return !!entry.upstream || !!(entry.env && 'ANTHROPIC_BASE_URL' in entry.env);
|
|
@@ -496,18 +501,10 @@ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined
|
|
|
496
501
|
// is not modelled). cacheWrite = 1.25× input (5-minute TTL), cacheWrite1h = 2×
|
|
497
502
|
// input, cacheRead = 0.1× input except Fable 5.1 (0.025×) and Opus 5.5 (0.05×). Refresh by hand when
|
|
498
503
|
// Anthropic moves a price. PREDEFINED_MODELS itself stays untouched — its entry
|
|
499
|
-
// shape is pinned (test/config-models-global.test.mjs:205).
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
504
|
-
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
505
|
-
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
506
|
-
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
507
|
-
'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
|
|
508
|
-
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
|
|
509
|
-
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
|
|
510
|
-
});
|
|
504
|
+
// shape is pinned (test/config-models-global.test.mjs:205). The table itself lives
|
|
505
|
+
// in the zero-import leaf list-prices.mjs (the credential broker prices budgets from
|
|
506
|
+
// it too) and is re-exported here for existing importers.
|
|
507
|
+
export { PREDEFINED_LIST_PRICES };
|
|
511
508
|
|
|
512
509
|
const FREE_RATES = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 });
|
|
513
510
|
|
|
@@ -103,13 +103,15 @@ export const FAILURE_POLICY = Object.freeze({
|
|
|
103
103
|
}),
|
|
104
104
|
// run()'s setup — checkout, graph build, skills gate — failed with the pipeline
|
|
105
105
|
// row already created. A pause here stamps `setupIncomplete`; resume replays it.
|
|
106
|
-
|
|
106
|
+
// A usage limit (OpenRouter's daily free requests spent by the Auto classifier, say)
|
|
107
|
+
// is not a setup bug: it pauses as a usage limit, resumable after the reset.
|
|
108
|
+
setup: Object.freeze({ usage_limit: both(pause(REASON.USAGE_LIMIT)), '*': both(pause(REASON.ERROR)) }),
|
|
107
109
|
// Before the pipeline row exists (topology, preflight, tool detection) there is
|
|
108
110
|
// nothing to resume into: a launch error is the only enactable verdict.
|
|
109
111
|
launch: Object.freeze({ '*': both(error()) }),
|
|
110
112
|
// Anything that escaped the engine after setup (a scheduler throw, a persist
|
|
111
113
|
// failure, a bookkeeping bug).
|
|
112
|
-
shell: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
|
|
114
|
+
shell: Object.freeze({ usage_limit: both(pause(REASON.USAGE_LIMIT)), '*': both(pause(REASON.ERROR)) }),
|
|
113
115
|
// resume() could not REHYDRATE the paused run — the checkout is gone, run.json
|
|
114
116
|
// is corrupt, a guardrail set or agent prompt no longer loads. The point on disk
|
|
115
117
|
// is already the best the run can offer: parking it again would re-persist the
|
package/src/core/git-info.mjs
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// never shell out to real git/gh/GitHub. Nothing here ever throws.
|
|
7
7
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
-
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
9
|
+
import { mkdtemp, writeFile, rm, readFile } from 'node:fs/promises';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { join, isAbsolute } from 'node:path';
|
|
12
12
|
import { githubEnv, readGithubCredentials } from './github-credentials.mjs';
|
|
13
13
|
|
|
14
14
|
/** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
|
|
@@ -175,13 +175,57 @@ const ownerRepoOfPrUrl = (url) => { const m = /^https:\/\/github\.com\/([^/]+\/[
|
|
|
175
175
|
/** "owner/name" from gh's `[HOST/]OWNER/REPO`, or null. */
|
|
176
176
|
const ownerRepo = (repo) => (repo ? String(repo).split('/').slice(-2).join('/') : null);
|
|
177
177
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The remote could not read the pack we sent ("remote: error: inflate: data stream error",
|
|
180
|
+
* "pack has bad object at offset N", "unpack failed: index-pack failed"). git streams stored
|
|
181
|
+
* objects to a remote without re-checking them, so this surfaces only there. Seen once on a
|
|
182
|
+
* hosted worca (a blob:none partial clone, 2026-09-27) while other git writers were busy in
|
|
183
|
+
* the same object store — a background `gc --auto` repacking ~12,500 loose objects and the
|
|
184
|
+
* team-metrics flush; the same push rebuilt afterwards was clean. Pure.
|
|
185
|
+
*/
|
|
186
|
+
export function isRemotePackFailure(stderr) {
|
|
187
|
+
return /unpack failed|index-pack (?:failed|abnormal exit)|pack has bad object|inflate: data stream error|bad pack header|unpacker error|did not receive expected object/i.test(String(stderr || ''));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A `git gc` running in this repository right now (its gc.pid names a live process here). */
|
|
191
|
+
async function gcRunning(projectDir) {
|
|
192
|
+
const r = await _run('git', ['rev-parse', '--git-common-dir'], { cwd: projectDir });
|
|
193
|
+
if (!r.ok) return false;
|
|
194
|
+
const common = r.stdout.trim();
|
|
195
|
+
let text;
|
|
196
|
+
try { text = await readFile(join(isAbsolute(common) ? common : join(projectDir, common), 'gc.pid'), 'utf8'); } catch { return false; }
|
|
197
|
+
const pid = Number.parseInt(String(text).trim().split(/\s+/)[0], 10);
|
|
198
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
199
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Wait (up to `maxMs`) for a running `git gc` in the repository to finish. */
|
|
203
|
+
async function waitForGc(projectDir, { maxMs = 120_000, stepMs = 2_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
204
|
+
const until = Date.now() + maxMs;
|
|
205
|
+
while (Date.now() < until && await gcRunning(projectDir)) await sleep(stepMs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Push the branch to `remote` (default origin) and set upstream. Idempotent; surfaces stderr.
|
|
210
|
+
* A pack the remote could not read (isRemotePackFailure) is pushed once more, after any running
|
|
211
|
+
* `git gc` in the repository finished, as a self-contained pack (--no-thin: no delta against
|
|
212
|
+
* objects the remote has, which a partial clone may not hold).
|
|
213
|
+
*/
|
|
214
|
+
export async function pushBranch(projectDir, branch, remote = 'origin', { gcWait = {} } = {}) {
|
|
180
215
|
const r0 = remote || 'origin';
|
|
181
216
|
const cred = await githubEnv('write', { repo: await githubRepoOf(projectDir, r0) });
|
|
182
217
|
if (cred.error) return { ok: false, stderr: cred.error };
|
|
183
218
|
const r = await _run('git', ['push', '-u', r0, branch], { cwd: projectDir, env: cred.env });
|
|
184
|
-
return { ok: r.ok, stderr: (r.stderr || '').trim() };
|
|
219
|
+
if (r.ok || !isRemotePackFailure(r.stderr)) return { ok: r.ok, stderr: (r.stderr || '').trim() };
|
|
220
|
+
await waitForGc(projectDir, gcWait);
|
|
221
|
+
const again = await _run('git', ['push', '--no-thin', '-u', r0, branch], { cwd: projectDir, env: cred.env });
|
|
222
|
+
if (again.ok) return { ok: true, stderr: (again.stderr || '').trim(), retried: true };
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
retried: true,
|
|
226
|
+
stderr: `${(again.stderr || '').trim()}\n(the remote could not read the pack git sent, twice — a second push waited for ` +
|
|
227
|
+
'any running git gc and sent a self-contained pack. Check the local repository with `git fsck --full`.)',
|
|
228
|
+
};
|
|
185
229
|
}
|
|
186
230
|
|
|
187
231
|
/**
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// variable only worca sets. No global `gh auth setup-git` is needed.
|
|
14
14
|
// credentialEnv is pure (token modes); githubEnv also mints in App mode.
|
|
15
15
|
import { appConfigured, loadAppConfig, mintInstallationToken } from './github-app.mjs';
|
|
16
|
+
import { currentBillTo } from './billing.mjs';
|
|
17
|
+
import { personGithubToken } from './broker-client.mjs';
|
|
16
18
|
|
|
17
19
|
/** Every variable that carries a GitHub credential. Never reaches an agent. */
|
|
18
20
|
export const GITHUB_CREDENTIAL_KEYS = Object.freeze([
|
|
@@ -67,7 +69,15 @@ export function credentialEnv(role, base = process.env) {
|
|
|
67
69
|
* for this one call (`repo` "owner/name" helps find the installation). Never throws:
|
|
68
70
|
* `{ env, error }`, where a failed mint leaves a credential-free env and says why.
|
|
69
71
|
*/
|
|
70
|
-
export async function githubEnv(role, { base = process.env, repo = null, fetchImpl = fetch, now = Date.now() } = {}) {
|
|
72
|
+
export async function githubEnv(role, { base = process.env, repo = null, fetchImpl = fetch, now = Date.now(), asPerson = true, person = undefined } = {}) {
|
|
73
|
+
// "Push as me" (optional, docs/credential-broker.md): a write (push, pull request) goes out
|
|
74
|
+
// with the acting person's own GitHub token, from the credential broker, for this call only.
|
|
75
|
+
// WORCA_GH_AS_PERSON=prefer their token when they connected GitHub, else the App/token below
|
|
76
|
+
// WORCA_GH_AS_PERSON=required their token or nothing: the push fails and says how to connect
|
|
77
|
+
if (role === 'write' && asPerson) {
|
|
78
|
+
const p = await personWriteEnv(base, person === undefined ? currentBillTo() : person);
|
|
79
|
+
if (p) return p;
|
|
80
|
+
}
|
|
71
81
|
if (readGithubCredentials(base).mode !== 'app') return { env: credentialEnv(role, base), error: null };
|
|
72
82
|
try {
|
|
73
83
|
const { token } = await mintInstallationToken(loadAppConfig(base), { role, repo, fetchImpl, now });
|
|
@@ -77,6 +87,39 @@ export async function githubEnv(role, { base = process.env, repo = null, fetchIm
|
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
/** WORCA_GH_AS_PERSON: 'prefer' | 'required' | null (off). Needs the credential broker. */
|
|
91
|
+
export function asPersonMode(env = process.env) {
|
|
92
|
+
const v = String(env.WORCA_GH_AS_PERSON || '').trim().toLowerCase();
|
|
93
|
+
if (!(typeof env.WORCA_BROKER_URL === 'string' && env.WORCA_BROKER_URL.trim())) return null;
|
|
94
|
+
return v === 'prefer' || v === 'required' ? v : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The write env with `person`'s own token, `{env, error}` when "push as me" decides the
|
|
99
|
+
* call (their token, or — in required mode — a refusal), or null to fall through to
|
|
100
|
+
* worca's own credential (off, or prefer mode without a connected GitHub).
|
|
101
|
+
*/
|
|
102
|
+
async function personWriteEnv(base, person) {
|
|
103
|
+
const mode = asPersonMode(base);
|
|
104
|
+
if (!mode) return null;
|
|
105
|
+
const who = typeof person === 'string' && person.includes('@') ? person : null;
|
|
106
|
+
if (!who) {
|
|
107
|
+
return mode === 'required'
|
|
108
|
+
? { env: stripGithubCredentials(base), error: 'pushes go out as the person who acted (WORCA_GH_AS_PERSON=required), and nobody signed in is behind this one' }
|
|
109
|
+
: null;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const { token } = await personGithubToken(who);
|
|
113
|
+
return { env: withToken(base, token), error: null, as: who };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (mode === 'required') {
|
|
116
|
+
return { env: stripGithubCredentials(base), error: `pushing as ${who}: ${err.message.replace(/^credential broker: /, '')}` };
|
|
117
|
+
}
|
|
118
|
+
if (err.code !== 'not_connected') console.warn(`[worca] push as ${who}: ${err.message}; using worca's own GitHub credential`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
function withToken(base, token) {
|
|
81
124
|
const env = stripGithubCredentials(base);
|
|
82
125
|
if (!token) return env;
|
|
@@ -21,6 +21,8 @@ import { DEFAULT_EXIT_CODES, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS,
|
|
|
21
21
|
import { probePython } from './python-probe.mjs';
|
|
22
22
|
import { stripGithubCredentials } from '../github-credentials.mjs';
|
|
23
23
|
import { agentIdentity, agentSpawn, killAgentGroupSync } from '../agent-user.mjs';
|
|
24
|
+
import { agentIdentityFor } from '../agent-pool.mjs';
|
|
25
|
+
import { currentOwner } from '../billing.mjs';
|
|
24
26
|
|
|
25
27
|
const CHILD_PATH = fileURLToPath(new URL('./script-child.mjs', import.meta.url));
|
|
26
28
|
/** The `python` harness (workbench spec §7), spawned as `<python> -u worca_script.py <program.py>`. */
|
|
@@ -283,7 +285,7 @@ export function spawnScript({ file, args, cwd, env, stdin = null, timeoutMs, sig
|
|
|
283
285
|
return new Promise((resolve, reject) => {
|
|
284
286
|
const started = Date.now();
|
|
285
287
|
// Scripts run the agents' code (tests, builds): under the agent's uid when the container has one.
|
|
286
|
-
const agent = platform !== 'win32' ?
|
|
288
|
+
const agent = platform !== 'win32' ? agentIdentityFor(currentOwner()) : null; // the owner's pool user (agent-pool.mjs)
|
|
287
289
|
let spawnFile = file;
|
|
288
290
|
let spawnArgs = args;
|
|
289
291
|
let spawnEnv = env;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/core/list-prices.mjs
|
|
2
|
+
// Anthropic list prices for the built-in model ids, in USD per MILLION tokens.
|
|
3
|
+
// A zero-import leaf so both config.mjs (the chat footer's live estimate) and the
|
|
4
|
+
// credential broker (src/broker/usage.mjs: per-person budgets) price from one table.
|
|
5
|
+
//
|
|
6
|
+
// From Anthropic's published pricing (platform.claude.com/docs/en/pricing — snapshot
|
|
7
|
+
// 2026-06-24; Opus 5.5 added 2026-09-22). `[1m]` twins and dated ids resolve to their
|
|
8
|
+
// base row (the long-context premium is not modelled). cacheWrite = 1.25× input
|
|
9
|
+
// (5-minute TTL), cacheWrite1h = 2× input, cacheRead = 0.1× input except Fable 5.1
|
|
10
|
+
// (0.025×) and Opus 5.5 (0.05×). Refresh by hand when Anthropic moves a price.
|
|
11
|
+
export const PREDEFINED_LIST_PRICES = Object.freeze({
|
|
12
|
+
'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
|
|
13
|
+
'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5, cacheWrite1h: 8 },
|
|
14
|
+
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
15
|
+
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
16
|
+
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
17
|
+
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
|
|
18
|
+
'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
|
|
19
|
+
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
|
|
20
|
+
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** The list-price row for a model id (`[1m]` twins and dated ids map to their base), or null. */
|
|
24
|
+
export function listPriceFor(modelId) {
|
|
25
|
+
const id = typeof modelId === 'string' ? modelId.trim().toLowerCase() : '';
|
|
26
|
+
if (!id) return null;
|
|
27
|
+
const base = id.replace(/\[1m\]$/, '').replace(/-\d{8}$/, '');
|
|
28
|
+
return PREDEFINED_LIST_PRICES[base] ?? null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/core/mcp-secrets.mjs
|
|
2
|
+
// Secrets in the MCP server definitions a run hands its agents (credential broker,
|
|
3
|
+
// docs/credential-broker.md). A run's --mcp-config merges each project's MCP servers and
|
|
4
|
+
// the local-scope ones from worca's own ~/.claude.json; whatever an entry carries in its
|
|
5
|
+
// `env`, `headers` or URL reaches the agent: the server process runs as the agent user
|
|
6
|
+
// with that environment, and the config file is readable to it. That is the one class
|
|
7
|
+
// of credential agents could still read once model keys live in the broker.
|
|
8
|
+
//
|
|
9
|
+
// A literal secret-looking value is a finding; a whole `${VAR}` reference is not: the CLI
|
|
10
|
+
// expands it from the agent's own environment, so the secret is only there if the operator
|
|
11
|
+
// put it in worca's environment on purpose (where agents can read it just the same).
|
|
12
|
+
// WORCA_MCP_SECRETS = block | warn | off default: block with the broker on, else warn.
|
|
13
|
+
// block drops the server from the run's config and says so; warn only says so. Values are
|
|
14
|
+
// never printed. Pure.
|
|
15
|
+
|
|
16
|
+
const SECRET_NAME_RE = /(^|[_-])(TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|COOKIE|SESSION)([_-]|$)|authorization|api[-_]?key|x-api-key|bearer/i;
|
|
17
|
+
const QUERY_SECRET_RE = /^(api[-_]?key|key|token|access[-_]?token|secret|password|auth|sig|signature)$/i;
|
|
18
|
+
const REF_RE = /^\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}$/;
|
|
19
|
+
const TOKEN_SHAPE_RE = /\b(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.)/;
|
|
20
|
+
|
|
21
|
+
export const MCP_SECRET_MODES = Object.freeze(['block', 'warn', 'off']);
|
|
22
|
+
|
|
23
|
+
/** The effective mode: WORCA_MCP_SECRETS, else block with the broker on, else warn. */
|
|
24
|
+
export function mcpSecretsMode(env = process.env) {
|
|
25
|
+
const v = String(env.WORCA_MCP_SECRETS || '').trim().toLowerCase();
|
|
26
|
+
if (MCP_SECRET_MODES.includes(v)) return v;
|
|
27
|
+
return typeof env.WORCA_BROKER_URL === 'string' && env.WORCA_BROKER_URL.trim() ? 'block' : 'warn';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const literal = (v) => typeof v === 'string' && v.trim() !== '' && !REF_RE.test(v.trim());
|
|
31
|
+
/** Bearer ${VAR} and the like: a reference with a fixed scheme word is still a reference. */
|
|
32
|
+
const refOnly = (v) => /^(Bearer|Basic|token)\s+\$\{[A-Za-z_][A-Za-z0-9_]*\}$/i.test(String(v).trim());
|
|
33
|
+
|
|
34
|
+
/** Where one server definition carries a literal secret: ['env LINEAR_API_KEY', 'header Authorization', …]. */
|
|
35
|
+
export function mcpSecretFindings(def) {
|
|
36
|
+
const out = [];
|
|
37
|
+
if (!def || typeof def !== 'object') return out;
|
|
38
|
+
for (const [k, v] of Object.entries(def.env || {})) {
|
|
39
|
+
if (literal(v) && (SECRET_NAME_RE.test(k) || TOKEN_SHAPE_RE.test(v))) out.push(`env ${k}`);
|
|
40
|
+
}
|
|
41
|
+
for (const [k, v] of Object.entries(def.headers || {})) {
|
|
42
|
+
if (literal(v) && !refOnly(v) && (SECRET_NAME_RE.test(k) || TOKEN_SHAPE_RE.test(v))) out.push(`header ${k}`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof def.url === 'string') {
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(def.url);
|
|
47
|
+
if (u.password || u.username) out.push('credentials in the URL');
|
|
48
|
+
for (const [k, v] of u.searchParams) if (QUERY_SECRET_RE.test(k) && literal(v)) out.push(`URL parameter ${k}`);
|
|
49
|
+
} catch { /* not a URL: nothing to find */ }
|
|
50
|
+
}
|
|
51
|
+
for (const a of Array.isArray(def.args) ? def.args : []) {
|
|
52
|
+
if (typeof a === 'string' && TOKEN_SHAPE_RE.test(a)) { out.push('a token in args'); break; }
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Screen a run's MCP servers. `block` drops the ones carrying a literal secret.
|
|
59
|
+
* @returns {{servers: Record<string, object>, warnings: string[], dropped: string[]}}
|
|
60
|
+
*/
|
|
61
|
+
export function screenMcpSecrets(servers, { mode = 'warn' } = {}) {
|
|
62
|
+
if (mode === 'off') return { servers, warnings: [], dropped: [] };
|
|
63
|
+
const kept = {};
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const dropped = [];
|
|
66
|
+
for (const [name, def] of Object.entries(servers || {})) {
|
|
67
|
+
const f = mcpSecretFindings(def);
|
|
68
|
+
if (!f.length) { kept[name] = def; continue; }
|
|
69
|
+
const where = f.join(', ');
|
|
70
|
+
if (mode === 'block') {
|
|
71
|
+
dropped.push(name);
|
|
72
|
+
warnings.push(`MCP server \`${name}\` was left out of this run: it carries a secret (${where}) that its agents could read. ` +
|
|
73
|
+
'Remove the secret from its definition, or set WORCA_MCP_SECRETS=warn to accept that agents can read it.');
|
|
74
|
+
} else {
|
|
75
|
+
kept[name] = def;
|
|
76
|
+
warnings.push(`MCP server \`${name}\` carries a secret (${where}); the run's agents can read it.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { servers: kept, warnings, dropped };
|
|
80
|
+
}
|