@vellumai/credential-executor 0.10.7-dev.202607102035.64f07ea → 0.10.7-staging.1
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 +1 -1
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +686 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +5 -3
- package/node_modules/@vellumai/service-contracts/src/grants.ts +184 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +4 -2
- package/node_modules/@vellumai/service-contracts/src/rendering.ts +135 -0
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +447 -4
- package/package.json +3 -2
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/command-executor.test.ts +1879 -0
- package/src/__tests__/command-validator.test.ts +1405 -0
- package/src/__tests__/command-workspace.test.ts +1050 -0
- package/src/__tests__/grant-store.test.ts +689 -0
- package/src/__tests__/http-executor.test.ts +1336 -0
- package/src/__tests__/http-policy.test.ts +1069 -0
- package/src/__tests__/local-materializers.test.ts +860 -0
- package/src/__tests__/local-standalone.test.ts +36 -5
- package/src/__tests__/local-token-refresh.test.ts +361 -0
- package/src/__tests__/manage-secure-command-tool.test.ts +134 -0
- package/src/__tests__/managed-integration.test.ts +91 -112
- package/src/__tests__/managed-lazy-getters.test.ts +359 -0
- package/src/__tests__/managed-materializers.test.ts +1028 -0
- package/src/__tests__/managed-reconnect.test.ts +2 -2
- package/src/__tests__/managed-rejection.test.ts +43 -0
- package/src/__tests__/toolstore.test.ts +773 -0
- package/src/__tests__/transport.test.ts +27 -23
- package/src/audit/store.ts +188 -0
- package/src/cli.ts +1 -1
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/egress-hooks.ts +203 -0
- package/src/commands/executor.ts +1155 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +286 -0
- package/src/commands/validator.ts +702 -0
- package/src/commands/workspace.ts +550 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +309 -0
- package/src/grants/rpc-handlers.ts +293 -0
- package/src/grants/temporary-store.ts +289 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +684 -0
- package/src/http/path-template.ts +245 -0
- package/src/http/policy.ts +238 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +88 -8
- package/src/main.ts +340 -228
- package/src/managed-errors.ts +9 -0
- package/src/managed-lazy-getters.ts +106 -0
- package/src/managed-main.ts +822 -0
- package/src/materializers/local-oauth-lookup.ts +98 -0
- package/src/materializers/local-token-refresh.ts +287 -0
- package/src/materializers/local.ts +316 -0
- package/src/materializers/managed-platform.ts +295 -0
- package/src/paths.ts +20 -4
- package/src/server.ts +469 -52
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +311 -0
- package/src/subjects/policy.ts +79 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +571 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +0 -104
- package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +0 -118
package/src/server.ts
CHANGED
|
@@ -20,13 +20,35 @@ import {
|
|
|
20
20
|
CES_PROTOCOL_VERSION,
|
|
21
21
|
CesRpcMethod,
|
|
22
22
|
CesRpcSchemas,
|
|
23
|
+
hashProposal,
|
|
24
|
+
type CommandGrantProposal,
|
|
23
25
|
type HandshakeAck,
|
|
24
26
|
type HandshakeRequest,
|
|
27
|
+
type MakeAuthenticatedRequest,
|
|
28
|
+
type ManageSecureCommandTool,
|
|
29
|
+
type ManageSecureCommandToolResponse,
|
|
25
30
|
type RpcEnvelope,
|
|
31
|
+
type RunAuthenticatedCommand,
|
|
32
|
+
type RunAuthenticatedCommandResponse,
|
|
26
33
|
type TransportMessage,
|
|
27
34
|
TransportMessageSchema,
|
|
28
35
|
} from "@vellumai/service-contracts/credential-rpc";
|
|
29
36
|
|
|
37
|
+
import { resolve } from "node:path";
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
executeAuthenticatedCommand,
|
|
41
|
+
type CommandExecutorDeps,
|
|
42
|
+
type ExecuteCommandRequest,
|
|
43
|
+
} from "./commands/executor.js";
|
|
44
|
+
|
|
45
|
+
import { validateContainedPath } from "./commands/workspace.js";
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
executeAuthenticatedHttpRequest,
|
|
49
|
+
type HttpExecutorDeps,
|
|
50
|
+
} from "./http/executor.js";
|
|
51
|
+
|
|
30
52
|
// ---------------------------------------------------------------------------
|
|
31
53
|
// Types
|
|
32
54
|
// ---------------------------------------------------------------------------
|
|
@@ -37,8 +59,13 @@ import {
|
|
|
37
59
|
* Each accepted connection owns one `SessionContext`, created when the server
|
|
38
60
|
* is constructed and populated with the negotiated session ID at handshake.
|
|
39
61
|
* Handlers receive it as their second argument and read the session ID at call
|
|
40
|
-
* time
|
|
41
|
-
* to the originating connection.
|
|
62
|
+
* time — so a handler registry shared across connections attributes each call
|
|
63
|
+
* (e.g. audit records) to the originating connection.
|
|
64
|
+
*
|
|
65
|
+
* Identity that does not vary across a daemon's connections — the assistant
|
|
66
|
+
* API key and assistant ID — deliberately lives outside this context
|
|
67
|
+
* (process-global, see `managed-lazy-getters.ts`); only the per-connection
|
|
68
|
+
* session ID belongs here.
|
|
42
69
|
*/
|
|
43
70
|
export interface SessionContext {
|
|
44
71
|
/** The RPC session ID negotiated at handshake. */
|
|
@@ -59,7 +86,9 @@ export type RpcMethodHandler<TReq = unknown, TRes = unknown> = (
|
|
|
59
86
|
/**
|
|
60
87
|
* Registry of method name to handler function.
|
|
61
88
|
*/
|
|
62
|
-
export type RpcHandlerRegistry = Partial<
|
|
89
|
+
export type RpcHandlerRegistry = Partial<
|
|
90
|
+
Record<string, RpcMethodHandler>
|
|
91
|
+
>;
|
|
63
92
|
|
|
64
93
|
export interface CesServerOptions {
|
|
65
94
|
/** Readable stream to consume messages from. */
|
|
@@ -73,11 +102,7 @@ export interface CesServerOptions {
|
|
|
73
102
|
/** Optional abort signal to shut down the server. */
|
|
74
103
|
signal?: AbortSignal;
|
|
75
104
|
/** Callback invoked when the handshake completes with the negotiated session ID and optional API key / assistant ID. */
|
|
76
|
-
onHandshakeComplete?: (
|
|
77
|
-
sessionId: string,
|
|
78
|
-
assistantApiKey?: string,
|
|
79
|
-
assistantId?: string,
|
|
80
|
-
) => void;
|
|
105
|
+
onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
|
|
81
106
|
/** Callback invoked when the assistant pushes an updated API key (and optionally assistant ID) after hatch. */
|
|
82
107
|
onApiKeyUpdate?: (assistantApiKey: string, assistantId?: string) => void;
|
|
83
108
|
}
|
|
@@ -97,11 +122,7 @@ export class CesRpcServer {
|
|
|
97
122
|
private readonly handlers: RpcHandlerRegistry;
|
|
98
123
|
private readonly logger: Pick<Console, "log" | "warn" | "error">;
|
|
99
124
|
private readonly signal?: AbortSignal;
|
|
100
|
-
private readonly onHandshakeComplete?: (
|
|
101
|
-
sessionId: string,
|
|
102
|
-
assistantApiKey?: string,
|
|
103
|
-
assistantId?: string,
|
|
104
|
-
) => void;
|
|
125
|
+
private readonly onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
|
|
105
126
|
|
|
106
127
|
private handshakeComplete = false;
|
|
107
128
|
/**
|
|
@@ -124,13 +145,8 @@ export class CesRpcServer {
|
|
|
124
145
|
// Auto-register the update_managed_credential handler if a callback is provided.
|
|
125
146
|
if (options.onApiKeyUpdate) {
|
|
126
147
|
const onUpdate = options.onApiKeyUpdate;
|
|
127
|
-
this.handlers[CesRpcMethod.UpdateManagedCredential] = (
|
|
128
|
-
request:
|
|
129
|
-
) => {
|
|
130
|
-
const { assistantApiKey, assistantId } = request as {
|
|
131
|
-
assistantApiKey: string;
|
|
132
|
-
assistantId?: string;
|
|
133
|
-
};
|
|
148
|
+
this.handlers[CesRpcMethod.UpdateManagedCredential] = (request: unknown) => {
|
|
149
|
+
const { assistantApiKey, assistantId } = request as { assistantApiKey: string; assistantId?: string };
|
|
134
150
|
onUpdate(assistantApiKey, assistantId);
|
|
135
151
|
return { updated: true };
|
|
136
152
|
};
|
|
@@ -160,8 +176,7 @@ export class CesRpcServer {
|
|
|
160
176
|
|
|
161
177
|
this.input.on("data", (chunk: Buffer | string) => {
|
|
162
178
|
if (this.closed) return;
|
|
163
|
-
this.buffer +=
|
|
164
|
-
typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
179
|
+
this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
165
180
|
this.processBuffer();
|
|
166
181
|
});
|
|
167
182
|
|
|
@@ -198,9 +213,7 @@ export class CesRpcServer {
|
|
|
198
213
|
if (this.closed) return;
|
|
199
214
|
this.closed = true;
|
|
200
215
|
this.input.destroy();
|
|
201
|
-
if (
|
|
202
|
-
typeof (this.output as { destroy?: () => void }).destroy === "function"
|
|
203
|
-
) {
|
|
216
|
+
if (typeof (this.output as { destroy?: () => void }).destroy === "function") {
|
|
204
217
|
this.output.destroy();
|
|
205
218
|
}
|
|
206
219
|
}
|
|
@@ -242,9 +255,7 @@ export class CesRpcServer {
|
|
|
242
255
|
|
|
243
256
|
if (msg.type === "handshake_request") {
|
|
244
257
|
if (this.handshakeComplete) {
|
|
245
|
-
this.logger.warn(
|
|
246
|
-
"[ces-server] Duplicate handshake_request after session established; rejecting",
|
|
247
|
-
);
|
|
258
|
+
this.logger.warn("[ces-server] Duplicate handshake_request after session established; rejecting");
|
|
248
259
|
const ack: HandshakeAck = {
|
|
249
260
|
type: "handshake_ack",
|
|
250
261
|
protocolVersion: CES_PROTOCOL_VERSION,
|
|
@@ -258,9 +269,7 @@ export class CesRpcServer {
|
|
|
258
269
|
this.handleHandshake(msg as HandshakeRequest);
|
|
259
270
|
} else if (msg.type === "rpc") {
|
|
260
271
|
this.handleRpcEnvelope(msg as unknown as RpcEnvelope).catch((err) => {
|
|
261
|
-
this.logger.error(
|
|
262
|
-
`[ces-server] Unhandled error in RPC handler: ${err}`,
|
|
263
|
-
);
|
|
272
|
+
this.logger.error(`[ces-server] Unhandled error in RPC handler: ${err}`);
|
|
264
273
|
});
|
|
265
274
|
} else {
|
|
266
275
|
this.logger.warn("[ces-server] Unexpected message type:", msg.type);
|
|
@@ -274,22 +283,14 @@ export class CesRpcServer {
|
|
|
274
283
|
protocolVersion: CES_PROTOCOL_VERSION,
|
|
275
284
|
sessionId: req.sessionId,
|
|
276
285
|
accepted,
|
|
277
|
-
...(accepted
|
|
278
|
-
? {}
|
|
279
|
-
: { reason: `Unsupported protocol version: ${req.protocolVersion}` }),
|
|
286
|
+
...(accepted ? {} : { reason: `Unsupported protocol version: ${req.protocolVersion}` }),
|
|
280
287
|
};
|
|
281
288
|
|
|
282
289
|
if (accepted) {
|
|
283
290
|
this.handshakeComplete = true;
|
|
284
291
|
this.sessionContext.sessionId = req.sessionId;
|
|
285
|
-
this.logger.log(
|
|
286
|
-
|
|
287
|
-
);
|
|
288
|
-
this.onHandshakeComplete?.(
|
|
289
|
-
req.sessionId,
|
|
290
|
-
req.assistantApiKey,
|
|
291
|
-
req.assistantId,
|
|
292
|
-
);
|
|
292
|
+
this.logger.log(`[ces-server] Handshake accepted for session ${req.sessionId}`);
|
|
293
|
+
this.onHandshakeComplete?.(req.sessionId, req.assistantApiKey, req.assistantId);
|
|
293
294
|
} else {
|
|
294
295
|
this.logger.warn(
|
|
295
296
|
`[ces-server] Handshake rejected: version mismatch (got ${req.protocolVersion}, expected ${CES_PROTOCOL_VERSION})`,
|
|
@@ -302,11 +303,7 @@ export class CesRpcServer {
|
|
|
302
303
|
private async handleRpcEnvelope(envelope: RpcEnvelope): Promise<void> {
|
|
303
304
|
if (!this.handshakeComplete) {
|
|
304
305
|
this.logger.warn("[ces-server] RPC received before handshake; ignoring");
|
|
305
|
-
this.sendRpcError(
|
|
306
|
-
envelope,
|
|
307
|
-
"HANDSHAKE_REQUIRED",
|
|
308
|
-
"Handshake not completed",
|
|
309
|
-
);
|
|
306
|
+
this.sendRpcError(envelope, "HANDSHAKE_REQUIRED", "Handshake not completed");
|
|
310
307
|
return;
|
|
311
308
|
}
|
|
312
309
|
|
|
@@ -319,11 +316,7 @@ export class CesRpcServer {
|
|
|
319
316
|
const handler = this.handlers[method];
|
|
320
317
|
|
|
321
318
|
if (!handler) {
|
|
322
|
-
this.sendRpcError(
|
|
323
|
-
envelope,
|
|
324
|
-
"METHOD_NOT_FOUND",
|
|
325
|
-
`Unknown method: ${method}`,
|
|
326
|
-
);
|
|
319
|
+
this.sendRpcError(envelope, "METHOD_NOT_FOUND", `Unknown method: ${method}`);
|
|
327
320
|
return;
|
|
328
321
|
}
|
|
329
322
|
|
|
@@ -391,6 +384,47 @@ export class CesRpcServer {
|
|
|
391
384
|
}
|
|
392
385
|
}
|
|
393
386
|
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Handler factory: make_authenticated_request
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Create a handler function for the `make_authenticated_request` RPC method.
|
|
393
|
+
*
|
|
394
|
+
* Binds the executor to the provided dependencies so it can be registered in
|
|
395
|
+
* the RPC handler registry. The per-connection session ID is merged in from
|
|
396
|
+
* the SessionContext at call time (for audit attribution); all other deps —
|
|
397
|
+
* including the managed subject/materializer options — are taken as supplied.
|
|
398
|
+
*/
|
|
399
|
+
export function createMakeAuthenticatedRequestHandler(
|
|
400
|
+
deps: HttpExecutorDeps,
|
|
401
|
+
): RpcMethodHandler {
|
|
402
|
+
return async (request: unknown, ctx: SessionContext) => {
|
|
403
|
+
return executeAuthenticatedHttpRequest(request as MakeAuthenticatedRequest, {
|
|
404
|
+
...deps,
|
|
405
|
+
sessionId: ctx.sessionId,
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Build an RPC handler registry that includes the `make_authenticated_request`
|
|
412
|
+
* handler alongside any additional handlers.
|
|
413
|
+
*
|
|
414
|
+
* This is a convenience helper for callers that want to wire up the HTTP
|
|
415
|
+
* executor without manually constructing the registry.
|
|
416
|
+
*/
|
|
417
|
+
export function buildHandlersWithHttp(
|
|
418
|
+
httpDeps: HttpExecutorDeps,
|
|
419
|
+
additionalHandlers?: RpcHandlerRegistry,
|
|
420
|
+
): RpcHandlerRegistry {
|
|
421
|
+
return {
|
|
422
|
+
...additionalHandlers,
|
|
423
|
+
[CesRpcMethod.MakeAuthenticatedRequest]:
|
|
424
|
+
createMakeAuthenticatedRequestHandler(httpDeps),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
394
428
|
// ---------------------------------------------------------------------------
|
|
395
429
|
// Factory helper
|
|
396
430
|
// ---------------------------------------------------------------------------
|
|
@@ -404,3 +438,386 @@ export class CesRpcServer {
|
|
|
404
438
|
export function createCesServer(options: CesServerOptions): CesRpcServer {
|
|
405
439
|
return new CesRpcServer(options);
|
|
406
440
|
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// run_authenticated_command handler factory
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Options for creating the `run_authenticated_command` RPC handler.
|
|
448
|
+
*/
|
|
449
|
+
export interface RunAuthenticatedCommandHandlerOptions {
|
|
450
|
+
/** Dependencies for the command executor. */
|
|
451
|
+
executorDeps: CommandExecutorDeps;
|
|
452
|
+
/**
|
|
453
|
+
* Default workspace directory for commands that don't specify one.
|
|
454
|
+
* Typically the assistant's workspace root.
|
|
455
|
+
*/
|
|
456
|
+
defaultWorkspaceDir: string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Create an RPC handler for the `run_authenticated_command` method.
|
|
461
|
+
*
|
|
462
|
+
* This handler:
|
|
463
|
+
* 1. Parses the `command` string into a bundleDigest, profileName, and argv.
|
|
464
|
+
* The expected format is: `<bundleDigest>/<profileName> <argv...>`
|
|
465
|
+
* 2. Delegates to `executeAuthenticatedCommand` for the full security pipeline.
|
|
466
|
+
* 3. Returns a `RunAuthenticatedCommandResponse` with the execution result.
|
|
467
|
+
*
|
|
468
|
+
* If the command string doesn't match the expected format (i.e. it's a
|
|
469
|
+
* plain shell command), the handler returns a structured error since only
|
|
470
|
+
* manifest-driven secure commands are supported.
|
|
471
|
+
*/
|
|
472
|
+
export function createRunAuthenticatedCommandHandler(
|
|
473
|
+
options: RunAuthenticatedCommandHandlerOptions,
|
|
474
|
+
): RpcMethodHandler<RunAuthenticatedCommand, RunAuthenticatedCommandResponse> {
|
|
475
|
+
return async (request, ctx) => {
|
|
476
|
+
// Parse the command string into bundle-digest/profile and argv
|
|
477
|
+
const parseResult = parseCommandString(request.command);
|
|
478
|
+
if (!parseResult.ok) {
|
|
479
|
+
return {
|
|
480
|
+
success: false,
|
|
481
|
+
error: { code: "INVALID_COMMAND", message: parseResult.error },
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Validate cwd when inputs or outputs are present — the workspace
|
|
486
|
+
// staging/copyback pipeline resolves paths relative to workspaceDir,
|
|
487
|
+
// so an unvalidated cwd could let a caller read/write outside the
|
|
488
|
+
// assistant workspace.
|
|
489
|
+
const workspaceDir = request.cwd ?? options.defaultWorkspaceDir;
|
|
490
|
+
const hasWorkspaceIO =
|
|
491
|
+
(request.inputs && request.inputs.length > 0) ||
|
|
492
|
+
(request.outputs && request.outputs.length > 0);
|
|
493
|
+
|
|
494
|
+
if (hasWorkspaceIO && request.cwd) {
|
|
495
|
+
const resolvedCwd = resolve(request.cwd);
|
|
496
|
+
const cwdError = validateContainedPath(
|
|
497
|
+
resolvedCwd,
|
|
498
|
+
options.defaultWorkspaceDir,
|
|
499
|
+
"Command cwd",
|
|
500
|
+
);
|
|
501
|
+
if (cwdError) {
|
|
502
|
+
return {
|
|
503
|
+
success: false,
|
|
504
|
+
error: {
|
|
505
|
+
code: "INVALID_CWD",
|
|
506
|
+
message:
|
|
507
|
+
`cwd cannot be used with inputs/outputs when it resolves outside ` +
|
|
508
|
+
`the workspace directory: ${cwdError}`,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const execRequest: ExecuteCommandRequest = {
|
|
515
|
+
bundleDigest: parseResult.bundleDigest,
|
|
516
|
+
profileName: parseResult.profileName,
|
|
517
|
+
credentialHandle: request.credentialHandle,
|
|
518
|
+
argv: parseResult.argv,
|
|
519
|
+
workspaceDir,
|
|
520
|
+
inputs: request.inputs,
|
|
521
|
+
outputs: request.outputs,
|
|
522
|
+
purpose: request.purpose,
|
|
523
|
+
grantId: request.grantId,
|
|
524
|
+
conversationId: request.conversationId,
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// Bind the per-connection session ID (for audit attribution) into the
|
|
528
|
+
// executor deps for this call.
|
|
529
|
+
const result = await executeAuthenticatedCommand(execRequest, {
|
|
530
|
+
...options.executorDeps,
|
|
531
|
+
sessionId: ctx.sessionId,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// If the failure was due to a missing grant, return a structured
|
|
535
|
+
// APPROVAL_REQUIRED response with the proposal so the approval
|
|
536
|
+
// bridge can activate.
|
|
537
|
+
if (!result.success && result.approvalRequired) {
|
|
538
|
+
const { credentialHandle, bundleDigest, profileName, command, purpose } =
|
|
539
|
+
result.approvalRequired;
|
|
540
|
+
|
|
541
|
+
const proposal: CommandGrantProposal = {
|
|
542
|
+
type: "command",
|
|
543
|
+
credentialHandle,
|
|
544
|
+
command,
|
|
545
|
+
purpose,
|
|
546
|
+
allowedCommandPatterns: [`${credentialHandle}:${bundleDigest}:${profileName}`],
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
success: false,
|
|
551
|
+
error: {
|
|
552
|
+
code: "APPROVAL_REQUIRED",
|
|
553
|
+
message: `No active grant covers this command. Approval is required.`,
|
|
554
|
+
details: {
|
|
555
|
+
proposal,
|
|
556
|
+
proposalHash: hashProposal(proposal),
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
auditId: result.auditId,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
success: result.success,
|
|
565
|
+
exitCode: result.exitCode,
|
|
566
|
+
stdout: result.stdout,
|
|
567
|
+
stderr: result.stderr,
|
|
568
|
+
error: result.error
|
|
569
|
+
? { code: "EXECUTION_ERROR", message: result.error }
|
|
570
|
+
: undefined,
|
|
571
|
+
auditId: result.auditId,
|
|
572
|
+
};
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Parse a CES command string into bundle digest, profile name, and argv.
|
|
578
|
+
*
|
|
579
|
+
* Expected format: `<bundleDigest>/<profileName> [argv...]`
|
|
580
|
+
*
|
|
581
|
+
* Examples:
|
|
582
|
+
* - `abc123def.../api-read api /repos/owner/repo --method GET`
|
|
583
|
+
* - `abc123def.../list-repos`
|
|
584
|
+
*/
|
|
585
|
+
function parseCommandString(
|
|
586
|
+
command: string,
|
|
587
|
+
): { ok: true; bundleDigest: string; profileName: string; argv: string[] }
|
|
588
|
+
| { ok: false; error: string } {
|
|
589
|
+
const trimmed = command.trim();
|
|
590
|
+
if (!trimmed) {
|
|
591
|
+
return { ok: false, error: "Command string is empty" };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Split on first space to separate the bundle/profile reference from argv
|
|
595
|
+
const firstSpaceIdx = trimmed.indexOf(" ");
|
|
596
|
+
const ref = firstSpaceIdx === -1 ? trimmed : trimmed.slice(0, firstSpaceIdx);
|
|
597
|
+
const argvStr = firstSpaceIdx === -1 ? "" : trimmed.slice(firstSpaceIdx + 1).trim();
|
|
598
|
+
|
|
599
|
+
// Parse the reference: <bundleDigest>/<profileName>
|
|
600
|
+
const slashIdx = ref.indexOf("/");
|
|
601
|
+
if (slashIdx === -1 || slashIdx === 0 || slashIdx === ref.length - 1) {
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
error: `Invalid command reference "${ref}". Expected format: "<bundleDigest>/<profileName> [argv...]"`,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const bundleDigest = ref.slice(0, slashIdx);
|
|
609
|
+
const profileName = ref.slice(slashIdx + 1);
|
|
610
|
+
|
|
611
|
+
// Parse argv — split on whitespace (simple tokenization)
|
|
612
|
+
const argv = argvStr ? argvStr.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
613
|
+
|
|
614
|
+
return { ok: true, bundleDigest, profileName, argv };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Convenience helper to register the `run_authenticated_command` handler
|
|
619
|
+
* into an RPC handler registry.
|
|
620
|
+
*/
|
|
621
|
+
export function registerCommandExecutionHandler(
|
|
622
|
+
registry: RpcHandlerRegistry,
|
|
623
|
+
options: RunAuthenticatedCommandHandlerOptions,
|
|
624
|
+
): void {
|
|
625
|
+
registry[CesRpcMethod.RunAuthenticatedCommand] =
|
|
626
|
+
createRunAuthenticatedCommandHandler(options) as RpcMethodHandler;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// manage_secure_command_tool handler factory
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Dependencies for the `manage_secure_command_tool` handler.
|
|
635
|
+
*/
|
|
636
|
+
export interface ManageSecureCommandToolHandlerDeps {
|
|
637
|
+
/**
|
|
638
|
+
* Download bundle bytes from the given HTTPS URL.
|
|
639
|
+
* Implementations should enforce size limits and timeouts.
|
|
640
|
+
*/
|
|
641
|
+
downloadBundle: (sourceUrl: string) => Promise<Buffer | Uint8Array>;
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Publish a bundle into the CES-private toolstore.
|
|
645
|
+
* Typically delegates to `publishBundle()` from `./toolstore/publish.js`.
|
|
646
|
+
*/
|
|
647
|
+
publishBundle: (request: import("./toolstore/publish.js").PublishRequest) => import("./toolstore/publish.js").PublishResult;
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Unregister/remove a tool from the tool registry by name.
|
|
651
|
+
* Returns true if the tool was found and removed.
|
|
652
|
+
*/
|
|
653
|
+
unregisterTool: (toolName: string) => boolean;
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Register a tool in the tool registry after successful publication.
|
|
657
|
+
* Called with the tool name, credential handle, description, and the
|
|
658
|
+
* bundle digest for runtime lookup.
|
|
659
|
+
*/
|
|
660
|
+
registerTool: (entry: {
|
|
661
|
+
toolName: string;
|
|
662
|
+
credentialHandle: string;
|
|
663
|
+
description: string;
|
|
664
|
+
bundleDigest: string;
|
|
665
|
+
}) => void;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Create an RPC handler for the `manage_secure_command_tool` method.
|
|
670
|
+
*
|
|
671
|
+
* This handler:
|
|
672
|
+
* 1. For "register" actions: validates required bundle metadata fields,
|
|
673
|
+
* downloads the bundle from `sourceUrl`, publishes it into the
|
|
674
|
+
* immutable toolstore with digest verification, and registers
|
|
675
|
+
* the tool entry.
|
|
676
|
+
* 2. For "unregister" actions: removes the tool from the registry.
|
|
677
|
+
*/
|
|
678
|
+
export function createManageSecureCommandToolHandler(
|
|
679
|
+
deps: ManageSecureCommandToolHandlerDeps,
|
|
680
|
+
): RpcMethodHandler<ManageSecureCommandTool, ManageSecureCommandToolResponse> {
|
|
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> => {
|
|
694
|
+
if (request.action === "unregister") {
|
|
695
|
+
const removed = deps.unregisterTool(request.toolName);
|
|
696
|
+
if (!removed) {
|
|
697
|
+
return {
|
|
698
|
+
success: false,
|
|
699
|
+
error: {
|
|
700
|
+
code: "TOOL_NOT_FOUND",
|
|
701
|
+
message: `Tool "${request.toolName}" is not registered.`,
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
return { success: true };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// action === "register"
|
|
709
|
+
const missing: string[] = [];
|
|
710
|
+
if (!request.bundleId) missing.push("bundleId");
|
|
711
|
+
if (!request.version) missing.push("version");
|
|
712
|
+
if (!request.sourceUrl) missing.push("sourceUrl");
|
|
713
|
+
if (!request.sha256) missing.push("sha256");
|
|
714
|
+
if (!request.credentialHandle) missing.push("credentialHandle");
|
|
715
|
+
if (!request.description) missing.push("description");
|
|
716
|
+
if (!request.secureCommandManifest) missing.push("secureCommandManifest");
|
|
717
|
+
if (missing.length > 0) {
|
|
718
|
+
return {
|
|
719
|
+
success: false,
|
|
720
|
+
error: {
|
|
721
|
+
code: "MISSING_FIELDS",
|
|
722
|
+
message: `Register action requires: ${missing.join(", ")}`,
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Validate HTTPS before downloading — CES is the security boundary
|
|
728
|
+
// and must not rely on the caller for URL scheme validation.
|
|
729
|
+
try {
|
|
730
|
+
const parsed = new URL(request.sourceUrl!);
|
|
731
|
+
if (parsed.protocol !== "https:") {
|
|
732
|
+
return {
|
|
733
|
+
success: false,
|
|
734
|
+
error: {
|
|
735
|
+
code: "INVALID_SOURCE_URL",
|
|
736
|
+
message: "sourceUrl must use HTTPS for secure bundle downloads.",
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
return {
|
|
742
|
+
success: false,
|
|
743
|
+
error: {
|
|
744
|
+
code: "INVALID_SOURCE_URL",
|
|
745
|
+
message: "sourceUrl is not a valid URL.",
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Download the bundle
|
|
751
|
+
let bundleBytes: Buffer | Uint8Array;
|
|
752
|
+
try {
|
|
753
|
+
bundleBytes = await deps.downloadBundle(request.sourceUrl!);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
return {
|
|
756
|
+
success: false,
|
|
757
|
+
error: {
|
|
758
|
+
code: "DOWNLOAD_FAILED",
|
|
759
|
+
message: `Failed to download bundle from ${request.sourceUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
760
|
+
},
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// The caller provides the full secure command manifest via the RPC
|
|
765
|
+
// payload. Cast to the internal type — publishBundle() validates it.
|
|
766
|
+
const secureCommandManifest =
|
|
767
|
+
request.secureCommandManifest as unknown as import("./commands/profiles.js").SecureCommandManifest;
|
|
768
|
+
|
|
769
|
+
// Publish into the immutable toolstore (includes digest verification)
|
|
770
|
+
const publishResult = deps.publishBundle({
|
|
771
|
+
bundleBytes,
|
|
772
|
+
expectedDigest: request.sha256!,
|
|
773
|
+
bundleId: request.bundleId!,
|
|
774
|
+
version: request.version!,
|
|
775
|
+
sourceUrl: request.sourceUrl!,
|
|
776
|
+
secureCommandManifest,
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
if (!publishResult.success) {
|
|
780
|
+
return {
|
|
781
|
+
success: false,
|
|
782
|
+
error: {
|
|
783
|
+
code: "PUBLISH_FAILED",
|
|
784
|
+
message: publishResult.error ?? "Unknown publish error",
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Register the tool entry for runtime lookup
|
|
790
|
+
deps.registerTool({
|
|
791
|
+
toolName: request.toolName,
|
|
792
|
+
credentialHandle: request.credentialHandle!,
|
|
793
|
+
description: request.description!,
|
|
794
|
+
bundleDigest: request.sha256!,
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
return { success: true };
|
|
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
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Convenience helper to register the `manage_secure_command_tool` handler
|
|
815
|
+
* into an RPC handler registry.
|
|
816
|
+
*/
|
|
817
|
+
export function registerManageSecureCommandToolHandler(
|
|
818
|
+
registry: RpcHandlerRegistry,
|
|
819
|
+
deps: ManageSecureCommandToolHandlerDeps,
|
|
820
|
+
): void {
|
|
821
|
+
registry[CesRpcMethod.ManageSecureCommandTool] =
|
|
822
|
+
createManageSecureCommandToolHandler(deps) as RpcMethodHandler;
|
|
823
|
+
}
|