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.
- package/LICENSE +21 -0
- package/README.md +47 -108
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +18 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/monitoring.d.ts.map +1 -1
- package/dist/cli/commands/monitoring.js +7 -3
- package/dist/cli/commands/monitoring.js.map +1 -1
- package/dist/cli/commands/settings.d.ts.map +1 -1
- package/dist/cli/commands/settings.js +40 -4
- package/dist/cli/commands/settings.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +26 -3
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/index.js +3 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/tui/app.d.ts.map +1 -1
- package/dist/cli/tui/app.js +52 -3
- package/dist/cli/tui/app.js.map +1 -1
- package/dist/cli/tui/header.d.ts.map +1 -1
- package/dist/cli/tui/header.js +2 -1
- package/dist/cli/tui/header.js.map +1 -1
- package/dist/cli/tui/settings-view.d.ts.map +1 -1
- package/dist/cli/tui/settings-view.js +22 -5
- package/dist/cli/tui/settings-view.js.map +1 -1
- package/dist/cli/tui/stats-view.js +2 -2
- package/dist/cli/tui/stats-view.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +141 -13
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +903 -21
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +77 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/forge/types.d.ts +7 -0
- package/dist/forge/types.d.ts.map +1 -1
- package/dist/git/repo.d.ts +8 -0
- package/dist/git/repo.d.ts.map +1 -1
- package/dist/git/repo.js +19 -0
- package/dist/git/repo.js.map +1 -1
- package/dist/git/worktree.d.ts +3 -0
- package/dist/git/worktree.d.ts.map +1 -1
- package/dist/git/worktree.js +43 -21
- package/dist/git/worktree.js.map +1 -1
- package/dist/loop/cost.d.ts +33 -8
- package/dist/loop/cost.d.ts.map +1 -1
- package/dist/loop/cost.js +250 -46
- package/dist/loop/cost.js.map +1 -1
- package/dist/loop/decision.js +3 -3
- package/dist/loop/decision.js.map +1 -1
- package/dist/loop/engine.d.ts +1 -0
- package/dist/loop/engine.d.ts.map +1 -1
- package/dist/loop/engine.js +76 -13
- package/dist/loop/engine.js.map +1 -1
- package/dist/loop/parallel.d.ts.map +1 -1
- package/dist/loop/parallel.js +2 -0
- package/dist/loop/parallel.js.map +1 -1
- package/dist/loop/pricing.d.ts +11 -4
- package/dist/loop/pricing.d.ts.map +1 -1
- package/dist/loop/pricing.js +62 -20
- package/dist/loop/pricing.js.map +1 -1
- package/dist/loop/progress.d.ts +24 -0
- package/dist/loop/progress.d.ts.map +1 -0
- package/dist/loop/progress.js +52 -0
- package/dist/loop/progress.js.map +1 -0
- package/dist/loop/step-executor.d.ts +4 -5
- package/dist/loop/step-executor.d.ts.map +1 -1
- package/dist/loop/step-executor.js +1 -0
- package/dist/loop/step-executor.js.map +1 -1
- package/dist/loop/types.d.ts +17 -0
- package/dist/loop/types.d.ts.map +1 -1
- package/dist/mcp/tools/admin.d.ts +29 -0
- package/dist/mcp/tools/admin.d.ts.map +1 -0
- package/dist/mcp/tools/admin.js +89 -0
- package/dist/mcp/tools/admin.js.map +1 -0
- package/dist/mcp/tools/auth.d.ts +3 -0
- package/dist/mcp/tools/auth.d.ts.map +1 -0
- package/dist/mcp/tools/auth.js +19 -0
- package/dist/mcp/tools/auth.js.map +1 -0
- package/dist/mcp/tools/index.d.ts.map +1 -1
- package/dist/mcp/tools/index.js +5 -533
- package/dist/mcp/tools/index.js.map +1 -1
- package/dist/mcp/tools/operations.d.ts +32 -0
- package/dist/mcp/tools/operations.d.ts.map +1 -0
- package/dist/mcp/tools/operations.js +95 -0
- package/dist/mcp/tools/operations.js.map +1 -0
- package/dist/mcp/tools/settings.d.ts +12 -0
- package/dist/mcp/tools/settings.d.ts.map +1 -0
- package/dist/mcp/tools/settings.js +58 -0
- package/dist/mcp/tools/settings.js.map +1 -0
- package/dist/mcp/tools/status.d.ts +25 -0
- package/dist/mcp/tools/status.d.ts.map +1 -0
- package/dist/mcp/tools/status.js +307 -0
- package/dist/mcp/tools/status.js.map +1 -0
- package/dist/ops/continue.js +6 -1
- package/dist/ops/continue.js.map +1 -1
- package/dist/ops/project-check.d.ts +15 -0
- package/dist/ops/project-check.d.ts.map +1 -0
- package/dist/ops/project-check.js +136 -0
- package/dist/ops/project-check.js.map +1 -0
- package/dist/ops/rebase-and-check.d.ts +2 -1
- package/dist/ops/rebase-and-check.d.ts.map +1 -1
- package/dist/ops/rebase-and-check.js +2 -2
- package/dist/ops/rebase-and-check.js.map +1 -1
- package/dist/ops/rebase.d.ts +8 -5
- package/dist/ops/rebase.d.ts.map +1 -1
- package/dist/ops/rebase.js +43 -29
- package/dist/ops/rebase.js.map +1 -1
- package/dist/runner/comment-commands.d.ts +20 -0
- package/dist/runner/comment-commands.d.ts.map +1 -0
- package/dist/runner/comment-commands.js +221 -0
- package/dist/runner/comment-commands.js.map +1 -0
- package/dist/runner/helpers.d.ts +57 -0
- package/dist/runner/helpers.d.ts.map +1 -0
- package/dist/runner/helpers.js +259 -0
- package/dist/runner/helpers.js.map +1 -0
- package/dist/runner/poller.d.ts.map +1 -1
- package/dist/runner/poller.js +19 -781
- package/dist/runner/poller.js.map +1 -1
- package/dist/runner/reaction-scan.d.ts +17 -0
- package/dist/runner/reaction-scan.d.ts.map +1 -0
- package/dist/runner/reaction-scan.js +33 -0
- package/dist/runner/reaction-scan.js.map +1 -0
- package/dist/runner/run-finalizer.d.ts +30 -0
- package/dist/runner/run-finalizer.d.ts.map +1 -0
- package/dist/runner/run-finalizer.js +217 -0
- package/dist/runner/run-finalizer.js.map +1 -0
- package/dist/settings/definitions/github.d.ts +3 -0
- package/dist/settings/definitions/github.d.ts.map +1 -0
- package/dist/settings/definitions/github.js +267 -0
- package/dist/settings/definitions/github.js.map +1 -0
- package/dist/settings/definitions/loop.d.ts +3 -0
- package/dist/settings/definitions/loop.d.ts.map +1 -0
- package/dist/settings/definitions/loop.js +113 -0
- package/dist/settings/definitions/loop.js.map +1 -0
- package/dist/settings/definitions/observability.d.ts +3 -0
- package/dist/settings/definitions/observability.d.ts.map +1 -0
- package/dist/settings/definitions/observability.js +74 -0
- package/dist/settings/definitions/observability.js.map +1 -0
- package/dist/settings/definitions/security.d.ts +3 -0
- package/dist/settings/definitions/security.d.ts.map +1 -0
- package/dist/settings/definitions/security.js +121 -0
- package/dist/settings/definitions/security.js.map +1 -0
- package/dist/settings/registry.d.ts +82 -6
- package/dist/settings/registry.d.ts.map +1 -1
- package/dist/settings/registry.js +301 -194
- package/dist/settings/registry.js.map +1 -1
- package/dist/settings/runtime.d.ts +5 -1
- package/dist/settings/runtime.d.ts.map +1 -1
- package/dist/settings/runtime.js +46 -9
- package/dist/settings/runtime.js.map +1 -1
- package/dist/state/db.d.ts.map +1 -1
- package/dist/state/db.js +2 -0
- package/dist/state/db.js.map +1 -1
- package/dist/state/migrations/020-cost-ledger.d.ts +3 -0
- package/dist/state/migrations/020-cost-ledger.d.ts.map +1 -0
- package/dist/state/migrations/020-cost-ledger.js +37 -0
- package/dist/state/migrations/020-cost-ledger.js.map +1 -0
- package/dist/state/runs.d.ts +1 -0
- package/dist/state/runs.d.ts.map +1 -1
- package/dist/state/runs.js +3 -0
- package/dist/state/runs.js.map +1 -1
- package/dist/state/stats.d.ts +20 -0
- package/dist/state/stats.d.ts.map +1 -1
- package/dist/state/stats.js +68 -8
- package/dist/state/stats.js.map +1 -1
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +13 -0
- package/dist/utils/logger.js.map +1 -1
- package/dist/web/routes/api-events.d.ts +10 -0
- package/dist/web/routes/api-events.d.ts.map +1 -0
- package/dist/web/routes/api-events.js +251 -0
- package/dist/web/routes/api-events.js.map +1 -0
- package/dist/web/routes/api-operations.d.ts +3 -0
- package/dist/web/routes/api-operations.d.ts.map +1 -0
- package/dist/web/routes/api-operations.js +371 -0
- package/dist/web/routes/api-operations.js.map +1 -0
- package/dist/web/routes/api-runs.d.ts +3 -0
- package/dist/web/routes/api-runs.d.ts.map +1 -0
- package/dist/web/routes/api-runs.js +96 -0
- package/dist/web/routes/api-runs.js.map +1 -0
- package/dist/web/routes/api-settings.d.ts +3 -0
- package/dist/web/routes/api-settings.d.ts.map +1 -0
- package/dist/web/routes/api-settings.js +61 -0
- package/dist/web/routes/api-settings.js.map +1 -0
- package/dist/web/routes/context.d.ts +15 -0
- package/dist/web/routes/context.d.ts.map +1 -0
- package/dist/web/routes/context.js +2 -0
- package/dist/web/routes/context.js.map +1 -0
- package/dist/web/server.d.ts +58 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +116 -847
- package/dist/web/server.js.map +1 -1
- package/dist/web/shell-session.d.ts +74 -0
- package/dist/web/shell-session.d.ts.map +1 -0
- package/dist/web/shell-session.js +279 -0
- package/dist/web/shell-session.js.map +1 -0
- package/dist/web/snapshots.d.ts +159 -0
- package/dist/web/snapshots.d.ts.map +1 -0
- package/dist/web/snapshots.js +231 -0
- package/dist/web/snapshots.js.map +1 -0
- package/dist/workers/acp.d.ts.map +1 -1
- package/dist/workers/acp.js +116 -0
- package/dist/workers/acp.js.map +1 -1
- package/dist/workers/claude.d.ts.map +1 -1
- package/dist/workers/claude.js +13 -3
- package/dist/workers/claude.js.map +1 -1
- package/dist/workers/codex.d.ts.map +1 -1
- package/dist/workers/codex.js +16 -4
- package/dist/workers/codex.js.map +1 -1
- package/dist/workers/types.d.ts +14 -4
- package/dist/workers/types.d.ts.map +1 -1
- package/examples/config.example.yaml +12 -3
- package/package.json +8 -2
- package/web/dist/assets/index-BIrXUwFe.css +1 -0
- package/web/dist/assets/index-COMzHPcP.js +26 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-k6kgdnzy.js +0 -9
- package/web/dist/assets/index-xm9qPlYB.css +0 -1
package/dist/web/server.js
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import { existsSync
|
|
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 {
|
|
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 {
|
|
18
|
-
import {
|
|
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,
|
|
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,
|
|
230
|
+
async function handleApiRequest(req, res, requestUrl, ctx) {
|
|
215
231
|
const method = req.method ?? 'GET';
|
|
216
232
|
const { pathname, searchParams } = requestUrl;
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
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/')
|
|
223
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
925
|
-
|
|
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
|
-
|
|
574
|
+
requestUrl = getRequestUrl(request);
|
|
1181
575
|
}
|
|
1182
576
|
catch {
|
|
1183
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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;
|