@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.
Files changed (202) hide show
  1. package/dashboard-dist/assets/{index-JCbkpAcV.js → index-D3XaKpZW.js} +45 -43
  2. package/dashboard-dist/assets/index-D3XaKpZW.js.map +1 -0
  3. package/dashboard-dist/index.html +1 -1
  4. package/dist/agent-farm/cli.d.ts.map +1 -1
  5. package/dist/agent-farm/cli.js +99 -36
  6. package/dist/agent-farm/cli.js.map +1 -1
  7. package/dist/agent-farm/commands/reset/constants.d.ts +119 -1
  8. package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/reset/constants.js +119 -1
  10. package/dist/agent-farm/commands/reset/constants.js.map +1 -1
  11. package/dist/agent-farm/commands/reset/context.d.ts +20 -2
  12. package/dist/agent-farm/commands/reset/context.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/reset/context.js +44 -3
  14. package/dist/agent-farm/commands/reset/context.js.map +1 -1
  15. package/dist/agent-farm/commands/reset/index.d.ts +7 -7
  16. package/dist/agent-farm/commands/reset/index.js +8 -8
  17. package/dist/agent-farm/commands/reset/index.js.map +1 -1
  18. package/dist/agent-farm/commands/reset/receipt.d.ts +1 -1
  19. package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -1
  20. package/dist/agent-farm/commands/reset/receipt.js +19 -8
  21. package/dist/agent-farm/commands/reset/receipt.js.map +1 -1
  22. package/dist/agent-farm/commands/reset/reorient.d.ts +1 -1
  23. package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -1
  24. package/dist/agent-farm/commands/reset/reorient.js +12 -5
  25. package/dist/agent-farm/commands/reset/reorient.js.map +1 -1
  26. package/dist/agent-farm/commands/reset/self.d.ts +335 -0
  27. package/dist/agent-farm/commands/reset/self.d.ts.map +1 -0
  28. package/dist/agent-farm/commands/reset/self.js +636 -0
  29. package/dist/agent-farm/commands/reset/self.js.map +1 -0
  30. package/dist/agent-farm/commands/reset.d.ts +18 -3
  31. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/reset.js +55 -33
  33. package/dist/agent-farm/commands/reset.js.map +1 -1
  34. package/dist/agent-farm/commands/self-refresh.d.ts +60 -0
  35. package/dist/agent-farm/commands/self-refresh.d.ts.map +1 -0
  36. package/dist/agent-farm/commands/self-refresh.js +506 -0
  37. package/dist/agent-farm/commands/self-refresh.js.map +1 -0
  38. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  39. package/dist/agent-farm/commands/send.js +27 -2
  40. package/dist/agent-farm/commands/send.js.map +1 -1
  41. package/dist/agent-farm/commands/spawn-worktree.js +1 -1
  42. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
  43. package/dist/agent-farm/commands/spawn.d.ts +7 -0
  44. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  45. package/dist/agent-farm/commands/spawn.js +13 -1
  46. package/dist/agent-farm/commands/spawn.js.map +1 -1
  47. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  48. package/dist/agent-farm/commands/tower.js +13 -0
  49. package/dist/agent-farm/commands/tower.js.map +1 -1
  50. package/dist/agent-farm/lib/reconnect-backoff.d.ts +10 -0
  51. package/dist/agent-farm/lib/reconnect-backoff.d.ts.map +1 -1
  52. package/dist/agent-farm/lib/reconnect-backoff.js +10 -0
  53. package/dist/agent-farm/lib/reconnect-backoff.js.map +1 -1
  54. package/dist/agent-farm/lib/tower-client.d.ts +12 -0
  55. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
  56. package/dist/agent-farm/lib/tower-client.js +12 -0
  57. package/dist/agent-farm/lib/tower-client.js.map +1 -1
  58. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  59. package/dist/agent-farm/lib/tunnel-client.js +7 -3
  60. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  61. package/dist/agent-farm/servers/canvas-relay.d.ts +60 -0
  62. package/dist/agent-farm/servers/canvas-relay.d.ts.map +1 -0
  63. package/dist/agent-farm/servers/canvas-relay.js +376 -0
  64. package/dist/agent-farm/servers/canvas-relay.js.map +1 -0
  65. package/dist/agent-farm/servers/gate-profiles.d.ts +1 -1
  66. package/dist/agent-farm/servers/gate-profiles.js +1 -1
  67. package/dist/agent-farm/servers/mailbox-delivery.d.ts +141 -13
  68. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  69. package/dist/agent-farm/servers/mailbox-delivery.js +172 -16
  70. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  71. package/dist/agent-farm/servers/mailbox-wiring.d.ts +24 -2
  72. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  73. package/dist/agent-farm/servers/mailbox-wiring.js +92 -32
  74. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  75. package/dist/agent-farm/servers/message-write.d.ts +58 -18
  76. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  77. package/dist/agent-farm/servers/message-write.js +73 -28
  78. package/dist/agent-farm/servers/message-write.js.map +1 -1
  79. package/dist/agent-farm/servers/overview.d.ts +19 -0
  80. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  81. package/dist/agent-farm/servers/overview.js +78 -1
  82. package/dist/agent-farm/servers/overview.js.map +1 -1
  83. package/dist/agent-farm/servers/render-gate.d.ts +13 -0
  84. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  85. package/dist/agent-farm/servers/render-gate.js +21 -0
  86. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  87. package/dist/agent-farm/servers/session-submit.d.ts +221 -28
  88. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
  89. package/dist/agent-farm/servers/session-submit.js +334 -43
  90. package/dist/agent-farm/servers/session-submit.js.map +1 -1
  91. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  92. package/dist/agent-farm/servers/tower-routes.js +269 -64
  93. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  94. package/dist/agent-farm/servers/tower-server.js +25 -3
  95. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  96. package/dist/agent-farm/servers/tower-terminals.d.ts +6 -0
  97. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  98. package/dist/agent-farm/servers/tower-terminals.js +30 -5
  99. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  100. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
  101. package/dist/agent-farm/servers/tower-tunnel.js +14 -0
  102. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
  103. package/dist/agent-farm/servers/tower-websocket.d.ts +6 -1
  104. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  105. package/dist/agent-farm/servers/tower-websocket.js +90 -37
  106. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  107. package/dist/agent-farm/types.d.ts +37 -2
  108. package/dist/agent-farm/types.d.ts.map +1 -1
  109. package/dist/agent-farm/utils/harness.d.ts +3 -3
  110. package/dist/agent-farm/utils/harness.js +1 -1
  111. package/dist/agent-farm/utils/message-format.d.ts +67 -3
  112. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  113. package/dist/agent-farm/utils/message-format.js +127 -14
  114. package/dist/agent-farm/utils/message-format.js.map +1 -1
  115. package/dist/agent-farm/utils/server-utils.d.ts +86 -4
  116. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  117. package/dist/agent-farm/utils/server-utils.js +303 -5
  118. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  119. package/dist/commands/porch/context-refresh.d.ts +181 -0
  120. package/dist/commands/porch/context-refresh.d.ts.map +1 -0
  121. package/dist/commands/porch/context-refresh.js +270 -0
  122. package/dist/commands/porch/context-refresh.js.map +1 -0
  123. package/dist/commands/porch/index.d.ts +6 -0
  124. package/dist/commands/porch/index.d.ts.map +1 -1
  125. package/dist/commands/porch/index.js +46 -0
  126. package/dist/commands/porch/index.js.map +1 -1
  127. package/dist/commands/porch/next.d.ts +0 -9
  128. package/dist/commands/porch/next.d.ts.map +1 -1
  129. package/dist/commands/porch/next.js +133 -4
  130. package/dist/commands/porch/next.js.map +1 -1
  131. package/dist/commands/porch/protocol.d.ts.map +1 -1
  132. package/dist/commands/porch/protocol.js +102 -0
  133. package/dist/commands/porch/protocol.js.map +1 -1
  134. package/dist/commands/porch/types.d.ts +61 -0
  135. package/dist/commands/porch/types.d.ts.map +1 -1
  136. package/dist/lib/forge-contracts.d.ts +9 -0
  137. package/dist/lib/forge-contracts.d.ts.map +1 -1
  138. package/dist/lib/forge-contracts.js.map +1 -1
  139. package/dist/lib/github.d.ts +15 -1
  140. package/dist/lib/github.d.ts.map +1 -1
  141. package/dist/lib/github.js +17 -0
  142. package/dist/lib/github.js.map +1 -1
  143. package/dist/lib/self-refresh-invocation.d.ts +60 -0
  144. package/dist/lib/self-refresh-invocation.d.ts.map +1 -0
  145. package/dist/lib/self-refresh-invocation.js +64 -0
  146. package/dist/lib/self-refresh-invocation.js.map +1 -0
  147. package/dist/lib/test-env.d.ts +50 -0
  148. package/dist/lib/test-env.d.ts.map +1 -1
  149. package/dist/lib/test-env.js +76 -0
  150. package/dist/lib/test-env.js.map +1 -1
  151. package/dist/terminal/attach-replay.d.ts +39 -0
  152. package/dist/terminal/attach-replay.d.ts.map +1 -0
  153. package/dist/terminal/attach-replay.js +76 -0
  154. package/dist/terminal/attach-replay.js.map +1 -0
  155. package/dist/terminal/pty-manager.d.ts.map +1 -1
  156. package/dist/terminal/pty-manager.js +35 -17
  157. package/dist/terminal/pty-manager.js.map +1 -1
  158. package/dist/terminal/pty-session.d.ts +81 -12
  159. package/dist/terminal/pty-session.d.ts.map +1 -1
  160. package/dist/terminal/pty-session.js +113 -25
  161. package/dist/terminal/pty-session.js.map +1 -1
  162. package/dist/terminal/session-screen.d.ts +25 -0
  163. package/dist/terminal/session-screen.d.ts.map +1 -1
  164. package/dist/terminal/session-screen.js +53 -8
  165. package/dist/terminal/session-screen.js.map +1 -1
  166. package/package.json +10 -7
  167. package/scripts/forge/gitea/pr-view.sh +4 -1
  168. package/scripts/forge/github/pr-view.sh +1 -1
  169. package/scripts/forge/gitlab/pr-view.sh +4 -1
  170. package/skeleton/.claude/skills/afx/SKILL.md +54 -0
  171. package/skeleton/.claude/skills/arch-save/SKILL.md +8 -2
  172. package/skeleton/.claude/skills/builder-refresh/SKILL.md +138 -0
  173. package/skeleton/.codex/skills/afx/SKILL.md +54 -0
  174. package/skeleton/.codex/skills/arch-save/SKILL.md +8 -2
  175. package/skeleton/.codex/skills/builder-refresh/SKILL.md +138 -0
  176. package/skeleton/protocol-schema.json +17 -0
  177. package/skeleton/protocols/air/protocol.json +1 -1
  178. package/skeleton/protocols/aspir/protocol.json +5 -1
  179. package/skeleton/protocols/bugfix/protocol.json +1 -1
  180. package/skeleton/protocols/experiment/protocol.json +1 -1
  181. package/skeleton/protocols/maintain/protocol.json +1 -1
  182. package/skeleton/protocols/pir/builder-prompt.md +3 -2
  183. package/skeleton/protocols/pir/prompts/implement.md +1 -1
  184. package/skeleton/protocols/pir/prompts/plan.md +1 -1
  185. package/skeleton/protocols/pir/prompts/review.md +1 -1
  186. package/skeleton/protocols/pir/protocol.json +1 -1
  187. package/skeleton/protocols/pir/protocol.md +4 -4
  188. package/skeleton/protocols/protocol-schema.json +17 -0
  189. package/skeleton/protocols/research/protocol.json +1 -1
  190. package/skeleton/protocols/spike/protocol.json +1 -1
  191. package/skeleton/protocols/spir/protocol.json +5 -1
  192. package/skeleton/resources/commands/agent-farm.md +14 -11
  193. package/skeleton/roles/builder.md +3 -4
  194. package/templates/3d-viewer.html +32 -4
  195. package/templates/open.html +58 -12
  196. package/templates/tower.html +26 -7
  197. package/templates/vendor/three-3MFLoader.js +1478 -0
  198. package/templates/vendor/three-STLLoader.js +410 -0
  199. package/templates/vendor/three-TrackballControls.js +828 -0
  200. package/templates/vendor/three-fflate.module.js +2474 -0
  201. package/templates/vendor/three.module.js +53044 -0
  202. 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 { formatArchitectMessage, formatBuilderMessage } from '../utils/message-format.js';
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 — preserved across the Spec 1313 merge for
43
- // the two explicit human-bypass paths (escape + interrupt), which do NOT route
44
- // through the mailbox's per-agent serializer and so need their own anti-fusion lock.
45
- import { submitToSession } from './session-submit.js';
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
- // Security: Validate Host and Origin headers
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.startsWith('http://localhost:') ||
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', 'Content-Type, Authorization');
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
- /** Format a message per sender/target — preserves the pre-1313 formatting rules. */
1234
- function formatMessageForTarget(isArchitectTarget, from, message, raw) {
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 formatArchitectMessage(message, undefined, raw); // any → builder
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
- await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter));
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, { ok: true, terminalId: result.terminalId, resolvedTo: toAgent, deferred: false });
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: this serializes interrupt against interrupt/escape — the only
1657
- // /api/send writers that take this per-terminal lock. It does NOT serialize against a
1658
- // concurrent mailbox/backstop delivery (which writes through the per-AGENT serializer, a
1659
- // disjoint lock); interrupt is the explicit gate-bypassing human action, and closing that
1660
- // cross-path race would require the mailbox write edge to take this lock too (a separate,
1661
- // larger change — flagged, not done here).
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.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
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
- // Determine which static file to serve
2039
- let staticPath;
2040
- if (!subPath || subPath === '' || subPath === 'index.html') {
2041
- staticPath = path.join(ctx.reactDashboardPath, 'index.html');
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
- // Check if it's a static asset
2045
- staticPath = path.join(ctx.reactDashboardPath, subPath);
2046
- }
2047
- // Try to serve the static file
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
- const indexPath = path.join(ctx.reactDashboardPath, 'index.html');
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, fileName);
2984
- html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
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, fileName);
2989
- html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
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
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3021
- res.end(html);
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' });