@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.
- package/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
package/ui/server.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
|
|
45
45
|
humanRateUsdPerHour, setHumanRateUsdPerHour, assertHumanRateInput,
|
|
46
46
|
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
47
|
+
askWeb, assertAskWebInput, setAskWeb, addAskWebHost,
|
|
47
48
|
chatPrefs, setChatPrefs,
|
|
48
49
|
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
49
50
|
titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
} from '../src/core/ask/store.mjs';
|
|
75
76
|
import { sanitizeTitle as askSanitizeTitle } from '../src/core/title.mjs';
|
|
76
77
|
import { ASK_LIMITS } from '../src/core/ask/limits.mjs';
|
|
78
|
+
import { askWebAccess, WEB_OFF } from '../src/core/ask/web-access.mjs';
|
|
77
79
|
import { askCatalog, validateModelEffort } from '../src/core/ask/models.mjs';
|
|
78
80
|
import { buildCatalog as askBuildCatalog } from '../src/core/ask/catalog.mjs';
|
|
79
81
|
import {
|
|
@@ -121,6 +123,14 @@ import {
|
|
|
121
123
|
} from '../src/core/remote-access.mjs';
|
|
122
124
|
import { detectDeployment, deploymentFacts } from '../src/core/deployment.mjs';
|
|
123
125
|
import { resolveIdentity, startedByOf, prAttributionFooter, actorOf, isSharedIdentity, byActor } from '../src/core/identity.mjs';
|
|
126
|
+
import { withBillTo, currentBillTo, currentOwner } from '../src/core/billing.mjs';
|
|
127
|
+
import { agentIdentity } from '../src/core/agent-user.mjs';
|
|
128
|
+
import { createAskToolServer } from '../src/core/ask/mcp-stdio.mjs';
|
|
129
|
+
import { webMcpEnv as askWebMcpEnv } from '../src/core/ask/spawn.mjs';
|
|
130
|
+
import { brokerEnabled, brokerInfo, personSlots, brokerUsageSummary, foldUsageByPerson } from '../src/core/broker-client.mjs';
|
|
131
|
+
import { freeDailyStatus } from '../src/core/openrouter-free.mjs';
|
|
132
|
+
import { checkBrokerAtBoot } from '../src/core/broker-boot.mjs';
|
|
133
|
+
import { modelSlot, missingCredentials, describeMissing } from '../src/core/broker-routing.mjs';
|
|
124
134
|
import { planClone, cloneProject, CloneError } from '../src/core/clone-project.mjs';
|
|
125
135
|
import { listFolders } from '../src/core/fs-browse.mjs';
|
|
126
136
|
import {
|
|
@@ -165,6 +175,8 @@ import { scheduleEventPrompt, scheduleNoticeText } from '../src/core/ask/schedul
|
|
|
165
175
|
import { applyModelChange } from '../src/core/ask/model-deps.mjs';
|
|
166
176
|
import { modelEventPrompt, modelNoticeText } from '../src/core/ask/model-proposal.mjs';
|
|
167
177
|
import { cloneEventPrompt, cloneNoticeText } from '../src/core/ask/clone-proposal.mjs';
|
|
178
|
+
import { webEventPrompt, webNoticeText, chatWebHosts } from '../src/core/ask/web-proposal.mjs';
|
|
179
|
+
import { hostAllowed as askHostAllowed } from '../src/core/web-allowlist.mjs';
|
|
168
180
|
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
169
181
|
import { sweepV1Runs, V1_RUN_RETIRED, getDb } from '../src/core/db.mjs';
|
|
170
182
|
import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
|
|
@@ -194,8 +206,10 @@ import {
|
|
|
194
206
|
} from '../src/core/memory-store.mjs';
|
|
195
207
|
import { memoryCaps } from '../src/core/settings.mjs';
|
|
196
208
|
import { onboardingPrefs, setOnboardingPrefs } from '../src/core/settings.mjs';
|
|
197
|
-
import { onboardingStatus } from '../src/core/onboarding.mjs'; // a THIRD settings import line (the two blocks above are unrelated readers)
|
|
209
|
+
import { configuredClaudeBin, onboardingStatus } from '../src/core/onboarding.mjs'; // a THIRD settings import line (the two blocks above are unrelated readers)
|
|
198
210
|
import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
|
|
211
|
+
import { probeClaudeAuth, CLAUDE_SIGNED_OUT_CODE, CLAUDE_SIGNED_OUT_MESSAGE } from '../src/core/preflight.mjs';
|
|
212
|
+
import { failedBecauseSignedOut } from '../src/core/claude-auth.mjs';
|
|
199
213
|
import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
200
214
|
import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
|
|
201
215
|
import {
|
|
@@ -459,6 +473,32 @@ wss.on('error', () => {});
|
|
|
459
473
|
/** All currently connected sockets. */
|
|
460
474
|
const sockets = new Set();
|
|
461
475
|
|
|
476
|
+
/** This process's id, sent in `hello`: a client that sees it change knows event seqs restarted. */
|
|
477
|
+
const BOOT_ID = randomUUID();
|
|
478
|
+
|
|
479
|
+
// Heartbeat. A proxy in front of worca (Cloudflare closes an idle WebSocket after ~100 s)
|
|
480
|
+
// drops a socket that carries no frames — a run parked on an open Auto proposal is silent
|
|
481
|
+
// for minutes — and every reconnect re-subscribes and replays. A ping every 30 s keeps the
|
|
482
|
+
// socket busy; one that has not answered the previous ping is dead and is terminated.
|
|
483
|
+
const HEARTBEAT_MS = 30_000;
|
|
484
|
+
function trackHeartbeat(ws) {
|
|
485
|
+
ws.isAlive = true;
|
|
486
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
487
|
+
}
|
|
488
|
+
function heartbeatTick(set = sockets) {
|
|
489
|
+
for (const ws of set) {
|
|
490
|
+
if (ws.isAlive === false) {
|
|
491
|
+
try { ws.terminate(); } catch { /* already gone */ }
|
|
492
|
+
set.delete(ws);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
ws.isAlive = false;
|
|
496
|
+
try { ws.ping(); } catch { /* closing */ }
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const heartbeat = setInterval(() => heartbeatTick(), HEARTBEAT_MS);
|
|
500
|
+
heartbeat.unref();
|
|
501
|
+
|
|
462
502
|
// server.close() only calls back once every connection is gone, and Node's
|
|
463
503
|
// closeAllConnections() skips UPGRADED sockets — a WebSocket whose close
|
|
464
504
|
// handshake has not completed (a client that vanished, or a test tearing down
|
|
@@ -481,6 +521,7 @@ wss.on('connection', (ws, req) => {
|
|
|
481
521
|
return;
|
|
482
522
|
}
|
|
483
523
|
sockets.add(ws);
|
|
524
|
+
trackHeartbeat(ws);
|
|
484
525
|
// Whose Ask threads this socket may see (a shared sign-in's name, else null = all).
|
|
485
526
|
ws.worcaViewer = askViewer(req);
|
|
486
527
|
// Optional ?runId=... (or ?scanId=.../?genId=...) -> replay that entry's buffered
|
|
@@ -508,7 +549,7 @@ wss.on('connection', (ws, req) => {
|
|
|
508
549
|
}
|
|
509
550
|
const id = requestedRunId || requestedScanId || requestedGenId || requestedBenchId;
|
|
510
551
|
|
|
511
|
-
send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello(ws) });
|
|
552
|
+
send(ws, { type: 'hello', bootId: BOOT_ID, runs: summarizeRuns(), ask: askHello(ws) });
|
|
512
553
|
|
|
513
554
|
if (id && runs.has(id)) {
|
|
514
555
|
replayEntry(ws, runs.get(id));
|
|
@@ -661,8 +702,12 @@ function emitDiffCommentsChanged(runId) {
|
|
|
661
702
|
// Append a tagged event to an entry's ring buffer (runId LAST so the runs-Map key
|
|
662
703
|
// always wins over any id the orchestrator stamped). Shared by the live wire
|
|
663
704
|
// (record) and out-of-band resolutions (resolvePending) so both honor MAX_BUFFER.
|
|
705
|
+
// `seq` numbers the entry's events so a client that reconnects (and is replayed the whole
|
|
706
|
+
// buffer) skips what it already applied (ui/public/ws-seq.mjs); BOOT_ID in `hello` tells it
|
|
707
|
+
// when a restarted server numbers from 1 again.
|
|
664
708
|
function bufferEvent(entry, event) {
|
|
665
|
-
|
|
709
|
+
entry.seq = (entry.seq || 0) + 1;
|
|
710
|
+
const tagged = { ...event, runId: entry.id, seq: entry.seq };
|
|
666
711
|
entry.events.push(tagged);
|
|
667
712
|
if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
|
|
668
713
|
return tagged;
|
|
@@ -1156,6 +1201,11 @@ app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
|
|
|
1156
1201
|
app.put('/api/scripts/:key/cases', express.json({ limit: '64mb' }));
|
|
1157
1202
|
app.use(express.json({ limit: '8mb' }));
|
|
1158
1203
|
|
|
1204
|
+
// Credential broker billing (src/core/billing.mjs): every claude spawn a request causes —
|
|
1205
|
+
// a run it starts, an Ask turn, a model Test — is billed to the person behind the request.
|
|
1206
|
+
// AFTER the body parsers: their stream callbacks would otherwise run outside this context.
|
|
1207
|
+
app.use((req, _res, next) => withBillTo(resolveIdentity(req).name, next));
|
|
1208
|
+
|
|
1159
1209
|
if (HLJS_ASSETS) {
|
|
1160
1210
|
const sendHljsModule = (file) => (_req, res, next) => {
|
|
1161
1211
|
res.type('text/javascript');
|
|
@@ -1560,6 +1610,14 @@ const startRunHandler = async (req, res) => {
|
|
|
1560
1610
|
return badRequest(res, 'workspaceId or projectDir is required');
|
|
1561
1611
|
}
|
|
1562
1612
|
|
|
1613
|
+
// Credential broker: the run's own model, when the request names one, is checked here
|
|
1614
|
+
// (an instant refusal); the harness then checks every node's model before it spawns
|
|
1615
|
+
// anything (run-harness.mjs#_brokerPreflight).
|
|
1616
|
+
if (!internal && body.mock !== true && !mockEnabled({}) && typeof body.model === 'string' && body.model.trim()) {
|
|
1617
|
+
const refusal = await brokerStartRefusal(req, [body.model.trim()]);
|
|
1618
|
+
if (refusal) return res.status(409).json({ error: refusal, code: 'credential-missing' });
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1563
1621
|
// Ask Worca card link (§8.1): both or neither; the thread must exist and
|
|
1564
1622
|
// the card must still be `proposed` BEFORE any run state is created.
|
|
1565
1623
|
const hasAskThread = body.askThreadId !== undefined && body.askThreadId !== null;
|
|
@@ -3219,7 +3277,9 @@ app.get('/api/runs/:id/recovery-patch', async (req, res) => {
|
|
|
3219
3277
|
// ---------------------------------------------------------------------------
|
|
3220
3278
|
// POST /api/runs/:id/overview -> Layer-2 on-demand overview agent.
|
|
3221
3279
|
// Accepts ?key=<storeKey> (preferred; history detail uses it) or ?projectDir=...
|
|
3222
|
-
// ?force=1 bypasses the cached overview.json. 200 { overview } | 404 | 500
|
|
3280
|
+
// ?force=1 bypasses the cached overview.json. 200 { overview } | 404 | 500, or
|
|
3281
|
+
// 409 code 'claude-signed-out' when the CLI is signed out (a cached overview
|
|
3282
|
+
// needs no Claude, so this maps the failure instead of probing up front).
|
|
3223
3283
|
// ---------------------------------------------------------------------------
|
|
3224
3284
|
app.post('/api/runs/:id/overview', async (req, res) => {
|
|
3225
3285
|
const id = req.params.id;
|
|
@@ -3235,6 +3295,9 @@ app.post('/api/runs/:id/overview', async (req, res) => {
|
|
|
3235
3295
|
res.json({ overview });
|
|
3236
3296
|
} catch (err) {
|
|
3237
3297
|
const msg = err && err.message ? err.message : String(err);
|
|
3298
|
+
if (msg !== 'pipeline not found' && await failedBecauseSignedOut({ message: msg })) {
|
|
3299
|
+
return res.status(409).json({ code: CLAUDE_SIGNED_OUT_CODE, error: CLAUDE_SIGNED_OUT_MESSAGE });
|
|
3300
|
+
}
|
|
3238
3301
|
const code = msg === 'pipeline not found' ? 404 : 500;
|
|
3239
3302
|
res.status(code).json({ error: msg });
|
|
3240
3303
|
}
|
|
@@ -3268,8 +3331,9 @@ app.get('/api/history', async (_req, res) => {
|
|
|
3268
3331
|
// POST writes ONLY those flags ({hidden?, welcomeSeen?}; booleans; unknown keys
|
|
3269
3332
|
// 400) and answers with the same full payload, so one round trip repaints.
|
|
3270
3333
|
// ---------------------------------------------------------------------------
|
|
3271
|
-
|
|
3272
|
-
|
|
3334
|
+
// ?recheck=1 skips the remembered Claude sign-in answer (the dialog's Check again).
|
|
3335
|
+
app.get('/api/onboarding', async (req, res) => {
|
|
3336
|
+
try { res.json(await onboardingStatus({ recheck: isTruthy(req.query.recheck) })); }
|
|
3273
3337
|
catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err) }); }
|
|
3274
3338
|
});
|
|
3275
3339
|
app.post('/api/onboarding', async (req, res) => {
|
|
@@ -3300,16 +3364,34 @@ app.get('/api/counts', (req, res) => {
|
|
|
3300
3364
|
// GET /api/stats?range=today|week|month|all -> the Statistics view payload (§6.9).
|
|
3301
3365
|
// Pure DB reads; an unknown range is the caller's fault, so getStats' RangeError
|
|
3302
3366
|
// maps to 400 while anything else keeps bubbling to the error handler.
|
|
3303
|
-
app.get('/api/stats', (req, res) => {
|
|
3367
|
+
app.get('/api/stats', async (req, res) => {
|
|
3368
|
+
let stats;
|
|
3304
3369
|
try {
|
|
3305
3370
|
const range = typeof req.query.range === 'string' && req.query.range ? req.query.range : 'month';
|
|
3306
|
-
|
|
3371
|
+
stats = getStats({ range });
|
|
3307
3372
|
} catch (err) {
|
|
3308
3373
|
if (err instanceof RangeError) return badRequest(res, err.message);
|
|
3309
3374
|
throw err;
|
|
3310
3375
|
}
|
|
3376
|
+
// Credential broker: model spend per person in the same window, as the broker metered it
|
|
3377
|
+
// (the authoritative figure: it saw every request). Absent with the broker off.
|
|
3378
|
+
if (brokerEnabled()) stats.byPerson = await statsByPerson(stats.windowStartMs, stats.windowEndMs);
|
|
3379
|
+
res.json(stats);
|
|
3311
3380
|
});
|
|
3312
3381
|
|
|
3382
|
+
/** Broker usage in [startMs, endMs) folded per person: {people:[…]} or {error}. */
|
|
3383
|
+
async function statsByPerson(startMs, endMs) {
|
|
3384
|
+
try {
|
|
3385
|
+
const { rows } = await brokerUsageSummary({
|
|
3386
|
+
since: Number.isFinite(startMs) ? new Date(startMs).toISOString() : undefined,
|
|
3387
|
+
until: Number.isFinite(endMs) ? new Date(endMs).toISOString() : undefined,
|
|
3388
|
+
});
|
|
3389
|
+
return { people: foldUsageByPerson(rows) };
|
|
3390
|
+
} catch (err) {
|
|
3391
|
+
return { error: err && err.message ? err.message : String(err) };
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3313
3395
|
// ---- Team metrics (team-metrics-design.md §4.6–§4.10) ----------------------------------
|
|
3314
3396
|
function metricsErrorStatus(code) {
|
|
3315
3397
|
switch (code) {
|
|
@@ -4716,6 +4798,20 @@ function startScan({ projectPaths, name, workspaceId }) {
|
|
|
4716
4798
|
return scanId;
|
|
4717
4799
|
}
|
|
4718
4800
|
|
|
4801
|
+
/**
|
|
4802
|
+
* Refuse a detached Claude job (workspace scan, agent generation) up front when
|
|
4803
|
+
* the CLI is signed out (preflight probeClaudeAuth): otherwise it starts and its
|
|
4804
|
+
* first agent dies ~30 s in with "Not logged in". Only a definite 'signed-out'
|
|
4805
|
+
* refuses — mock, an auth env var, or an unknown answer (an older CLI) all pass.
|
|
4806
|
+
* Sends the 409 and returns true when it refused.
|
|
4807
|
+
*/
|
|
4808
|
+
async function refuseSignedOutClaude(res) {
|
|
4809
|
+
const { state } = await probeClaudeAuth({ bin: configuredClaudeBin(), mock: isTruthy(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK) });
|
|
4810
|
+
if (state !== 'signed-out') return false;
|
|
4811
|
+
res.status(409).json({ code: CLAUDE_SIGNED_OUT_CODE, error: CLAUDE_SIGNED_OUT_MESSAGE });
|
|
4812
|
+
return true;
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4719
4815
|
// POST /api/workspaces/scan (pre-persist, Step 2->3). Takes projectPaths directly:
|
|
4720
4816
|
// validate >=2 paths + fs.existsSync each + reject non-git-repos (400); the deep
|
|
4721
4817
|
// git work happens inside the engine.
|
|
@@ -4730,6 +4826,7 @@ app.post('/api/workspaces/scan', async (req, res) => {
|
|
|
4730
4826
|
if (!fs.existsSync(dir)) return badRequest(res, `member path is missing: ${dir}`);
|
|
4731
4827
|
if (!isGitRepo(dir)) return badRequest(res, `member is not a git repository: ${dir}`);
|
|
4732
4828
|
}
|
|
4829
|
+
if (await refuseSignedOutClaude(res)) return;
|
|
4733
4830
|
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
|
|
4734
4831
|
const scanId = startScan({ projectPaths, name });
|
|
4735
4832
|
res.json({ scanId });
|
|
@@ -4751,6 +4848,7 @@ app.post('/api/workspaces/:id/scan', async (req, res) => {
|
|
|
4751
4848
|
r.workspaceId === id && r.kind === 'workspace-run' &&
|
|
4752
4849
|
['running', 'starting', 'created'].includes(String(r.status || '').toLowerCase()));
|
|
4753
4850
|
if (liveRun) return res.status(409).json({ error: 'a live run exists for this workspace' });
|
|
4851
|
+
if (await refuseSignedOutClaude(res)) return;
|
|
4754
4852
|
const scanId = startScan({ projectPaths: ws.projectPaths, name: ws.name, workspaceId: ws.id });
|
|
4755
4853
|
res.json({ scanId });
|
|
4756
4854
|
} catch (err) {
|
|
@@ -4882,6 +4980,7 @@ const settingsState = () => ({
|
|
|
4882
4980
|
humanRateUsdPerHour: humanRateUsdPerHour(),
|
|
4883
4981
|
askMaxTurns: askMaxTurns(),
|
|
4884
4982
|
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
4983
|
+
askWeb: askWeb(), // Ask Worca web access (off by default)
|
|
4885
4984
|
debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
|
|
4886
4985
|
debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
|
|
4887
4986
|
titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
|
|
@@ -4938,6 +5037,112 @@ app.get('/api/whoami', (req, res) => {
|
|
|
4938
5037
|
res.json(who.source === 'local' ? { name: null, source: 'local', shared: false } : { ...who, shared: isSharedIdentity(who.source) });
|
|
4939
5038
|
});
|
|
4940
5039
|
|
|
5040
|
+
// The signed-in person's model credentials, as the credential broker sees them
|
|
5041
|
+
// (docs/credential-broker.md). Status only: which slots have a key, never a key.
|
|
5042
|
+
// { enabled:false } with the broker off.
|
|
5043
|
+
// OpenRouter's daily allowance of `:free` requests (src/core/openrouter-free.mjs), for the
|
|
5044
|
+
// signed-in person with the credential broker, else for the install's key. The sidebar line,
|
|
5045
|
+
// the new-run warning and the Providers card read it; ?refresh=1 asks OpenRouter now.
|
|
5046
|
+
app.get('/api/openrouter/free-daily', async (req, res) => {
|
|
5047
|
+
try {
|
|
5048
|
+
res.json(await freeDailyStatus({ person: currentBillTo(), force: req.query.refresh === '1' }));
|
|
5049
|
+
} catch (err) {
|
|
5050
|
+
res.status(500).json({ enabled: false, error: err && err.message ? err.message : String(err) });
|
|
5051
|
+
}
|
|
5052
|
+
});
|
|
5053
|
+
|
|
5054
|
+
app.get('/api/credentials', async (req, res) => {
|
|
5055
|
+
if (!brokerEnabled()) return res.json({ enabled: false });
|
|
5056
|
+
try {
|
|
5057
|
+
const info = await brokerInfo();
|
|
5058
|
+
const who = resolveIdentity(req);
|
|
5059
|
+
// Which slot each catalog model spends from: the pickers' "your key / no key" badges.
|
|
5060
|
+
const models = {};
|
|
5061
|
+
try {
|
|
5062
|
+
for (const m of await listModels()) {
|
|
5063
|
+
const r = modelSlot(m.id);
|
|
5064
|
+
if (r) models[m.id] = r.slot ? { slot: r.slot } : r.keyless ? { keyless: true } : { error: r.error };
|
|
5065
|
+
}
|
|
5066
|
+
} catch { /* badges are a nicety: an unreadable catalog leaves them off */ }
|
|
5067
|
+
const base = { enabled: true, mode: info.mode, keyPage: info.publicUrl || null, models };
|
|
5068
|
+
if (info.mode !== 'multi') return res.json({ ...base, person: null, slots: (await personSlots('local')).slots });
|
|
5069
|
+
if (!isSharedIdentity(who.source)) return res.json({ ...base, person: null, slots: [] });
|
|
5070
|
+
const r = await personSlots(who.name);
|
|
5071
|
+
res.json({ ...base, person: r.person, slots: r.slots });
|
|
5072
|
+
} catch (err) {
|
|
5073
|
+
res.status(502).json({ enabled: true, error: err && err.message ? err.message : String(err) });
|
|
5074
|
+
}
|
|
5075
|
+
});
|
|
5076
|
+
|
|
5077
|
+
// ── Ask Worca tool relay (agent users, agent-pool.mjs) ───────────────────────────────
|
|
5078
|
+
// When agents run under their own users, a chat's claude runs as the person's agent user,
|
|
5079
|
+
// which cannot read worca's database. Its MCP child then only relays each JSON-RPC line
|
|
5080
|
+
// here; the worca tools run in this process (createAskToolServer), in the chat owner's
|
|
5081
|
+
// billing context. One token per turn, loopback callers only, dropped when the turn ends.
|
|
5082
|
+
const askRelays = new Map(); // token -> { rpc, out, billTo, owner }
|
|
5083
|
+
|
|
5084
|
+
function askAgentRelay({ threadId, reader, web = null }) {
|
|
5085
|
+
const token = randomBytes(24).toString('base64url');
|
|
5086
|
+
const life = new AbortController();
|
|
5087
|
+
const entry = { out: [], billTo: currentBillTo(), owner: currentOwner() };
|
|
5088
|
+
// The tools run here, so this turn's web access rides a private env copy (never process.env itself);
|
|
5089
|
+
// the search key is read from worca's own environment, where it was set.
|
|
5090
|
+
const env = { ...process.env, ...askWebMcpEnv(web) };
|
|
5091
|
+
entry.rpc = createAskToolServer({ threadId, reader, signal: life.signal, env, write: (s) => { entry.out.push(s); } });
|
|
5092
|
+
askRelays.set(token, entry);
|
|
5093
|
+
const port = server.address()?.port || PORT;
|
|
5094
|
+
return { url: `http://127.0.0.1:${port}/api/ask/relay`, token, dispose: () => { life.abort(); askRelays.delete(token); } };
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
app.post('/api/ask/relay', async (req, res) => {
|
|
5098
|
+
if (!isInContainer(req)) return res.status(403).json({ error: 'relay: loopback callers only' });
|
|
5099
|
+
const entry = askRelays.get(String(req.headers['x-worca-relay'] || ''));
|
|
5100
|
+
if (!entry) return res.status(403).json({ error: 'relay: unknown or finished turn' });
|
|
5101
|
+
const line = req.body && typeof req.body.line === 'string' ? req.body.line : null;
|
|
5102
|
+
if (line === null) return badRequest(res, 'relay: line is required');
|
|
5103
|
+
// One line at a time per turn (the child sends sequentially), so the output collected
|
|
5104
|
+
// between feed and idle is this line's answer.
|
|
5105
|
+
await withBillTo(entry.billTo, async () => {
|
|
5106
|
+
entry.out = [];
|
|
5107
|
+
await entry.rpc.feed(line);
|
|
5108
|
+
await entry.rpc.idle();
|
|
5109
|
+
}, { owner: entry.owner });
|
|
5110
|
+
res.json({ out: entry.out });
|
|
5111
|
+
});
|
|
5112
|
+
|
|
5113
|
+
/**
|
|
5114
|
+
* Credential-broker preflight for starting work with known models (an Ask message, a run
|
|
5115
|
+
* started with an explicit model): refused up front, naming each missing key and the key
|
|
5116
|
+
* page, instead of the first spawn failing. In single mode the one set of keys is checked.
|
|
5117
|
+
* Never blocks on a broker that can't be asked.
|
|
5118
|
+
* @param {string[]} modelIds
|
|
5119
|
+
* @returns {Promise<string|null>} the refusal, or null to proceed
|
|
5120
|
+
*/
|
|
5121
|
+
async function brokerStartRefusal(req, modelIds) {
|
|
5122
|
+
if (!brokerEnabled() || !modelIds || !modelIds.length) return null;
|
|
5123
|
+
try {
|
|
5124
|
+
const info = await brokerInfo();
|
|
5125
|
+
let person = 'local';
|
|
5126
|
+
if (info.mode === 'multi') {
|
|
5127
|
+
const who = resolveIdentity(req);
|
|
5128
|
+
if (!isSharedIdentity(who.source)) return null;
|
|
5129
|
+
person = who.name;
|
|
5130
|
+
}
|
|
5131
|
+
const r = missingCredentials(modelIds, modelSlot, (await personSlots(person)).slots || []);
|
|
5132
|
+
if (!r.missing.length && !r.errors.length) return null;
|
|
5133
|
+
const one = r.missing.length === 1 && !r.errors.length ? r.missing[0] : null;
|
|
5134
|
+
const where = info.publicUrl ? ` Add it on the key page (${info.publicUrl}), then try again.` : ' Add it to the credential broker, then try again.';
|
|
5135
|
+
if (one) {
|
|
5136
|
+
return one.state === 'invalid'
|
|
5137
|
+
? `Your ${one.label} was rejected by the provider.${where}`
|
|
5138
|
+
: `You haven't added your ${one.label} yet, and ${one.models.join(', ')} needs it.${where}`;
|
|
5139
|
+
}
|
|
5140
|
+
return `Can't start: ${describeMissing(r, info.publicUrl)}`;
|
|
5141
|
+
} catch {
|
|
5142
|
+
return null;
|
|
5143
|
+
}
|
|
5144
|
+
}
|
|
5145
|
+
|
|
4941
5146
|
/** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
|
|
4942
5147
|
function bearerMatches(header, expected) {
|
|
4943
5148
|
if (!expected || typeof header !== 'string') return false;
|
|
@@ -4980,6 +5185,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
4980
5185
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
4981
5186
|
const hasHumanRateKey = has('humanRateUsdPerHour');
|
|
4982
5187
|
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
5188
|
+
const hasAskWeb = has('askWeb'); // null clears back to unset (a team default applies)
|
|
4983
5189
|
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
4984
5190
|
const hasTitleModelKey = has('titleModel');
|
|
4985
5191
|
const hasHideBuiltinKey = has('hideBuiltinModels');
|
|
@@ -5016,6 +5222,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
5016
5222
|
assertCostLimitInputs(budget);
|
|
5017
5223
|
if (hasHumanRateKey) assertHumanRateInput(body.humanRateUsdPerHour ?? '');
|
|
5018
5224
|
assertAskLimitInputs(ask);
|
|
5225
|
+
if (hasAskWeb && body.askWeb !== null) assertAskWebInput(body.askWeb);
|
|
5019
5226
|
if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
|
|
5020
5227
|
if (hasTitleModelKey) {
|
|
5021
5228
|
assertTitleModelInput(titleModelInput);
|
|
@@ -5046,6 +5253,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
5046
5253
|
if (hasHumanRateKey) await setHumanRateUsdPerHour(body.humanRateUsdPerHour ?? '');
|
|
5047
5254
|
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
5048
5255
|
if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
|
|
5256
|
+
if (hasAskWeb) await setAskWeb(body.askWeb);
|
|
5049
5257
|
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
5050
5258
|
if (hasTitleModelKey) await setTitleModel(titleModelInput);
|
|
5051
5259
|
if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
|
|
@@ -5057,7 +5265,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
5057
5265
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
5058
5266
|
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
5059
5267
|
// "save" its old checkbox state over this one with no feedback to either).
|
|
5060
|
-
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasUiLevelKey || hasAutoKey || hasHumanRateKey || hasMemoryDefragKey || has('schedule')) emitChanged('settings-changed');
|
|
5268
|
+
if (hasAskKey || hasAskWeb || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasUiLevelKey || hasAutoKey || hasHumanRateKey || hasMemoryDefragKey || has('schedule')) emitChanged('settings-changed');
|
|
5061
5269
|
res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
|
|
5062
5270
|
} catch (err) {
|
|
5063
5271
|
// The setters throw only on an unusable path -> client error (400).
|
|
@@ -6276,21 +6484,32 @@ app.get('/api/ask/runs/:id', (req, res) => {
|
|
|
6276
6484
|
}
|
|
6277
6485
|
});
|
|
6278
6486
|
|
|
6279
|
-
app.patch('/api/ask/threads/:id', (req, res) => {
|
|
6487
|
+
app.patch('/api/ask/threads/:id', async (req, res) => {
|
|
6280
6488
|
const id = askIdParam(res, req.params.id, 'thread');
|
|
6281
6489
|
if (!id) return;
|
|
6282
6490
|
try {
|
|
6283
6491
|
const body = req.body || {};
|
|
6284
6492
|
const patch = {};
|
|
6285
|
-
//
|
|
6286
|
-
//
|
|
6287
|
-
|
|
6493
|
+
// The Ask panel's model · effort picker, moved while this chat is open: the pair
|
|
6494
|
+
// travels together, so a switch away and back finds it before any send.
|
|
6495
|
+
const pick = body.model !== undefined || body.effort !== undefined;
|
|
6496
|
+
// Title keeps its original contract exactly: a PATCH that names none of the
|
|
6497
|
+
// fields still earns the title error, so pre-#397 callers see identical behaviour.
|
|
6498
|
+
if (body.title !== undefined || (body.scope === undefined && !pick)) {
|
|
6288
6499
|
const raw = body.title;
|
|
6289
6500
|
if (typeof raw !== 'string' || !raw.trim() || raw.length > 120) {
|
|
6290
6501
|
return badRequest(res, 'title must be a non-empty string of at most 120 characters');
|
|
6291
6502
|
}
|
|
6292
6503
|
patch.title = raw.trim();
|
|
6293
6504
|
}
|
|
6505
|
+
if (pick) {
|
|
6506
|
+
// The same check as the message POST. Awaited BEFORE the scope branch, so its
|
|
6507
|
+
// read-modify-write of the stored context stays synchronous.
|
|
6508
|
+
const mv = await validateModelEffort(body.model, body.effort);
|
|
6509
|
+
if (!mv.ok) return badRequest(res, mv.error);
|
|
6510
|
+
patch.model = mv.model;
|
|
6511
|
+
patch.effort = mv.effort;
|
|
6512
|
+
}
|
|
6294
6513
|
if (body.scope !== undefined) {
|
|
6295
6514
|
// #397: the Ask panel's scope selector. Merged per field into the stored
|
|
6296
6515
|
// context — the pin replaces only the target keys, so the last page
|
|
@@ -6493,11 +6712,24 @@ function askValidateScope(raw) {
|
|
|
6493
6712
|
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
6494
6713
|
}
|
|
6495
6714
|
|
|
6715
|
+
/** One chat turn's web access: settings ⊕ the pinned project's team policy ⊕ the hosts this chat's web
|
|
6716
|
+
* cards allowed "for this chat". Never throws: a failure reads as off. */
|
|
6717
|
+
function askWebAccessFor(threadId, ctx) {
|
|
6718
|
+
try {
|
|
6719
|
+
const pinned = askPinnedScope(ctx || {});
|
|
6720
|
+
return askWebAccess({ projectKey: pinned?.projectKey || null, chatHosts: chatWebHosts(askListMessages(threadId)) });
|
|
6721
|
+
} catch (err) {
|
|
6722
|
+
console.warn(`[worca-ask] web access resolve failed (${err?.message || err}) — web stays off`);
|
|
6723
|
+
return WEB_OFF;
|
|
6724
|
+
}
|
|
6725
|
+
}
|
|
6726
|
+
|
|
6496
6727
|
/** The system prompt of ONE Ask turn: the rules, the catalog, and — only when the chat's
|
|
6497
6728
|
* "Create and run scripts" pref is on (W20) — the scripts section with the runtimes this host
|
|
6498
|
-
* actually has (the python probe, cached 60 s)
|
|
6499
|
-
|
|
6500
|
-
|
|
6729
|
+
* actually has (the python probe, cached 60 s); plus the web section when `web` (askWebAccess()
|
|
6730
|
+
* for this turn) is on. Memory is mounted, not rendered. */
|
|
6731
|
+
async function askSystemPromptFor(catalog, { web = null } = {}) {
|
|
6732
|
+
return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput(), deployment: DEPLOYMENT, web });
|
|
6501
6733
|
}
|
|
6502
6734
|
|
|
6503
6735
|
/** "scheduled Sat Sep 19, 02:00 (run 1a2b…)" / "repeats: Every weekday at 02:00 (sch_…)" / "proposes: …" — or ''. */
|
|
@@ -6618,7 +6850,7 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
6618
6850
|
// workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
|
|
6619
6851
|
const wf = !!(b.card && b.card.type === 'workflow');
|
|
6620
6852
|
if (wf && b.state === 'building') continue; // transient (no name yet) — never worth a header line
|
|
6621
|
-
if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy' || b.card.type === 'clone')) {
|
|
6853
|
+
if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy' || b.card.type === 'clone' || b.card.type === 'web')) {
|
|
6622
6854
|
cards.push({ id: b.id, type: b.card.type, state: b.state, summary: b.card.summary || '' });
|
|
6623
6855
|
continue;
|
|
6624
6856
|
}
|
|
@@ -6756,7 +6988,11 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
|
|
|
6756
6988
|
const withText = attRows.map((a, i) => ({ id: a.id, name: a.name, bytes: a.bytes, kind: a.kind, mime: a.mime, text: files[i].text }));
|
|
6757
6989
|
const { inline, listed } = askSelectInlineAttachments(withText);
|
|
6758
6990
|
const headerCtx = await resolveAskContext(id, ctx, listed, userMsg.id, { signedIn });
|
|
6759
|
-
|
|
6991
|
+
// Web access (docs/guardrails.md "Web access"): resolved ONCE per turn — local settings ⊕ the pinned project's
|
|
6992
|
+
// team policy — so the prompt section, the sub-agent note and the MCP child's tools agree.
|
|
6993
|
+
const pinned = askPinnedScope(ctx);
|
|
6994
|
+
const web = askWebAccessFor(id, ctx);
|
|
6995
|
+
const systemPrompt = await askSystemPromptFor(catalog, { web });
|
|
6760
6996
|
const header = askBuildContextHeader(headerCtx);
|
|
6761
6997
|
const prompt = askBuildTurnPrompt(header, text, inline);
|
|
6762
6998
|
const prior = askListMessages(id).filter((m) => m.seq < userMsg.seq);
|
|
@@ -6774,12 +7010,16 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
|
|
|
6774
7010
|
firstTurn: !synthetic && userMsg.seq === 1 && titleWasAuto, // P3: an event never titles the thread (D13 guard kept)
|
|
6775
7011
|
firstText: text,
|
|
6776
7012
|
deterministicTitle,
|
|
6777
|
-
pinnedScope:
|
|
7013
|
+
pinnedScope: pinned, // #397: proposal defaulting + mismatch flag
|
|
7014
|
+
web,
|
|
6778
7015
|
timeZone: ctx.timeZone || (thread.context && thread.context.timeZone) || null, // scheduled runs: the user's clock
|
|
6779
7016
|
memoryProject: headerCtx.project ? { key: headerCtx.project.key, name: headerCtx.project.name || '' } : null, // native-rules revision: the turn mounts global + this project through --add-dir
|
|
6780
7017
|
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
6781
7018
|
attachmentNames,
|
|
6782
7019
|
deps: {
|
|
7020
|
+
// Agents under their own users (agent-pool.mjs): the chat runs as the person's agent
|
|
7021
|
+
// user and its worca tools run here, through the relay. null = the classic MCP child.
|
|
7022
|
+
agentRelay: agentIdentity() ? askAgentRelay : null,
|
|
6783
7023
|
onFrame: stampAskFrames(id, job),
|
|
6784
7024
|
onOutOfTurn: (f) => broadcast({ ...f, threadId: id }),
|
|
6785
7025
|
onCommentMutation: ({ runId }) => { emitDiffCommentsChanged(runId); },
|
|
@@ -6858,6 +7098,10 @@ app.post('/api/ask/threads/:id/messages', async (req, res) => {
|
|
|
6858
7098
|
if (!text.trim()) return badRequest(res, 'text is required');
|
|
6859
7099
|
const mv = await validateModelEffort(body.model, body.effort);
|
|
6860
7100
|
if (!mv.ok) return badRequest(res, mv.error);
|
|
7101
|
+
if (!mockEnabled({})) {
|
|
7102
|
+
const refusal = await brokerStartRefusal(req, [String(body.model)]);
|
|
7103
|
+
if (refusal) return res.status(409).json({ error: refusal, code: 'credential-missing' });
|
|
7104
|
+
}
|
|
6861
7105
|
const cv = validateClientContext(body.context);
|
|
6862
7106
|
if (!cv.ok) return badRequest(res, cv.error);
|
|
6863
7107
|
// #397: explicit pin beats page context, per field. A context carrying its own
|
|
@@ -7058,9 +7302,9 @@ async function startMetricsEventTurn(threadId, block) {
|
|
|
7058
7302
|
const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
|
|
7059
7303
|
const result = card.result || null;
|
|
7060
7304
|
// One event turn for every non-workflow card; the type picks the wording. Metrics is the fallback.
|
|
7061
|
-
const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' ? card.type : 'metrics';
|
|
7062
|
-
const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, clone: cloneEventPrompt, metrics: metricsEventPrompt }[kind];
|
|
7063
|
-
const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, clone: cloneNoticeText, metrics: metricsNoticeText }[kind];
|
|
7305
|
+
const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' || card.type === 'clone' || card.type === 'web' ? card.type : 'metrics';
|
|
7306
|
+
const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, clone: cloneEventPrompt, web: webEventPrompt, metrics: metricsEventPrompt }[kind];
|
|
7307
|
+
const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, clone: cloneNoticeText, web: webNoticeText, metrics: metricsNoticeText }[kind];
|
|
7064
7308
|
const text = eventPrompt({ cardId: block.id, state, card, result });
|
|
7065
7309
|
const notice = noticeText({ state, card, result });
|
|
7066
7310
|
let mv = await validateModelEffort(thread.model, thread.effort);
|
|
@@ -7162,6 +7406,38 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
|
|
|
7162
7406
|
const turn = await startMetricsEventTurn(id, block);
|
|
7163
7407
|
return res.json({ block, turn });
|
|
7164
7408
|
}
|
|
7409
|
+
if (found.block.card && found.block.card.type === 'web') {
|
|
7410
|
+
// Web card (docs/guardrails.md "Web access"): proposed → applied | failed | declined. "For this chat" is
|
|
7411
|
+
// recorded on the card itself (chatWebHosts reads it back each turn); "always" adds the exact host to
|
|
7412
|
+
// settings. Re-checked HERE against the chat's current web access: off, or outside the team cap, fails.
|
|
7413
|
+
if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
|
|
7414
|
+
if (body.state === 'applied' && body.scope !== 'chat' && body.scope !== 'always') return badRequest(res, 'scope must be "chat" or "always"');
|
|
7415
|
+
if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
7416
|
+
if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
|
|
7417
|
+
if (body.state === 'declined') {
|
|
7418
|
+
const block = flipCard(id, cardId, { state: 'declined' });
|
|
7419
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
7420
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
7421
|
+
return res.json({ block, turn });
|
|
7422
|
+
}
|
|
7423
|
+
askCardBusy.add(cardId);
|
|
7424
|
+
let block;
|
|
7425
|
+
try {
|
|
7426
|
+
let result;
|
|
7427
|
+
try {
|
|
7428
|
+
const host = found.block.card.change?.host;
|
|
7429
|
+
const now = askWebAccessFor(id, askGetThread(id)?.context || {});
|
|
7430
|
+
if (!now.enabled) throw new Error('web access is off for this chat — switch it on in Settings → Ask Worca → Web access');
|
|
7431
|
+
if (Array.isArray(now.teamCap) && !askHostAllowed(host, now.teamCap)) throw new Error(`${host} is outside the team policy's web allowlist`);
|
|
7432
|
+
if (body.scope === 'always') { await addAskWebHost(host); emitChanged('settings-changed'); }
|
|
7433
|
+
result = { ok: true, scope: body.scope };
|
|
7434
|
+
} catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
|
|
7435
|
+
block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
|
|
7436
|
+
} finally { askCardBusy.delete(cardId); }
|
|
7437
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
7438
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
7439
|
+
return res.json({ block, turn });
|
|
7440
|
+
}
|
|
7165
7441
|
if (found.block.card && found.block.card.type === 'clone') {
|
|
7166
7442
|
// Clone card (docs/deploy-railway.md "First project"): proposed → cloning → applied | failed, or declined.
|
|
7167
7443
|
// The clone happens HERE, behind the click, as the same job the Projects view starts (startCloneJob);
|
|
@@ -7363,6 +7639,7 @@ app.post('/api/agents/generate', async (req, res) => {
|
|
|
7363
7639
|
const allAgents = await listAgents();
|
|
7364
7640
|
const byKey = Object.fromEntries(allAgents.map((m) => [m.key, m]));
|
|
7365
7641
|
const pick = (keys) => (Array.isArray(keys) ? keys : []).map((k) => byKey[k]).filter(Boolean);
|
|
7642
|
+
if (await refuseSignedOutClaude(res)) return;
|
|
7366
7643
|
const genId = startAgentGen({
|
|
7367
7644
|
name, purpose: String(body.purpose || ''), details: String(body.details || ''),
|
|
7368
7645
|
expectedBefore: pick(body.expectedBefore), expectedAfter: pick(body.expectedAfter),
|
|
@@ -8728,6 +9005,24 @@ if (isMain) {
|
|
|
8728
9005
|
}
|
|
8729
9006
|
for (const w of REMOTE_ACCESS_CHECK.warnings) console.warn(`[worca-ui] remote access: ${w}`);
|
|
8730
9007
|
|
|
9008
|
+
// Credential broker (docs/credential-broker.md): with WORCA_BROKER_URL set, worca holds no
|
|
9009
|
+
// model credential. Refuse to serve while one is still reachable by agents, or while the
|
|
9010
|
+
// broker can't be reached.
|
|
9011
|
+
{
|
|
9012
|
+
const shared = !!identityCheck || !!String(process.env.WORCA_IDENTITY_HEADER || '').trim();
|
|
9013
|
+
const b = await checkBrokerAtBoot({ shared, log: (l) => console.log(l) });
|
|
9014
|
+
for (const w of b.warnings) console.warn(`[worca-ui] credentials: ${w}`);
|
|
9015
|
+
if (b.fatal.length) {
|
|
9016
|
+
for (const e of b.fatal) console.error(e.startsWith('worca:') ? e : `[worca-ui] credentials: ${e}`);
|
|
9017
|
+
console.error('[worca-ui] not starting.');
|
|
9018
|
+
process.exit(78);
|
|
9019
|
+
}
|
|
9020
|
+
if (b.on) {
|
|
9021
|
+
const slots = (b.info?.slots || []).map((s) => s.id).join(', ');
|
|
9022
|
+
console.log(`[worca-ui] credentials: broker ${b.info?.mode}${b.info?.publicUrl ? `, key page ${b.info.publicUrl}` : ''}, slots ${slots}`);
|
|
9023
|
+
}
|
|
9024
|
+
}
|
|
9025
|
+
|
|
8731
9026
|
try {
|
|
8732
9027
|
seedBuiltinMarketplace();
|
|
8733
9028
|
} catch (err) {
|
|
@@ -8819,10 +9114,11 @@ export { app, server, runs };
|
|
|
8819
9114
|
export const _testing = {
|
|
8820
9115
|
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, wireScriptBench, startScriptBench,
|
|
8821
9116
|
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, answerRun,
|
|
8822
|
-
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
9117
|
+
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard, askWebAccessFor,
|
|
8823
9118
|
startCloneJob, followCloneCard, CLONE_JOBS,
|
|
8824
9119
|
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
8825
9120
|
askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
|
|
8826
9121
|
uiControl, bearerMatches,
|
|
8827
9122
|
broadcast, askFilesRunDir,
|
|
9123
|
+
trackHeartbeat, heartbeatTick, BOOT_ID,
|
|
8828
9124
|
};
|