@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.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +2 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +686 -0
  5. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +5 -3
  6. package/node_modules/@vellumai/service-contracts/src/grants.ts +184 -0
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +4 -2
  8. package/node_modules/@vellumai/service-contracts/src/rendering.ts +135 -0
  9. package/node_modules/@vellumai/service-contracts/src/rpc.ts +447 -4
  10. package/package.json +3 -2
  11. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  12. package/src/__tests__/command-executor.test.ts +1879 -0
  13. package/src/__tests__/command-validator.test.ts +1405 -0
  14. package/src/__tests__/command-workspace.test.ts +1050 -0
  15. package/src/__tests__/grant-store.test.ts +689 -0
  16. package/src/__tests__/http-executor.test.ts +1336 -0
  17. package/src/__tests__/http-policy.test.ts +1069 -0
  18. package/src/__tests__/local-materializers.test.ts +860 -0
  19. package/src/__tests__/local-standalone.test.ts +36 -5
  20. package/src/__tests__/local-token-refresh.test.ts +361 -0
  21. package/src/__tests__/manage-secure-command-tool.test.ts +134 -0
  22. package/src/__tests__/managed-integration.test.ts +91 -112
  23. package/src/__tests__/managed-lazy-getters.test.ts +359 -0
  24. package/src/__tests__/managed-materializers.test.ts +1028 -0
  25. package/src/__tests__/managed-reconnect.test.ts +2 -2
  26. package/src/__tests__/managed-rejection.test.ts +43 -0
  27. package/src/__tests__/toolstore.test.ts +773 -0
  28. package/src/__tests__/transport.test.ts +27 -23
  29. package/src/audit/store.ts +188 -0
  30. package/src/cli.ts +1 -1
  31. package/src/commands/auth-adapters.ts +169 -0
  32. package/src/commands/egress-hooks.ts +203 -0
  33. package/src/commands/executor.ts +1155 -0
  34. package/src/commands/output-scan.ts +157 -0
  35. package/src/commands/profiles.ts +286 -0
  36. package/src/commands/validator.ts +702 -0
  37. package/src/commands/workspace.ts +550 -0
  38. package/src/grants/index.ts +17 -0
  39. package/src/grants/persistent-store.ts +309 -0
  40. package/src/grants/rpc-handlers.ts +293 -0
  41. package/src/grants/temporary-store.ts +289 -0
  42. package/src/http/audit.ts +84 -0
  43. package/src/http/executor.ts +684 -0
  44. package/src/http/path-template.ts +245 -0
  45. package/src/http/policy.ts +238 -0
  46. package/src/http/response-filter.ts +233 -0
  47. package/src/index.ts +88 -8
  48. package/src/main.ts +340 -228
  49. package/src/managed-errors.ts +9 -0
  50. package/src/managed-lazy-getters.ts +106 -0
  51. package/src/managed-main.ts +822 -0
  52. package/src/materializers/local-oauth-lookup.ts +98 -0
  53. package/src/materializers/local-token-refresh.ts +287 -0
  54. package/src/materializers/local.ts +316 -0
  55. package/src/materializers/managed-platform.ts +295 -0
  56. package/src/paths.ts +20 -4
  57. package/src/server.ts +469 -52
  58. package/src/subjects/local.ts +177 -0
  59. package/src/subjects/managed.ts +311 -0
  60. package/src/subjects/policy.ts +79 -0
  61. package/src/toolstore/integrity.ts +94 -0
  62. package/src/toolstore/manifest.ts +154 -0
  63. package/src/toolstore/publish.ts +571 -0
  64. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +0 -104
  65. 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, so a handler registry shared across connections can attribute each call
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<Record<string, RpcMethodHandler>>;
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: unknown,
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
- `[ces-server] Handshake accepted for session ${req.sessionId}`,
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
+ }