@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895

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 +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
package/src/paths.ts CHANGED
@@ -84,21 +84,6 @@ export function getCesDataRoot(mode?: CesMode): string {
84
84
  // Subdirectory layout
85
85
  // ---------------------------------------------------------------------------
86
86
 
87
- /** Directory for CES grant persistence. */
88
- export function getCesGrantsDir(mode?: CesMode): string {
89
- return join(getCesDataRoot(mode), "grants");
90
- }
91
-
92
- /** Directory for CES audit log persistence. */
93
- export function getCesAuditDir(mode?: CesMode): string {
94
- return join(getCesDataRoot(mode), "audit");
95
- }
96
-
97
- /** Directory for CES secure tool store (registered secure command tools). */
98
- export function getCesToolStoreDir(mode?: CesMode): string {
99
- return join(getCesDataRoot(mode), "toolstore");
100
- }
101
-
102
87
  /** Directory for CES log files. */
103
88
  export function getCesLogDir(mode?: CesMode): string {
104
89
  return join(getCesDataRoot(mode), "logs");
@@ -141,7 +126,7 @@ export function getBootstrapSocketPath(): string {
141
126
  }
142
127
 
143
128
  // ---------------------------------------------------------------------------
144
- // Local-mode standalone socket (temporary — CES_STANDALONE)
129
+ // Local-mode CES socket
145
130
  // ---------------------------------------------------------------------------
146
131
 
147
132
  /** Default local-mode CES socket filename (under the local data root). */
@@ -150,10 +135,9 @@ const LOCAL_SOCKET_NAME = "ces.sock";
150
135
  /**
151
136
  * Return the path to the local-mode CES Unix socket.
152
137
  *
153
- * Used when local CES runs as a standalone sibling (`CES_STANDALONE=1`, the
154
- * CLI-launched opt-in) rather than as the assistant's stdio
155
- * child. The socket lives under the CES-private local data root, whose
156
- * directory permissions are the access boundary.
138
+ * Used when local CES runs as a CLI-launched sibling process. The socket
139
+ * lives under the CES-private local data root, whose directory permissions
140
+ * are the access boundary.
157
141
  *
158
142
  * Priority:
159
143
  * 1. `CES_LOCAL_SOCKET` env var (full file path override; the CLI sets this
package/src/server.ts CHANGED
@@ -20,35 +20,13 @@ import {
20
20
  CES_PROTOCOL_VERSION,
21
21
  CesRpcMethod,
22
22
  CesRpcSchemas,
23
- hashProposal,
24
- type CommandGrantProposal,
25
23
  type HandshakeAck,
26
24
  type HandshakeRequest,
27
- type MakeAuthenticatedRequest,
28
- type ManageSecureCommandTool,
29
- type ManageSecureCommandToolResponse,
30
25
  type RpcEnvelope,
31
- type RunAuthenticatedCommand,
32
- type RunAuthenticatedCommandResponse,
33
26
  type TransportMessage,
34
27
  TransportMessageSchema,
35
28
  } from "@vellumai/service-contracts/credential-rpc";
36
29
 
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
-
52
30
  // ---------------------------------------------------------------------------
53
31
  // Types
54
32
  // ---------------------------------------------------------------------------
@@ -59,13 +37,8 @@ import {
59
37
  * Each accepted connection owns one `SessionContext`, created when the server
60
38
  * is constructed and populated with the negotiated session ID at handshake.
61
39
  * Handlers receive it as their second argument and read the session ID at call
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.
40
+ * time, so a handler registry shared across connections can attribute each call
41
+ * to the originating connection.
69
42
  */
70
43
  export interface SessionContext {
71
44
  /** The RPC session ID negotiated at handshake. */
@@ -86,9 +59,7 @@ export type RpcMethodHandler<TReq = unknown, TRes = unknown> = (
86
59
  /**
87
60
  * Registry of method name to handler function.
88
61
  */
89
- export type RpcHandlerRegistry = Partial<
90
- Record<string, RpcMethodHandler>
91
- >;
62
+ export type RpcHandlerRegistry = Partial<Record<string, RpcMethodHandler>>;
92
63
 
93
64
  export interface CesServerOptions {
94
65
  /** Readable stream to consume messages from. */
@@ -102,7 +73,11 @@ export interface CesServerOptions {
102
73
  /** Optional abort signal to shut down the server. */
103
74
  signal?: AbortSignal;
104
75
  /** Callback invoked when the handshake completes with the negotiated session ID and optional API key / assistant ID. */
105
- onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
76
+ onHandshakeComplete?: (
77
+ sessionId: string,
78
+ assistantApiKey?: string,
79
+ assistantId?: string,
80
+ ) => void;
106
81
  /** Callback invoked when the assistant pushes an updated API key (and optionally assistant ID) after hatch. */
107
82
  onApiKeyUpdate?: (assistantApiKey: string, assistantId?: string) => void;
108
83
  }
@@ -122,7 +97,11 @@ export class CesRpcServer {
122
97
  private readonly handlers: RpcHandlerRegistry;
123
98
  private readonly logger: Pick<Console, "log" | "warn" | "error">;
124
99
  private readonly signal?: AbortSignal;
125
- private readonly onHandshakeComplete?: (sessionId: string, assistantApiKey?: string, assistantId?: string) => void;
100
+ private readonly onHandshakeComplete?: (
101
+ sessionId: string,
102
+ assistantApiKey?: string,
103
+ assistantId?: string,
104
+ ) => void;
126
105
 
127
106
  private handshakeComplete = false;
128
107
  /**
@@ -145,8 +124,13 @@ export class CesRpcServer {
145
124
  // Auto-register the update_managed_credential handler if a callback is provided.
146
125
  if (options.onApiKeyUpdate) {
147
126
  const onUpdate = options.onApiKeyUpdate;
148
- this.handlers[CesRpcMethod.UpdateManagedCredential] = (request: unknown) => {
149
- const { assistantApiKey, assistantId } = request as { assistantApiKey: string; assistantId?: string };
127
+ this.handlers[CesRpcMethod.UpdateManagedCredential] = (
128
+ request: unknown,
129
+ ) => {
130
+ const { assistantApiKey, assistantId } = request as {
131
+ assistantApiKey: string;
132
+ assistantId?: string;
133
+ };
150
134
  onUpdate(assistantApiKey, assistantId);
151
135
  return { updated: true };
152
136
  };
@@ -176,7 +160,8 @@ export class CesRpcServer {
176
160
 
177
161
  this.input.on("data", (chunk: Buffer | string) => {
178
162
  if (this.closed) return;
179
- this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
163
+ this.buffer +=
164
+ typeof chunk === "string" ? chunk : chunk.toString("utf-8");
180
165
  this.processBuffer();
181
166
  });
182
167
 
@@ -213,7 +198,9 @@ export class CesRpcServer {
213
198
  if (this.closed) return;
214
199
  this.closed = true;
215
200
  this.input.destroy();
216
- if (typeof (this.output as { destroy?: () => void }).destroy === "function") {
201
+ if (
202
+ typeof (this.output as { destroy?: () => void }).destroy === "function"
203
+ ) {
217
204
  this.output.destroy();
218
205
  }
219
206
  }
@@ -255,7 +242,9 @@ export class CesRpcServer {
255
242
 
256
243
  if (msg.type === "handshake_request") {
257
244
  if (this.handshakeComplete) {
258
- this.logger.warn("[ces-server] Duplicate handshake_request after session established; rejecting");
245
+ this.logger.warn(
246
+ "[ces-server] Duplicate handshake_request after session established; rejecting",
247
+ );
259
248
  const ack: HandshakeAck = {
260
249
  type: "handshake_ack",
261
250
  protocolVersion: CES_PROTOCOL_VERSION,
@@ -269,7 +258,9 @@ export class CesRpcServer {
269
258
  this.handleHandshake(msg as HandshakeRequest);
270
259
  } else if (msg.type === "rpc") {
271
260
  this.handleRpcEnvelope(msg as unknown as RpcEnvelope).catch((err) => {
272
- this.logger.error(`[ces-server] Unhandled error in RPC handler: ${err}`);
261
+ this.logger.error(
262
+ `[ces-server] Unhandled error in RPC handler: ${err}`,
263
+ );
273
264
  });
274
265
  } else {
275
266
  this.logger.warn("[ces-server] Unexpected message type:", msg.type);
@@ -283,14 +274,22 @@ export class CesRpcServer {
283
274
  protocolVersion: CES_PROTOCOL_VERSION,
284
275
  sessionId: req.sessionId,
285
276
  accepted,
286
- ...(accepted ? {} : { reason: `Unsupported protocol version: ${req.protocolVersion}` }),
277
+ ...(accepted
278
+ ? {}
279
+ : { reason: `Unsupported protocol version: ${req.protocolVersion}` }),
287
280
  };
288
281
 
289
282
  if (accepted) {
290
283
  this.handshakeComplete = true;
291
284
  this.sessionContext.sessionId = req.sessionId;
292
- this.logger.log(`[ces-server] Handshake accepted for session ${req.sessionId}`);
293
- this.onHandshakeComplete?.(req.sessionId, req.assistantApiKey, req.assistantId);
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
+ );
294
293
  } else {
295
294
  this.logger.warn(
296
295
  `[ces-server] Handshake rejected: version mismatch (got ${req.protocolVersion}, expected ${CES_PROTOCOL_VERSION})`,
@@ -303,7 +302,11 @@ export class CesRpcServer {
303
302
  private async handleRpcEnvelope(envelope: RpcEnvelope): Promise<void> {
304
303
  if (!this.handshakeComplete) {
305
304
  this.logger.warn("[ces-server] RPC received before handshake; ignoring");
306
- this.sendRpcError(envelope, "HANDSHAKE_REQUIRED", "Handshake not completed");
305
+ this.sendRpcError(
306
+ envelope,
307
+ "HANDSHAKE_REQUIRED",
308
+ "Handshake not completed",
309
+ );
307
310
  return;
308
311
  }
309
312
 
@@ -316,7 +319,11 @@ export class CesRpcServer {
316
319
  const handler = this.handlers[method];
317
320
 
318
321
  if (!handler) {
319
- this.sendRpcError(envelope, "METHOD_NOT_FOUND", `Unknown method: ${method}`);
322
+ this.sendRpcError(
323
+ envelope,
324
+ "METHOD_NOT_FOUND",
325
+ `Unknown method: ${method}`,
326
+ );
320
327
  return;
321
328
  }
322
329
 
@@ -384,47 +391,6 @@ export class CesRpcServer {
384
391
  }
385
392
  }
386
393
 
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
-
428
394
  // ---------------------------------------------------------------------------
429
395
  // Factory helper
430
396
  // ---------------------------------------------------------------------------
@@ -438,386 +404,3 @@ export function buildHandlersWithHttp(
438
404
  export function createCesServer(options: CesServerOptions): CesRpcServer {
439
405
  return new CesRpcServer(options);
440
406
  }
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
- }