@vellumai/credential-executor 0.10.3 → 0.10.4-dev.202607010028.1a4efcc
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/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +1 -5
- package/package.json +1 -1
- package/src/__tests__/command-executor.test.ts +24 -15
- package/src/__tests__/command-workspace.test.ts +24 -0
- package/src/__tests__/grant-store.test.ts +104 -0
- package/src/__tests__/http-executor.test.ts +1 -1
- package/src/__tests__/manage-secure-command-tool.test.ts +134 -0
- package/src/__tests__/managed-integration.test.ts +7 -5
- package/src/commands/executor.ts +3 -4
- package/src/grants/temporary-store.ts +71 -8
- package/src/http/executor.ts +5 -6
- package/src/index.ts +1 -1
- package/src/main.ts +5 -12
- package/src/managed-main.ts +26 -30
- package/src/server.ts +70 -24
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* 1. Does NOT import from assistant, gateway, credential-executor, or other
|
|
6
6
|
* service runtime modules.
|
|
7
7
|
* 2. Does NOT import from runtime shared packages that sit above it in the
|
|
8
|
-
* dependency hierarchy (@vellumai/credential-storage, @vellumai/egress-proxy
|
|
9
|
-
* @vellumai/skill-host-contracts).
|
|
8
|
+
* dependency hierarchy (@vellumai/credential-storage, @vellumai/egress-proxy).
|
|
10
9
|
* 3. Does NOT import from x-client packages (@vellumai/assistant-client,
|
|
11
10
|
* @vellumai/ces-client, @vellumai/gateway-client).
|
|
12
11
|
* 4. Remains a pure schema/type package — no runtime dependencies beyond zod.
|
|
@@ -73,8 +72,6 @@ const FORBIDDEN_IMPORT_PATTERNS = [
|
|
|
73
72
|
/require\s*\(\s*["']@vellumai\/credential-storage(?:\/|["'])/,
|
|
74
73
|
/from\s+["']@vellumai\/egress-proxy(?:\/|["'])/,
|
|
75
74
|
/require\s*\(\s*["']@vellumai\/egress-proxy(?:\/|["'])/,
|
|
76
|
-
/from\s+["']@vellumai\/skill-host-contracts(?:\/|["'])/,
|
|
77
|
-
/require\s*\(\s*["']@vellumai\/skill-host-contracts(?:\/|["'])/,
|
|
78
75
|
|
|
79
76
|
// x-client packages (higher layer)
|
|
80
77
|
/from\s+["']@vellumai\/assistant-client(?:\/|["'])/,
|
|
@@ -143,7 +140,6 @@ describe("package boundary", () => {
|
|
|
143
140
|
"@vellumai/assistant",
|
|
144
141
|
"@vellumai/credential-storage",
|
|
145
142
|
"@vellumai/egress-proxy",
|
|
146
|
-
"@vellumai/skill-host-contracts",
|
|
147
143
|
"@vellumai/assistant-client",
|
|
148
144
|
"@vellumai/ces-client",
|
|
149
145
|
"@vellumai/gateway-client",
|
package/package.json
CHANGED
|
@@ -1345,11 +1345,14 @@ describe("server — run_authenticated_command handler", () => {
|
|
|
1345
1345
|
defaultWorkspaceDir: testWorkspaceDir,
|
|
1346
1346
|
});
|
|
1347
1347
|
|
|
1348
|
-
const response = await handler(
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1348
|
+
const response = await handler(
|
|
1349
|
+
{
|
|
1350
|
+
credentialHandle: "local_static:test/api_key",
|
|
1351
|
+
command: "",
|
|
1352
|
+
purpose: "Test empty command",
|
|
1353
|
+
},
|
|
1354
|
+
{ sessionId: "test-session" },
|
|
1355
|
+
);
|
|
1353
1356
|
|
|
1354
1357
|
expect(response.success).toBe(false);
|
|
1355
1358
|
expect(response.error?.code).toBe("INVALID_COMMAND");
|
|
@@ -1366,11 +1369,14 @@ describe("server — run_authenticated_command handler", () => {
|
|
|
1366
1369
|
defaultWorkspaceDir: testWorkspaceDir,
|
|
1367
1370
|
});
|
|
1368
1371
|
|
|
1369
|
-
const response = await handler(
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1372
|
+
const response = await handler(
|
|
1373
|
+
{
|
|
1374
|
+
credentialHandle: "local_static:test/api_key",
|
|
1375
|
+
command: "just-a-plain-command --with-args",
|
|
1376
|
+
purpose: "Test plain command",
|
|
1377
|
+
},
|
|
1378
|
+
{ sessionId: "test-session" },
|
|
1379
|
+
);
|
|
1374
1380
|
|
|
1375
1381
|
expect(response.success).toBe(false);
|
|
1376
1382
|
expect(response.error?.code).toBe("INVALID_COMMAND");
|
|
@@ -1388,11 +1394,14 @@ describe("server — run_authenticated_command handler", () => {
|
|
|
1388
1394
|
});
|
|
1389
1395
|
|
|
1390
1396
|
// This will fail at bundle resolution (fake digest), but the parse succeeds
|
|
1391
|
-
const response = await handler(
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1397
|
+
const response = await handler(
|
|
1398
|
+
{
|
|
1399
|
+
credentialHandle: "local_static:test/api_key",
|
|
1400
|
+
command: `${"a".repeat(64)}/list api /repos --method GET`,
|
|
1401
|
+
purpose: "Test command parsing",
|
|
1402
|
+
},
|
|
1403
|
+
{ sessionId: "test-session" },
|
|
1404
|
+
);
|
|
1396
1405
|
|
|
1397
1406
|
// Should fail at bundle resolution, not at command parsing
|
|
1398
1407
|
expect(response.error?.code).not.toBe("INVALID_COMMAND");
|
|
@@ -112,6 +112,30 @@ describe("stageInputs", () => {
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test("concurrent stagings get isolated scratch directories", () => {
|
|
116
|
+
// run_authenticated_command relies on each invocation getting its own
|
|
117
|
+
// scratch dir so that interleaved commands cannot read or clobber each
|
|
118
|
+
// other's staged inputs/outputs. Same config, two stagings → distinct dirs.
|
|
119
|
+
const config: WorkspaceStageConfig = {
|
|
120
|
+
workspaceDir,
|
|
121
|
+
inputs: [{ workspacePath: "input.txt" }],
|
|
122
|
+
outputs: [],
|
|
123
|
+
secrets: new Set(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const a = stageInputs(config);
|
|
127
|
+
const b = stageInputs(config);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
expect(a.scratchDir).not.toBe(b.scratchDir);
|
|
131
|
+
expect(existsSync(a.scratchDir)).toBe(true);
|
|
132
|
+
expect(existsSync(b.scratchDir)).toBe(true);
|
|
133
|
+
} finally {
|
|
134
|
+
cleanupScratchDir(a.scratchDir);
|
|
135
|
+
cleanupScratchDir(b.scratchDir);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
115
139
|
test("staged inputs are read-only", () => {
|
|
116
140
|
const config: WorkspaceStageConfig = {
|
|
117
141
|
workspaceDir,
|
|
@@ -419,6 +419,110 @@ describe("TemporaryGrantStore", () => {
|
|
|
419
419
|
expect(store.check("allow_once", "hash-1")).toBe(false);
|
|
420
420
|
expect(store.check("allow_once", "hash-2")).toBe(false);
|
|
421
421
|
});
|
|
422
|
+
|
|
423
|
+
test("remains usable immediately (default TTL is not instantaneous)", () => {
|
|
424
|
+
// The default `allow_once` TTL must still allow a prompt retry of the
|
|
425
|
+
// just-approved operation.
|
|
426
|
+
store.add("allow_once", "hash-default-ttl");
|
|
427
|
+
expect(store.check("allow_once", "hash-default-ttl")).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("expires after its TTL even if never consumed (ATL-935)", () => {
|
|
431
|
+
// An unconsumed single-use approval must not live forever.
|
|
432
|
+
store.add("allow_once", "hash-once-expire", { durationMs: 1 });
|
|
433
|
+
|
|
434
|
+
const start = Date.now();
|
|
435
|
+
while (Date.now() - start < 5) {
|
|
436
|
+
// spin
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
expect(store.check("allow_once", "hash-once-expire")).toBe(false);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
// ATL-935: ephemeral approvals are bounded by TTLs, not by connection
|
|
445
|
+
// teardown.
|
|
446
|
+
//
|
|
447
|
+
// In managed mode the store instance — contents included — is deliberately
|
|
448
|
+
// shared across assistant reconnects and, in the multi-process daemon model,
|
|
449
|
+
// across the connections that each talk to CES, so one guardian approval can
|
|
450
|
+
// be used by any connection entitled to it. The security boundary is the
|
|
451
|
+
// per-grant TTL: an approval that is recorded but never consumed must expire
|
|
452
|
+
// on its own rather than survive indefinitely and be replayed by a much later
|
|
453
|
+
// connection. These tests encode both halves of that contract — an approval
|
|
454
|
+
// stays usable across a reconnect (sharing preserved) but does not outlive
|
|
455
|
+
// its TTL (replay window bounded).
|
|
456
|
+
// -------------------------------------------------------------------------
|
|
457
|
+
describe("ATL-935: ephemeral approvals are bounded by TTLs, not teardown", () => {
|
|
458
|
+
test("every grant kind is bounded by a TTL (no unbounded approvals)", () => {
|
|
459
|
+
// The finding was that allow_once and allow_conversation had no time
|
|
460
|
+
// bound at all. Every kind must now expire on its own.
|
|
461
|
+
store.add("allow_once", "exp-once", { durationMs: 1 });
|
|
462
|
+
store.add("allow_10m", "exp-10m", { durationMs: 1 });
|
|
463
|
+
store.add("allow_conversation", "exp-conv", {
|
|
464
|
+
conversationId: "c",
|
|
465
|
+
durationMs: 1,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const start = Date.now();
|
|
469
|
+
while (Date.now() - start < 5) {
|
|
470
|
+
// spin until all three TTLs lapse
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
expect(store.check("allow_once", "exp-once")).toBe(false);
|
|
474
|
+
expect(store.check("allow_10m", "exp-10m")).toBe(false);
|
|
475
|
+
expect(store.check("allow_conversation", "exp-conv", "c")).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("allow_once that is never consumed expires instead of lingering", () => {
|
|
479
|
+
// The headline scenario: an approval recorded but not consumed before a
|
|
480
|
+
// connection drops must not be replayable by a later connection.
|
|
481
|
+
store.add("allow_once", "hash-stale", { durationMs: 1 });
|
|
482
|
+
|
|
483
|
+
const start = Date.now();
|
|
484
|
+
while (Date.now() - start < 5) {
|
|
485
|
+
// spin
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
expect(store.checkAny("hash-stale")).toBeUndefined();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("allow_conversation is bounded by an absolute TTL backstop", () => {
|
|
492
|
+
store.add("allow_conversation", "hash-conv-ttl", {
|
|
493
|
+
conversationId: "conv-1",
|
|
494
|
+
durationMs: 1,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
const start = Date.now();
|
|
498
|
+
while (Date.now() - start < 5) {
|
|
499
|
+
// spin
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
expect(store.checkAny("hash-conv-ttl", "conv-1")).toBeUndefined();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("an unexpired approval is still shared across a reconnect (no teardown)", () => {
|
|
506
|
+
// The multi-process daemon model depends on this: the store is NOT
|
|
507
|
+
// cleared on disconnect, so an approval granted while one connection was
|
|
508
|
+
// live remains usable by a later or sibling connection within its TTL.
|
|
509
|
+
store.add("allow_conversation", "hash-shared", {
|
|
510
|
+
conversationId: "conv-1",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Simulate a reconnect — no clear() happens between connections.
|
|
514
|
+
expect(store.checkAny("hash-shared", "conv-1")).toBe(
|
|
515
|
+
"allow_conversation",
|
|
516
|
+
);
|
|
517
|
+
// Still usable again (conversation grants are not consumed on use).
|
|
518
|
+
expect(store.checkAny("hash-shared", "conv-1")).toBe(
|
|
519
|
+
"allow_conversation",
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// An allow_10m approval likewise survives a reconnect within its window.
|
|
523
|
+
store.add("allow_10m", "hash-shared-10m");
|
|
524
|
+
expect(store.checkAny("hash-shared-10m")).toBe("allow_10m");
|
|
525
|
+
});
|
|
422
526
|
});
|
|
423
527
|
|
|
424
528
|
describe("allow_10m", () => {
|
|
@@ -280,7 +280,7 @@ function buildDeps(
|
|
|
280
280
|
metadataStore: fixture.metadataStore,
|
|
281
281
|
oauthConnections: createOAuthLookup(oauthConnections),
|
|
282
282
|
},
|
|
283
|
-
sessionId:
|
|
283
|
+
sessionId: "test-session",
|
|
284
284
|
logger: silentLogger,
|
|
285
285
|
...overrides,
|
|
286
286
|
auditStore: overrides.auditStore ?? new AuditStore(fixture.tmpDir),
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the manage_secure_command_tool handler's operation serialization.
|
|
3
|
+
*
|
|
4
|
+
* The register path awaits a bundle download mid-handler. Without
|
|
5
|
+
* serialization, a concurrent unregister could run its "still in use?" check
|
|
6
|
+
* and bundle delete during that await — against a registry that doesn't yet
|
|
7
|
+
* reflect the in-flight registration — transiently deleting a bundle another
|
|
8
|
+
* caller is publishing or executing. The handler runs operations one-at-a-time
|
|
9
|
+
* to close that window.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
import type { ManageSecureCommandTool } from "@vellumai/service-contracts/credential-rpc";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createManageSecureCommandToolHandler,
|
|
18
|
+
type ManageSecureCommandToolHandlerDeps,
|
|
19
|
+
} from "../server.js";
|
|
20
|
+
|
|
21
|
+
const CTX = { sessionId: "test-session" };
|
|
22
|
+
|
|
23
|
+
function registerRequest(toolName: string): ManageSecureCommandTool {
|
|
24
|
+
return {
|
|
25
|
+
action: "register",
|
|
26
|
+
toolName,
|
|
27
|
+
bundleId: "bundle-1",
|
|
28
|
+
version: "1.0.0",
|
|
29
|
+
sourceUrl: "https://example.com/bundle.tgz",
|
|
30
|
+
sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef0",
|
|
31
|
+
credentialHandle: "local_static:svc/key",
|
|
32
|
+
description: "a tool",
|
|
33
|
+
// publishBundle is mocked in these tests, so the manifest contents are
|
|
34
|
+
// irrelevant — only its presence matters for the required-field check.
|
|
35
|
+
secureCommandManifest: {} as unknown as ManageSecureCommandTool["secureCommandManifest"],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function unregisterRequest(toolName: string): ManageSecureCommandTool {
|
|
40
|
+
return { action: "unregister", toolName };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("manage_secure_command_tool serialization", () => {
|
|
44
|
+
test("a slow register does not let a concurrent unregister interleave", async () => {
|
|
45
|
+
const events: string[] = [];
|
|
46
|
+
|
|
47
|
+
let releaseDownload!: () => void;
|
|
48
|
+
const downloadGate = new Promise<void>((resolve) => {
|
|
49
|
+
releaseDownload = resolve;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const deps: ManageSecureCommandToolHandlerDeps = {
|
|
53
|
+
downloadBundle: async () => {
|
|
54
|
+
events.push("download:start");
|
|
55
|
+
await downloadGate;
|
|
56
|
+
events.push("download:end");
|
|
57
|
+
return Buffer.from("bundle-bytes");
|
|
58
|
+
},
|
|
59
|
+
publishBundle: () => {
|
|
60
|
+
events.push("publish");
|
|
61
|
+
return { success: true, deduplicated: false, bundlePath: "/tmp/bundle" };
|
|
62
|
+
},
|
|
63
|
+
registerTool: () => {
|
|
64
|
+
events.push("register");
|
|
65
|
+
},
|
|
66
|
+
unregisterTool: (toolName: string) => {
|
|
67
|
+
events.push(`unregister:${toolName}`);
|
|
68
|
+
return true;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handler = createManageSecureCommandToolHandler(deps);
|
|
73
|
+
|
|
74
|
+
// Fire a register (which blocks in downloadBundle) then an unregister.
|
|
75
|
+
const registerPromise = handler(registerRequest("tool-a"), CTX);
|
|
76
|
+
const unregisterPromise = handler(unregisterRequest("tool-b"), CTX);
|
|
77
|
+
|
|
78
|
+
// Let the event loop run: the register has reached its download await, and
|
|
79
|
+
// the unregister must be queued behind it — its delete must not have run.
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
81
|
+
expect(events).toEqual(["download:start"]);
|
|
82
|
+
|
|
83
|
+
// Release the download; both operations complete in order.
|
|
84
|
+
releaseDownload();
|
|
85
|
+
const [registerResult, unregisterResult] = await Promise.all([
|
|
86
|
+
registerPromise,
|
|
87
|
+
unregisterPromise,
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
expect(registerResult.success).toBe(true);
|
|
91
|
+
expect(unregisterResult.success).toBe(true);
|
|
92
|
+
|
|
93
|
+
// The unregister ran only after the register fully completed.
|
|
94
|
+
expect(events).toEqual([
|
|
95
|
+
"download:start",
|
|
96
|
+
"download:end",
|
|
97
|
+
"publish",
|
|
98
|
+
"register",
|
|
99
|
+
"unregister:tool-b",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a rejected operation does not break serialization for later ones", async () => {
|
|
104
|
+
const events: string[] = [];
|
|
105
|
+
|
|
106
|
+
const deps: ManageSecureCommandToolHandlerDeps = {
|
|
107
|
+
downloadBundle: async () => {
|
|
108
|
+
throw new Error("network down");
|
|
109
|
+
},
|
|
110
|
+
publishBundle: () => ({
|
|
111
|
+
success: true,
|
|
112
|
+
deduplicated: false,
|
|
113
|
+
bundlePath: "/tmp/bundle",
|
|
114
|
+
}),
|
|
115
|
+
registerTool: () => {},
|
|
116
|
+
unregisterTool: (toolName: string) => {
|
|
117
|
+
events.push(`unregister:${toolName}`);
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handler = createManageSecureCommandToolHandler(deps);
|
|
123
|
+
|
|
124
|
+
// First op fails inside the handler (download error → structured failure),
|
|
125
|
+
// second op must still run.
|
|
126
|
+
const first = await handler(registerRequest("tool-a"), CTX);
|
|
127
|
+
const second = await handler(unregisterRequest("tool-b"), CTX);
|
|
128
|
+
|
|
129
|
+
expect(first.success).toBe(false);
|
|
130
|
+
expect(first.error?.code).toBe("DOWNLOAD_FAILED");
|
|
131
|
+
expect(second.success).toBe(true);
|
|
132
|
+
expect(events).toEqual(["unregister:tool-b"]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
createListGrantsHandler,
|
|
43
43
|
createListAuditRecordsHandler,
|
|
44
44
|
} from "../grants/rpc-handlers.js";
|
|
45
|
-
import { CesRpcServer, type RpcHandlerRegistry, type ServeEndReason
|
|
45
|
+
import { CesRpcServer, type RpcHandlerRegistry, type ServeEndReason } from "../server.js";
|
|
46
46
|
import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
|
|
47
47
|
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
@@ -412,7 +412,7 @@ describe("managed CES integration (real Unix socket)", () => {
|
|
|
412
412
|
// -- Server gets the connection and wires up RPC ---------------------------
|
|
413
413
|
const conn = await connectionPromise;
|
|
414
414
|
|
|
415
|
-
|
|
415
|
+
let observedSessionId = "";
|
|
416
416
|
const handlers = buildMinimalHandlers(dataDir);
|
|
417
417
|
|
|
418
418
|
serverRpcServer = new CesRpcServer({
|
|
@@ -426,7 +426,7 @@ describe("managed CES integration (real Unix socket)", () => {
|
|
|
426
426
|
},
|
|
427
427
|
signal: controller.signal,
|
|
428
428
|
onHandshakeComplete: (hsSessionId) => {
|
|
429
|
-
|
|
429
|
+
observedSessionId = hsSessionId;
|
|
430
430
|
},
|
|
431
431
|
});
|
|
432
432
|
|
|
@@ -449,8 +449,10 @@ describe("managed CES integration (real Unix socket)", () => {
|
|
|
449
449
|
expect(ack.protocolVersion).toBe(CES_PROTOCOL_VERSION);
|
|
450
450
|
expect(ack.sessionId).toBe(handshakeSessionId);
|
|
451
451
|
|
|
452
|
-
// Verify onHandshakeComplete callback fired
|
|
453
|
-
|
|
452
|
+
// Verify onHandshakeComplete callback fired and the server populated its
|
|
453
|
+
// per-connection SessionContext.
|
|
454
|
+
expect(observedSessionId).toBe(handshakeSessionId);
|
|
455
|
+
expect(serverRpcServer.currentSessionId).toBe(handshakeSessionId);
|
|
454
456
|
|
|
455
457
|
// -- Step 2: RPC dispatch (list_grants) ------------------------------------
|
|
456
458
|
const rpcId = "rpc-1";
|
package/src/commands/executor.ts
CHANGED
|
@@ -79,7 +79,6 @@ import { hashProposal, type AuditRecordSummary, type CommandGrantProposal } from
|
|
|
79
79
|
import type { AuditStore } from "../audit/store.js";
|
|
80
80
|
import type { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
81
81
|
import type { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
82
|
-
import type { SessionIdRef } from "../server.js";
|
|
83
82
|
|
|
84
83
|
// ---------------------------------------------------------------------------
|
|
85
84
|
// Types
|
|
@@ -171,8 +170,8 @@ export interface CommandExecutorDeps {
|
|
|
171
170
|
materializeCredential: MaterializeCredentialFn;
|
|
172
171
|
/** Audit store for persisting token-free audit records. */
|
|
173
172
|
auditStore?: AuditStore;
|
|
174
|
-
/**
|
|
175
|
-
sessionId?:
|
|
173
|
+
/** Session ID for audit records, taken from the calling connection's SessionContext at dispatch time. */
|
|
174
|
+
sessionId?: string;
|
|
176
175
|
/** CES operating mode (for toolstore path resolution). */
|
|
177
176
|
cesMode?: CesMode;
|
|
178
177
|
/** Egress proxy session start hooks (for creating the proxy server). */
|
|
@@ -567,7 +566,7 @@ export async function executeAuthenticatedCommand(
|
|
|
567
566
|
credentialHandle: request.credentialHandle,
|
|
568
567
|
toolName: "command",
|
|
569
568
|
target: `${request.bundleDigest}/${request.profileName}`,
|
|
570
|
-
sessionId: deps.sessionId
|
|
569
|
+
sessionId: deps.sessionId ?? "unknown",
|
|
571
570
|
success: execResult.success,
|
|
572
571
|
...(execResult.error ? { errorMessage: execResult.error } : {}),
|
|
573
572
|
timestamp: new Date().toISOString(),
|
|
@@ -6,11 +6,29 @@
|
|
|
6
6
|
* which is the desired behaviour for ephemeral approvals.
|
|
7
7
|
*
|
|
8
8
|
* Keying:
|
|
9
|
-
* - `allow_once`: Keyed by proposal hash. Consumed (deleted) on first use
|
|
9
|
+
* - `allow_once`: Keyed by proposal hash. Consumed (deleted) on first use, and
|
|
10
|
+
* bounded by a short default TTL so an approval that is never consumed cannot
|
|
11
|
+
* linger and be replayed long after the guardian approved the imminent
|
|
12
|
+
* operation (see ATL-935).
|
|
10
13
|
* - `allow_10m`: Keyed by proposal hash. Checked for expiry on every read;
|
|
11
14
|
* expired entries are lazily purged.
|
|
12
15
|
* - `allow_conversation`: Keyed by proposal hash + conversation ID. Scoped to a
|
|
13
|
-
* single conversation
|
|
16
|
+
* single conversation and bounded by a generous absolute TTL backstop so an
|
|
17
|
+
* approval cannot live for the store's entire (process-long) lifetime and be
|
|
18
|
+
* replayed by a later connection that presents the same conversation ID
|
|
19
|
+
* (see ATL-935).
|
|
20
|
+
*
|
|
21
|
+
* Lifetime note: in managed mode this store instance is process-scoped and
|
|
22
|
+
* deliberately shared across assistant reconnects — and, in the forthcoming
|
|
23
|
+
* multi-process daemon model, across the multiple connections that each talk to
|
|
24
|
+
* CES — so a single guardian approval can be used by any connection entitled to
|
|
25
|
+
* it. Grant lifetime is therefore bounded by per-grant TTLs rather than by
|
|
26
|
+
* connection teardown: every grant kind carries an expiry, so an unconsumed
|
|
27
|
+
* approval expires on its own instead of surviving indefinitely. (A future
|
|
28
|
+
* multi-connection daemon may additionally evict on quiescence — when the count
|
|
29
|
+
* of live CES connections reaches zero — to scope grants to assistant presence;
|
|
30
|
+
* that is connection-lifecycle machinery the multi-connection work should own,
|
|
31
|
+
* and is intentionally not built here.)
|
|
14
32
|
*/
|
|
15
33
|
|
|
16
34
|
// ---------------------------------------------------------------------------
|
|
@@ -28,13 +46,41 @@ export interface TemporaryGrant {
|
|
|
28
46
|
conversationId?: string;
|
|
29
47
|
/** When the grant was created (epoch ms). */
|
|
30
48
|
createdAt: number;
|
|
31
|
-
/** When the grant expires (epoch ms). Set for
|
|
49
|
+
/** When the grant expires (epoch ms). Set for every grant kind: a short
|
|
50
|
+
* default for `allow_once`, the timed window for `allow_10m`, and a generous
|
|
51
|
+
* absolute backstop for `allow_conversation`. */
|
|
32
52
|
expiresAt?: number;
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
/** Default TTL for timed grants (10 minutes). */
|
|
36
56
|
const DEFAULT_TIMED_DURATION_MS = 10 * 60 * 1000;
|
|
37
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Default TTL for single-use (`allow_once`) grants (2 minutes).
|
|
60
|
+
*
|
|
61
|
+
* `allow_once` exists to bridge the gap between a guardian approval and the
|
|
62
|
+
* caller immediately retrying the just-approved operation. Without a TTL, an
|
|
63
|
+
* approval that is never consumed (e.g. the assistant connection drops before
|
|
64
|
+
* the retry) would live for the store's entire lifetime and could later be
|
|
65
|
+
* replayed without a fresh prompt (ATL-935). A short bound keeps the grant
|
|
66
|
+
* usable for a prompt retry while ensuring a stale, unconsumed approval
|
|
67
|
+
* expires on its own.
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_ONCE_DURATION_MS = 2 * 60 * 1000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Absolute TTL backstop for `allow_conversation` grants (12 hours).
|
|
73
|
+
*
|
|
74
|
+
* `allow_conversation` is scoped to a conversation ID and is meant to persist
|
|
75
|
+
* for the life of that conversation, so it is not consumed on use and has no
|
|
76
|
+
* short timeout. But without any bound it would live for the store's entire
|
|
77
|
+
* process-long lifetime and could be replayed by a later connection that
|
|
78
|
+
* presents the same conversation ID long after the original approval (ATL-935).
|
|
79
|
+
* A generous backstop keeps the grant usable across a normal working session
|
|
80
|
+
* while ensuring a long-stale approval eventually requires a fresh prompt.
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_CONVERSATION_DURATION_MS = 12 * 60 * 60 * 1000;
|
|
83
|
+
|
|
38
84
|
// ---------------------------------------------------------------------------
|
|
39
85
|
// Store implementation
|
|
40
86
|
// ---------------------------------------------------------------------------
|
|
@@ -102,8 +148,19 @@ export class TemporaryGrantStore {
|
|
|
102
148
|
if (kind === "allow_10m") {
|
|
103
149
|
grant.expiresAt =
|
|
104
150
|
Date.now() + (options?.durationMs ?? DEFAULT_TIMED_DURATION_MS);
|
|
105
|
-
} else if (kind === "allow_once"
|
|
106
|
-
|
|
151
|
+
} else if (kind === "allow_once") {
|
|
152
|
+
// `allow_once` is always bounded by a TTL — a caller-supplied duration
|
|
153
|
+
// when present, otherwise a short default — so an unconsumed single-use
|
|
154
|
+
// approval cannot be replayed indefinitely (ATL-935).
|
|
155
|
+
grant.expiresAt =
|
|
156
|
+
Date.now() + (options?.durationMs ?? DEFAULT_ONCE_DURATION_MS);
|
|
157
|
+
} else if (kind === "allow_conversation") {
|
|
158
|
+
// `allow_conversation` persists for the conversation and is not consumed
|
|
159
|
+
// on use, but it still carries a generous absolute TTL backstop so a
|
|
160
|
+
// conversation-scoped approval cannot linger for the store's entire
|
|
161
|
+
// process lifetime and be replayed by a later connection (ATL-935).
|
|
162
|
+
grant.expiresAt =
|
|
163
|
+
Date.now() + (options?.durationMs ?? DEFAULT_CONVERSATION_DURATION_MS);
|
|
107
164
|
}
|
|
108
165
|
|
|
109
166
|
this.store.set(key, grant);
|
|
@@ -115,8 +172,9 @@ export class TemporaryGrantStore {
|
|
|
115
172
|
* - `allow_once`: Returns `true` and **consumes** the grant (deletes it).
|
|
116
173
|
* - `allow_10m`: Returns `true` only if the grant has not expired.
|
|
117
174
|
* Expired grants are lazily purged.
|
|
118
|
-
* - `allow_conversation`: Returns `true` only if a grant exists
|
|
119
|
-
* proposal hash scoped to the specified conversation ID.
|
|
175
|
+
* - `allow_conversation`: Returns `true` only if a non-expired grant exists
|
|
176
|
+
* for the given proposal hash scoped to the specified conversation ID.
|
|
177
|
+
* Expired grants (past the absolute TTL backstop) are lazily purged.
|
|
120
178
|
*
|
|
121
179
|
* Returns `false` if no matching grant exists.
|
|
122
180
|
*/
|
|
@@ -149,7 +207,12 @@ export class TemporaryGrantStore {
|
|
|
149
207
|
return true;
|
|
150
208
|
}
|
|
151
209
|
|
|
152
|
-
// allow_conversation —
|
|
210
|
+
// allow_conversation — bounded by an absolute TTL backstop; lazily purge an
|
|
211
|
+
// expired grant and deny, mirroring allow_10m.
|
|
212
|
+
if (grant.expiresAt !== undefined && Date.now() >= grant.expiresAt) {
|
|
213
|
+
this.store.delete(key);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
153
216
|
return true;
|
|
154
217
|
}
|
|
155
218
|
|
package/src/http/executor.ts
CHANGED
|
@@ -43,7 +43,6 @@ import { materializeManagedToken, type ManagedMaterializerOptions } from "../mat
|
|
|
43
43
|
import { resolveLocalSubject, type LocalSubjectResolverDeps } from "../subjects/local.js";
|
|
44
44
|
import { checkCredentialPolicy } from "../subjects/policy.js";
|
|
45
45
|
import { resolveManagedSubject, type ManagedSubjectResolverOptions } from "../subjects/managed.js";
|
|
46
|
-
import type { SessionIdRef } from "../server.js";
|
|
47
46
|
|
|
48
47
|
// ---------------------------------------------------------------------------
|
|
49
48
|
// Auth injection constants
|
|
@@ -81,8 +80,8 @@ export interface HttpExecutorDeps {
|
|
|
81
80
|
managedMaterializerOptions?: ManagedMaterializerOptions;
|
|
82
81
|
/** Audit store for persisting token-free audit records. */
|
|
83
82
|
auditStore: AuditStore;
|
|
84
|
-
/**
|
|
85
|
-
sessionId
|
|
83
|
+
/** Session ID for audit records, injected per call from the calling connection's SessionContext. */
|
|
84
|
+
sessionId?: string;
|
|
86
85
|
/** Optional custom fetch implementation (for testing). */
|
|
87
86
|
fetch?: typeof globalThis.fetch;
|
|
88
87
|
/** Optional logger. */
|
|
@@ -185,7 +184,7 @@ export async function executeAuthenticatedHttpRequest(
|
|
|
185
184
|
const audit = generateHttpAuditSummary({
|
|
186
185
|
credentialHandle: request.credentialHandle,
|
|
187
186
|
grantId,
|
|
188
|
-
sessionId: deps.sessionId
|
|
187
|
+
sessionId: deps.sessionId ?? "unknown",
|
|
189
188
|
method: request.method,
|
|
190
189
|
url: request.url,
|
|
191
190
|
success: false,
|
|
@@ -235,7 +234,7 @@ export async function executeAuthenticatedHttpRequest(
|
|
|
235
234
|
const audit = generateHttpAuditSummary({
|
|
236
235
|
credentialHandle: request.credentialHandle,
|
|
237
236
|
grantId,
|
|
238
|
-
sessionId: deps.sessionId
|
|
237
|
+
sessionId: deps.sessionId ?? "unknown",
|
|
239
238
|
method: request.method,
|
|
240
239
|
url: request.url,
|
|
241
240
|
success: false,
|
|
@@ -261,7 +260,7 @@ export async function executeAuthenticatedHttpRequest(
|
|
|
261
260
|
const audit = generateHttpAuditSummary({
|
|
262
261
|
credentialHandle: request.credentialHandle,
|
|
263
262
|
grantId,
|
|
264
|
-
sessionId: deps.sessionId
|
|
263
|
+
sessionId: deps.sessionId ?? "unknown",
|
|
265
264
|
method: request.method,
|
|
266
265
|
url: request.url,
|
|
267
266
|
success: true,
|
package/src/index.ts
CHANGED
package/src/main.ts
CHANGED
|
@@ -59,7 +59,6 @@ import {
|
|
|
59
59
|
registerCommandExecutionHandler,
|
|
60
60
|
registerManageSecureCommandToolHandler,
|
|
61
61
|
type RpcHandlerRegistry,
|
|
62
|
-
type SessionIdRef,
|
|
63
62
|
} from "./server.js";
|
|
64
63
|
import {
|
|
65
64
|
deleteBundleFromToolstore,
|
|
@@ -124,7 +123,6 @@ function getSecurityDir(): string {
|
|
|
124
123
|
// ---------------------------------------------------------------------------
|
|
125
124
|
|
|
126
125
|
function buildHandlers(
|
|
127
|
-
sessionIdRef: SessionIdRef,
|
|
128
126
|
secureKeyBackend: SecureKeyBackend,
|
|
129
127
|
): RpcHandlerRegistry {
|
|
130
128
|
// -- Grant stores ----------------------------------------------------------
|
|
@@ -174,7 +172,6 @@ function buildHandlers(
|
|
|
174
172
|
oauthConnections,
|
|
175
173
|
},
|
|
176
174
|
auditStore,
|
|
177
|
-
sessionId: sessionIdRef,
|
|
178
175
|
});
|
|
179
176
|
|
|
180
177
|
// Register run_authenticated_command handler
|
|
@@ -216,7 +213,6 @@ function buildHandlers(
|
|
|
216
213
|
};
|
|
217
214
|
},
|
|
218
215
|
auditStore,
|
|
219
|
-
sessionId: sessionIdRef,
|
|
220
216
|
cesMode: "local",
|
|
221
217
|
egressHooks: buildCesEgressHooks(),
|
|
222
218
|
},
|
|
@@ -389,10 +385,9 @@ async function main(): Promise<void> {
|
|
|
389
385
|
log.info("CES local startup: migrations complete");
|
|
390
386
|
|
|
391
387
|
// Build the handler registry with all available RPC implementations.
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
const
|
|
395
|
-
const handlers = buildHandlers(sessionIdRef, secureKeyBackend);
|
|
388
|
+
// The handshake session ID is captured per connection in the server's
|
|
389
|
+
// SessionContext; handlers read it at call time for audit records.
|
|
390
|
+
const handlers = buildHandlers(secureKeyBackend);
|
|
396
391
|
|
|
397
392
|
const rpcLog = getLogger("rpc");
|
|
398
393
|
const server = new CesRpcServer({
|
|
@@ -405,10 +400,8 @@ async function main(): Promise<void> {
|
|
|
405
400
|
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
406
401
|
},
|
|
407
402
|
signal: controller.signal,
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
},
|
|
411
|
-
// Local mode reads API keys from env/store directly — no-op handler.
|
|
403
|
+
// Local mode reads API keys from env/store directly — no-op handler so
|
|
404
|
+
// update_managed_credential is still registered and returns success.
|
|
412
405
|
onApiKeyUpdate: () => {},
|
|
413
406
|
});
|
|
414
407
|
|
package/src/managed-main.ts
CHANGED
|
@@ -57,7 +57,6 @@ import {
|
|
|
57
57
|
registerManageSecureCommandToolHandler,
|
|
58
58
|
type RpcHandlerRegistry,
|
|
59
59
|
type ServeEndReason,
|
|
60
|
-
type SessionIdRef,
|
|
61
60
|
} from "./server.js";
|
|
62
61
|
import {
|
|
63
62
|
deleteBundleFromToolstore,
|
|
@@ -120,11 +119,10 @@ function ensureDataDirs(): void {
|
|
|
120
119
|
// ---------------------------------------------------------------------------
|
|
121
120
|
|
|
122
121
|
function buildHandlers(
|
|
123
|
-
sessionIdRef: SessionIdRef,
|
|
124
122
|
apiKeyRef: ApiKeyRef,
|
|
125
123
|
assistantIdRef: AssistantIdRef,
|
|
126
124
|
secureKeyBackend: SecureKeyBackend,
|
|
127
|
-
):
|
|
125
|
+
): RpcHandlerRegistry {
|
|
128
126
|
// -- Grant stores ----------------------------------------------------------
|
|
129
127
|
const persistentGrantStore = new PersistentGrantStore(
|
|
130
128
|
getCesGrantsDir("managed"),
|
|
@@ -214,7 +212,6 @@ function buildHandlers(
|
|
|
214
212
|
return getManagedMaterializerOptions();
|
|
215
213
|
},
|
|
216
214
|
auditStore,
|
|
217
|
-
sessionId: sessionIdRef,
|
|
218
215
|
};
|
|
219
216
|
|
|
220
217
|
const handlers = buildHandlersWithHttp(httpDeps);
|
|
@@ -283,7 +280,6 @@ function buildHandlers(
|
|
|
283
280
|
}
|
|
284
281
|
},
|
|
285
282
|
auditStore,
|
|
286
|
-
sessionId: sessionIdRef,
|
|
287
283
|
cesMode: "managed",
|
|
288
284
|
egressHooks: buildCesEgressHooks(),
|
|
289
285
|
},
|
|
@@ -415,7 +411,7 @@ function buildHandlers(
|
|
|
415
411
|
return { results };
|
|
416
412
|
}) as (typeof handlers)[string];
|
|
417
413
|
|
|
418
|
-
return
|
|
414
|
+
return handlers;
|
|
419
415
|
}
|
|
420
416
|
|
|
421
417
|
// ---------------------------------------------------------------------------
|
|
@@ -672,23 +668,32 @@ async function main(): Promise<void> {
|
|
|
672
668
|
// `unregister` miss a tool registered in an earlier session and orphan its
|
|
673
669
|
// bundle.
|
|
674
670
|
//
|
|
675
|
-
// The in-memory temporary-grant store is
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
671
|
+
// The in-memory temporary-grant store instance is also process-scoped and is
|
|
672
|
+
// deliberately reused — contents included — across reconnects. Ephemeral
|
|
673
|
+
// approvals (`allow_once` / `allow_10m` / `allow_conversation`) are keyed by
|
|
674
|
+
// proposal hash (plus a caller-supplied conversation ID), not by the
|
|
675
|
+
// connection that produced them, precisely so a single guardian approval can
|
|
676
|
+
// be shared by any connection entitled to use it. That sharing is what the
|
|
677
|
+
// multi-process daemon model needs: several assistant processes will each
|
|
678
|
+
// talk to CES, and an approval granted while one is connected must remain
|
|
679
|
+
// usable by the others. Grant lifetime is therefore bounded by per-grant TTLs
|
|
680
|
+
// (every kind now carries an expiry), not by tearing the store down on
|
|
681
|
+
// disconnect — so an approval that is never consumed expires on its own
|
|
682
|
+
// instead of surviving indefinitely and being replayed by a much later
|
|
683
|
+
// connection without a fresh guardian prompt (ATL-935). A future
|
|
684
|
+
// multi-connection daemon may additionally evict on quiescence (when the
|
|
685
|
+
// count of live CES connections reaches zero) to scope grants to assistant
|
|
686
|
+
// presence; that is connection-lifecycle machinery the multi-connection work
|
|
687
|
+
// should own, and is intentionally not added here.
|
|
679
688
|
//
|
|
680
|
-
// The mutable refs carry the handshake-provided
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
689
|
+
// The mutable refs carry the handshake-provided API key and assistant ID;
|
|
690
|
+
// handlers read them at call time. These don't vary across a daemon's
|
|
691
|
+
// connections, so they stay process-global. The per-connection session ID,
|
|
692
|
+
// by contrast, lives in each CesRpcServer's SessionContext (handlers read it
|
|
693
|
+
// at call time for audit attribution).
|
|
684
694
|
const apiKeyRef: ApiKeyRef = { current: "" };
|
|
685
695
|
const assistantIdRef: AssistantIdRef = { current: "" };
|
|
686
|
-
const
|
|
687
|
-
sessionIdRef,
|
|
688
|
-
apiKeyRef,
|
|
689
|
-
assistantIdRef,
|
|
690
|
-
secureKeyBackend,
|
|
691
|
-
);
|
|
696
|
+
const handlers = buildHandlers(apiKeyRef, assistantIdRef, secureKeyBackend);
|
|
692
697
|
|
|
693
698
|
// Serve loop. CES is a long-lived sidecar that must outlive any single
|
|
694
699
|
// assistant session: the assistant container can crash and be restarted
|
|
@@ -726,8 +731,7 @@ async function main(): Promise<void> {
|
|
|
726
731
|
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
727
732
|
},
|
|
728
733
|
signal: controller.signal,
|
|
729
|
-
onHandshakeComplete: (
|
|
730
|
-
sessionIdRef.current = hsSessionId;
|
|
734
|
+
onHandshakeComplete: (_hsSessionId, hsApiKey, hsAssistantId) => {
|
|
731
735
|
// Overwrite the credential refs on every handshake. The handler
|
|
732
736
|
// registry persists across reconnects, so a new session that omits
|
|
733
737
|
// the API key / assistant ID must fail closed (falling back to the
|
|
@@ -785,14 +789,6 @@ async function main(): Promise<void> {
|
|
|
785
789
|
|
|
786
790
|
rpcConnected = false;
|
|
787
791
|
|
|
788
|
-
// Drop all ephemeral approvals when the session ends. `allow_once` /
|
|
789
|
-
// `allow_10m` grants are keyed by proposal hash only, so reusing the
|
|
790
|
-
// store across a reconnect would let a pre-disconnect approval be
|
|
791
|
-
// consumed by a later session without re-prompting. Clearing here
|
|
792
|
-
// restores the prior behavior, where the process exited on stream end
|
|
793
|
-
// and these grants never survived.
|
|
794
|
-
temporaryGrantStore.clear();
|
|
795
|
-
|
|
796
792
|
// A signal-driven end means the process is shutting down; exit the loop.
|
|
797
793
|
// Any other end reason (the assistant disconnected, its stream closed,
|
|
798
794
|
// or the transport errored) means we keep the sidecar up and await a
|
package/src/server.ts
CHANGED
|
@@ -54,20 +54,33 @@ import {
|
|
|
54
54
|
// ---------------------------------------------------------------------------
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* Per-connection session context.
|
|
58
|
+
*
|
|
59
|
+
* Each accepted connection owns one `SessionContext`, created when the server
|
|
60
|
+
* is constructed and populated with the negotiated session ID at handshake.
|
|
61
|
+
* Handlers receive it as their second argument and read the session ID at call
|
|
62
|
+
* time — so a handler registry shared across connections attributes each call
|
|
63
|
+
* (e.g. audit records) to the originating connection.
|
|
64
|
+
*
|
|
65
|
+
* Identity that does not vary across a daemon's connections — the assistant
|
|
66
|
+
* API key and assistant ID — deliberately lives outside this context
|
|
67
|
+
* (process-global, see `managed-lazy-getters.ts`); only the per-connection
|
|
68
|
+
* session ID belongs here.
|
|
60
69
|
*/
|
|
61
|
-
export interface
|
|
62
|
-
|
|
70
|
+
export interface SessionContext {
|
|
71
|
+
/** The RPC session ID negotiated at handshake. */
|
|
72
|
+
sessionId: string;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/**
|
|
66
|
-
* Handler function for a single RPC method. Receives the validated
|
|
67
|
-
*
|
|
76
|
+
* Handler function for a single RPC method. Receives the validated request
|
|
77
|
+
* payload and the originating connection's `SessionContext`, and returns the
|
|
78
|
+
* response payload (or throws). Handlers that don't need the context may omit
|
|
79
|
+
* the second parameter.
|
|
68
80
|
*/
|
|
69
81
|
export type RpcMethodHandler<TReq = unknown, TRes = unknown> = (
|
|
70
82
|
request: TReq,
|
|
83
|
+
ctx: SessionContext,
|
|
71
84
|
) => Promise<TRes> | TRes;
|
|
72
85
|
|
|
73
86
|
/**
|
|
@@ -112,7 +125,12 @@ export class CesRpcServer {
|
|
|
112
125
|
private readonly onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
|
|
113
126
|
|
|
114
127
|
private handshakeComplete = false;
|
|
115
|
-
|
|
128
|
+
/**
|
|
129
|
+
* This connection's session context. The object identity is stable for the
|
|
130
|
+
* life of the server and passed by reference to every handler; `sessionId` is
|
|
131
|
+
* populated when the handshake completes.
|
|
132
|
+
*/
|
|
133
|
+
private readonly sessionContext: SessionContext = { sessionId: "" };
|
|
116
134
|
private buffer = "";
|
|
117
135
|
private closed = false;
|
|
118
136
|
|
|
@@ -187,7 +205,7 @@ export class CesRpcServer {
|
|
|
187
205
|
|
|
188
206
|
/** The session ID established during handshake (null before handshake). */
|
|
189
207
|
get currentSessionId(): string | null {
|
|
190
|
-
return this.sessionId;
|
|
208
|
+
return this.sessionContext.sessionId || null;
|
|
191
209
|
}
|
|
192
210
|
|
|
193
211
|
/** Shut down the server gracefully, destroying transport streams. */
|
|
@@ -270,7 +288,7 @@ export class CesRpcServer {
|
|
|
270
288
|
|
|
271
289
|
if (accepted) {
|
|
272
290
|
this.handshakeComplete = true;
|
|
273
|
-
this.sessionId = req.sessionId;
|
|
291
|
+
this.sessionContext.sessionId = req.sessionId;
|
|
274
292
|
this.logger.log(`[ces-server] Handshake accepted for session ${req.sessionId}`);
|
|
275
293
|
this.onHandshakeComplete?.(req.sessionId, req.assistantApiKey, req.assistantId);
|
|
276
294
|
} else {
|
|
@@ -320,7 +338,7 @@ export class CesRpcServer {
|
|
|
320
338
|
}
|
|
321
339
|
|
|
322
340
|
try {
|
|
323
|
-
const result = await handler(validatedPayload);
|
|
341
|
+
const result = await handler(validatedPayload, this.sessionContext);
|
|
324
342
|
this.sendRpcResponse(envelope, result);
|
|
325
343
|
} catch (err) {
|
|
326
344
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -373,17 +391,19 @@ export class CesRpcServer {
|
|
|
373
391
|
/**
|
|
374
392
|
* Create a handler function for the `make_authenticated_request` RPC method.
|
|
375
393
|
*
|
|
376
|
-
* Binds the executor to the provided dependencies so it can be registered
|
|
377
|
-
*
|
|
394
|
+
* Binds the executor to the provided dependencies so it can be registered in
|
|
395
|
+
* the RPC handler registry. The per-connection session ID is merged in from
|
|
396
|
+
* the SessionContext at call time (for audit attribution); all other deps —
|
|
397
|
+
* including the managed subject/materializer options — are taken as supplied.
|
|
378
398
|
*/
|
|
379
399
|
export function createMakeAuthenticatedRequestHandler(
|
|
380
400
|
deps: HttpExecutorDeps,
|
|
381
401
|
): RpcMethodHandler {
|
|
382
|
-
return async (request: unknown) => {
|
|
383
|
-
return executeAuthenticatedHttpRequest(
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
);
|
|
402
|
+
return async (request: unknown, ctx: SessionContext) => {
|
|
403
|
+
return executeAuthenticatedHttpRequest(request as MakeAuthenticatedRequest, {
|
|
404
|
+
...deps,
|
|
405
|
+
sessionId: ctx.sessionId,
|
|
406
|
+
});
|
|
387
407
|
};
|
|
388
408
|
}
|
|
389
409
|
|
|
@@ -452,7 +472,7 @@ export interface RunAuthenticatedCommandHandlerOptions {
|
|
|
452
472
|
export function createRunAuthenticatedCommandHandler(
|
|
453
473
|
options: RunAuthenticatedCommandHandlerOptions,
|
|
454
474
|
): RpcMethodHandler<RunAuthenticatedCommand, RunAuthenticatedCommandResponse> {
|
|
455
|
-
return async (request) => {
|
|
475
|
+
return async (request, ctx) => {
|
|
456
476
|
// Parse the command string into bundle-digest/profile and argv
|
|
457
477
|
const parseResult = parseCommandString(request.command);
|
|
458
478
|
if (!parseResult.ok) {
|
|
@@ -504,10 +524,12 @@ export function createRunAuthenticatedCommandHandler(
|
|
|
504
524
|
conversationId: request.conversationId,
|
|
505
525
|
};
|
|
506
526
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
527
|
+
// Bind the per-connection session ID (for audit attribution) into the
|
|
528
|
+
// executor deps for this call.
|
|
529
|
+
const result = await executeAuthenticatedCommand(execRequest, {
|
|
530
|
+
...options.executorDeps,
|
|
531
|
+
sessionId: ctx.sessionId,
|
|
532
|
+
});
|
|
511
533
|
|
|
512
534
|
// If the failure was due to a missing grant, return a structured
|
|
513
535
|
// APPROVAL_REQUIRED response with the proposal so the approval
|
|
@@ -656,7 +678,19 @@ export interface ManageSecureCommandToolHandlerDeps {
|
|
|
656
678
|
export function createManageSecureCommandToolHandler(
|
|
657
679
|
deps: ManageSecureCommandToolHandlerDeps,
|
|
658
680
|
): RpcMethodHandler<ManageSecureCommandTool, ManageSecureCommandToolResponse> {
|
|
659
|
-
|
|
681
|
+
// Serialize all manage_secure_command_tool operations. The register path
|
|
682
|
+
// awaits a bundle download mid-handler; during that await a concurrent
|
|
683
|
+
// unregister would run its "still in use?" check + bundle delete against a
|
|
684
|
+
// registry that doesn't yet reflect the in-flight registration — transiently
|
|
685
|
+
// deleting a bundle another caller is publishing or executing. Running these
|
|
686
|
+
// operations one-at-a-time closes that window. The registry and toolstore are
|
|
687
|
+
// process-global, so this single chain serializes tool management across
|
|
688
|
+
// every connection.
|
|
689
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
690
|
+
|
|
691
|
+
const handle = async (
|
|
692
|
+
request: ManageSecureCommandTool,
|
|
693
|
+
): Promise<ManageSecureCommandToolResponse> => {
|
|
660
694
|
if (request.action === "unregister") {
|
|
661
695
|
const removed = deps.unregisterTool(request.toolName);
|
|
662
696
|
if (!removed) {
|
|
@@ -762,6 +796,18 @@ export function createManageSecureCommandToolHandler(
|
|
|
762
796
|
|
|
763
797
|
return { success: true };
|
|
764
798
|
};
|
|
799
|
+
|
|
800
|
+
return (request) => {
|
|
801
|
+
// Chain each operation onto the previous one so they never interleave.
|
|
802
|
+
const result = tail.then(() => handle(request));
|
|
803
|
+
// Keep the chain alive regardless of this op's outcome — a rejection must
|
|
804
|
+
// not break serialization for subsequent operations.
|
|
805
|
+
tail = result.then(
|
|
806
|
+
() => undefined,
|
|
807
|
+
() => undefined,
|
|
808
|
+
);
|
|
809
|
+
return result;
|
|
810
|
+
};
|
|
765
811
|
}
|
|
766
812
|
|
|
767
813
|
/**
|