@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
|
@@ -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
|
-
|
|
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
|
/**
|