@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
package/README.md CHANGED
@@ -352,6 +352,11 @@ worca --project /path/to/your/project --prompt "Add a /search endpoint" --workfl
352
352
  # pause with Ctrl+C, continue later (survives restarts)
353
353
  worca resume <pipelineId>
354
354
 
355
+ # see every run from the terminal — no browser, no Worca server needed
356
+ worca runs
357
+ worca runs --status paused
358
+ worca runs <pipelineId> # one run in detail (any unique prefix; --json for machines)
359
+
355
360
  # run it later: once, from this terminal, or on a repeat (needs `worca ui` up, or --wait)
356
361
  worca --project /path/to/your/project --prompt "Upgrade dependencies" --at "tomorrow 02:00"
357
362
  worca --project /path/to/your/project --prompt "Upgrade dependencies" --at 02:00 --wait --yes
@@ -394,7 +399,8 @@ interactive run paused and you can resume it); `1` a hard error, a stop, or an
394
399
  interactive pause an error forced; `2` a usage error; `3` a `--yes` run that
395
400
  parked itself — auth, quota, a usage or cost limit, exhausted retries, or a
396
401
  step error — with nobody attached to resume it. Nothing is discarded on a
397
- pause: `worca resume <pipelineId>` picks the run up where it stopped, and the
402
+ pause: `worca resume <pipelineId>` picks the run up where it stopped — on the
403
+ model it was started with (`--model`, or the one picked in the UI) — and the
398
404
  cause is printed with the pause block on stdout.
399
405
 
400
406
  ### `/worca` skill (inside Claude Code)
@@ -0,0 +1,79 @@
1
+ # docker/compose.broker.yml — the credential broker overlay (docs/credential-broker.md).
2
+ #
3
+ # docker compose -f compose.yml -f compose.broker.yml up -d
4
+ # (add -f compose.egress.yml as well to lock worca's network down)
5
+ #
6
+ # Model keys move out of the worca container into a second container from the same
7
+ # image. worca holds no key: each claude spawn gets a short-lived token from the
8
+ # broker, and the broker adds the real key on the way to the provider. Agents can't
9
+ # read a key because there is none where they run.
10
+ #
11
+ # In .env (never ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN: compose.yml would hand
12
+ # those to worca, and worca refuses to start while a key is within agents' reach):
13
+ #
14
+ # WORCA_BROKER_SECRET=<32+ random chars> # `docker compose run --rm broker worca broker secrets`
15
+ # WORCA_BROKER_KEY_ANTHROPIC=sk-ant-… # single mode: the one key, held by the broker only
16
+ #
17
+ # Multi mode (a team sharing one instance, each person with their own keys) also needs
18
+ # WORCA_BROKER_MODE=multi, WORCA_BROKER_VAULT_KEY, WORCA_BROKER_PUBLIC_URL and an identity
19
+ # check for the key page; see docs/credential-broker.md.
20
+
21
+ services:
22
+ worca:
23
+ depends_on:
24
+ - broker
25
+ environment:
26
+ WORCA_BROKER_URL: http://broker:8080
27
+ WORCA_BROKER_SECRET: ${WORCA_BROKER_SECRET:?set WORCA_BROKER_SECRET in .env}
28
+ WORCA_BROKER_SYSTEM_BILL_TO: ${WORCA_BROKER_SYSTEM_BILL_TO:-}
29
+ # Optional: pushes and pull requests as the person who acted (prefer | required),
30
+ # and what to do with MCP servers that carry a literal secret (block | warn | off).
31
+ WORCA_GH_AS_PERSON: ${WORCA_GH_AS_PERSON:-}
32
+ WORCA_MCP_SECRETS: ${WORCA_MCP_SECRETS:-}
33
+ # The key lives in the broker now; these stay empty in worca.
34
+ ANTHROPIC_API_KEY: ""
35
+ CLAUDE_CODE_OAUTH_TOKEN: ""
36
+
37
+ broker:
38
+ image: ghcr.io/sinishadjukic/worca:${WORCA_TAG:-latest}
39
+ init: true
40
+ command: ["worca", "broker"]
41
+ environment:
42
+ WORCA_BROKER_MODE: ${WORCA_BROKER_MODE:-single}
43
+ WORCA_BROKER_SECRET: ${WORCA_BROKER_SECRET:?set WORCA_BROKER_SECRET in .env}
44
+ WORCA_BROKER_VAULT_KEY: ${WORCA_BROKER_VAULT_KEY:-}
45
+ WORCA_BROKER_KEY_ANTHROPIC: ${WORCA_BROKER_KEY_ANTHROPIC:-}
46
+ WORCA_BROKER_LOCAL_URL: ${WORCA_BROKER_LOCAL_URL:-}
47
+ WORCA_BROKER_PUBLIC_URL: ${WORCA_BROKER_PUBLIC_URL:-}
48
+ WORCA_BROKER_RETURN_URL: ${WORCA_BROKER_RETURN_URL:-}
49
+ WORCA_CF_ACCESS_TEAM_DOMAIN: ${WORCA_BROKER_CF_ACCESS_TEAM_DOMAIN:-}
50
+ WORCA_CF_ACCESS_AUD: ${WORCA_BROKER_CF_ACCESS_AUD:-}
51
+ WORCA_IDENTITY_HEADER: ${WORCA_BROKER_IDENTITY_HEADER:-}
52
+ WORCA_BROKER_DEFAULT_DAILY_USD: ${WORCA_BROKER_DEFAULT_DAILY_USD:-}
53
+ WORCA_BROKER_DEFAULT_MONTHLY_USD: ${WORCA_BROKER_DEFAULT_MONTHLY_USD:-}
54
+ # "Push as me": the GitHub App or OAuth App people sign in with on the key page.
55
+ WORCA_BROKER_GITHUB_CLIENT_ID: ${WORCA_BROKER_GITHUB_CLIENT_ID:-}
56
+ WORCA_BROKER_GITHUB_CLIENT_SECRET: ${WORCA_BROKER_GITHUB_CLIENT_SECRET:-}
57
+ WORCA_BROKER_GITHUB_SCOPES: ${WORCA_BROKER_GITHUB_SCOPES:-repo}
58
+ # The image's /worca is owned by the worca user, so a named volume there is too.
59
+ WORCA_BROKER_DATA_DIR: /worca/broker
60
+ volumes:
61
+ - broker-data:/worca
62
+ healthcheck:
63
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
64
+ interval: 30s
65
+ timeout: 5s
66
+ start_period: 10s
67
+ retries: 3
68
+ # No published ports: worca reaches the broker on the compose network. In multi mode
69
+ # the key page (8081) is published through cloudflared or a reverse proxy, never 8080.
70
+ security_opt:
71
+ - no-new-privileges:true
72
+ cap_drop:
73
+ - ALL
74
+ pids_limit: 128
75
+ mem_limit: 256m
76
+ restart: unless-stopped
77
+
78
+ volumes:
79
+ broker-data:
@@ -0,0 +1,40 @@
1
+ # docker/compose.isolation.yml — agents under their own users, for a worca a TEAM shares
2
+ # (docs/credential-broker.md). Use it with the broker overlay:
3
+ #
4
+ # docker compose -f compose.yml -f compose.broker.yml -f compose.isolation.yml up -d
5
+ #
6
+ # What it changes, and why:
7
+ # - One volume (/data) holds everything, like the single-volume hosts (Railway): the
8
+ # entrypoint starts as root ONLY to prepare it, then drops to the `worca` user.
9
+ # - Agents run as `worca-agent`, and each signed-in person's agents as their own user
10
+ # from the pool (worca-agent-01..16): they can't read worca's settings, database or
11
+ # environment, nor each other's processes (and the broker token in them).
12
+ # - That switch is a `sudo` to those users only (never root), which needs setuid to
13
+ # work: `no-new-privileges` is lifted and exactly the capabilities it needs are kept.
14
+ #
15
+ # Needs Docker Compose 2.24 or newer (the !reset tag). Your repositories live on the
16
+ # volume (/data/projects): clone them from the UI, or copy them in.
17
+
18
+ services:
19
+ worca:
20
+ user: "0:0"
21
+ environment:
22
+ WORCA_DATA_DIR: /data
23
+ volumes:
24
+ - worca-data:/data
25
+ security_opt: !reset []
26
+ cap_drop:
27
+ - ALL
28
+ cap_add:
29
+ - CHOWN # the entrypoint takes the fresh volume
30
+ - DAC_OVERRIDE
31
+ - FOWNER
32
+ - FSETID # the shared folders' setgid bit (without it the kernel silently drops it,
33
+ # new files get worca's own group, and agent users can't read them)
34
+ - SETUID # dropping to worca, and sudo to the agent users
35
+ - SETGID
36
+ - AUDIT_WRITE # sudo's session record
37
+ - KILL # stopping an agent's process group
38
+
39
+ volumes:
40
+ worca-data:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.5.0",
3
+ "version": "1.6.0-rc.1",
4
4
  "description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -65,6 +65,7 @@
65
65
  "@highlightjs/cdn-assets": "11.12.0",
66
66
  "dompurify": "3.4.14",
67
67
  "express": "^4.19.2",
68
+ "htmlparser2": "12.0.0",
68
69
  "marked": "18.0.10",
69
70
  "ws": "^8.18.0"
70
71
  },
@@ -0,0 +1,200 @@
1
+ // src/broker/config.mjs
2
+ // The credential broker's configuration (plans/credential-broker-design.html §6.2).
3
+ // Pure: reads only the `env` it is given (plus the files its *_FILE variables name),
4
+ // and returns every problem as a list instead of throwing, so the entry point can
5
+ // print them all and exit 78 once.
6
+ import { readFileSync } from 'node:fs';
7
+ import { normalizeTeamDomain } from '../core/cf-access.mjs';
8
+
9
+ export const MODES = Object.freeze(['single', 'multi']);
10
+ const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,64}$/;
11
+ const DAY_MS = 86_400_000;
12
+
13
+ /**
14
+ * A secret from `NAME_FILE` (preferred: never in the process env) or `NAME`.
15
+ * Trailing whitespace is dropped (files usually end in a newline).
16
+ * @returns {{value: string|null, error: string|null}}
17
+ */
18
+ export function readSecret(env, name, readFile = readFileSync) {
19
+ const file = String(env[`${name}_FILE`] || '').trim();
20
+ if (file) {
21
+ try {
22
+ const v = String(readFile(file, 'utf8')).trim();
23
+ return v ? { value: v, error: null } : { value: null, error: `${name}_FILE (${file}) is empty` };
24
+ } catch (err) {
25
+ return { value: null, error: `${name}_FILE (${file}) cannot be read: ${err.code || err.message}` };
26
+ }
27
+ }
28
+ const v = typeof env[name] === 'string' ? env[name].trim() : '';
29
+ return { value: v || null, error: null };
30
+ }
31
+
32
+ /** "48h" | "90m" | "3600s" | "3600000" (ms) -> ms, or null. */
33
+ export function parseDuration(v) {
34
+ const s = String(v ?? '').trim();
35
+ if (!s) return null;
36
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(s);
37
+ if (!m) return null;
38
+ const n = Number(m[1]);
39
+ const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: DAY_MS }[m[2] || 'ms'];
40
+ return Math.round(n * mult);
41
+ }
42
+
43
+ function parsePort(v, fallback) {
44
+ if (v === undefined || v === '') return fallback;
45
+ const n = Number(v);
46
+ return Number.isInteger(n) && n >= 0 && n < 65536 ? n : NaN; // 0 = an ephemeral port (tests)
47
+ }
48
+
49
+ function parseUsd(v) {
50
+ if (v === undefined || v === null || String(v).trim() === '') return null;
51
+ const n = Number(v);
52
+ return Number.isFinite(n) && n >= 0 ? n : NaN;
53
+ }
54
+
55
+ function isLoopbackHost(h) {
56
+ const host = String(h || '').toLowerCase().replace(/^\[|\]$/g, '');
57
+ return host === 'localhost' || host === '::1' || /^127\./.test(host);
58
+ }
59
+
60
+ /** A 32-byte AES key from base64 (or base64url), or null. */
61
+ export function decodeVaultKey(v) {
62
+ if (!v) return null;
63
+ let buf;
64
+ try { buf = Buffer.from(String(v).trim(), /[-_]/.test(v) ? 'base64url' : 'base64'); } catch { return null; }
65
+ return buf.length === 32 ? buf : null;
66
+ }
67
+
68
+ /** Slot ids whose single-mode key is set: WORCA_BROKER_KEY_<SLOT>[_FILE]. */
69
+ function singleKeys(env, readFile, errors) {
70
+ const out = {};
71
+ const names = new Set();
72
+ for (const k of Object.keys(env)) {
73
+ const m = /^WORCA_BROKER_KEY_([A-Z0-9_]+?)(_FILE)?$/.exec(k);
74
+ if (m) names.add(m[1]);
75
+ }
76
+ for (const n of names) {
77
+ const { value, error } = readSecret(env, `WORCA_BROKER_KEY_${n}`, readFile);
78
+ if (error) errors.push(error);
79
+ if (value) out[n.toLowerCase().replace(/_/g, '-')] = value;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * @param {Record<string,string|undefined>} env
86
+ * @returns {{config: object, errors: string[]}}
87
+ */
88
+ export function readBrokerConfig(env = process.env, { readFile = readFileSync } = {}) {
89
+ const errors = [];
90
+ const mode = String(env.WORCA_BROKER_MODE || '').trim().toLowerCase();
91
+ if (!MODES.includes(mode)) errors.push(`WORCA_BROKER_MODE must be one of ${MODES.join(', ')} (got ${JSON.stringify(env.WORCA_BROKER_MODE ?? '')})`);
92
+
93
+ const secret = readSecret(env, 'WORCA_BROKER_SECRET', readFile);
94
+ if (secret.error) errors.push(secret.error);
95
+ else if (!secret.value) errors.push('WORCA_BROKER_SECRET (or WORCA_BROKER_SECRET_FILE) is required: the secret worca uses to ask for spawn tokens');
96
+ else if (secret.value.length < 32) errors.push('WORCA_BROKER_SECRET is too short: use at least 32 characters (`worca broker secrets` prints one)');
97
+
98
+ const vk = readSecret(env, 'WORCA_BROKER_VAULT_KEY', readFile);
99
+ if (vk.error) errors.push(vk.error);
100
+ const vaultKey = decodeVaultKey(vk.value);
101
+ if (vk.value && !vaultKey) errors.push('WORCA_BROKER_VAULT_KEY must be 32 bytes, base64-encoded (`worca broker secrets` prints one)');
102
+ if (mode === 'multi' && !vk.value) errors.push('WORCA_BROKER_VAULT_KEY is required in multi mode: it encrypts people\'s keys at rest');
103
+ const vko = readSecret(env, 'WORCA_BROKER_VAULT_KEY_OLD', readFile);
104
+ if (vko.error) errors.push(vko.error);
105
+ const vaultKeyOld = decodeVaultKey(vko.value);
106
+ if (vko.value && !vaultKeyOld) errors.push('WORCA_BROKER_VAULT_KEY_OLD must be 32 bytes, base64-encoded');
107
+
108
+ const host = String(env.WORCA_BROKER_HOST || '0.0.0.0').trim();
109
+ const port = parsePort(env.WORCA_BROKER_PORT, 8080);
110
+ const uiPort = parsePort(env.WORCA_BROKER_UI_PORT, 8081);
111
+ if (Number.isNaN(port)) errors.push('WORCA_BROKER_PORT must be a port number');
112
+ if (Number.isNaN(uiPort)) errors.push('WORCA_BROKER_UI_PORT must be a port number');
113
+ if (port === uiPort && port !== 0) errors.push('WORCA_BROKER_PORT and WORCA_BROKER_UI_PORT must differ: the key page port is the only one a public route may point at');
114
+
115
+ let publicUrl = null;
116
+ const pu = String(env.WORCA_BROKER_PUBLIC_URL || '').trim();
117
+ if (pu) {
118
+ try {
119
+ const u = new URL(pu);
120
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopbackHost(u.hostname))) {
121
+ errors.push('WORCA_BROKER_PUBLIC_URL must be https (http only for localhost)');
122
+ } else if (u.pathname !== '/' || u.search || u.hash || u.username || u.password) {
123
+ errors.push('WORCA_BROKER_PUBLIC_URL must be an origin only, e.g. https://worca-01-keys.example.com');
124
+ } else publicUrl = u.origin;
125
+ } catch { errors.push('WORCA_BROKER_PUBLIC_URL is not a URL'); }
126
+ }
127
+ let returnUrl = null;
128
+ const ru = String(env.WORCA_BROKER_RETURN_URL || '').trim();
129
+ if (ru) {
130
+ try { const u = new URL(ru); if (u.protocol === 'https:' || u.protocol === 'http:') returnUrl = u.href; else throw new Error(); }
131
+ catch { errors.push('WORCA_BROKER_RETURN_URL must be an http(s) URL'); }
132
+ }
133
+
134
+ const teamDomain = normalizeTeamDomain(env.WORCA_CF_ACCESS_TEAM_DOMAIN);
135
+ const aud = String(env.WORCA_CF_ACCESS_AUD || '').trim();
136
+ const identityHeader = String(env.WORCA_IDENTITY_HEADER || '').trim();
137
+ let identity = null;
138
+ if (teamDomain || aud) {
139
+ if (!teamDomain || !aud) errors.push('set both WORCA_CF_ACCESS_TEAM_DOMAIN and WORCA_CF_ACCESS_AUD (the AUD of the key page\'s own Access application)');
140
+ else identity = { kind: 'access', teamDomain, aud };
141
+ } else if (identityHeader) {
142
+ if (!HEADER_NAME_RE.test(identityHeader)) errors.push('WORCA_IDENTITY_HEADER is not a valid header name');
143
+ else identity = { kind: 'header', header: identityHeader.toLowerCase() };
144
+ }
145
+ if (mode === 'multi') {
146
+ if (!publicUrl) errors.push('WORCA_BROKER_PUBLIC_URL is required in multi mode: the address of the key page');
147
+ if (!identity) errors.push('multi mode needs an identity check for the key page: WORCA_CF_ACCESS_TEAM_DOMAIN + WORCA_CF_ACCESS_AUD, or WORCA_IDENTITY_HEADER');
148
+ }
149
+
150
+ const defaultDailyUsd = parseUsd(env.WORCA_BROKER_DEFAULT_DAILY_USD);
151
+ const defaultMonthlyUsd = parseUsd(env.WORCA_BROKER_DEFAULT_MONTHLY_USD);
152
+ if (Number.isNaN(defaultDailyUsd)) errors.push('WORCA_BROKER_DEFAULT_DAILY_USD must be a number >= 0');
153
+ if (Number.isNaN(defaultMonthlyUsd)) errors.push('WORCA_BROKER_DEFAULT_MONTHLY_USD must be a number >= 0');
154
+
155
+ let tokenMaxTtlMs = 48 * 3_600_000;
156
+ if (env.WORCA_BROKER_TOKEN_MAX_TTL) {
157
+ const t = parseDuration(env.WORCA_BROKER_TOKEN_MAX_TTL);
158
+ if (!t || t < 60_000) errors.push('WORCA_BROKER_TOKEN_MAX_TTL must be a duration of at least 1m, e.g. 48h');
159
+ else tokenMaxTtlMs = t;
160
+ }
161
+
162
+ // "Push as me" (optional): each person signs in with GitHub on the key page through the
163
+ // operator's GitHub App or OAuth App; worca's pushes and PRs then go out as them.
164
+ const githubClientId = String(env.WORCA_BROKER_GITHUB_CLIENT_ID || '').trim() || null;
165
+ const ghSecret = readSecret(env, 'WORCA_BROKER_GITHUB_CLIENT_SECRET', readFile);
166
+ if (ghSecret.error) errors.push(ghSecret.error);
167
+ if (ghSecret.value && !githubClientId) errors.push('WORCA_BROKER_GITHUB_CLIENT_SECRET needs WORCA_BROKER_GITHUB_CLIENT_ID');
168
+ if (githubClientId && !/^[A-Za-z0-9._-]{8,64}$/.test(githubClientId)) errors.push('WORCA_BROKER_GITHUB_CLIENT_ID does not look like a GitHub client ID');
169
+
170
+ const keys = singleKeys(env, readFile, errors);
171
+ const dataDir = String(env.WORCA_BROKER_DATA_DIR || '').trim() || null;
172
+
173
+ const config = Object.freeze({
174
+ mode: MODES.includes(mode) ? mode : null,
175
+ secret: secret.value,
176
+ vaultKey,
177
+ vaultKeyOld,
178
+ dataDir,
179
+ host,
180
+ port,
181
+ uiPort,
182
+ uiEnabled: mode === 'multi',
183
+ publicUrl,
184
+ returnUrl,
185
+ identity,
186
+ slotsFile: String(env.WORCA_BROKER_SLOTS_FILE || '').trim() || null,
187
+ localUrl: String(env.WORCA_BROKER_LOCAL_URL || '').trim() || null,
188
+ github: githubClientId ? Object.freeze({
189
+ clientId: githubClientId,
190
+ clientSecret: ghSecret.value || null,
191
+ scope: String(env.WORCA_BROKER_GITHUB_SCOPES ?? 'repo').trim(),
192
+ }) : null,
193
+ singleKeys: Object.freeze(keys),
194
+ allowTeamKeys: /^(1|true|yes|on)$/i.test(String(env.WORCA_BROKER_ALLOW_TEAM_KEYS || '')),
195
+ defaultDailyUsd: Number.isNaN(defaultDailyUsd) ? null : defaultDailyUsd,
196
+ defaultMonthlyUsd: Number.isNaN(defaultMonthlyUsd) ? null : defaultMonthlyUsd,
197
+ tokenMaxTtlMs,
198
+ });
199
+ return { config, errors };
200
+ }
@@ -0,0 +1,124 @@
1
+ // src/broker/copilot.mjs
2
+ // GitHub Copilot inside the credential broker (plans/credential-broker-design.html §5.3).
3
+ // A person signs in on the key page with GitHub's device flow; the broker keeps their
4
+ // GitHub token sealed in the vault and, per request, exchanges it for Copilot's
5
+ // short-lived API token (cached until shortly before it expires). worca never sees
6
+ // either token. The editor headers and endpoints are the ones worca's own Copilot
7
+ // provider uses (src/core/bridge/providers/copilot.mjs), imported from there.
8
+ import { githubHeaders, GITHUB_API, GITHUB_BASE, GITHUB_CLIENT_ID, GITHUB_SCOPES } from '../core/bridge/providers/copilot.mjs';
9
+
10
+ export const DEFAULT_EXCHANGE_URL = `${GITHUB_API}/copilot_internal/v2/token`;
11
+ const REFRESH_MARGIN_MS = 60_000;
12
+
13
+ /** A Copilot API host must be GitHub's (the exchange response names it; never trust anything else). */
14
+ export function isCopilotHost(origin) {
15
+ try {
16
+ const u = new URL(origin);
17
+ return u.protocol === 'https:' && (u.hostname === 'githubcopilot.com' || u.hostname.endsWith('.githubcopilot.com'));
18
+ } catch { return false; }
19
+ }
20
+
21
+ /**
22
+ * The exchange, cached per GitHub token.
23
+ * @param {{fetchImpl?:typeof fetch, exchangeUrl?:string, now?:()=>number, defaultHost?:string, allowHost?:(o:string)=>boolean}} o
24
+ */
25
+ export function createCopilotExchange({ fetchImpl = globalThis.fetch, exchangeUrl = DEFAULT_EXCHANGE_URL, now = Date.now, defaultHost = 'https://api.githubcopilot.com', allowHost = isCopilotHost } = {}) {
26
+ const cache = new Map(); // github token -> {token, host, expiresAt}
27
+ return {
28
+ /** {token, host} or throws with .status (401/403 = the GitHub sign-in no longer works). */
29
+ async token(githubToken, { force = false } = {}) {
30
+ const hit = cache.get(githubToken);
31
+ if (!force && hit && hit.expiresAt - REFRESH_MARGIN_MS > now()) return hit;
32
+ const res = await fetchImpl(exchangeUrl, { headers: githubHeaders(githubToken) });
33
+ if (!res.ok) {
34
+ cache.delete(githubToken);
35
+ const err = new Error(res.status === 401 || res.status === 403
36
+ ? `GitHub refused the sign-in (${res.status}): sign in to Copilot again`
37
+ : `the Copilot token exchange failed (HTTP ${res.status})`);
38
+ err.status = res.status;
39
+ throw err;
40
+ }
41
+ const j = await res.json();
42
+ if (!j || typeof j.token !== 'string') throw new Error('the Copilot token exchange returned no token');
43
+ let host = defaultHost;
44
+ const api = j.endpoints && typeof j.endpoints.api === 'string' ? j.endpoints.api.replace(/\/+$/, '') : null;
45
+ if (api && allowHost(api)) host = new URL(api).origin;
46
+ const expiresAt = Number.isFinite(j.expires_at) ? j.expires_at * 1000 : now() + 25 * 60_000;
47
+ const v = { token: j.token, host, expiresAt };
48
+ cache.set(githubToken, v);
49
+ return v;
50
+ },
51
+ invalidate(githubToken) { cache.delete(githubToken); },
52
+ };
53
+ }
54
+
55
+ /**
56
+ * GitHub's device flow, step 1: a code for the person to enter at github.com/login/device.
57
+ * `clientId`/`scope`: Copilot's own app by default; "push as me" passes the operator's
58
+ * GitHub App or OAuth App (WORCA_BROKER_GITHUB_CLIENT_ID).
59
+ */
60
+ export async function startDeviceFlow({ fetchImpl = globalThis.fetch, baseUrl = GITHUB_BASE, clientId = GITHUB_CLIENT_ID, scope = GITHUB_SCOPES } = {}) {
61
+ const res = await fetchImpl(`${baseUrl}/login/device/code`, {
62
+ method: 'POST',
63
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
64
+ body: JSON.stringify({ client_id: clientId, ...(scope ? { scope } : {}) }),
65
+ });
66
+ if (!res.ok) throw new Error(`GitHub device sign-in failed (HTTP ${res.status})`);
67
+ const j = await res.json();
68
+ if (!j.device_code || !j.user_code) throw new Error('GitHub returned no device code');
69
+ return { deviceCode: j.device_code, userCode: j.user_code, verificationUri: j.verification_uri || 'https://github.com/login/device', interval: Number(j.interval) || 5, expiresIn: Number(j.expires_in) || 900 };
70
+ }
71
+
72
+ /**
73
+ * Step 2, polled: {token, refreshToken?, expiresIn?} once the person approved (a GitHub App
74
+ * with expiring user tokens also returns a refresh token), {pending:true} before, {error}.
75
+ */
76
+ export async function pollDeviceFlow(deviceCode, { fetchImpl = globalThis.fetch, baseUrl = GITHUB_BASE, clientId = GITHUB_CLIENT_ID } = {}) {
77
+ const res = await fetchImpl(`${baseUrl}/login/oauth/access_token`, {
78
+ method: 'POST',
79
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
80
+ body: JSON.stringify({ client_id: clientId, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
81
+ });
82
+ const j = await res.json().catch(() => ({}));
83
+ if (j.access_token) {
84
+ return {
85
+ token: j.access_token,
86
+ ...(j.refresh_token ? { refreshToken: j.refresh_token } : {}),
87
+ ...(Number.isFinite(j.expires_in) ? { expiresIn: j.expires_in } : {}),
88
+ };
89
+ }
90
+ if (j.error === 'authorization_pending' || j.error === 'slow_down') return { pending: true, slowDown: j.error === 'slow_down' };
91
+ return { error: j.error_description || j.error || `HTTP ${res.status}` };
92
+ }
93
+
94
+ // ── "Push as me": a person's GitHub user token (credential broker, docs/credential-broker.md)
95
+
96
+ /**
97
+ * A stored GitHub credential: a plain token (a pasted fine-grained token, an OAuth App
98
+ * token that doesn't expire) or `{"t":…,"r":…,"e":…}` for a GitHub App user token with
99
+ * its refresh token and expiry (ms). Pure.
100
+ */
101
+ export function parseGithubSecret(secret) {
102
+ const s = String(secret || '');
103
+ if (s.startsWith('{')) {
104
+ try { const j = JSON.parse(s); if (typeof j.t === 'string') return { token: j.t, refreshToken: j.r || null, expiresAt: Number(j.e) || null }; } catch { /* plain */ }
105
+ }
106
+ return { token: s, refreshToken: null, expiresAt: null };
107
+ }
108
+
109
+ export function githubSecretOf({ token, refreshToken = null, expiresIn = null }, now = Date.now()) {
110
+ return refreshToken ? JSON.stringify({ t: token, r: refreshToken, e: expiresIn ? now + expiresIn * 1000 : null }) : token;
111
+ }
112
+
113
+ /** Refresh a GitHub App user token. Returns the new stored secret, or throws. */
114
+ export async function refreshGithubToken({ refreshToken, clientId, clientSecret, fetchImpl = globalThis.fetch, baseUrl = GITHUB_BASE, now = Date.now() }) {
115
+ if (!clientSecret) throw new Error('the GitHub sign-in expired and the broker has no client secret to renew it: sign in again');
116
+ const res = await fetchImpl(`${baseUrl}/login/oauth/access_token`, {
117
+ method: 'POST',
118
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
119
+ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken }),
120
+ });
121
+ const j = await res.json().catch(() => ({}));
122
+ if (!j.access_token) throw new Error(`GitHub would not renew the sign-in (${j.error || `HTTP ${res.status}`}): sign in again`);
123
+ return githubSecretOf({ token: j.access_token, refreshToken: j.refresh_token || refreshToken, expiresIn: j.expires_in }, now);
124
+ }
@@ -0,0 +1,88 @@
1
+ // src/broker/limits.mjs
2
+ // Spend and load limits (plans/credential-broker-design.html §6.4 step 4): the
3
+ // worst a rogue agent can do is burn credits, and these cap how many. In memory:
4
+ // concurrency per token and per person+slot, requests per minute per person+slot.
5
+ // Budgets read the usage table (cached briefly) against the person's own cap, the
6
+ // operator default, and the token's own budget.
7
+
8
+ const DAY_MS = 86_400_000;
9
+ const SPEND_CACHE_MS = 10_000;
10
+
11
+ export function startOfUtcDay(now) { const d = new Date(now); d.setUTCHours(0, 0, 0, 0); return d.getTime(); }
12
+ export function startOfUtcMonth(now) { const d = new Date(now); d.setUTCDate(1); d.setUTCHours(0, 0, 0, 0); return d.getTime(); }
13
+
14
+ export function createLimits({ store, config, now = Date.now }) {
15
+ const active = new Map(); // key -> count
16
+ const windows = new Map(); // person|slot -> [timestamps]
17
+ const spendCache = new Map(); // person|slot|period -> {at, usd}
18
+
19
+ const inc = (k) => active.set(k, (active.get(k) || 0) + 1);
20
+ const dec = (k) => { const n = (active.get(k) || 1) - 1; if (n <= 0) active.delete(k); else active.set(k, n); };
21
+
22
+ function spent(billTo, slot, period) {
23
+ const key = `${billTo}|${slot}|${period}`;
24
+ const t = now();
25
+ const hit = spendCache.get(key);
26
+ if (hit && t - hit.at < SPEND_CACHE_MS) return hit.usd;
27
+ const since = period === 'day' ? startOfUtcDay(t) : startOfUtcMonth(t);
28
+ const usd = store.spentSince(billTo, slot, since);
29
+ spendCache.set(key, { at: t, usd });
30
+ return usd;
31
+ }
32
+
33
+ return {
34
+ /**
35
+ * Admit one request, or say why not. On admission the caller MUST call release().
36
+ * @returns {{release: () => void} | {status:number, kind:'rate'|'budget', message:string, retryAfter?:number}}
37
+ */
38
+ acquire({ tokenRow, slot, credentialRow = null }) {
39
+ const person = tokenRow.bill_to;
40
+ const lim = slot.limits || {};
41
+ const perToken = Number(lim.concurrencyPerToken) || 8;
42
+ const perPerson = Number(lim.concurrencyPerPerson) || 16;
43
+ const rpm = Number(lim.rpmPerPerson) || 240;
44
+
45
+ // Budgets first: a spent budget is not cleared by waiting.
46
+ if (tokenRow.budget_usd != null && tokenRow.spent_usd >= tokenRow.budget_usd) {
47
+ return { status: 403, kind: 'budget', message: `quota reached: this spawn's budget of $${fmt(tokenRow.budget_usd)} is spent` };
48
+ }
49
+ const daily = credentialRow?.daily_usd ?? config.defaultDailyUsd;
50
+ if (daily != null && spent(person, slot.id, 'day') >= daily) {
51
+ return { status: 403, kind: 'budget', message: `quota reached: daily budget of $${fmt(daily)} for ${person} on ${slot.id}` };
52
+ }
53
+ const monthly = credentialRow?.monthly_usd ?? config.defaultMonthlyUsd;
54
+ if (monthly != null && spent(person, slot.id, 'month') >= monthly) {
55
+ return { status: 403, kind: 'budget', message: `quota reached: monthly budget of $${fmt(monthly)} for ${person} on ${slot.id}` };
56
+ }
57
+
58
+ const tk = `t:${tokenRow.hash}`;
59
+ const pk = `p:${person}|${slot.id}`;
60
+ if ((active.get(tk) || 0) >= perToken || (active.get(pk) || 0) >= perPerson) {
61
+ return { status: 429, kind: 'rate', message: 'too many concurrent requests, retry after 2 seconds', retryAfter: 2 };
62
+ }
63
+ const t = now();
64
+ const w = (windows.get(pk) || []).filter((x) => t - x < 60_000);
65
+ if (w.length >= rpm) {
66
+ const wait = Math.max(1, Math.ceil((60_000 - (t - w[0])) / 1000));
67
+ windows.set(pk, w);
68
+ return { status: 429, kind: 'rate', message: `too many requests per minute, retry after ${wait} seconds`, retryAfter: wait };
69
+ }
70
+ w.push(t);
71
+ windows.set(pk, w);
72
+ inc(tk); inc(pk);
73
+ let released = false;
74
+ return { release() { if (released) return; released = true; dec(tk); dec(pk); } };
75
+ },
76
+ /** Forget cached spend for a person (after a usage row lands, budgets see it within 10 s anyway). */
77
+ invalidate(billTo, slot) {
78
+ for (const p of ['day', 'month']) spendCache.delete(`${billTo}|${slot}|${p}`);
79
+ },
80
+ _active: active,
81
+ };
82
+ }
83
+
84
+ function fmt(n) {
85
+ return Number(n).toFixed(2).replace(/\.00$/, '');
86
+ }
87
+
88
+ export { DAY_MS };