@vellumai/credential-executor 0.8.4 → 0.8.6
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/bun.lock +279 -0
- package/eslint.config.mjs +23 -0
- package/knip.json +9 -0
- package/package.json +7 -2
- package/src/__tests__/ces-migrations-runner.test.ts +3 -3
- package/src/__tests__/command-executor.test.ts +5 -12
- package/src/__tests__/command-workspace.test.ts +0 -1
- package/src/__tests__/http-executor.test.ts +2 -7
- package/src/__tests__/managed-integration.test.ts +9 -10
- package/src/__tests__/managed-lazy-getters.test.ts +65 -0
- package/src/__tests__/managed-materializers.test.ts +1 -3
- package/src/__tests__/managed-reconnect.test.ts +244 -0
- package/src/__tests__/toolstore.test.ts +1 -1
- package/src/__tests__/transport.test.ts +8 -8
- package/src/managed-lazy-getters.ts +28 -4
- package/src/managed-main.ts +315 -133
- package/src/server.ts +11 -6
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
21
|
-
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { mkdirSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
|
|
22
22
|
import { tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
import { createConnection, type Socket } from "node:net";
|
|
24
|
+
import { createConnection, createServer as createNetServer, type Socket } from "node:net";
|
|
25
25
|
import { Readable, Writable } from "node:stream";
|
|
26
26
|
|
|
27
27
|
import {
|
|
@@ -37,13 +37,12 @@ import {
|
|
|
37
37
|
} from "@vellumai/service-contracts/credential-rpc";
|
|
38
38
|
|
|
39
39
|
import { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
40
|
-
import { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
41
40
|
import { AuditStore } from "../audit/store.js";
|
|
42
41
|
import {
|
|
43
42
|
createListGrantsHandler,
|
|
44
43
|
createListAuditRecordsHandler,
|
|
45
44
|
} from "../grants/rpc-handlers.js";
|
|
46
|
-
import { CesRpcServer, type RpcHandlerRegistry, type SessionIdRef } from "../server.js";
|
|
45
|
+
import { CesRpcServer, type RpcHandlerRegistry, type ServeEndReason, type SessionIdRef } from "../server.js";
|
|
47
46
|
import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
|
|
48
47
|
|
|
49
48
|
// ---------------------------------------------------------------------------
|
|
@@ -159,12 +158,11 @@ function acceptOneConnection(socketPath: string, signal: AbortSignal): Promise<{
|
|
|
159
158
|
socket: Socket;
|
|
160
159
|
}> {
|
|
161
160
|
return new Promise((resolve, reject) => {
|
|
162
|
-
const { createServer: createNetServer } = require("node:net");
|
|
163
161
|
const netServer = createNetServer();
|
|
164
162
|
|
|
165
163
|
const cleanup = () => {
|
|
166
164
|
netServer.close();
|
|
167
|
-
try {
|
|
165
|
+
try { unlinkSync(socketPath); } catch { /* ok */ }
|
|
168
166
|
};
|
|
169
167
|
|
|
170
168
|
if (signal.aborted) {
|
|
@@ -188,7 +186,7 @@ function acceptOneConnection(socketPath: string, signal: AbortSignal): Promise<{
|
|
|
188
186
|
|
|
189
187
|
netServer.on("connection", (sock: Socket) => {
|
|
190
188
|
netServer.close();
|
|
191
|
-
try {
|
|
189
|
+
try { unlinkSync(socketPath); } catch { /* ok */ }
|
|
192
190
|
|
|
193
191
|
const readable = new Readable({ read() {} });
|
|
194
192
|
const writable = new Writable({
|
|
@@ -356,9 +354,10 @@ describe("managed CES integration (real Unix socket)", () => {
|
|
|
356
354
|
// -- Pick a free port for health server ------------------------------------
|
|
357
355
|
// Use port 0 trick: bind, read the port, close, then use it.
|
|
358
356
|
const healthPort = await new Promise<number>((resolve) => {
|
|
359
|
-
const srv =
|
|
357
|
+
const srv = createNetServer();
|
|
360
358
|
srv.listen(0, () => {
|
|
361
|
-
const
|
|
359
|
+
const address = srv.address();
|
|
360
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
362
361
|
srv.close(() => resolve(port));
|
|
363
362
|
});
|
|
364
363
|
});
|
|
@@ -681,7 +680,7 @@ describe("credential CRUD RPC", () => {
|
|
|
681
680
|
*/
|
|
682
681
|
async function setupCredentialRpc(): Promise<{
|
|
683
682
|
clientSock: Socket;
|
|
684
|
-
servePromise: Promise<
|
|
683
|
+
servePromise: Promise<ServeEndReason>;
|
|
685
684
|
}> {
|
|
686
685
|
savedEnv = saveEnv();
|
|
687
686
|
tmpDir = mkdtempSync(join(tmpdir(), "ces-cred-integ-"));
|
|
@@ -9,11 +9,76 @@
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
applyManagedCredentialRefs,
|
|
12
13
|
buildLazyGetters,
|
|
13
14
|
type ApiKeyRef,
|
|
14
15
|
type AssistantIdRef,
|
|
15
16
|
} from "../managed-lazy-getters.js";
|
|
16
17
|
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// applyManagedCredentialRefs — fail-closed overwrite across sessions
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
describe("applyManagedCredentialRefs", () => {
|
|
23
|
+
test("overwrites both refs with the provided values", () => {
|
|
24
|
+
const apiKeyRef: ApiKeyRef = { current: "old-key" };
|
|
25
|
+
const assistantIdRef: AssistantIdRef = { current: "ast_old" };
|
|
26
|
+
|
|
27
|
+
applyManagedCredentialRefs(apiKeyRef, assistantIdRef, "new-key", "ast_new");
|
|
28
|
+
|
|
29
|
+
expect(apiKeyRef.current).toBe("new-key");
|
|
30
|
+
expect(assistantIdRef.current).toBe("ast_new");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("clears a stale assistant ID when the new value is omitted", () => {
|
|
34
|
+
// A new session (or an API-key-only update) that does not carry an
|
|
35
|
+
// assistant ID must not inherit the previous session's ID.
|
|
36
|
+
const apiKeyRef: ApiKeyRef = { current: "prev-key" };
|
|
37
|
+
const assistantIdRef: AssistantIdRef = { current: "ast_prev" };
|
|
38
|
+
|
|
39
|
+
applyManagedCredentialRefs(
|
|
40
|
+
apiKeyRef,
|
|
41
|
+
assistantIdRef,
|
|
42
|
+
"rotated-key",
|
|
43
|
+
undefined,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(apiKeyRef.current).toBe("rotated-key");
|
|
47
|
+
expect(assistantIdRef.current).toBe("");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("clears a stale API key when the new value is omitted", () => {
|
|
51
|
+
const apiKeyRef: ApiKeyRef = { current: "prev-key" };
|
|
52
|
+
const assistantIdRef: AssistantIdRef = { current: "ast_prev" };
|
|
53
|
+
|
|
54
|
+
applyManagedCredentialRefs(apiKeyRef, assistantIdRef, undefined, undefined);
|
|
55
|
+
|
|
56
|
+
expect(apiKeyRef.current).toBe("");
|
|
57
|
+
expect(assistantIdRef.current).toBe("");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("lazy getters fail closed after a reconnect that omits the ID", () => {
|
|
61
|
+
const apiKeyRef: ApiKeyRef = { current: "session1-key" };
|
|
62
|
+
const assistantIdRef: AssistantIdRef = { current: "ast_session1" };
|
|
63
|
+
const { getManagedMaterializerOptions } = buildLazyGetters({
|
|
64
|
+
platformBaseUrl: "https://api.vellum.ai",
|
|
65
|
+
assistantIdRef,
|
|
66
|
+
apiKeyRef,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// A reconnecting session provides a key but no assistant ID.
|
|
70
|
+
applyManagedCredentialRefs(
|
|
71
|
+
apiKeyRef,
|
|
72
|
+
assistantIdRef,
|
|
73
|
+
"session2-key",
|
|
74
|
+
undefined,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Materialization must not proceed with the prior session's assistant ID.
|
|
78
|
+
expect(getManagedMaterializerOptions()).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
17
82
|
// ---------------------------------------------------------------------------
|
|
18
83
|
// Before API key arrives
|
|
19
84
|
// ---------------------------------------------------------------------------
|
|
@@ -19,13 +19,11 @@ import { platformOAuthHandle } from "@vellumai/service-contracts/credential-rpc"
|
|
|
19
19
|
import {
|
|
20
20
|
type ManagedSubject,
|
|
21
21
|
resolveManagedSubject,
|
|
22
|
-
SubjectResolutionError,
|
|
23
22
|
type PlatformCatalogEntry,
|
|
24
23
|
type ResolvedSubject,
|
|
25
24
|
} from "../subjects/managed.js";
|
|
26
25
|
import {
|
|
27
26
|
materializeManagedToken,
|
|
28
|
-
MaterializationError,
|
|
29
27
|
type ManagedMaterializerOptions,
|
|
30
28
|
} from "../materializers/managed-platform.js";
|
|
31
29
|
|
|
@@ -82,7 +80,7 @@ function createMockFetch(handlers: {
|
|
|
82
80
|
error?: Error;
|
|
83
81
|
};
|
|
84
82
|
}): typeof globalThis.fetch {
|
|
85
|
-
return (async (input: RequestInfo | URL,
|
|
83
|
+
return (async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
86
84
|
const url = typeof input === "string" ? input : input.toString();
|
|
87
85
|
|
|
88
86
|
if (url.includes("/oauth/managed/catalog")) {
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed CES reconnection test (real entrypoint subprocess).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the actual `managed-main.ts` entrypoint and verifies that the CES
|
|
5
|
+
* sidecar survives the assistant disconnecting and accepts a reconnection,
|
|
6
|
+
* rather than shutting down when the RPC stream ends.
|
|
7
|
+
*
|
|
8
|
+
* This guards the core invariant that CES runs independently of whether the
|
|
9
|
+
* assistant is actively connected: the assistant container can crash and be
|
|
10
|
+
* restarted (Kubernetes restarts containers, not the whole pod), and the
|
|
11
|
+
* restarted assistant must be able to reconnect to a still-running CES.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { createConnection, createServer, type Socket } from "node:net";
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
CES_PROTOCOL_VERSION,
|
|
22
|
+
type HandshakeAck,
|
|
23
|
+
} from "@vellumai/service-contracts/credential-rpc";
|
|
24
|
+
|
|
25
|
+
import type { Subprocess } from "bun";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Sleep for the given number of milliseconds. */
|
|
32
|
+
function delay(ms: number): Promise<void> {
|
|
33
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pick a currently-free TCP port by binding to port 0 and reading it back. */
|
|
37
|
+
function pickFreePort(): Promise<number> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const srv = createServer();
|
|
40
|
+
srv.listen(0, () => {
|
|
41
|
+
const address = srv.address();
|
|
42
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
43
|
+
srv.close(() => (port ? resolve(port) : reject(new Error("no port"))));
|
|
44
|
+
});
|
|
45
|
+
srv.on("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Poll the health endpoint until it responds OK or the deadline passes. */
|
|
50
|
+
async function waitForHealth(port: number, timeoutMs = 15_000): Promise<void> {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
try {
|
|
54
|
+
const resp = await fetch(`http://127.0.0.1:${port}/healthz`);
|
|
55
|
+
if (resp.ok) return;
|
|
56
|
+
} catch {
|
|
57
|
+
// not up yet
|
|
58
|
+
}
|
|
59
|
+
await delay(100);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`CES health endpoint did not come up within ${timeoutMs}ms`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read the `rpcConnected` field from /readyz. */
|
|
65
|
+
async function readyzRpcConnected(port: number): Promise<boolean> {
|
|
66
|
+
const resp = await fetch(`http://127.0.0.1:${port}/readyz`);
|
|
67
|
+
const body = (await resp.json()) as { rpcConnected?: boolean };
|
|
68
|
+
return body.rpcConnected === true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Wait until the socket path exists (CES has bound the bootstrap socket). */
|
|
72
|
+
async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise<void> {
|
|
73
|
+
const deadline = Date.now() + timeoutMs;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
if (existsSync(socketPath)) return;
|
|
76
|
+
await delay(50);
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Bootstrap socket did not appear within ${timeoutMs}ms`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Connect to the bootstrap socket, retrying past the listen/connect race. */
|
|
82
|
+
function connectToSocket(
|
|
83
|
+
socketPath: string,
|
|
84
|
+
{ maxRetries = 40, baseDelayMs = 25 } = {},
|
|
85
|
+
): Promise<Socket> {
|
|
86
|
+
return new Promise((resolveConn, reject) => {
|
|
87
|
+
let attempt = 0;
|
|
88
|
+
const tryConnect = () => {
|
|
89
|
+
const sock = createConnection(socketPath, () => {
|
|
90
|
+
sock.removeAllListeners("error");
|
|
91
|
+
resolveConn(sock);
|
|
92
|
+
});
|
|
93
|
+
sock.on("error", (err: NodeJS.ErrnoException) => {
|
|
94
|
+
sock.destroy();
|
|
95
|
+
attempt++;
|
|
96
|
+
if (
|
|
97
|
+
attempt < maxRetries &&
|
|
98
|
+
(err.code === "ENOENT" || err.code === "ECONNREFUSED")
|
|
99
|
+
) {
|
|
100
|
+
setTimeout(tryConnect, baseDelayMs);
|
|
101
|
+
} else {
|
|
102
|
+
reject(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
tryConnect();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Send a handshake and resolve the resulting ack. */
|
|
111
|
+
function handshake(sock: Socket, sessionId: string): Promise<HandshakeAck> {
|
|
112
|
+
return new Promise((resolveAck, reject) => {
|
|
113
|
+
let buffer = "";
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
sock.removeAllListeners("data");
|
|
116
|
+
reject(new Error("Timed out waiting for handshake ack"));
|
|
117
|
+
}, 5_000);
|
|
118
|
+
|
|
119
|
+
sock.on("data", (chunk: Buffer) => {
|
|
120
|
+
buffer += chunk.toString("utf-8");
|
|
121
|
+
const idx = buffer.indexOf("\n");
|
|
122
|
+
if (idx === -1) return;
|
|
123
|
+
const line = buffer.slice(0, idx).trim();
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
sock.removeAllListeners("data");
|
|
126
|
+
try {
|
|
127
|
+
resolveAck(JSON.parse(line) as HandshakeAck);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
reject(err as Error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
sock.on("error", (err) => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
reject(err);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
sock.write(
|
|
138
|
+
JSON.stringify({
|
|
139
|
+
type: "handshake_request",
|
|
140
|
+
protocolVersion: CES_PROTOCOL_VERSION,
|
|
141
|
+
sessionId,
|
|
142
|
+
}) + "\n",
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Test
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
let tmpDir: string | undefined;
|
|
152
|
+
let proc: Subprocess | undefined;
|
|
153
|
+
|
|
154
|
+
afterEach(async () => {
|
|
155
|
+
if (proc) {
|
|
156
|
+
proc.kill("SIGTERM");
|
|
157
|
+
await Promise.race([proc.exited, delay(3_000)]);
|
|
158
|
+
proc = undefined;
|
|
159
|
+
}
|
|
160
|
+
if (tmpDir) {
|
|
161
|
+
try {
|
|
162
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
163
|
+
} catch {
|
|
164
|
+
/* ok */
|
|
165
|
+
}
|
|
166
|
+
tmpDir = undefined;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("managed CES reconnection (real entrypoint)", () => {
|
|
171
|
+
test("survives an assistant disconnect and accepts a reconnection", async () => {
|
|
172
|
+
tmpDir = mkdtempSync(join(tmpdir(), "ces-reconnect-"));
|
|
173
|
+
const dataDir = join(tmpDir, "ces-data");
|
|
174
|
+
const socketDir = join(tmpDir, "bootstrap");
|
|
175
|
+
const socketPath = join(socketDir, "ces.sock");
|
|
176
|
+
const assistantDataMount = join(tmpDir, "assistant-data-ro");
|
|
177
|
+
mkdirSync(dataDir, { recursive: true });
|
|
178
|
+
mkdirSync(socketDir, { recursive: true });
|
|
179
|
+
mkdirSync(join(assistantDataMount, ".vellum"), { recursive: true });
|
|
180
|
+
|
|
181
|
+
const healthPort = await pickFreePort();
|
|
182
|
+
const managedMain = resolve(__dirname, "..", "managed-main.ts");
|
|
183
|
+
|
|
184
|
+
proc = Bun.spawn({
|
|
185
|
+
cmd: [process.execPath, managedMain],
|
|
186
|
+
env: {
|
|
187
|
+
...process.env,
|
|
188
|
+
CES_MODE: "managed",
|
|
189
|
+
CES_DATA_DIR: dataDir,
|
|
190
|
+
CES_BOOTSTRAP_SOCKET: socketPath,
|
|
191
|
+
CES_HEALTH_PORT: String(healthPort),
|
|
192
|
+
CES_ASSISTANT_DATA_MOUNT: assistantDataMount,
|
|
193
|
+
},
|
|
194
|
+
stdout: "ignore",
|
|
195
|
+
stderr: "ignore",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Sidecar comes up and binds the bootstrap socket.
|
|
199
|
+
await waitForHealth(healthPort);
|
|
200
|
+
await waitForSocket(socketPath);
|
|
201
|
+
expect(await readyzRpcConnected(healthPort)).toBe(false);
|
|
202
|
+
|
|
203
|
+
// First assistant session connects and completes a handshake.
|
|
204
|
+
const first = await connectToSocket(socketPath);
|
|
205
|
+
const ack1 = await handshake(first, "session-1");
|
|
206
|
+
expect(ack1.accepted).toBe(true);
|
|
207
|
+
|
|
208
|
+
// Give /readyz a moment to flip, then confirm CES sees the connection.
|
|
209
|
+
await delay(200);
|
|
210
|
+
expect(await readyzRpcConnected(healthPort)).toBe(true);
|
|
211
|
+
|
|
212
|
+
// Simulate the assistant pod crashing: drop the connection hard.
|
|
213
|
+
first.destroy();
|
|
214
|
+
|
|
215
|
+
// CES must NOT exit. It should stay healthy, flip rpcConnected back to
|
|
216
|
+
// false, and re-bind the bootstrap socket to await a reconnection.
|
|
217
|
+
await waitForSocket(socketPath);
|
|
218
|
+
expect(proc.killed).toBe(false);
|
|
219
|
+
const resp = await fetch(`http://127.0.0.1:${healthPort}/healthz`);
|
|
220
|
+
expect(resp.ok).toBe(true);
|
|
221
|
+
|
|
222
|
+
// Wait for the new session's rpcConnected to clear before reconnecting.
|
|
223
|
+
const cleared = await (async () => {
|
|
224
|
+
const deadline = Date.now() + 5_000;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
if (!(await readyzRpcConnected(healthPort))) return true;
|
|
227
|
+
await delay(100);
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
})();
|
|
231
|
+
expect(cleared).toBe(true);
|
|
232
|
+
|
|
233
|
+
// The restarted assistant reconnects and handshakes successfully.
|
|
234
|
+
const second = await connectToSocket(socketPath);
|
|
235
|
+
const ack2 = await handshake(second, "session-2");
|
|
236
|
+
expect(ack2.accepted).toBe(true);
|
|
237
|
+
expect(ack2.sessionId).toBe("session-2");
|
|
238
|
+
|
|
239
|
+
await delay(200);
|
|
240
|
+
expect(await readyzRpcConnected(healthPort)).toBe(true);
|
|
241
|
+
|
|
242
|
+
second.destroy();
|
|
243
|
+
}, 30_000);
|
|
244
|
+
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
rmSync,
|
|
5
5
|
existsSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
readdirSync,
|
|
7
8
|
writeFileSync,
|
|
8
9
|
symlinkSync,
|
|
9
10
|
} from "node:fs";
|
|
@@ -395,7 +396,6 @@ describe("publishBundle — digest mismatch rejection", () => {
|
|
|
395
396
|
|
|
396
397
|
// Also check that no staging directories were left behind
|
|
397
398
|
if (existsSync(toolstoreDir)) {
|
|
398
|
-
const { readdirSync } = require("node:fs");
|
|
399
399
|
const entries = readdirSync(toolstoreDir) as string[];
|
|
400
400
|
const stagingDirs = entries.filter((e: string) =>
|
|
401
401
|
e.startsWith(".staging-"),
|
|
@@ -160,7 +160,7 @@ describe("health probes", () => {
|
|
|
160
160
|
expect(src).toMatch(/\/healthz/);
|
|
161
161
|
expect(src).toMatch(/\/readyz/);
|
|
162
162
|
// Health server uses Bun.serve on a dedicated port, not the socket
|
|
163
|
-
expect(src).toMatch(/startHealthServer\(healthPort/);
|
|
163
|
+
expect(src).toMatch(/startHealthServer\(\s*healthPort/);
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
test("getHealthPort defaults to 8090", () => {
|
|
@@ -221,7 +221,7 @@ describe("local entrypoint transport isolation", () => {
|
|
|
221
221
|
describe("CesRpcServer", () => {
|
|
222
222
|
test("completes handshake with correct protocol version", async () => {
|
|
223
223
|
const { server, handshake, input } = createTestServer();
|
|
224
|
-
const
|
|
224
|
+
const _servePromise = server.serve();
|
|
225
225
|
|
|
226
226
|
const ack = await handshake();
|
|
227
227
|
expect(ack.type).toBe("handshake_ack");
|
|
@@ -237,7 +237,7 @@ describe("CesRpcServer", () => {
|
|
|
237
237
|
|
|
238
238
|
test("rejects handshake with wrong protocol version", async () => {
|
|
239
239
|
const { server, send, collectOutputLines, input } = createTestServer();
|
|
240
|
-
const
|
|
240
|
+
const _servePromise = server.serve();
|
|
241
241
|
|
|
242
242
|
send({
|
|
243
243
|
type: "handshake_request",
|
|
@@ -259,7 +259,7 @@ describe("CesRpcServer", () => {
|
|
|
259
259
|
|
|
260
260
|
test("rejects RPC before handshake", async () => {
|
|
261
261
|
const { server, send, collectOutputLines, input } = createTestServer();
|
|
262
|
-
const
|
|
262
|
+
const _servePromise = server.serve();
|
|
263
263
|
|
|
264
264
|
send({
|
|
265
265
|
type: "rpc",
|
|
@@ -283,13 +283,13 @@ describe("CesRpcServer", () => {
|
|
|
283
283
|
|
|
284
284
|
test("dispatches RPC to registered handler", async () => {
|
|
285
285
|
const handlers: RpcHandlerRegistry = {
|
|
286
|
-
list_grants: async (
|
|
286
|
+
list_grants: async (_req: unknown) => {
|
|
287
287
|
return { grants: [] };
|
|
288
288
|
},
|
|
289
289
|
};
|
|
290
290
|
|
|
291
291
|
const { server, handshake, rpc, input } = createTestServer(handlers);
|
|
292
|
-
const
|
|
292
|
+
const _servePromise = server.serve();
|
|
293
293
|
|
|
294
294
|
await handshake();
|
|
295
295
|
|
|
@@ -304,7 +304,7 @@ describe("CesRpcServer", () => {
|
|
|
304
304
|
|
|
305
305
|
test("returns METHOD_NOT_FOUND for unknown methods", async () => {
|
|
306
306
|
const { server, handshake, rpc, input } = createTestServer();
|
|
307
|
-
const
|
|
307
|
+
const _servePromise = server.serve();
|
|
308
308
|
|
|
309
309
|
await handshake();
|
|
310
310
|
|
|
@@ -326,7 +326,7 @@ describe("CesRpcServer", () => {
|
|
|
326
326
|
};
|
|
327
327
|
|
|
328
328
|
const { server, handshake, rpc, input } = createTestServer(handlers);
|
|
329
|
-
const
|
|
329
|
+
const _servePromise = server.serve();
|
|
330
330
|
|
|
331
331
|
await handshake();
|
|
332
332
|
|
|
@@ -32,6 +32,27 @@ export interface AssistantIdRef {
|
|
|
32
32
|
current: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Overwrite the session-scoped managed credential refs.
|
|
37
|
+
*
|
|
38
|
+
* The managed handler registry is long-lived — it persists across assistant
|
|
39
|
+
* reconnects — so every handshake and every `update_managed_credential` must
|
|
40
|
+
* fully overwrite these refs, including clearing them when a value is absent.
|
|
41
|
+
* Otherwise a new or reprovisioned session could keep materializing platform
|
|
42
|
+
* credentials with the previous session's API key or assistant ID. Absent
|
|
43
|
+
* values fall back to "" (fail closed): the lazy getters then return no
|
|
44
|
+
* materialization options, and `getAssistantApiKey` falls back to the env key.
|
|
45
|
+
*/
|
|
46
|
+
export function applyManagedCredentialRefs(
|
|
47
|
+
apiKeyRef: ApiKeyRef,
|
|
48
|
+
assistantIdRef: AssistantIdRef,
|
|
49
|
+
apiKey: string | undefined,
|
|
50
|
+
assistantId: string | undefined,
|
|
51
|
+
): void {
|
|
52
|
+
apiKeyRef.current = apiKey ?? "";
|
|
53
|
+
assistantIdRef.current = assistantId ?? "";
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
export interface LazyGetterOptions {
|
|
36
57
|
platformBaseUrl: string;
|
|
37
58
|
assistantIdRef: AssistantIdRef;
|
|
@@ -55,10 +76,11 @@ export interface LazyGetters {
|
|
|
55
76
|
export function buildLazyGetters(opts: LazyGetterOptions): LazyGetters {
|
|
56
77
|
const { platformBaseUrl, assistantIdRef, apiKeyRef, envApiKey } = opts;
|
|
57
78
|
|
|
58
|
-
const getAssistantApiKey = (): string =>
|
|
59
|
-
apiKeyRef.current || envApiKey || "";
|
|
79
|
+
const getAssistantApiKey = (): string => apiKeyRef.current || envApiKey || "";
|
|
60
80
|
|
|
61
|
-
const getManagedSubjectOptions = ():
|
|
81
|
+
const getManagedSubjectOptions = ():
|
|
82
|
+
| ManagedSubjectResolverOptions
|
|
83
|
+
| undefined => {
|
|
62
84
|
const key = getAssistantApiKey();
|
|
63
85
|
const id = assistantIdRef.current;
|
|
64
86
|
return platformBaseUrl && key && id
|
|
@@ -66,7 +88,9 @@ export function buildLazyGetters(opts: LazyGetterOptions): LazyGetters {
|
|
|
66
88
|
: undefined;
|
|
67
89
|
};
|
|
68
90
|
|
|
69
|
-
const getManagedMaterializerOptions = ():
|
|
91
|
+
const getManagedMaterializerOptions = ():
|
|
92
|
+
| ManagedMaterializerOptions
|
|
93
|
+
| undefined => {
|
|
70
94
|
const key = getAssistantApiKey();
|
|
71
95
|
const id = assistantIdRef.current;
|
|
72
96
|
return platformBaseUrl && key && id
|