@vellumai/credential-executor 0.10.4-staging.1 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.10.4-staging.1",
3
+ "version": "0.10.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1345,11 +1345,14 @@ describe("server — run_authenticated_command handler", () => {
1345
1345
  defaultWorkspaceDir: testWorkspaceDir,
1346
1346
  });
1347
1347
 
1348
- const response = await handler({
1349
- credentialHandle: "local_static:test/api_key",
1350
- command: "",
1351
- purpose: "Test empty command",
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
- credentialHandle: "local_static:test/api_key",
1371
- command: "just-a-plain-command --with-args",
1372
- purpose: "Test plain command",
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
- credentialHandle: "local_static:test/api_key",
1393
- command: `${"a".repeat(64)}/list api /repos --method GET`,
1394
- purpose: "Test command parsing",
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,
@@ -280,7 +280,7 @@ function buildDeps(
280
280
  metadataStore: fixture.metadataStore,
281
281
  oauthConnections: createOAuthLookup(oauthConnections),
282
282
  },
283
- sessionId: { current: "test-session" },
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, type SessionIdRef } from "../server.js";
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
- const sessionIdRef: SessionIdRef = { current: `integ-${Date.now()}` };
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
- sessionIdRef.current = hsSessionId;
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
- expect(sessionIdRef.current).toBe(handshakeSessionId);
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";
@@ -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
- /** Mutable reference to the session ID for audit records. Updated to the handshake session ID once the RPC handshake completes. */
175
- sessionId?: SessionIdRef;
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?.current ?? "unknown",
569
+ sessionId: deps.sessionId ?? "unknown",
571
570
  success: execResult.success,
572
571
  ...(execResult.error ? { errorMessage: execResult.error } : {}),
573
572
  timestamp: new Date().toISOString(),
@@ -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
- /** Mutable reference to the session ID for audit records. Updated to the handshake session ID once the RPC handshake completes. */
85
- sessionId: SessionIdRef;
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.current,
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.current,
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.current,
263
+ sessionId: deps.sessionId ?? "unknown",
265
264
  method: request.method,
266
265
  url: request.url,
267
266
  success: true,
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@ export type {
29
29
  RpcHandlerRegistry,
30
30
  RpcMethodHandler,
31
31
  RunAuthenticatedCommandHandlerOptions,
32
- SessionIdRef,
32
+ SessionContext,
33
33
  } from "./server.js";
34
34
 
35
35
  export {
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
- // Use a mutable ref so audit records capture the handshake session ID
393
- // once it's negotiated (the handshake completes before any RPC call).
394
- const sessionIdRef: SessionIdRef = { current: `ces-local-${Date.now()}` };
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
- onHandshakeComplete: (hsSessionId) => {
409
- sessionIdRef.current = hsSessionId;
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
 
@@ -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
- ): { handlers: RpcHandlerRegistry; temporaryGrantStore: TemporaryGrantStore } {
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 { handlers, temporaryGrantStore };
414
+ return handlers;
419
415
  }
420
416
 
421
417
  // ---------------------------------------------------------------------------
@@ -672,19 +668,21 @@ 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 the exception: `allow_once` /
676
- // `allow_10m` grants are keyed by proposal hash only (not session), so they
677
- // would otherwise leak ephemeral approvals across sessions. It is cleared at
678
- // the end of every session below so a reconnecting assistant must re-prompt.
671
+ // The in-memory temporary-grant store is also process-scoped, and (unlike
672
+ // before) is NOT cleared between sessions: ephemeral approvals are shared
673
+ // across all of a daemon's connections for the process lifetime. Its own
674
+ // semantics keep this safe `allow_once` is consumed on first use,
675
+ // `allow_10m` expires by wall-clock TTL, `allow_conversation` is cleared when
676
+ // its conversation ends (see the serve loop below).
679
677
  //
680
- // The mutable refs carry the handshake-provided session ID, API key, and
681
- // assistant ID; handlers read them at call time, so updating the refs when
682
- // each session's handshake completes is all that's needed per connection.
683
- const sessionIdRef: SessionIdRef = { current: `ces-managed-${Date.now()}` };
678
+ // The mutable refs carry the handshake-provided API key and assistant ID;
679
+ // handlers read them at call time. These don't vary across a daemon's
680
+ // connections, so they stay process-global. The per-connection session ID,
681
+ // by contrast, lives in each CesRpcServer's SessionContext (handlers read it
682
+ // at call time for audit attribution).
684
683
  const apiKeyRef: ApiKeyRef = { current: "" };
685
684
  const assistantIdRef: AssistantIdRef = { current: "" };
686
- const { handlers, temporaryGrantStore } = buildHandlers(
687
- sessionIdRef,
685
+ const handlers = buildHandlers(
688
686
  apiKeyRef,
689
687
  assistantIdRef,
690
688
  secureKeyBackend,
@@ -726,8 +724,7 @@ async function main(): Promise<void> {
726
724
  error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
727
725
  },
728
726
  signal: controller.signal,
729
- onHandshakeComplete: (hsSessionId, hsApiKey, hsAssistantId) => {
730
- sessionIdRef.current = hsSessionId;
727
+ onHandshakeComplete: (_hsSessionId, hsApiKey, hsAssistantId) => {
731
728
  // Overwrite the credential refs on every handshake. The handler
732
729
  // registry persists across reconnects, so a new session that omits
733
730
  // the API key / assistant ID must fail closed (falling back to the
@@ -785,14 +782,16 @@ async function main(): Promise<void> {
785
782
 
786
783
  rpcConnected = false;
787
784
 
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
-
785
+ // Temporary grants are process-shared: they are NOT cleared when a session
786
+ // ends. CES is moving to a model where the assistant daemon's multiple
787
+ // processes each hold their own connection, so an ephemeral approval
788
+ // (`allow_once` / `allow_10m` / `allow_conversation`) granted on one
789
+ // connection must remain usable by the others rather than being scoped to a
790
+ // single session. The store's own semantics keep this safe across a
791
+ // reconnect: `allow_once` is consumed on first use, `allow_10m` is bounded
792
+ // by its wall-clock TTL, and `allow_conversation` is cleared when its
793
+ // conversation ends.
794
+ //
796
795
  // A signal-driven end means the process is shutting down; exit the loop.
797
796
  // Any other end reason (the assistant disconnected, its stream closed,
798
797
  // 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
- * Mutable reference to the current session ID. Allows handlers that are
58
- * registered before the RPC handshake to read the actual handshake session
59
- * ID at call time (after the handshake completes and sets `.current`).
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 SessionIdRef {
62
- current: string;
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
- * request payload and returns the response payload (or throws).
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
- private sessionId: string | null = null;
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
- * in the RPC handler registry.
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
- request as MakeAuthenticatedRequest,
385
- deps,
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
- const result = await executeAuthenticatedCommand(
508
- execRequest,
509
- options.executorDeps,
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
- return async (request) => {
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
  /**