@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.
Files changed (101) hide show
  1. package/README.md +7 -1
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +52 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +165 -9
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +244 -43
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +175 -6
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +33 -6
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +99 -50
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. 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 ? agentIdentity() : null;
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) assistantText += 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
- const fromStderr = stderrBuf.trim();
897
- const raw = fromStderr || errorDetail || 'no stderr';
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
- err.errorClass = fromStderr ? stderrClass : classifyError(raw);
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
@@ -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
- export const PREDEFINED_LIST_PRICES = Object.freeze({
501
- 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
502
- 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5, cacheWrite1h: 8 },
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
- setup: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
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
@@ -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
- /** Push the branch to `remote` (default origin) and set upstream. Idempotent; surfaces stderr. */
179
- export async function pushBranch(projectDir, branch, remote = 'origin') {
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' ? agentIdentity() : null;
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
+ }