@vellumai/credential-executor 0.10.3-dev.202606292229.153e9b2 → 0.10.3-dev.202606292327.80883ab

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.3-dev.202606292229.153e9b2",
3
+ "version": "0.10.3-dev.202606292327.80883ab",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,
@@ -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
+ });
@@ -122,7 +122,7 @@ function buildHandlers(
122
122
  apiKeyRef: ApiKeyRef,
123
123
  assistantIdRef: AssistantIdRef,
124
124
  secureKeyBackend: SecureKeyBackend,
125
- ): { handlers: RpcHandlerRegistry; temporaryGrantStore: TemporaryGrantStore } {
125
+ ): RpcHandlerRegistry {
126
126
  // -- Grant stores ----------------------------------------------------------
127
127
  const persistentGrantStore = new PersistentGrantStore(
128
128
  getCesGrantsDir("managed"),
@@ -411,7 +411,7 @@ function buildHandlers(
411
411
  return { results };
412
412
  }) as (typeof handlers)[string];
413
413
 
414
- return { handlers, temporaryGrantStore };
414
+ return handlers;
415
415
  }
416
416
 
417
417
  // ---------------------------------------------------------------------------
@@ -668,10 +668,12 @@ async function main(): Promise<void> {
668
668
  // `unregister` miss a tool registered in an earlier session and orphan its
669
669
  // bundle.
670
670
  //
671
- // The in-memory temporary-grant store is the exception: `allow_once` /
672
- // `allow_10m` grants are keyed by proposal hash only (not session), so they
673
- // would otherwise leak ephemeral approvals across sessions. It is cleared at
674
- // 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).
675
677
  //
676
678
  // The mutable refs carry the handshake-provided API key and assistant ID;
677
679
  // handlers read them at call time. These don't vary across a daemon's
@@ -680,7 +682,7 @@ async function main(): Promise<void> {
680
682
  // at call time for audit attribution).
681
683
  const apiKeyRef: ApiKeyRef = { current: "" };
682
684
  const assistantIdRef: AssistantIdRef = { current: "" };
683
- const { handlers, temporaryGrantStore } = buildHandlers(
685
+ const handlers = buildHandlers(
684
686
  apiKeyRef,
685
687
  assistantIdRef,
686
688
  secureKeyBackend,
@@ -780,14 +782,16 @@ async function main(): Promise<void> {
780
782
 
781
783
  rpcConnected = false;
782
784
 
783
- // Drop all ephemeral approvals when the session ends. `allow_once` /
784
- // `allow_10m` grants are keyed by proposal hash only, so reusing the
785
- // store across a reconnect would let a pre-disconnect approval be
786
- // consumed by a later session without re-prompting. Clearing here
787
- // restores the prior behavior, where the process exited on stream end
788
- // and these grants never survived.
789
- temporaryGrantStore.clear();
790
-
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
+ //
791
795
  // A signal-driven end means the process is shutting down; exit the loop.
792
796
  // Any other end reason (the assistant disconnected, its stream closed,
793
797
  // or the transport errored) means we keep the sidecar up and await a
package/src/server.ts CHANGED
@@ -678,7 +678,19 @@ export interface ManageSecureCommandToolHandlerDeps {
678
678
  export function createManageSecureCommandToolHandler(
679
679
  deps: ManageSecureCommandToolHandlerDeps,
680
680
  ): RpcMethodHandler<ManageSecureCommandTool, ManageSecureCommandToolResponse> {
681
- 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> => {
682
694
  if (request.action === "unregister") {
683
695
  const removed = deps.unregisterTool(request.toolName);
684
696
  if (!removed) {
@@ -784,6 +796,18 @@ export function createManageSecureCommandToolHandler(
784
796
 
785
797
  return { success: true };
786
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
+ };
787
811
  }
788
812
 
789
813
  /**