@vellumai/credential-executor 0.10.3-dev.202606292252.adea010 → 0.10.3-dev.202606300318.cf23f52

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.202606292252.adea010",
3
+ "version": "0.10.3-dev.202606300318.cf23f52",
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
+ });
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
  /**