@animalabs/agent-framework 0.14.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 +42 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +94 -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 +423 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1906 -102
- 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 +145 -0
- package/dist/src/modules/history/index.d.ts.map +1 -0
- package/dist/src/modules/history/index.js +1150 -0
- package/dist/src/modules/history/index.js.map +1 -0
- package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
- package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
- package/dist/src/modules/history/search-regex-worker.js +57 -0
- package/dist/src/modules/history/search-regex-worker.js.map +1 -0
- package/dist/src/modules/index.d.ts +1 -0
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -0
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +42 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +5 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.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 +40 -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.d.ts +2 -0
- package/dist/test/history-module.test.d.ts.map +1 -0
- package/dist/test/history-module.test.js +595 -0
- package/dist/test/history-module.test.js.map +1 -0
- 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/kv-unified-open-flight-retry.test.d.ts +2 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +39 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -1
- 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 +118 -1
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
- package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
- package/dist/test/puppet-turn-reservation.test.js +139 -0
- package/dist/test/puppet-turn-reservation.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +29 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.js +114 -0
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.d.ts +2 -0
- package/dist/test/workspace-read-text.test.d.ts.map +1 -0
- package/dist/test/workspace-read-text.test.js +101 -0
- package/dist/test/workspace-read-text.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +110 -0
- package/src/api/server.ts +159 -2
- package/src/api/types.ts +15 -0
- package/src/framework.ts +2196 -115
- 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 +1277 -0
- package/src/modules/history/search-regex-worker.ts +76 -0
- package/src/modules/index.ts +2 -0
- package/src/modules/workspace/index.ts +42 -1
- package/src/modules/workspace/types.ts +5 -0
- 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 +46 -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",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"author": "Anima Research",
|
|
47
47
|
"license": "MIT",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@animalabs/chronicle": "^0.
|
|
50
|
-
"@animalabs/context-manager": "^0.
|
|
49
|
+
"@animalabs/chronicle": "^0.4.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
|
@@ -99,6 +99,11 @@ export class Agent {
|
|
|
99
99
|
private _state: AgentState = { status: 'idle' };
|
|
100
100
|
private _inferenceStartedAt = 0;
|
|
101
101
|
private _streamId = 0;
|
|
102
|
+
/** kv-unified receipt flights opened by the CURRENT activation and not yet
|
|
103
|
+
* settled by a usage event. Held on the agent (not only in the stream's
|
|
104
|
+
* closure) so the next activation can close whatever its predecessor left
|
|
105
|
+
* open — see failOpenKvSubmissions. */
|
|
106
|
+
private kvOpenQueue: Array<{ submissionId: string; wireReceipt: CacheWireReceipt }> | null = null;
|
|
102
107
|
lastStreamInputTokens = 0;
|
|
103
108
|
/** Real prefix size of the last usage event: fresh + cache creation +
|
|
104
109
|
* cache read. THE window-shaped number — `lastStreamInputTokens` alone
|
|
@@ -271,6 +276,75 @@ export class Agent {
|
|
|
271
276
|
return { ...this.runtimeSettingsOverrides };
|
|
272
277
|
}
|
|
273
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
|
+
|
|
274
348
|
getEffectiveSameRoundThinkTextPolicy(): SameRoundThinkTextPolicy {
|
|
275
349
|
return this.runtimeSettingsOverrides.sameRoundThinkTextPolicy
|
|
276
350
|
?? this.configuredSameRoundThinkTextPolicy
|
|
@@ -730,6 +804,19 @@ export class Agent {
|
|
|
730
804
|
throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
|
|
731
805
|
}
|
|
732
806
|
|
|
807
|
+
// A new activation supersedes every receipt flight the previous one left
|
|
808
|
+
// open. Membrane fires the kv-unified wire receipt per provider attempt,
|
|
809
|
+
// BEFORE the adapter call; only that attempt's usage event settles it.
|
|
810
|
+
// When the attempt dies without usage (transport error, idle timeout,
|
|
811
|
+
// framework cancel for a budget restart or endTurn) the flight stays open
|
|
812
|
+
// until the old driveStream's `finally` runs — and every successor path
|
|
813
|
+
// (error-policy retry, budget restart, the next wake after an abort)
|
|
814
|
+
// starts the new stream BEFORE that `finally`. The successor's first
|
|
815
|
+
// receipt then met the ledger's single-flight guard and failed the
|
|
816
|
+
// recovery itself: "kv-unified submission devops:46:…:4 is still in
|
|
817
|
+
// flight" (devops agent, 2026-09-16 07:33Z, no provider error logged).
|
|
818
|
+
this.failOpenKvSubmissions();
|
|
819
|
+
|
|
733
820
|
this._streamId++;
|
|
734
821
|
this._inferenceStartedAt = Date.now();
|
|
735
822
|
this.lastStreamInputTokens = 0;
|
|
@@ -753,6 +840,7 @@ export class Agent {
|
|
|
753
840
|
const kvQueue: Array<{ submissionId: string; wireReceipt: CacheWireReceipt }> = [];
|
|
754
841
|
let kvCall = 0;
|
|
755
842
|
if (kvEnabled) {
|
|
843
|
+
this.kvOpenQueue = kvQueue;
|
|
756
844
|
const layoutHash = stableHash(request.messages);
|
|
757
845
|
const kvRequest = request as NormalizedRequest & KvUnifiedRequestHooks;
|
|
758
846
|
kvRequest.onCacheWireReceipt = (receipt) => {
|
|
@@ -791,6 +879,28 @@ export class Agent {
|
|
|
791
879
|
};
|
|
792
880
|
}
|
|
793
881
|
|
|
882
|
+
/** Close every kv-unified receipt flight the previous activation left
|
|
883
|
+
* unsettled. Idempotent: the framework's driveStream `finally` drains the
|
|
884
|
+
* same per-stream queue, so whichever runs first empties it and the other
|
|
885
|
+
* finds nothing. Failing an already-settled id is a no-op in the ledger. */
|
|
886
|
+
private failOpenKvSubmissions(): void {
|
|
887
|
+
const open = this.kvOpenQueue?.splice(0) ?? [];
|
|
888
|
+
this.kvOpenQueue = null;
|
|
889
|
+
if (open.length === 0) return;
|
|
890
|
+
const strategy = (this.contextManager as unknown as { getStrategy?: () => unknown })
|
|
891
|
+
.getStrategy?.() as { reportKvUnifiedFailed?: (submissionId: string) => void } | undefined;
|
|
892
|
+
for (const { submissionId } of open) {
|
|
893
|
+
console.error(
|
|
894
|
+
`[kv-unified] ${this.name}: closing receipt flight ${submissionId} left open by the previous activation`,
|
|
895
|
+
);
|
|
896
|
+
try {
|
|
897
|
+
strategy?.reportKvUnifiedFailed?.(submissionId);
|
|
898
|
+
} catch (err) {
|
|
899
|
+
console.error(`[kv-unified] ${this.name}: could not close flight ${submissionId}:`, err);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
794
904
|
/**
|
|
795
905
|
* Transition to waiting_for_tools when stream yields tool calls.
|
|
796
906
|
* Called by framework's driveStream.
|
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
|
}
|