@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
@@ -0,0 +1,129 @@
1
+ // src/broker/main.mjs
2
+ // `worca broker`: the credential broker (plans/credential-broker-design.html).
3
+ // Two listeners:
4
+ // WORCA_BROKER_PORT (8080) private only: the /p/<slot>/… proxy agents call with a
5
+ // spawn token, and the /internal API the worca server calls
6
+ // WORCA_BROKER_UI_PORT (8081) the key page, the only port a public route may point at
7
+ // (multi mode only)
8
+ import http from 'node:http';
9
+ import { readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { readBrokerConfig } from './config.mjs';
13
+ import { builtinSlots, mergeSlots } from './slots.mjs';
14
+ import { openStore } from './store.mjs';
15
+ import { createBrokerService } from './service.mjs';
16
+ import { createUiHandler } from './ui-server.mjs';
17
+
18
+ const EX_CONFIG = 78;
19
+
20
+ function version() {
21
+ try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || null; }
22
+ catch { return null; }
23
+ }
24
+
25
+ /** Load config + slots; returns {config, slots} or {errors}. */
26
+ export function loadBroker(env = process.env) {
27
+ const { config, errors } = readBrokerConfig(env);
28
+ let slots = [];
29
+ try {
30
+ const extra = config.slotsFile ? JSON.parse(readFileSync(config.slotsFile, 'utf8')) : undefined;
31
+ slots = mergeSlots(builtinSlots({ localUrl: config.localUrl, github: config.github }), extra);
32
+ } catch (err) {
33
+ errors.push(`slots: ${err.message}`);
34
+ }
35
+ if (config.mode === 'multi' && Object.keys(config.singleKeys).length && !config.allowTeamKeys) {
36
+ errors.push('WORCA_BROKER_KEY_* is for single mode; in multi mode each person saves their own key on the key page (WORCA_BROKER_ALLOW_TEAM_KEYS=1 allows team keys for operator slots)');
37
+ }
38
+ for (const id of Object.keys(config.singleKeys)) {
39
+ if (!slots.some((s) => s.id === id)) errors.push(`WORCA_BROKER_KEY_${id.toUpperCase().replace(/-/g, '_')} names an unknown slot`);
40
+ }
41
+ return { config: { ...config, version: version() }, slots, errors };
42
+ }
43
+
44
+ /**
45
+ * Start both listeners. Resolves with {close, ports} once listening.
46
+ * @param {object} o {config, slots, store?, log?}
47
+ */
48
+ export async function startBroker({ config, slots, store, log = (l) => process.stdout.write(`${l}\n`) }) {
49
+ const st = store || openStore(config.dataDir ? join(config.dataDir, 'broker.db') : ':memory:');
50
+ const service = createBrokerService({ config, slots, store: st, log });
51
+ const rot = service.rotateVault();
52
+ if (rot.rotated) log(`vault: re-encrypted ${rot.rotated} credential(s) with the new key`);
53
+ if (rot.unreadable) log(`vault key changed: ${rot.unreadable} credential(s) can't be decrypted; people must enter them again (or set WORCA_BROKER_VAULT_KEY_OLD)`);
54
+
55
+ const priv = http.createServer((req, res) => service.handlePrivate(req, res));
56
+ // No forward-proxy behaviour: a CONNECT tunnel is refused outright.
57
+ priv.on('connect', (_req, socket) => { socket.end('HTTP/1.1 405 Method Not Allowed\r\n\r\n'); });
58
+ priv.on('clientError', (_err, socket) => socket.destroy());
59
+ priv.requestTimeout = 0; // streamed completions can run for many minutes
60
+ priv.headersTimeout = 30_000;
61
+
62
+ const listen = (server, port) => new Promise((resolveP, rejectP) => {
63
+ server.once('error', rejectP);
64
+ server.listen(port, config.host, () => { server.off('error', rejectP); resolveP(server.address().port); });
65
+ });
66
+ const ports = { private: await listen(priv, config.port) };
67
+ let ui = null;
68
+ if (config.uiEnabled) {
69
+ ui = http.createServer(createUiHandler({ config, service, store: st, log }));
70
+ ui.on('clientError', (_err, socket) => socket.destroy());
71
+ ports.ui = await listen(ui, config.uiPort);
72
+ }
73
+ const sweep = setInterval(() => {
74
+ try { st.deleteExpiredTokens(); st.deleteOldUsage(); } catch { /* next time */ }
75
+ }, 3_600_000);
76
+ sweep.unref();
77
+
78
+ return {
79
+ ports, service, store: st,
80
+ close: () => new Promise((resolveP) => {
81
+ clearInterval(sweep);
82
+ let n = ui ? 2 : 1;
83
+ const done = () => { if (--n === 0) { if (!store) st.close(); resolveP(); } };
84
+ priv.close(done); priv.closeAllConnections?.();
85
+ if (ui) { ui.close(done); ui.closeAllConnections?.(); }
86
+ }),
87
+ };
88
+ }
89
+
90
+ /** `worca broker secrets`: a fresh shared secret and vault key, for pasting into a secret store. */
91
+ export function generateSecrets() {
92
+ return { secret: randomBytes(32).toString('base64url'), vaultKey: randomBytes(32).toString('base64') };
93
+ }
94
+
95
+ /** `worca broker [serve]` entry. Returns an exit code, or never returns while serving. */
96
+ export async function runBrokerCli(args = []) {
97
+ const sub = args[0] || 'serve';
98
+ if (sub === 'secrets') {
99
+ const s = generateSecrets();
100
+ process.stdout.write(`WORCA_BROKER_SECRET=${s.secret}\nWORCA_BROKER_VAULT_KEY=${s.vaultKey}\n`);
101
+ return 0;
102
+ }
103
+ if (sub === 'revoke') {
104
+ const i = args.indexOf('--person');
105
+ const person = i >= 0 ? args[i + 1] : null;
106
+ if (!person) { process.stderr.write('usage: worca broker revoke --person <email>\n'); return 2; }
107
+ const { config, errors } = loadBroker();
108
+ if (errors.length || !config.dataDir) { process.stderr.write(`worca broker: ${errors[0] || 'WORCA_BROKER_DATA_DIR is not set'}\n`); return EX_CONFIG; }
109
+ const st = openStore(join(config.dataDir, 'broker.db'));
110
+ const n = st.revokeWhere({ billTo: String(person).trim().toLowerCase() });
111
+ st.close();
112
+ process.stdout.write(`revoked ${n} live token(s) for ${person}\n`);
113
+ return 0;
114
+ }
115
+ if (sub !== 'serve') { process.stderr.write('usage: worca broker [serve | secrets | revoke --person <email>]\n'); return 2; }
116
+
117
+ const { config, slots, errors } = loadBroker();
118
+ if (errors.length) {
119
+ for (const e of errors) process.stderr.write(`worca broker: ${e}\n`);
120
+ return EX_CONFIG;
121
+ }
122
+ const b = await startBroker({ config, slots });
123
+ process.stdout.write(`worca broker ${config.version || ''} (${config.mode}) listening: private :${b.ports.private}${b.ports.ui ? `, key page :${b.ports.ui}` : ''}; slots ${slots.map((s) => s.id).join(', ')}\n`);
124
+ if (config.publicUrl) process.stdout.write(`key page: ${config.publicUrl}\n`);
125
+ await new Promise((resolveP) => {
126
+ for (const sig of ['SIGTERM', 'SIGINT']) process.once(sig, () => { b.close().then(resolveP); });
127
+ });
128
+ return 0;
129
+ }
@@ -0,0 +1,45 @@
1
+ // src/broker/scrub.mjs
2
+ // Guarantee K4 (plans/credential-broker-design.html §6.10): nothing the broker sends
3
+ // back to an agent contains a credential. Upstream error bodies sometimes echo the
4
+ // key they rejected, in full or masked (OpenAI: "Incorrect API key provided:
5
+ // sk-ab****xyz"). Pure.
6
+
7
+ export const REMOVED = '[removed by worca broker]';
8
+
9
+ export const KEY_SHAPES = Object.freeze([
10
+ /sk-ant-[A-Za-z0-9_-]{8,}/g, // Anthropic keys and OAuth tokens
11
+ /sk-[A-Za-z0-9_-]*\*{2,}[A-Za-z0-9_-]*/g, // OpenAI's masked echo "sk-ab****xyz"
12
+ /sk-(?:proj-|or-v1-|or-)?[A-Za-z0-9_-]{8,}/g, // OpenAI / OpenRouter keys
13
+ /\bgh[opsu]_[A-Za-z0-9]{20,}/g, // GitHub tokens (Copilot sign-in)
14
+ /\bA(?:KIA|SIA)[0-9A-Z]{16}\b/g, // AWS access key ids
15
+ /\bwbt_[A-Za-z0-9_-]{20,}/g, // the broker's own spawn tokens
16
+ ]);
17
+
18
+ const WINDOW = 12;
19
+
20
+ function escapeRe(s) {
21
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+ }
23
+
24
+ /**
25
+ * Remove `secrets` (exact values and every 12-character window of each) and every
26
+ * known key shape from `text`.
27
+ * @param {string} text
28
+ * @param {string[]} [secrets]
29
+ */
30
+ export function scrubText(text, secrets = []) {
31
+ let out = String(text ?? '');
32
+ for (const secret of secrets) {
33
+ const s = String(secret || '');
34
+ if (s.length < 8) continue;
35
+ out = out.split(s).join(REMOVED);
36
+ if (s.length > WINDOW) {
37
+ // Any 12-char slice of the secret (a truncated echo) — one alternation, built once.
38
+ const parts = new Set();
39
+ for (let i = 0; i + WINDOW <= s.length; i++) parts.add(escapeRe(s.slice(i, i + WINDOW)));
40
+ out = out.replace(new RegExp([...parts].join('|'), 'g'), REMOVED);
41
+ }
42
+ }
43
+ for (const re of KEY_SHAPES) out = out.replace(re, REMOVED);
44
+ return out;
45
+ }