night-orch 0.3.2 → 0.4.0

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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -108
  3. package/dist/cli/commands/doctor.d.ts +1 -0
  4. package/dist/cli/commands/doctor.d.ts.map +1 -1
  5. package/dist/cli/commands/doctor.js +18 -0
  6. package/dist/cli/commands/doctor.js.map +1 -1
  7. package/dist/cli/commands/monitoring.d.ts.map +1 -1
  8. package/dist/cli/commands/monitoring.js +7 -3
  9. package/dist/cli/commands/monitoring.js.map +1 -1
  10. package/dist/cli/commands/settings.d.ts.map +1 -1
  11. package/dist/cli/commands/settings.js +40 -4
  12. package/dist/cli/commands/settings.js.map +1 -1
  13. package/dist/cli/commands/status.d.ts.map +1 -1
  14. package/dist/cli/commands/status.js +26 -3
  15. package/dist/cli/commands/status.js.map +1 -1
  16. package/dist/cli/index.js +3 -2
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/cli/tui/app.d.ts.map +1 -1
  19. package/dist/cli/tui/app.js +52 -3
  20. package/dist/cli/tui/app.js.map +1 -1
  21. package/dist/cli/tui/header.d.ts.map +1 -1
  22. package/dist/cli/tui/header.js +2 -1
  23. package/dist/cli/tui/header.js.map +1 -1
  24. package/dist/cli/tui/settings-view.d.ts.map +1 -1
  25. package/dist/cli/tui/settings-view.js +22 -5
  26. package/dist/cli/tui/settings-view.js.map +1 -1
  27. package/dist/cli/tui/stats-view.js +2 -2
  28. package/dist/cli/tui/stats-view.js.map +1 -1
  29. package/dist/config/loader.d.ts.map +1 -1
  30. package/dist/config/loader.js +141 -13
  31. package/dist/config/loader.js.map +1 -1
  32. package/dist/config/schema.d.ts +903 -21
  33. package/dist/config/schema.d.ts.map +1 -1
  34. package/dist/config/schema.js +77 -6
  35. package/dist/config/schema.js.map +1 -1
  36. package/dist/forge/types.d.ts +7 -0
  37. package/dist/forge/types.d.ts.map +1 -1
  38. package/dist/git/repo.d.ts +8 -0
  39. package/dist/git/repo.d.ts.map +1 -1
  40. package/dist/git/repo.js +19 -0
  41. package/dist/git/repo.js.map +1 -1
  42. package/dist/git/worktree.d.ts +3 -0
  43. package/dist/git/worktree.d.ts.map +1 -1
  44. package/dist/git/worktree.js +43 -21
  45. package/dist/git/worktree.js.map +1 -1
  46. package/dist/loop/cost.d.ts +33 -8
  47. package/dist/loop/cost.d.ts.map +1 -1
  48. package/dist/loop/cost.js +250 -46
  49. package/dist/loop/cost.js.map +1 -1
  50. package/dist/loop/decision.js +3 -3
  51. package/dist/loop/decision.js.map +1 -1
  52. package/dist/loop/engine.d.ts +1 -0
  53. package/dist/loop/engine.d.ts.map +1 -1
  54. package/dist/loop/engine.js +76 -13
  55. package/dist/loop/engine.js.map +1 -1
  56. package/dist/loop/parallel.d.ts.map +1 -1
  57. package/dist/loop/parallel.js +2 -0
  58. package/dist/loop/parallel.js.map +1 -1
  59. package/dist/loop/pricing.d.ts +11 -4
  60. package/dist/loop/pricing.d.ts.map +1 -1
  61. package/dist/loop/pricing.js +62 -20
  62. package/dist/loop/pricing.js.map +1 -1
  63. package/dist/loop/progress.d.ts +24 -0
  64. package/dist/loop/progress.d.ts.map +1 -0
  65. package/dist/loop/progress.js +52 -0
  66. package/dist/loop/progress.js.map +1 -0
  67. package/dist/loop/step-executor.d.ts +4 -5
  68. package/dist/loop/step-executor.d.ts.map +1 -1
  69. package/dist/loop/step-executor.js +1 -0
  70. package/dist/loop/step-executor.js.map +1 -1
  71. package/dist/loop/types.d.ts +17 -0
  72. package/dist/loop/types.d.ts.map +1 -1
  73. package/dist/mcp/tools/admin.d.ts +29 -0
  74. package/dist/mcp/tools/admin.d.ts.map +1 -0
  75. package/dist/mcp/tools/admin.js +89 -0
  76. package/dist/mcp/tools/admin.js.map +1 -0
  77. package/dist/mcp/tools/auth.d.ts +3 -0
  78. package/dist/mcp/tools/auth.d.ts.map +1 -0
  79. package/dist/mcp/tools/auth.js +19 -0
  80. package/dist/mcp/tools/auth.js.map +1 -0
  81. package/dist/mcp/tools/index.d.ts.map +1 -1
  82. package/dist/mcp/tools/index.js +5 -533
  83. package/dist/mcp/tools/index.js.map +1 -1
  84. package/dist/mcp/tools/operations.d.ts +32 -0
  85. package/dist/mcp/tools/operations.d.ts.map +1 -0
  86. package/dist/mcp/tools/operations.js +95 -0
  87. package/dist/mcp/tools/operations.js.map +1 -0
  88. package/dist/mcp/tools/settings.d.ts +12 -0
  89. package/dist/mcp/tools/settings.d.ts.map +1 -0
  90. package/dist/mcp/tools/settings.js +58 -0
  91. package/dist/mcp/tools/settings.js.map +1 -0
  92. package/dist/mcp/tools/status.d.ts +25 -0
  93. package/dist/mcp/tools/status.d.ts.map +1 -0
  94. package/dist/mcp/tools/status.js +307 -0
  95. package/dist/mcp/tools/status.js.map +1 -0
  96. package/dist/ops/continue.js +6 -1
  97. package/dist/ops/continue.js.map +1 -1
  98. package/dist/ops/project-check.d.ts +15 -0
  99. package/dist/ops/project-check.d.ts.map +1 -0
  100. package/dist/ops/project-check.js +136 -0
  101. package/dist/ops/project-check.js.map +1 -0
  102. package/dist/ops/rebase-and-check.d.ts +2 -1
  103. package/dist/ops/rebase-and-check.d.ts.map +1 -1
  104. package/dist/ops/rebase-and-check.js +2 -2
  105. package/dist/ops/rebase-and-check.js.map +1 -1
  106. package/dist/ops/rebase.d.ts +8 -5
  107. package/dist/ops/rebase.d.ts.map +1 -1
  108. package/dist/ops/rebase.js +43 -29
  109. package/dist/ops/rebase.js.map +1 -1
  110. package/dist/runner/comment-commands.d.ts +20 -0
  111. package/dist/runner/comment-commands.d.ts.map +1 -0
  112. package/dist/runner/comment-commands.js +221 -0
  113. package/dist/runner/comment-commands.js.map +1 -0
  114. package/dist/runner/helpers.d.ts +57 -0
  115. package/dist/runner/helpers.d.ts.map +1 -0
  116. package/dist/runner/helpers.js +259 -0
  117. package/dist/runner/helpers.js.map +1 -0
  118. package/dist/runner/poller.d.ts.map +1 -1
  119. package/dist/runner/poller.js +19 -781
  120. package/dist/runner/poller.js.map +1 -1
  121. package/dist/runner/reaction-scan.d.ts +17 -0
  122. package/dist/runner/reaction-scan.d.ts.map +1 -0
  123. package/dist/runner/reaction-scan.js +33 -0
  124. package/dist/runner/reaction-scan.js.map +1 -0
  125. package/dist/runner/run-finalizer.d.ts +30 -0
  126. package/dist/runner/run-finalizer.d.ts.map +1 -0
  127. package/dist/runner/run-finalizer.js +217 -0
  128. package/dist/runner/run-finalizer.js.map +1 -0
  129. package/dist/settings/definitions/github.d.ts +3 -0
  130. package/dist/settings/definitions/github.d.ts.map +1 -0
  131. package/dist/settings/definitions/github.js +267 -0
  132. package/dist/settings/definitions/github.js.map +1 -0
  133. package/dist/settings/definitions/loop.d.ts +3 -0
  134. package/dist/settings/definitions/loop.d.ts.map +1 -0
  135. package/dist/settings/definitions/loop.js +113 -0
  136. package/dist/settings/definitions/loop.js.map +1 -0
  137. package/dist/settings/definitions/observability.d.ts +3 -0
  138. package/dist/settings/definitions/observability.d.ts.map +1 -0
  139. package/dist/settings/definitions/observability.js +74 -0
  140. package/dist/settings/definitions/observability.js.map +1 -0
  141. package/dist/settings/definitions/security.d.ts +3 -0
  142. package/dist/settings/definitions/security.d.ts.map +1 -0
  143. package/dist/settings/definitions/security.js +121 -0
  144. package/dist/settings/definitions/security.js.map +1 -0
  145. package/dist/settings/registry.d.ts +82 -6
  146. package/dist/settings/registry.d.ts.map +1 -1
  147. package/dist/settings/registry.js +301 -194
  148. package/dist/settings/registry.js.map +1 -1
  149. package/dist/settings/runtime.d.ts +5 -1
  150. package/dist/settings/runtime.d.ts.map +1 -1
  151. package/dist/settings/runtime.js +46 -9
  152. package/dist/settings/runtime.js.map +1 -1
  153. package/dist/state/db.d.ts.map +1 -1
  154. package/dist/state/db.js +2 -0
  155. package/dist/state/db.js.map +1 -1
  156. package/dist/state/migrations/020-cost-ledger.d.ts +3 -0
  157. package/dist/state/migrations/020-cost-ledger.d.ts.map +1 -0
  158. package/dist/state/migrations/020-cost-ledger.js +37 -0
  159. package/dist/state/migrations/020-cost-ledger.js.map +1 -0
  160. package/dist/state/runs.d.ts +1 -0
  161. package/dist/state/runs.d.ts.map +1 -1
  162. package/dist/state/runs.js +3 -0
  163. package/dist/state/runs.js.map +1 -1
  164. package/dist/state/stats.d.ts +20 -0
  165. package/dist/state/stats.d.ts.map +1 -1
  166. package/dist/state/stats.js +68 -8
  167. package/dist/state/stats.js.map +1 -1
  168. package/dist/utils/logger.d.ts +9 -0
  169. package/dist/utils/logger.d.ts.map +1 -1
  170. package/dist/utils/logger.js +13 -0
  171. package/dist/utils/logger.js.map +1 -1
  172. package/dist/web/routes/api-events.d.ts +10 -0
  173. package/dist/web/routes/api-events.d.ts.map +1 -0
  174. package/dist/web/routes/api-events.js +251 -0
  175. package/dist/web/routes/api-events.js.map +1 -0
  176. package/dist/web/routes/api-operations.d.ts +3 -0
  177. package/dist/web/routes/api-operations.d.ts.map +1 -0
  178. package/dist/web/routes/api-operations.js +371 -0
  179. package/dist/web/routes/api-operations.js.map +1 -0
  180. package/dist/web/routes/api-runs.d.ts +3 -0
  181. package/dist/web/routes/api-runs.d.ts.map +1 -0
  182. package/dist/web/routes/api-runs.js +96 -0
  183. package/dist/web/routes/api-runs.js.map +1 -0
  184. package/dist/web/routes/api-settings.d.ts +3 -0
  185. package/dist/web/routes/api-settings.d.ts.map +1 -0
  186. package/dist/web/routes/api-settings.js +61 -0
  187. package/dist/web/routes/api-settings.js.map +1 -0
  188. package/dist/web/routes/context.d.ts +15 -0
  189. package/dist/web/routes/context.d.ts.map +1 -0
  190. package/dist/web/routes/context.js +2 -0
  191. package/dist/web/routes/context.js.map +1 -0
  192. package/dist/web/server.d.ts +58 -1
  193. package/dist/web/server.d.ts.map +1 -1
  194. package/dist/web/server.js +116 -847
  195. package/dist/web/server.js.map +1 -1
  196. package/dist/web/shell-session.d.ts +74 -0
  197. package/dist/web/shell-session.d.ts.map +1 -0
  198. package/dist/web/shell-session.js +279 -0
  199. package/dist/web/shell-session.js.map +1 -0
  200. package/dist/web/snapshots.d.ts +159 -0
  201. package/dist/web/snapshots.d.ts.map +1 -0
  202. package/dist/web/snapshots.js +231 -0
  203. package/dist/web/snapshots.js.map +1 -0
  204. package/dist/workers/acp.d.ts.map +1 -1
  205. package/dist/workers/acp.js +116 -0
  206. package/dist/workers/acp.js.map +1 -1
  207. package/dist/workers/claude.d.ts.map +1 -1
  208. package/dist/workers/claude.js +13 -3
  209. package/dist/workers/claude.js.map +1 -1
  210. package/dist/workers/codex.d.ts.map +1 -1
  211. package/dist/workers/codex.js +16 -4
  212. package/dist/workers/codex.js.map +1 -1
  213. package/dist/workers/types.d.ts +14 -4
  214. package/dist/workers/types.d.ts.map +1 -1
  215. package/examples/config.example.yaml +12 -3
  216. package/package.json +8 -2
  217. package/web/dist/assets/index-BIrXUwFe.css +1 -0
  218. package/web/dist/assets/index-COMzHPcP.js +26 -0
  219. package/web/dist/index.html +2 -2
  220. package/web/dist/assets/index-k6kgdnzy.js +0 -9
  221. package/web/dist/assets/index-xm9qPlYB.css +0 -1
@@ -1,27 +1,25 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
- import { homedir } from 'node:os';
3
+ import { existsSync } from 'node:fs';
5
4
  import { readFile, stat } from 'node:fs/promises';
6
5
  import { extname, resolve, dirname, sep } from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
8
7
  import { WebSocket, WebSocketServer } from 'ws';
9
- import { handleToolCall } from '../mcp/tools/index.js';
10
- import { handleResourceRead } from '../mcp/resources/index.js';
11
8
  import { InteractiveAgentSessionManager, } from './agent-session.js';
12
- import { loadTuiStats } from '../state/stats.js';
13
- import { getBuildInfo } from '../utils/build-info.js';
9
+ import { ShellSessionManager, } from './shell-session.js';
14
10
  import { logger } from '../utils/logger.js';
15
11
  import { sanitizeError } from '../utils/sanitize-error.js';
16
12
  import { nowUtcIso } from '../utils/time.js';
17
- import { listRuntimeSettings, resolveConfigWithRuntimeSettings, RuntimeSettingInputError, } from '../settings/runtime.js';
18
- import { getSettingDefinition, resolveSettingYamlValue } from '../settings/registry.js';
13
+ import { buildDashboardSnapshot, } from './snapshots.js';
14
+ import { handleRunRoutes } from './routes/api-runs.js';
15
+ import { handleOperationRoutes } from './routes/api-operations.js';
16
+ import { handleSettingsRoutes } from './routes/api-settings.js';
17
+ import { handleWsMessage, publishRunSubscriptions, publishAgentSessionSubscriptions, publishShellSessionSubscriptions, } from './routes/api-events.js';
19
18
  const ONE_MEGABYTE = 1024 * 1024;
20
19
  const DEFAULT_SNAPSHOT_INTERVAL_MS = 3000;
21
20
  const MUTATION_INTENT_HEADER = 'x-night-orch-intent';
22
21
  const MUTATION_INTENT_VALUE = 'mutate';
23
22
  const WEB_AUTH_TOKEN_HEADER = 'x-night-orch-web-token';
24
- const BUILD_INFO = getBuildInfo();
25
23
  const CONTENT_TYPES = {
26
24
  '.css': 'text/css; charset=utf-8',
27
25
  '.html': 'text/html; charset=utf-8',
@@ -44,12 +42,6 @@ export function resolveWebFrontendDistPath(explicitPath) {
44
42
  return resolve(moduleDir, '../../web/dist');
45
43
  }
46
44
  export async function startWebServer(deps, options) {
47
- // Hard gate on non-loopback binds: the web server hands out a mutation
48
- // token via `/api/session` to any same-origin caller and offers
49
- // destructive endpoints (retry/cleanup/sync/etc) under that token. When
50
- // bound to a non-loopback address, require an operator-supplied auth
51
- // token env (`NIGHT_ORCH_WEB_AUTH_TOKEN`) so any single-origin XSS or
52
- // local process cannot trivially escalate to full write access.
53
45
  const bindHostName = normalizeHostname(options.host) ?? options.host;
54
46
  const isLoopbackBind = bindHostName === '127.0.0.1'
55
47
  || bindHostName === '::1'
@@ -64,6 +56,7 @@ export async function startWebServer(deps, options) {
64
56
  const agentSessionManager = new InteractiveAgentSessionManager(deps.config, {
65
57
  workspacePath: resolveAgentSessionWorkspacePath(deps),
66
58
  });
59
+ const shellSessionManager = new ShellSessionManager();
67
60
  const frontendDistPath = resolveWebFrontendDistPath(options.frontendDistPath);
68
61
  const hasFrontendAssets = existsSync(resolve(frontendDistPath, 'index.html'));
69
62
  if (!hasFrontendAssets) {
@@ -71,6 +64,14 @@ export async function startWebServer(deps, options) {
71
64
  }
72
65
  const wsServer = new WebSocketServer({ noServer: true });
73
66
  const clients = new Map();
67
+ const routeContext = {
68
+ deps,
69
+ security,
70
+ operationsEnabled,
71
+ rawConfig: options.rawConfig,
72
+ agentSessionManager,
73
+ shellSessionManager,
74
+ };
74
75
  const httpServer = createServer(async (req, res) => {
75
76
  try {
76
77
  const requestUrl = getRequestUrl(req);
@@ -79,7 +80,7 @@ export async function startWebServer(deps, options) {
79
80
  writeJson(res, 403, { error: 'Forbidden host' });
80
81
  return;
81
82
  }
82
- await handleApiRequest(req, res, requestUrl, deps, security, operationsEnabled, options.rawConfig, agentSessionManager);
83
+ await handleApiRequest(req, res, requestUrl, routeContext);
83
84
  return;
84
85
  }
85
86
  if (requestUrl.pathname === '/ws') {
@@ -126,13 +127,16 @@ export async function startWebServer(deps, options) {
126
127
  return;
127
128
  }
128
129
  wsServer.handleUpgrade(req, socket, head, (ws) => {
129
- wsServer.emit('connection', ws);
130
+ wsServer.emit('connection', ws, req);
130
131
  });
131
132
  });
132
- wsServer.on('connection', (ws) => {
133
+ wsServer.on('connection', (ws, req) => {
134
+ const isAuthenticated = resolveWebSocketAuthenticationState(req, security);
133
135
  const state = {
136
+ isAuthenticated,
134
137
  runSubscriptions: new Map(),
135
138
  agentSessionSubscriptions: new Map(),
139
+ shellSessionSubscriptions: new Map(),
136
140
  };
137
141
  clients.set(ws, state);
138
142
  sendWebsocket(ws, {
@@ -145,7 +149,7 @@ export async function startWebServer(deps, options) {
145
149
  sendWebsocket(ws, { type: 'error', error: 'Unsupported websocket payload type' });
146
150
  return;
147
151
  }
148
- void handleWsMessage(ws, state, decoded, deps, agentSessionManager);
152
+ void handleWsMessage(ws, state, decoded, deps, agentSessionManager, shellSessionManager);
149
153
  });
150
154
  ws.on('close', () => {
151
155
  clients.delete(ws);
@@ -170,6 +174,7 @@ export async function startWebServer(deps, options) {
170
174
  continue;
171
175
  await publishRunSubscriptions(ws, state, deps);
172
176
  publishAgentSessionSubscriptions(ws, state, agentSessionManager);
177
+ publishShellSessionSubscriptions(ws, state, shellSessionManager);
173
178
  }
174
179
  }
175
180
  catch (err) {
@@ -192,8 +197,19 @@ export async function startWebServer(deps, options) {
192
197
  publishAgentSessionSubscriptions(ws, state, agentSessionManager);
193
198
  }
194
199
  });
200
+ const stopShellSessionStreaming = shellSessionManager.onSessionEvent((sessionId) => {
201
+ for (const [ws, state] of clients.entries()) {
202
+ if (ws.readyState !== WebSocket.OPEN)
203
+ continue;
204
+ if (!state.shellSessionSubscriptions.has(sessionId))
205
+ continue;
206
+ publishShellSessionSubscriptions(ws, state, shellSessionManager);
207
+ }
208
+ });
195
209
  httpServer.on('close', () => {
196
210
  stopAgentSessionStreaming();
211
+ stopShellSessionStreaming();
212
+ shellSessionManager.closeAll();
197
213
  clearInterval(interval);
198
214
  for (const ws of wsServer.clients) {
199
215
  ws.close();
@@ -211,16 +227,21 @@ export async function startWebServer(deps, options) {
211
227
  await publishTick();
212
228
  return httpServer;
213
229
  }
214
- async function handleApiRequest(req, res, requestUrl, deps, security, operationsEnabled, rawConfig, agentSessionManager) {
230
+ async function handleApiRequest(req, res, requestUrl, ctx) {
215
231
  const method = req.method ?? 'GET';
216
232
  const { pathname, searchParams } = requestUrl;
217
- const runtimeDeps = {
218
- ...deps,
219
- config: resolveConfigWithRuntimeSettings(deps.config, deps.db),
220
- };
233
+ const { security, operationsEnabled } = ctx;
234
+ if (method === 'GET' && pathname.startsWith('/api/shell/')) {
235
+ const shellReadGuardFailure = validateShellReadRequest(req, security);
236
+ if (shellReadGuardFailure) {
237
+ writeJson(res, shellReadGuardFailure.statusCode, { error: shellReadGuardFailure.error });
238
+ return;
239
+ }
240
+ }
221
241
  if ((method === 'POST' || method === 'DELETE')
222
- && (pathname.startsWith('/api/operations/') || pathname.startsWith('/api/agent/'))) {
223
- // Update is a supervisor operation — always allowed regardless of attach/standalone mode.
242
+ && (pathname.startsWith('/api/operations/')
243
+ || pathname.startsWith('/api/agent/')
244
+ || pathname.startsWith('/api/shell/'))) {
224
245
  if (!operationsEnabled && pathname !== '/api/operations/update') {
225
246
  writeJson(res, 409, { error: 'Web operations are disabled by server policy.' });
226
247
  return;
@@ -231,418 +252,12 @@ async function handleApiRequest(req, res, requestUrl, deps, security, operations
231
252
  return;
232
253
  }
233
254
  }
234
- if (method === 'GET' && pathname === '/api/health') {
235
- writeJson(res, 200, { status: 'ok', now: nowUtcIso() });
236
- return;
237
- }
238
- if (method === 'GET' && pathname === '/api/dashboard') {
239
- const snapshot = await buildDashboardSnapshot(runtimeDeps);
240
- writeJson(res, 200, snapshot);
241
- return;
242
- }
243
- if (method === 'GET' && pathname === '/api/projects') {
244
- const snapshot = buildProjectsSnapshot(runtimeDeps);
245
- writeJson(res, 200, snapshot);
246
- return;
247
- }
248
- if (method === 'GET' && pathname === '/api/settings') {
249
- const snapshot = buildSettingsSnapshot(deps, rawConfig);
250
- writeJson(res, 200, snapshot);
251
- return;
252
- }
253
- if (method === 'GET' && pathname === '/api/session') {
254
- // When the server is bound to a non-loopback address with an operator-
255
- // supplied auth token, we must NOT hand it out via HTTP — the client
256
- // must provide it out-of-band. For loopback, disclosure is low-risk.
257
- writeJson(res, 200, {
258
- mutationToken: security.operatorAuthMode ? null : security.webMutationToken,
259
- operationsEnabled,
260
- requiresExternalAuth: security.operatorAuthMode,
261
- });
262
- return;
263
- }
264
- if (method === 'GET' && pathname === '/api/agent/sessions') {
265
- writeJson(res, 200, agentSessionManager.listSessions());
266
- return;
267
- }
268
- if (method === 'GET' && pathname === '/api/status') {
269
- const repo = searchParams.get('repo') ?? undefined;
270
- const result = await handleToolCall('night-orch-status', { repo }, runtimeDeps);
271
- writeJson(res, 200, result);
272
- return;
273
- }
274
- if (method === 'GET' && pathname === '/api/runs') {
275
- const repo = searchParams.get('repo') ?? undefined;
276
- const status = searchParams.get('status') ?? undefined;
277
- const limit = toBoundedInt(searchParams.get('limit'), 50, 1, 500);
278
- const result = await handleToolCall('night-orch-list-runs', { repo, status, limit }, runtimeDeps);
279
- writeJson(res, 200, result);
280
- return;
281
- }
282
- if (method === 'GET' && pathname === '/api/cost') {
283
- const days = toBoundedInt(searchParams.get('days'), 7, 1, 30);
284
- const result = await handleToolCall('night-orch-cost-report', { days }, runtimeDeps);
285
- writeJson(res, 200, result);
286
- return;
287
- }
288
- if (method === 'GET' && pathname === '/api/stats') {
289
- writeJson(res, 200, loadTuiStats(runtimeDeps.db, { costModel: runtimeDeps.config.cost.model }));
290
- return;
291
- }
292
- if (method === 'GET' && pathname === '/api/config') {
293
- const result = await handleResourceRead('night-orch://config', runtimeDeps);
294
- writeJson(res, 200, result);
295
- return;
296
- }
297
- if (method === 'GET') {
298
- const agentSessionEventsMatch = pathname.match(/^\/api\/agent\/sessions\/([^/]+)\/events$/);
299
- if (agentSessionEventsMatch) {
300
- const sessionId = decodeURIComponent(agentSessionEventsMatch[1] ?? '');
301
- const since = toBoundedInt(searchParams.get('since'), 0, 0, Number.MAX_SAFE_INTEGER);
302
- const limit = toBoundedInt(searchParams.get('limit'), 100, 1, 400);
303
- try {
304
- writeJson(res, 200, agentSessionManager.getEvents(sessionId, since, limit));
305
- }
306
- catch (err) {
307
- const message = err.message;
308
- const statusCode = message.startsWith('Session not found:') ? 404 : 400;
309
- writeJson(res, statusCode, { error: message });
310
- }
311
- return;
312
- }
313
- const agentSessionDetailMatch = pathname.match(/^\/api\/agent\/sessions\/([^/]+)$/);
314
- if (agentSessionDetailMatch) {
315
- const sessionId = decodeURIComponent(agentSessionDetailMatch[1] ?? '');
316
- const session = agentSessionManager.getSession(sessionId);
317
- if (!session) {
318
- writeJson(res, 404, { error: `Session not found: ${sessionId}` });
319
- return;
320
- }
321
- writeJson(res, 200, { session });
322
- return;
323
- }
324
- const runDetailMatch = pathname.match(/^\/api\/runs\/([^/]+)$/);
325
- if (runDetailMatch) {
326
- const runId = decodeURIComponent(runDetailMatch[1] ?? '');
327
- const result = await handleToolCall('night-orch-run-detail', { runId }, runtimeDeps);
328
- writeJson(res, 200, result);
329
- return;
330
- }
331
- const runEventsMatch = pathname.match(/^\/api\/runs\/([^/]+)\/events$/);
332
- if (runEventsMatch) {
333
- const runId = decodeURIComponent(runEventsMatch[1] ?? '');
334
- const since = toBoundedInt(searchParams.get('since'), 0, 0, Number.MAX_SAFE_INTEGER);
335
- const limit = toBoundedInt(searchParams.get('limit'), 100, 1, 200);
336
- const result = await handleToolCall('night-orch-stream-events', { runId, since, limit }, runtimeDeps);
337
- writeJson(res, 200, result);
338
- return;
339
- }
340
- const repoIssuesMatch = pathname.match(/^\/api\/repos\/([^/]+)\/issues$/);
341
- if (repoIssuesMatch) {
342
- const repo = decodeURIComponent(repoIssuesMatch[1] ?? '');
343
- const filter = searchParams.get('filter') ?? 'all';
344
- const result = await handleToolCall('night-orch-list-issues', { repo, filter }, runtimeDeps);
345
- writeJson(res, 200, result);
346
- return;
347
- }
348
- }
349
- if (method === 'POST' && pathname === '/api/operations/poll') {
350
- const body = await readJsonBody(req);
351
- const result = await handleToolCall('night-orch-poll', withMcpMutationAuth({ dryRun: Boolean(body['dryRun']) }, security), runtimeDeps);
352
- writeJson(res, 200, result);
353
- return;
354
- }
355
- if (method === 'POST' && pathname === '/api/operations/sync') {
356
- const body = await readJsonBody(req);
357
- const result = await handleToolCall('night-orch-sync', withMcpMutationAuth({ dryRun: Boolean(body['dryRun']) }, security), runtimeDeps);
358
- writeJson(res, 200, result);
359
- return;
360
- }
361
- if (method === 'POST' && pathname === '/api/operations/cleanup') {
362
- const body = await readJsonBody(req);
363
- const result = await handleToolCall('night-orch-cleanup', withMcpMutationAuth({ dryRun: Boolean(body['dryRun']) }, security), runtimeDeps);
364
- writeJson(res, 200, result);
365
- return;
366
- }
367
- if (method === 'POST' && pathname === '/api/operations/labels-init') {
368
- const body = await readJsonBody(req);
369
- const repo = toNonEmptyString(body['repo']);
370
- if (!repo) {
371
- writeJson(res, 400, { error: 'repo is required' });
372
- return;
373
- }
374
- const result = await handleToolCall('night-orch-labels-init', withMcpMutationAuth({
375
- repo,
376
- dryRun: Boolean(body['dryRun']),
377
- }, security), runtimeDeps);
378
- writeJson(res, 200, result);
379
- return;
380
- }
381
- if (method === 'POST' && pathname === '/api/operations/retry') {
382
- const body = await readJsonBody(req);
383
- const repo = toNonEmptyString(body['repo']);
384
- const issueNumber = toBoundedInt(body['issueNumber'], NaN, 1, Number.MAX_SAFE_INTEGER);
385
- if (!repo || Number.isNaN(issueNumber)) {
386
- writeJson(res, 400, { error: 'repo and issueNumber are required' });
387
- return;
388
- }
389
- const result = await handleToolCall('night-orch-retry', withMcpMutationAuth({
390
- repo,
391
- issueNumber,
392
- resetPlan: Boolean(body['resetPlan']),
393
- fresh: Boolean(body['fresh']),
394
- }, security), runtimeDeps);
395
- writeJson(res, 200, result);
396
- return;
397
- }
398
- if (method === 'POST' && pathname === '/api/operations/rebase') {
399
- const body = await readJsonBody(req);
400
- const repo = toNonEmptyString(body['repo']);
401
- const issueNumber = toBoundedInt(body['issueNumber'], NaN, 1, Number.MAX_SAFE_INTEGER);
402
- if (!repo || Number.isNaN(issueNumber)) {
403
- writeJson(res, 400, { error: 'repo and issueNumber are required' });
404
- return;
405
- }
406
- const result = await handleToolCall('night-orch-rebase', withMcpMutationAuth({
407
- repo,
408
- issueNumber,
409
- check: body['check'] === undefined ? true : Boolean(body['check']),
410
- }, security), runtimeDeps);
411
- writeJson(res, 200, result);
412
- return;
413
- }
414
- if (method === 'POST' && pathname === '/api/operations/continue') {
415
- const body = await readJsonBody(req);
416
- const repo = toNonEmptyString(body['repo']);
417
- const issueNumber = toBoundedInt(body['issueNumber'], NaN, 1, Number.MAX_SAFE_INTEGER);
418
- if (!repo || Number.isNaN(issueNumber)) {
419
- writeJson(res, 400, { error: 'repo and issueNumber are required' });
420
- return;
421
- }
422
- const result = await handleToolCall('night-orch-continue', withMcpMutationAuth({
423
- repo,
424
- issueNumber,
425
- }, security), runtimeDeps);
426
- writeJson(res, 200, result);
427
- return;
428
- }
429
- if (method === 'POST' && pathname === '/api/operations/delete-entry') {
430
- const body = await readJsonBody(req);
431
- const repo = toNonEmptyString(body['repo']);
432
- const issueNumber = toBoundedInt(body['issueNumber'], NaN, 1, Number.MAX_SAFE_INTEGER);
433
- if (!repo || Number.isNaN(issueNumber)) {
434
- writeJson(res, 400, { error: 'repo and issueNumber are required' });
435
- return;
436
- }
437
- const result = await handleToolCall('night-orch-delete-entry', withMcpMutationAuth({
438
- repo,
439
- issueNumber,
440
- force: Boolean(body['force']),
441
- dryRun: Boolean(body['dryRun']),
442
- }, security), runtimeDeps);
443
- writeJson(res, 200, result);
444
- return;
445
- }
446
- if (method === 'POST' && pathname === '/api/operations/daily-cost-override/set') {
447
- const body = await readJsonBody(req);
448
- const amountUsd = toFiniteNumber(body['amountUsd']);
449
- if (amountUsd === null || amountUsd <= 0) {
450
- writeJson(res, 400, { error: 'amountUsd must be a positive finite number' });
451
- return;
452
- }
453
- try {
454
- const result = await handleToolCall('night-orch-daily-cost-override', withMcpMutationAuth({ amountUsd }, security), runtimeDeps);
455
- writeJson(res, 200, result);
456
- }
457
- catch (err) {
458
- writeJson(res, 400, { error: err.message });
459
- }
460
- return;
461
- }
462
- if (method === 'POST' && pathname === '/api/operations/daily-cost-override/clear') {
463
- try {
464
- const result = await handleToolCall('night-orch-daily-cost-override', withMcpMutationAuth({ clear: true }, security), runtimeDeps);
465
- writeJson(res, 200, result);
466
- }
467
- catch (err) {
468
- writeJson(res, 400, { error: err.message });
469
- }
470
- return;
471
- }
472
- if (method === 'POST' && pathname === '/api/operations/cost-override/set') {
473
- const body = await readJsonBody(req);
474
- const repo = toNonEmptyString(body['repo']);
475
- const issueNumber = toBoundedInt(body['issueNumber'], NaN, 1, Number.MAX_SAFE_INTEGER);
476
- const amountUsd = toFiniteNumber(body['amountUsd']);
477
- if (!repo || Number.isNaN(issueNumber)) {
478
- writeJson(res, 400, { error: 'repo and issueNumber are required' });
479
- return;
480
- }
481
- if (amountUsd === null || amountUsd <= 0) {
482
- writeJson(res, 400, { error: 'amountUsd must be a positive finite number' });
483
- return;
484
- }
485
- try {
486
- const result = await handleToolCall('night-orch-cost-override', withMcpMutationAuth({ repo, issueNumber, amountUsd }, security), runtimeDeps);
487
- writeJson(res, 200, result);
488
- }
489
- catch (err) {
490
- writeJson(res, 400, { error: err.message });
491
- }
492
- return;
493
- }
494
- if (method === 'POST' && pathname === '/api/operations/cost-override/clear') {
495
- const body = await readJsonBody(req);
496
- const repo = toNonEmptyString(body['repo']);
497
- const issueNumber = toBoundedInt(body['issueNumber'], NaN, 1, Number.MAX_SAFE_INTEGER);
498
- if (!repo || Number.isNaN(issueNumber)) {
499
- writeJson(res, 400, { error: 'repo and issueNumber are required' });
500
- return;
501
- }
502
- try {
503
- const result = await handleToolCall('night-orch-cost-override', withMcpMutationAuth({ repo, issueNumber, clear: true }, security), runtimeDeps);
504
- writeJson(res, 200, result);
505
- }
506
- catch (err) {
507
- writeJson(res, 400, { error: err.message });
508
- }
255
+ if (await handleRunRoutes(req, res, method, pathname, searchParams, ctx))
509
256
  return;
510
- }
511
- if (method === 'POST' && pathname === '/api/operations/settings/set') {
512
- const body = await readJsonBody(req);
513
- const key = toNonEmptyString(body['key']);
514
- const value = body['value'];
515
- if (!key || value === undefined) {
516
- writeJson(res, 400, { error: 'key and value are required' });
517
- return;
518
- }
519
- try {
520
- const result = await handleToolCall('night-orch-set-setting', withMcpMutationAuth({ key, value }, security), deps);
521
- writeJson(res, 200, result);
522
- }
523
- catch (err) {
524
- if (isRuntimeSettingInputError(err)) {
525
- writeJson(res, 400, { error: err.message });
526
- return;
527
- }
528
- throw err;
529
- }
257
+ if (await handleSettingsRoutes(req, res, method, pathname, searchParams, ctx))
530
258
  return;
531
- }
532
- if (method === 'POST' && pathname === '/api/operations/settings/clear') {
533
- const body = await readJsonBody(req);
534
- const key = toNonEmptyString(body['key']);
535
- if (!key) {
536
- writeJson(res, 400, { error: 'key is required' });
537
- return;
538
- }
539
- try {
540
- const result = await handleToolCall('night-orch-clear-setting', withMcpMutationAuth({ key }, security), deps);
541
- writeJson(res, 200, result);
542
- }
543
- catch (err) {
544
- if (isRuntimeSettingInputError(err)) {
545
- writeJson(res, 400, { error: err.message });
546
- return;
547
- }
548
- throw err;
549
- }
550
- return;
551
- }
552
- if (method === 'GET' && pathname === '/api/update-status') {
553
- const statusPath = resolve(homedir(), '.config', 'night-orch', 'update-status.json');
554
- try {
555
- const parsed = JSON.parse(readFileSync(statusPath, 'utf-8'));
556
- const status = {
557
- state: typeof parsed['state'] === 'string' ? parsed['state'] : 'idle',
558
- ...(typeof parsed['error'] === 'string' ? { error: parsed['error'] } : {}),
559
- };
560
- writeJson(res, 200, status);
561
- }
562
- catch {
563
- writeJson(res, 200, { state: 'idle' });
564
- }
565
- return;
566
- }
567
- if (method === 'POST' && pathname === '/api/operations/update') {
568
- // Try IPC first (running under supervisor)
569
- if (typeof process.send === 'function') {
570
- process.send({ type: 'update-requested' });
571
- writeJson(res, 200, { accepted: true, method: 'ipc' });
572
- return;
573
- }
574
- // Fallback: trigger file
575
- const dataDir = resolve(homedir(), '.config', 'night-orch');
576
- const triggerPath = resolve(dataDir, 'update-requested');
577
- mkdirSync(dataDir, { recursive: true });
578
- writeFileSync(triggerPath, nowUtcIso());
579
- writeJson(res, 200, { accepted: true, method: 'trigger-file' });
580
- return;
581
- }
582
- if (method === 'POST' && pathname === '/api/agent/sessions') {
583
- const body = await readJsonBody(req);
584
- const agentRaw = toNonEmptyString(body['agent']);
585
- const profileName = toNonEmptyString(body['profileName']);
586
- const cwd = toNonEmptyString(body['cwd']);
587
- if (agentRaw !== 'claude' && agentRaw !== 'codex') {
588
- writeJson(res, 400, { error: 'agent must be "claude" or "codex"' });
589
- return;
590
- }
591
- try {
592
- const session = agentSessionManager.createSession({
593
- agent: agentRaw,
594
- profileName,
595
- cwd,
596
- });
597
- writeJson(res, 200, { session });
598
- }
599
- catch (err) {
600
- writeJson(res, 400, { error: err.message });
601
- }
602
- return;
603
- }
604
- const agentSessionMessageMatch = pathname.match(/^\/api\/agent\/sessions\/([^/]+)\/messages$/);
605
- if (method === 'POST' && agentSessionMessageMatch) {
606
- const sessionId = decodeURIComponent(agentSessionMessageMatch[1] ?? '');
607
- const body = await readJsonBody(req);
608
- const prompt = toNonEmptyString(body['prompt']);
609
- if (!prompt) {
610
- writeJson(res, 400, { error: 'prompt is required' });
611
- return;
612
- }
613
- try {
614
- const result = agentSessionManager.sendPrompt(sessionId, prompt);
615
- writeJson(res, 200, result);
616
- }
617
- catch (err) {
618
- const message = err.message;
619
- const statusCode = message.startsWith('Session not found:')
620
- ? 404
621
- : message.includes('running') || message.includes('closed')
622
- ? 409
623
- : 400;
624
- writeJson(res, statusCode, { error: message });
625
- }
626
- return;
627
- }
628
- const agentSessionCloseMatch = pathname.match(/^\/api\/agent\/sessions\/([^/]+)$/);
629
- if (method === 'DELETE' && agentSessionCloseMatch) {
630
- const sessionId = decodeURIComponent(agentSessionCloseMatch[1] ?? '');
631
- try {
632
- const session = agentSessionManager.closeSession(sessionId);
633
- writeJson(res, 200, { session });
634
- }
635
- catch (err) {
636
- const message = err.message;
637
- const statusCode = message.startsWith('Session not found:')
638
- ? 404
639
- : message.includes('running')
640
- ? 409
641
- : 400;
642
- writeJson(res, statusCode, { error: message });
643
- }
259
+ if (await handleOperationRoutes(req, res, method, pathname, searchParams, ctx))
644
260
  return;
645
- }
646
261
  writeJson(res, 404, { error: `Unknown API route: ${method} ${pathname}` });
647
262
  }
648
263
  async function serveFrontend(req, res, pathname, frontendDistPath, hasFrontendAssets) {
@@ -660,9 +275,6 @@ async function serveFrontend(req, res, pathname, frontendDistPath, hasFrontendAs
660
275
  }
661
276
  const relativePath = pathname === '/' ? '/index.html' : pathname;
662
277
  const targetPath = resolve(frontendDistPath, `.${relativePath}`);
663
- // Path-traversal guard: `targetPath.startsWith(frontendDistPath)` alone
664
- // is insufficient — a sibling directory such as `frontendDistPath` +
665
- // "-stash" would pass. Require exact match or a true subdirectory path.
666
278
  if (targetPath !== frontendDistPath && !targetPath.startsWith(frontendDistPath + sep)) {
667
279
  writeJson(res, 403, { error: 'Forbidden path' });
668
280
  return;
@@ -705,7 +317,11 @@ async function pickExistingFile(path) {
705
317
  }
706
318
  return null;
707
319
  }
708
- async function readJsonBody(req) {
320
+ export function writeJson(res, status, payload) {
321
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
322
+ res.end(JSON.stringify(payload));
323
+ }
324
+ export async function readJsonBody(req) {
709
325
  const chunks = [];
710
326
  let size = 0;
711
327
  for await (const chunk of req) {
@@ -734,10 +350,50 @@ async function readJsonBody(req) {
734
350
  throw new Error(`Invalid JSON body: ${err.message}`);
735
351
  }
736
352
  }
353
+ export function toBoundedInt(value, fallback, min, max) {
354
+ const parsed = typeof value === 'number'
355
+ ? value
356
+ : typeof value === 'string'
357
+ ? Number.parseInt(value, 10)
358
+ : NaN;
359
+ if (!Number.isFinite(parsed)) {
360
+ return fallback;
361
+ }
362
+ return Math.max(min, Math.min(max, Math.floor(parsed)));
363
+ }
364
+ export function toNonEmptyString(value) {
365
+ if (typeof value !== 'string') {
366
+ return null;
367
+ }
368
+ const trimmed = value.trim();
369
+ return trimmed.length > 0 ? trimmed : null;
370
+ }
371
+ export function toFiniteNumber(value) {
372
+ const parsed = typeof value === 'number'
373
+ ? value
374
+ : typeof value === 'string'
375
+ ? Number.parseFloat(value)
376
+ : NaN;
377
+ return Number.isFinite(parsed) ? parsed : null;
378
+ }
379
+ export function withMcpMutationAuth(args, security) {
380
+ if (!security.mcpMutationAuthToken) {
381
+ return args;
382
+ }
383
+ return {
384
+ ...args,
385
+ authToken: security.mcpMutationAuthToken,
386
+ };
387
+ }
388
+ export function sendWebsocket(ws, payload) {
389
+ if (ws.readyState !== WebSocket.OPEN)
390
+ return;
391
+ ws.send(JSON.stringify(payload));
392
+ }
737
393
  function getRequestUrl(req) {
738
394
  return new URL(req.url ?? '/', 'http://localhost');
739
395
  }
740
- function validateMutationRequest(req, security) {
396
+ export function validateMutationRequest(req, security) {
741
397
  if (!isAllowedRequestHost(req, security)) {
742
398
  return { statusCode: 403, error: 'Forbidden host' };
743
399
  }
@@ -761,6 +417,16 @@ function validateMutationRequest(req, security) {
761
417
  }
762
418
  return null;
763
419
  }
420
+ function validateShellReadRequest(req, security) {
421
+ const webToken = getSingleHeaderValue(req.headers[WEB_AUTH_TOKEN_HEADER]);
422
+ if (!webToken) {
423
+ return { statusCode: 401, error: `Missing required header: ${WEB_AUTH_TOKEN_HEADER}` };
424
+ }
425
+ if (!isMatchingToken(webToken, security.webMutationToken)) {
426
+ return { statusCode: 403, error: 'Invalid web auth token' };
427
+ }
428
+ return null;
429
+ }
764
430
  function hasAllowedOrigin(req, security, allowMissingOrigin) {
765
431
  const originHeader = getSingleHeaderValue(req.headers.origin);
766
432
  if (!originHeader) {
@@ -792,26 +458,7 @@ function getSingleHeaderValue(value) {
792
458
  }
793
459
  return null;
794
460
  }
795
- function withMcpMutationAuth(args, security) {
796
- if (!security.mcpMutationAuthToken) {
797
- return args;
798
- }
799
- return {
800
- ...args,
801
- authToken: security.mcpMutationAuthToken,
802
- };
803
- }
804
461
  function createWebSecurityContext(deps, options) {
805
- // When bound to a non-loopback address, the operator-supplied env var
806
- // IS the mutation token. The startup guard in startWebServer() ensures
807
- // the env var is set before we get here. Using it directly means the
808
- // /api/session endpoint can refuse to disclose it — the operator gives
809
- // the token to trusted clients out-of-band (browser extension, curl
810
- // header, etc).
811
- //
812
- // For loopback binds, keep the random-per-process token and disclose it
813
- // via /api/session as before — the risk is low when only local processes
814
- // can reach the server.
815
462
  const operatorToken = process.env['NIGHT_ORCH_WEB_AUTH_TOKEN'];
816
463
  const bindHostName = normalizeHostname(options.host) ?? options.host;
817
464
  const isLoopback = bindHostName === '127.0.0.1'
@@ -921,393 +568,21 @@ function rejectUpgrade(socket, statusCode, message) {
921
568
  payload);
922
569
  socket.destroy();
923
570
  }
924
- function writeJson(res, status, payload) {
925
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
926
- res.end(JSON.stringify(payload));
927
- }
928
- function toBoundedInt(value, fallback, min, max) {
929
- const parsed = typeof value === 'number'
930
- ? value
931
- : typeof value === 'string'
932
- ? Number.parseInt(value, 10)
933
- : NaN;
934
- if (!Number.isFinite(parsed)) {
935
- return fallback;
936
- }
937
- return Math.max(min, Math.min(max, Math.floor(parsed)));
938
- }
939
- function toNonEmptyString(value) {
940
- if (typeof value !== 'string') {
941
- return null;
942
- }
943
- const trimmed = value.trim();
944
- return trimmed.length > 0 ? trimmed : null;
945
- }
946
- function toFiniteNumber(value) {
947
- const parsed = typeof value === 'number'
948
- ? value
949
- : typeof value === 'string'
950
- ? Number.parseFloat(value)
951
- : NaN;
952
- return Number.isFinite(parsed) ? parsed : null;
953
- }
954
- async function buildDashboardSnapshot(deps) {
955
- const runtimeConfig = resolveConfigWithRuntimeSettings(deps.config, deps.db);
956
- const runtimeDeps = {
957
- ...deps,
958
- config: runtimeConfig,
959
- };
960
- const [status, runs, cost] = await Promise.all([
961
- handleToolCall('night-orch-status', {}, runtimeDeps),
962
- handleToolCall('night-orch-list-runs', { limit: 100 }, runtimeDeps),
963
- handleToolCall('night-orch-cost-report', { days: 7 }, runtimeDeps),
964
- ]);
965
- return {
966
- generatedAt: nowUtcIso(),
967
- status,
968
- runs,
969
- cost,
970
- build: BUILD_INFO,
971
- config: {
972
- repos: runtimeConfig.repos.map((repo) => repo.repo),
973
- pollIntervalSeconds: runtimeConfig.github.pollIntervalSeconds,
974
- },
975
- stats: loadTuiStats(runtimeDeps.db, { costModel: runtimeConfig.cost.model }),
976
- };
977
- }
978
- function buildProjectsSnapshot(deps) {
979
- return {
980
- generatedAt: nowUtcIso(),
981
- githubDefaults: {
982
- tokenEnv: deps.config.github.tokenEnv,
983
- apiBaseUrl: deps.config.github.apiBaseUrl,
984
- },
985
- workerProfiles: Object.fromEntries(Object.entries(deps.config.workerProfiles).map(([name, profile]) => [
986
- name,
987
- sanitizeWorkerProfile(profile),
988
- ])),
989
- repos: deps.config.repos.map((repo) => sanitizeProjectRepo(repo)),
990
- };
991
- }
992
- function buildSettingsSnapshot(deps, rawConfig) {
993
- const runtimeSettings = listRuntimeSettings(deps.config, deps.db);
994
- return {
995
- generatedAt: nowUtcIso(),
996
- settings: runtimeSettings.map((setting) => {
997
- const definition = getSettingDefinition(setting.key);
998
- if (!definition) {
999
- return {
1000
- ...setting,
1001
- hasYamlValue: false,
1002
- yamlValue: null,
1003
- };
1004
- }
1005
- const { hasYamlValue, yamlValue } = resolveSettingYamlValue(definition, rawConfig, deps.config);
1006
- return {
1007
- ...setting,
1008
- hasYamlValue,
1009
- yamlValue,
1010
- };
1011
- }),
1012
- };
1013
- }
1014
- function sanitizeWorkerProfile(profile) {
1015
- return {
1016
- type: profile.type,
1017
- command: profile.command,
1018
- args: [...profile.args],
1019
- workerTimeoutSeconds: profile.workerTimeoutSeconds,
1020
- minimalEnv: profile.minimalEnv,
1021
- runtimeWrapper: profile.runtimeWrapper,
1022
- envKeys: Object.keys(profile.env),
1023
- };
1024
- }
1025
- function sanitizeProjectRepo(repo) {
1026
- return {
1027
- repo: repo.repo,
1028
- forge: repo.forge,
1029
- linkedProjects: [...repo.linkedProjects],
1030
- apiBaseUrl: repo.apiBaseUrl,
1031
- tokenEnv: repo.tokenEnv,
1032
- maxConcurrentRuns: repo.maxConcurrentRuns,
1033
- localPath: repo.localPath,
1034
- baseBranch: repo.baseBranch,
1035
- branchPrefix: repo.branchPrefix,
1036
- labels: sanitizeLabels(repo.labels),
1037
- ...(repo.kanban
1038
- ? {
1039
- kanban: {
1040
- triggerLabel: repo.kanban.triggerLabel,
1041
- labels: sanitizeLabels(repo.kanban.labels),
1042
- },
1043
- }
1044
- : {}),
1045
- labelConfig: Object.fromEntries(Object.entries(repo.labelConfig).map(([label, config]) => [
1046
- label,
1047
- {
1048
- ...(config.color ? { color: config.color } : {}),
1049
- ...(config.description ? { description: config.description } : {}),
1050
- },
1051
- ])),
1052
- defaults: {
1053
- planner: repo.defaults.planner,
1054
- coder: repo.defaults.coder,
1055
- reviewer: repo.defaults.reviewer,
1056
- doneMode: repo.defaults.doneMode,
1057
- notifyPriority: repo.defaults.notifyPriority,
1058
- prMentions: [...repo.defaults.prMentions],
1059
- },
1060
- ...(repo.environment ? { environment: sanitizeEnvironment(repo.environment) } : {}),
1061
- verify: repo.verify.map((command) => copyCommandSpec(command)),
1062
- prompts: {
1063
- plannerSystem: Boolean(repo.prompts?.plannerSystem),
1064
- coderSystem: Boolean(repo.prompts?.coderSystem),
1065
- reviewerSystem: Boolean(repo.prompts?.reviewerSystem),
1066
- },
1067
- planning: {
1068
- prdDirectory: repo.planning.prdDirectory,
1069
- },
1070
- selectors: {
1071
- includeLabelsAny: [...repo.selectors.includeLabelsAny],
1072
- excludeLabelsAny: [...repo.selectors.excludeLabelsAny],
1073
- },
1074
- agents: { ...repo.agents },
1075
- ...(repo.workflow ? { workflow: repo.workflow } : {}),
1076
- ...(repo.workflowByTriage ? { workflowByTriage: { ...repo.workflowByTriage } } : {}),
1077
- mergeQueue: {
1078
- enabled: repo.mergeQueue.enabled,
1079
- batchSize: repo.mergeQueue.batchSize,
1080
- mergeMethod: repo.mergeQueue.mergeMethod,
1081
- retryFlakyOnce: repo.mergeQueue.retryFlakyOnce,
1082
- requireApproval: repo.mergeQueue.requireApproval,
1083
- stagingBranchPrefix: repo.mergeQueue.stagingBranchPrefix,
1084
- },
1085
- };
1086
- }
1087
- function sanitizeEnvironment(environment) {
1088
- return {
1089
- defaultMode: environment.defaultMode,
1090
- ...(environment.dedicated
1091
- ? {
1092
- dedicated: {
1093
- compose: {
1094
- file: environment.dedicated.compose.file,
1095
- services: [...environment.dedicated.compose.services],
1096
- projectName: environment.dedicated.compose.projectName,
1097
- },
1098
- env: {
1099
- copyFrom: environment.dedicated.env.copyFrom,
1100
- overrideKeys: Object.keys(environment.dedicated.env.overrides),
1101
- overrideFiles: [...environment.dedicated.env.overrideFiles],
1102
- },
1103
- ...(environment.dedicated.healthcheck
1104
- ? { healthcheck: copyCommandSpec(environment.dedicated.healthcheck) }
1105
- : {}),
1106
- teardownOnComplete: environment.dedicated.teardownOnComplete,
1107
- },
1108
- }
1109
- : {}),
1110
- ...(environment.shared
1111
- ? {
1112
- shared: {
1113
- requireRunning: environment.shared.requireRunning,
1114
- ...(environment.shared.healthcheck
1115
- ? { healthcheck: copyCommandSpec(environment.shared.healthcheck) }
1116
- : {}),
1117
- },
1118
- }
1119
- : {}),
1120
- bootstrap: environment.bootstrap.map((step) => ({
1121
- when: step.when,
1122
- command: copyCommandSpec(step.command),
1123
- ...(step.failureHints && step.failureHints.length > 0
1124
- ? {
1125
- failureHints: step.failureHints.map((hint) => ({
1126
- contains: hint.contains,
1127
- message: hint.message,
1128
- output: hint.output,
1129
- })),
1130
- }
1131
- : {}),
1132
- })),
1133
- cleanup: environment.cleanup.map((step) => ({
1134
- when: step.when,
1135
- command: copyCommandSpec(step.command),
1136
- ...(step.failureHints && step.failureHints.length > 0
1137
- ? {
1138
- failureHints: step.failureHints.map((hint) => ({
1139
- contains: hint.contains,
1140
- message: hint.message,
1141
- output: hint.output,
1142
- })),
1143
- }
1144
- : {}),
1145
- })),
1146
- };
1147
- }
1148
- function sanitizeLabels(labels) {
1149
- return {
1150
- ready: [...labels.ready],
1151
- running: labels.running,
1152
- blocked: normalizeLabelValue(labels.blocked),
1153
- needsHuman: labels.needsHuman,
1154
- reviewReady: labels.reviewReady,
1155
- error: labels.error,
1156
- retry: labels.retry,
1157
- planning: labels.planning,
1158
- mergeQueued: labels.mergeQueued,
1159
- merging: labels.merging,
1160
- mergeFailed: labels.mergeFailed,
1161
- };
1162
- }
1163
- function normalizeLabelValue(value) {
1164
- if (typeof value === 'string')
1165
- return value;
1166
- if (Array.isArray(value)) {
1167
- return value.find((entry) => typeof entry === 'string') ?? '';
1168
- }
1169
- return '';
1170
- }
1171
- function copyCommandSpec(command) {
1172
- if (Array.isArray(command)) {
1173
- return [...command];
1174
- }
1175
- return command;
1176
- }
1177
- async function handleWsMessage(ws, state, rawMessage, deps, agentSessionManager) {
1178
- let command;
571
+ function resolveWebSocketAuthenticationState(request, security) {
572
+ let requestUrl;
1179
573
  try {
1180
- command = JSON.parse(rawMessage);
574
+ requestUrl = getRequestUrl(request);
1181
575
  }
1182
576
  catch {
1183
- sendWebsocket(ws, { type: 'error', error: 'Invalid JSON message' });
1184
- return;
1185
- }
1186
- if (command.type === 'subscribe-run-events') {
1187
- const runId = typeof command.runId === 'string' ? command.runId : '';
1188
- if (!runId) {
1189
- sendWebsocket(ws, { type: 'error', error: 'runId is required for subscribe-run-events' });
1190
- return;
1191
- }
1192
- const cursor = Number.isFinite(command.since) ? Math.max(0, Math.floor(command.since ?? 0)) : 0;
1193
- state.runSubscriptions.set(runId, cursor);
1194
- sendWebsocket(ws, { type: 'subscribed', payload: { runId, since: cursor } });
1195
- return;
1196
- }
1197
- if (command.type === 'unsubscribe-run-events') {
1198
- const runId = typeof command.runId === 'string' ? command.runId : '';
1199
- if (!runId) {
1200
- sendWebsocket(ws, { type: 'error', error: 'runId is required for unsubscribe-run-events' });
1201
- return;
1202
- }
1203
- state.runSubscriptions.delete(runId);
1204
- sendWebsocket(ws, { type: 'unsubscribed', payload: { runId } });
1205
- return;
1206
- }
1207
- if (command.type === 'subscribe-agent-session-events') {
1208
- const sessionId = typeof command.sessionId === 'string' ? command.sessionId : '';
1209
- if (!sessionId) {
1210
- sendWebsocket(ws, { type: 'error', error: 'sessionId is required for subscribe-agent-session-events' });
1211
- return;
1212
- }
1213
- const cursor = Number.isFinite(command.since) ? Math.max(0, Math.floor(command.since ?? 0)) : 0;
1214
- state.agentSessionSubscriptions.set(sessionId, cursor);
1215
- sendWebsocket(ws, { type: 'subscribed', payload: { sessionId, since: cursor } });
1216
- publishAgentSessionSubscriptions(ws, state, agentSessionManager);
1217
- return;
1218
- }
1219
- if (command.type === 'unsubscribe-agent-session-events') {
1220
- const sessionId = typeof command.sessionId === 'string' ? command.sessionId : '';
1221
- if (!sessionId) {
1222
- sendWebsocket(ws, { type: 'error', error: 'sessionId is required for unsubscribe-agent-session-events' });
1223
- return;
1224
- }
1225
- state.agentSessionSubscriptions.delete(sessionId);
1226
- sendWebsocket(ws, { type: 'unsubscribed', payload: { sessionId } });
1227
- return;
1228
- }
1229
- if (command.type === 'refresh') {
1230
- const snapshot = await buildDashboardSnapshot(deps);
1231
- sendWebsocket(ws, { type: 'snapshot', payload: snapshot });
1232
- return;
1233
- }
1234
- sendWebsocket(ws, { type: 'error', error: 'Unknown command type' });
1235
- }
1236
- async function publishRunSubscriptions(ws, state, deps) {
1237
- for (const [runId, since] of state.runSubscriptions.entries()) {
1238
- try {
1239
- const result = await handleToolCall('night-orch-stream-events', { runId, since, limit: 200 }, deps);
1240
- const eventPayload = toRunEventPayload(result);
1241
- if (!eventPayload) {
1242
- continue;
1243
- }
1244
- state.runSubscriptions.set(runId, eventPayload.lastEventId);
1245
- if (eventPayload.events.length === 0) {
1246
- continue;
1247
- }
1248
- sendWebsocket(ws, {
1249
- type: 'run-events',
1250
- payload: {
1251
- runId,
1252
- events: eventPayload.events,
1253
- lastEventId: eventPayload.lastEventId,
1254
- },
1255
- });
1256
- }
1257
- catch (err) {
1258
- sendWebsocket(ws, {
1259
- type: 'error',
1260
- error: `Failed to stream events for ${runId}: ${err.message}`,
1261
- });
1262
- }
577
+ return false;
1263
578
  }
1264
- }
1265
- function publishAgentSessionSubscriptions(ws, state, manager) {
1266
- for (const [sessionId, since] of state.agentSessionSubscriptions.entries()) {
1267
- let result;
1268
- try {
1269
- result = manager.getEvents(sessionId, since, 200);
1270
- }
1271
- catch (err) {
1272
- sendWebsocket(ws, {
1273
- type: 'error',
1274
- error: `Failed to stream agent-session events for ${sessionId}: ${err.message}`,
1275
- });
1276
- continue;
1277
- }
1278
- state.agentSessionSubscriptions.set(sessionId, result.lastEventId);
1279
- if (result.events.length === 0) {
1280
- continue;
1281
- }
1282
- sendWebsocket(ws, {
1283
- type: 'agent-session-events',
1284
- payload: {
1285
- sessionId,
1286
- status: result.status,
1287
- events: result.events,
1288
- lastEventId: result.lastEventId,
1289
- },
1290
- });
579
+ const queryToken = requestUrl.searchParams.get('token');
580
+ const headerToken = getSingleHeaderValue(request.headers[WEB_AUTH_TOKEN_HEADER]);
581
+ const providedToken = toNonEmptyString(queryToken) ?? headerToken;
582
+ if (!providedToken) {
583
+ return false;
1291
584
  }
1292
- }
1293
- function toRunEventPayload(input) {
1294
- if (!input || typeof input !== 'object')
1295
- return null;
1296
- const maybeEvents = input.events;
1297
- const maybeLastEventId = input.lastEventId;
1298
- if (!Array.isArray(maybeEvents))
1299
- return null;
1300
- if (typeof maybeLastEventId !== 'number' || !Number.isFinite(maybeLastEventId))
1301
- return null;
1302
- return {
1303
- events: maybeEvents,
1304
- lastEventId: Math.max(0, Math.floor(maybeLastEventId)),
1305
- };
1306
- }
1307
- function sendWebsocket(ws, payload) {
1308
- if (ws.readyState !== WebSocket.OPEN)
1309
- return;
1310
- ws.send(JSON.stringify(payload));
585
+ return isMatchingToken(providedToken, security.webMutationToken);
1311
586
  }
1312
587
  function isClientRequestError(message) {
1313
588
  return message.startsWith('Invalid JSON body') || message === 'Request body too large';
@@ -1315,12 +590,6 @@ function isClientRequestError(message) {
1315
590
  function isAuthorizationError(message) {
1316
591
  return message.startsWith('Unauthorized:');
1317
592
  }
1318
- function isRuntimeSettingInputError(err) {
1319
- if (err instanceof RuntimeSettingInputError) {
1320
- return true;
1321
- }
1322
- return err instanceof Error && err.name === 'RuntimeSettingInputError';
1323
- }
1324
593
  function decodeWsMessage(raw) {
1325
594
  if (typeof raw === 'string') {
1326
595
  return raw;