@cluesmith/codev 3.3.0 → 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard-dist/assets/{index-JCbkpAcV.js → index-D3XaKpZW.js} +45 -43
- package/dashboard-dist/assets/index-D3XaKpZW.js.map +1 -0
- package/dashboard-dist/index.html +1 -1
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +99 -36
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/reset/constants.d.ts +119 -1
- package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/constants.js +119 -1
- package/dist/agent-farm/commands/reset/constants.js.map +1 -1
- package/dist/agent-farm/commands/reset/context.d.ts +20 -2
- package/dist/agent-farm/commands/reset/context.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/context.js +44 -3
- package/dist/agent-farm/commands/reset/context.js.map +1 -1
- package/dist/agent-farm/commands/reset/index.d.ts +7 -7
- package/dist/agent-farm/commands/reset/index.js +8 -8
- package/dist/agent-farm/commands/reset/index.js.map +1 -1
- package/dist/agent-farm/commands/reset/receipt.d.ts +1 -1
- package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/receipt.js +19 -8
- package/dist/agent-farm/commands/reset/receipt.js.map +1 -1
- package/dist/agent-farm/commands/reset/reorient.d.ts +1 -1
- package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset/reorient.js +12 -5
- package/dist/agent-farm/commands/reset/reorient.js.map +1 -1
- package/dist/agent-farm/commands/reset/self.d.ts +335 -0
- package/dist/agent-farm/commands/reset/self.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/self.js +636 -0
- package/dist/agent-farm/commands/reset/self.js.map +1 -0
- package/dist/agent-farm/commands/reset.d.ts +18 -3
- package/dist/agent-farm/commands/reset.d.ts.map +1 -1
- package/dist/agent-farm/commands/reset.js +55 -33
- package/dist/agent-farm/commands/reset.js.map +1 -1
- package/dist/agent-farm/commands/self-refresh.d.ts +60 -0
- package/dist/agent-farm/commands/self-refresh.d.ts.map +1 -0
- package/dist/agent-farm/commands/self-refresh.js +506 -0
- package/dist/agent-farm/commands/self-refresh.js.map +1 -0
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +27 -2
- package/dist/agent-farm/commands/send.js.map +1 -1
- package/dist/agent-farm/commands/spawn-worktree.js +1 -1
- package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts +7 -0
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +13 -1
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/tower.d.ts.map +1 -1
- package/dist/agent-farm/commands/tower.js +13 -0
- package/dist/agent-farm/commands/tower.js.map +1 -1
- package/dist/agent-farm/lib/reconnect-backoff.d.ts +10 -0
- package/dist/agent-farm/lib/reconnect-backoff.d.ts.map +1 -1
- package/dist/agent-farm/lib/reconnect-backoff.js +10 -0
- package/dist/agent-farm/lib/reconnect-backoff.js.map +1 -1
- package/dist/agent-farm/lib/tower-client.d.ts +12 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js +12 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.js +7 -3
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
- package/dist/agent-farm/servers/canvas-relay.d.ts +60 -0
- package/dist/agent-farm/servers/canvas-relay.d.ts.map +1 -0
- package/dist/agent-farm/servers/canvas-relay.js +376 -0
- package/dist/agent-farm/servers/canvas-relay.js.map +1 -0
- package/dist/agent-farm/servers/gate-profiles.d.ts +1 -1
- package/dist/agent-farm/servers/gate-profiles.js +1 -1
- package/dist/agent-farm/servers/mailbox-delivery.d.ts +141 -13
- package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-delivery.js +172 -16
- package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.d.ts +24 -2
- package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
- package/dist/agent-farm/servers/mailbox-wiring.js +92 -32
- package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
- package/dist/agent-farm/servers/message-write.d.ts +58 -18
- package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
- package/dist/agent-farm/servers/message-write.js +73 -28
- package/dist/agent-farm/servers/message-write.js.map +1 -1
- package/dist/agent-farm/servers/overview.d.ts +19 -0
- package/dist/agent-farm/servers/overview.d.ts.map +1 -1
- package/dist/agent-farm/servers/overview.js +78 -1
- package/dist/agent-farm/servers/overview.js.map +1 -1
- package/dist/agent-farm/servers/render-gate.d.ts +13 -0
- package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
- package/dist/agent-farm/servers/render-gate.js +21 -0
- package/dist/agent-farm/servers/render-gate.js.map +1 -1
- package/dist/agent-farm/servers/session-submit.d.ts +221 -28
- package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
- package/dist/agent-farm/servers/session-submit.js +334 -43
- package/dist/agent-farm/servers/session-submit.js.map +1 -1
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-routes.js +269 -64
- package/dist/agent-farm/servers/tower-routes.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +25 -3
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts +6 -0
- package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.js +30 -5
- package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
- package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-tunnel.js +14 -0
- package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.d.ts +6 -1
- package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.js +90 -37
- package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
- package/dist/agent-farm/types.d.ts +37 -2
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/harness.d.ts +3 -3
- package/dist/agent-farm/utils/harness.js +1 -1
- package/dist/agent-farm/utils/message-format.d.ts +67 -3
- package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
- package/dist/agent-farm/utils/message-format.js +127 -14
- package/dist/agent-farm/utils/message-format.js.map +1 -1
- package/dist/agent-farm/utils/server-utils.d.ts +86 -4
- package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
- package/dist/agent-farm/utils/server-utils.js +303 -5
- package/dist/agent-farm/utils/server-utils.js.map +1 -1
- package/dist/commands/porch/context-refresh.d.ts +181 -0
- package/dist/commands/porch/context-refresh.d.ts.map +1 -0
- package/dist/commands/porch/context-refresh.js +270 -0
- package/dist/commands/porch/context-refresh.js.map +1 -0
- package/dist/commands/porch/index.d.ts +6 -0
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +46 -0
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/next.d.ts +0 -9
- package/dist/commands/porch/next.d.ts.map +1 -1
- package/dist/commands/porch/next.js +133 -4
- package/dist/commands/porch/next.js.map +1 -1
- package/dist/commands/porch/protocol.d.ts.map +1 -1
- package/dist/commands/porch/protocol.js +102 -0
- package/dist/commands/porch/protocol.js.map +1 -1
- package/dist/commands/porch/types.d.ts +61 -0
- package/dist/commands/porch/types.d.ts.map +1 -1
- package/dist/lib/forge-contracts.d.ts +9 -0
- package/dist/lib/forge-contracts.d.ts.map +1 -1
- package/dist/lib/forge-contracts.js.map +1 -1
- package/dist/lib/github.d.ts +15 -1
- package/dist/lib/github.d.ts.map +1 -1
- package/dist/lib/github.js +17 -0
- package/dist/lib/github.js.map +1 -1
- package/dist/lib/self-refresh-invocation.d.ts +60 -0
- package/dist/lib/self-refresh-invocation.d.ts.map +1 -0
- package/dist/lib/self-refresh-invocation.js +64 -0
- package/dist/lib/self-refresh-invocation.js.map +1 -0
- package/dist/lib/test-env.d.ts +50 -0
- package/dist/lib/test-env.d.ts.map +1 -1
- package/dist/lib/test-env.js +76 -0
- package/dist/lib/test-env.js.map +1 -1
- package/dist/terminal/attach-replay.d.ts +39 -0
- package/dist/terminal/attach-replay.d.ts.map +1 -0
- package/dist/terminal/attach-replay.js +76 -0
- package/dist/terminal/attach-replay.js.map +1 -0
- package/dist/terminal/pty-manager.d.ts.map +1 -1
- package/dist/terminal/pty-manager.js +35 -17
- package/dist/terminal/pty-manager.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +81 -12
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +113 -25
- package/dist/terminal/pty-session.js.map +1 -1
- package/dist/terminal/session-screen.d.ts +25 -0
- package/dist/terminal/session-screen.d.ts.map +1 -1
- package/dist/terminal/session-screen.js +53 -8
- package/dist/terminal/session-screen.js.map +1 -1
- package/package.json +10 -7
- package/scripts/forge/gitea/pr-view.sh +4 -1
- package/scripts/forge/github/pr-view.sh +1 -1
- package/scripts/forge/gitlab/pr-view.sh +4 -1
- package/skeleton/.claude/skills/afx/SKILL.md +54 -0
- package/skeleton/.claude/skills/arch-save/SKILL.md +8 -2
- package/skeleton/.claude/skills/builder-refresh/SKILL.md +138 -0
- package/skeleton/.codex/skills/afx/SKILL.md +54 -0
- package/skeleton/.codex/skills/arch-save/SKILL.md +8 -2
- package/skeleton/.codex/skills/builder-refresh/SKILL.md +138 -0
- package/skeleton/protocol-schema.json +17 -0
- package/skeleton/protocols/air/protocol.json +1 -1
- package/skeleton/protocols/aspir/protocol.json +5 -1
- package/skeleton/protocols/bugfix/protocol.json +1 -1
- package/skeleton/protocols/experiment/protocol.json +1 -1
- package/skeleton/protocols/maintain/protocol.json +1 -1
- package/skeleton/protocols/pir/builder-prompt.md +3 -2
- package/skeleton/protocols/pir/prompts/implement.md +1 -1
- package/skeleton/protocols/pir/prompts/plan.md +1 -1
- package/skeleton/protocols/pir/prompts/review.md +1 -1
- package/skeleton/protocols/pir/protocol.json +1 -1
- package/skeleton/protocols/pir/protocol.md +4 -4
- package/skeleton/protocols/protocol-schema.json +17 -0
- package/skeleton/protocols/research/protocol.json +1 -1
- package/skeleton/protocols/spike/protocol.json +1 -1
- package/skeleton/protocols/spir/protocol.json +5 -1
- package/skeleton/resources/commands/agent-farm.md +14 -11
- package/skeleton/roles/builder.md +3 -4
- package/templates/3d-viewer.html +32 -4
- package/templates/open.html +58 -12
- package/templates/tower.html +26 -7
- package/templates/vendor/three-3MFLoader.js +1478 -0
- package/templates/vendor/three-STLLoader.js +410 -0
- package/templates/vendor/three-TrackballControls.js +828 -0
- package/templates/vendor/three-fflate.module.js +2474 -0
- package/templates/vendor/three.module.js +53044 -0
- package/dashboard-dist/assets/index-JCbkpAcV.js.map +0 -1
|
@@ -24,7 +24,8 @@ import { version } from '../../version.js';
|
|
|
24
24
|
const execAsync = promisify(exec);
|
|
25
25
|
import { getBuilders, setArchitectByName } from '../state.js';
|
|
26
26
|
import { DEFAULT_COLS, defaultSessionOptions } from '../../terminal/index.js';
|
|
27
|
-
import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
|
|
27
|
+
import { parseJsonBody, isRequestAllowed, isAllowedOrigin, isAllowedHost, getExpectedKey, escapeHtml } from '../utils/server-utils.js';
|
|
28
|
+
import { TOWER_KEY_HEADER, LEGACY_WEB_KEY_HEADER, VSCODE_USER_SENDER } from '@cluesmith/codev-types';
|
|
28
29
|
import { isRateLimited, normalizeWorkspacePath, getLanguageForExt, getMimeTypeForFile, serveStaticFile, } from './tower-utils.js';
|
|
29
30
|
import { handleTunnelEndpoint } from './tower-tunnel.js';
|
|
30
31
|
import { getWorktreeConfig, getActivityHooks } from '../utils/config.js';
|
|
@@ -33,16 +34,18 @@ import { hasTeam, loadTeamMembers, loadMessages } from '../../lib/team.js';
|
|
|
33
34
|
import { fetchTeamGitHubData } from '../../lib/team-github.js';
|
|
34
35
|
import { resolveTarget, resolveAgentInRegistry, broadcastMessage, isResolveError } from './tower-messages.js';
|
|
35
36
|
import { handleCommandRoute, COMMAND_ROUTE } from './command-relay.js';
|
|
36
|
-
import {
|
|
37
|
+
import { handleCanvasRoute, CANVAS_ROUTE_PREFIX } from './canvas-relay.js';
|
|
38
|
+
import { formatArchitectMessage, formatArchitectToBuilderMessage, formatBuilderMessage, formatUserViaVsCodeMessage, messageTooLargeError, MAX_MESSAGE_BYTES, } from '../utils/message-format.js';
|
|
37
39
|
import { writeMessageToSession, writeEscapeToSession } from './message-write.js';
|
|
38
40
|
import { makeDeliveryPorts, getMailboxDrainer } from './mailbox-wiring.js';
|
|
39
41
|
import { deliverAgentMailSerialized } from './mailbox-delivery.js';
|
|
40
42
|
import { deliverCronMail, CRON_SENDER } from './cron-delivery.js';
|
|
41
43
|
import { enqueue as enqueueMailbox, getById as getMailboxById, markDelivered as markMailboxDelivered, listHeld as listHeldMailbox, dismiss as dismissMailbox, } from '../db/mailbox.js';
|
|
42
|
-
// Spec 1273 per-terminal submission lock
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
44
|
+
// Spec 1273 per-terminal submission lock. Originally the anti-fusion lock for the two
|
|
45
|
+
// explicit human-bypass paths (escape + interrupt); since Issue #1365 the gated mailbox
|
|
46
|
+
// delivery path takes it too, so it now serializes the whole write edge of a terminal.
|
|
47
|
+
// The operator paths pass a wait ceiling — see OPERATOR_SUBMIT_WAIT_CEILING_MS.
|
|
48
|
+
import { submitToSession, OPERATOR_SUBMIT_WAIT_CEILING_MS } from './session-submit.js';
|
|
46
49
|
// Spec 1307 `--delay` — Tower-side deferred delivery, re-homed onto the Spec 1313
|
|
47
50
|
// mailbox (the merge that carried this feature was flattened by a later rebase, so it
|
|
48
51
|
// is grafted here explicitly): the due-time callback enqueues to the mailbox and
|
|
@@ -51,7 +54,7 @@ import { submitToSession } from './session-submit.js';
|
|
|
51
54
|
import { scheduleDelayedSend, validateDelaySeconds } from './delayed-send.js';
|
|
52
55
|
import { getKnownWorkspacePaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, instancesReady, stopInstance, addArchitect, removeArchitect, } from './tower-instances.js';
|
|
53
56
|
import { OverviewCache } from './overview.js';
|
|
54
|
-
import { fetchIssue, searchIssues, fetchPRList, fetchCurrentUser, parseLinkedIssue, parseArea, } from '../../lib/github.js';
|
|
57
|
+
import { fetchIssue, fetchPR, searchIssues, fetchPRList, fetchCurrentUser, parseLinkedIssue, parseArea, } from '../../lib/github.js';
|
|
55
58
|
import { computeAnalytics } from './analytics.js';
|
|
56
59
|
import { getAllTasks, executeTask, getTaskId } from './tower-cron.js';
|
|
57
60
|
import { getGlobalDb } from '../db/index.js';
|
|
@@ -74,6 +77,7 @@ const ROUTES = {
|
|
|
74
77
|
'GET /api/version': (_req, res, _url, ctx) => handleVersion(res, ctx),
|
|
75
78
|
'GET /api/overview': (_req, res, url, ctx) => handleOverview(res, url, undefined, ctx),
|
|
76
79
|
'GET /api/issue': (_req, res, url) => handleIssueView(res, url),
|
|
80
|
+
'GET /api/pr': (_req, res, url) => handlePRView(res, url),
|
|
77
81
|
'GET /api/issue-search': (_req, res, url) => handleIssueSearch(res, url),
|
|
78
82
|
'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url),
|
|
79
83
|
'GET /api/activity-hooks': (_req, res, url) => handleActivityHooksView(res, url),
|
|
@@ -113,27 +117,38 @@ function respondStartingUp(res) {
|
|
|
113
117
|
// Main request handler
|
|
114
118
|
// ============================================================================
|
|
115
119
|
export async function handleRequest(req, res, ctx) {
|
|
116
|
-
//
|
|
117
|
-
if (!isRequestAllowed(req)) {
|
|
118
|
-
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
119
|
-
res.end('Forbidden');
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
// CORS headers — allow localhost and tunnel proxy origins
|
|
120
|
+
// CORS headers — reflect only allowlisted origins (advisory Layer 3).
|
|
123
121
|
const origin = req.headers.origin;
|
|
124
|
-
if (origin && (origin
|
|
125
|
-
origin.startsWith('http://127.0.0.1:') ||
|
|
126
|
-
origin.startsWith('https://'))) {
|
|
122
|
+
if (origin && isAllowedOrigin(origin)) {
|
|
127
123
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
124
|
+
res.setHeader('Vary', 'Origin');
|
|
128
125
|
}
|
|
129
126
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
130
|
-
res.setHeader('Access-Control-Allow-Headers',
|
|
127
|
+
res.setHeader('Access-Control-Allow-Headers', `Content-Type, ${TOWER_KEY_HEADER}, ${LEGACY_WEB_KEY_HEADER}`);
|
|
131
128
|
res.setHeader('Cache-Control', 'no-store');
|
|
129
|
+
// A CORS preflight carries no credentials and performs no action, so it is
|
|
130
|
+
// answered before the key check — otherwise browser clients could never
|
|
131
|
+
// complete the preflight that precedes an authenticated request.
|
|
132
132
|
if (req.method === 'OPTIONS') {
|
|
133
133
|
res.writeHead(200);
|
|
134
134
|
res.end();
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
137
|
+
// Request authentication (advisory GHSA-xvjp-7748-v88v): every route outside
|
|
138
|
+
// the narrow public-route allowlist must present the shared local key. This
|
|
139
|
+
// is the single HTTP choke point every route passes through. CORS above is
|
|
140
|
+
// defense-in-depth only — a no-preflight "simple" request still lands here.
|
|
141
|
+
if (!isRequestAllowed(req)) {
|
|
142
|
+
// Log the reason so a broken legitimate client (disallowed Host vs missing
|
|
143
|
+
// key) is diagnosable — a bare 401 is indistinguishable from either side.
|
|
144
|
+
const reason = isAllowedHost(req.headers.host)
|
|
145
|
+
? 'missing or invalid key'
|
|
146
|
+
: `disallowed Host "${req.headers.host ?? ''}"`;
|
|
147
|
+
ctx.log('WARN', `401 ${req.method ?? 'GET'} ${req.url ?? '/'} — ${reason}`);
|
|
148
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
149
|
+
res.end('Unauthorized');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
137
152
|
const url = new URL(req.url || '/', `http://localhost:${ctx.port}`);
|
|
138
153
|
try {
|
|
139
154
|
// Exact-match route dispatch (O(1) lookup)
|
|
@@ -155,6 +170,12 @@ export async function handleRequest(req, res, ctx) {
|
|
|
155
170
|
if (url.pathname === COMMAND_ROUTE) {
|
|
156
171
|
return await handleCommandRoute(req, res, url, ctx);
|
|
157
172
|
}
|
|
173
|
+
// Canvas command channel: /api/canvas/* — same self-routing shape as the command relay, but
|
|
174
|
+
// targeted rather than broadcast: it keeps a registry of live canvas views so it can resolve
|
|
175
|
+
// exactly one and answer when none is open (spec 1401).
|
|
176
|
+
if (url.pathname.startsWith(CANVAS_ROUTE_PREFIX)) {
|
|
177
|
+
return await handleCanvasRoute(req, res, url, ctx);
|
|
178
|
+
}
|
|
158
179
|
// Workspace API: /api/workspaces/:encodedPath/activate|deactivate|status (Spec 0090 Phase 1)
|
|
159
180
|
const workspaceApiMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)\/(activate|deactivate|status)$/);
|
|
160
181
|
if (workspaceApiMatch) {
|
|
@@ -890,7 +911,7 @@ async function handleOverview(res, url, workspaceOverride, ctx) {
|
|
|
890
911
|
// every collection field is required ('never undefined' for `architects`,
|
|
891
912
|
// Issue 1104), so emit them all empty rather than a partial payload.
|
|
892
913
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
893
|
-
res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false }));
|
|
914
|
+
res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false, queuedFeedback: {}, feedbackMode: 'forward' }));
|
|
894
915
|
return;
|
|
895
916
|
}
|
|
896
917
|
// Build set of active builder role_ids (lowercased) from live terminal sessions.
|
|
@@ -959,6 +980,30 @@ async function handleIssueView(res, url) {
|
|
|
959
980
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
960
981
|
res.end(JSON.stringify(issue));
|
|
961
982
|
}
|
|
983
|
+
async function handlePRView(res, url) {
|
|
984
|
+
// Mirror of handleIssueView for PRs: same workspace resolution, same
|
|
985
|
+
// ?number= param, routed through the `pr-view` forge concept. Powers the
|
|
986
|
+
// VSCode `codev.openPRById` / QuickPick "View PR #N" browser-open flow.
|
|
987
|
+
let workspaceRoot = url.searchParams.get('workspace');
|
|
988
|
+
if (!workspaceRoot) {
|
|
989
|
+
const knownPaths = getKnownWorkspacePaths();
|
|
990
|
+
workspaceRoot = knownPaths.find(p => !p.includes('/.builders/')) || null;
|
|
991
|
+
}
|
|
992
|
+
const number = url.searchParams.get('number');
|
|
993
|
+
if (!workspaceRoot || !number) {
|
|
994
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
995
|
+
res.end(JSON.stringify({ error: 'Missing workspace or number' }));
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const pr = await fetchPR(number, { cwd: workspaceRoot });
|
|
999
|
+
if (!pr) {
|
|
1000
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1001
|
+
res.end(JSON.stringify({ error: `PR #${number} not found or forge unavailable` }));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1005
|
+
res.end(JSON.stringify(pr));
|
|
1006
|
+
}
|
|
962
1007
|
/**
|
|
963
1008
|
* GET /api/issue-search — the data source for the VSCode "Search Backlog"
|
|
964
1009
|
* editor-tab webview (#920). Returns the issue set for the requested
|
|
@@ -1230,13 +1275,24 @@ function liveTargetIdentity(result) {
|
|
|
1230
1275
|
const archName = architectNameForTerminal(result.workspacePath, result.terminalId);
|
|
1231
1276
|
return { toAgent: archName ?? result.agent, isArchitectTarget: archName !== null };
|
|
1232
1277
|
}
|
|
1233
|
-
/**
|
|
1234
|
-
|
|
1278
|
+
/**
|
|
1279
|
+
* Format a message per sender/target — preserves the pre-1313 formatting rules.
|
|
1280
|
+
*
|
|
1281
|
+
* #1574: `toAgent` is the CANONICAL recipient the mailbox row is keyed on (the
|
|
1282
|
+
* architect reverse-map for architect targets, the resolved agent id otherwise),
|
|
1283
|
+
* so the header a recipient reads names the same identity the row was addressed
|
|
1284
|
+
* to. Passing anything else here would make the frame attest to a fiction.
|
|
1285
|
+
*/
|
|
1286
|
+
function formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw) {
|
|
1287
|
+
// #1494: a human's approval relayed by the VS Code extension gets its own
|
|
1288
|
+
// header, so the architect can tell it from a peer-architect instruction.
|
|
1289
|
+
if (isArchitectTarget && from === VSCODE_USER_SENDER)
|
|
1290
|
+
return formatUserViaVsCodeMessage(toAgent, message, undefined, raw);
|
|
1235
1291
|
if (isArchitectTarget && from)
|
|
1236
|
-
return formatBuilderMessage(from, message, undefined, raw); // builder → architect
|
|
1292
|
+
return formatBuilderMessage(from, toAgent, message, undefined, raw); // builder → architect
|
|
1237
1293
|
if (!isArchitectTarget)
|
|
1238
|
-
return
|
|
1239
|
-
return raw ? message : formatArchitectMessage(message, undefined, false); // unknown → architect
|
|
1294
|
+
return formatArchitectToBuilderMessage(toAgent, message, undefined, raw); // any → builder
|
|
1295
|
+
return raw ? message : formatArchitectMessage(toAgent, message, undefined, false); // unknown → architect
|
|
1240
1296
|
}
|
|
1241
1297
|
/**
|
|
1242
1298
|
* Route a cron notification through the Spec 1313 mailbox + gate — the Tower-wired
|
|
@@ -1256,9 +1312,12 @@ export async function deliverCronMessage(task, message, log) {
|
|
|
1256
1312
|
const ports = makeDeliveryPorts(log);
|
|
1257
1313
|
// Preserve the pre-1313 cron framing: a message FROM the `af-cron` pseudo-builder,
|
|
1258
1314
|
// regardless of whether the target is an architect or a builder.
|
|
1315
|
+
//
|
|
1316
|
+
// #1574: the frame names its recipient, which is only known AFTER resolution —
|
|
1317
|
+
// so it is built per-branch rather than once up front. `base` carries everything
|
|
1318
|
+
// that does not depend on the target.
|
|
1259
1319
|
const base = {
|
|
1260
1320
|
body: message,
|
|
1261
|
-
formattedMessage: formatBuilderMessage(CRON_SENDER, message),
|
|
1262
1321
|
supersedeKey: task.name,
|
|
1263
1322
|
};
|
|
1264
1323
|
const live = resolveTarget(task.target, task.workspacePath);
|
|
@@ -1266,6 +1325,7 @@ export async function deliverCronMessage(task, message, log) {
|
|
|
1266
1325
|
const { toAgent } = liveTargetIdentity(live);
|
|
1267
1326
|
return deliverCronMail(ports, db, {
|
|
1268
1327
|
...base,
|
|
1328
|
+
formattedMessage: formatBuilderMessage(CRON_SENDER, toAgent, message),
|
|
1269
1329
|
workspacePath: live.workspacePath,
|
|
1270
1330
|
toAgent,
|
|
1271
1331
|
terminalId: live.terminalId,
|
|
@@ -1279,6 +1339,7 @@ export async function deliverCronMessage(task, message, log) {
|
|
|
1279
1339
|
if (!isResolveError(reg)) {
|
|
1280
1340
|
return deliverCronMail(ports, db, {
|
|
1281
1341
|
...base,
|
|
1342
|
+
formattedMessage: formatBuilderMessage(CRON_SENDER, reg.agent, message),
|
|
1282
1343
|
workspacePath: reg.workspacePath,
|
|
1283
1344
|
toAgent: reg.agent,
|
|
1284
1345
|
terminalId: null,
|
|
@@ -1308,8 +1369,26 @@ function holdAndRespond(res, ctx, input, reason) {
|
|
|
1308
1369
|
held: true,
|
|
1309
1370
|
reason,
|
|
1310
1371
|
mailboxId: row.id,
|
|
1372
|
+
bodyLength: Buffer.byteLength(input.body, 'utf8'),
|
|
1311
1373
|
});
|
|
1312
1374
|
}
|
|
1375
|
+
/** Machine-readable reason paired with `degraded: true` on an operator send response. */
|
|
1376
|
+
const DEGRADED_SUBMIT_REASON = 'submit-wait-ceiling-expired';
|
|
1377
|
+
/**
|
|
1378
|
+
* Announce that an operator submission gave up waiting for the per-terminal lock and wrote
|
|
1379
|
+
* unserialized (Issue #1365).
|
|
1380
|
+
*
|
|
1381
|
+
* This is the degraded path, and it is deliberately loud. The ceiling arms only against a
|
|
1382
|
+
* DELIVERY write (behind another operator the wait stays unbounded, as before #1365), so what
|
|
1383
|
+
* expiry falls back to is exactly the pre-#1365 operator-vs-delivery behaviour: two disjoint
|
|
1384
|
+
* locks, no serialization. That one pair is therefore no worse than the old status quo — but
|
|
1385
|
+
* it used to be invisible, and now it is not, here and in the `degraded` flag on the response.
|
|
1386
|
+
*/
|
|
1387
|
+
function logCeilingExpired(ctx, action, toAgent, terminalId, waitedMs) {
|
|
1388
|
+
ctx.log('WARN', `${action} → ${toAgent} (terminal ${terminalId.slice(0, 8)}...) waited ${waitedMs}ms for an in-flight ` +
|
|
1389
|
+
`write and proceeded UNSERIALIZED — it may interleave with that write. A message long enough to ` +
|
|
1390
|
+
`hold the line this long is the usual cause.`);
|
|
1391
|
+
}
|
|
1313
1392
|
/**
|
|
1314
1393
|
* Handle a delayed (`--delay`) send (Spec 1307, re-homed onto the Spec 1313 mailbox; round 3
|
|
1315
1394
|
* durable rework). The row is RESOLVED, authorized (the builder-spoofing check inside
|
|
@@ -1372,7 +1451,7 @@ function handleDelayedSend(res, ctx, db, params) {
|
|
|
1372
1451
|
sendJson(res, statusCode, { error: errorCode, message: live.message });
|
|
1373
1452
|
return;
|
|
1374
1453
|
}
|
|
1375
|
-
const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
|
|
1454
|
+
const formattedMessage = formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw);
|
|
1376
1455
|
// Persist NOW, at request time, with the due time. reason=null → SCHEDULED, not stuck.
|
|
1377
1456
|
const row = enqueueMailbox(db, {
|
|
1378
1457
|
workspacePath,
|
|
@@ -1414,6 +1493,17 @@ function handleDelayedSend(res, ctx, db, params) {
|
|
|
1414
1493
|
live.write('\x03'); // Ctrl+C only — end the turn; body follows via the gate
|
|
1415
1494
|
fired = true;
|
|
1416
1495
|
return 0; // no body, no Enter on this path
|
|
1496
|
+
}, undefined,
|
|
1497
|
+
// The same operator ceiling as the immediate paths (Issue #1365). This one fires
|
|
1498
|
+
// UNATTENDED, so it is the writer most likely to meet a gated delivery mid-pace —
|
|
1499
|
+
// which is precisely why the delayed `^C` must take this lock at all.
|
|
1500
|
+
{
|
|
1501
|
+
waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
|
|
1502
|
+
onCeilingExpired: (waitedMs) => logCeilingExpired(ctx, 'delayed interrupt ^C', toAgent, terminalId, waitedMs),
|
|
1503
|
+
// This is the one operator path whose write can be a NO-OP (the liveness re-check
|
|
1504
|
+
// above). A degraded no-op raced nobody, so it must not bump the bypass counter —
|
|
1505
|
+
// that would make a concurrent delivery hold and re-deliver for nothing.
|
|
1506
|
+
wroteBytes: () => fired,
|
|
1417
1507
|
})
|
|
1418
1508
|
.then(() => ctx.log('INFO', fired
|
|
1419
1509
|
? `Delayed interrupt ^C fired → ${toAgent} (terminal ${terminalId.slice(0, 8)}...); body delivers via the gate`
|
|
@@ -1440,6 +1530,7 @@ function handleDelayedSend(res, ctx, db, params) {
|
|
|
1440
1530
|
deliverAfter,
|
|
1441
1531
|
mailboxId: row.id,
|
|
1442
1532
|
notBefore,
|
|
1533
|
+
bodyLength: Buffer.byteLength(params.message, 'utf8'),
|
|
1443
1534
|
});
|
|
1444
1535
|
}
|
|
1445
1536
|
async function handleSend(req, res, ctx) {
|
|
@@ -1457,6 +1548,17 @@ async function handleSend(req, res, ctx) {
|
|
|
1457
1548
|
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Missing or empty "message" field' }));
|
|
1458
1549
|
return;
|
|
1459
1550
|
}
|
|
1551
|
+
// Issue #1573: refuse an over-large body LOUDLY, here at the route rather than only at the
|
|
1552
|
+
// CLI — `/api/send` is a public local route, and the CLI is not its only caller. Nothing
|
|
1553
|
+
// downstream bounded message size, so an oversized body used to be paced onto the line one
|
|
1554
|
+
// line at a time and reported delivered whatever the composer made of it. Rejecting is the
|
|
1555
|
+
// only honest option: truncating would reproduce #1564 on purpose.
|
|
1556
|
+
const bodyLength = Buffer.byteLength(message, 'utf8');
|
|
1557
|
+
if (bodyLength > MAX_MESSAGE_BYTES) {
|
|
1558
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1559
|
+
res.end(JSON.stringify({ error: 'MESSAGE_TOO_LARGE', message: messageTooLargeError(bodyLength) }));
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1460
1562
|
// Optional fields
|
|
1461
1563
|
const from = typeof body.from === 'string' ? body.from : undefined;
|
|
1462
1564
|
const workspace = typeof body.workspace === 'string' ? body.workspace : undefined;
|
|
@@ -1523,7 +1625,7 @@ async function handleSend(req, res, ctx) {
|
|
|
1523
1625
|
workspacePath: reg.workspacePath,
|
|
1524
1626
|
toAgent: reg.agent,
|
|
1525
1627
|
body: message,
|
|
1526
|
-
formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, message, raw),
|
|
1628
|
+
formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, reg.agent, message, raw),
|
|
1527
1629
|
fromAgent: from ?? null,
|
|
1528
1630
|
fromWorkspace: senderWorkspace,
|
|
1529
1631
|
noEnter,
|
|
@@ -1561,7 +1663,7 @@ async function handleSend(req, res, ctx) {
|
|
|
1561
1663
|
workspacePath: result.workspacePath,
|
|
1562
1664
|
toAgent,
|
|
1563
1665
|
body: message,
|
|
1564
|
-
formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
|
|
1666
|
+
formattedMessage: formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw),
|
|
1565
1667
|
fromAgent: from ?? null,
|
|
1566
1668
|
fromWorkspace: senderWorkspace,
|
|
1567
1669
|
noEnter,
|
|
@@ -1586,7 +1688,7 @@ async function handleSend(req, res, ctx) {
|
|
|
1586
1688
|
workspacePath: result.workspacePath,
|
|
1587
1689
|
toAgent,
|
|
1588
1690
|
body: message,
|
|
1589
|
-
formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
|
|
1691
|
+
formattedMessage: formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw),
|
|
1590
1692
|
fromAgent: from ?? null,
|
|
1591
1693
|
fromWorkspace: senderWorkspace,
|
|
1592
1694
|
noEnter,
|
|
@@ -1602,7 +1704,17 @@ async function handleSend(req, res, ctx) {
|
|
|
1602
1704
|
if (escape) {
|
|
1603
1705
|
// Awaited: the response must not claim delivery before the ESC and its
|
|
1604
1706
|
// Enter have actually been written (Spec 1273 verify).
|
|
1605
|
-
|
|
1707
|
+
// Issue #1365: escape is an operator submission — it blocks behind an in-flight write
|
|
1708
|
+
// (including a gated mailbox delivery, which now takes this same lock) so its ESC and
|
|
1709
|
+
// Enter can no longer truncate a delivery mid-pace, but only up to the wait ceiling.
|
|
1710
|
+
let escapeDegraded = false;
|
|
1711
|
+
await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter), undefined, {
|
|
1712
|
+
waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
|
|
1713
|
+
onCeilingExpired: (waitedMs) => {
|
|
1714
|
+
escapeDegraded = true;
|
|
1715
|
+
logCeilingExpired(ctx, 'ESC', toAgent, result.terminalId, waitedMs);
|
|
1716
|
+
},
|
|
1717
|
+
});
|
|
1606
1718
|
broadcastMessage({
|
|
1607
1719
|
type: 'message',
|
|
1608
1720
|
from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
|
|
@@ -1612,10 +1724,16 @@ async function handleSend(req, res, ctx) {
|
|
|
1612
1724
|
timestamp: new Date().toISOString(),
|
|
1613
1725
|
});
|
|
1614
1726
|
ctx.log('INFO', `Interrupt (ESC) sent: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
|
|
1615
|
-
sendJson(res, 200, {
|
|
1727
|
+
sendJson(res, 200, {
|
|
1728
|
+
ok: true,
|
|
1729
|
+
terminalId: result.terminalId,
|
|
1730
|
+
resolvedTo: toAgent,
|
|
1731
|
+
deferred: false,
|
|
1732
|
+
...(escapeDegraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
|
|
1733
|
+
});
|
|
1616
1734
|
return;
|
|
1617
1735
|
}
|
|
1618
|
-
const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
|
|
1736
|
+
const formattedMessage = formatMessageForTarget(isArchitectTarget, from, toAgent, message, raw);
|
|
1619
1737
|
// NB: `--delay` (deliverAfter) is handled earlier by handleDelayedSend, before this
|
|
1620
1738
|
// immediate-path block — a delayed send is resolved + persisted at request time and does
|
|
1621
1739
|
// not fall through here.
|
|
@@ -1653,15 +1771,29 @@ async function handleSend(req, res, ctx) {
|
|
|
1653
1771
|
// during the 100 ms gap. `writeMessageToSession(..., 100)` schedules the text 100 ms after
|
|
1654
1772
|
// the ^C (the settle) and returns the completion offset, so the lock is held until the
|
|
1655
1773
|
// whole interrupt is on the wire; uncontended, it runs at once.
|
|
1656
|
-
// Scope of the guarantee
|
|
1657
|
-
//
|
|
1658
|
-
// concurrent
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
1774
|
+
// Scope of the guarantee (Issue #1365 — this used to end at "interrupt vs interrupt/escape"):
|
|
1775
|
+
// the gated mailbox delivery path now takes this SAME per-terminal lock, so an interrupt is
|
|
1776
|
+
// serialized against a concurrent delivery too. That closes the case where a `^C` landed
|
|
1777
|
+
// inside a delivery's own text→Enter window, clearing the composer while every byte still
|
|
1778
|
+
// reported success — the row read `delivered` for a message the agent never saw. The lock
|
|
1779
|
+
// order is per-agent → per-terminal (the delivery takes this one as a leaf), never the
|
|
1780
|
+
// reverse, so there is no cycle. See session-submit.ts for the full boundary.
|
|
1781
|
+
// Whether this interrupt gave up waiting and wrote unserialized. It must reach the SENDER,
|
|
1782
|
+
// not just the Tower log (codex review of PR #1492): the row above was claimed `delivered`
|
|
1783
|
+
// before the write, so a degraded interrupt would otherwise report an unqualified success
|
|
1784
|
+
// for a body that may have interleaved with the delivery it skipped. We keep the
|
|
1785
|
+
// claim-first tradeoff (un-claiming risks a double delivery — see above) and tell the
|
|
1786
|
+
// truth about it instead.
|
|
1787
|
+
let degraded = false;
|
|
1662
1788
|
await submitToSession(result.terminalId, () => {
|
|
1663
1789
|
session.write('\x03'); // Ctrl+C
|
|
1664
1790
|
return writeMessageToSession(session, formattedMessage, noEnter, 100);
|
|
1791
|
+
}, undefined, {
|
|
1792
|
+
waitCeilingMs: OPERATOR_SUBMIT_WAIT_CEILING_MS,
|
|
1793
|
+
onCeilingExpired: (waitedMs) => {
|
|
1794
|
+
degraded = true;
|
|
1795
|
+
logCeilingExpired(ctx, 'interrupt', toAgent, result.terminalId, waitedMs);
|
|
1796
|
+
},
|
|
1665
1797
|
});
|
|
1666
1798
|
broadcastMessage({
|
|
1667
1799
|
type: 'message',
|
|
@@ -1681,6 +1813,9 @@ async function handleSend(req, res, ctx) {
|
|
|
1681
1813
|
held: false,
|
|
1682
1814
|
mailboxId: row.id,
|
|
1683
1815
|
reason: null,
|
|
1816
|
+
bodyLength,
|
|
1817
|
+
// Additive and present only when it happened, so older clients are unaffected.
|
|
1818
|
+
...(degraded ? { degraded: true, degradedReason: DEGRADED_SUBMIT_REASON } : {}),
|
|
1684
1819
|
});
|
|
1685
1820
|
return;
|
|
1686
1821
|
}
|
|
@@ -1727,6 +1862,7 @@ async function handleSend(req, res, ctx) {
|
|
|
1727
1862
|
held: false,
|
|
1728
1863
|
mailboxId: row.id,
|
|
1729
1864
|
reason: null,
|
|
1865
|
+
bodyLength,
|
|
1730
1866
|
});
|
|
1731
1867
|
return;
|
|
1732
1868
|
}
|
|
@@ -1745,6 +1881,7 @@ async function handleSend(req, res, ctx) {
|
|
|
1745
1881
|
held: true,
|
|
1746
1882
|
reason,
|
|
1747
1883
|
mailboxId: row.id,
|
|
1884
|
+
bodyLength,
|
|
1748
1885
|
});
|
|
1749
1886
|
}
|
|
1750
1887
|
/**
|
|
@@ -1965,14 +2102,68 @@ function handleDashboard(res, ctx) {
|
|
|
1965
2102
|
}
|
|
1966
2103
|
try {
|
|
1967
2104
|
const template = fs.readFileSync(ctx.templatePath, 'utf-8');
|
|
1968
|
-
res
|
|
1969
|
-
res.end(template);
|
|
2105
|
+
sendKeyInjectedHtml(res, template);
|
|
1970
2106
|
}
|
|
1971
2107
|
catch (err) {
|
|
1972
2108
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1973
2109
|
res.end('Error loading template: ' + err.message);
|
|
1974
2110
|
}
|
|
1975
2111
|
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Same-origin key injection (advisory GHSA-xvjp-7748-v88v Layer 4). Writes the
|
|
2114
|
+
* shared local key into an HTML shell Tower serves (tower.html or the React SPA
|
|
2115
|
+
* index.html) so the page can authenticate its own API/WebSocket calls, without
|
|
2116
|
+
* embedding a readable secret in the shipped template. The key is hex, so
|
|
2117
|
+
* JSON.stringify yields a safe `<script>`-embeddable literal.
|
|
2118
|
+
*
|
|
2119
|
+
* tower.html carries an explicit placeholder; the built SPA index.html does not,
|
|
2120
|
+
* so the script is inserted before `</head>` there — ahead of the deferred SPA
|
|
2121
|
+
* module, so `window.__CODEV_TOWER_KEY__` is set before the app's first request.
|
|
2122
|
+
*/
|
|
2123
|
+
function injectWebKey(html) {
|
|
2124
|
+
const key = getExpectedKey();
|
|
2125
|
+
// Only embed a well-formed hex key. `ensureLocalKey` always produces 64 hex
|
|
2126
|
+
// chars; validating before embedding means a hand-edited or third-party-written
|
|
2127
|
+
// key file containing e.g. `</script>` can never become stored XSS in a shell.
|
|
2128
|
+
// A malformed key yields no injection (clients then fail closed with 401).
|
|
2129
|
+
const injection = key && /^[0-9a-f]{64}$/.test(key)
|
|
2130
|
+
? `<script>window.__CODEV_TOWER_KEY__ = ${JSON.stringify(key)};</script>`
|
|
2131
|
+
: '';
|
|
2132
|
+
if (html.includes('<!-- CODEV_TOWER_KEY_INJECTION -->')) {
|
|
2133
|
+
return html.replace('<!-- CODEV_TOWER_KEY_INJECTION -->', injection);
|
|
2134
|
+
}
|
|
2135
|
+
if (injection && html.includes('</head>')) {
|
|
2136
|
+
return html.replace('</head>', `${injection}</head>`);
|
|
2137
|
+
}
|
|
2138
|
+
return html;
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Send a key-injected HTML shell. Strips the CORS `Access-Control-Allow-Origin`
|
|
2142
|
+
* header set by the front door: this response body carries the key, and the
|
|
2143
|
+
* shell is only ever loaded by a same-origin navigation, so it must never be
|
|
2144
|
+
* readable by a cross-origin `fetch` (advisory GHSA-xvjp-7748-v88v). Same-origin
|
|
2145
|
+
* reads are unaffected; the key check still guards the actual API routes.
|
|
2146
|
+
*/
|
|
2147
|
+
function sendKeyInjectedHtml(res, template) {
|
|
2148
|
+
res.removeHeader('Access-Control-Allow-Origin');
|
|
2149
|
+
res.removeHeader('Vary');
|
|
2150
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2151
|
+
res.end(injectWebKey(template));
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Serve the React dashboard's index.html with the key injected (SPA shell + SPA
|
|
2155
|
+
* client-side-routing fallback). Returns false if the file cannot be read.
|
|
2156
|
+
*/
|
|
2157
|
+
function serveDashboardIndex(dashboardPath, res) {
|
|
2158
|
+
try {
|
|
2159
|
+
const html = fs.readFileSync(path.join(dashboardPath, 'index.html'), 'utf-8');
|
|
2160
|
+
sendKeyInjectedHtml(res, html);
|
|
2161
|
+
return true;
|
|
2162
|
+
}
|
|
2163
|
+
catch {
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
1976
2167
|
// ============================================================================
|
|
1977
2168
|
// Workspace-scoped route handler
|
|
1978
2169
|
// ============================================================================
|
|
@@ -2035,22 +2226,24 @@ async function handleWorkspaceRoutes(req, res, ctx, url) {
|
|
|
2035
2226
|
// 3. React dashboard is available
|
|
2036
2227
|
// 4. Workspace doesn't need to be running for static files
|
|
2037
2228
|
if (!isApiCall && !isWsPath && ctx.hasReactDashboard) {
|
|
2038
|
-
//
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2229
|
+
// The SPA shell (index.html) is served with the shared key injected same-origin
|
|
2230
|
+
// (advisory GHSA-xvjp-7748-v88v) so a direct navigation to a workspace URL can
|
|
2231
|
+
// authenticate without first visiting the Tower root. Other static assets
|
|
2232
|
+
// (JS/CSS/images) carry no secret and stream as-is.
|
|
2233
|
+
const isIndex = !subPath || subPath === '' || subPath === 'index.html';
|
|
2234
|
+
if (isIndex) {
|
|
2235
|
+
if (serveDashboardIndex(ctx.reactDashboardPath, res)) {
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2042
2238
|
}
|
|
2043
2239
|
else {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
if (serveStaticFile(staticPath, res)) {
|
|
2049
|
-
return;
|
|
2240
|
+
const staticPath = path.join(ctx.reactDashboardPath, subPath);
|
|
2241
|
+
if (serveStaticFile(staticPath, res)) {
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2050
2244
|
}
|
|
2051
|
-
// SPA fallback: serve index.html for client-side routing
|
|
2052
|
-
|
|
2053
|
-
if (serveStaticFile(indexPath, res)) {
|
|
2245
|
+
// SPA fallback: serve the (key-injected) index.html for client-side routing.
|
|
2246
|
+
if (serveDashboardIndex(ctx.reactDashboardPath, res)) {
|
|
2054
2247
|
return;
|
|
2055
2248
|
}
|
|
2056
2249
|
}
|
|
@@ -2979,16 +3172,24 @@ function handleWorkspaceAnnotate(req, res, ctx, url, workspacePath, annotateMatc
|
|
|
2979
3172
|
let html = fs.readFileSync(tplPath, 'utf-8');
|
|
2980
3173
|
const fileName = path.basename(filePath);
|
|
2981
3174
|
const fileSize = fs.statSync(filePath).size;
|
|
3175
|
+
// The shell carries the injected key (advisory GHSA-xvjp-7748-v88v), so a
|
|
3176
|
+
// maliciously-named file must not become XSS (which would read the key).
|
|
3177
|
+
// HTML-escape values that land in markup/attributes (safe in the JS string
|
|
3178
|
+
// contexts too — it blocks `<`, `"`, `'`), and for the JSON-in-<script>
|
|
3179
|
+
// value escape `<` so a filename containing `</script>` can't break out.
|
|
3180
|
+
const safeFileName = escapeHtml(fileName);
|
|
3181
|
+
const safeFilePath = escapeHtml(filePath);
|
|
3182
|
+
const filePathJson = JSON.stringify(filePath).replace(/</g, '\\u003c');
|
|
2982
3183
|
if (is3D) {
|
|
2983
|
-
html = html.replace(/\{\{FILE\}\}/g,
|
|
2984
|
-
html = html.replace(/\{\{FILE_PATH_JSON\}\}/g,
|
|
2985
|
-
html = html.replace(/\{\{FORMAT\}\}/g, ext);
|
|
3184
|
+
html = html.replace(/\{\{FILE\}\}/g, safeFileName);
|
|
3185
|
+
html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, filePathJson);
|
|
3186
|
+
html = html.replace(/\{\{FORMAT\}\}/g, escapeHtml(ext));
|
|
2986
3187
|
}
|
|
2987
3188
|
else {
|
|
2988
|
-
html = html.replace(/\{\{FILE\}\}/g,
|
|
2989
|
-
html = html.replace(/\{\{FILE_PATH\}\}/g,
|
|
3189
|
+
html = html.replace(/\{\{FILE\}\}/g, safeFileName);
|
|
3190
|
+
html = html.replace(/\{\{FILE_PATH\}\}/g, safeFilePath);
|
|
2990
3191
|
html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
|
|
2991
|
-
html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
|
|
3192
|
+
html = html.replace(/\{\{LANG\}\}/g, escapeHtml(getLanguageForExt(ext)));
|
|
2992
3193
|
html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
|
|
2993
3194
|
html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
|
|
2994
3195
|
html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
|
|
@@ -3007,18 +3208,22 @@ function handleWorkspaceAnnotate(req, res, ctx, url, workspacePath, annotateMatc
|
|
|
3007
3208
|
initScript = `initPdf(${fileSize});`;
|
|
3008
3209
|
}
|
|
3009
3210
|
else {
|
|
3010
|
-
initScript = `fetch('file').then(r=>r.text()).then(init);`;
|
|
3211
|
+
initScript = `fetch('file',{headers:authHeaders()}).then(r=>r.text()).then(init);`;
|
|
3011
3212
|
}
|
|
3012
3213
|
html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
|
|
3013
3214
|
}
|
|
3014
|
-
// Handle ?line= query param for scroll-to-line
|
|
3215
|
+
// Handle ?line= query param for scroll-to-line. Validate as a bare integer
|
|
3216
|
+
// before interpolating into the script — untrusted query input must never
|
|
3217
|
+
// reach the key-bearing shell's markup unescaped.
|
|
3015
3218
|
const lineParam = url.searchParams.get('line');
|
|
3016
|
-
if (lineParam) {
|
|
3219
|
+
if (lineParam && /^\d+$/.test(lineParam)) {
|
|
3017
3220
|
const scrollScript = `<script>window.addEventListener('load',()=>{setTimeout(()=>{const el=document.querySelector('[data-line="${lineParam}"]');if(el){el.scrollIntoView({block:'center'});el.classList.add('highlighted-line');}},200);})</script>`;
|
|
3018
3221
|
html = html.replace('</body>', `${scrollScript}</body>`);
|
|
3019
3222
|
}
|
|
3020
|
-
|
|
3021
|
-
|
|
3223
|
+
// Same-origin key injection so the annotator's fetches can authenticate
|
|
3224
|
+
// (advisory GHSA-xvjp-7748-v88v). The shell is public (iframe navigation);
|
|
3225
|
+
// its data/media sub-routes stay keyed.
|
|
3226
|
+
sendKeyInjectedHtml(res, html);
|
|
3022
3227
|
}
|
|
3023
3228
|
catch (err) {
|
|
3024
3229
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|