@vellumai/assistant 0.5.3 → 0.5.5

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 (111) hide show
  1. package/Dockerfile +18 -27
  2. package/docs/architecture/memory.md +105 -0
  3. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  4. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/archive-recall.test.ts +560 -0
  7. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  8. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  11. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  12. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  13. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  14. package/src/__tests__/memory-reducer.test.ts +7 -1
  15. package/src/__tests__/memory-regressions.test.ts +24 -4
  16. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  17. package/src/__tests__/openai-whisper.test.ts +93 -0
  18. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  19. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  20. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  21. package/src/__tests__/volume-security-guard.test.ts +155 -0
  22. package/src/cli/commands/conversations.ts +18 -0
  23. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  24. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  25. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  26. package/src/config/env-registry.ts +9 -0
  27. package/src/config/feature-flag-registry.json +8 -0
  28. package/src/config/loader.ts +0 -1
  29. package/src/config/schemas/memory-simplified.ts +1 -1
  30. package/src/credential-execution/managed-catalog.ts +5 -15
  31. package/src/daemon/config-watcher.ts +4 -1
  32. package/src/daemon/conversation-memory.ts +117 -0
  33. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  34. package/src/daemon/daemon-control.ts +7 -0
  35. package/src/daemon/handlers/conversations.ts +11 -0
  36. package/src/daemon/lifecycle.ts +51 -2
  37. package/src/daemon/providers-setup.ts +2 -1
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/archive-recall.ts +516 -0
  41. package/src/memory/brief-time.ts +5 -4
  42. package/src/memory/conversation-crud.ts +210 -0
  43. package/src/memory/conversation-key-store.ts +33 -4
  44. package/src/memory/db-init.ts +4 -0
  45. package/src/memory/embedding-local.ts +11 -5
  46. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  47. package/src/memory/job-handlers/conversation-starters.ts +24 -30
  48. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  49. package/src/memory/jobs-store.ts +2 -0
  50. package/src/memory/jobs-worker.ts +8 -0
  51. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  52. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  53. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  54. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  55. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  56. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  57. package/src/memory/migrations/index.ts +1 -0
  58. package/src/memory/reducer-scheduler.ts +242 -0
  59. package/src/memory/reducer-types.ts +9 -2
  60. package/src/memory/reducer.ts +25 -11
  61. package/src/memory/schema/infrastructure.ts +1 -0
  62. package/src/messaging/provider.ts +9 -0
  63. package/src/messaging/providers/slack/adapter.ts +29 -2
  64. package/src/oauth/connection-resolver.test.ts +22 -18
  65. package/src/oauth/connection-resolver.ts +92 -7
  66. package/src/oauth/platform-connection.test.ts +78 -69
  67. package/src/oauth/platform-connection.ts +12 -19
  68. package/src/permissions/trust-client.ts +343 -0
  69. package/src/permissions/trust-store-interface.ts +105 -0
  70. package/src/permissions/trust-store.ts +523 -36
  71. package/src/platform/client.test.ts +148 -0
  72. package/src/platform/client.ts +71 -0
  73. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  74. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  75. package/src/providers/speech-to-text/resolve.ts +9 -0
  76. package/src/providers/speech-to-text/types.ts +17 -0
  77. package/src/runtime/auth/route-policy.ts +10 -1
  78. package/src/runtime/http-server.ts +2 -2
  79. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  80. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  81. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  82. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  83. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  84. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  85. package/src/runtime/routes/log-export-routes.ts +1 -0
  86. package/src/runtime/routes/secret-routes.ts +5 -1
  87. package/src/schedule/schedule-store.ts +7 -0
  88. package/src/schedule/scheduler.ts +6 -2
  89. package/src/security/ces-credential-client.ts +173 -0
  90. package/src/security/secure-keys.ts +65 -22
  91. package/src/signals/bash.ts +3 -0
  92. package/src/signals/cancel.ts +3 -0
  93. package/src/signals/confirm.ts +3 -0
  94. package/src/signals/conversation-undo.ts +3 -0
  95. package/src/signals/event-stream.ts +7 -0
  96. package/src/signals/shotgun.ts +3 -0
  97. package/src/signals/trust-rule.ts +3 -0
  98. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  99. package/src/telemetry/usage-telemetry-reporter.ts +22 -20
  100. package/src/tools/filesystem/edit.ts +6 -1
  101. package/src/tools/filesystem/read.ts +6 -1
  102. package/src/tools/filesystem/write.ts +6 -1
  103. package/src/tools/memory/handlers.ts +129 -1
  104. package/src/tools/schedule/create.ts +3 -0
  105. package/src/tools/schedule/list.ts +5 -1
  106. package/src/tools/schedule/update.ts +6 -0
  107. package/src/util/device-id.ts +70 -7
  108. package/src/util/logger.ts +35 -9
  109. package/src/util/platform.ts +29 -5
  110. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  111. package/src/workspace/migrations/registry.ts +2 -0
@@ -1,5 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { VellumPlatformClient } from "../platform/client.js";
3
4
  import { BackendError, VellumError } from "../util/errors.js";
4
5
  import {
5
6
  CredentialRequiredError,
@@ -7,40 +8,53 @@ import {
7
8
  ProviderUnreachableError,
8
9
  } from "./platform-connection.js";
9
10
 
11
+ function makeMockClient(
12
+ fetchImpl?: typeof globalThis.fetch,
13
+ ): VellumPlatformClient {
14
+ const mockFetchFn =
15
+ fetchImpl ??
16
+ (mock(async () => {
17
+ return new Response(
18
+ JSON.stringify({ status: 200, headers: {}, body: null }),
19
+ { status: 200 },
20
+ );
21
+ }) as unknown as typeof globalThis.fetch);
22
+
23
+ return {
24
+ baseUrl: "https://platform.example.com",
25
+ assistantApiKey: "test-api-key",
26
+ platformAssistantId: "asst-abc",
27
+ fetch: mock(async (path: string, init?: RequestInit) => {
28
+ const url = `https://platform.example.com${path}`;
29
+ const headers = new Headers(init?.headers);
30
+ headers.set("Authorization", "Api-Key test-api-key");
31
+ return mockFetchFn(url, { ...init, headers });
32
+ }),
33
+ } as unknown as VellumPlatformClient;
34
+ }
35
+
10
36
  const DEFAULT_OPTIONS = {
11
37
  id: "conn-1",
12
38
  providerKey: "integration:google",
13
39
  externalId: "ext-123",
14
40
  accountInfo: "user@example.com",
15
- assistantId: "asst-abc",
16
- platformBaseUrl: "https://platform.example.com",
17
- apiKey: "test-api-key",
41
+ client: makeMockClient(),
42
+ connectionId: "platform-conn-123",
18
43
  };
19
44
 
20
45
  describe("PlatformOAuthConnection", () => {
21
- let originalFetch: typeof globalThis.fetch;
22
-
23
- beforeEach(() => {
24
- originalFetch = globalThis.fetch;
25
- });
26
-
27
- afterEach(() => {
28
- globalThis.fetch = originalFetch;
29
- });
30
-
31
46
  test("successful proxied request", async () => {
32
47
  const upstreamBody = { messages: [{ id: "msg-1", snippet: "Hello" }] };
33
48
 
34
- globalThis.fetch = mock(
35
- async (url: string | URL | Request, init?: RequestInit) => {
36
- expect(url).toBe(
37
- "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/google/",
49
+ const client = makeMockClient(
50
+ mock(async (url: string | URL | Request, init?: RequestInit) => {
51
+ expect(String(url)).toBe(
52
+ "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/platform-conn-123/",
38
53
  );
39
54
  expect(init?.method).toBe("POST");
40
- expect(init?.headers).toEqual({
41
- Authorization: "Api-Key test-api-key",
42
- "Content-Type": "application/json",
43
- });
55
+ const headers = new Headers(init?.headers);
56
+ expect(headers.get("Authorization")).toBe("Api-Key test-api-key");
57
+ expect(headers.get("Content-Type")).toBe("application/json");
44
58
 
45
59
  const parsed = JSON.parse(init?.body as string);
46
60
  expect(parsed).toEqual({
@@ -61,10 +75,13 @@ describe("PlatformOAuthConnection", () => {
61
75
  }),
62
76
  { status: 200 },
63
77
  );
64
- },
65
- ) as unknown as typeof globalThis.fetch;
78
+ }) as unknown as typeof globalThis.fetch,
79
+ );
66
80
 
67
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
81
+ const conn = new PlatformOAuthConnection({
82
+ ...DEFAULT_OPTIONS,
83
+ client,
84
+ });
68
85
  const result = await conn.request({
69
86
  method: "GET",
70
87
  path: "/gmail/v1/users/me/messages",
@@ -77,8 +94,8 @@ describe("PlatformOAuthConnection", () => {
77
94
  });
78
95
 
79
96
  test("forwards baseUrl when provided", async () => {
80
- globalThis.fetch = mock(
81
- async (_url: string | URL | Request, init?: RequestInit) => {
97
+ const client = makeMockClient(
98
+ mock(async (_url: string | URL | Request, init?: RequestInit) => {
82
99
  const parsed = JSON.parse(init?.body as string);
83
100
  expect(parsed.request.baseUrl).toBe(
84
101
  "https://www.googleapis.com/calendar/v3",
@@ -88,10 +105,10 @@ describe("PlatformOAuthConnection", () => {
88
105
  JSON.stringify({ status: 200, headers: {}, body: {} }),
89
106
  { status: 200 },
90
107
  );
91
- },
92
- ) as unknown as typeof globalThis.fetch;
108
+ }) as unknown as typeof globalThis.fetch,
109
+ );
93
110
 
94
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
111
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
95
112
  await conn.request({
96
113
  method: "GET",
97
114
  path: "/calendars/primary/events",
@@ -100,8 +117,8 @@ describe("PlatformOAuthConnection", () => {
100
117
  });
101
118
 
102
119
  test("omits baseUrl from envelope when not provided", async () => {
103
- globalThis.fetch = mock(
104
- async (_url: string | URL | Request, init?: RequestInit) => {
120
+ const client = makeMockClient(
121
+ mock(async (_url: string | URL | Request, init?: RequestInit) => {
105
122
  const parsed = JSON.parse(init?.body as string);
106
123
  expect("baseUrl" in parsed.request).toBe(false);
107
124
 
@@ -109,10 +126,10 @@ describe("PlatformOAuthConnection", () => {
109
126
  JSON.stringify({ status: 200, headers: {}, body: null }),
110
127
  { status: 200 },
111
128
  );
112
- },
113
- ) as unknown as typeof globalThis.fetch;
129
+ }) as unknown as typeof globalThis.fetch,
130
+ );
114
131
 
115
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
132
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
116
133
  await conn.request({ method: "GET", path: "/some/path" });
117
134
  });
118
135
 
@@ -127,22 +144,26 @@ describe("PlatformOAuthConnection", () => {
127
144
  });
128
145
 
129
146
  test("424 response throws CredentialRequiredError", async () => {
130
- globalThis.fetch = mock(async () => {
131
- return new Response("", { status: 424 });
132
- }) as unknown as typeof globalThis.fetch;
147
+ const client = makeMockClient(
148
+ mock(
149
+ async () => new Response("", { status: 424 }),
150
+ ) as unknown as typeof globalThis.fetch,
151
+ );
133
152
 
134
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
153
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
135
154
  await expect(
136
155
  conn.request({ method: "GET", path: "/test" }),
137
156
  ).rejects.toThrow(CredentialRequiredError);
138
157
  });
139
158
 
140
159
  test("502 response throws ProviderUnreachableError", async () => {
141
- globalThis.fetch = mock(async () => {
142
- return new Response("", { status: 502 });
143
- }) as unknown as typeof globalThis.fetch;
160
+ const client = makeMockClient(
161
+ mock(
162
+ async () => new Response("", { status: 502 }),
163
+ ) as unknown as typeof globalThis.fetch,
164
+ );
144
165
 
145
- const conn = new PlatformOAuthConnection(DEFAULT_OPTIONS);
166
+ const conn = new PlatformOAuthConnection({ ...DEFAULT_OPTIONS, client });
146
167
  await expect(
147
168
  conn.request({ method: "GET", path: "/test" }),
148
169
  ).rejects.toThrow(ProviderUnreachableError);
@@ -155,36 +176,24 @@ describe("PlatformOAuthConnection", () => {
155
176
  );
156
177
  });
157
178
 
158
- test("strips trailing slash from platformBaseUrl to avoid double slashes", async () => {
159
- globalThis.fetch = mock(async (url: string | URL | Request) => {
160
- expect(String(url)).toBe(
161
- "https://platform.example.com/v1/assistants/asst-abc/external-provider-proxy/google/",
162
- );
163
- return new Response(
164
- JSON.stringify({ status: 200, headers: {}, body: null }),
165
- { status: 200 },
166
- );
167
- }) as unknown as typeof globalThis.fetch;
168
-
169
- const conn = new PlatformOAuthConnection({
170
- ...DEFAULT_OPTIONS,
171
- platformBaseUrl: "https://platform.example.com/",
172
- });
173
- await conn.request({ method: "GET", path: "/test" });
174
- });
175
-
176
- test("strips integration: prefix from providerKey for slug", async () => {
177
- globalThis.fetch = mock(async (url: string | URL | Request) => {
178
- expect(String(url)).toContain("/external-provider-proxy/slack/");
179
- return new Response(
180
- JSON.stringify({ status: 200, headers: {}, body: null }),
181
- { status: 200 },
182
- );
183
- }) as unknown as typeof globalThis.fetch;
179
+ test("uses connectionId in proxy URL regardless of providerKey format", async () => {
180
+ const client = makeMockClient(
181
+ mock(async (url: string | URL | Request) => {
182
+ expect(String(url)).toContain(
183
+ "/external-provider-proxy/slack-conn-456/",
184
+ );
185
+ return new Response(
186
+ JSON.stringify({ status: 200, headers: {}, body: null }),
187
+ { status: 200 },
188
+ );
189
+ }) as unknown as typeof globalThis.fetch,
190
+ );
184
191
 
185
192
  const conn = new PlatformOAuthConnection({
186
193
  ...DEFAULT_OPTIONS,
194
+ client,
187
195
  providerKey: "integration:slack",
196
+ connectionId: "slack-conn-456",
188
197
  });
189
198
  await conn.request({ method: "GET", path: "/test" });
190
199
  });
@@ -1,3 +1,4 @@
1
+ import type { VellumPlatformClient } from "../platform/client.js";
1
2
  import { BackendError } from "../util/errors.js";
2
3
  import type {
3
4
  OAuthConnection,
@@ -24,9 +25,9 @@ export interface PlatformOAuthConnectionOptions {
24
25
  providerKey: string;
25
26
  externalId: string;
26
27
  accountInfo: string | null;
27
- assistantId: string;
28
- platformBaseUrl: string;
29
- apiKey: string;
28
+ client: VellumPlatformClient;
29
+ /** Platform-side connection ID used in the proxy URL path. */
30
+ connectionId: string;
30
31
  }
31
32
 
32
33
  export class PlatformOAuthConnection implements OAuthConnection {
@@ -35,18 +36,13 @@ export class PlatformOAuthConnection implements OAuthConnection {
35
36
  readonly externalId: string;
36
37
  readonly accountInfo: string | null;
37
38
 
38
- private readonly assistantId: string;
39
- private readonly platformBaseUrl: string;
40
- private readonly apiKey: string;
39
+ private readonly client: VellumPlatformClient;
40
+ private readonly connectionId: string;
41
41
 
42
42
  constructor(options: PlatformOAuthConnectionOptions) {
43
- const missing: string[] = [];
44
- if (!options.platformBaseUrl) missing.push("platform base URL");
45
- if (!options.apiKey) missing.push("assistant API key");
46
- if (!options.assistantId) missing.push("assistant ID");
47
- if (missing.length > 0) {
43
+ if (!options.connectionId) {
48
44
  throw new BackendError(
49
- `Platform-managed connection for "${options.providerKey}" cannot be created: missing ${missing.join(", ")}. ` +
45
+ `Platform-managed connection for "${options.providerKey}" cannot be created: missing connection ID. ` +
50
46
  `Log in to the Vellum platform or switch to using your own OAuth app.`,
51
47
  );
52
48
  }
@@ -55,14 +51,12 @@ export class PlatformOAuthConnection implements OAuthConnection {
55
51
  this.providerKey = options.providerKey;
56
52
  this.externalId = options.externalId;
57
53
  this.accountInfo = options.accountInfo;
58
- this.assistantId = options.assistantId;
59
- this.platformBaseUrl = options.platformBaseUrl.replace(/\/+$/, "");
60
- this.apiKey = options.apiKey;
54
+ this.client = options.client;
55
+ this.connectionId = options.connectionId;
61
56
  }
62
57
 
63
58
  async request(req: OAuthConnectionRequest): Promise<OAuthConnectionResponse> {
64
- const providerSlug = this.providerKey.replace(/^integration:/, "");
65
- const proxyUrl = `${this.platformBaseUrl}/v1/assistants/${this.assistantId}/external-provider-proxy/${providerSlug}/`;
59
+ const proxyPath = `/v1/assistants/${this.client.platformAssistantId}/external-provider-proxy/${this.connectionId}/`;
66
60
 
67
61
  const body: Record<string, unknown> = {
68
62
  request: {
@@ -75,10 +69,9 @@ export class PlatformOAuthConnection implements OAuthConnection {
75
69
  },
76
70
  };
77
71
 
78
- const response = await fetch(proxyUrl, {
72
+ const response = await this.client.fetch(proxyPath, {
79
73
  method: "POST",
80
74
  headers: {
81
- Authorization: `Api-Key ${this.apiKey}`,
82
75
  "Content-Type": "application/json",
83
76
  },
84
77
  body: JSON.stringify(body),
@@ -0,0 +1,343 @@
1
+ /**
2
+ * HTTP client for the gateway's trust rule endpoints.
3
+ *
4
+ * Provides CRUD operations over trust rules stored in the gateway,
5
+ * replacing direct filesystem access to trust.json when the assistant
6
+ * runs in a containerized environment.
7
+ *
8
+ * Both async and synchronous variants are exported. The sync variants
9
+ * use `Bun.spawnSync` + `curl` to make blocking HTTP calls — acceptable
10
+ * for user-initiated write operations that are infrequent.
11
+ */
12
+
13
+ import type { TrustRule } from "@vellumai/ces-contracts";
14
+
15
+ import { getGatewayInternalBaseUrl } from "../config/env.js";
16
+ import { mintDaemonDeliveryToken } from "../runtime/auth/token-service.js";
17
+ import { getLogger } from "../util/logger.js";
18
+
19
+ const log = getLogger("trust-client");
20
+
21
+ const REQUEST_TIMEOUT_MS = 10_000;
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Result types (not in ces-contracts — local to the client)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface AcceptStarterBundleResult {
28
+ accepted: boolean;
29
+ rulesAdded: number;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Resolve the gateway base URL for trust rule requests.
38
+ *
39
+ * Prefers the `GATEWAY_INTERNAL_URL` env var (set in Docker environments
40
+ * where the gateway runs in a separate container), falling back to the
41
+ * existing `getGatewayInternalBaseUrl()` helper for local deployments.
42
+ */
43
+ function getBaseUrl(): string {
44
+ return process.env.GATEWAY_INTERNAL_URL ?? getGatewayInternalBaseUrl();
45
+ }
46
+
47
+ function authHeaders(): Record<string, string> {
48
+ return {
49
+ Authorization: `Bearer ${mintDaemonDeliveryToken()}`,
50
+ "Content-Type": "application/json",
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Execute a fetch request with standard timeout and error handling.
56
+ * Throws a descriptive error on non-OK responses or network failures.
57
+ */
58
+ async function request<T>(
59
+ method: string,
60
+ path: string,
61
+ body?: unknown,
62
+ ): Promise<T> {
63
+ const url = `${getBaseUrl()}${path}`;
64
+ const options: RequestInit = {
65
+ method,
66
+ headers: authHeaders(),
67
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
68
+ };
69
+ if (body !== undefined) {
70
+ options.body = JSON.stringify(body);
71
+ }
72
+
73
+ let response: Response;
74
+ try {
75
+ response = await fetch(url, options);
76
+ } catch (err) {
77
+ log.error({ err, method, path }, "Trust rule request failed (network)");
78
+ throw new Error(
79
+ `Trust rule request failed: ${method} ${path}: ${err instanceof Error ? err.message : String(err)}`,
80
+ );
81
+ }
82
+
83
+ if (!response.ok) {
84
+ const text = await response.text().catch(() => "<unreadable>");
85
+ log.error(
86
+ { status: response.status, body: text, method, path },
87
+ "Trust rule request failed",
88
+ );
89
+ throw new Error(
90
+ `Trust rule request failed (${response.status}): ${method} ${path}: ${text}`,
91
+ );
92
+ }
93
+
94
+ return (await response.json()) as T;
95
+ }
96
+
97
+ /**
98
+ * Synchronous HTTP request via `Bun.spawnSync` + `curl`.
99
+ *
100
+ * Used by the gateway trust store adapter for write operations that must
101
+ * return synchronously to satisfy the `TrustStoreBackend` interface.
102
+ * Write operations are user-initiated and infrequent, so blocking is acceptable.
103
+ */
104
+ function requestSync<T>(method: string, path: string, body?: unknown): T {
105
+ const url = `${getBaseUrl()}${path}`;
106
+ const headers = authHeaders();
107
+ const args: string[] = [
108
+ "curl",
109
+ "-s",
110
+ "-S",
111
+ "-X",
112
+ method,
113
+ "--max-time",
114
+ String(Math.ceil(REQUEST_TIMEOUT_MS / 1000)),
115
+ "-H",
116
+ `Authorization: ${headers.Authorization}`,
117
+ "-H",
118
+ "Content-Type: application/json",
119
+ "-w",
120
+ "\n%{http_code}",
121
+ ];
122
+ if (body !== undefined) {
123
+ args.push("-d", JSON.stringify(body));
124
+ }
125
+ args.push(url);
126
+
127
+ const proc = Bun.spawnSync(args, {
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ });
131
+
132
+ if (proc.exitCode !== 0) {
133
+ const stderr = proc.stderr.toString().trim();
134
+ log.error(
135
+ { exitCode: proc.exitCode, stderr, method, path },
136
+ "Trust rule sync request failed (curl)",
137
+ );
138
+ throw new Error(
139
+ `Trust rule sync request failed: ${method} ${path}: curl exit ${proc.exitCode}: ${stderr}`,
140
+ );
141
+ }
142
+
143
+ const output = proc.stdout.toString().trim();
144
+ // curl -w "\n%{http_code}" appends the HTTP status code on the last line
145
+ const lastNewline = output.lastIndexOf("\n");
146
+ const responseBody = lastNewline >= 0 ? output.slice(0, lastNewline) : "";
147
+ const statusCode = parseInt(
148
+ lastNewline >= 0 ? output.slice(lastNewline + 1) : output,
149
+ 10,
150
+ );
151
+
152
+ if (statusCode < 200 || statusCode >= 300) {
153
+ log.error(
154
+ { status: statusCode, body: responseBody, method, path },
155
+ "Trust rule sync request failed",
156
+ );
157
+ throw new Error(
158
+ `Trust rule sync request failed (${statusCode}): ${method} ${path}: ${responseBody}`,
159
+ );
160
+ }
161
+
162
+ if (!responseBody) {
163
+ return {} as T;
164
+ }
165
+
166
+ try {
167
+ return JSON.parse(responseBody) as T;
168
+ } catch (err) {
169
+ log.error(
170
+ { err, responseBody, method, path },
171
+ "Failed to parse sync response JSON",
172
+ );
173
+ throw new Error(
174
+ `Trust rule sync request: failed to parse response: ${method} ${path}`,
175
+ );
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Public API
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** Fetch all trust rules from the gateway. */
184
+ export async function getAllRules(): Promise<TrustRule[]> {
185
+ const data = await request<{ rules: TrustRule[] }>("GET", "/v1/trust-rules");
186
+ return data.rules;
187
+ }
188
+
189
+ /** Create a new trust rule. */
190
+ export async function addRule(params: {
191
+ tool: string;
192
+ pattern: string;
193
+ scope: string;
194
+ decision?: TrustRule["decision"];
195
+ priority?: number;
196
+ allowHighRisk?: boolean;
197
+ executionTarget?: string;
198
+ }): Promise<TrustRule> {
199
+ const data = await request<{ rule: TrustRule }>(
200
+ "POST",
201
+ "/v1/trust-rules",
202
+ params,
203
+ );
204
+ return data.rule;
205
+ }
206
+
207
+ /** Update an existing trust rule by ID. */
208
+ export async function updateRule(
209
+ id: string,
210
+ updates: {
211
+ tool?: string;
212
+ pattern?: string;
213
+ scope?: string;
214
+ decision?: TrustRule["decision"];
215
+ priority?: number;
216
+ allowHighRisk?: boolean;
217
+ executionTarget?: string;
218
+ },
219
+ ): Promise<TrustRule> {
220
+ const data = await request<{ rule: TrustRule }>(
221
+ "PATCH",
222
+ `/v1/trust-rules/${encodeURIComponent(id)}`,
223
+ updates,
224
+ );
225
+ return data.rule;
226
+ }
227
+
228
+ /** Remove a trust rule by ID. Returns true if the rule was found and deleted. */
229
+ export async function removeRule(id: string): Promise<boolean> {
230
+ const data = await request<{ success: boolean }>(
231
+ "DELETE",
232
+ `/v1/trust-rules/${encodeURIComponent(id)}`,
233
+ );
234
+ return data.success;
235
+ }
236
+
237
+ /** Clear all user trust rules (default rules are preserved by the gateway). */
238
+ export async function clearRules(): Promise<void> {
239
+ await request<{ success: boolean }>("POST", "/v1/trust-rules/clear");
240
+ }
241
+
242
+ /**
243
+ * Find the highest-priority matching rule for a tool invocation.
244
+ *
245
+ * @param tool Tool name (e.g. "host_bash")
246
+ * @param candidates Command candidates to match against rule patterns
247
+ * @param scope Working directory scope
248
+ */
249
+ export async function findMatchingRule(
250
+ tool: string,
251
+ candidates: string[],
252
+ scope: string,
253
+ ): Promise<TrustRule | null> {
254
+ const params = new URLSearchParams({
255
+ tool,
256
+ commands: candidates.join(","),
257
+ scope,
258
+ });
259
+ const data = await request<{ rule: TrustRule | null }>(
260
+ "GET",
261
+ `/v1/trust-rules/match?${params.toString()}`,
262
+ );
263
+ return data.rule;
264
+ }
265
+
266
+ /** Accept the starter approval bundle, seeding common low-risk allow rules. */
267
+ export async function acceptStarterBundle(): Promise<AcceptStarterBundleResult> {
268
+ return request<AcceptStarterBundleResult>(
269
+ "POST",
270
+ "/v1/trust-rules/starter-bundle",
271
+ );
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Synchronous API — used by the gateway trust store adapter
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /** Fetch all trust rules from the gateway (synchronous). */
279
+ export function getAllRulesSync(): TrustRule[] {
280
+ const data = requestSync<{ rules: TrustRule[] }>("GET", "/v1/trust-rules");
281
+ return data.rules;
282
+ }
283
+
284
+ /** Create a new trust rule (synchronous). */
285
+ export function addRuleSync(params: {
286
+ tool: string;
287
+ pattern: string;
288
+ scope: string;
289
+ decision?: TrustRule["decision"];
290
+ priority?: number;
291
+ allowHighRisk?: boolean;
292
+ executionTarget?: string;
293
+ }): TrustRule {
294
+ const data = requestSync<{ rule: TrustRule }>(
295
+ "POST",
296
+ "/v1/trust-rules",
297
+ params,
298
+ );
299
+ return data.rule;
300
+ }
301
+
302
+ /** Update an existing trust rule by ID (synchronous). */
303
+ export function updateRuleSync(
304
+ id: string,
305
+ updates: {
306
+ tool?: string;
307
+ pattern?: string;
308
+ scope?: string;
309
+ decision?: TrustRule["decision"];
310
+ priority?: number;
311
+ allowHighRisk?: boolean;
312
+ executionTarget?: string;
313
+ },
314
+ ): TrustRule {
315
+ const data = requestSync<{ rule: TrustRule }>(
316
+ "PATCH",
317
+ `/v1/trust-rules/${encodeURIComponent(id)}`,
318
+ updates,
319
+ );
320
+ return data.rule;
321
+ }
322
+
323
+ /** Remove a trust rule by ID (synchronous). Returns true if deleted. */
324
+ export function removeRuleSync(id: string): boolean {
325
+ const data = requestSync<{ success: boolean }>(
326
+ "DELETE",
327
+ `/v1/trust-rules/${encodeURIComponent(id)}`,
328
+ );
329
+ return data.success;
330
+ }
331
+
332
+ /** Clear all user trust rules (synchronous). */
333
+ export function clearRulesSync(): void {
334
+ requestSync<{ success: boolean }>("POST", "/v1/trust-rules/clear");
335
+ }
336
+
337
+ /** Accept the starter approval bundle (synchronous). */
338
+ export function acceptStarterBundleSync(): AcceptStarterBundleResult {
339
+ return requestSync<AcceptStarterBundleResult>(
340
+ "POST",
341
+ "/v1/trust-rules/starter-bundle",
342
+ );
343
+ }