@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
|
@@ -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/managed-main.ts
CHANGED
|
@@ -122,7 +122,7 @@ function buildHandlers(
|
|
|
122
122
|
apiKeyRef: ApiKeyRef,
|
|
123
123
|
assistantIdRef: AssistantIdRef,
|
|
124
124
|
secureKeyBackend: SecureKeyBackend,
|
|
125
|
-
):
|
|
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
|
|
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
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
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
|
|
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
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
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
|
-
|
|
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
|
/**
|