@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
@@ -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
+ });
@@ -38,7 +38,17 @@ import {
38
38
  getCredentialMetadata,
39
39
  upsertCredentialMetadata,
40
40
  } from "../../tools/credentials/metadata-store.js";
41
- import { ACP_OAUTH_TOKEN_FIELD, ACP_SERVICE } from "../acp-credentials.js";
41
+ import {
42
+ ACP_OAUTH_EXPIRES_AT_FIELD,
43
+ ACP_OAUTH_REFRESH_TOKEN_FIELD,
44
+ ACP_OAUTH_TOKEN_FIELD,
45
+ ACP_SERVICE,
46
+ } from "../acp-credentials.js";
47
+
48
+ const ensureFreshAcpClaudeToken = mock(async () => {});
49
+ mock.module("../claude-token-refresh.js", () => ({
50
+ ensureFreshAcpClaudeToken,
51
+ }));
42
52
 
43
53
  const mockLogWarn = mock((_fields: unknown, _msg: string) => {});
44
54
 
@@ -76,6 +86,7 @@ beforeEach(() => {
76
86
  _resetBackend();
77
87
  _setMetadataPath(join(TEST_DIR, "metadata.json"));
78
88
  mockLogWarn.mockClear();
89
+ ensureFreshAcpClaudeToken.mockClear();
79
90
  });
80
91
 
81
92
  afterEach(() => {
@@ -127,6 +138,54 @@ describe("prepareAgentEnv — claude-agent-acp gating", () => {
127
138
  });
128
139
 
129
140
  expect(prepared.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("vault-AAA");
141
+ expect(ensureFreshAcpClaudeToken).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ test("renews the vault token before a spawn, not when a config override supplies one", async () => {
145
+ await seedVaultToken("vault-unused");
146
+
147
+ const prepared = await prepareAgentEnv({
148
+ command: "claude-agent-acp",
149
+ args: [],
150
+ env: { CLAUDE_CODE_OAUTH_TOKEN: "config-override" },
151
+ });
152
+
153
+ expect(prepared.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("config-override");
154
+ expect(ensureFreshAcpClaudeToken).not.toHaveBeenCalled();
155
+ });
156
+
157
+ test("injects only the access token; refresh material never reaches the child env", async () => {
158
+ const refreshToken = "refresh-must-not-cross-the-child-boundary";
159
+ await seedVaultToken("sk-ant-oat-access");
160
+ await seedVaultValue(ACP_OAUTH_REFRESH_TOKEN_FIELD, refreshToken);
161
+ await seedVaultValue(
162
+ ACP_OAUTH_EXPIRES_AT_FIELD,
163
+ String(Date.now() + 60 * 60 * 1000),
164
+ );
165
+
166
+ const prepared = await prepareAgentEnv({
167
+ command: "claude-agent-acp",
168
+ args: [],
169
+ });
170
+
171
+ expect(prepared.env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("sk-ant-oat-access");
172
+ expect(JSON.stringify(prepared.env)).not.toContain(refreshToken);
173
+ expect(prepared.env).not.toHaveProperty("CLAUDE_CODE_OAUTH_REFRESH_TOKEN");
174
+
175
+ let leaked: string | undefined;
176
+ const brokerResult = await credentialBroker.serverUse({
177
+ service: ACP_SERVICE,
178
+ field: ACP_OAUTH_REFRESH_TOKEN_FIELD,
179
+ toolName: ACP_SPAWN_TOOL,
180
+ execute: async (value) => {
181
+ leaked = value;
182
+ },
183
+ });
184
+ expect(brokerResult.success).toBe(false);
185
+ expect(leaked).toBeUndefined();
186
+ expect(brokerResult.success ? undefined : brokerResult.reason).toContain(
187
+ "No credential found for acp/claude_oauth_refresh_token",
188
+ );
130
189
  });
131
190
 
132
191
  test("auto-registers metadata with acp_spawn in allowedTools when none exists", async () => {