@animalabs/agent-framework 0.15.0 → 0.16.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/README.md +22 -1
- package/dist/src/agent.d.ts +32 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +55 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts +9 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +156 -0
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/api/types.d.ts +15 -0
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/framework.d.ts +418 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1782 -68
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +16 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +41 -3
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +172 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +444 -2
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +9 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +71 -13
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +89 -13
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +655 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/operator-log.d.ts +78 -0
- package/dist/src/operator-log.d.ts.map +1 -0
- package/dist/src/operator-log.js +102 -0
- package/dist/src/operator-log.js.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +20 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/types/framework.d.ts +24 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +36 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +199 -1
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/api-admin-gate.test.d.ts +2 -0
- package/dist/test/api-admin-gate.test.d.ts.map +1 -0
- package/dist/test/api-admin-gate.test.js +116 -0
- package/dist/test/api-admin-gate.test.js.map +1 -0
- package/dist/test/channel-label-history.test.d.ts +2 -0
- package/dist/test/channel-label-history.test.d.ts.map +1 -0
- package/dist/test/channel-label-history.test.js +424 -0
- package/dist/test/channel-label-history.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +16 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/event-gate.test.js +44 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
- package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
- package/dist/test/helpers/quiesce-crash-child.js +156 -0
- package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
- package/dist/test/history-module-overview.test.d.ts +2 -0
- package/dist/test/history-module-overview.test.d.ts.map +1 -0
- package/dist/test/history-module-overview.test.js +711 -0
- package/dist/test/history-module-overview.test.js.map +1 -0
- package/dist/test/history-module.test.js +146 -0
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/host-quiesce.test.d.ts +2 -0
- package/dist/test/host-quiesce.test.d.ts.map +1 -0
- package/dist/test/host-quiesce.test.js +863 -0
- package/dist/test/host-quiesce.test.js.map +1 -0
- package/dist/test/operator-surgery.test.d.ts +2 -0
- package/dist/test/operator-surgery.test.d.ts.map +1 -0
- package/dist/test/operator-surgery.test.js +398 -0
- package/dist/test/operator-surgery.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.js +5 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +69 -0
- package/src/api/server.ts +159 -2
- package/src/api/types.ts +15 -0
- package/src/framework.ts +2081 -89
- package/src/gate/event-gate.ts +47 -4
- package/src/index.ts +5 -1
- package/src/mcpl/channel-registry.ts +495 -2
- package/src/mcpl/server-connection.ts +73 -13
- package/src/modules/history/index.ts +689 -25
- package/src/operator-log.ts +132 -0
- package/src/recovery/discord-awareness-outbox.ts +19 -0
- package/src/types/framework.ts +27 -0
- package/src/types/trace.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animalabs/agent-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"license": "MIT",
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@animalabs/chronicle": "^0.4.0",
|
|
50
|
-
"@animalabs/context-manager": "^0.
|
|
50
|
+
"@animalabs/context-manager": "^0.10.0",
|
|
51
51
|
"@animalabs/membrane": "^0.5.78",
|
|
52
52
|
"chokidar": "^4.0.3",
|
|
53
53
|
"discord.js": "^14.25.1",
|
package/src/agent.ts
CHANGED
|
@@ -276,6 +276,75 @@ export class Agent {
|
|
|
276
276
|
return { ...this.runtimeSettingsOverrides };
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Read-only mirror of `updateRuntimeSettings`' budget semantics: derive what
|
|
281
|
+
* the next compile would actually plan at if `patch` were applied, WITHOUT
|
|
282
|
+
* mutating anything. This is the settings→config mapping a feasibility
|
|
283
|
+
* preview must share with the live path — previewing `patch.contextBudgetTokens`
|
|
284
|
+
* directly models the wrong compile for a paced descent (a non-immediate
|
|
285
|
+
* decrease never changes the compile budget; it only arms a prepared-window
|
|
286
|
+
* transition — see the else-branch in updateRuntimeSettings).
|
|
287
|
+
*
|
|
288
|
+
* - `path: 'immediate'` — the patch changes the compile budget (increase, or
|
|
289
|
+
* decrease with `immediate: true`); `effectiveBudgetTokens` = patch value.
|
|
290
|
+
* - `path: 'paced'` — non-immediate decrease; `effectiveBudgetTokens` stays
|
|
291
|
+
* at the live budget and `advisoryTargetTokens` carries the descent target.
|
|
292
|
+
* - `path: 'none'` — patch absent or budget untouched.
|
|
293
|
+
*
|
|
294
|
+
* `overrides` uses strategy-config names (`recentWindowTokens`,
|
|
295
|
+
* `kvStableReachTokens`) suitable for `previewContext`. `preparedWindowTokens`
|
|
296
|
+
* is deliberately never mapped: it is strategy instance state, not config,
|
|
297
|
+
* and cannot be simulated by a preview. A `kvStableReachTokens` override is
|
|
298
|
+
* omitted while a prepared window is in flight — the live runtime pace
|
|
299
|
+
* shadows the config value there, so the override would not be observed.
|
|
300
|
+
*/
|
|
301
|
+
planRuntimeSettings(patch?: AgentRuntimeSettingsPatch): {
|
|
302
|
+
effectiveBudgetTokens: number;
|
|
303
|
+
/** The budget compiles currently plan at — the baseline the patch moves
|
|
304
|
+
* from. Lets callers distinguish a lowering from a no-op or an increase
|
|
305
|
+
* (an increase on an already-wedged agent never makes things worse). */
|
|
306
|
+
liveBudgetTokens: number;
|
|
307
|
+
advisoryTargetTokens?: number;
|
|
308
|
+
path: 'immediate' | 'paced' | 'none';
|
|
309
|
+
overrides: Record<string, unknown>;
|
|
310
|
+
} {
|
|
311
|
+
if (patch && Object.keys(patch).length > 0) {
|
|
312
|
+
this.validateRuntimeSettingsPatch(patch);
|
|
313
|
+
}
|
|
314
|
+
const hot = this.getHotContextSettings();
|
|
315
|
+
const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS;
|
|
316
|
+
|
|
317
|
+
const overrides: Record<string, unknown> = {};
|
|
318
|
+
if (patch?.tailTokens !== undefined) {
|
|
319
|
+
overrides.recentWindowTokens = patch.tailTokens;
|
|
320
|
+
}
|
|
321
|
+
if (
|
|
322
|
+
patch?.transitionPaceTokens !== undefined &&
|
|
323
|
+
hot?.preparedWindowTokens === undefined
|
|
324
|
+
) {
|
|
325
|
+
overrides.kvStableReachTokens = patch.transitionPaceTokens;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (patch?.contextBudgetTokens === undefined) {
|
|
329
|
+
return { effectiveBudgetTokens: live, liveBudgetTokens: live, path: 'none', overrides };
|
|
330
|
+
}
|
|
331
|
+
if (patch.contextBudgetTokens >= live || patch.immediate) {
|
|
332
|
+
return {
|
|
333
|
+
effectiveBudgetTokens: patch.contextBudgetTokens,
|
|
334
|
+
liveBudgetTokens: live,
|
|
335
|
+
path: 'immediate',
|
|
336
|
+
overrides,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
effectiveBudgetTokens: live,
|
|
341
|
+
liveBudgetTokens: live,
|
|
342
|
+
advisoryTargetTokens: patch.contextBudgetTokens,
|
|
343
|
+
path: 'paced',
|
|
344
|
+
overrides,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
279
348
|
getEffectiveSameRoundThinkTextPolicy(): SameRoundThinkTextPolicy {
|
|
280
349
|
return this.runtimeSettingsOverrides.sameRoundThinkTextPolicy
|
|
281
350
|
?? this.configuredSameRoundThinkTextPolicy
|
package/src/api/server.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
10
10
|
import { createServer, IncomingMessage, ServerResponse } from 'node:http';
|
|
11
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
11
12
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
12
13
|
import type { AgentFramework } from '../framework.js';
|
|
14
|
+
import { ResumeBlockedError } from '../framework.js';
|
|
13
15
|
import type { TraceEvent, ProcessEvent } from '../types/index.js';
|
|
14
16
|
import type {
|
|
15
17
|
ApiServerConfig,
|
|
@@ -46,7 +48,9 @@ import type {
|
|
|
46
48
|
|
|
47
49
|
export * from './types.js';
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
type ResolvedApiServerConfig = Required<Omit<ApiServerConfig, 'adminToken' | 'allowedOrigins'>> & Pick<ApiServerConfig, 'adminToken' | 'allowedOrigins'>;
|
|
52
|
+
|
|
53
|
+
const DEFAULT_CONFIG: ResolvedApiServerConfig = {
|
|
50
54
|
port: 8765,
|
|
51
55
|
host: 'localhost',
|
|
52
56
|
path: '/ws',
|
|
@@ -56,11 +60,19 @@ const DEFAULT_CONFIG: Required<ApiServerConfig> = {
|
|
|
56
60
|
/** Polling interval for subscriptions (ms) */
|
|
57
61
|
const SUBSCRIPTION_POLL_INTERVAL = 50;
|
|
58
62
|
|
|
63
|
+
/** Constant-time token comparison (length leak is acceptable; content isn't). */
|
|
64
|
+
function timingSafeTokenEqual(presented: string, expected: string): boolean {
|
|
65
|
+
const a = Buffer.from(presented);
|
|
66
|
+
const b = Buffer.from(expected);
|
|
67
|
+
if (a.length !== b.length) return false;
|
|
68
|
+
return timingSafeEqual(a, b);
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
/**
|
|
60
72
|
* API Server for the agent framework.
|
|
61
73
|
*/
|
|
62
74
|
export class ApiServer {
|
|
63
|
-
private config:
|
|
75
|
+
private config: ResolvedApiServerConfig;
|
|
64
76
|
private framework: AgentFramework;
|
|
65
77
|
private wss: WebSocketServer | null = null;
|
|
66
78
|
private httpServer: ReturnType<typeof createServer> | null = null;
|
|
@@ -74,9 +86,37 @@ export class ApiServer {
|
|
|
74
86
|
|
|
75
87
|
constructor(framework: AgentFramework, config: ApiServerConfig = {}) {
|
|
76
88
|
this.framework = framework;
|
|
89
|
+
// An empty token would look configured while matching an empty header:
|
|
90
|
+
// the gate would pass for anyone. Refuse it at construction.
|
|
91
|
+
if (config.adminToken !== undefined && config.adminToken.length === 0) {
|
|
92
|
+
throw new Error('ApiServerConfig.adminToken must be non-empty when set (omit it to disable the gate)');
|
|
93
|
+
}
|
|
77
94
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
/**
|
|
98
|
+
* WebSocket upgrade origin policy (see ApiServerConfig.allowedOrigins).
|
|
99
|
+
* Non-browser clients send no Origin and pass; a browser Origin must match
|
|
100
|
+
* this server's own host or the configured allow-list.
|
|
101
|
+
*/
|
|
102
|
+
private originAllowed(origin: string | undefined, hostHeader: string | undefined): boolean {
|
|
103
|
+
if (origin === undefined || origin === '' || origin === 'null') return origin !== 'null';
|
|
104
|
+
let originHost: string;
|
|
105
|
+
try {
|
|
106
|
+
originHost = new URL(origin).host;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (hostHeader && originHost.toLowerCase() === hostHeader.toLowerCase()) return true;
|
|
111
|
+
return (this.config.allowedOrigins ?? []).some((allowed) => {
|
|
112
|
+
try {
|
|
113
|
+
return new URL(allowed).host.toLowerCase() === originHost.toLowerCase();
|
|
114
|
+
} catch {
|
|
115
|
+
return allowed.toLowerCase() === originHost.toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
80
120
|
/**
|
|
81
121
|
* Start the API server.
|
|
82
122
|
*/
|
|
@@ -95,6 +135,14 @@ export class ApiServer {
|
|
|
95
135
|
this.wss = new WebSocketServer({
|
|
96
136
|
server: this.httpServer,
|
|
97
137
|
path: this.config.path,
|
|
138
|
+
// Browsers skip CORS for WS handshakes, so the Origin check is the
|
|
139
|
+
// only thing standing between a visited web page and this command
|
|
140
|
+
// surface on a localhost bind (host.quiesce, undo, branch.delete…).
|
|
141
|
+
verifyClient: (info: { origin: string; req: IncomingMessage }, done: (ok: boolean, code?: number, msg?: string) => void) => {
|
|
142
|
+
const ok = this.originAllowed(info.origin, info.req.headers.host);
|
|
143
|
+
if (!ok) console.warn(`[api] rejected WebSocket upgrade from origin ${info.origin}`);
|
|
144
|
+
done(ok, 403, 'origin not allowed');
|
|
145
|
+
},
|
|
98
146
|
});
|
|
99
147
|
|
|
100
148
|
this.wss.on('connection', (ws) => {
|
|
@@ -357,11 +405,37 @@ export class ApiServer {
|
|
|
357
405
|
case 'events.subscribe':
|
|
358
406
|
return this.cmdEventsSubscribe(params as unknown as EventsSubscribeParams);
|
|
359
407
|
|
|
408
|
+
// Host quiesce/maintenance mode (issue #122). When an adminToken is
|
|
409
|
+
// configured, the mutating verbs require it here too — the WS surface
|
|
410
|
+
// must not be a token-free path to the same authority as the HTTP verbs.
|
|
411
|
+
case 'host.quiesce':
|
|
412
|
+
this.requireAdminToken(params);
|
|
413
|
+
return this.framework.quiesce(params as
|
|
414
|
+
{ reason?: string; timeoutMs?: number; abandon?: boolean } | undefined);
|
|
415
|
+
case 'host.resume':
|
|
416
|
+
this.requireAdminToken(params);
|
|
417
|
+
return this.framework.resume(params as { force?: boolean } | undefined);
|
|
418
|
+
case 'host.status':
|
|
419
|
+
return this.framework.getHostModeStatus();
|
|
420
|
+
case 'host.maintenanceTick':
|
|
421
|
+
this.requireAdminToken(params);
|
|
422
|
+
return this.framework.maintenanceTick();
|
|
423
|
+
|
|
360
424
|
default:
|
|
361
425
|
throw new Error(`Unknown command: ${command}`);
|
|
362
426
|
}
|
|
363
427
|
}
|
|
364
428
|
|
|
429
|
+
/** WS-side admin gate for the mutating host verbs: no-op unless an
|
|
430
|
+
* adminToken is configured, then `params.adminToken` must match. */
|
|
431
|
+
private requireAdminToken(params?: Record<string, unknown>): void {
|
|
432
|
+
if (this.config.adminToken === undefined) return;
|
|
433
|
+
const presented = params?.adminToken;
|
|
434
|
+
if (typeof presented !== 'string' || !timingSafeTokenEqual(presented, this.config.adminToken)) {
|
|
435
|
+
throw new Error('adminToken required for host verbs on this server');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
365
439
|
// ==========================================================================
|
|
366
440
|
// Conversation Commands
|
|
367
441
|
// ==========================================================================
|
|
@@ -861,11 +935,94 @@ export class ApiServer {
|
|
|
861
935
|
agents: agents.map((a) => ({ name: a.name, status: a.state.status })),
|
|
862
936
|
clients: this.clients.size,
|
|
863
937
|
queueDepth: this.framework.getQueueDepth(),
|
|
938
|
+
quiesced: this.framework.getHostModeStatus().quiesced,
|
|
864
939
|
})
|
|
865
940
|
);
|
|
866
941
|
return;
|
|
867
942
|
}
|
|
868
943
|
|
|
944
|
+
// Host quiesce/maintenance mode (issue #122). Options ride the query
|
|
945
|
+
// string — this file has no body parser and these verbs don't warrant
|
|
946
|
+
// introducing one. Auth: opt-in shared secret (config.adminToken); the
|
|
947
|
+
// default bind is localhost and the WS surface carries the same authority,
|
|
948
|
+
// so no ambient remoteAddress check is attempted (it breaks behind
|
|
949
|
+
// proxies both ways and adds nothing on a local bind).
|
|
950
|
+
if (url.pathname === '/hostmode') {
|
|
951
|
+
// Carries the operator's free-text reason; when a token is configured,
|
|
952
|
+
// reads require it too (ACAO:* makes this cross-origin readable).
|
|
953
|
+
if (this.config.adminToken !== undefined) {
|
|
954
|
+
const presented = req.headers['x-admin-token'];
|
|
955
|
+
if (typeof presented !== 'string' || !timingSafeTokenEqual(presented, this.config.adminToken)) {
|
|
956
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
957
|
+
res.end(JSON.stringify({ error: 'x-admin-token required' }));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
962
|
+
res.end(JSON.stringify(this.framework.getHostModeStatus()));
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const hostVerb = req.method === 'POST'
|
|
966
|
+
&& ['/quiesce', '/resume', '/maintenance/tick'].includes(url.pathname);
|
|
967
|
+
if (hostVerb) {
|
|
968
|
+
// CSRF guard: the x-admin-token header is REQUIRED even when no token
|
|
969
|
+
// is configured (any value then). A custom header makes the request
|
|
970
|
+
// non-simple, forcing a CORS preflight — and the OPTIONS response only
|
|
971
|
+
// allows Content-Type, so browsers refuse to send it cross-origin.
|
|
972
|
+
// Without this, ACAO:* + query-string options made POST /quiesce a
|
|
973
|
+
// simple request any web page could fire at a localhost bind.
|
|
974
|
+
const presented = req.headers['x-admin-token'];
|
|
975
|
+
const authorized = typeof presented === 'string' && (
|
|
976
|
+
this.config.adminToken === undefined || timingSafeTokenEqual(presented, this.config.adminToken)
|
|
977
|
+
);
|
|
978
|
+
if (!authorized) {
|
|
979
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
980
|
+
res.end(JSON.stringify({
|
|
981
|
+
error: this.config.adminToken !== undefined
|
|
982
|
+
? 'x-admin-token required (configured token mismatch or missing header)'
|
|
983
|
+
: 'x-admin-token header required (any value; forces CORS preflight)',
|
|
984
|
+
}));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const respond = (code: number, body: unknown) => {
|
|
988
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
989
|
+
res.end(JSON.stringify(body));
|
|
990
|
+
};
|
|
991
|
+
void (async () => {
|
|
992
|
+
try {
|
|
993
|
+
if (url.pathname === '/quiesce') {
|
|
994
|
+
const timeoutMs = url.searchParams.get('timeoutMs');
|
|
995
|
+
respond(200, await this.framework.quiesce({
|
|
996
|
+
...(url.searchParams.get('reason')
|
|
997
|
+
? { reason: url.searchParams.get('reason')! } : {}),
|
|
998
|
+
...(timeoutMs ? { timeoutMs: Number(timeoutMs) } : {}),
|
|
999
|
+
...(url.searchParams.get('abandon') === 'true' ? { abandon: true } : {}),
|
|
1000
|
+
}));
|
|
1001
|
+
} else if (url.pathname === '/resume') {
|
|
1002
|
+
respond(200, await this.framework.resume(
|
|
1003
|
+
url.searchParams.get('force') === 'true' ? { force: true } : undefined,
|
|
1004
|
+
));
|
|
1005
|
+
} else {
|
|
1006
|
+
respond(200, await this.framework.maintenanceTick());
|
|
1007
|
+
}
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
if (error instanceof ResumeBlockedError) {
|
|
1010
|
+
respond(409, {
|
|
1011
|
+
error: error.message,
|
|
1012
|
+
verdicts: error.verdicts,
|
|
1013
|
+
hostMode: this.framework.getHostModeStatus(),
|
|
1014
|
+
});
|
|
1015
|
+
} else {
|
|
1016
|
+
respond(500, { error: error instanceof Error ? error.message : String(error) });
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
})().catch(() => {
|
|
1020
|
+
// The client went away mid-drain: respond() threw, and the fallback
|
|
1021
|
+
// respond(500) threw again. Nothing left to tell anyone.
|
|
1022
|
+
});
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
869
1026
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
870
1027
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
871
1028
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -539,4 +539,19 @@ export interface ApiServerConfig {
|
|
|
539
539
|
path?: string;
|
|
540
540
|
/** Enable HTTP endpoints alongside WebSocket (default: true) */
|
|
541
541
|
enableHttp?: boolean;
|
|
542
|
+
/** Opt-in shared secret for the HTTP host verbs (POST /quiesce, /resume,
|
|
543
|
+
* /maintenance/tick). When set, those requests must carry it in the
|
|
544
|
+
* `x-admin-token` header (401 otherwise). Unset preserves the historical
|
|
545
|
+
* bind-address-is-the-security-model behavior — the default bind is
|
|
546
|
+
* localhost; deployments that front the port with a proxy should set this. */
|
|
547
|
+
adminToken?: string;
|
|
548
|
+
/**
|
|
549
|
+
* Browser origins allowed to open the WebSocket. Browsers do not apply
|
|
550
|
+
* CORS to WebSocket handshakes, so without this check any web page the
|
|
551
|
+
* operator visits could drive the WS command surface on a localhost bind.
|
|
552
|
+
* Upgrades with no Origin header (non-browser clients) always pass; a
|
|
553
|
+
* browser Origin passes when it names this server's own host (the
|
|
554
|
+
* request's Host header) or appears here. Default: none beyond same-host.
|
|
555
|
+
*/
|
|
556
|
+
allowedOrigins?: string[];
|
|
542
557
|
}
|