@worca/app 1.4.0 → 1.5.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/docker/.env.example +54 -0
- package/docker/compose.clonein.yml +24 -0
- package/docker/compose.dev.yml +24 -0
- package/docker/compose.egress.yml +47 -0
- package/docker/compose.ssh.yml +17 -0
- package/docker/compose.teams.yml +25 -0
- package/docker/compose.yml +57 -0
- package/package.json +4 -1
- package/src/cli/container.mjs +227 -0
- package/src/cli/models.mjs +5 -4
- package/src/cli/schedule.mjs +23 -10
- package/src/cli/worca-cc.mjs +62 -4
- package/src/core/agent-user.mjs +92 -0
- package/src/core/artifacts.mjs +41 -9
- package/src/core/ask/clone-deps.mjs +25 -0
- package/src/core/ask/clone-proposal.mjs +79 -0
- package/src/core/ask/events.mjs +10 -0
- package/src/core/ask/mcp-stdio.mjs +4 -2
- package/src/core/ask/model-proposal.mjs +14 -3
- package/src/core/ask/prompt.mjs +27 -6
- package/src/core/ask/schedule-deps.mjs +16 -11
- package/src/core/ask/spawn.mjs +8 -3
- package/src/core/ask/store.mjs +40 -10
- package/src/core/ask/tool-deps.mjs +36 -1
- package/src/core/ask/tools.mjs +69 -8
- package/src/core/ask/turn.mjs +31 -1
- package/src/core/bridge/errors.mjs +50 -2
- package/src/core/bridge/provider-ops.mjs +20 -7
- package/src/core/bridge/providers/copilot.mjs +51 -5
- package/src/core/bridge/translate/common.mjs +144 -0
- package/src/core/bridge/translate/request.mjs +16 -73
- package/src/core/bridge/translate/responses-request.mjs +185 -0
- package/src/core/bridge/translate/responses-stream.mjs +327 -0
- package/src/core/bridge/upstream.mjs +37 -10
- package/src/core/cf-access.mjs +90 -0
- package/src/core/chat/command-router.mjs +17 -12
- package/src/core/claude-runner.mjs +26 -3
- package/src/core/clone-project.mjs +139 -0
- package/src/core/config.mjs +16 -9
- package/src/core/db.mjs +60 -5
- package/src/core/deployment.mjs +33 -0
- package/src/core/diff-comments.mjs +17 -8
- package/src/core/git-info.mjs +27 -7
- package/src/core/github-app.mjs +107 -0
- package/src/core/github-credentials.mjs +93 -0
- package/src/core/graph/executor.mjs +22 -4
- package/src/core/graph/script-runner.mjs +24 -3
- package/src/core/guardrails.mjs +1 -0
- package/src/core/identity.mjs +83 -0
- package/src/core/metrics/pr-events-workflow.yml +127 -0
- package/src/core/metrics/prs.mjs +383 -0
- package/src/core/metrics/read.mjs +39 -29
- package/src/core/metrics/record.mjs +30 -2
- package/src/core/metrics/sync.mjs +35 -4
- package/src/core/model-env.mjs +47 -7
- package/src/core/model-test.mjs +29 -5
- package/src/core/notifications.mjs +36 -14
- package/src/core/orchestrator.mjs +10 -4
- package/src/core/phases.mjs +2 -0
- package/src/core/pipeline-delete.mjs +4 -3
- package/src/core/plugin-repo.mjs +7 -3
- package/src/core/policy/gate.mjs +9 -5
- package/src/core/policy/state.mjs +2 -2
- package/src/core/policy/sync.mjs +5 -4
- package/src/core/remote-access.mjs +138 -0
- package/src/core/run-harness.mjs +71 -9
- package/src/core/scheduler.mjs +63 -33
- package/src/shared/team-metrics/timeline.mjs +330 -0
- package/ui/public/app.js +591 -14
- package/ui/public/ask-panel.mjs +65 -3
- package/ui/public/bridge-view.mjs +72 -14
- package/ui/public/index.html +42 -9
- package/ui/public/schedules-view.mjs +21 -2
- package/ui/public/session-guard.mjs +77 -0
- package/ui/public/style.css +171 -1
- package/ui/public/team-metrics-timeline.mjs +480 -0
- package/ui/public/team-metrics-view.mjs +10 -2
- package/ui/public/team-policy-view.mjs +6 -1
- package/ui/server.mjs +454 -104
package/src/core/model-env.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
467
|
+
return upstream && isTranslatedApi(upstream.api) ? ['WebSearch', 'WebFetch'] : [];
|
|
428
468
|
}
|
|
429
469
|
|
|
430
470
|
// ── providers: account-level state shared by bridged entries (§6.2) ─────────
|
package/src/core/model-test.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
83
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
83
|
-
|
|
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})
|
|
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})
|
|
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(() => {});
|
package/src/core/phases.mjs
CHANGED
|
@@ -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
|
|
package/src/core/plugin-repo.mjs
CHANGED
|
@@ -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
|
-
|
|
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: { ...
|
|
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
|
package/src/core/policy/gate.mjs
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
}
|
package/src/core/policy/sync.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|