@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.
- package/docs/desktop-browser-cli.md +33 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
- package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
- package/openapi.yaml +50 -4
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
- package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
- package/src/__tests__/credential-routes.test.ts +38 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +6 -2
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +185 -30
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/oauth-commands-routes.test.ts +55 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
- package/src/__tests__/oauth-providers-routes.test.ts +1 -0
- package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
- package/src/__tests__/ui-channel-variants.test.ts +1 -56
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
- package/src/acp/__tests__/acp-credentials.test.ts +13 -0
- package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
- package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
- package/src/acp/acp-claude-oauth.ts +328 -14
- package/src/acp/acp-credentials.ts +19 -0
- package/src/acp/claude-token-refresh.ts +150 -0
- package/src/acp/prepare-agent-env.ts +21 -6
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +132 -0
- package/src/calls/voice-session-bridge.ts +56 -26
- package/src/calls/voice-triage-escalate.ts +3 -3
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/oauth/status.ts +5 -0
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
- package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +11 -3
- package/src/config/loader.ts +1 -0
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/services.ts +10 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +19 -7
- package/src/daemon/conversation-surfaces.ts +6 -1
- package/src/daemon/conversation-tool-setup.ts +37 -43
- package/src/daemon/host-cu-proxy.ts +94 -31
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
- package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +47 -0
- package/src/live-voice/__tests__/session-controls.test.ts +113 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +88 -7
- package/src/live-voice/protocol.ts +69 -0
- package/src/live-voice/session-controls.ts +139 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
- package/src/oauth/connection-resolver.test.ts +27 -0
- package/src/oauth/connection-resolver.ts +25 -1
- package/src/oauth/provider-serializer.ts +9 -0
- package/src/oauth/seed-providers.ts +121 -2
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
- package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
- package/src/runtime/routes/app-management-routes.ts +17 -2
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/credential-routes.ts +1 -4
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/oauth-commands-routes.ts +14 -0
- package/src/runtime/routes/oauth-providers.ts +7 -0
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +3 -4
- package/src/tools/ui-surface/channel-variants.ts +10 -59
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/watch/watch-retro.ts +6 -8
- package/src/desktop/desktop-feature.test.ts +0 -18
- 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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
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
|
-
|
|
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
|
+
});
|