@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.2

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 (64) hide show
  1. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  2. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  3. package/openapi.yaml +35 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  6. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -0
  7. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  8. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  9. package/src/__tests__/credential-routes.test.ts +38 -0
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  11. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  12. package/src/__tests__/host-cu-proxy.test.ts +69 -12
  13. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  14. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  15. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  16. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  17. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  18. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  19. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  20. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  21. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  22. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  23. package/src/acp/acp-claude-oauth.ts +328 -14
  24. package/src/acp/acp-credentials.ts +19 -0
  25. package/src/acp/claude-token-refresh.ts +150 -0
  26. package/src/acp/prepare-agent-env.ts +21 -6
  27. package/src/calls/__tests__/voice-control-protocol.test.ts +62 -0
  28. package/src/calls/__tests__/voice-session-bridge.test.ts +19 -0
  29. package/src/calls/voice-control-protocol.ts +102 -0
  30. package/src/calls/voice-session-bridge.ts +33 -26
  31. package/src/calls/voice-triage-escalate.ts +3 -3
  32. package/src/cli/commands/oauth/status.ts +5 -0
  33. package/src/config/bundled-skills/computer-use/SKILL.md +13 -4
  34. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  35. package/src/config/feature-flag-registry.json +9 -1
  36. package/src/config/loader.ts +1 -0
  37. package/src/config/schemas/services.ts +10 -0
  38. package/src/daemon/conversation-runtime-assembly.ts +18 -6
  39. package/src/daemon/conversation-surfaces.ts +6 -1
  40. package/src/daemon/conversation-tool-setup.ts +8 -16
  41. package/src/daemon/host-cu-proxy.ts +43 -5
  42. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +121 -0
  43. package/src/live-voice/__tests__/live-voice-events.test.ts +8 -7
  44. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  45. package/src/live-voice/__tests__/protocol.test.ts +39 -0
  46. package/src/live-voice/__tests__/session-controls.test.ts +79 -0
  47. package/src/live-voice/live-voice-session.ts +85 -7
  48. package/src/live-voice/protocol.ts +60 -0
  49. package/src/live-voice/session-controls.ts +111 -0
  50. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  51. package/src/oauth/connection-resolver.test.ts +27 -0
  52. package/src/oauth/connection-resolver.ts +25 -1
  53. package/src/oauth/provider-serializer.ts +9 -0
  54. package/src/oauth/seed-providers.ts +110 -2
  55. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  56. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  57. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  58. package/src/runtime/routes/app-management-routes.ts +17 -2
  59. package/src/runtime/routes/credential-routes.ts +1 -4
  60. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  61. package/src/runtime/routes/oauth-providers.ts +7 -0
  62. package/src/tools/computer-use/definitions.ts +1 -1
  63. package/src/tools/ui-surface/channel-variants.ts +10 -59
  64. package/src/watch/watch-retro.ts +6 -8
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { ACP_OAUTH_TOKEN_FIELD, ACP_SERVICE } from "../acp/acp-credentials.js";
3
+ import {
4
+ ACP_OAUTH_EXPIRES_AT_FIELD,
5
+ ACP_OAUTH_REFRESH_TOKEN_FIELD,
6
+ ACP_OAUTH_TOKEN_FIELD,
7
+ ACP_SERVICE,
8
+ } from "../acp/acp-credentials.js";
4
9
  import { credentialKey } from "../security/credential-key.js";
5
10
 
6
11
  let secureKeyStore: Record<string, string | undefined> = {};
@@ -32,6 +37,11 @@ mock.module("../oauth/manual-token-connection.js", () => ({
32
37
  syncManualTokenConnection: async () => {},
33
38
  }));
34
39
 
40
+ mock.module("../runtime/routes/credential-in-use.js", () => ({
41
+ assertCredentialNotInUse: () => [],
42
+ invalidateConnectionsAfterCredentialDelete: () => {},
43
+ }));
44
+
35
45
  afterAll(() => {
36
46
  mock.restore();
37
47
  });
@@ -42,6 +52,9 @@ import { ROUTES } from "../runtime/routes/secret-routes.js";
42
52
  const addRoute = ROUTES.find(
43
53
  (r) => r.method === "POST" && r.endpoint === "secrets",
44
54
  )!;
55
+ const deleteRoute = ROUTES.find(
56
+ (r) => r.method === "DELETE" && r.endpoint === "secrets",
57
+ )!;
45
58
 
46
59
  function addAcpOauthToken(value: string) {
47
60
  return addRoute.handler({
@@ -74,4 +87,49 @@ describe("secret routes ACP OAuth-token format guard", () => {
74
87
  secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)],
75
88
  ).toBe("sk-ant-oat01-valid-oauth-token");
76
89
  });
90
+
91
+ test("stores a pasted Claude token without touching leftover refresh material", async () => {
92
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)] =
93
+ "stale-refresh";
94
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)] =
95
+ "111";
96
+
97
+ await addAcpOauthToken("sk-ant-oat01-pasted-token");
98
+
99
+ expect(
100
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)],
101
+ ).toBe("sk-ant-oat01-pasted-token");
102
+ expect(
103
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)],
104
+ ).toBe("stale-refresh");
105
+ expect(
106
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)],
107
+ ).toBe("111");
108
+ });
109
+
110
+ test("deletes only the requested Claude access-token field", async () => {
111
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)] =
112
+ "sk-ant-oat01-connected";
113
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)] =
114
+ "refresh-leftover";
115
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)] =
116
+ "111";
117
+
118
+ await deleteRoute.handler({
119
+ body: {
120
+ type: "credential",
121
+ name: `${ACP_SERVICE}:${ACP_OAUTH_TOKEN_FIELD}`,
122
+ },
123
+ });
124
+
125
+ expect(
126
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD)],
127
+ ).toBeUndefined();
128
+ expect(
129
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_REFRESH_TOKEN_FIELD)],
130
+ ).toBe("refresh-leftover");
131
+ expect(
132
+ secureKeyStore[credentialKey(ACP_SERVICE, ACP_OAUTH_EXPIRES_AT_FIELD)],
133
+ ).toBe("111");
134
+ });
77
135
  });
@@ -88,6 +88,7 @@ import {
88
88
  } from "../tools/registry.js";
89
89
  import { RiskLevel } from "../tools/tool-types.js";
90
90
  import type { Tool } from "../tools/types.js";
91
+ import { uiShowTool } from "../tools/ui-surface/definitions.js";
91
92
  import { asConversation } from "./helpers/mock-conversation.js";
92
93
 
93
94
  // ---------------------------------------------------------------------------
@@ -249,6 +250,56 @@ describe("createResolveToolsCallback — toolContextPin", () => {
249
250
  });
250
251
  }
251
252
 
253
+ test("Slack and desktop turns keep the same full ui_show schema while surfaces persist", () => {
254
+ projectedSkillToolNames = [];
255
+ const slackCaps = {
256
+ channel: "slack" as const,
257
+ dashboardCapable: false,
258
+ supportsDynamicUi: false,
259
+ supportsVoiceInput: false,
260
+ };
261
+ const desktopCaps = {
262
+ channel: "macos" as const,
263
+ dashboardCapable: true,
264
+ supportsDynamicUi: true,
265
+ supportsVoiceInput: true,
266
+ };
267
+ const liveSlackResolve = createResolveToolsCallback(
268
+ [uiShowTool],
269
+ makeProjectionCtx({
270
+ hasNoClient: false,
271
+ channelCapabilities: slackCaps,
272
+ }),
273
+ )!;
274
+ const clientlessSlackResolve = createResolveToolsCallback(
275
+ [uiShowTool],
276
+ clientlessExecutionCtx({
277
+ channelCapabilities: slackCaps,
278
+ }),
279
+ )!;
280
+ const liveDesktopResolve = createResolveToolsCallback(
281
+ [uiShowTool],
282
+ makeProjectionCtx({
283
+ hasNoClient: false,
284
+ channelCapabilities: desktopCaps,
285
+ }),
286
+ )!;
287
+
288
+ const [liveSlackUiShow] = liveSlackResolve(EMPTY_HISTORY);
289
+ const [clientlessSlackUiShow] = clientlessSlackResolve(EMPTY_HISTORY);
290
+ const [liveDesktopUiShow] = liveDesktopResolve(EMPTY_HISTORY);
291
+ expect(liveSlackUiShow).toEqual(clientlessSlackUiShow);
292
+ expect(liveSlackUiShow).toEqual(liveDesktopUiShow);
293
+ expect(
294
+ (
295
+ liveSlackUiShow.input_schema as {
296
+ properties: { surface_type: { enum: string[] } };
297
+ }
298
+ ).properties.surface_type.enum,
299
+ ).toContain("choice");
300
+ expect(liveSlackUiShow.description).toContain("dynamic_page");
301
+ });
302
+
252
303
  test("control: without a pin, a clientless fork drops client-gated tools but keeps host and UI tools", () => {
253
304
  projectedSkillToolNames = [];
254
305
  const resolve = createResolveToolsCallback(
@@ -2,10 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { ToolDefinition } from "../providers/types.js";
4
4
  import { ACTIVATION_MOMENT_PARAMS } from "../telemetry/activation-funnel.js";
5
- import {
6
- injectActivationMomentParam,
7
- projectUiToolsForChannel,
8
- } from "../tools/ui-surface/channel-variants.js";
5
+ import { injectActivationMomentParam } from "../tools/ui-surface/channel-variants.js";
9
6
  import {
10
7
  uiDismissTool,
11
8
  uiShowTool,
@@ -23,58 +20,6 @@ const defs: ToolDefinition[] = [
23
20
  },
24
21
  ];
25
22
 
26
- function surfaceTypeEnum(def: ToolDefinition): string[] {
27
- return (
28
- def.input_schema as { properties: { surface_type: { enum: string[] } } }
29
- ).properties.surface_type.enum;
30
- }
31
-
32
- describe("projectUiToolsForChannel", () => {
33
- test("returns the same array for channels without a variant", () => {
34
- for (const channel of [undefined, "macos", "telegram", "phone"]) {
35
- expect(projectUiToolsForChannel(defs, channel)).toBe(defs);
36
- }
37
- });
38
-
39
- test("slack gets a task_progress-only ui_show", () => {
40
- const projected = projectUiToolsForChannel(defs, "slack");
41
- const uiShow = projected.find((d) => d.name === "ui_show")!;
42
-
43
- expect(surfaceTypeEnum(uiShow)).toEqual(["card"]);
44
- expect(uiShow.description).toContain("task_progress");
45
- expect(uiShow.description).toContain(
46
- "the only surface this channel renders",
47
- );
48
- expect(uiShow.description).not.toContain("oauth_connect");
49
- expect(uiShow.description).not.toContain("dynamic_page");
50
- expect(uiShow.description!.length).toBeLessThan(
51
- uiShowTool.description.length / 2,
52
- );
53
- });
54
-
55
- test("slack projection keeps execute and leaves other tools untouched", () => {
56
- const projected = projectUiToolsForChannel(defs, "slack");
57
- const uiShow = projected.find((d) => d.name === "ui_show") as {
58
- execute?: unknown;
59
- };
60
-
61
- expect(uiShow.execute).toBe(uiShowTool.execute);
62
- expect(projected.find((d) => d.name === "ui_update")).toBe(uiUpdateTool);
63
- expect(projected.find((d) => d.name === "ui_dismiss")).toBe(uiDismissTool);
64
- expect(projected.find((d) => d.name === "bash")).toBe(defs[3]);
65
- });
66
-
67
- test("projection never mutates the shared definitions", () => {
68
- const descriptionBefore = uiShowTool.description;
69
- const schemaBefore = JSON.stringify(uiShowTool.input_schema);
70
-
71
- projectUiToolsForChannel(defs, "slack");
72
-
73
- expect(uiShowTool.description).toBe(descriptionBefore);
74
- expect(JSON.stringify(uiShowTool.input_schema)).toBe(schemaBefore);
75
- });
76
- });
77
-
78
23
  describe("injectActivationMomentParam", () => {
79
24
  function properties(def: ToolDefinition): Record<string, unknown> {
80
25
  return (def.input_schema as { properties: Record<string, unknown> })
@@ -22,11 +22,38 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
22
22
  // ---------------------------------------------------------------------------
23
23
 
24
24
  let storeReturn = true;
25
+ let deleteReturn: "deleted" | "not-found" | "error" = "not-found";
25
26
  let getReturn: string | undefined = undefined;
26
- const setSecureKeyAsync = mock(
27
- async (_account: string, _value: string) => storeReturn,
28
- );
29
- const getSecureKeyAsync = mock(async (_account: string) => getReturn);
27
+ const vault = new Map<string, string>();
28
+ const ACCESS_KEY = "credential/acp/claude_oauth_token";
29
+ const REFRESH_KEY = "credential/acp/claude_oauth_refresh_token";
30
+ const EXPIRES_KEY = "credential/acp/claude_oauth_expires_at";
31
+ const DIGEST_KEY = "credential/acp/claude_oauth_access_digest";
32
+
33
+ const setSecureKeyAsync = mock(async (account: string, value: string) => {
34
+ if (!storeReturn) {
35
+ return false;
36
+ }
37
+ vault.set(account, value);
38
+ return true;
39
+ });
40
+ const getSecureKeyAsync = mock(async (account: string) => {
41
+ if (vault.has(account)) {
42
+ return vault.get(account);
43
+ }
44
+ // Existing cases that only seed the access token keep using getReturn.
45
+ if (account === ACCESS_KEY) {
46
+ return getReturn;
47
+ }
48
+ return undefined;
49
+ });
50
+ const deleteSecureKeyAsync = mock(async (account: string) => {
51
+ if (deleteReturn === "error") {
52
+ return "error";
53
+ }
54
+ const existed = vault.delete(account);
55
+ return existed ? "deleted" : "not-found";
56
+ });
30
57
 
31
58
  // Spread the real module rather than listing two exports. These cases reach
32
59
  // the marker tables, which pulls persistence into the graph, and anything in
@@ -37,6 +64,7 @@ mock.module("../../security/secure-keys.js", () => ({
37
64
  ...realSecureKeys,
38
65
  setSecureKeyAsync,
39
66
  getSecureKeyAsync,
67
+ deleteSecureKeyAsync,
40
68
  }));
41
69
 
42
70
  const { _setMetadataPath, getCredentialMetadata, upsertCredentialMetadata } =
@@ -69,6 +97,10 @@ const {
69
97
  parseManualClaudeCode,
70
98
  storeAcpClaudeToken,
71
99
  hasAcpClaudeToken,
100
+ persistRefreshedAcpClaudeTokens,
101
+ forgetAcpClaudeRenewalStateIfUnbound,
102
+ clearAcpClaudeRefreshToken,
103
+ isAcpClaudeTokenExpiring,
72
104
  } = await import("../acp-claude-oauth.js");
73
105
 
74
106
  /**
@@ -95,9 +127,12 @@ beforeEach(() => {
95
127
  mkdirSync(TEST_DIR, { recursive: true });
96
128
  _setMetadataPath(join(TEST_DIR, "metadata.json"));
97
129
  storeReturn = true;
130
+ deleteReturn = "not-found";
98
131
  getReturn = undefined;
132
+ vault.clear();
99
133
  setSecureKeyAsync.mockClear();
100
134
  getSecureKeyAsync.mockClear();
135
+ deleteSecureKeyAsync.mockClear();
101
136
  });
102
137
 
103
138
  afterEach(() => {
@@ -204,11 +239,43 @@ describe("storeAcpClaudeToken", () => {
204
239
  test("writes the token to the acp/claude_oauth_token vault field", async () => {
205
240
  await storeAcpClaudeToken("sk-ant-oat-token");
206
241
 
207
- expect(setSecureKeyAsync).toHaveBeenCalledTimes(1);
208
242
  expect(setSecureKeyAsync).toHaveBeenCalledWith(
209
- "credential/acp/claude_oauth_token",
243
+ ACCESS_KEY,
210
244
  "sk-ant-oat-token",
211
245
  );
246
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-token");
247
+ });
248
+
249
+ test("persists refresh token and expiry from a Connect exchange", async () => {
250
+ const before = Date.now();
251
+ await storeAcpClaudeToken({
252
+ accessToken: "sk-ant-oat-connected",
253
+ refreshToken: "refresh-from-exchange",
254
+ expiresIn: 28800,
255
+ });
256
+ const after = Date.now();
257
+
258
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-connected");
259
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-from-exchange");
260
+ expect(vault.get(DIGEST_KEY)).toBe(
261
+ claudeTokenDigest("sk-ant-oat-connected"),
262
+ );
263
+ const expiresAt = Number(vault.get(EXPIRES_KEY));
264
+ expect(expiresAt).toBeGreaterThanOrEqual(before + 28800 * 1000);
265
+ expect(expiresAt).toBeLessThanOrEqual(after + 28800 * 1000);
266
+ });
267
+
268
+ test("clears companion fields when the exchange returns only an access token", async () => {
269
+ vault.set(REFRESH_KEY, "stale-refresh");
270
+ vault.set(EXPIRES_KEY, "1");
271
+ vault.set(DIGEST_KEY, "stale-digest");
272
+
273
+ await storeAcpClaudeToken({ accessToken: "sk-ant-oat-access-only" });
274
+
275
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-access-only");
276
+ expect(vault.has(REFRESH_KEY)).toBe(false);
277
+ expect(vault.has(EXPIRES_KEY)).toBe(false);
278
+ expect(vault.has(DIGEST_KEY)).toBe(false);
212
279
  });
213
280
 
214
281
  test("takes a domain-restricted credential from not-connected to connected", async () => {
@@ -309,6 +376,190 @@ describe("hasAcpClaudeToken", () => {
309
376
 
310
377
  expect(oauthMetadata()).toBeUndefined();
311
378
  });
379
+
380
+ test("reports true for an expired token that still has a refresh token", async () => {
381
+ vault.set(ACCESS_KEY, "sk-ant-oat-expired-renewable");
382
+ vault.set(REFRESH_KEY, "refresh-still-good");
383
+ vault.set(EXPIRES_KEY, String(Date.now() - 1000));
384
+
385
+ expect(await hasAcpClaudeToken()).toBe(true);
386
+ });
387
+
388
+ test("reports false for an expired token with no refresh token", async () => {
389
+ vault.set(ACCESS_KEY, "sk-ant-oat-expired-dead");
390
+ vault.set(EXPIRES_KEY, String(Date.now() - 1000));
391
+
392
+ expect(await hasAcpClaudeToken()).toBe(false);
393
+ });
394
+
395
+ test("reports true for a token with no recorded expiry (legacy access-token-only)", async () => {
396
+ vault.set(ACCESS_KEY, "sk-ant-oat-legacy");
397
+
398
+ expect(await hasAcpClaudeToken()).toBe(true);
399
+ expect(await isAcpClaudeTokenExpiring()).toBe(false);
400
+ });
401
+
402
+ test("reports false when only leftover refresh material remains", async () => {
403
+ vault.set(REFRESH_KEY, "refresh-leftover");
404
+ vault.set(EXPIRES_KEY, String(Date.now() - 1000));
405
+
406
+ expect(await hasAcpClaudeToken()).toBe(false);
407
+ });
408
+
409
+ test("reports true for a pasted token whose leftover expiry describes a previous token", async () => {
410
+ vault.set(ACCESS_KEY, "sk-ant-oat-pasted");
411
+ vault.set(REFRESH_KEY, "refresh-previous");
412
+ vault.set(EXPIRES_KEY, String(Date.now() - 1000));
413
+ vault.set(DIGEST_KEY, claudeTokenDigest("sk-ant-oat-previous"));
414
+
415
+ expect(await hasAcpClaudeToken()).toBe(true);
416
+ });
417
+ });
418
+
419
+ describe("persistRefreshedAcpClaudeTokens", () => {
420
+ test("writes a rotated refresh token and new expiry without touching policy", async () => {
421
+ upsertCredentialMetadata(ACP_SERVICE, OAUTH_FIELD, {
422
+ allowedTools: ["some_other_tool"],
423
+ });
424
+ vault.set(ACCESS_KEY, "sk-ant-oat-current");
425
+ vault.set(REFRESH_KEY, "refresh-expected");
426
+ vault.set(DIGEST_KEY, claudeTokenDigest("sk-ant-oat-current"));
427
+ const before = Date.now();
428
+
429
+ await persistRefreshedAcpClaudeTokens(
430
+ {
431
+ accessToken: "sk-ant-oat-refreshed",
432
+ refreshToken: "refresh-rotated",
433
+ expiresIn: 3600,
434
+ },
435
+ "refresh-expected",
436
+ );
437
+
438
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-refreshed");
439
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-rotated");
440
+ expect(vault.get(DIGEST_KEY)).toBe(
441
+ claudeTokenDigest("sk-ant-oat-refreshed"),
442
+ );
443
+ const expiresAt = Number(vault.get(EXPIRES_KEY));
444
+ expect(expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
445
+ expect(oauthMetadata()?.allowedTools).toEqual(["some_other_tool"]);
446
+ });
447
+
448
+ test("keeps the stored refresh token when the response omits a new one", async () => {
449
+ vault.set(ACCESS_KEY, "sk-ant-oat-current");
450
+ vault.set(REFRESH_KEY, "refresh-kept");
451
+ vault.set(DIGEST_KEY, claudeTokenDigest("sk-ant-oat-current"));
452
+
453
+ await persistRefreshedAcpClaudeTokens(
454
+ {
455
+ accessToken: "sk-ant-oat-refreshed",
456
+ expiresIn: 3600,
457
+ },
458
+ "refresh-kept",
459
+ );
460
+
461
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-refreshed");
462
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-kept");
463
+ });
464
+
465
+ test("refuses to persist when the stored refresh token no longer matches", async () => {
466
+ vault.set(ACCESS_KEY, "sk-ant-oat-current");
467
+ vault.set(REFRESH_KEY, "refresh-current");
468
+ vault.set(EXPIRES_KEY, "999");
469
+
470
+ const persisted = await persistRefreshedAcpClaudeTokens(
471
+ {
472
+ accessToken: "sk-ant-oat-stale",
473
+ refreshToken: "refresh-stale-rotated",
474
+ expiresIn: 3600,
475
+ },
476
+ "refresh-old",
477
+ );
478
+
479
+ expect(persisted).toBe(false);
480
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-current");
481
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-current");
482
+ expect(vault.get(EXPIRES_KEY)).toBe("999");
483
+ });
484
+
485
+ test("refuses to persist when the access token is no longer stored", async () => {
486
+ vault.set(REFRESH_KEY, "refresh-leftover");
487
+ vault.set(EXPIRES_KEY, "111");
488
+
489
+ const persisted = await persistRefreshedAcpClaudeTokens(
490
+ {
491
+ accessToken: "sk-ant-oat-resurrected",
492
+ refreshToken: "refresh-rotated",
493
+ expiresIn: 3600,
494
+ },
495
+ "refresh-leftover",
496
+ );
497
+
498
+ expect(persisted).toBe(false);
499
+ expect(vault.has(ACCESS_KEY)).toBe(false);
500
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-leftover");
501
+ expect(vault.get(EXPIRES_KEY)).toBe("111");
502
+ });
503
+
504
+ test("refuses to persist when the access token is not the one the refresh material was written with", async () => {
505
+ vault.set(ACCESS_KEY, "sk-ant-oat-pasted");
506
+ vault.set(REFRESH_KEY, "refresh-previous");
507
+ vault.set(EXPIRES_KEY, "111");
508
+ vault.set(DIGEST_KEY, claudeTokenDigest("sk-ant-oat-previous"));
509
+
510
+ const persisted = await persistRefreshedAcpClaudeTokens(
511
+ {
512
+ accessToken: "sk-ant-oat-overwritten",
513
+ refreshToken: "refresh-rotated",
514
+ expiresIn: 3600,
515
+ },
516
+ "refresh-previous",
517
+ );
518
+
519
+ expect(persisted).toBe(false);
520
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-pasted");
521
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-previous");
522
+ expect(vault.get(EXPIRES_KEY)).toBe("111");
523
+ });
524
+ });
525
+
526
+ describe("clearAcpClaudeRefreshToken", () => {
527
+ test("leaves the stored refresh token when it no longer matches the rejected one", async () => {
528
+ vault.set(REFRESH_KEY, "refresh-current");
529
+
530
+ await clearAcpClaudeRefreshToken("refresh-old");
531
+
532
+ expect(vault.get(REFRESH_KEY)).toBe("refresh-current");
533
+ });
534
+ });
535
+
536
+ describe("forgetAcpClaudeRenewalStateIfUnbound", () => {
537
+ test("clears leftover renewal fields after the access token was replaced", async () => {
538
+ vault.set(ACCESS_KEY, "sk-ant-oat-pasted");
539
+ vault.set(REFRESH_KEY, "stale-refresh");
540
+ vault.set(EXPIRES_KEY, "111");
541
+ vault.set(DIGEST_KEY, claudeTokenDigest("sk-ant-oat-previous"));
542
+
543
+ await forgetAcpClaudeRenewalStateIfUnbound();
544
+
545
+ expect(vault.get(ACCESS_KEY)).toBe("sk-ant-oat-pasted");
546
+ expect(vault.has(REFRESH_KEY)).toBe(false);
547
+ expect(vault.has(EXPIRES_KEY)).toBe(false);
548
+ expect(vault.has(DIGEST_KEY)).toBe(false);
549
+ });
550
+
551
+ test("keeps renewal fields when they still describe the stored access token", async () => {
552
+ vault.set(ACCESS_KEY, "sk-ant-oat-current");
553
+ vault.set(REFRESH_KEY, "keep-refresh");
554
+ vault.set(EXPIRES_KEY, "222");
555
+ vault.set(DIGEST_KEY, claudeTokenDigest("sk-ant-oat-current"));
556
+
557
+ await forgetAcpClaudeRenewalStateIfUnbound();
558
+
559
+ expect(vault.get(REFRESH_KEY)).toBe("keep-refresh");
560
+ expect(vault.get(EXPIRES_KEY)).toBe("222");
561
+ expect(vault.get(DIGEST_KEY)).toBe(claudeTokenDigest("sk-ant-oat-current"));
562
+ });
312
563
  });
313
564
 
314
565
  describe("storeAcpClaudeToken: retiring the card registry", () => {
@@ -5,6 +5,9 @@
5
5
  import { describe, expect, test } from "bun:test";
6
6
 
7
7
  import {
8
+ ACP_OAUTH_ACCESS_DIGEST_FIELD,
9
+ ACP_OAUTH_EXPIRES_AT_FIELD,
10
+ ACP_OAUTH_REFRESH_TOKEN_FIELD,
8
11
  ACP_OAUTH_TOKEN_FIELD,
9
12
  ACP_SERVICE,
10
13
  AcpCredentialFormatError,
@@ -70,6 +73,9 @@ describe("constants", () => {
70
73
  test("expose the ACP service and OAuth field names", () => {
71
74
  expect(ACP_SERVICE).toBe("acp");
72
75
  expect(ACP_OAUTH_TOKEN_FIELD).toBe("claude_oauth_token");
76
+ expect(ACP_OAUTH_REFRESH_TOKEN_FIELD).toBe("claude_oauth_refresh_token");
77
+ expect(ACP_OAUTH_EXPIRES_AT_FIELD).toBe("claude_oauth_expires_at");
78
+ expect(ACP_OAUTH_ACCESS_DIGEST_FIELD).toBe("claude_oauth_access_digest");
73
79
  });
74
80
  });
75
81
 
@@ -81,6 +87,13 @@ describe("isAcpClaudeOauthField", () => {
81
87
  test("rejects other services and fields", () => {
82
88
  expect(isAcpClaudeOauthField("acp", "openai_api_key")).toBe(false);
83
89
  expect(isAcpClaudeOauthField("acp", "codex_api_key")).toBe(false);
90
+ expect(isAcpClaudeOauthField("acp", "claude_oauth_refresh_token")).toBe(
91
+ false,
92
+ );
93
+ expect(isAcpClaudeOauthField("acp", "claude_oauth_expires_at")).toBe(false);
94
+ expect(isAcpClaudeOauthField("acp", "claude_oauth_access_digest")).toBe(
95
+ false,
96
+ );
84
97
  expect(isAcpClaudeOauthField("sentry", "claude_oauth_token")).toBe(false);
85
98
  expect(isAcpClaudeOauthField("openai", "api_key")).toBe(false);
86
99
  });