@vellumai/credential-executor 0.4.55
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/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CES RPC server.
|
|
3
|
+
*
|
|
4
|
+
* Implements the server-side of the CES wire protocol defined in
|
|
5
|
+
* `@vellumai/ces-contracts`. The server reads newline-delimited JSON
|
|
6
|
+
* messages from a readable stream, dispatches them through the RPC
|
|
7
|
+
* contract, and writes responses back to a writable stream.
|
|
8
|
+
*
|
|
9
|
+
* Transport-agnostic: callers provide the readable/writable pair.
|
|
10
|
+
* - Local mode: stdin/stdout
|
|
11
|
+
* - Managed mode: the accepted Unix socket stream
|
|
12
|
+
*
|
|
13
|
+
* The server handles the handshake, validates envelopes, dispatches
|
|
14
|
+
* method calls, and sends structured responses or errors.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Readable, Writable } from "node:stream";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
CES_PROTOCOL_VERSION,
|
|
21
|
+
CesRpcMethod,
|
|
22
|
+
CesRpcSchemas,
|
|
23
|
+
type HandshakeAck,
|
|
24
|
+
type HandshakeRequest,
|
|
25
|
+
type MakeAuthenticatedRequest,
|
|
26
|
+
type ManageSecureCommandTool,
|
|
27
|
+
type ManageSecureCommandToolResponse,
|
|
28
|
+
type RpcEnvelope,
|
|
29
|
+
type RunAuthenticatedCommand,
|
|
30
|
+
type RunAuthenticatedCommandResponse,
|
|
31
|
+
type TransportMessage,
|
|
32
|
+
TransportMessageSchema,
|
|
33
|
+
} from "@vellumai/ces-contracts";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
executeAuthenticatedCommand,
|
|
37
|
+
type CommandExecutorDeps,
|
|
38
|
+
type ExecuteCommandRequest,
|
|
39
|
+
} from "./commands/executor.js";
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
executeAuthenticatedHttpRequest,
|
|
43
|
+
type HttpExecutorDeps,
|
|
44
|
+
} from "./http/executor.js";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Types
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handler function for a single RPC method. Receives the validated
|
|
52
|
+
* request payload and returns the response payload (or throws).
|
|
53
|
+
*/
|
|
54
|
+
export type RpcMethodHandler<TReq = unknown, TRes = unknown> = (
|
|
55
|
+
request: TReq,
|
|
56
|
+
) => Promise<TRes> | TRes;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Registry of method name to handler function.
|
|
60
|
+
*/
|
|
61
|
+
export type RpcHandlerRegistry = Partial<
|
|
62
|
+
Record<string, RpcMethodHandler>
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
export interface CesServerOptions {
|
|
66
|
+
/** Readable stream to consume messages from. */
|
|
67
|
+
input: Readable;
|
|
68
|
+
/** Writable stream to send responses to. */
|
|
69
|
+
output: Writable;
|
|
70
|
+
/** Map of RPC method names to handler functions. */
|
|
71
|
+
handlers: RpcHandlerRegistry;
|
|
72
|
+
/** Optional logger (defaults to console). */
|
|
73
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
74
|
+
/** Optional abort signal to shut down the server. */
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Server implementation
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export class CesRpcServer {
|
|
83
|
+
private readonly input: Readable;
|
|
84
|
+
private readonly output: Writable;
|
|
85
|
+
private readonly handlers: RpcHandlerRegistry;
|
|
86
|
+
private readonly logger: Pick<Console, "log" | "warn" | "error">;
|
|
87
|
+
private readonly signal?: AbortSignal;
|
|
88
|
+
|
|
89
|
+
private handshakeComplete = false;
|
|
90
|
+
private sessionId: string | null = null;
|
|
91
|
+
private buffer = "";
|
|
92
|
+
private closed = false;
|
|
93
|
+
|
|
94
|
+
constructor(options: CesServerOptions) {
|
|
95
|
+
this.input = options.input;
|
|
96
|
+
this.output = options.output;
|
|
97
|
+
this.handlers = options.handlers;
|
|
98
|
+
this.logger = options.logger ?? console;
|
|
99
|
+
this.signal = options.signal;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Start serving. Returns a promise that resolves when the input stream
|
|
104
|
+
* ends or the abort signal fires.
|
|
105
|
+
*/
|
|
106
|
+
async serve(): Promise<void> {
|
|
107
|
+
return new Promise<void>((resolve, reject) => {
|
|
108
|
+
if (this.signal?.aborted) {
|
|
109
|
+
this.close();
|
|
110
|
+
resolve();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const onAbort = () => {
|
|
115
|
+
this.close();
|
|
116
|
+
resolve();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (this.signal) {
|
|
120
|
+
this.signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.input.on("data", (chunk: Buffer | string) => {
|
|
124
|
+
if (this.closed) return;
|
|
125
|
+
this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
126
|
+
this.processBuffer();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this.input.on("end", () => {
|
|
130
|
+
if (this.signal) {
|
|
131
|
+
this.signal.removeEventListener("abort", onAbort);
|
|
132
|
+
}
|
|
133
|
+
this.close();
|
|
134
|
+
resolve();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
this.input.on("error", (err) => {
|
|
138
|
+
if (this.signal) {
|
|
139
|
+
this.signal.removeEventListener("abort", onAbort);
|
|
140
|
+
}
|
|
141
|
+
this.close();
|
|
142
|
+
reject(err);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether the server has completed the handshake. */
|
|
148
|
+
get isHandshakeComplete(): boolean {
|
|
149
|
+
return this.handshakeComplete;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The session ID established during handshake (null before handshake). */
|
|
153
|
+
get currentSessionId(): string | null {
|
|
154
|
+
return this.sessionId;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Shut down the server gracefully. */
|
|
158
|
+
close(): void {
|
|
159
|
+
this.closed = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
// Internal
|
|
164
|
+
// -----------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
private processBuffer(): void {
|
|
167
|
+
let newlineIdx: number;
|
|
168
|
+
while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
|
|
169
|
+
const line = this.buffer.slice(0, newlineIdx).trim();
|
|
170
|
+
this.buffer = this.buffer.slice(newlineIdx + 1);
|
|
171
|
+
if (line.length === 0) continue;
|
|
172
|
+
this.handleLine(line);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private handleLine(line: string): void {
|
|
177
|
+
let parsed: unknown;
|
|
178
|
+
try {
|
|
179
|
+
parsed = JSON.parse(line);
|
|
180
|
+
} catch {
|
|
181
|
+
this.logger.warn("[ces-server] Failed to parse JSON line:", line);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Parse as a transport message
|
|
186
|
+
const msgResult = TransportMessageSchema.safeParse(parsed);
|
|
187
|
+
if (!msgResult.success) {
|
|
188
|
+
this.logger.warn(
|
|
189
|
+
"[ces-server] Invalid transport message:",
|
|
190
|
+
msgResult.error,
|
|
191
|
+
);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const msg = msgResult.data as TransportMessage;
|
|
196
|
+
|
|
197
|
+
if (msg.type === "handshake_request") {
|
|
198
|
+
this.handleHandshake(msg as HandshakeRequest);
|
|
199
|
+
} else if (msg.type === "rpc") {
|
|
200
|
+
this.handleRpcEnvelope(msg as unknown as RpcEnvelope);
|
|
201
|
+
} else {
|
|
202
|
+
this.logger.warn("[ces-server] Unexpected message type:", msg.type);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private handleHandshake(req: HandshakeRequest): void {
|
|
207
|
+
const accepted = req.protocolVersion === CES_PROTOCOL_VERSION;
|
|
208
|
+
const ack: HandshakeAck = {
|
|
209
|
+
type: "handshake_ack",
|
|
210
|
+
protocolVersion: CES_PROTOCOL_VERSION,
|
|
211
|
+
sessionId: req.sessionId,
|
|
212
|
+
accepted,
|
|
213
|
+
...(accepted ? {} : { reason: `Unsupported protocol version: ${req.protocolVersion}` }),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (accepted) {
|
|
217
|
+
this.handshakeComplete = true;
|
|
218
|
+
this.sessionId = req.sessionId;
|
|
219
|
+
this.logger.log(`[ces-server] Handshake accepted for session ${req.sessionId}`);
|
|
220
|
+
} else {
|
|
221
|
+
this.logger.warn(
|
|
222
|
+
`[ces-server] Handshake rejected: version mismatch (got ${req.protocolVersion}, expected ${CES_PROTOCOL_VERSION})`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.sendMessage(ack);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async handleRpcEnvelope(envelope: RpcEnvelope): Promise<void> {
|
|
230
|
+
if (!this.handshakeComplete) {
|
|
231
|
+
this.logger.warn("[ces-server] RPC received before handshake; ignoring");
|
|
232
|
+
this.sendRpcError(envelope, "HANDSHAKE_REQUIRED", "Handshake not completed");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (envelope.kind !== "request") {
|
|
237
|
+
// Server only processes requests; responses are ignored
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const method = envelope.method;
|
|
242
|
+
const handler = this.handlers[method];
|
|
243
|
+
|
|
244
|
+
if (!handler) {
|
|
245
|
+
this.sendRpcError(envelope, "METHOD_NOT_FOUND", `Unknown method: ${method}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Validate the request payload against the registered schema (if available)
|
|
250
|
+
const schemas = CesRpcSchemas[method as CesRpcMethod];
|
|
251
|
+
let validatedPayload = envelope.payload;
|
|
252
|
+
|
|
253
|
+
if (schemas) {
|
|
254
|
+
const parseResult = schemas.request.safeParse(envelope.payload);
|
|
255
|
+
if (!parseResult.success) {
|
|
256
|
+
this.sendRpcError(
|
|
257
|
+
envelope,
|
|
258
|
+
"INVALID_REQUEST",
|
|
259
|
+
`Invalid payload for ${method}: ${parseResult.error.message}`,
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
validatedPayload = parseResult.data;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const result = await handler(validatedPayload);
|
|
268
|
+
this.sendRpcResponse(envelope, result);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
271
|
+
this.sendRpcError(envelope, "HANDLER_ERROR", message);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private sendRpcResponse(request: RpcEnvelope, payload: unknown): void {
|
|
276
|
+
const response: RpcEnvelope & { type: "rpc" } = {
|
|
277
|
+
type: "rpc",
|
|
278
|
+
id: request.id,
|
|
279
|
+
kind: "response",
|
|
280
|
+
method: request.method,
|
|
281
|
+
payload,
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
this.sendMessage(response);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private sendRpcError(
|
|
288
|
+
request: RpcEnvelope,
|
|
289
|
+
code: string,
|
|
290
|
+
message: string,
|
|
291
|
+
): void {
|
|
292
|
+
const response: RpcEnvelope & { type: "rpc" } = {
|
|
293
|
+
type: "rpc",
|
|
294
|
+
id: request.id,
|
|
295
|
+
kind: "response",
|
|
296
|
+
method: request.method,
|
|
297
|
+
payload: {
|
|
298
|
+
success: false,
|
|
299
|
+
error: { code, message },
|
|
300
|
+
},
|
|
301
|
+
timestamp: new Date().toISOString(),
|
|
302
|
+
};
|
|
303
|
+
this.sendMessage(response);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private sendMessage(msg: unknown): void {
|
|
307
|
+
if (this.closed) return;
|
|
308
|
+
const line = JSON.stringify(msg) + "\n";
|
|
309
|
+
this.output.write(line);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Handler factory: make_authenticated_request
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create a handler function for the `make_authenticated_request` RPC method.
|
|
319
|
+
*
|
|
320
|
+
* Binds the executor to the provided dependencies so it can be registered
|
|
321
|
+
* in the RPC handler registry.
|
|
322
|
+
*/
|
|
323
|
+
export function createMakeAuthenticatedRequestHandler(
|
|
324
|
+
deps: HttpExecutorDeps,
|
|
325
|
+
): RpcMethodHandler {
|
|
326
|
+
return async (request: unknown) => {
|
|
327
|
+
return executeAuthenticatedHttpRequest(
|
|
328
|
+
request as MakeAuthenticatedRequest,
|
|
329
|
+
deps,
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Build an RPC handler registry that includes the `make_authenticated_request`
|
|
336
|
+
* handler alongside any additional handlers.
|
|
337
|
+
*
|
|
338
|
+
* This is a convenience helper for callers that want to wire up the HTTP
|
|
339
|
+
* executor without manually constructing the registry.
|
|
340
|
+
*/
|
|
341
|
+
export function buildHandlersWithHttp(
|
|
342
|
+
httpDeps: HttpExecutorDeps,
|
|
343
|
+
additionalHandlers?: RpcHandlerRegistry,
|
|
344
|
+
): RpcHandlerRegistry {
|
|
345
|
+
return {
|
|
346
|
+
...additionalHandlers,
|
|
347
|
+
[CesRpcMethod.MakeAuthenticatedRequest]:
|
|
348
|
+
createMakeAuthenticatedRequestHandler(httpDeps),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Factory helper
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Create a CES RPC server with the given options and start serving.
|
|
358
|
+
*
|
|
359
|
+
* This is the primary entrypoint for both local and managed modes —
|
|
360
|
+
* callers just provide different input/output streams.
|
|
361
|
+
*/
|
|
362
|
+
export function createCesServer(options: CesServerOptions): CesRpcServer {
|
|
363
|
+
return new CesRpcServer(options);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// run_authenticated_command handler factory
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Options for creating the `run_authenticated_command` RPC handler.
|
|
372
|
+
*/
|
|
373
|
+
export interface RunAuthenticatedCommandHandlerOptions {
|
|
374
|
+
/** Dependencies for the command executor. */
|
|
375
|
+
executorDeps: CommandExecutorDeps;
|
|
376
|
+
/**
|
|
377
|
+
* Default workspace directory for commands that don't specify one.
|
|
378
|
+
* Typically the assistant's workspace root.
|
|
379
|
+
*/
|
|
380
|
+
defaultWorkspaceDir: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Create an RPC handler for the `run_authenticated_command` method.
|
|
385
|
+
*
|
|
386
|
+
* This handler:
|
|
387
|
+
* 1. Parses the `command` string into a bundleDigest, profileName, and argv.
|
|
388
|
+
* The expected format is: `<bundleDigest>/<profileName> <argv...>`
|
|
389
|
+
* 2. Delegates to `executeAuthenticatedCommand` for the full security pipeline.
|
|
390
|
+
* 3. Returns a `RunAuthenticatedCommandResponse` with the execution result.
|
|
391
|
+
*
|
|
392
|
+
* If the command string doesn't match the expected format (i.e. it's a
|
|
393
|
+
* plain shell command), the handler returns a structured error since only
|
|
394
|
+
* manifest-driven secure commands are supported.
|
|
395
|
+
*/
|
|
396
|
+
export function createRunAuthenticatedCommandHandler(
|
|
397
|
+
options: RunAuthenticatedCommandHandlerOptions,
|
|
398
|
+
): RpcMethodHandler<RunAuthenticatedCommand, RunAuthenticatedCommandResponse> {
|
|
399
|
+
return async (request) => {
|
|
400
|
+
// Parse the command string into bundle-digest/profile and argv
|
|
401
|
+
const parseResult = parseCommandString(request.command);
|
|
402
|
+
if (!parseResult.ok) {
|
|
403
|
+
return {
|
|
404
|
+
success: false,
|
|
405
|
+
error: { code: "INVALID_COMMAND", message: parseResult.error },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const execRequest: ExecuteCommandRequest = {
|
|
410
|
+
bundleDigest: parseResult.bundleDigest,
|
|
411
|
+
profileName: parseResult.profileName,
|
|
412
|
+
credentialHandle: request.credentialHandle,
|
|
413
|
+
argv: parseResult.argv,
|
|
414
|
+
workspaceDir: request.cwd ?? options.defaultWorkspaceDir,
|
|
415
|
+
purpose: request.purpose,
|
|
416
|
+
grantId: request.grantId,
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const result = await executeAuthenticatedCommand(
|
|
420
|
+
execRequest,
|
|
421
|
+
options.executorDeps,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
success: result.success,
|
|
426
|
+
exitCode: result.exitCode,
|
|
427
|
+
stdout: result.stdout,
|
|
428
|
+
stderr: result.stderr,
|
|
429
|
+
error: result.error
|
|
430
|
+
? { code: "EXECUTION_ERROR", message: result.error }
|
|
431
|
+
: undefined,
|
|
432
|
+
auditId: result.auditId,
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Parse a CES command string into bundle digest, profile name, and argv.
|
|
439
|
+
*
|
|
440
|
+
* Expected format: `<bundleDigest>/<profileName> [argv...]`
|
|
441
|
+
*
|
|
442
|
+
* Examples:
|
|
443
|
+
* - `abc123def.../api-read api /repos/owner/repo --method GET`
|
|
444
|
+
* - `abc123def.../list-repos`
|
|
445
|
+
*/
|
|
446
|
+
function parseCommandString(
|
|
447
|
+
command: string,
|
|
448
|
+
): { ok: true; bundleDigest: string; profileName: string; argv: string[] }
|
|
449
|
+
| { ok: false; error: string } {
|
|
450
|
+
const trimmed = command.trim();
|
|
451
|
+
if (!trimmed) {
|
|
452
|
+
return { ok: false, error: "Command string is empty" };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Split on first space to separate the bundle/profile reference from argv
|
|
456
|
+
const firstSpaceIdx = trimmed.indexOf(" ");
|
|
457
|
+
const ref = firstSpaceIdx === -1 ? trimmed : trimmed.slice(0, firstSpaceIdx);
|
|
458
|
+
const argvStr = firstSpaceIdx === -1 ? "" : trimmed.slice(firstSpaceIdx + 1).trim();
|
|
459
|
+
|
|
460
|
+
// Parse the reference: <bundleDigest>/<profileName>
|
|
461
|
+
const slashIdx = ref.indexOf("/");
|
|
462
|
+
if (slashIdx === -1 || slashIdx === 0 || slashIdx === ref.length - 1) {
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
error: `Invalid command reference "${ref}". Expected format: "<bundleDigest>/<profileName> [argv...]"`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const bundleDigest = ref.slice(0, slashIdx);
|
|
470
|
+
const profileName = ref.slice(slashIdx + 1);
|
|
471
|
+
|
|
472
|
+
// Parse argv — split on whitespace (simple tokenization)
|
|
473
|
+
const argv = argvStr ? argvStr.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
474
|
+
|
|
475
|
+
return { ok: true, bundleDigest, profileName, argv };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Convenience helper to register the `run_authenticated_command` handler
|
|
480
|
+
* into an RPC handler registry.
|
|
481
|
+
*/
|
|
482
|
+
export function registerCommandExecutionHandler(
|
|
483
|
+
registry: RpcHandlerRegistry,
|
|
484
|
+
options: RunAuthenticatedCommandHandlerOptions,
|
|
485
|
+
): void {
|
|
486
|
+
registry[CesRpcMethod.RunAuthenticatedCommand] =
|
|
487
|
+
createRunAuthenticatedCommandHandler(options) as RpcMethodHandler;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// manage_secure_command_tool handler factory
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Dependencies for the `manage_secure_command_tool` handler.
|
|
496
|
+
*/
|
|
497
|
+
export interface ManageSecureCommandToolHandlerDeps {
|
|
498
|
+
/**
|
|
499
|
+
* Download bundle bytes from the given HTTPS URL.
|
|
500
|
+
* Implementations should enforce size limits and timeouts.
|
|
501
|
+
*/
|
|
502
|
+
downloadBundle: (sourceUrl: string) => Promise<Buffer | Uint8Array>;
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Publish a bundle into the CES-private toolstore.
|
|
506
|
+
* Typically delegates to `publishBundle()` from `./toolstore/publish.js`.
|
|
507
|
+
*/
|
|
508
|
+
publishBundle: (request: import("./toolstore/publish.js").PublishRequest) => import("./toolstore/publish.js").PublishResult;
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Unregister/remove a tool from the tool registry by name.
|
|
512
|
+
* Returns true if the tool was found and removed.
|
|
513
|
+
*/
|
|
514
|
+
unregisterTool: (toolName: string) => boolean;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Register a tool in the tool registry after successful publication.
|
|
518
|
+
* Called with the tool name, credential handle, description, and the
|
|
519
|
+
* bundle digest for runtime lookup.
|
|
520
|
+
*/
|
|
521
|
+
registerTool: (entry: {
|
|
522
|
+
toolName: string;
|
|
523
|
+
credentialHandle: string;
|
|
524
|
+
description: string;
|
|
525
|
+
bundleDigest: string;
|
|
526
|
+
}) => void;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Create an RPC handler for the `manage_secure_command_tool` method.
|
|
531
|
+
*
|
|
532
|
+
* This handler:
|
|
533
|
+
* 1. For "register" actions: validates required bundle metadata fields,
|
|
534
|
+
* downloads the bundle from `sourceUrl`, publishes it into the
|
|
535
|
+
* immutable toolstore with digest verification, and registers
|
|
536
|
+
* the tool entry.
|
|
537
|
+
* 2. For "unregister" actions: removes the tool from the registry.
|
|
538
|
+
*/
|
|
539
|
+
export function createManageSecureCommandToolHandler(
|
|
540
|
+
deps: ManageSecureCommandToolHandlerDeps,
|
|
541
|
+
): RpcMethodHandler<ManageSecureCommandTool, ManageSecureCommandToolResponse> {
|
|
542
|
+
return async (request) => {
|
|
543
|
+
if (request.action === "unregister") {
|
|
544
|
+
const removed = deps.unregisterTool(request.toolName);
|
|
545
|
+
if (!removed) {
|
|
546
|
+
return {
|
|
547
|
+
success: false,
|
|
548
|
+
error: {
|
|
549
|
+
code: "TOOL_NOT_FOUND",
|
|
550
|
+
message: `Tool "${request.toolName}" is not registered.`,
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return { success: true };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// action === "register"
|
|
558
|
+
const missing: string[] = [];
|
|
559
|
+
if (!request.bundleId) missing.push("bundleId");
|
|
560
|
+
if (!request.version) missing.push("version");
|
|
561
|
+
if (!request.sourceUrl) missing.push("sourceUrl");
|
|
562
|
+
if (!request.sha256) missing.push("sha256");
|
|
563
|
+
if (!request.credentialHandle) missing.push("credentialHandle");
|
|
564
|
+
if (missing.length > 0) {
|
|
565
|
+
return {
|
|
566
|
+
success: false,
|
|
567
|
+
error: {
|
|
568
|
+
code: "MISSING_FIELDS",
|
|
569
|
+
message: `Register action requires: ${missing.join(", ")}`,
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Download the bundle
|
|
575
|
+
let bundleBytes: Buffer | Uint8Array;
|
|
576
|
+
try {
|
|
577
|
+
bundleBytes = await deps.downloadBundle(request.sourceUrl!);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
return {
|
|
580
|
+
success: false,
|
|
581
|
+
error: {
|
|
582
|
+
code: "DOWNLOAD_FAILED",
|
|
583
|
+
message: `Failed to download bundle from ${request.sourceUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Publish into the immutable toolstore (includes digest verification)
|
|
589
|
+
const publishResult = deps.publishBundle({
|
|
590
|
+
bundleBytes,
|
|
591
|
+
expectedDigest: request.sha256!,
|
|
592
|
+
bundleId: request.bundleId!,
|
|
593
|
+
version: request.version!,
|
|
594
|
+
sourceUrl: request.sourceUrl!,
|
|
595
|
+
// The secure command manifest is embedded in the bundle; for now
|
|
596
|
+
// pass a minimal manifest with declared profiles.
|
|
597
|
+
secureCommandManifest: {
|
|
598
|
+
commandProfiles: Object.fromEntries(
|
|
599
|
+
(request.profiles ?? []).map((p) => [p, { command: request.toolName }]),
|
|
600
|
+
),
|
|
601
|
+
} as unknown as import("./commands/profiles.js").SecureCommandManifest,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
if (!publishResult.success) {
|
|
605
|
+
return {
|
|
606
|
+
success: false,
|
|
607
|
+
error: {
|
|
608
|
+
code: "PUBLISH_FAILED",
|
|
609
|
+
message: publishResult.error ?? "Unknown publish error",
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Register the tool entry for runtime lookup
|
|
615
|
+
deps.registerTool({
|
|
616
|
+
toolName: request.toolName,
|
|
617
|
+
credentialHandle: request.credentialHandle!,
|
|
618
|
+
description: request.description ?? "",
|
|
619
|
+
bundleDigest: request.sha256!,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
return { success: true };
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Convenience helper to register the `manage_secure_command_tool` handler
|
|
628
|
+
* into an RPC handler registry.
|
|
629
|
+
*/
|
|
630
|
+
export function registerManageSecureCommandToolHandler(
|
|
631
|
+
registry: RpcHandlerRegistry,
|
|
632
|
+
deps: ManageSecureCommandToolHandlerDeps,
|
|
633
|
+
): void {
|
|
634
|
+
registry[CesRpcMethod.ManageSecureCommandTool] =
|
|
635
|
+
createManageSecureCommandToolHandler(deps) as RpcMethodHandler;
|
|
636
|
+
}
|