@vellumai/credential-executor 0.4.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/Dockerfile +55 -0
  2. package/bun.lock +37 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/command-executor.test.ts +1333 -0
  5. package/src/__tests__/command-validator.test.ts +708 -0
  6. package/src/__tests__/command-workspace.test.ts +997 -0
  7. package/src/__tests__/grant-store.test.ts +467 -0
  8. package/src/__tests__/http-executor.test.ts +1251 -0
  9. package/src/__tests__/http-policy.test.ts +970 -0
  10. package/src/__tests__/local-materializers.test.ts +826 -0
  11. package/src/__tests__/managed-materializers.test.ts +961 -0
  12. package/src/__tests__/toolstore.test.ts +539 -0
  13. package/src/__tests__/transport.test.ts +388 -0
  14. package/src/audit/store.ts +188 -0
  15. package/src/commands/auth-adapters.ts +169 -0
  16. package/src/commands/executor.ts +840 -0
  17. package/src/commands/output-scan.ts +157 -0
  18. package/src/commands/profiles.ts +282 -0
  19. package/src/commands/validator.ts +438 -0
  20. package/src/commands/workspace.ts +512 -0
  21. package/src/grants/index.ts +17 -0
  22. package/src/grants/persistent-store.ts +247 -0
  23. package/src/grants/rpc-handlers.ts +269 -0
  24. package/src/grants/temporary-store.ts +219 -0
  25. package/src/http/audit.ts +84 -0
  26. package/src/http/executor.ts +540 -0
  27. package/src/http/path-template.ts +179 -0
  28. package/src/http/policy.ts +256 -0
  29. package/src/http/response-filter.ts +233 -0
  30. package/src/index.ts +106 -0
  31. package/src/main.ts +263 -0
  32. package/src/managed-main.ts +420 -0
  33. package/src/materializers/local.ts +300 -0
  34. package/src/materializers/managed-platform.ts +270 -0
  35. package/src/paths.ts +137 -0
  36. package/src/server.ts +636 -0
  37. package/src/subjects/local.ts +177 -0
  38. package/src/subjects/managed.ts +290 -0
  39. package/src/toolstore/integrity.ts +94 -0
  40. package/src/toolstore/manifest.ts +154 -0
  41. package/src/toolstore/publish.ts +342 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,826 @@
1
+ /**
2
+ * Tests for CES local subject resolution and credential materialisation.
3
+ *
4
+ * Covers:
5
+ * 1. UUID and service/field refs for static credentials
6
+ * 2. Disconnected OAuth handles (missing connection, missing access token)
7
+ * 3. Missing secure keys (metadata present but secret missing)
8
+ * 4. Refresh-on-expiry for OAuth tokens
9
+ * 5. Deterministic failure behaviour before any network call or command launch
10
+ * 6. Circuit breaker tripping after repeated refresh failures
11
+ * 7. Provider key mismatch on OAuth handles
12
+ * 8. Non-local handle types rejected by the local resolver
13
+ */
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+
17
+ import {
18
+ HandleType,
19
+ localStaticHandle,
20
+ localOAuthHandle,
21
+ platformOAuthHandle,
22
+ } from "@vellumai/ces-contracts";
23
+ import {
24
+ type OAuthConnectionRecord,
25
+ type SecureKeyBackend,
26
+ type SecureKeyDeleteResult,
27
+ type StaticCredentialRecord,
28
+ StaticCredentialMetadataStore,
29
+ oauthConnectionAccessTokenPath,
30
+ oauthConnectionRefreshTokenPath,
31
+ REFRESH_FAILURE_THRESHOLD,
32
+ } from "@vellumai/credential-storage";
33
+
34
+ import {
35
+ resolveLocalSubject,
36
+ type OAuthConnectionLookup,
37
+ type LocalSubjectResolverDeps,
38
+ } from "../subjects/local.js";
39
+ import {
40
+ LocalMaterialiser,
41
+ type TokenRefreshFn,
42
+ } from "../materializers/local.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Test helpers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * In-memory secure-key backend for testing. Stores key-value pairs in a Map.
50
+ */
51
+ function createMemoryBackend(
52
+ initial: Record<string, string> = {},
53
+ ): SecureKeyBackend {
54
+ const store = new Map<string, string>(Object.entries(initial));
55
+ return {
56
+ async get(key: string): Promise<string | undefined> {
57
+ return store.get(key);
58
+ },
59
+ async set(key: string, value: string): Promise<boolean> {
60
+ store.set(key, value);
61
+ return true;
62
+ },
63
+ async delete(key: string): Promise<SecureKeyDeleteResult> {
64
+ if (store.has(key)) {
65
+ store.delete(key);
66
+ return "deleted";
67
+ }
68
+ return "not-found";
69
+ },
70
+ async list(): Promise<string[]> {
71
+ return Array.from(store.keys());
72
+ },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Build a minimal static credential record for testing.
78
+ */
79
+ function buildStaticRecord(
80
+ overrides: Partial<StaticCredentialRecord> = {},
81
+ ): StaticCredentialRecord {
82
+ return {
83
+ credentialId: overrides.credentialId ?? "cred-uuid-1",
84
+ service: overrides.service ?? "github",
85
+ field: overrides.field ?? "api_key",
86
+ allowedTools: overrides.allowedTools ?? [],
87
+ allowedDomains: overrides.allowedDomains ?? [],
88
+ createdAt: overrides.createdAt ?? Date.now(),
89
+ updatedAt: overrides.updatedAt ?? Date.now(),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Build a minimal OAuth connection record for testing.
95
+ */
96
+ function buildOAuthConnection(
97
+ overrides: Partial<OAuthConnectionRecord> = {},
98
+ ): OAuthConnectionRecord {
99
+ return {
100
+ id: overrides.id ?? "conn-uuid-1",
101
+ providerKey: overrides.providerKey ?? "integration:google",
102
+ accountInfo: overrides.accountInfo ?? "user@example.com",
103
+ grantedScopes: overrides.grantedScopes ?? ["openid", "email"],
104
+ accessTokenPath: overrides.accessTokenPath ??
105
+ oauthConnectionAccessTokenPath("conn-uuid-1"),
106
+ hasRefreshToken: overrides.hasRefreshToken ?? true,
107
+ expiresAt: overrides.expiresAt ?? null,
108
+ createdAt: overrides.createdAt ?? Date.now(),
109
+ updatedAt: overrides.updatedAt ?? Date.now(),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Create an in-memory OAuth connection lookup backed by a Map.
115
+ */
116
+ function createOAuthLookup(
117
+ connections: OAuthConnectionRecord[] = [],
118
+ ): OAuthConnectionLookup {
119
+ const byId = new Map(connections.map((c) => [c.id, c]));
120
+ return {
121
+ getById(connectionId: string) {
122
+ return byId.get(connectionId);
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Create a StaticCredentialMetadataStore backed by an in-memory JSON file.
129
+ * Uses a temporary path that is unique per test.
130
+ */
131
+ function createMemoryMetadataStore(
132
+ records: StaticCredentialRecord[] = [],
133
+ ): StaticCredentialMetadataStore {
134
+ const tmpPath = `/tmp/ces-test-metadata-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
135
+ const store = new StaticCredentialMetadataStore(tmpPath);
136
+ // Seed records by upserting them
137
+ for (const r of records) {
138
+ store.upsert(r.service, r.field, {
139
+ allowedTools: r.allowedTools,
140
+ allowedDomains: r.allowedDomains,
141
+ usageDescription: r.usageDescription,
142
+ alias: r.alias,
143
+ injectionTemplates: r.injectionTemplates,
144
+ });
145
+ }
146
+ return store;
147
+ }
148
+
149
+ /**
150
+ * Create resolver deps from lists of records and connections.
151
+ */
152
+ function createResolverDeps(opts: {
153
+ staticRecords?: StaticCredentialRecord[];
154
+ oauthConnections?: OAuthConnectionRecord[];
155
+ }): LocalSubjectResolverDeps {
156
+ return {
157
+ metadataStore: createMemoryMetadataStore(opts.staticRecords ?? []),
158
+ oauthConnections: createOAuthLookup(opts.oauthConnections ?? []),
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 1. Local static subject resolution
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("local static subject resolution", () => {
167
+ test("resolves a valid service/field handle to a static subject", () => {
168
+ const record = buildStaticRecord({
169
+ service: "github",
170
+ field: "api_key",
171
+ });
172
+ const deps = createResolverDeps({ staticRecords: [record] });
173
+ const handle = localStaticHandle("github", "api_key");
174
+
175
+ const result = resolveLocalSubject(handle, deps);
176
+
177
+ expect(result.ok).toBe(true);
178
+ if (!result.ok) return;
179
+ expect(result.subject.type).toBe(HandleType.LocalStatic);
180
+ if (result.subject.type !== HandleType.LocalStatic) return;
181
+ expect(result.subject.metadata.service).toBe("github");
182
+ expect(result.subject.metadata.field).toBe("api_key");
183
+ expect(result.subject.storageKey).toBe("credential/github/api_key");
184
+ });
185
+
186
+ test("fails when no metadata exists for the service/field", () => {
187
+ const deps = createResolverDeps({ staticRecords: [] });
188
+ const handle = localStaticHandle("nonexistent", "key");
189
+
190
+ const result = resolveLocalSubject(handle, deps);
191
+
192
+ expect(result.ok).toBe(false);
193
+ if (result.ok) return;
194
+ expect(result.error).toMatch(/No local static credential found/);
195
+ expect(result.error).toMatch(/nonexistent/);
196
+ });
197
+
198
+ test("resolves different service/field combinations independently", () => {
199
+ const records = [
200
+ buildStaticRecord({ service: "fal", field: "api_key" }),
201
+ buildStaticRecord({ service: "github", field: "token" }),
202
+ ];
203
+ const deps = createResolverDeps({ staticRecords: records });
204
+
205
+ const falResult = resolveLocalSubject(
206
+ localStaticHandle("fal", "api_key"),
207
+ deps,
208
+ );
209
+ const ghResult = resolveLocalSubject(
210
+ localStaticHandle("github", "token"),
211
+ deps,
212
+ );
213
+
214
+ expect(falResult.ok).toBe(true);
215
+ expect(ghResult.ok).toBe(true);
216
+ if (!falResult.ok || !ghResult.ok) return;
217
+ expect(falResult.subject.type).toBe(HandleType.LocalStatic);
218
+ expect(ghResult.subject.type).toBe(HandleType.LocalStatic);
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // 2. Local OAuth subject resolution
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe("local OAuth subject resolution", () => {
227
+ test("resolves a valid OAuth handle to an OAuth subject", () => {
228
+ const conn = buildOAuthConnection({
229
+ id: "conn-abc",
230
+ providerKey: "integration:google",
231
+ });
232
+ const deps = createResolverDeps({ oauthConnections: [conn] });
233
+ const handle = localOAuthHandle("integration:google", "conn-abc");
234
+
235
+ const result = resolveLocalSubject(handle, deps);
236
+
237
+ expect(result.ok).toBe(true);
238
+ if (!result.ok) return;
239
+ expect(result.subject.type).toBe(HandleType.LocalOAuth);
240
+ if (result.subject.type !== HandleType.LocalOAuth) return;
241
+ expect(result.subject.connection.id).toBe("conn-abc");
242
+ expect(result.subject.connection.providerKey).toBe("integration:google");
243
+ });
244
+
245
+ test("fails when the connection does not exist", () => {
246
+ const deps = createResolverDeps({ oauthConnections: [] });
247
+ const handle = localOAuthHandle("integration:google", "missing-conn");
248
+
249
+ const result = resolveLocalSubject(handle, deps);
250
+
251
+ expect(result.ok).toBe(false);
252
+ if (result.ok) return;
253
+ expect(result.error).toMatch(/No local OAuth connection found/);
254
+ expect(result.error).toMatch(/missing-conn/);
255
+ });
256
+
257
+ test("fails when provider key in handle does not match connection", () => {
258
+ const conn = buildOAuthConnection({
259
+ id: "conn-xyz",
260
+ providerKey: "integration:slack",
261
+ });
262
+ const deps = createResolverDeps({ oauthConnections: [conn] });
263
+ // Handle says google but connection is slack
264
+ const handle = localOAuthHandle("integration:google", "conn-xyz");
265
+
266
+ const result = resolveLocalSubject(handle, deps);
267
+
268
+ expect(result.ok).toBe(false);
269
+ if (result.ok) return;
270
+ expect(result.error).toMatch(/providerKey/);
271
+ expect(result.error).toMatch(/integration:slack/);
272
+ expect(result.error).toMatch(/integration:google/);
273
+ });
274
+ });
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // 3. Non-local handle types rejected
278
+ // ---------------------------------------------------------------------------
279
+
280
+ describe("non-local handle rejection", () => {
281
+ test("rejects platform_oauth handles in the local resolver", () => {
282
+ const deps = createResolverDeps({});
283
+ const handle = platformOAuthHandle("platform-conn-123");
284
+
285
+ const result = resolveLocalSubject(handle, deps);
286
+
287
+ expect(result.ok).toBe(false);
288
+ if (result.ok) return;
289
+ expect(result.error).toMatch(/not a local handle/);
290
+ });
291
+
292
+ test("rejects malformed handles", () => {
293
+ const deps = createResolverDeps({});
294
+
295
+ const result = resolveLocalSubject("garbage-no-colon", deps);
296
+
297
+ expect(result.ok).toBe(false);
298
+ if (result.ok) return;
299
+ expect(result.error).toMatch(/Invalid handle format/);
300
+ });
301
+
302
+ test("rejects unknown handle type prefixes", () => {
303
+ const deps = createResolverDeps({});
304
+
305
+ const result = resolveLocalSubject("unknown_type:foo/bar", deps);
306
+
307
+ expect(result.ok).toBe(false);
308
+ if (result.ok) return;
309
+ expect(result.error).toMatch(/Unknown handle type/);
310
+ });
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // 4. Static credential materialisation
315
+ // ---------------------------------------------------------------------------
316
+
317
+ describe("static credential materialisation", () => {
318
+ test("materialises a stored secret value", async () => {
319
+ const record = buildStaticRecord({
320
+ service: "fal",
321
+ field: "api_key",
322
+ });
323
+ const deps = createResolverDeps({ staticRecords: [record] });
324
+ const handle = localStaticHandle("fal", "api_key");
325
+
326
+ const resolved = resolveLocalSubject(handle, deps);
327
+ expect(resolved.ok).toBe(true);
328
+ if (!resolved.ok) return;
329
+
330
+ const backend = createMemoryBackend({
331
+ "credential/fal/api_key": "secret-fal-key-123",
332
+ });
333
+ const materialiser = new LocalMaterialiser({
334
+ secureKeyBackend: backend,
335
+ });
336
+
337
+ const result = await materialiser.materialise(resolved.subject);
338
+
339
+ expect(result.ok).toBe(true);
340
+ if (!result.ok) return;
341
+ expect(result.credential.value).toBe("secret-fal-key-123");
342
+ expect(result.credential.handleType).toBe(HandleType.LocalStatic);
343
+ });
344
+
345
+ test("fails when the secure key is missing (metadata without secret)", async () => {
346
+ const record = buildStaticRecord({
347
+ service: "github",
348
+ field: "token",
349
+ });
350
+ const deps = createResolverDeps({ staticRecords: [record] });
351
+ const handle = localStaticHandle("github", "token");
352
+
353
+ const resolved = resolveLocalSubject(handle, deps);
354
+ expect(resolved.ok).toBe(true);
355
+ if (!resolved.ok) return;
356
+
357
+ // Empty backend — no secret stored
358
+ const backend = createMemoryBackend({});
359
+ const materialiser = new LocalMaterialiser({
360
+ secureKeyBackend: backend,
361
+ });
362
+
363
+ const result = await materialiser.materialise(resolved.subject);
364
+
365
+ expect(result.ok).toBe(false);
366
+ if (result.ok) return;
367
+ expect(result.error).toMatch(/Secure key/);
368
+ expect(result.error).toMatch(/not found/);
369
+ expect(result.error).toMatch(/credential\/github\/token/);
370
+ });
371
+ });
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // 5. OAuth token materialisation
375
+ // ---------------------------------------------------------------------------
376
+
377
+ describe("OAuth token materialisation", () => {
378
+ test("materialises a valid non-expired access token", async () => {
379
+ const conn = buildOAuthConnection({
380
+ id: "conn-1",
381
+ providerKey: "integration:google",
382
+ // Token expires in the future (1 hour from now)
383
+ expiresAt: Date.now() + 60 * 60 * 1000,
384
+ hasRefreshToken: true,
385
+ });
386
+ const deps = createResolverDeps({ oauthConnections: [conn] });
387
+ const handle = localOAuthHandle("integration:google", "conn-1");
388
+
389
+ const resolved = resolveLocalSubject(handle, deps);
390
+ expect(resolved.ok).toBe(true);
391
+ if (!resolved.ok) return;
392
+
393
+ const backend = createMemoryBackend({
394
+ [oauthConnectionAccessTokenPath("conn-1")]: "ya29.valid-token",
395
+ });
396
+ const materialiser = new LocalMaterialiser({
397
+ secureKeyBackend: backend,
398
+ });
399
+
400
+ const result = await materialiser.materialise(resolved.subject);
401
+
402
+ expect(result.ok).toBe(true);
403
+ if (!result.ok) return;
404
+ expect(result.credential.value).toBe("ya29.valid-token");
405
+ expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
406
+ });
407
+
408
+ test("fails when no access token is stored (disconnected connection)", async () => {
409
+ const conn = buildOAuthConnection({
410
+ id: "conn-disconnected",
411
+ providerKey: "integration:slack",
412
+ hasRefreshToken: false,
413
+ });
414
+ const deps = createResolverDeps({ oauthConnections: [conn] });
415
+ const handle = localOAuthHandle("integration:slack", "conn-disconnected");
416
+
417
+ const resolved = resolveLocalSubject(handle, deps);
418
+ expect(resolved.ok).toBe(true);
419
+ if (!resolved.ok) return;
420
+
421
+ // Empty backend — no access token
422
+ const backend = createMemoryBackend({});
423
+ const materialiser = new LocalMaterialiser({
424
+ secureKeyBackend: backend,
425
+ });
426
+
427
+ const result = await materialiser.materialise(resolved.subject);
428
+
429
+ expect(result.ok).toBe(false);
430
+ if (result.ok) return;
431
+ expect(result.error).toMatch(/No access token found/);
432
+ expect(result.error).toMatch(/disconnected/);
433
+ });
434
+
435
+ test("materialises a token with null expiresAt (no expiry info)", async () => {
436
+ const conn = buildOAuthConnection({
437
+ id: "conn-noexpiry",
438
+ providerKey: "integration:github",
439
+ expiresAt: null,
440
+ hasRefreshToken: false,
441
+ });
442
+ const deps = createResolverDeps({ oauthConnections: [conn] });
443
+ const handle = localOAuthHandle("integration:github", "conn-noexpiry");
444
+
445
+ const resolved = resolveLocalSubject(handle, deps);
446
+ expect(resolved.ok).toBe(true);
447
+ if (!resolved.ok) return;
448
+
449
+ const backend = createMemoryBackend({
450
+ [oauthConnectionAccessTokenPath("conn-noexpiry")]: "gho_token123",
451
+ });
452
+ const materialiser = new LocalMaterialiser({
453
+ secureKeyBackend: backend,
454
+ });
455
+
456
+ const result = await materialiser.materialise(resolved.subject);
457
+
458
+ // null expiresAt means isTokenExpired returns false, so the token is used as-is
459
+ expect(result.ok).toBe(true);
460
+ if (!result.ok) return;
461
+ expect(result.credential.value).toBe("gho_token123");
462
+ });
463
+ });
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // 6. Refresh-on-expiry
467
+ // ---------------------------------------------------------------------------
468
+
469
+ describe("OAuth refresh-on-expiry", () => {
470
+ test("refreshes an expired token and returns the new access token", async () => {
471
+ const conn = buildOAuthConnection({
472
+ id: "conn-expired",
473
+ providerKey: "integration:google",
474
+ // Token expired 10 minutes ago
475
+ expiresAt: Date.now() - 10 * 60 * 1000,
476
+ hasRefreshToken: true,
477
+ });
478
+ const deps = createResolverDeps({ oauthConnections: [conn] });
479
+ const handle = localOAuthHandle("integration:google", "conn-expired");
480
+
481
+ const resolved = resolveLocalSubject(handle, deps);
482
+ expect(resolved.ok).toBe(true);
483
+ if (!resolved.ok) return;
484
+
485
+ const backend = createMemoryBackend({
486
+ [oauthConnectionAccessTokenPath("conn-expired")]: "old-expired-token",
487
+ [oauthConnectionRefreshTokenPath("conn-expired")]: "refresh-token-abc",
488
+ });
489
+
490
+ const refreshFn: TokenRefreshFn = async (_connId, _refreshToken) => {
491
+ return {
492
+ success: true,
493
+ accessToken: "ya29.new-refreshed-token",
494
+ expiresAt: Date.now() + 3600 * 1000,
495
+ };
496
+ };
497
+
498
+ const materialiser = new LocalMaterialiser({
499
+ secureKeyBackend: backend,
500
+ tokenRefreshFn: refreshFn,
501
+ });
502
+
503
+ const result = await materialiser.materialise(resolved.subject);
504
+
505
+ expect(result.ok).toBe(true);
506
+ if (!result.ok) return;
507
+ expect(result.credential.value).toBe("ya29.new-refreshed-token");
508
+ expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
509
+ });
510
+
511
+ test("fails when token is expired but no refresh function is configured", async () => {
512
+ const conn = buildOAuthConnection({
513
+ id: "conn-no-refresh-fn",
514
+ providerKey: "integration:google",
515
+ expiresAt: Date.now() - 10 * 60 * 1000,
516
+ hasRefreshToken: true,
517
+ });
518
+ const deps = createResolverDeps({ oauthConnections: [conn] });
519
+ const handle = localOAuthHandle("integration:google", "conn-no-refresh-fn");
520
+
521
+ const resolved = resolveLocalSubject(handle, deps);
522
+ expect(resolved.ok).toBe(true);
523
+ if (!resolved.ok) return;
524
+
525
+ const backend = createMemoryBackend({
526
+ [oauthConnectionAccessTokenPath("conn-no-refresh-fn")]: "old-token",
527
+ [oauthConnectionRefreshTokenPath("conn-no-refresh-fn")]: "refresh-tok",
528
+ });
529
+
530
+ // No tokenRefreshFn provided
531
+ const materialiser = new LocalMaterialiser({
532
+ secureKeyBackend: backend,
533
+ });
534
+
535
+ const result = await materialiser.materialise(resolved.subject);
536
+
537
+ expect(result.ok).toBe(false);
538
+ if (result.ok) return;
539
+ expect(result.error).toMatch(/expired/);
540
+ expect(result.error).toMatch(/no refresh.*function/i);
541
+ });
542
+
543
+ test("fails when token is expired and no refresh token is stored", async () => {
544
+ const conn = buildOAuthConnection({
545
+ id: "conn-no-stored-refresh",
546
+ providerKey: "integration:google",
547
+ expiresAt: Date.now() - 10 * 60 * 1000,
548
+ hasRefreshToken: true,
549
+ });
550
+ const deps = createResolverDeps({ oauthConnections: [conn] });
551
+ const handle = localOAuthHandle(
552
+ "integration:google",
553
+ "conn-no-stored-refresh",
554
+ );
555
+
556
+ const resolved = resolveLocalSubject(handle, deps);
557
+ expect(resolved.ok).toBe(true);
558
+ if (!resolved.ok) return;
559
+
560
+ // Access token exists but refresh token does not
561
+ const backend = createMemoryBackend({
562
+ [oauthConnectionAccessTokenPath("conn-no-stored-refresh")]: "old-token",
563
+ // No refresh token entry
564
+ });
565
+
566
+ const refreshFn: TokenRefreshFn = async () => {
567
+ throw new Error("Should not be called");
568
+ };
569
+
570
+ const materialiser = new LocalMaterialiser({
571
+ secureKeyBackend: backend,
572
+ tokenRefreshFn: refreshFn,
573
+ });
574
+
575
+ const result = await materialiser.materialise(resolved.subject);
576
+
577
+ expect(result.ok).toBe(false);
578
+ if (result.ok) return;
579
+ expect(result.error).toMatch(/expired/);
580
+ expect(result.error).toMatch(/no refresh.*token.*available/i);
581
+ });
582
+
583
+ test("fails when refresh function returns a failure result", async () => {
584
+ const conn = buildOAuthConnection({
585
+ id: "conn-refresh-fail",
586
+ providerKey: "integration:google",
587
+ expiresAt: Date.now() - 10 * 60 * 1000,
588
+ hasRefreshToken: true,
589
+ });
590
+ const deps = createResolverDeps({ oauthConnections: [conn] });
591
+ const handle = localOAuthHandle(
592
+ "integration:google",
593
+ "conn-refresh-fail",
594
+ );
595
+
596
+ const resolved = resolveLocalSubject(handle, deps);
597
+ expect(resolved.ok).toBe(true);
598
+ if (!resolved.ok) return;
599
+
600
+ const backend = createMemoryBackend({
601
+ [oauthConnectionAccessTokenPath("conn-refresh-fail")]: "old-token",
602
+ [oauthConnectionRefreshTokenPath("conn-refresh-fail")]: "refresh-tok",
603
+ });
604
+
605
+ const refreshFn: TokenRefreshFn = async () => {
606
+ return { success: false, error: "Token has been revoked by user" };
607
+ };
608
+
609
+ const materialiser = new LocalMaterialiser({
610
+ secureKeyBackend: backend,
611
+ tokenRefreshFn: refreshFn,
612
+ });
613
+
614
+ const result = await materialiser.materialise(resolved.subject);
615
+
616
+ expect(result.ok).toBe(false);
617
+ if (result.ok) return;
618
+ expect(result.error).toMatch(/Failed to refresh/);
619
+ expect(result.error).toMatch(/revoked/);
620
+ });
621
+ });
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // 7. Circuit breaker
625
+ // ---------------------------------------------------------------------------
626
+
627
+ describe("refresh circuit breaker", () => {
628
+ test("trips after repeated refresh failures and returns error", async () => {
629
+ const conn = buildOAuthConnection({
630
+ id: "conn-breaker",
631
+ providerKey: "integration:google",
632
+ expiresAt: Date.now() - 10 * 60 * 1000,
633
+ hasRefreshToken: true,
634
+ });
635
+ const deps = createResolverDeps({ oauthConnections: [conn] });
636
+ const handle = localOAuthHandle("integration:google", "conn-breaker");
637
+
638
+ const resolved = resolveLocalSubject(handle, deps);
639
+ expect(resolved.ok).toBe(true);
640
+ if (!resolved.ok) return;
641
+
642
+ const backend = createMemoryBackend({
643
+ [oauthConnectionAccessTokenPath("conn-breaker")]: "old-token",
644
+ [oauthConnectionRefreshTokenPath("conn-breaker")]: "refresh-tok",
645
+ });
646
+
647
+ let callCount = 0;
648
+ const refreshFn: TokenRefreshFn = async () => {
649
+ callCount++;
650
+ return { success: false, error: "Provider rejected refresh" };
651
+ };
652
+
653
+ const materialiser = new LocalMaterialiser({
654
+ secureKeyBackend: backend,
655
+ tokenRefreshFn: refreshFn,
656
+ });
657
+
658
+ // Exhaust the circuit breaker threshold
659
+ for (let i = 0; i < REFRESH_FAILURE_THRESHOLD; i++) {
660
+ const result = await materialiser.materialise(resolved.subject);
661
+ expect(result.ok).toBe(false);
662
+ }
663
+
664
+ // The next attempt should be blocked by the circuit breaker
665
+ const blockedResult = await materialiser.materialise(resolved.subject);
666
+ expect(blockedResult.ok).toBe(false);
667
+ if (blockedResult.ok) return;
668
+ expect(blockedResult.error).toMatch(/circuit breaker/i);
669
+
670
+ // The refresh function should NOT have been called for the blocked attempt
671
+ expect(callCount).toBe(REFRESH_FAILURE_THRESHOLD);
672
+ });
673
+ });
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // 8. Deterministic failure before outbound work
677
+ // ---------------------------------------------------------------------------
678
+
679
+ describe("deterministic fail-closed behaviour", () => {
680
+ test("all resolution failures happen synchronously before materialisation", () => {
681
+ const deps = createResolverDeps({});
682
+
683
+ // Invalid handle format
684
+ const r1 = resolveLocalSubject("not-a-handle", deps);
685
+ expect(r1.ok).toBe(false);
686
+
687
+ // Missing static credential
688
+ const r2 = resolveLocalSubject(localStaticHandle("missing", "key"), deps);
689
+ expect(r2.ok).toBe(false);
690
+
691
+ // Missing OAuth connection
692
+ const r3 = resolveLocalSubject(
693
+ localOAuthHandle("integration:x", "missing-conn"),
694
+ deps,
695
+ );
696
+ expect(r3.ok).toBe(false);
697
+
698
+ // Platform handle in local resolver
699
+ const r4 = resolveLocalSubject(platformOAuthHandle("plat-conn"), deps);
700
+ expect(r4.ok).toBe(false);
701
+
702
+ // All failures are deterministic, synchronous, and happen before
703
+ // any async materialisation (network call, command launch) is attempted.
704
+ });
705
+
706
+ test("materialisation failures for missing keys are deterministic", async () => {
707
+ const record = buildStaticRecord({
708
+ service: "aws",
709
+ field: "secret_key",
710
+ });
711
+ const deps = createResolverDeps({ staticRecords: [record] });
712
+ const handle = localStaticHandle("aws", "secret_key");
713
+
714
+ const resolved = resolveLocalSubject(handle, deps);
715
+ expect(resolved.ok).toBe(true);
716
+ if (!resolved.ok) return;
717
+
718
+ const backend = createMemoryBackend({}); // Empty — key not stored
719
+ const materialiser = new LocalMaterialiser({
720
+ secureKeyBackend: backend,
721
+ });
722
+
723
+ const result = await materialiser.materialise(resolved.subject);
724
+
725
+ expect(result.ok).toBe(false);
726
+ if (result.ok) return;
727
+ // Error happens before any network call could be made
728
+ expect(result.error).toMatch(/not found/);
729
+ });
730
+
731
+ test("OAuth disconnection detected before any refresh attempt", async () => {
732
+ const conn = buildOAuthConnection({
733
+ id: "conn-disco",
734
+ providerKey: "integration:slack",
735
+ });
736
+ const deps = createResolverDeps({ oauthConnections: [conn] });
737
+ const handle = localOAuthHandle("integration:slack", "conn-disco");
738
+
739
+ const resolved = resolveLocalSubject(handle, deps);
740
+ expect(resolved.ok).toBe(true);
741
+ if (!resolved.ok) return;
742
+
743
+ // No access token stored
744
+ const backend = createMemoryBackend({});
745
+ let refreshCalled = false;
746
+ const refreshFn: TokenRefreshFn = async () => {
747
+ refreshCalled = true;
748
+ return { success: true, accessToken: "tok", expiresAt: null };
749
+ };
750
+
751
+ const materialiser = new LocalMaterialiser({
752
+ secureKeyBackend: backend,
753
+ tokenRefreshFn: refreshFn,
754
+ });
755
+
756
+ const result = await materialiser.materialise(resolved.subject);
757
+
758
+ expect(result.ok).toBe(false);
759
+ // Refresh function should NOT have been called
760
+ expect(refreshCalled).toBe(false);
761
+ });
762
+ });
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // 9. End-to-end resolution + materialisation
766
+ // ---------------------------------------------------------------------------
767
+
768
+ describe("end-to-end local materialisation", () => {
769
+ test("full pipeline: resolve static handle -> materialise secret", async () => {
770
+ const record = buildStaticRecord({
771
+ service: "openai",
772
+ field: "api_key",
773
+ });
774
+ const deps = createResolverDeps({ staticRecords: [record] });
775
+ const handle = localStaticHandle("openai", "api_key");
776
+
777
+ // Step 1: Resolve
778
+ const resolved = resolveLocalSubject(handle, deps);
779
+ expect(resolved.ok).toBe(true);
780
+ if (!resolved.ok) return;
781
+
782
+ // Step 2: Materialise
783
+ const backend = createMemoryBackend({
784
+ "credential/openai/api_key": "sk-live-abc123",
785
+ });
786
+ const materialiser = new LocalMaterialiser({
787
+ secureKeyBackend: backend,
788
+ });
789
+
790
+ const result = await materialiser.materialise(resolved.subject);
791
+ expect(result.ok).toBe(true);
792
+ if (!result.ok) return;
793
+ expect(result.credential.value).toBe("sk-live-abc123");
794
+ expect(result.credential.handleType).toBe(HandleType.LocalStatic);
795
+ });
796
+
797
+ test("full pipeline: resolve OAuth handle -> materialise token", async () => {
798
+ const conn = buildOAuthConnection({
799
+ id: "conn-e2e",
800
+ providerKey: "integration:linear",
801
+ expiresAt: Date.now() + 3600 * 1000,
802
+ hasRefreshToken: true,
803
+ });
804
+ const deps = createResolverDeps({ oauthConnections: [conn] });
805
+ const handle = localOAuthHandle("integration:linear", "conn-e2e");
806
+
807
+ // Step 1: Resolve
808
+ const resolved = resolveLocalSubject(handle, deps);
809
+ expect(resolved.ok).toBe(true);
810
+ if (!resolved.ok) return;
811
+
812
+ // Step 2: Materialise
813
+ const backend = createMemoryBackend({
814
+ [oauthConnectionAccessTokenPath("conn-e2e")]: "lin_api_token_xyz",
815
+ });
816
+ const materialiser = new LocalMaterialiser({
817
+ secureKeyBackend: backend,
818
+ });
819
+
820
+ const result = await materialiser.materialise(resolved.subject);
821
+ expect(result.ok).toBe(true);
822
+ if (!result.ok) return;
823
+ expect(result.credential.value).toBe("lin_api_token_xyz");
824
+ expect(result.credential.handleType).toBe(HandleType.LocalOAuth);
825
+ });
826
+ });