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

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 (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -93,6 +93,27 @@ describe("user plugin loader", () => {
93
93
  expect(initHooks).toHaveLength(1);
94
94
  });
95
95
 
96
+ test("loads a plugin via a standard plugin.json manifest", async () => {
97
+ const pluginDir = join(PLUGINS_DIR, "standard-plugin");
98
+ mkdirSync(join(pluginDir, "hooks"), { recursive: true });
99
+ writeFileSync(
100
+ join(pluginDir, "plugin.json"),
101
+ JSON.stringify({
102
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
103
+ name: "standard-plugin",
104
+ version: "1.0.0",
105
+ }),
106
+ );
107
+ writeFileSync(
108
+ join(pluginDir, "hooks", "init.ts"),
109
+ "export default async function init(_ctx: unknown): Promise<void> {}\n",
110
+ );
111
+
112
+ await loadUserPlugins();
113
+
114
+ expect(await getUserHooksFor("init")).toHaveLength(1);
115
+ });
116
+
96
117
  test("per-plugin failure is isolated: other plugins still load", async () => {
97
118
  // Plugin A has a malformed package.json; the loader must isolate the
98
119
  // failure and still load the healthy sibling.
@@ -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
  });
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Tests for the Claude ACP OAuth renewal policy.
3
+ *
4
+ * Storage is mocked (it belongs to `acp-claude-oauth.ts`, tested separately) so
5
+ * these assert only the decisions this module owns: when to spend the refresh
6
+ * token, what to persist, and what to tear down when the provider rejects it.
7
+ *
8
+ * `isCredentialError` is deliberately not mocked. Whether a failure is
9
+ * permanent or transient is the hinge of the whole module, and asserting it
10
+ * against the real classifier is the only way these tests stay honest about
11
+ * which thrown errors clear a user's stored credential.
12
+ */
13
+
14
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
15
+
16
+ import type { OAuth2TokenResult } from "../../security/oauth2.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Mocks: wired BEFORE importing the module via dynamic import.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ let expiring = false;
23
+ let storedAccessToken = true;
24
+ let storedRefreshToken: string | null = null;
25
+ let refreshImpl: () => Promise<OAuth2TokenResult> = async () => {
26
+ throw new Error("refreshOAuth2Token not stubbed for this test");
27
+ };
28
+
29
+ let spawnDenial: string | undefined = undefined;
30
+ const isAcpClaudeTokenExpiring = mock(async () => expiring);
31
+ const hasStoredAcpClaudeAccessToken = mock(async () => storedAccessToken);
32
+ const forgetAcpClaudeRenewalStateIfUnbound = mock(async () => {});
33
+ const readAcpClaudeRefreshToken = mock(async () => storedRefreshToken);
34
+ const persistRefreshedAcpClaudeTokens = mock(
35
+ async (_tokens: unknown, _expectedRefreshToken: string) => true,
36
+ );
37
+ const clearAcpClaudeRefreshToken = mock(async () => {});
38
+ const acpSpawnCredentialDenialReason = mock((_field: string) => spawnDenial);
39
+ const refreshOAuth2Token = mock(async (..._args: unknown[]) => refreshImpl());
40
+
41
+ mock.module("../acp-claude-oauth.js", () => ({
42
+ CLAUDE_OAUTH_CONFIG: {
43
+ tokenExchangeUrl: "https://platform.claude.com/v1/oauth/token",
44
+ clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
45
+ tokenExchangeBodyFormat: "json",
46
+ },
47
+ isAcpClaudeTokenExpiring,
48
+ hasStoredAcpClaudeAccessToken,
49
+ forgetAcpClaudeRenewalStateIfUnbound,
50
+ readAcpClaudeRefreshToken,
51
+ persistRefreshedAcpClaudeTokens,
52
+ clearAcpClaudeRefreshToken,
53
+ }));
54
+ mock.module("../prepare-agent-env.js", () => ({
55
+ acpSpawnCredentialDenialReason,
56
+ }));
57
+ mock.module("../../security/oauth2.js", () => ({ refreshOAuth2Token }));
58
+
59
+ const { ensureFreshAcpClaudeToken } =
60
+ await import("../claude-token-refresh.js");
61
+
62
+ beforeEach(() => {
63
+ expiring = false;
64
+ storedAccessToken = true;
65
+ storedRefreshToken = null;
66
+ spawnDenial = undefined;
67
+ refreshImpl = async () => {
68
+ throw new Error("refreshOAuth2Token not stubbed for this test");
69
+ };
70
+ isAcpClaudeTokenExpiring.mockClear();
71
+ hasStoredAcpClaudeAccessToken.mockClear();
72
+ forgetAcpClaudeRenewalStateIfUnbound.mockClear();
73
+ readAcpClaudeRefreshToken.mockClear();
74
+ persistRefreshedAcpClaudeTokens.mockClear();
75
+ clearAcpClaudeRefreshToken.mockClear();
76
+ acpSpawnCredentialDenialReason.mockClear();
77
+ refreshOAuth2Token.mockClear();
78
+ });
79
+
80
+ describe("ensureFreshAcpClaudeToken: skip paths", () => {
81
+ test("does nothing while the token is still fresh", async () => {
82
+ expiring = false;
83
+ storedRefreshToken = "refresh-me";
84
+
85
+ await ensureFreshAcpClaudeToken();
86
+
87
+ expect(refreshOAuth2Token).not.toHaveBeenCalled();
88
+ expect(persistRefreshedAcpClaudeTokens).not.toHaveBeenCalled();
89
+ });
90
+
91
+ test("does nothing when expiring with no refresh token stored", async () => {
92
+ expiring = true;
93
+ storedRefreshToken = null;
94
+
95
+ await ensureFreshAcpClaudeToken();
96
+
97
+ expect(refreshOAuth2Token).not.toHaveBeenCalled();
98
+ expect(clearAcpClaudeRefreshToken).not.toHaveBeenCalled();
99
+ });
100
+
101
+ test("drops unbound leftover refresh material before deciding to renew", async () => {
102
+ expiring = false;
103
+ storedAccessToken = true;
104
+
105
+ await ensureFreshAcpClaudeToken();
106
+
107
+ expect(forgetAcpClaudeRenewalStateIfUnbound).toHaveBeenCalledTimes(1);
108
+ expect(refreshOAuth2Token).not.toHaveBeenCalled();
109
+ });
110
+
111
+ test("does not mint a new access token after the stored one was deleted", async () => {
112
+ expiring = true;
113
+ storedAccessToken = false;
114
+ storedRefreshToken = "refresh-leftover";
115
+
116
+ await ensureFreshAcpClaudeToken();
117
+
118
+ expect(refreshOAuth2Token).not.toHaveBeenCalled();
119
+ expect(persistRefreshedAcpClaudeTokens).not.toHaveBeenCalled();
120
+ expect(clearAcpClaudeRefreshToken).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+
124
+ describe("ensureFreshAcpClaudeToken: renewal", () => {
125
+ test("refreshes against Claude's token endpoint and persists the rotated set", async () => {
126
+ expiring = true;
127
+ storedRefreshToken = "refresh-me";
128
+ refreshImpl = async () => ({
129
+ accessToken: "sk-ant-oat-new",
130
+ refreshToken: "refresh-rotated",
131
+ expiresIn: 3600,
132
+ });
133
+
134
+ await ensureFreshAcpClaudeToken();
135
+
136
+ expect(refreshOAuth2Token).toHaveBeenCalledTimes(1);
137
+ const args = refreshOAuth2Token.mock.calls[0];
138
+ expect(args[0]).toBe("https://platform.claude.com/v1/oauth/token");
139
+ expect(args[1]).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e");
140
+ expect(args[2]).toBe("refresh-me");
141
+ expect(args[3]).toBeUndefined();
142
+ expect(args[5]).toBe("json");
143
+
144
+ expect(persistRefreshedAcpClaudeTokens).toHaveBeenCalledWith(
145
+ {
146
+ accessToken: "sk-ant-oat-new",
147
+ refreshToken: "refresh-rotated",
148
+ expiresIn: 3600,
149
+ },
150
+ "refresh-me",
151
+ );
152
+ });
153
+
154
+ test("does not treat a skipped stale persist as a failure", async () => {
155
+ expiring = true;
156
+ storedRefreshToken = "refresh-me";
157
+ persistRefreshedAcpClaudeTokens.mockResolvedValueOnce(false);
158
+ refreshImpl = async () => ({
159
+ accessToken: "sk-ant-oat-stale",
160
+ refreshToken: "refresh-rotated",
161
+ expiresIn: 3600,
162
+ });
163
+
164
+ await expect(ensureFreshAcpClaudeToken()).resolves.toBeUndefined();
165
+ expect(clearAcpClaudeRefreshToken).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test("runs one refresh for concurrent spawns", async () => {
169
+ expiring = true;
170
+ storedRefreshToken = "refresh-me";
171
+
172
+ let resolveRefresh!: (v: OAuth2TokenResult) => void;
173
+ const gate = new Promise<OAuth2TokenResult>((resolve) => {
174
+ resolveRefresh = resolve;
175
+ });
176
+ refreshImpl = () => gate;
177
+
178
+ const both = Promise.all([
179
+ ensureFreshAcpClaudeToken(),
180
+ ensureFreshAcpClaudeToken(),
181
+ ]);
182
+ for (let i = 0; i < 20; i++) {
183
+ await Promise.resolve();
184
+ }
185
+ resolveRefresh({ accessToken: "sk-ant-oat-new" });
186
+ await both;
187
+
188
+ expect(refreshOAuth2Token).toHaveBeenCalledTimes(1);
189
+ });
190
+ });
191
+
192
+ describe("ensureFreshAcpClaudeToken: policy", () => {
193
+ test("does not refresh when the spawn policy denies reading the credential", async () => {
194
+ expiring = true;
195
+ storedRefreshToken = "refresh-me";
196
+ spawnDenial = 'Tool "acp_spawn" is not allowed to use this credential';
197
+
198
+ await ensureFreshAcpClaudeToken();
199
+
200
+ expect(refreshOAuth2Token).not.toHaveBeenCalled();
201
+ expect(persistRefreshedAcpClaudeTokens).not.toHaveBeenCalled();
202
+ });
203
+ });
204
+
205
+ describe("ensureFreshAcpClaudeToken: failures", () => {
206
+ test("clears refresh material when the provider rejects the refresh token", async () => {
207
+ expiring = true;
208
+ storedRefreshToken = "revoked";
209
+ refreshImpl = async () => {
210
+ throw new Error("OAuth2 token refresh failed (HTTP 400 invalid_grant)");
211
+ };
212
+
213
+ await ensureFreshAcpClaudeToken();
214
+
215
+ expect(clearAcpClaudeRefreshToken).toHaveBeenCalledWith("revoked");
216
+ expect(persistRefreshedAcpClaudeTokens).not.toHaveBeenCalled();
217
+ });
218
+
219
+ test("clears refresh material when the refresh succeeds with no access token", async () => {
220
+ expiring = true;
221
+ storedRefreshToken = "refresh-me";
222
+ refreshImpl = async () =>
223
+ ({
224
+ accessToken: "",
225
+ refreshToken: "refresh-rotated",
226
+ expiresIn: 3600,
227
+ }) as OAuth2TokenResult;
228
+
229
+ await ensureFreshAcpClaudeToken();
230
+
231
+ expect(clearAcpClaudeRefreshToken).toHaveBeenCalledWith("refresh-me");
232
+ expect(persistRefreshedAcpClaudeTokens).not.toHaveBeenCalled();
233
+ });
234
+
235
+ test("keeps refresh material on a transient failure", async () => {
236
+ expiring = true;
237
+ storedRefreshToken = "refresh-me";
238
+ refreshImpl = async () => {
239
+ throw new Error("fetch failed: ECONNREFUSED");
240
+ };
241
+
242
+ await ensureFreshAcpClaudeToken();
243
+
244
+ expect(clearAcpClaudeRefreshToken).not.toHaveBeenCalled();
245
+ expect(persistRefreshedAcpClaudeTokens).not.toHaveBeenCalled();
246
+ });
247
+
248
+ test("never throws, so a refresh outage cannot fail the spawn", async () => {
249
+ expiring = true;
250
+ storedRefreshToken = "refresh-me";
251
+ refreshImpl = async () => {
252
+ throw new Error("HTTP 401 unauthorized");
253
+ };
254
+
255
+ await expect(ensureFreshAcpClaudeToken()).resolves.toBeUndefined();
256
+ });
257
+ });