@kbediako/codex-orchestrator 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/bin/codex-orchestrator.js +38 -0
- package/dist/orchestrator/src/cli/config/delegationConfig.js +485 -0
- package/dist/orchestrator/src/cli/control/confirmations.js +262 -0
- package/dist/orchestrator/src/cli/control/controlServer.js +1476 -0
- package/dist/orchestrator/src/cli/control/controlState.js +46 -0
- package/dist/orchestrator/src/cli/control/controlWatcher.js +222 -0
- package/dist/orchestrator/src/cli/control/delegationTokens.js +62 -0
- package/dist/orchestrator/src/cli/control/questions.js +106 -0
- package/dist/orchestrator/src/cli/delegationServer.js +1368 -0
- package/dist/orchestrator/src/cli/events/runEventStream.js +246 -0
- package/dist/orchestrator/src/cli/exec/context.js +4 -1
- package/dist/orchestrator/src/cli/exec/stageRunner.js +30 -5
- package/dist/orchestrator/src/cli/metrics/metricsAggregator.js +377 -147
- package/dist/orchestrator/src/cli/metrics/metricsRecorder.js +3 -5
- package/dist/orchestrator/src/cli/orchestrator.js +217 -40
- package/dist/orchestrator/src/cli/rlmRunner.js +26 -3
- package/dist/orchestrator/src/cli/run/manifestPersister.js +33 -3
- package/dist/orchestrator/src/cli/run/runPaths.js +14 -0
- package/dist/orchestrator/src/cli/services/commandRunner.js +1 -1
- package/dist/orchestrator/src/cli/utils/devtools.js +33 -2
- package/dist/orchestrator/src/persistence/ExperienceStore.js +113 -46
- package/dist/orchestrator/src/persistence/PersistenceCoordinator.js +8 -8
- package/dist/orchestrator/src/persistence/TaskStateStore.js +2 -1
- package/dist/orchestrator/src/persistence/lockFile.js +26 -1
- package/dist/orchestrator/src/sync/CloudSyncWorker.js +17 -4
- package/dist/packages/orchestrator/src/telemetry/otel-exporter.js +21 -0
- package/package.json +3 -1
|
@@ -0,0 +1,1476 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { chmod, readFile } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { writeJsonAtomic } from '../utils/fs.js';
|
|
8
|
+
import { isoTimestamp } from '../utils/time.js';
|
|
9
|
+
import { logger } from '../../logger.js';
|
|
10
|
+
import { ControlStateStore } from './controlState.js';
|
|
11
|
+
import { ConfirmationStore } from './confirmations.js';
|
|
12
|
+
import { QuestionQueue } from './questions.js';
|
|
13
|
+
import { DelegationTokenStore } from './delegationTokens.js';
|
|
14
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
15
|
+
const EXPIRY_INTERVAL_MS = 15_000;
|
|
16
|
+
const SESSION_TTL_MS = 15 * 60 * 1000;
|
|
17
|
+
const CHILD_CONTROL_TIMEOUT_MS = 15_000;
|
|
18
|
+
const CSRF_HEADER = 'x-csrf-token';
|
|
19
|
+
const DELEGATION_TOKEN_HEADER = 'x-codex-delegation-token';
|
|
20
|
+
const DELEGATION_RUN_HEADER = 'x-codex-delegation-run-id';
|
|
21
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
22
|
+
const UI_ASSET_PATHS = {
|
|
23
|
+
'/ui': 'index.html',
|
|
24
|
+
'/ui/': 'index.html',
|
|
25
|
+
'/ui/app.js': 'app.js',
|
|
26
|
+
'/ui/styles.css': 'styles.css',
|
|
27
|
+
'/ui/favicon.svg': 'favicon.svg'
|
|
28
|
+
};
|
|
29
|
+
const UI_ROOT = resolveUiRoot();
|
|
30
|
+
class HttpError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
class SessionTokenStore {
|
|
38
|
+
ttlMs;
|
|
39
|
+
tokens = new Map();
|
|
40
|
+
constructor(ttlMs) {
|
|
41
|
+
this.ttlMs = ttlMs;
|
|
42
|
+
}
|
|
43
|
+
issue() {
|
|
44
|
+
this.prune();
|
|
45
|
+
const token = randomBytes(24).toString('hex');
|
|
46
|
+
const expiresAt = Date.now() + this.ttlMs;
|
|
47
|
+
this.tokens.set(token, expiresAt);
|
|
48
|
+
return { token, expiresAt: new Date(expiresAt).toISOString() };
|
|
49
|
+
}
|
|
50
|
+
validate(token) {
|
|
51
|
+
this.prune();
|
|
52
|
+
const expiresAt = this.tokens.get(token);
|
|
53
|
+
if (!expiresAt) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (expiresAt <= Date.now()) {
|
|
57
|
+
this.tokens.delete(token);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
prune() {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
for (const [token, expiresAt] of this.tokens.entries()) {
|
|
65
|
+
if (expiresAt <= now) {
|
|
66
|
+
this.tokens.delete(token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class ControlServer {
|
|
72
|
+
server;
|
|
73
|
+
controlStore;
|
|
74
|
+
confirmationStore;
|
|
75
|
+
questionQueue;
|
|
76
|
+
delegationTokens;
|
|
77
|
+
sessionTokens;
|
|
78
|
+
eventStream;
|
|
79
|
+
clients;
|
|
80
|
+
persist;
|
|
81
|
+
paths;
|
|
82
|
+
baseUrl = null;
|
|
83
|
+
expiryTimer = null;
|
|
84
|
+
constructor(options) {
|
|
85
|
+
this.server = options.server;
|
|
86
|
+
this.controlStore = options.controlStore;
|
|
87
|
+
this.confirmationStore = options.confirmationStore;
|
|
88
|
+
this.questionQueue = options.questionQueue;
|
|
89
|
+
this.delegationTokens = options.delegationTokens;
|
|
90
|
+
this.sessionTokens = options.sessionTokens;
|
|
91
|
+
this.eventStream = options.eventStream;
|
|
92
|
+
this.clients = options.clients;
|
|
93
|
+
this.persist = options.persist;
|
|
94
|
+
this.paths = options.paths;
|
|
95
|
+
}
|
|
96
|
+
static async start(options) {
|
|
97
|
+
const token = randomBytes(24).toString('hex');
|
|
98
|
+
const controlSeed = await readJsonFile(options.paths.controlPath);
|
|
99
|
+
const confirmationsSeed = await readJsonFile(options.paths.confirmationsPath);
|
|
100
|
+
const questionsSeed = await readJsonFile(options.paths.questionsPath);
|
|
101
|
+
const delegationSeed = await readJsonFile(options.paths.delegationTokensPath);
|
|
102
|
+
const controlStore = new ControlStateStore({
|
|
103
|
+
runId: options.runId,
|
|
104
|
+
controlSeq: controlSeed?.control_seq ?? 0,
|
|
105
|
+
latestAction: controlSeed?.latest_action ?? null,
|
|
106
|
+
featureToggles: controlSeed?.feature_toggles ?? null
|
|
107
|
+
});
|
|
108
|
+
const defaultToggles = controlSeed?.feature_toggles ?? {};
|
|
109
|
+
if (!('rlm' in defaultToggles)) {
|
|
110
|
+
controlStore.updateFeatureToggles({ rlm: { policy: options.config.rlm.policy } });
|
|
111
|
+
}
|
|
112
|
+
const confirmationStore = new ConfirmationStore({
|
|
113
|
+
runId: options.runId,
|
|
114
|
+
expiresInMs: options.config.confirm.expiresInMs,
|
|
115
|
+
maxPending: options.config.confirm.maxPending,
|
|
116
|
+
seed: {
|
|
117
|
+
pending: confirmationsSeed?.pending ?? [],
|
|
118
|
+
issued: confirmationsSeed?.issued ?? [],
|
|
119
|
+
consumed_nonce_ids: confirmationsSeed?.consumed_nonce_ids ?? []
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const questionQueue = new QuestionQueue({ seed: questionsSeed?.questions ?? [] });
|
|
123
|
+
const delegationTokens = new DelegationTokenStore({ seed: delegationSeed?.tokens ?? [] });
|
|
124
|
+
const sessionTokens = new SessionTokenStore(SESSION_TTL_MS);
|
|
125
|
+
const clients = new Set();
|
|
126
|
+
const persist = {
|
|
127
|
+
control: async () => writeJsonAtomic(options.paths.controlPath, controlStore.snapshot()),
|
|
128
|
+
confirmations: async () => writeJsonAtomic(options.paths.confirmationsPath, confirmationStore.snapshot()),
|
|
129
|
+
questions: async () => writeJsonAtomic(options.paths.questionsPath, { questions: questionQueue.list() }),
|
|
130
|
+
delegationTokens: async () => writeJsonAtomic(options.paths.delegationTokensPath, { tokens: delegationTokens.list() })
|
|
131
|
+
};
|
|
132
|
+
const server = http.createServer((req, res) => {
|
|
133
|
+
handleRequest({
|
|
134
|
+
req,
|
|
135
|
+
res,
|
|
136
|
+
token,
|
|
137
|
+
controlStore,
|
|
138
|
+
confirmationStore,
|
|
139
|
+
questionQueue,
|
|
140
|
+
delegationTokens,
|
|
141
|
+
sessionTokens,
|
|
142
|
+
eventStream: options.eventStream,
|
|
143
|
+
config: options.config,
|
|
144
|
+
persist,
|
|
145
|
+
clients,
|
|
146
|
+
paths: options.paths
|
|
147
|
+
}).catch((error) => {
|
|
148
|
+
const status = error instanceof HttpError ? error.status : 500;
|
|
149
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
150
|
+
res.end(JSON.stringify({ error: error?.message ?? String(error) }));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
const instance = new ControlServer({
|
|
154
|
+
server,
|
|
155
|
+
controlStore,
|
|
156
|
+
confirmationStore,
|
|
157
|
+
questionQueue,
|
|
158
|
+
delegationTokens,
|
|
159
|
+
sessionTokens,
|
|
160
|
+
eventStream: options.eventStream,
|
|
161
|
+
clients,
|
|
162
|
+
persist,
|
|
163
|
+
paths: options.paths
|
|
164
|
+
});
|
|
165
|
+
const host = options.config.ui.bindHost;
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
const onError = (error) => {
|
|
168
|
+
server.off('error', onError);
|
|
169
|
+
try {
|
|
170
|
+
server.close(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Ignore close errors on a server that failed to bind.
|
|
174
|
+
}
|
|
175
|
+
reject(error);
|
|
176
|
+
};
|
|
177
|
+
server.once('error', onError);
|
|
178
|
+
server.listen(0, host, () => {
|
|
179
|
+
server.off('error', onError);
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
const address = server.address();
|
|
184
|
+
const port = typeof address === 'string' || !address ? 0 : address.port;
|
|
185
|
+
instance.baseUrl = `http://${formatHostForUrl(host)}:${port}`;
|
|
186
|
+
server.on('error', (error) => {
|
|
187
|
+
logger.error(`Control server error: ${error?.message ?? String(error)}`);
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
await writeJsonAtomic(options.paths.controlAuthPath, {
|
|
191
|
+
token,
|
|
192
|
+
created_at: isoTimestamp()
|
|
193
|
+
});
|
|
194
|
+
await chmod(options.paths.controlAuthPath, 0o600).catch(() => undefined);
|
|
195
|
+
await writeJsonAtomic(options.paths.controlEndpointPath, {
|
|
196
|
+
base_url: instance.baseUrl,
|
|
197
|
+
token_path: options.paths.controlAuthPath
|
|
198
|
+
});
|
|
199
|
+
await chmod(options.paths.controlEndpointPath, 0o600).catch(() => undefined);
|
|
200
|
+
await writeJsonAtomic(options.paths.controlPath, controlStore.snapshot());
|
|
201
|
+
instance.expiryTimer = setInterval(() => {
|
|
202
|
+
expireConfirmations({
|
|
203
|
+
...instance.buildContext(options.config, token),
|
|
204
|
+
req: null,
|
|
205
|
+
res: null
|
|
206
|
+
}).catch(() => undefined);
|
|
207
|
+
expireQuestions({
|
|
208
|
+
...instance.buildContext(options.config, token),
|
|
209
|
+
req: null,
|
|
210
|
+
res: null
|
|
211
|
+
}).catch(() => undefined);
|
|
212
|
+
}, EXPIRY_INTERVAL_MS);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (instance.expiryTimer) {
|
|
216
|
+
clearInterval(instance.expiryTimer);
|
|
217
|
+
instance.expiryTimer = null;
|
|
218
|
+
}
|
|
219
|
+
await new Promise((resolve) => {
|
|
220
|
+
server.close(() => resolve());
|
|
221
|
+
});
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return instance;
|
|
225
|
+
}
|
|
226
|
+
getBaseUrl() {
|
|
227
|
+
return this.baseUrl;
|
|
228
|
+
}
|
|
229
|
+
broadcast(entry) {
|
|
230
|
+
const payload = `data: ${JSON.stringify(entry)}\n\n`;
|
|
231
|
+
for (const client of this.clients) {
|
|
232
|
+
client.write(payload, (error) => {
|
|
233
|
+
if (error) {
|
|
234
|
+
this.clients.delete(client);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async close() {
|
|
240
|
+
if (this.expiryTimer) {
|
|
241
|
+
clearInterval(this.expiryTimer);
|
|
242
|
+
this.expiryTimer = null;
|
|
243
|
+
}
|
|
244
|
+
for (const client of this.clients) {
|
|
245
|
+
client.end();
|
|
246
|
+
}
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
this.server.close(() => resolve());
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
buildContext(config, token) {
|
|
252
|
+
return {
|
|
253
|
+
token,
|
|
254
|
+
controlStore: this.controlStore,
|
|
255
|
+
confirmationStore: this.confirmationStore,
|
|
256
|
+
questionQueue: this.questionQueue,
|
|
257
|
+
delegationTokens: this.delegationTokens,
|
|
258
|
+
sessionTokens: this.sessionTokens,
|
|
259
|
+
eventStream: this.eventStream,
|
|
260
|
+
config,
|
|
261
|
+
persist: this.persist,
|
|
262
|
+
clients: this.clients,
|
|
263
|
+
paths: this.paths
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function handleRequest(context) {
|
|
268
|
+
if (!context.req || !context.res) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const { req, res } = context;
|
|
272
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
273
|
+
if (url.pathname === '/health') {
|
|
274
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
275
|
+
res.end(JSON.stringify({ status: 'ok', timestamp: isoTimestamp() }));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (url.pathname === '/' || url.pathname === '') {
|
|
279
|
+
const search = url.search ? url.search : '';
|
|
280
|
+
res.writeHead(302, { Location: `/ui${search}` });
|
|
281
|
+
res.end();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const uiAsset = resolveUiAssetPath(url.pathname);
|
|
285
|
+
if (uiAsset) {
|
|
286
|
+
await serveUiAsset(uiAsset, res);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (url.pathname === '/auth/session' && (req.method === 'GET' || req.method === 'POST')) {
|
|
290
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
|
291
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
292
|
+
res.end(JSON.stringify({ error: 'loopback_only' }));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const allowedHosts = normalizeAllowedHosts(context.config.ui.allowedBindHosts);
|
|
296
|
+
const hostHeader = parseHostHeader(req.headers.host);
|
|
297
|
+
if (!hostHeader || !allowedHosts.has(hostHeader)) {
|
|
298
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
299
|
+
res.end(JSON.stringify({ error: 'host_not_allowed' }));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const originHost = parseOriginHost(req.headers.origin);
|
|
303
|
+
if (originHost) {
|
|
304
|
+
if (!allowedHosts.has(originHost)) {
|
|
305
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
306
|
+
res.end(JSON.stringify({ error: 'origin_not_allowed' }));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else if (req.method !== 'GET') {
|
|
311
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
312
|
+
res.end(JSON.stringify({ error: 'origin_required' }));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const session = context.sessionTokens.issue();
|
|
316
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
317
|
+
res.end(JSON.stringify({ token: session.token, expires_at: session.expiresAt }));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const auth = resolveAuthToken(req, context);
|
|
321
|
+
if (!auth) {
|
|
322
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
323
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (requiresCsrf(req) && !isCsrfValid(req, auth.token)) {
|
|
327
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
328
|
+
res.end(JSON.stringify({ error: 'csrf_invalid' }));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (isRunnerOnlyEndpoint(url.pathname, req.method) && auth.kind !== 'control') {
|
|
332
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
333
|
+
res.end(JSON.stringify({ error: 'runner_only' }));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (url.pathname === '/events' && req.method === 'GET') {
|
|
337
|
+
res.writeHead(200, {
|
|
338
|
+
'Content-Type': 'text/event-stream',
|
|
339
|
+
'Cache-Control': 'no-cache',
|
|
340
|
+
Connection: 'keep-alive'
|
|
341
|
+
});
|
|
342
|
+
res.write(`: ok\n\n`);
|
|
343
|
+
context.clients.add(res);
|
|
344
|
+
req.on('close', () => {
|
|
345
|
+
context.clients.delete(res);
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (url.pathname === '/ui/data.json' && req.method === 'GET') {
|
|
350
|
+
const payload = await buildUiDataset(context);
|
|
351
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
352
|
+
res.end(JSON.stringify(payload));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (url.pathname === '/control/action' && req.method === 'POST') {
|
|
356
|
+
const body = await readJsonBody(req);
|
|
357
|
+
const action = readStringValue(body, 'action');
|
|
358
|
+
if (!isControlAction(action)) {
|
|
359
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
360
|
+
res.end(JSON.stringify({ error: 'invalid_action' }));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (auth.kind === 'session' && action !== 'pause' && action !== 'resume') {
|
|
364
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
365
|
+
res.end(JSON.stringify({ error: 'ui_action_disallowed' }));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
let resolvedRequestId = readStringValue(body, 'request_id', 'requestId');
|
|
369
|
+
if (action === 'cancel') {
|
|
370
|
+
const confirmNonce = readStringValue(body, 'confirm_nonce', 'confirmNonce');
|
|
371
|
+
if (!confirmNonce) {
|
|
372
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
373
|
+
res.end(JSON.stringify({ error: 'confirmation_required' }));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const tool = readStringValue(body, 'tool') ?? 'delegate.cancel';
|
|
377
|
+
const params = readRecordValue(body, 'params') ?? {};
|
|
378
|
+
let validation;
|
|
379
|
+
try {
|
|
380
|
+
validation = context.confirmationStore.validateNonce({
|
|
381
|
+
confirmNonce,
|
|
382
|
+
tool,
|
|
383
|
+
params
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
388
|
+
res.end(JSON.stringify({ error: error?.message ?? 'confirmation_invalid' }));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
resolvedRequestId = validation.request.request_id;
|
|
392
|
+
await context.persist.confirmations();
|
|
393
|
+
await emitControlEvent(context, {
|
|
394
|
+
event: 'confirmation_resolved',
|
|
395
|
+
actor: 'runner',
|
|
396
|
+
payload: {
|
|
397
|
+
request_id: validation.request.request_id,
|
|
398
|
+
nonce_id: validation.nonce_id,
|
|
399
|
+
outcome: 'approved'
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
const reason = readStringValue(body, 'reason');
|
|
404
|
+
context.controlStore.updateAction({
|
|
405
|
+
action,
|
|
406
|
+
requestedBy: readStringValue(body, 'requested_by', 'requestedBy') ?? 'ui',
|
|
407
|
+
requestId: resolvedRequestId,
|
|
408
|
+
reason
|
|
409
|
+
});
|
|
410
|
+
await context.persist.control();
|
|
411
|
+
const snapshot = context.controlStore.snapshot();
|
|
412
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
413
|
+
res.end(JSON.stringify(snapshot));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (url.pathname === '/confirmations/create' && req.method === 'POST') {
|
|
417
|
+
await expireConfirmations(context);
|
|
418
|
+
const body = await readJsonBody(req);
|
|
419
|
+
const rawAction = readStringValue(body, 'action');
|
|
420
|
+
const action = rawAction === 'cancel' || rawAction === 'merge' ? rawAction : 'other';
|
|
421
|
+
let tool = readStringValue(body, 'tool') ?? 'unknown';
|
|
422
|
+
let params = readRecordValue(body, 'params') ?? {};
|
|
423
|
+
if (auth.kind === 'session') {
|
|
424
|
+
if (rawAction !== 'cancel' || tool !== 'ui.cancel') {
|
|
425
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
426
|
+
res.end(JSON.stringify({ error: 'ui_confirmation_disallowed' }));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
tool = 'ui.cancel';
|
|
430
|
+
params = {};
|
|
431
|
+
}
|
|
432
|
+
const { confirmation, wasCreated } = context.confirmationStore.create({
|
|
433
|
+
action,
|
|
434
|
+
tool,
|
|
435
|
+
params
|
|
436
|
+
});
|
|
437
|
+
await context.persist.confirmations();
|
|
438
|
+
if (wasCreated && context.config.confirm.autoPause) {
|
|
439
|
+
const latestAction = context.controlStore.snapshot().latest_action?.action ?? null;
|
|
440
|
+
if (latestAction !== 'pause') {
|
|
441
|
+
context.controlStore.updateAction({
|
|
442
|
+
action: 'pause',
|
|
443
|
+
requestedBy: 'runner',
|
|
444
|
+
requestId: confirmation.request_id,
|
|
445
|
+
reason: 'confirmation_required'
|
|
446
|
+
});
|
|
447
|
+
await context.persist.control();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const runId = context.controlStore.snapshot().run_id;
|
|
451
|
+
if (wasCreated) {
|
|
452
|
+
await emitControlEvent(context, {
|
|
453
|
+
event: 'confirmation_required',
|
|
454
|
+
actor: 'runner',
|
|
455
|
+
payload: {
|
|
456
|
+
request_id: confirmation.request_id,
|
|
457
|
+
confirm_scope: {
|
|
458
|
+
run_id: runId,
|
|
459
|
+
action: confirmation.action,
|
|
460
|
+
action_params_digest: confirmation.action_params_digest
|
|
461
|
+
},
|
|
462
|
+
action_params_digest: confirmation.action_params_digest,
|
|
463
|
+
digest_alg: confirmation.digest_alg,
|
|
464
|
+
confirm_expires_in_ms: Date.parse(confirmation.expires_at) - Date.parse(confirmation.requested_at)
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
469
|
+
res.end(JSON.stringify({
|
|
470
|
+
request_id: confirmation.request_id,
|
|
471
|
+
confirm_scope: {
|
|
472
|
+
run_id: runId,
|
|
473
|
+
action: confirmation.action,
|
|
474
|
+
action_params_digest: confirmation.action_params_digest
|
|
475
|
+
},
|
|
476
|
+
action_params_digest: confirmation.action_params_digest,
|
|
477
|
+
digest_alg: confirmation.digest_alg,
|
|
478
|
+
confirm_expires_in_ms: Date.parse(confirmation.expires_at) - Date.parse(confirmation.requested_at)
|
|
479
|
+
}));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (url.pathname === '/confirmations' && req.method === 'GET') {
|
|
483
|
+
await expireConfirmations(context);
|
|
484
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
485
|
+
res.end(JSON.stringify({ pending: sanitizeConfirmations(context.confirmationStore.listPending()) }));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (url.pathname === '/confirmations/approve' && req.method === 'POST') {
|
|
489
|
+
await expireConfirmations(context);
|
|
490
|
+
const body = await readJsonBody(req);
|
|
491
|
+
const requestId = readStringValue(body, 'request_id', 'requestId');
|
|
492
|
+
if (!requestId) {
|
|
493
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
494
|
+
res.end(JSON.stringify({ error: 'missing_request_id' }));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const actor = readStringValue(body, 'actor') ?? 'ui';
|
|
498
|
+
context.confirmationStore.approve(requestId, actor);
|
|
499
|
+
const entry = context.confirmationStore.get(requestId);
|
|
500
|
+
await context.persist.confirmations();
|
|
501
|
+
if (entry && entry.tool.startsWith('ui.') && entry.action === 'cancel') {
|
|
502
|
+
try {
|
|
503
|
+
const nonce = context.confirmationStore.issue(requestId);
|
|
504
|
+
const validation = context.confirmationStore.validateNonce({
|
|
505
|
+
confirmNonce: nonce.confirm_nonce,
|
|
506
|
+
tool: entry.tool,
|
|
507
|
+
params: entry.params
|
|
508
|
+
});
|
|
509
|
+
await context.persist.confirmations();
|
|
510
|
+
await emitControlEvent(context, {
|
|
511
|
+
event: 'confirmation_resolved',
|
|
512
|
+
actor: 'runner',
|
|
513
|
+
payload: {
|
|
514
|
+
request_id: validation.request.request_id,
|
|
515
|
+
nonce_id: validation.nonce_id,
|
|
516
|
+
outcome: 'approved'
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
context.controlStore.updateAction({
|
|
520
|
+
action: 'cancel',
|
|
521
|
+
requestedBy: actor,
|
|
522
|
+
requestId: requestId
|
|
523
|
+
});
|
|
524
|
+
await context.persist.control();
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
528
|
+
res.end(JSON.stringify({ error: error?.message ?? 'confirmation_invalid' }));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
533
|
+
res.end(JSON.stringify({ status: 'approved' }));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if ((url.pathname === '/confirmations/issue' || url.pathname === '/confirmations/consume') && req.method === 'POST') {
|
|
537
|
+
await expireConfirmations(context);
|
|
538
|
+
const body = await readJsonBody(req);
|
|
539
|
+
const requestId = readStringValue(body, 'request_id', 'requestId');
|
|
540
|
+
if (!requestId) {
|
|
541
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
542
|
+
res.end(JSON.stringify({ error: 'missing_request_id' }));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
let nonce;
|
|
546
|
+
try {
|
|
547
|
+
nonce = context.confirmationStore.issue(requestId);
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
551
|
+
res.end(JSON.stringify({ error: error?.message ?? 'confirmation_invalid' }));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await context.persist.confirmations();
|
|
555
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
556
|
+
res.end(JSON.stringify(nonce));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (url.pathname === '/confirmations/validate' && req.method === 'POST') {
|
|
560
|
+
await expireConfirmations(context);
|
|
561
|
+
const body = await readJsonBody(req);
|
|
562
|
+
const confirmNonce = readStringValue(body, 'confirm_nonce', 'confirmNonce');
|
|
563
|
+
if (!confirmNonce) {
|
|
564
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
565
|
+
res.end(JSON.stringify({ error: 'missing_confirm_nonce' }));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const tool = readStringValue(body, 'tool') ?? 'unknown';
|
|
569
|
+
const params = readRecordValue(body, 'params') ?? {};
|
|
570
|
+
let validation;
|
|
571
|
+
try {
|
|
572
|
+
validation = context.confirmationStore.validateNonce({ confirmNonce, tool, params });
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
576
|
+
res.end(JSON.stringify({ error: error?.message ?? 'confirmation_invalid' }));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
await context.persist.confirmations();
|
|
580
|
+
await emitControlEvent(context, {
|
|
581
|
+
event: 'confirmation_resolved',
|
|
582
|
+
actor: 'runner',
|
|
583
|
+
payload: {
|
|
584
|
+
request_id: validation.request.request_id,
|
|
585
|
+
nonce_id: validation.nonce_id,
|
|
586
|
+
outcome: 'approved'
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
590
|
+
res.end(JSON.stringify({ status: 'valid', request_id: validation.request.request_id, nonce_id: validation.nonce_id }));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (url.pathname === '/security/violation' && req.method === 'POST') {
|
|
594
|
+
const body = await readJsonBody(req);
|
|
595
|
+
await emitControlEvent(context, {
|
|
596
|
+
event: 'security_violation',
|
|
597
|
+
actor: 'runner',
|
|
598
|
+
payload: {
|
|
599
|
+
kind: readStringValue(body, 'kind') ?? 'unknown',
|
|
600
|
+
summary: readStringValue(body, 'summary') ?? 'security_violation',
|
|
601
|
+
severity: readStringValue(body, 'severity') ?? 'high',
|
|
602
|
+
related_request_id: readStringValue(body, 'related_request_id', 'relatedRequestId') ?? null,
|
|
603
|
+
details_redacted: true
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
607
|
+
res.end(JSON.stringify({ status: 'recorded' }));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (url.pathname === '/delegation/register' && req.method === 'POST') {
|
|
611
|
+
const body = await readJsonBody(req);
|
|
612
|
+
const token = readStringValue(body, 'token');
|
|
613
|
+
const parentRunId = readStringValue(body, 'parent_run_id', 'parentRunId');
|
|
614
|
+
const childRunId = readStringValue(body, 'child_run_id', 'childRunId');
|
|
615
|
+
if (!token || !parentRunId || !childRunId) {
|
|
616
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
617
|
+
res.end(JSON.stringify({ error: 'missing_delegation_fields' }));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const record = context.delegationTokens.register(token, parentRunId, childRunId);
|
|
621
|
+
await context.persist.delegationTokens();
|
|
622
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
623
|
+
res.end(JSON.stringify({ status: 'registered', token_id: record.token_id }));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (url.pathname === '/questions' && req.method === 'GET') {
|
|
627
|
+
await expireQuestions(context);
|
|
628
|
+
const questions = context.questionQueue.list();
|
|
629
|
+
queueQuestionResolutions(context, questions);
|
|
630
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
631
|
+
res.end(JSON.stringify({ questions }));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (url.pathname === '/questions/enqueue' && req.method === 'POST') {
|
|
635
|
+
const delegationAuth = readDelegationHeaders(req);
|
|
636
|
+
if (!delegationAuth || !validateDelegation(context, delegationAuth)) {
|
|
637
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
638
|
+
res.end(JSON.stringify({ error: 'delegation_token_invalid' }));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const body = await readJsonBody(req);
|
|
642
|
+
const prompt = readStringValue(body, 'prompt');
|
|
643
|
+
if (!prompt) {
|
|
644
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
645
|
+
res.end(JSON.stringify({ error: 'missing_prompt' }));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const autoPause = readBooleanValue(body, 'auto_pause', 'autoPause') ?? true;
|
|
649
|
+
const expiryFallback = parseExpiryFallback(readStringValue(body, 'expiry_fallback', 'expiryFallback'));
|
|
650
|
+
const parentRunId = context.controlStore.snapshot().run_id;
|
|
651
|
+
const rawFromManifest = readStringValue(body, 'from_manifest_path', 'fromManifestPath');
|
|
652
|
+
let resolvedFromManifest = null;
|
|
653
|
+
if (rawFromManifest) {
|
|
654
|
+
try {
|
|
655
|
+
resolvedFromManifest = resolveRunManifestPath(rawFromManifest, context.config.ui.allowedRunRoots, 'from_manifest_path');
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
659
|
+
res.end(JSON.stringify({ error: 'invalid_manifest_path' }));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const manifest = await readJsonFile(resolvedFromManifest);
|
|
663
|
+
if (!manifest || manifest.run_id !== delegationAuth.childRunId) {
|
|
664
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
665
|
+
res.end(JSON.stringify({ error: 'delegation_run_mismatch' }));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const record = context.questionQueue.enqueue({
|
|
670
|
+
parentRunId,
|
|
671
|
+
fromRunId: delegationAuth.childRunId,
|
|
672
|
+
fromManifestPath: resolvedFromManifest ?? null,
|
|
673
|
+
prompt,
|
|
674
|
+
urgency: parseUrgency(readStringValue(body, 'urgency')),
|
|
675
|
+
expiresInMs: readNumberValue(body, 'expires_in_ms', 'expiresInMs'),
|
|
676
|
+
autoPause,
|
|
677
|
+
expiryFallback
|
|
678
|
+
});
|
|
679
|
+
await context.persist.questions();
|
|
680
|
+
await emitControlEvent(context, {
|
|
681
|
+
event: 'question_queued',
|
|
682
|
+
actor: 'delegate',
|
|
683
|
+
payload: {
|
|
684
|
+
question_id: record.question_id,
|
|
685
|
+
parent_run_id: record.parent_run_id,
|
|
686
|
+
from_run_id: record.from_run_id,
|
|
687
|
+
prompt: record.prompt,
|
|
688
|
+
urgency: record.urgency,
|
|
689
|
+
queued_at: record.queued_at,
|
|
690
|
+
expires_at: record.expires_at ?? null,
|
|
691
|
+
expires_in_ms: record.expires_in_ms ?? null
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
695
|
+
res.end(JSON.stringify(record));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (url.pathname === '/questions/answer' && req.method === 'POST') {
|
|
699
|
+
const body = await readJsonBody(req);
|
|
700
|
+
const questionId = readStringValue(body, 'question_id', 'questionId');
|
|
701
|
+
const answer = readStringValue(body, 'answer');
|
|
702
|
+
if (!questionId || !answer) {
|
|
703
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
704
|
+
res.end(JSON.stringify({ error: 'missing_question_or_answer' }));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
context.questionQueue.answer(questionId, answer, readStringValue(body, 'answered_by', 'answeredBy') ?? 'user');
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
const message = error?.message ?? 'question_invalid';
|
|
712
|
+
const status = message === 'question_not_found' ? 404 : 409;
|
|
713
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
714
|
+
res.end(JSON.stringify({ error: message }));
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
await context.persist.questions();
|
|
718
|
+
const record = context.questionQueue.get(questionId);
|
|
719
|
+
if (record) {
|
|
720
|
+
await emitControlEvent(context, {
|
|
721
|
+
event: 'question_answered',
|
|
722
|
+
actor: 'user',
|
|
723
|
+
payload: {
|
|
724
|
+
question_id: record.question_id,
|
|
725
|
+
parent_run_id: record.parent_run_id,
|
|
726
|
+
answer: record.answer,
|
|
727
|
+
answered_by: record.answered_by,
|
|
728
|
+
answered_at: record.answered_at
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
await emitControlEvent(context, {
|
|
732
|
+
event: 'question_closed',
|
|
733
|
+
actor: 'runner',
|
|
734
|
+
payload: {
|
|
735
|
+
question_id: record.question_id,
|
|
736
|
+
parent_run_id: record.parent_run_id,
|
|
737
|
+
outcome: record.status,
|
|
738
|
+
closed_at: record.closed_at,
|
|
739
|
+
expires_at: record.expires_at ?? null
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
await maybeResolveChildQuestion(context, record, record.status);
|
|
743
|
+
}
|
|
744
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
745
|
+
res.end(JSON.stringify({ status: 'answered' }));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (url.pathname === '/questions/dismiss' && req.method === 'POST') {
|
|
749
|
+
const body = await readJsonBody(req);
|
|
750
|
+
const questionId = readStringValue(body, 'question_id', 'questionId');
|
|
751
|
+
if (!questionId) {
|
|
752
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
753
|
+
res.end(JSON.stringify({ error: 'missing_question_id' }));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
context.questionQueue.dismiss(questionId, readStringValue(body, 'dismissed_by', 'dismissedBy') ?? 'user');
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
const message = error?.message ?? 'question_invalid';
|
|
761
|
+
const status = message === 'question_not_found' ? 404 : 409;
|
|
762
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
763
|
+
res.end(JSON.stringify({ error: message }));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
await context.persist.questions();
|
|
767
|
+
const record = context.questionQueue.get(questionId);
|
|
768
|
+
if (record) {
|
|
769
|
+
await emitControlEvent(context, {
|
|
770
|
+
event: 'question_closed',
|
|
771
|
+
actor: 'user',
|
|
772
|
+
payload: {
|
|
773
|
+
question_id: record.question_id,
|
|
774
|
+
parent_run_id: record.parent_run_id,
|
|
775
|
+
outcome: record.status,
|
|
776
|
+
closed_at: record.closed_at,
|
|
777
|
+
expires_at: record.expires_at ?? null
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
await maybeResolveChildQuestion(context, record, record.status);
|
|
781
|
+
}
|
|
782
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
783
|
+
res.end(JSON.stringify({ status: 'dismissed' }));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (url.pathname.startsWith('/questions/') && req.method === 'GET') {
|
|
787
|
+
await expireQuestions(context);
|
|
788
|
+
const delegationAuth = readDelegationHeaders(req);
|
|
789
|
+
if (delegationAuth && !validateDelegation(context, delegationAuth)) {
|
|
790
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
791
|
+
res.end(JSON.stringify({ error: 'delegation_token_invalid' }));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const questionId = url.pathname.split('/').pop();
|
|
795
|
+
const record = questionId ? context.questionQueue.get(questionId) : null;
|
|
796
|
+
if (!record) {
|
|
797
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
798
|
+
res.end(JSON.stringify({ error: 'not_found' }));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (delegationAuth && record.from_run_id !== delegationAuth.childRunId) {
|
|
802
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
803
|
+
res.end(JSON.stringify({ error: 'delegation_scope_mismatch' }));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
807
|
+
res.end(JSON.stringify(record));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
811
|
+
res.end(JSON.stringify({ error: 'not_found' }));
|
|
812
|
+
}
|
|
813
|
+
function isControlAction(action) {
|
|
814
|
+
return action === 'pause' || action === 'resume' || action === 'cancel' || action === 'fail';
|
|
815
|
+
}
|
|
816
|
+
async function expireConfirmations(context) {
|
|
817
|
+
const expired = context.confirmationStore.expire();
|
|
818
|
+
if (expired.length === 0) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
await context.persist.confirmations();
|
|
822
|
+
for (const entry of expired) {
|
|
823
|
+
await emitControlEvent(context, {
|
|
824
|
+
event: 'confirmation_resolved',
|
|
825
|
+
actor: 'runner',
|
|
826
|
+
payload: {
|
|
827
|
+
request_id: entry.request.request_id,
|
|
828
|
+
nonce_id: entry.nonce_id,
|
|
829
|
+
outcome: 'expired'
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
async function expireQuestions(context) {
|
|
835
|
+
const expired = context.questionQueue.expire();
|
|
836
|
+
if (expired.length === 0) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
await context.persist.questions();
|
|
840
|
+
for (const record of expired) {
|
|
841
|
+
await emitControlEvent(context, {
|
|
842
|
+
event: 'question_closed',
|
|
843
|
+
actor: 'runner',
|
|
844
|
+
payload: {
|
|
845
|
+
question_id: record.question_id,
|
|
846
|
+
parent_run_id: record.parent_run_id,
|
|
847
|
+
outcome: 'expired',
|
|
848
|
+
closed_at: record.closed_at ?? null,
|
|
849
|
+
expires_at: record.expires_at ?? null
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
await maybeResolveChildQuestion(context, record, 'expired');
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function resolveAuthToken(req, context) {
|
|
856
|
+
const header = req.headers.authorization;
|
|
857
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
const token = header.slice('Bearer '.length);
|
|
861
|
+
if (safeTokenCompare(token, context.token)) {
|
|
862
|
+
return { token, kind: 'control' };
|
|
863
|
+
}
|
|
864
|
+
if (context.sessionTokens.validate(token)) {
|
|
865
|
+
return { token, kind: 'session' };
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
function isCsrfValid(req, token) {
|
|
870
|
+
const header = req.headers[CSRF_HEADER];
|
|
871
|
+
if (!header) {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
return safeTokenCompare(header, token);
|
|
875
|
+
}
|
|
876
|
+
function requiresCsrf(req) {
|
|
877
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
878
|
+
return method !== 'GET' && method !== 'HEAD';
|
|
879
|
+
}
|
|
880
|
+
function isRunnerOnlyEndpoint(pathname, method) {
|
|
881
|
+
if ((method ?? 'GET').toUpperCase() !== 'POST') {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
const normalized = pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname;
|
|
885
|
+
return (normalized === '/confirmations/issue' ||
|
|
886
|
+
normalized === '/confirmations/consume' ||
|
|
887
|
+
normalized === '/confirmations/validate' ||
|
|
888
|
+
normalized === '/delegation/register' ||
|
|
889
|
+
normalized === '/questions/enqueue' ||
|
|
890
|
+
normalized === '/security/violation');
|
|
891
|
+
}
|
|
892
|
+
export function formatHostForUrl(host) {
|
|
893
|
+
if (host.includes(':') && !host.startsWith('[')) {
|
|
894
|
+
return `[${host}]`;
|
|
895
|
+
}
|
|
896
|
+
return host;
|
|
897
|
+
}
|
|
898
|
+
export function isLoopbackAddress(address) {
|
|
899
|
+
if (!address) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
if (LOOPBACK_HOSTS.has(address)) {
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
if (address.startsWith('::ffff:')) {
|
|
906
|
+
return address.slice(7) === '127.0.0.1';
|
|
907
|
+
}
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
function safeTokenCompare(left, right) {
|
|
911
|
+
if (left.length !== right.length) {
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
return timingSafeEqual(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
|
915
|
+
}
|
|
916
|
+
function sanitizeConfirmations(entries) {
|
|
917
|
+
return entries.map((entry) => {
|
|
918
|
+
const sanitized = { ...entry };
|
|
919
|
+
delete sanitized.params;
|
|
920
|
+
return sanitized;
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
async function readJsonBody(req) {
|
|
924
|
+
const chunks = [];
|
|
925
|
+
let totalBytes = 0;
|
|
926
|
+
for await (const chunk of req) {
|
|
927
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
928
|
+
totalBytes += buf.length;
|
|
929
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
930
|
+
throw new HttpError(413, 'request_body_too_large');
|
|
931
|
+
}
|
|
932
|
+
chunks.push(buf);
|
|
933
|
+
}
|
|
934
|
+
if (chunks.length === 0) {
|
|
935
|
+
return {};
|
|
936
|
+
}
|
|
937
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
938
|
+
if (!raw.trim()) {
|
|
939
|
+
return {};
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
return JSON.parse(raw);
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
throw new HttpError(400, 'invalid_json');
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
async function readJsonFile(path) {
|
|
949
|
+
try {
|
|
950
|
+
const raw = await readFile(path, 'utf8');
|
|
951
|
+
return JSON.parse(raw);
|
|
952
|
+
}
|
|
953
|
+
catch (error) {
|
|
954
|
+
if (error.code === 'ENOENT') {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
logger.warn(`Failed to read JSON file ${path}: ${error?.message ?? error}`);
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function safeJsonParse(text) {
|
|
962
|
+
try {
|
|
963
|
+
return JSON.parse(text);
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
async function emitControlEvent(context, input) {
|
|
970
|
+
if (!context.eventStream) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
let entry;
|
|
974
|
+
try {
|
|
975
|
+
entry = await context.eventStream.append({
|
|
976
|
+
event: input.event,
|
|
977
|
+
actor: input.actor,
|
|
978
|
+
payload: input.payload
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
logger.warn(`Failed to append control event ${input.event}: ${error?.message ?? error}`);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const payload = `data: ${JSON.stringify(entry)}\n\n`;
|
|
986
|
+
for (const client of context.clients) {
|
|
987
|
+
client.write(payload, (error) => {
|
|
988
|
+
if (error) {
|
|
989
|
+
context.clients.delete(client);
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function readStringValue(record, ...keys) {
|
|
995
|
+
for (const key of keys) {
|
|
996
|
+
const value = record[key];
|
|
997
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
998
|
+
return value.trim();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return undefined;
|
|
1002
|
+
}
|
|
1003
|
+
function readNumberValue(record, ...keys) {
|
|
1004
|
+
for (const key of keys) {
|
|
1005
|
+
const value = record[key];
|
|
1006
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
1007
|
+
return value;
|
|
1008
|
+
}
|
|
1009
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
1010
|
+
const parsed = Number(value);
|
|
1011
|
+
if (Number.isFinite(parsed)) {
|
|
1012
|
+
return parsed;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return undefined;
|
|
1017
|
+
}
|
|
1018
|
+
function readBooleanValue(record, ...keys) {
|
|
1019
|
+
for (const key of keys) {
|
|
1020
|
+
const value = record[key];
|
|
1021
|
+
if (typeof value === 'boolean') {
|
|
1022
|
+
return value;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return undefined;
|
|
1026
|
+
}
|
|
1027
|
+
function readRecordValue(record, key) {
|
|
1028
|
+
const value = record[key];
|
|
1029
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1030
|
+
return value;
|
|
1031
|
+
}
|
|
1032
|
+
return undefined;
|
|
1033
|
+
}
|
|
1034
|
+
function parseUrgency(value) {
|
|
1035
|
+
if (value === 'low' || value === 'med' || value === 'high') {
|
|
1036
|
+
return value;
|
|
1037
|
+
}
|
|
1038
|
+
return 'med';
|
|
1039
|
+
}
|
|
1040
|
+
function parseExpiryFallback(value) {
|
|
1041
|
+
if (value === 'pause' || value === 'resume' || value === 'fail') {
|
|
1042
|
+
return value;
|
|
1043
|
+
}
|
|
1044
|
+
return undefined;
|
|
1045
|
+
}
|
|
1046
|
+
function readDelegationHeaders(req) {
|
|
1047
|
+
const token = readHeaderValue(req.headers[DELEGATION_TOKEN_HEADER]);
|
|
1048
|
+
const childRunId = readHeaderValue(req.headers[DELEGATION_RUN_HEADER]);
|
|
1049
|
+
if (!token || !childRunId) {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
return { token, childRunId };
|
|
1053
|
+
}
|
|
1054
|
+
function readHeaderValue(value) {
|
|
1055
|
+
if (Array.isArray(value)) {
|
|
1056
|
+
const values = [];
|
|
1057
|
+
for (const entry of value) {
|
|
1058
|
+
if (typeof entry !== 'string') {
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const parts = entry.split(',');
|
|
1062
|
+
for (const part of parts) {
|
|
1063
|
+
const trimmed = part.trim();
|
|
1064
|
+
if (trimmed) {
|
|
1065
|
+
values.push(trimmed);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return readUniqueHeaderValue(values);
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof value === 'string') {
|
|
1072
|
+
const parts = value.split(',');
|
|
1073
|
+
const values = [];
|
|
1074
|
+
for (const part of parts) {
|
|
1075
|
+
const trimmed = part.trim();
|
|
1076
|
+
if (trimmed) {
|
|
1077
|
+
values.push(trimmed);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return readUniqueHeaderValue(values);
|
|
1081
|
+
}
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
function readUniqueHeaderValue(values) {
|
|
1085
|
+
if (values.length === 0) {
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
const unique = new Set(values);
|
|
1089
|
+
if (unique.size > 1) {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
return values[0];
|
|
1093
|
+
}
|
|
1094
|
+
function validateDelegation(context, auth) {
|
|
1095
|
+
const parentRunId = context.controlStore.snapshot().run_id;
|
|
1096
|
+
return context.delegationTokens.validate(auth.token, parentRunId, auth.childRunId);
|
|
1097
|
+
}
|
|
1098
|
+
async function maybeResolveChildQuestion(context, record, outcome) {
|
|
1099
|
+
const autoPause = record.auto_pause ?? true;
|
|
1100
|
+
if (!autoPause || !record.from_manifest_path) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
let action = null;
|
|
1104
|
+
let reason = 'question_answered';
|
|
1105
|
+
if (outcome === 'expired') {
|
|
1106
|
+
const fallback = record.expiry_fallback ?? context.config.delegate.expiryFallback ?? 'pause';
|
|
1107
|
+
if (fallback === 'pause') {
|
|
1108
|
+
action = 'pause';
|
|
1109
|
+
reason = 'question_expired';
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
action = fallback === 'resume' ? 'resume' : 'fail';
|
|
1113
|
+
reason = 'question_expired';
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
action = 'resume';
|
|
1118
|
+
reason = outcome === 'dismissed' ? 'question_dismissed' : 'question_answered';
|
|
1119
|
+
}
|
|
1120
|
+
if (!action) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const shouldResolve = await isChildAwaitingQuestion(context, record.from_manifest_path);
|
|
1124
|
+
if (!shouldResolve) {
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
await callChildControlEndpoint(context, record.from_manifest_path, {
|
|
1129
|
+
action,
|
|
1130
|
+
requested_by: 'parent',
|
|
1131
|
+
reason
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
logger.warn(`Failed to resolve child question: ${error?.message ?? error}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function queueQuestionResolutions(context, records) {
|
|
1139
|
+
for (const record of records) {
|
|
1140
|
+
if (record.status === 'queued') {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
void maybeResolveChildQuestion(context, record, record.status).catch((error) => {
|
|
1144
|
+
logger.warn(`Failed to resolve child question: ${error?.message ?? error}`);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
async function isChildAwaitingQuestion(context, manifestPath) {
|
|
1149
|
+
try {
|
|
1150
|
+
const resolvedManifest = resolveRunManifestPath(manifestPath, context.config.ui.allowedRunRoots, 'from_manifest_path');
|
|
1151
|
+
const controlPath = resolve(dirname(resolvedManifest), 'control.json');
|
|
1152
|
+
const snapshot = await readJsonFile(controlPath);
|
|
1153
|
+
const latest = snapshot?.latest_action;
|
|
1154
|
+
if (!latest || latest.action !== 'pause') {
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
1157
|
+
return latest.reason === 'awaiting_question_answer';
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
async function callChildControlEndpoint(context, manifestPath, payload) {
|
|
1164
|
+
const { baseUrl, token } = await loadControlEndpoint(manifestPath, context);
|
|
1165
|
+
const url = new URL('/control/action', baseUrl);
|
|
1166
|
+
const controller = new AbortController();
|
|
1167
|
+
const timer = setTimeout(() => controller.abort(), CHILD_CONTROL_TIMEOUT_MS);
|
|
1168
|
+
let res;
|
|
1169
|
+
try {
|
|
1170
|
+
res = await fetch(url.toString(), {
|
|
1171
|
+
method: 'POST',
|
|
1172
|
+
headers: {
|
|
1173
|
+
'Content-Type': 'application/json',
|
|
1174
|
+
Authorization: `Bearer ${token}`,
|
|
1175
|
+
[CSRF_HEADER]: token
|
|
1176
|
+
},
|
|
1177
|
+
body: JSON.stringify(payload),
|
|
1178
|
+
signal: controller.signal
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
catch (error) {
|
|
1182
|
+
if (error?.name === 'AbortError') {
|
|
1183
|
+
throw new Error('child control request timeout');
|
|
1184
|
+
}
|
|
1185
|
+
throw error;
|
|
1186
|
+
}
|
|
1187
|
+
finally {
|
|
1188
|
+
clearTimeout(timer);
|
|
1189
|
+
}
|
|
1190
|
+
if (!res.ok) {
|
|
1191
|
+
const message = await res.text();
|
|
1192
|
+
throw new Error(`child control error: ${res.status} ${message}`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
async function loadControlEndpoint(manifestPath, context) {
|
|
1196
|
+
const resolvedManifest = resolveRunManifestPath(manifestPath, context.config.ui.allowedRunRoots, 'from_manifest_path');
|
|
1197
|
+
const runDir = dirname(resolvedManifest);
|
|
1198
|
+
const endpointPath = resolve(runDir, 'control_endpoint.json');
|
|
1199
|
+
const raw = await readFile(endpointPath, 'utf8');
|
|
1200
|
+
const endpointInfo = JSON.parse(raw);
|
|
1201
|
+
const baseUrl = validateControlBaseUrl(endpointInfo.base_url, context.config.ui.allowedBindHosts);
|
|
1202
|
+
const tokenPath = resolveControlTokenPath(endpointInfo.token_path, runDir);
|
|
1203
|
+
const token = await readControlToken(tokenPath);
|
|
1204
|
+
return { baseUrl, token };
|
|
1205
|
+
}
|
|
1206
|
+
function validateControlBaseUrl(raw, allowedHosts) {
|
|
1207
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
1208
|
+
throw new Error('control base_url missing');
|
|
1209
|
+
}
|
|
1210
|
+
let parsed;
|
|
1211
|
+
try {
|
|
1212
|
+
parsed = new URL(raw);
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
throw new Error('control base_url invalid');
|
|
1216
|
+
}
|
|
1217
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
1218
|
+
throw new Error('control base_url invalid');
|
|
1219
|
+
}
|
|
1220
|
+
if (parsed.username || parsed.password) {
|
|
1221
|
+
throw new Error('control base_url invalid');
|
|
1222
|
+
}
|
|
1223
|
+
const allowed = normalizeAllowedHosts(allowedHosts);
|
|
1224
|
+
if (allowed.size > 0 && !allowed.has(parsed.hostname.toLowerCase())) {
|
|
1225
|
+
throw new Error('control base_url not permitted');
|
|
1226
|
+
}
|
|
1227
|
+
return parsed;
|
|
1228
|
+
}
|
|
1229
|
+
function normalizeAllowedHosts(allowedHosts) {
|
|
1230
|
+
const values = allowedHosts && allowedHosts.length > 0 ? allowedHosts : Array.from(LOOPBACK_HOSTS);
|
|
1231
|
+
return new Set(values.map((entry) => entry.toLowerCase()));
|
|
1232
|
+
}
|
|
1233
|
+
function parseHostHeader(value) {
|
|
1234
|
+
if (!value) {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
const trimmed = value.trim();
|
|
1238
|
+
if (!trimmed) {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
if (trimmed.startsWith('[')) {
|
|
1242
|
+
const end = trimmed.indexOf(']');
|
|
1243
|
+
if (end === -1) {
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
return trimmed.slice(1, end).toLowerCase();
|
|
1247
|
+
}
|
|
1248
|
+
const parts = trimmed.split(':');
|
|
1249
|
+
if (parts.length > 2) {
|
|
1250
|
+
return trimmed.toLowerCase();
|
|
1251
|
+
}
|
|
1252
|
+
const host = parts[0]?.trim();
|
|
1253
|
+
return host ? host.toLowerCase() : null;
|
|
1254
|
+
}
|
|
1255
|
+
function parseOriginHost(value) {
|
|
1256
|
+
if (!value) {
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
const parsed = new URL(value);
|
|
1261
|
+
return parsed.hostname.toLowerCase();
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
function resolveControlTokenPath(tokenPath, runDir) {
|
|
1268
|
+
const fallback = resolve(runDir, 'control_auth.json');
|
|
1269
|
+
const raw = typeof tokenPath === 'string' ? tokenPath.trim() : '';
|
|
1270
|
+
const resolved = raw ? resolve(runDir, raw) : fallback;
|
|
1271
|
+
if (!isPathWithinRoots(resolved, [runDir])) {
|
|
1272
|
+
throw new Error('control auth path invalid');
|
|
1273
|
+
}
|
|
1274
|
+
return resolved;
|
|
1275
|
+
}
|
|
1276
|
+
async function readControlToken(tokenPath) {
|
|
1277
|
+
const tokenRaw = await readFile(tokenPath, 'utf8');
|
|
1278
|
+
const parsedToken = safeJsonParse(tokenRaw);
|
|
1279
|
+
const tokenValue = parsedToken && typeof parsedToken === 'object' && !Array.isArray(parsedToken)
|
|
1280
|
+
? parsedToken.token
|
|
1281
|
+
: null;
|
|
1282
|
+
const token = typeof tokenValue === 'string' && tokenValue.trim().length > 0
|
|
1283
|
+
? tokenValue.trim()
|
|
1284
|
+
: tokenRaw.trim();
|
|
1285
|
+
if (!token) {
|
|
1286
|
+
throw new Error('control auth token missing');
|
|
1287
|
+
}
|
|
1288
|
+
return token;
|
|
1289
|
+
}
|
|
1290
|
+
function resolveRunManifestPath(rawPath, allowedRoots, label) {
|
|
1291
|
+
const resolved = resolve(rawPath);
|
|
1292
|
+
assertRunManifestPath(resolved, label);
|
|
1293
|
+
if (!isPathWithinRoots(resolved, allowedRoots)) {
|
|
1294
|
+
throw new Error(`${label} not permitted`);
|
|
1295
|
+
}
|
|
1296
|
+
return resolved;
|
|
1297
|
+
}
|
|
1298
|
+
function assertRunManifestPath(pathname, label) {
|
|
1299
|
+
if (basename(pathname) !== 'manifest.json') {
|
|
1300
|
+
throw new Error(`${label} invalid`);
|
|
1301
|
+
}
|
|
1302
|
+
const runDir = dirname(pathname);
|
|
1303
|
+
const cliDir = dirname(runDir);
|
|
1304
|
+
if (basename(cliDir) !== 'cli') {
|
|
1305
|
+
throw new Error(`${label} invalid`);
|
|
1306
|
+
}
|
|
1307
|
+
const taskDir = dirname(cliDir);
|
|
1308
|
+
const runsDir = dirname(taskDir);
|
|
1309
|
+
if (basename(runsDir) !== '.runs') {
|
|
1310
|
+
throw new Error(`${label} invalid`);
|
|
1311
|
+
}
|
|
1312
|
+
if (!basename(runDir) || !basename(taskDir)) {
|
|
1313
|
+
throw new Error(`${label} invalid`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
function isPathWithinRoots(pathname, roots) {
|
|
1317
|
+
const resolved = normalizePath(realpathSafe(pathname));
|
|
1318
|
+
return roots.some((root) => {
|
|
1319
|
+
const resolvedRoot = normalizePath(realpathSafe(root));
|
|
1320
|
+
if (resolvedRoot === resolved) {
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
const relativePath = relative(resolvedRoot, resolved);
|
|
1324
|
+
if (!relativePath) {
|
|
1325
|
+
return true;
|
|
1326
|
+
}
|
|
1327
|
+
if (isAbsolute(relativePath)) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
return !relativePath.startsWith(`..${sep}`) && relativePath !== '..';
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function realpathSafe(pathname) {
|
|
1334
|
+
try {
|
|
1335
|
+
return realpathSync(pathname);
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
return resolve(pathname);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
function normalizePath(pathname) {
|
|
1342
|
+
return process.platform === 'win32' ? pathname.toLowerCase() : pathname;
|
|
1343
|
+
}
|
|
1344
|
+
async function buildUiDataset(context) {
|
|
1345
|
+
const manifest = await readJsonFile(context.paths.manifestPath);
|
|
1346
|
+
const generatedAt = isoTimestamp();
|
|
1347
|
+
if (!manifest) {
|
|
1348
|
+
return { generated_at: generatedAt, tasks: [], runs: [], codebase: null, activity: [] };
|
|
1349
|
+
}
|
|
1350
|
+
const bucketInfo = classifyBucket(manifest.status, context.controlStore.snapshot());
|
|
1351
|
+
const approvalsTotal = Array.isArray(manifest.approvals) ? manifest.approvals.length : 0;
|
|
1352
|
+
const repoRoot = resolveRepoRootFromRunDir(context.paths.runDir);
|
|
1353
|
+
const links = {
|
|
1354
|
+
manifest: repoRoot ? relative(repoRoot, context.paths.manifestPath) : context.paths.manifestPath,
|
|
1355
|
+
log: repoRoot ? relative(repoRoot, context.paths.logPath) : context.paths.logPath,
|
|
1356
|
+
metrics: null,
|
|
1357
|
+
state: null
|
|
1358
|
+
};
|
|
1359
|
+
const stages = Array.isArray(manifest.commands)
|
|
1360
|
+
? manifest.commands.map((command) => ({
|
|
1361
|
+
id: command.id,
|
|
1362
|
+
title: command.title || command.id,
|
|
1363
|
+
status: command.status
|
|
1364
|
+
}))
|
|
1365
|
+
: [];
|
|
1366
|
+
const runEntry = {
|
|
1367
|
+
run_id: manifest.run_id,
|
|
1368
|
+
task_id: manifest.task_id,
|
|
1369
|
+
status: manifest.status,
|
|
1370
|
+
started_at: manifest.started_at,
|
|
1371
|
+
updated_at: manifest.updated_at,
|
|
1372
|
+
completed_at: manifest.completed_at,
|
|
1373
|
+
stages,
|
|
1374
|
+
links,
|
|
1375
|
+
approvals_pending: 0,
|
|
1376
|
+
approvals_total: approvalsTotal,
|
|
1377
|
+
heartbeat_stale: false
|
|
1378
|
+
};
|
|
1379
|
+
const taskEntry = {
|
|
1380
|
+
task_id: manifest.task_id,
|
|
1381
|
+
title: manifest.pipeline_title || manifest.task_id,
|
|
1382
|
+
bucket: bucketInfo.bucket,
|
|
1383
|
+
bucket_reason: bucketInfo.reason,
|
|
1384
|
+
status: manifest.status,
|
|
1385
|
+
last_update: manifest.updated_at,
|
|
1386
|
+
latest_run_id: manifest.run_id,
|
|
1387
|
+
approvals_pending: 0,
|
|
1388
|
+
approvals_total: approvalsTotal,
|
|
1389
|
+
summary: manifest.summary ?? ''
|
|
1390
|
+
};
|
|
1391
|
+
return {
|
|
1392
|
+
generated_at: generatedAt,
|
|
1393
|
+
tasks: [taskEntry],
|
|
1394
|
+
runs: [runEntry],
|
|
1395
|
+
codebase: null,
|
|
1396
|
+
activity: []
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
function classifyBucket(status, control) {
|
|
1400
|
+
if (status === 'queued') {
|
|
1401
|
+
return { bucket: 'pending', reason: 'queued' };
|
|
1402
|
+
}
|
|
1403
|
+
if (status === 'in_progress') {
|
|
1404
|
+
const latest = control.latest_action?.action ?? null;
|
|
1405
|
+
if (latest === 'pause') {
|
|
1406
|
+
return { bucket: 'ongoing', reason: 'paused' };
|
|
1407
|
+
}
|
|
1408
|
+
return { bucket: 'active', reason: 'running' };
|
|
1409
|
+
}
|
|
1410
|
+
if (status === 'succeeded' || status === 'failed' || status === 'cancelled') {
|
|
1411
|
+
return { bucket: 'complete', reason: 'terminal' };
|
|
1412
|
+
}
|
|
1413
|
+
return { bucket: 'pending', reason: 'unknown' };
|
|
1414
|
+
}
|
|
1415
|
+
function resolveRepoRootFromRunDir(runDir) {
|
|
1416
|
+
const candidate = resolve(runDir, '..', '..', '..', '..');
|
|
1417
|
+
return candidate || null;
|
|
1418
|
+
}
|
|
1419
|
+
function resolveUiRoot() {
|
|
1420
|
+
const candidates = [
|
|
1421
|
+
resolve(process.cwd(), 'packages', 'orchestrator-status-ui'),
|
|
1422
|
+
resolve(process.cwd(), '..', 'packages', 'orchestrator-status-ui'),
|
|
1423
|
+
resolve(process.cwd(), '..', '..', 'packages', 'orchestrator-status-ui'),
|
|
1424
|
+
resolve(fileURLToPath(new URL('../../../../packages/orchestrator-status-ui', import.meta.url)))
|
|
1425
|
+
];
|
|
1426
|
+
for (const candidate of candidates) {
|
|
1427
|
+
if (existsSync(join(candidate, 'index.html'))) {
|
|
1428
|
+
return candidate;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
function resolveUiAssetPath(pathname) {
|
|
1434
|
+
if (!UI_ROOT) {
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
const asset = UI_ASSET_PATHS[pathname];
|
|
1438
|
+
if (!asset) {
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
return resolve(UI_ROOT, asset);
|
|
1442
|
+
}
|
|
1443
|
+
async function serveUiAsset(assetPath, res) {
|
|
1444
|
+
try {
|
|
1445
|
+
const payload = await readFile(assetPath);
|
|
1446
|
+
res.writeHead(200, {
|
|
1447
|
+
'Content-Type': resolveUiContentType(assetPath),
|
|
1448
|
+
'Cache-Control': 'no-store'
|
|
1449
|
+
});
|
|
1450
|
+
res.end(payload);
|
|
1451
|
+
}
|
|
1452
|
+
catch (error) {
|
|
1453
|
+
logger.warn(`Failed to serve UI asset ${assetPath}: ${error?.message ?? error}`);
|
|
1454
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1455
|
+
res.end('Not found');
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function resolveUiContentType(assetPath) {
|
|
1459
|
+
if (assetPath.endsWith('.html')) {
|
|
1460
|
+
return 'text/html; charset=utf-8';
|
|
1461
|
+
}
|
|
1462
|
+
if (assetPath.endsWith('.css')) {
|
|
1463
|
+
return 'text/css; charset=utf-8';
|
|
1464
|
+
}
|
|
1465
|
+
if (assetPath.endsWith('.js')) {
|
|
1466
|
+
return 'application/javascript; charset=utf-8';
|
|
1467
|
+
}
|
|
1468
|
+
if (assetPath.endsWith('.svg')) {
|
|
1469
|
+
return 'image/svg+xml';
|
|
1470
|
+
}
|
|
1471
|
+
return 'application/octet-stream';
|
|
1472
|
+
}
|
|
1473
|
+
export const __test__ = {
|
|
1474
|
+
readDelegationHeaders,
|
|
1475
|
+
callChildControlEndpoint
|
|
1476
|
+
};
|