@worca/app 1.4.0 → 1.5.0-rc.2

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 (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
@@ -58,6 +58,30 @@ export function withTierModelEnv(env, modelId) {
58
58
  return out;
59
59
  }
60
60
 
61
+ // The CLI's cloud transports. Any one truthy makes the CLI ignore
62
+ // ANTHROPIC_BASE_URL and talk to that cloud instead (Vertex: ANTHROPIC_VERTEX_BASE_URL),
63
+ // so a shell that exports CLAUDE_CODE_USE_VERTEX=1 for its first-party Claude
64
+ // sent every bridged or endpoint-routed model there — an id the cloud does not
65
+ // know (`unrecognized_model`, exit 1). The model env merges over the ambient env
66
+ // at spawn, so an explicit '0' (the CLI reads it as false) wins.
67
+ export const PROVIDER_MODE_ENV_KEYS = Object.freeze([
68
+ 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_FOUNDRY',
69
+ ]);
70
+
71
+ /**
72
+ * An endpoint-routed env with every cloud transport the entry left unset
73
+ * turned off. Pure: a non-routed env (no ANTHROPIC_BASE_URL) comes back
74
+ * untouched and an explicit key in the env is never overwritten.
75
+ * @param {Record<string,string>|undefined} env a PREPARED model env
76
+ * @returns {Record<string,string>|undefined}
77
+ */
78
+ export function withProviderModesOff(env) {
79
+ if (!env || typeof env !== 'object' || !('ANTHROPIC_BASE_URL' in env)) return env;
80
+ const out = { ...env };
81
+ for (const k of PROVIDER_MODE_ENV_KEYS) if (!(k in out)) out[k] = '0';
82
+ return out;
83
+ }
84
+
61
85
  // Env keys a model entry may NOT set (§4.4): process fundamentals and worca's
62
86
  // own runtime knobs, any of which injection could otherwise subvert (mock
63
87
  // mode, the claude binary path, the effort flag name). Everything else —
@@ -291,7 +315,7 @@ export function subagentModelIssue(v) {
291
315
  // catalog (settings.mjs) and a plugin manifest (plugin-manifest.mjs) validate
292
316
  // the same shape against one rule, and neither may import the other.
293
317
  //
294
- // { provider: 'copilot'|'openai'|'anthropic', api: 'anthropic'|'openai-chat',
318
+ // { provider: 'copilot'|'openai'|'anthropic', api: 'anthropic'|'openai-chat'|'openai-responses',
295
319
  // model: '<upstream id>', baseUrl?, apiKey?, headers?, capabilities? }
296
320
  //
297
321
  // A bridged entry is dispatched through worca's in-process loopback bridge
@@ -301,13 +325,17 @@ export function subagentModelIssue(v) {
301
325
  // not also appear in the entry's own `env` map.
302
326
 
303
327
  export const UPSTREAM_PROVIDERS = Object.freeze(['copilot', 'openai', 'anthropic']);
304
- export const UPSTREAM_APIS = Object.freeze(['anthropic', 'openai-chat']);
328
+ export const UPSTREAM_APIS = Object.freeze(['anthropic', 'openai-chat', 'openai-responses']);
305
329
  /** Which wire protocols each provider can be driven through. */
306
330
  export const PROVIDER_APIS = Object.freeze({
307
- copilot: Object.freeze(['anthropic', 'openai-chat']),
308
- openai: Object.freeze(['openai-chat']),
331
+ copilot: Object.freeze(['anthropic', 'openai-chat', 'openai-responses']),
332
+ openai: Object.freeze(['openai-chat', 'openai-responses']),
309
333
  anthropic: Object.freeze(['anthropic']),
310
334
  });
335
+ /** The wire protocols the bridge TRANSLATES to — everything but the Anthropic passthrough. */
336
+ export const TRANSLATED_APIS = Object.freeze(['openai-chat', 'openai-responses']);
337
+ /** Whether `api` is translated by the bridge (no server tools, no thinking passthrough). */
338
+ export function isTranslatedApi(api) { return TRANSLATED_APIS.includes(api); }
311
339
  /** Env keys the bridge owns; rejected in an `env` map beside `upstream`. */
312
340
  export const BRIDGE_ROUTING_KEYS = Object.freeze([
313
341
  'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_MODEL',
@@ -316,6 +344,10 @@ export const BRIDGE_ROUTING_KEYS = Object.freeze([
316
344
  export const CAPABILITY_FLAGS = Object.freeze(['toolCalls', 'vision', 'reasoning']);
317
345
  /** Numeric capability limits an entry may pin. */
318
346
  export const CAPABILITY_LIMITS = Object.freeze(['maxPromptTokens', 'maxOutputTokens']);
347
+ /** Reasoning-effort levels an upstream may list, lowest first (capabilities.reasoningEfforts). */
348
+ export const REASONING_EFFORT_LEVELS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
349
+ /** List-valued capabilities an entry may pin. */
350
+ export const CAPABILITY_LISTS = Object.freeze(['reasoningEfforts']);
319
351
  const FORBIDDEN_UPSTREAM_HEADERS = new Set(['authorization', 'host', 'content-length', 'content-type', 'transfer-encoding']);
320
352
  const HEADER_NAME_RE = /^[A-Za-z0-9-]{1,80}$/;
321
353
 
@@ -334,8 +366,16 @@ export function assertModelCapabilities(caps) {
334
366
  const n = Number(v);
335
367
  if (!Number.isInteger(n) || n <= 0) throw new Error(`upstream.capabilities.${k} must be a positive integer`);
336
368
  out[k] = n;
369
+ } else if (CAPABILITY_LISTS.includes(k)) {
370
+ if (v === null || v === undefined) continue;
371
+ if (!Array.isArray(v)) throw new Error(`upstream.capabilities.${k} must be an array of effort levels`);
372
+ for (const e of v) {
373
+ if (!REASONING_EFFORT_LEVELS.includes(e)) throw new Error(`upstream.capabilities.${k}: unknown level ${JSON.stringify(e)} — allowed: ${REASONING_EFFORT_LEVELS.join(', ')}`);
374
+ }
375
+ const levels = REASONING_EFFORT_LEVELS.filter((e) => v.includes(e));
376
+ if (levels.length) out[k] = levels;
337
377
  } else {
338
- throw new Error(`unknown upstream.capabilities key ${JSON.stringify(k)} — allowed: ${[...CAPABILITY_FLAGS, ...CAPABILITY_LIMITS].join(', ')}`);
378
+ throw new Error(`unknown upstream.capabilities key ${JSON.stringify(k)} — allowed: ${[...CAPABILITY_FLAGS, ...CAPABILITY_LIMITS, ...CAPABILITY_LISTS].join(', ')}`);
339
379
  }
340
380
  }
341
381
  return Object.keys(out).length ? out : undefined;
@@ -422,9 +462,9 @@ export function upstreamEnvConflict(env) {
422
462
  return null;
423
463
  }
424
464
 
425
- /** Whether an `openai-chat` upstream drops the CLI's web tools (§5.3). */
465
+ /** The CLI's web tools a translated (openai-chat / openai-responses) upstream withholds (§5.3). */
426
466
  export function bridgeExcludedTools(upstream) {
427
- return upstream && upstream.api === 'openai-chat' ? ['WebSearch', 'WebFetch'] : [];
467
+ return upstream && isTranslatedApi(upstream.api) ? ['WebSearch', 'WebFetch'] : [];
428
468
  }
429
469
 
430
470
  // ── providers: account-level state shared by bridged entries (§6.2) ─────────
@@ -9,6 +9,7 @@ import { runClaude } from './claude-runner.mjs';
9
9
  import { resolveModelEnv } from './config.mjs';
10
10
  import { AUX_EFFORT } from './model-env.mjs';
11
11
  import { classifyError } from './recoverable-error.mjs';
12
+ import { bridgeEvents } from './bridge/telemetry.mjs';
12
13
 
13
14
  const TEST_TIMEOUT_MS = 60_000;
14
15
  const REPLY_CAP = 100;
@@ -56,6 +57,17 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
56
57
  }
57
58
  const timer = setTimeout(() => ctrl.abort(), TEST_TIMEOUT_MS);
58
59
  timer.unref?.();
60
+ // A bridged model's failure is recorded by the in-process bridge with the
61
+ // upstream's reason; the CLI's stderr for such a run carries only warnings
62
+ // (unrecognized_model, connectors disabled), so that reason wins. The test's
63
+ // own call is untagged (resolveModelEnv(id) passes no execution id); a
64
+ // pipeline node run on the same model is tagged and ignored. An untagged
65
+ // call (Ask, titles) failing on the same id during the Test is picked up
66
+ // too — rare, and still a real failure of this model.
67
+ const want = String(id || '').toLowerCase();
68
+ let bridgeFailure = null;
69
+ const onBridgeFailure = (e) => { if (e && !e.tag && String(e.catalogId || '').toLowerCase() === want) bridgeFailure = e; };
70
+ bridgeEvents.on('failure', onBridgeFailure);
59
71
  try {
60
72
  const { text } = await run({
61
73
  cwd: process.cwd(),
@@ -76,17 +88,29 @@ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
76
88
  }
77
89
  return { ok: true, text: reply.slice(0, REPLY_CAP) };
78
90
  } catch (err) {
79
- if (err && err.name === 'AbortError') {
91
+ // The Test's own timer can fire while the CLI still retries a failure the
92
+ // bridge booked (it retries a 5xx well past TEST_TIMEOUT_MS): that failure
93
+ // is the answer. A Test the caller cancelled stays a timeout.
94
+ const booked = bridgeFailure && bridgeFailure.message && !(signal && signal.aborted);
95
+ if (err && err.name === 'AbortError' && !booked) {
80
96
  return { ok: false, errorClass: 'timeout', message: `Timed out after ${TEST_TIMEOUT_MS / 1000}s`, hint: hintFor('timeout') };
81
97
  }
82
- const message = err && err.message ? err.message : String(err);
83
- const errorClass = (err && err.errorClass) || classifyError(message);
98
+ const message = bridgeFailure && bridgeFailure.message
99
+ ? String(bridgeFailure.message)
100
+ : (err && err.message ? err.message : String(err));
101
+ const errorClass = bridgeFailure && bridgeFailure.message
102
+ ? classifyError(message)
103
+ : ((err && err.errorClass) || classifyError(message));
84
104
  // A bridged model whose provider is not usable (model-bridge-design.md
85
105
  // §8.5): resolveModelEnv fails fast with `bridgeReason`, and the hint
86
- // names the fix instead of the generic credential advice.
87
- const hint = err && err.bridgeReason ? bridgeHintFor(err.bridgeReason, err.bridgeProvider) : hintFor(errorClass);
106
+ // names the fix instead of the generic credential advice. A bridge failure
107
+ // classed `network` ("endpoint unreachable", "upstream error (500)") is not
108
+ // an ANTHROPIC_BASE_URL problem: no hint, so the UI shows the message.
109
+ const hint = err && err.bridgeReason ? bridgeHintFor(err.bridgeReason, err.bridgeProvider)
110
+ : bridgeFailure && bridgeFailure.message && errorClass === 'network' ? '' : hintFor(errorClass);
88
111
  return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
89
112
  } finally {
113
+ bridgeEvents.off('failure', onBridgeFailure);
90
114
  clearTimeout(timer);
91
115
  if (signal) signal.removeEventListener?.('abort', onOuterAbort);
92
116
  }
@@ -4,7 +4,10 @@
4
4
  // self-paused, completed) lands here — but the table carries a `scope` column so an
5
5
  // app-wide centre can reuse it without a second system.
6
6
  //
7
- // Read state is GLOBAL (Worca is single-user per machine). `info` rows arrive
7
+ // Read state is GLOBAL on a local install (one person per machine). On a shared deployment
8
+ // (a real per-person sign-in, identity.mjs#isSharedIdentity) callers pass `reader`, and
9
+ // "Mark read" is per person (notification_reads, v37): a row is read for a person when it
10
+ // was read globally (an info row, or a resolved problem) or they marked it. `info` rows arrive
8
11
  // already read: only `problem` rows count towards the unread badge. A problem
9
12
  // RESOLVES itself when the user acts on it (Run now, reschedule, resume), so stale
10
13
  // alarms never pile up.
@@ -40,9 +43,9 @@ function rowToNotification(r) {
40
43
  title: r.title || null,
41
44
  message: r.message,
42
45
  createdAt: r.created_at,
43
- readAt: r.read_at || null,
46
+ readAt: r.read_at || r.reader_read_at || null,
44
47
  resolvedAt: r.resolved_at || null,
45
- unread: !r.read_at && r.severity === 'problem',
48
+ unread: !r.read_at && !r.reader_read_at && r.severity === 'problem',
46
49
  };
47
50
  }
48
51
 
@@ -72,19 +75,26 @@ export function addNotification({
72
75
  * List notifications, newest first.
73
76
  * @param {{scope?:string, unread?:boolean, problems?:boolean, scheduleId?:string, limit?:number}} [o]
74
77
  */
75
- export function listNotifications({ scope = 'schedule', unread = false, problems = false, scheduleId = null, limit = 200 } = {}) {
76
- const where = ['scope = ?'];
78
+ export function listNotifications({ scope = 'schedule', unread = false, problems = false, scheduleId = null, limit = 200, reader = null } = {}) {
79
+ const where = ['n.scope = ?'];
77
80
  const args = [scope];
78
- if (unread) where.push("read_at IS NULL AND severity = 'problem'");
79
- if (problems) where.push("severity = 'problem'");
80
- if (scheduleId) { where.push('schedule_id = ?'); args.push(scheduleId); }
81
+ if (unread) where.push(`n.read_at IS NULL AND n.severity = 'problem'${reader ? ' AND r.read_at IS NULL' : ''}`);
82
+ if (problems) where.push("n.severity = 'problem'");
83
+ if (scheduleId) { where.push('n.schedule_id = ?'); args.push(scheduleId); }
81
84
  const lim = Math.max(1, Math.min(1000, Number(limit) || 200));
82
- return getDb().prepare(`SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT ${lim}`)
83
- .all(...args).map(rowToNotification);
85
+ const join = reader ? 'LEFT JOIN notification_reads r ON r.notification_id = n.id AND r.reader = ?' : '';
86
+ const cols = reader ? 'n.*, r.read_at AS reader_read_at' : 'n.*';
87
+ return getDb().prepare(`SELECT ${cols} FROM notifications n ${join} WHERE ${where.join(' AND ')} ORDER BY n.id DESC LIMIT ${lim}`)
88
+ .all(...(reader ? [reader] : []), ...args).map(rowToNotification);
84
89
  }
85
90
 
86
- /** Unread PROBLEM rows in a scope — the sidebar badge. */
87
- export function unreadCount(scope = 'schedule') {
91
+ /** Unread PROBLEM rows in a scope — the sidebar badge (per person when `reader` is given). */
92
+ export function unreadCount(scope = 'schedule', { reader = null } = {}) {
93
+ if (reader) {
94
+ return getDb().prepare(`SELECT COUNT(*) AS n FROM notifications n
95
+ LEFT JOIN notification_reads r ON r.notification_id = n.id AND r.reader = ?
96
+ WHERE n.scope = ? AND n.read_at IS NULL AND n.severity = 'problem' AND r.read_at IS NULL`).get(reader, scope).n;
97
+ }
88
98
  return getDb().prepare("SELECT COUNT(*) AS n FROM notifications WHERE scope = ? AND read_at IS NULL AND severity = 'problem'").get(scope).n;
89
99
  }
90
100
 
@@ -94,13 +104,24 @@ export function latestNotificationId() {
94
104
  }
95
105
 
96
106
  /** Mark one row read (or unread with `read:false`). @returns {boolean} whether a row changed */
97
- export function markRead(id, { read = true, now = new Date() } = {}) {
107
+ export function markRead(id, { read = true, now = new Date(), reader = null } = {}) {
108
+ if (reader) {
109
+ const exists = getDb().prepare('SELECT 1 FROM notifications WHERE id = ?').get(Number(id));
110
+ if (!exists) return false;
111
+ if (read) getDb().prepare('INSERT OR REPLACE INTO notification_reads (notification_id, reader, read_at) VALUES (?, ?, ?)').run(Number(id), reader, now.toISOString());
112
+ else getDb().prepare('DELETE FROM notification_reads WHERE notification_id = ? AND reader = ?').run(Number(id), reader);
113
+ return true;
114
+ }
98
115
  const info = getDb().prepare('UPDATE notifications SET read_at = ? WHERE id = ?').run(read ? now.toISOString() : null, Number(id));
99
116
  return info.changes > 0;
100
117
  }
101
118
 
102
119
  /** Mark every unread row of a scope read. @returns {number} rows changed */
103
- export function markAllRead(scope = 'schedule', { now = new Date() } = {}) {
120
+ export function markAllRead(scope = 'schedule', { now = new Date(), reader = null } = {}) {
121
+ if (reader) {
122
+ return getDb().prepare(`INSERT OR IGNORE INTO notification_reads (notification_id, reader, read_at)
123
+ SELECT id, ?, ? FROM notifications WHERE scope = ? AND read_at IS NULL`).run(reader, now.toISOString(), scope).changes;
124
+ }
104
125
  return getDb().prepare('UPDATE notifications SET read_at = ? WHERE scope = ? AND read_at IS NULL').run(now.toISOString(), scope).changes;
105
126
  }
106
127
 
@@ -124,5 +145,6 @@ export function resolveNotifications({ ticketId = null, scheduleId = null, kinds
124
145
  /** Drop rows older than the retention window. @returns {number} rows removed */
125
146
  export function purgeNotifications({ days = NOTIFICATION_RETENTION_DAYS, now = new Date() } = {}) {
126
147
  const cutoff = new Date(now.getTime() - days * 86400000).toISOString();
148
+ try { getDb().prepare('DELETE FROM notification_reads WHERE notification_id IN (SELECT id FROM notifications WHERE created_at < ?)').run(cutoff); } catch { /* a hand-seeded schema without the table */ }
127
149
  return getDb().prepare('DELETE FROM notifications WHERE created_at < ?').run(cutoff).changes;
128
150
  }
@@ -50,6 +50,7 @@ import { readAskFile } from './protocol.mjs';
50
50
  import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from './ask-forms.mjs';
51
51
  import { classifyError } from './recoverable-error.mjs';
52
52
  import { resolveFailure, markTerminal, isTerminal } from './failure-policy.mjs';
53
+ import { byActor } from './identity.mjs';
53
54
 
54
55
  /** Max ask-then-resume question rounds per execution (mirrors v1's constant). */
55
56
  const MAX_QUESTION_ROUNDS = 3;
@@ -1178,6 +1179,9 @@ export class GraphOrchestrator extends RunHarness {
1178
1179
  runners: this._runners, // P3's injection seam (runExecution reads ctx.runners)
1179
1180
  resumeSessionId: this._takeResumeSession(executionId),
1180
1181
  ask: (q) => this._enqueueAsk(() => this._ask(q)),
1182
+ // Who answered question `id` (identity.mjs actor; answer() records it) — the clarifier
1183
+ // stores it with the answer and audits it.
1184
+ answeredBy: (id) => this.answeredBy(id),
1181
1185
  onEvent: (e) => this._onAgentEvent(nc.key || node.kind, e, attr),
1182
1186
  claudeOpts: {
1183
1187
  bin: this.claude.bin,
@@ -1610,11 +1614,12 @@ export class GraphOrchestrator extends RunHarness {
1610
1614
  }));
1611
1615
  this._checkAbort();
1612
1616
  const values = (answered && typeof answered === 'object' && answered.values) || {};
1617
+ const formBy = this.answeredBy(`questions-${stepKey}-r${round}`);
1613
1618
  await writeStepQuestions(this.pipeline.id, stepKey, round, {
1614
1619
  agentKey: nc.key, nodeId: ctx.nodeId,
1615
- answers: { kind: 'form', form: formAsk.form, version: formAsk.version, values },
1620
+ answers: { kind: 'form', form: formAsk.form, version: formAsk.version, values, ...(formBy ? { answeredBy: formBy } : {}) },
1616
1621
  });
1617
- await appendAudit(this.pipeline.dir, `${agentLabel}: form "${formAsk.form}" answered (round ${round}).`).catch(() => {});
1622
+ await appendAudit(this.pipeline.dir, `${agentLabel}: form "${formAsk.form}" answered${byActor(formBy)} (round ${round}).`, { actor: formBy }).catch(() => {});
1618
1623
  await rm(qPath, { force: true }).catch(() => {});
1619
1624
  const step = this.state.steps.find((s) => s.key === stepKey);
1620
1625
  if (step?.sessionId) ctx.resumeSessionId = step.sessionId;
@@ -1643,10 +1648,11 @@ export class GraphOrchestrator extends RunHarness {
1643
1648
  const answers = normalizeClarifyAnswer(payload, questions);
1644
1649
  const byId = new Map(questions.map((q) => [q.id, q]));
1645
1650
  const enriched = answers.map((a) => ({ id: a.id, question: byId.get(a.id)?.question || '', choice: a.choice }));
1651
+ const answerBy = this.answeredBy(`questions-${stepKey}-r${round}`);
1646
1652
  await writeStepQuestions(this.pipeline.id, stepKey, round, {
1647
- agentKey: nc.key, nodeId: ctx.nodeId, answers: { answers: enriched },
1653
+ agentKey: nc.key, nodeId: ctx.nodeId, answers: { answers: enriched, ...(answerBy ? { answeredBy: answerBy } : {}) },
1648
1654
  });
1649
- await appendAudit(this.pipeline.dir, `${agentLabel}: ${enriched.length} answer(s) received (round ${round}).`).catch(() => {});
1655
+ await appendAudit(this.pipeline.dir, `${agentLabel}: ${enriched.length} answer(s) received${byActor(answerBy)} (round ${round}).`, { actor: answerBy }).catch(() => {});
1650
1656
  // Consume the processed round file: the DB row is authoritative, and a
1651
1657
  // surviving file would re-gate the user on a crash/pause-resumed re-run.
1652
1658
  await rm(qPath, { force: true }).catch(() => {});
@@ -625,6 +625,8 @@ export function runOpts(ctx, { role, prompt, systemPrompt, allowedTools }) {
625
625
  permissionRules: c.permissionRules,
626
626
  envScrub: c.envScrub,
627
627
  envAllowlist: c.envAllowlist,
628
+ // Every role and node is a pipeline agent: under WORCA_AGENT_USER when the container has one.
629
+ asAgent: true,
628
630
  bin: c.bin,
629
631
  mock: c.mock,
630
632
  signal: ctx.signal,
@@ -31,6 +31,7 @@ import {
31
31
  import { branchExists, hasGh, findPrForBranch } from './git-info.mjs';
32
32
  import { retainedWorkPatchName } from './results.mjs';
33
33
  import { deleteCommentsForRun } from './diff-comments.mjs';
34
+ import { byActor } from './identity.mjs';
34
35
 
35
36
  // Statuses for which deletion is refused (the entry is or may be live).
36
37
  const ACTIVE = new Set(['running', 'starting', 'created', 'pausing']);
@@ -275,7 +276,7 @@ export async function archivePipeline({ projectDir = null, key = null, workspace
275
276
  * preserving the pipeline row and artifact directory. Every live checkout is
276
277
  * snapshotted first; any snapshot failure aborts before removal.
277
278
  */
278
- export async function discardRetainedWorktrees({ projectDir = null, key = null, workspaceKey = null, id } = {}) {
279
+ export async function discardRetainedWorktrees({ projectDir = null, key = null, workspaceKey = null, id, by = null } = {}) {
279
280
  if (!id || typeof id !== 'string') throw err('id is required', 'BAD_REQUEST');
280
281
  const storeKey = workspaceKey
281
282
  ? `workspaces/${workspaceKey}`
@@ -427,8 +428,8 @@ export async function discardRetainedWorktrees({ projectDir = null, key = null,
427
428
  else report.warnings.push(`run-root: ${removal.reason || 'removal refused'}`);
428
429
  }
429
430
  await appendAudit(runDir,
430
- `Discarded ${report.worktrees.length} retained worktree(s) after saving recovery patch(es): ` +
431
- patches.map((p) => `\`${p}\``).join(', ')).catch(() => {});
431
+ `Discarded ${report.worktrees.length} retained worktree(s)${byActor(by)} after saving recovery patch(es): ` +
432
+ patches.map((p) => `\`${p}\``).join(', '), { actor: by }).catch(() => {});
432
433
  return report;
433
434
  }
434
435
 
@@ -16,16 +16,20 @@ import { tmpdir } from 'node:os';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { pluginsRoot, pluginDir, readPluginsLock } from './plugins-lock.mjs';
18
18
  import { normalizeManifest, findEscapingSymlinks } from './plugin-manifest.mjs';
19
+ import { githubEnv } from './github-credentials.mjs';
19
20
 
20
21
  const execFileP = promisify(execFile);
21
- const defaultExec = (cmd, args, opts = {}) =>
22
- execFileP(cmd, args, {
22
+ const defaultExec = async (cmd, args, opts = {}) => {
23
+ // The read credential for this call (a fresh App token in App mode); a failed mint leaves none.
24
+ const { env } = await githubEnv('read');
25
+ return execFileP(cmd, args, {
23
26
  maxBuffer: 16 * 1024 * 1024,
24
27
  timeout: 120_000,
25
28
  killSignal: 'SIGKILL',
26
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
29
+ env: { ...env, GIT_TERMINAL_PROMPT: '0' },
27
30
  ...opts,
28
31
  });
32
+ };
29
33
 
30
34
  /** Filesystem slug for a repo URL — shared by the bare-cache dir and marketplace
31
35
  * ids. Injective: a readable prefix plus an 8-hex digest of the EXACT input, so
@@ -38,13 +38,15 @@ export async function teamCapsForTarget({ projectDir = null, workspaceId = null
38
38
  }
39
39
 
40
40
  const usd = (n) => Number(n).toFixed(2);
41
+ /** A person (identity.mjs actor) worth recording: not empty, not 'local'. */
42
+ const personOf = (by) => (typeof by === 'string' && by.trim() && by !== 'local' ? by.trim() : null);
41
43
 
42
44
  /**
43
45
  * The team TOTAL cap at a run entry. `pastTeamCap` records the once-per-window acknowledgement
44
46
  * (design §7) and lets the run through; a cap with `requireReason` refuses an empty reason.
45
47
  * @returns {Promise<{blocked:false, caps:object, ack?:object} | {blocked:true, caps:object, error:string, code:'team_total'|'reason_required', policy:object}>}
46
48
  */
47
- export async function checkTeamTotalGate(target, { pastTeamCap = false, reason = null, unattended = false, now = new Date() } = {}) {
49
+ export async function checkTeamTotalGate(target, { pastTeamCap = false, reason = null, unattended = false, now = new Date(), by = null } = {}) {
48
50
  const caps = await teamCapsForTarget(target);
49
51
  const { total, policy, period } = caps;
50
52
  if (!policy || total.binding !== 'team' || total.cap == null) return { blocked: false, caps };
@@ -60,7 +62,7 @@ export async function checkTeamTotalGate(target, { pastTeamCap = false, reason =
60
62
  if (pastTeamCap) {
61
63
  const clean = cleanReason(reason);
62
64
  if (team.requireReason && !clean) return { blocked: true, caps, code: 'reason_required', error: `the team policy on ${policy.home} requires a reason to continue past its total cap`, policy: detail };
63
- const ack = setTotalAck(ackKey, policy.home, windowStartMs, { reason: clean });
65
+ const ack = setTotalAck(ackKey, policy.home, windowStartMs, { reason: clean, ...(personOf(by) ? { by: personOf(by) } : {}) });
64
66
  return { blocked: false, caps, ack };
65
67
  }
66
68
  return { blocked: true, caps, code: 'team_total', error: `team total cap reached ($${usd(spent)} >= $${usd(total.cap)} this ${w}, ${policy.home})`, policy: detail };
@@ -71,7 +73,7 @@ export async function checkTeamTotalGate(target, { pastTeamCap = false, reason =
71
73
  * the run, design §7) and lets the resume through.
72
74
  * @returns {{blocked:false} | {blocked:true, error:string, code:'team_pipeline'|'reason_required', policy:object}}
73
75
  */
74
- export function checkTeamPipelineGate(caps, { pipelineId, spentSoFar = 0, pastTeamCap = false, reason = null, unattended = false } = {}) {
76
+ export function checkTeamPipelineGate(caps, { pipelineId, spentSoFar = 0, pastTeamCap = false, reason = null, unattended = false, by = null } = {}) {
75
77
  const { pipeline, policy } = caps;
76
78
  // The team SOFT cap is checked whether or not it is the tighter number (the local override
77
79
  // never bypasses it — run-harness._checkCostLimits applies the same rule at every boundary).
@@ -84,8 +86,10 @@ export function checkTeamPipelineGate(caps, { pipelineId, spentSoFar = 0, pastTe
84
86
  if (pastTeamCap) {
85
87
  const clean = cleanReason(reason);
86
88
  if (team.requireReason && !clean) return { blocked: true, code: 'reason_required', error: `the team policy on ${policy.home} requires a reason to continue past its cost cap`, policy: detail };
87
- writePolicyState(pipelineId, { home: policy.home, sha: policy.sha, overrides: ['pipeline'], ...(clean ? { reason: clean } : {}) });
88
- return { blocked: false, overridden: true };
89
+ // Who chose to continue, next to why (identity.mjs); absent for 'local' / unknown.
90
+ writePolicyState(pipelineId, { home: policy.home, sha: policy.sha, overrides: ['pipeline'], ...(clean ? { reason: clean } : {}), ...(personOf(by) ? { overriddenBy: personOf(by) } : {}) });
91
+ // `fresh`: this request armed the override (the caller audits who and why).
92
+ return { blocked: false, overridden: true, fresh: true, reason: clean || null };
89
93
  }
90
94
  return { blocked: true, code: 'team_pipeline', error: `team cost cap reached ($${usd(spentSoFar)} >= $${usd(team.value)}, ${policy.home})`, policy: detail };
91
95
  }
@@ -66,14 +66,14 @@ export function readTotalAck(projectKey, home, windowStartMs) {
66
66
  }
67
67
 
68
68
  /** Record the acknowledgement; older windows are swept so the blob never grows. */
69
- export function setTotalAck(projectKey, home, windowStartMs, { reason = null } = {}) {
69
+ export function setTotalAck(projectKey, home, windowStartMs, { reason = null, by = null } = {}) {
70
70
  const prefs = readTeamPolicyPrefs(projectKey);
71
71
  const acks = {};
72
72
  for (const [k, v] of Object.entries(prefs?.acks && typeof prefs.acks === 'object' ? prefs.acks : {})) {
73
73
  const ms = Number(k.split('|')[1]);
74
74
  if (Number.isFinite(ms) && ms >= windowStartMs) acks[k] = v;
75
75
  }
76
- acks[ackKey(home, windowStartMs)] = { at: new Date().toISOString(), reason: cleanReason(reason) };
76
+ acks[ackKey(home, windowStartMs)] = { at: new Date().toISOString(), reason: cleanReason(reason), ...(by ? { by } : {}) };
77
77
  writeTeamPolicyPrefs(projectKey, { acks });
78
78
  return acks[ackKey(home, windowStartMs)];
79
79
  }
@@ -276,12 +276,13 @@ function sameContent(a, b) {
276
276
  * mode 'follow' → create a marker { delegateTo }; or join; with change:true rewrite an existing marker.
277
277
  * @returns {Promise<{action:'created'|'joined'|'changed', slug:string}>}
278
278
  */
279
- export async function enableTeamPolicy(projectDir, { mode = 'here', delegateTo = null, change = false, title = '', now = new Date() } = {}) {
279
+ export async function enableTeamPolicy(projectDir, { mode = 'here', delegateTo = null, change = false, title = '', now = new Date(), by = null } = {}) {
280
280
  if (mode !== 'here' && mode !== 'follow') throw policyError('BAD_REQUEST', 'mode must be "here" or "follow"');
281
281
  const { slug, hasOrigin } = await projectSlug(projectDir);
282
282
  if (!hasOrigin) throw policyError('NO_ORIGIN', 'this project has no origin remote — a team policy lives on origin, so there is nowhere to put it');
283
283
  const target = mode === 'follow' ? (await validateFollowTarget(delegateTo, slug)).slug : null;
284
- const user = await gitUserName(projectDir);
284
+ // The person who asked (identity.mjs) when known; else, as before, the checkout's git user.
285
+ const user = (typeof by === 'string' && by.trim() && by !== 'local' ? by.trim() : null) ?? await gitUserName(projectDir);
285
286
  const doc = mode === 'follow'
286
287
  ? { schema: POLICY_SCHEMA, enabledAt: stampNow(now), enabledBy: user, delegateTo: target }
287
288
  : emptyPolicyDoc({ updatedBy: user, title: title || `${slug} team policy`, now });
@@ -337,7 +338,7 @@ async function rewriteMarker(slug, projectDir, doc) {
337
338
  * is a caller bug and answers BAD_REQUEST with the list. A rejection is thrown VERBATIM.
338
339
  * @returns {Promise<{ok:true, slug:string, sha:string|null, doc:object}>}
339
340
  */
340
- export async function publishPolicy(projectDir, rawDoc, { message = null, now = new Date() } = {}) {
341
+ export async function publishPolicy(projectDir, rawDoc, { message = null, now = new Date(), by = null } = {}) {
341
342
  const prefs = (await discoverPolicy(projectDir, { force: true }).catch(() => null)) || readTeamPolicyPrefs(projectKey(projectDir)) || {};
342
343
  if (!prefs.present) throw policyError('NOT_HOME', `this project has no ${POLICY_BRANCH} branch`);
343
344
  if (!prefs.docKnown) throw policyError('FETCH_FAILED', `could not read the ${POLICY_BRANCH} branch; try again`);
@@ -347,7 +348,7 @@ export async function publishPolicy(projectDir, rawDoc, { message = null, now =
347
348
  if (warnings.length) throw policyError('BAD_REQUEST', `the document has ${warnings.length} problem(s): ${warnings[0]}`, { warnings });
348
349
  const slug = prefs.slug;
349
350
  doc.updatedAt = stampNow(now);
350
- doc.updatedBy = await gitUserName(projectDir);
351
+ doc.updatedBy = (typeof by === 'string' && by.trim() && by !== 'local' ? by.trim() : null) ?? await gitUserName(projectDir);
351
352
  delete doc.delegateTo;
352
353
  const body = serializePolicyDoc(doc);
353
354
  const commitMsg = message && String(message).trim() ? `policy: ${String(message).trim().slice(0, 120)}` : 'policy: update team policy';
@@ -0,0 +1,138 @@
1
+ // Remote access behind an identity proxy (docs/remote-access.md).
2
+ //
3
+ // Worca is a single-user localhost tool (ui/server.mjs, S1): it accepts only
4
+ // requests whose Host (and browser Origin) is a loopback name. Behind a proxy
5
+ // the browser sends the public hostname, so a hosted deployment opts in with:
6
+ //
7
+ // WORCA_ALLOWED_HOSTS hostnames accepted besides loopback, comma-
8
+ // separated; ".example.com" matches subdomains
9
+ // WORCA_CF_ACCESS_TEAM_DOMAIN + WORCA_CF_ACCESS_AUD: every request must carry
10
+ // a valid Cloudflare Access token for that app
11
+ // WORCA_INSECURE_NO_IDENTITY_CHECK=1 run with an allowlist but no token check
12
+ // (the proxy alone is trusted); never the default
13
+ //
14
+ // Fail closed: an allowlist without an identity check is a startup error unless
15
+ // the insecure flag says so explicitly. With none of these set, nothing changes.
16
+ import { createAccessVerifier, normalizeTeamDomain } from './cf-access.mjs';
17
+
18
+ export const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
19
+ const LOOPBACK_ADDRS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
20
+ const LOOPBACK_BINDS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
21
+ const HOST_ENTRY_RE = /^\.?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
22
+
23
+ /** Hostname (no port) from a Host header value or full Origin URL, or null. */
24
+ export function hostnameOf(value) {
25
+ if (!value) return null;
26
+ try {
27
+ return new URL(value.includes('://') ? value : `http://${value}`).hostname;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /** "a.example.com, .example.org" -> { hosts: [...], invalid: [...] } (lowercased, deduped). */
34
+ export function parseAllowedHosts(value) {
35
+ const hosts = [];
36
+ const invalid = [];
37
+ for (const raw of String(value || '').split(',')) {
38
+ const h = raw.trim().toLowerCase();
39
+ if (!h) continue;
40
+ if (!HOST_ENTRY_RE.test(h) || h === '.') invalid.push(raw.trim());
41
+ else if (!hosts.includes(h)) hosts.push(h);
42
+ }
43
+ return { hosts, invalid };
44
+ }
45
+
46
+ const truthy = (v) => /^(1|true|yes|on)$/i.test(String(v || '').trim());
47
+
48
+ /** The remote-access settings from the environment. Pure: reads only `env`. */
49
+ export function readRemoteAccessConfig(env = process.env) {
50
+ const { hosts, invalid } = parseAllowedHosts(env.WORCA_ALLOWED_HOSTS);
51
+ const teamDomain = normalizeTeamDomain(env.WORCA_CF_ACCESS_TEAM_DOMAIN);
52
+ const aud = String(env.WORCA_CF_ACCESS_AUD || '').trim();
53
+ return {
54
+ allowedHosts: hosts,
55
+ invalidHosts: invalid,
56
+ identity: teamDomain || aud ? { provider: 'cloudflare-access', teamDomain, aud } : null,
57
+ insecureNoIdentity: truthy(env.WORCA_INSECURE_NO_IDENTITY_CHECK),
58
+ };
59
+ }
60
+
61
+ /** True when the server answers to any non-loopback hostname. */
62
+ export function isRemoteMode(cfg) {
63
+ return cfg.allowedHosts.some((h) => !LOCAL_HOSTNAMES.has(h));
64
+ }
65
+
66
+ /**
67
+ * Validate a config against the bind address. Errors stop the server from
68
+ * starting; warnings are logged. Both are one-line, actionable messages.
69
+ */
70
+ export function checkRemoteAccessConfig(cfg, { bindHost = '127.0.0.1' } = {}) {
71
+ const errors = [];
72
+ const warnings = [];
73
+ if (cfg.invalidHosts.length) {
74
+ errors.push(`WORCA_ALLOWED_HOSTS has invalid entries: ${cfg.invalidHosts.join(', ')} (use hostnames like worca-01.example.com or .example.com, no scheme or port)`);
75
+ }
76
+ if (cfg.identity) {
77
+ if (!cfg.identity.teamDomain) errors.push('WORCA_CF_ACCESS_AUD is set but WORCA_CF_ACCESS_TEAM_DOMAIN is not');
78
+ if (!cfg.identity.aud) errors.push('WORCA_CF_ACCESS_TEAM_DOMAIN is set but WORCA_CF_ACCESS_AUD is not');
79
+ }
80
+ const remote = isRemoteMode(cfg);
81
+ if (remote && !cfg.identity && !cfg.insecureNoIdentity) {
82
+ errors.push('WORCA_ALLOWED_HOSTS is set but no identity check is configured: set WORCA_CF_ACCESS_TEAM_DOMAIN and WORCA_CF_ACCESS_AUD, or set WORCA_INSECURE_NO_IDENTITY_CHECK=1 if a proxy you trust is the only way in');
83
+ }
84
+ if (remote && !cfg.identity && cfg.insecureNoIdentity) {
85
+ warnings.push('WORCA_INSECURE_NO_IDENTITY_CHECK=1: worca checks no identity; anyone who reaches it controls this machine');
86
+ }
87
+ if (cfg.identity && cfg.insecureNoIdentity) {
88
+ warnings.push('WORCA_INSECURE_NO_IDENTITY_CHECK is ignored: a Cloudflare Access check is configured and enforced');
89
+ }
90
+ if (cfg.identity && !remote) {
91
+ warnings.push('WORCA_CF_ACCESS_* is set but WORCA_ALLOWED_HOSTS is empty: only localhost requests are accepted');
92
+ }
93
+ if (!LOOPBACK_BINDS.has(String(bindHost).toLowerCase()) && !remote) {
94
+ warnings.push(`listening on ${bindHost} but WORCA_ALLOWED_HOSTS is empty: every request not addressed to localhost gets a 403`);
95
+ }
96
+ return { errors, warnings };
97
+ }
98
+
99
+ /** (req) => true when Host and (if present) Origin are loopback or allowlisted. */
100
+ export function createHostGuard(allowedHosts = []) {
101
+ const allowed = (name) => {
102
+ if (!name) return false;
103
+ const h = name.toLowerCase();
104
+ if (LOCAL_HOSTNAMES.has(h)) return true;
105
+ return allowedHosts.some((a) => (a.startsWith('.') ? h.endsWith(a) : h === a));
106
+ };
107
+ return function isAllowedRequest(req) {
108
+ if (!allowed(hostnameOf(req.headers.host))) return false;
109
+ const origin = req.headers.origin;
110
+ if (origin && !allowed(hostnameOf(origin))) return false;
111
+ return true;
112
+ };
113
+ }
114
+
115
+ /**
116
+ * A connection from inside this machine/container (`worca ui stop`, the CLI,
117
+ * curl in a shell): loopback TCP peer AND loopback Host. A proxy on another
118
+ * host (cloudflared as its own service) never matches, so it is always checked.
119
+ */
120
+ export function isInContainer(req) {
121
+ return LOOPBACK_ADDRS.has(req.socket?.remoteAddress)
122
+ && LOCAL_HOSTNAMES.has(hostnameOf(req.headers.host));
123
+ }
124
+
125
+ /**
126
+ * The identity check for this config, or null when none applies (local mode,
127
+ * or the explicit insecure flag). Provider-agnostic: returns
128
+ * `(req) => Promise<{ email, sub } | null>`; rejects only when the provider
129
+ * cannot be reached (the caller answers 503).
130
+ */
131
+ export function createIdentityCheck(cfg, { fetchImpl, now } = {}) {
132
+ if (!cfg.identity) return null;
133
+ if (cfg.identity.provider === 'cloudflare-access') {
134
+ const verify = createAccessVerifier({ teamDomain: cfg.identity.teamDomain, aud: cfg.identity.aud, fetchImpl, now });
135
+ return (req) => verify(req.headers['cf-access-jwt-assertion']);
136
+ }
137
+ throw new Error(`unknown identity provider: ${cfg.identity.provider}`);
138
+ }