@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
@@ -8,18 +8,29 @@
8
8
  * builds one against the manual redirect page and parses the `code#state`
9
9
  * string the user copies back. Both converge on `storeAcpClaudeToken`, which
10
10
  * writes the `acp/claude_oauth_token` vault field the ACP broker reads at
11
- * spawn time and provisions the `acp_spawn` read policy.
11
+ * spawn time, persists any refresh token and expiry the exchange returned,
12
+ * and provisions the `acp_spawn` read policy.
13
+ *
14
+ * Refresh, expiry, and the bound-access digest are stored without credential
15
+ * metadata so the broker cannot hand them to a spawned agent. The policy for
16
+ * when to spend the refresh token lives in `claude-token-refresh.ts`.
12
17
  */
13
18
 
19
+ import { computeExpiresAt, isTokenExpired } from "@vellumai/credential-storage";
20
+
14
21
  import { credentialKey } from "../security/credential-key.js";
15
22
  import type { OAuth2Config } from "../security/oauth2.js";
16
23
  import {
24
+ deleteSecureKeyAsync,
17
25
  getSecureKeyAsync,
18
26
  setSecureKeyAsync,
19
27
  } from "../security/secure-keys.js";
20
28
  import { getLogger } from "../util/logger.js";
21
29
  import { claudeTokenDigest } from "./acp-auth-marker-store.js";
22
30
  import {
31
+ ACP_OAUTH_ACCESS_DIGEST_FIELD,
32
+ ACP_OAUTH_EXPIRES_AT_FIELD,
33
+ ACP_OAUTH_REFRESH_TOKEN_FIELD,
23
34
  ACP_OAUTH_TOKEN_FIELD,
24
35
  ACP_SERVICE,
25
36
  classifyAnthropicToken,
@@ -32,6 +43,22 @@ import {
32
43
 
33
44
  const log = getLogger("acp:claude-oauth");
34
45
 
46
+ /**
47
+ * Serializes every Claude OAuth token-set write (Connect, renewal persist,
48
+ * companion clear) so a compare-and-write cannot interleave with another
49
+ * writer. The network refresh itself stays outside this queue.
50
+ */
51
+ let tokenWriteQueue: Promise<unknown> = Promise.resolve();
52
+
53
+ function withAcpClaudeTokenWrite<T>(fn: () => Promise<T>): Promise<T> {
54
+ const run = tokenWriteQueue.then(fn, fn);
55
+ tokenWriteQueue = run.then(
56
+ () => undefined,
57
+ () => undefined,
58
+ );
59
+ return run;
60
+ }
61
+
35
62
  /**
36
63
  * Verified Claude Code public OAuth client. PKCE-only (no client secret);
37
64
  * the single `user:inference` scope is what the ACP adapter's
@@ -137,20 +164,103 @@ export async function storedClaudeTokenDigest(): Promise<string | undefined> {
137
164
  return token ? claudeTokenDigest(token) : undefined;
138
165
  }
139
166
 
167
+ /** Tokens as returned by an authorization-code exchange or a refresh. */
168
+ export interface AcpClaudeTokens {
169
+ accessToken: string;
170
+ refreshToken?: string;
171
+ /** Lifetime in seconds, as the provider reports it. */
172
+ expiresIn?: number;
173
+ }
174
+
175
+ function vaultKey(field: string): string {
176
+ return credentialKey(ACP_SERVICE, field);
177
+ }
178
+
179
+ /**
180
+ * Write a companion field, or delete it when there is no value.
181
+ *
182
+ * Both outcomes are checked. The store signals failure by return value rather
183
+ * than by throwing (`setSecureKeyAsync` returns false, `deleteSecureKeyAsync`
184
+ * returns `"error"` on timeout), so ignoring them would let a backend hiccup
185
+ * report success while leaving the token-set fields out of sync: an access
186
+ * token with no way to renew it, or a new access token still paired with a
187
+ * previous connect's refresh token.
188
+ */
189
+ async function writeOrClear(
190
+ field: string,
191
+ value: string | undefined,
192
+ ): Promise<void> {
193
+ if (value) {
194
+ const stored = await setSecureKeyAsync(vaultKey(field), value);
195
+ if (!stored) {
196
+ throw new Error(
197
+ `Failed to store Claude OAuth ${field} in secure storage.`,
198
+ );
199
+ }
200
+ return;
201
+ }
202
+ const result = await deleteSecureKeyAsync(vaultKey(field));
203
+ if (result === "error") {
204
+ throw new Error(
205
+ `Failed to clear Claude OAuth ${field} from secure storage.`,
206
+ );
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Write a Claude OAuth token set to the `acp/claude_oauth_*` vault fields.
212
+ * Throws when the backing store rejects any of the writes.
213
+ *
214
+ * The refresh token and expiry are written as a set with the access token:
215
+ * when the provider returns neither, any previously stored values are cleared
216
+ * rather than left behind. A stale refresh token paired with a newly connected
217
+ * access token would otherwise renew into the credential from a previous
218
+ * connect.
219
+ *
220
+ * Only the access-token field gets credential metadata. `serverUse` refuses
221
+ * any field without metadata, so the refresh token and expiry stay
222
+ * unreachable through the broker and cannot be injected into a spawned
223
+ * agent's env.
224
+ */
225
+ async function writeConnectedTokenSet(tokens: AcpClaudeTokens): Promise<void> {
226
+ await withAcpClaudeTokenWrite(async () => {
227
+ const stored = await setSecureKeyAsync(
228
+ vaultKey(ACP_OAUTH_TOKEN_FIELD),
229
+ tokens.accessToken,
230
+ );
231
+ if (!stored) {
232
+ throw new Error("Failed to store Claude OAuth token in secure storage.");
233
+ }
234
+
235
+ await writeOrClear(ACP_OAUTH_REFRESH_TOKEN_FIELD, tokens.refreshToken);
236
+ const expiresAt = computeExpiresAt(tokens.expiresIn);
237
+ await writeOrClear(
238
+ ACP_OAUTH_EXPIRES_AT_FIELD,
239
+ expiresAt == null ? undefined : String(expiresAt),
240
+ );
241
+ const hasRenewal = Boolean(tokens.refreshToken) || expiresAt != null;
242
+ await writeOrClear(
243
+ ACP_OAUTH_ACCESS_DIGEST_FIELD,
244
+ hasRenewal ? claudeTokenDigest(tokens.accessToken) : undefined,
245
+ );
246
+ });
247
+ }
248
+
140
249
  /**
141
- * Store a captured Claude OAuth token in the `acp/claude_oauth_token` vault
142
- * field and provision the policy the broker applies at spawn time: grant the
250
+ * Store a captured Claude OAuth token set in the `acp/claude_oauth_*` vault
251
+ * fields and provision the policy the broker applies at spawn time: grant the
143
252
  * `acp_spawn` read and lift any domain restriction. Throws when the backing
144
253
  * store rejects the write.
254
+ *
255
+ * A string argument is treated as an access-token-only write and clears any
256
+ * companion refresh or expiry fields so they cannot describe a previous token.
145
257
  */
146
- export async function storeAcpClaudeToken(token: string): Promise<void> {
147
- const stored = await setSecureKeyAsync(
148
- credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD),
149
- token,
150
- );
151
- if (!stored) {
152
- throw new Error("Failed to store Claude OAuth token in secure storage.");
153
- }
258
+ export async function storeAcpClaudeToken(
259
+ tokens: string | AcpClaudeTokens,
260
+ ): Promise<void> {
261
+ const tokenSet: AcpClaudeTokens =
262
+ typeof tokens === "string" ? { accessToken: tokens } : tokens;
263
+ await writeConnectedTokenSet(tokenSet);
154
264
  // Repair rather than merely ensure the policy: an explicit Connect is a
155
265
  // deliberate opt-in to ACP, so this widens a credential the broker would
156
266
  // otherwise keep denying the spawn read on, which would dead-loop the Connect
@@ -241,6 +351,11 @@ export async function notifyAcpConnectRetired(): Promise<void> {
241
351
  * spawn-time broker read applies, so "connected" means precisely "the spawn
242
352
  * would get this token". The token-shape guard stays here instead: the broker
243
353
  * knows nothing about Anthropic token formats.
354
+ *
355
+ * An expired token with no refresh token is the same shape of problem, and is
356
+ * likewise NOT connected: the card has to stay up so the user can reconnect.
357
+ * An expired token that still has a refresh token IS connected, because
358
+ * `ensureFreshAcpClaudeToken` renews it on the next spawn.
244
359
  */
245
360
  export async function hasAcpClaudeToken(): Promise<boolean> {
246
361
  return (await usableStoredClaudeToken()) !== undefined;
@@ -257,9 +372,7 @@ export async function hasAcpClaudeToken(): Promise<boolean> {
257
372
  * card and no working token.
258
373
  */
259
374
  async function usableStoredClaudeToken(): Promise<string | undefined> {
260
- const token = await getSecureKeyAsync(
261
- credentialKey(ACP_SERVICE, ACP_OAUTH_TOKEN_FIELD),
262
- );
375
+ const token = await getSecureKeyAsync(vaultKey(ACP_OAUTH_TOKEN_FIELD));
263
376
  if (token == null || token.length === 0) {
264
377
  return undefined;
265
378
  }
@@ -274,6 +387,15 @@ async function usableStoredClaudeToken(): Promise<string | undefined> {
274
387
  );
275
388
  return undefined;
276
389
  }
390
+ if (await isAcpClaudeTokenExpiring()) {
391
+ const boundDigest = await readBoundAccessDigest();
392
+ if (boundDigest && boundDigest !== claudeTokenDigest(token)) {
393
+ return token;
394
+ }
395
+ if (!(await hasAcpClaudeRefreshToken())) {
396
+ return undefined;
397
+ }
398
+ }
277
399
  return token;
278
400
  }
279
401
 
@@ -326,3 +448,195 @@ export async function acpConnectCardStillWarranted(
326
448
  await import("./acp-auth-marker-store.js");
327
449
  return claudeCredentialRefused(resolved);
328
450
  }
451
+
452
+ /**
453
+ * Persist a token set obtained by renewing an existing credential, leaving
454
+ * the read policy exactly as it was.
455
+ *
456
+ * A renewal happens on a passive spawn with no user in the loop, so it must
457
+ * not widen what the credential is allowed to do. Granting `acp_spawn` here
458
+ * would let a background refresh restore a permission a user or admin had
459
+ * removed from `allowedTools`.
460
+ *
461
+ * A rotated refresh token is written when the provider returns one. When the
462
+ * response omits a refresh token, the stored refresh token is kept so a
463
+ * non-rotating grant does not lose its renewal material. A missing or
464
+ * non-positive `expiresIn` clears the recorded expiry so a stale past expiry
465
+ * cannot condemn the new access token.
466
+ *
467
+ * `expectedRefreshToken` is the refresh token this request spent. If the
468
+ * vault no longer holds that value, the access-token field is gone, or the
469
+ * stored access token is not the one this refresh material was written
470
+ * with, the persist is skipped so a newer token set is not overwritten and
471
+ * a deleted or pasted credential is not replaced. Returns whether the write
472
+ * happened.
473
+ */
474
+ export async function persistRefreshedAcpClaudeTokens(
475
+ tokens: AcpClaudeTokens,
476
+ expectedRefreshToken: string,
477
+ ): Promise<boolean> {
478
+ if (!tokens.accessToken) {
479
+ throw new Error("Refreshed Claude OAuth response had no access token.");
480
+ }
481
+ return withAcpClaudeTokenWrite(async () => {
482
+ const current = await readAcpClaudeRefreshToken();
483
+ if (current !== expectedRefreshToken) {
484
+ log.info(
485
+ "Skipping Claude OAuth refresh persist because the stored refresh token changed while the request was in flight",
486
+ );
487
+ return false;
488
+ }
489
+ const access = await getSecureKeyAsync(vaultKey(ACP_OAUTH_TOKEN_FIELD));
490
+ if (access == null || access.length === 0) {
491
+ log.info(
492
+ "Skipping Claude OAuth refresh persist because the access token is no longer stored",
493
+ );
494
+ return false;
495
+ }
496
+ if (!(await renewalIsBoundTo(access))) {
497
+ log.info(
498
+ "Skipping Claude OAuth refresh persist because the stored access token is not the one this refresh material was written with",
499
+ );
500
+ return false;
501
+ }
502
+ const stored = await setSecureKeyAsync(
503
+ vaultKey(ACP_OAUTH_TOKEN_FIELD),
504
+ tokens.accessToken,
505
+ );
506
+ if (!stored) {
507
+ throw new Error("Failed to store Claude OAuth token in secure storage.");
508
+ }
509
+ if (tokens.refreshToken) {
510
+ await writeOrClear(ACP_OAUTH_REFRESH_TOKEN_FIELD, tokens.refreshToken);
511
+ }
512
+ const expiresAt = computeExpiresAt(tokens.expiresIn);
513
+ await writeOrClear(
514
+ ACP_OAUTH_EXPIRES_AT_FIELD,
515
+ expiresAt == null ? undefined : String(expiresAt),
516
+ );
517
+ await writeOrClear(
518
+ ACP_OAUTH_ACCESS_DIGEST_FIELD,
519
+ claudeTokenDigest(tokens.accessToken),
520
+ );
521
+ return true;
522
+ });
523
+ }
524
+
525
+ /**
526
+ * The stored absolute expiry in epoch milliseconds, or null when unknown.
527
+ *
528
+ * Unknown is the norm for tokens connected before expiry was recorded, and is
529
+ * treated as "assume usable": we cannot tell fresh from expired, and guessing
530
+ * expired would refresh or discard a perfectly good token.
531
+ */
532
+ async function readExpiresAt(): Promise<number | null> {
533
+ const raw = await getSecureKeyAsync(vaultKey(ACP_OAUTH_EXPIRES_AT_FIELD));
534
+ if (!raw) {
535
+ return null;
536
+ }
537
+ const parsed = Number(raw);
538
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
539
+ }
540
+
541
+ /**
542
+ * Whether the stored access token is past its recorded expiry, or close
543
+ * enough that it should be renewed before use. False when no expiry was
544
+ * recorded.
545
+ */
546
+ export async function isAcpClaudeTokenExpiring(): Promise<boolean> {
547
+ return isTokenExpired(await readExpiresAt());
548
+ }
549
+
550
+ /** The stored refresh token, or null when none was captured. */
551
+ export async function readAcpClaudeRefreshToken(): Promise<string | null> {
552
+ const token = await getSecureKeyAsync(vaultKey(ACP_OAUTH_REFRESH_TOKEN_FIELD));
553
+ return token != null && token.length > 0 ? token : null;
554
+ }
555
+
556
+ /** Whether renewal material is on hand, without revealing it. */
557
+ export async function hasAcpClaudeRefreshToken(): Promise<boolean> {
558
+ return (await readAcpClaudeRefreshToken()) != null;
559
+ }
560
+
561
+ /**
562
+ * Whether the access-token vault field is present. Presence only: not
563
+ * whether the value is usable, expired, or readable by the spawn broker.
564
+ *
565
+ * Renewal spends a refresh token to replace this field. If the field is
566
+ * gone, there is no credential to renew.
567
+ */
568
+ export async function hasStoredAcpClaudeAccessToken(): Promise<boolean> {
569
+ const token = await getSecureKeyAsync(vaultKey(ACP_OAUTH_TOKEN_FIELD));
570
+ return token != null && token.length > 0;
571
+ }
572
+
573
+ async function readBoundAccessDigest(): Promise<string | null> {
574
+ const digest = await getSecureKeyAsync(
575
+ vaultKey(ACP_OAUTH_ACCESS_DIGEST_FIELD),
576
+ );
577
+ return digest != null && digest.length > 0 ? digest : null;
578
+ }
579
+
580
+ async function renewalIsBoundTo(accessToken: string): Promise<boolean> {
581
+ const digest = await readBoundAccessDigest();
582
+ return digest != null && digest === claudeTokenDigest(accessToken);
583
+ }
584
+
585
+ async function clearRenewalFields(): Promise<void> {
586
+ await writeOrClear(ACP_OAUTH_REFRESH_TOKEN_FIELD, undefined);
587
+ await writeOrClear(ACP_OAUTH_EXPIRES_AT_FIELD, undefined);
588
+ await writeOrClear(ACP_OAUTH_ACCESS_DIGEST_FIELD, undefined);
589
+ }
590
+
591
+ /**
592
+ * Drop the refresh token, keeping the recorded expiry and the access token.
593
+ * Called when the provider rejects the refresh token.
594
+ *
595
+ * The expiry has to survive. `hasAcpClaudeToken()` reads "not connected" from
596
+ * the combination of an expired token and no refresh token. Clearing the
597
+ * expiry as well would make `readExpiresAt()` return null, which
598
+ * {@link isAcpClaudeTokenExpiring} treats as "assume usable", and the dead
599
+ * credential would report itself connected.
600
+ */
601
+ export async function clearAcpClaudeRefreshToken(
602
+ expectedRefreshToken?: string,
603
+ ): Promise<void> {
604
+ await withAcpClaudeTokenWrite(async () => {
605
+ if (expectedRefreshToken !== undefined) {
606
+ const current = await readAcpClaudeRefreshToken();
607
+ if (current !== expectedRefreshToken) {
608
+ log.info(
609
+ "Skipping Claude OAuth refresh-token clear because the stored refresh token changed while the request was in flight",
610
+ );
611
+ return;
612
+ }
613
+ }
614
+ await writeOrClear(ACP_OAUTH_REFRESH_TOKEN_FIELD, undefined);
615
+ });
616
+ }
617
+
618
+ /**
619
+ * Drop refresh, expiry, and the bound-access digest when they do not describe
620
+ * the access token currently stored.
621
+ *
622
+ * Connect and persist write those fields as a set with the access token. A
623
+ * later paste or CLI write replaces only the access-token field. Spending the
624
+ * leftover refresh token would overwrite that replacement.
625
+ */
626
+ export async function forgetAcpClaudeRenewalStateIfUnbound(): Promise<void> {
627
+ await withAcpClaudeTokenWrite(async () => {
628
+ const access = await getSecureKeyAsync(vaultKey(ACP_OAUTH_TOKEN_FIELD));
629
+ const refresh = await readAcpClaudeRefreshToken();
630
+ const expiresAt = await readExpiresAt();
631
+ const digest = await readBoundAccessDigest();
632
+ const hasRenewal = refresh != null || expiresAt != null || digest != null;
633
+ if (!hasRenewal) {
634
+ return;
635
+ }
636
+ if (access && digest && digest === claudeTokenDigest(access)) {
637
+ return;
638
+ }
639
+ await clearRenewalFields();
640
+ });
641
+ }
642
+
@@ -14,6 +14,25 @@
14
14
  export const ACP_SERVICE = "acp";
15
15
  export const ACP_OAUTH_TOKEN_FIELD = "claude_oauth_token";
16
16
 
17
+ /**
18
+ * Refresh and expiry for {@link ACP_OAUTH_TOKEN_FIELD}, written by the Connect
19
+ * Claude exchange that stored that access token. Expiry is epoch milliseconds,
20
+ * matching `computeExpiresAt` and `isTokenExpired` in
21
+ * `@vellumai/credential-storage`.
22
+ *
23
+ * {@link ACP_OAUTH_ACCESS_DIGEST_FIELD} is a digest of the access token those
24
+ * fields were written with, so a later paste of a different access token cannot
25
+ * spend leftover refresh material.
26
+ *
27
+ * These fields are given no credential metadata. `credentialBroker.serverUse`
28
+ * refuses any field without metadata, so they stay unreachable through the
29
+ * broker and cannot be injected into a spawned agent's env. Only the access
30
+ * token crosses into the child process.
31
+ */
32
+ export const ACP_OAUTH_REFRESH_TOKEN_FIELD = "claude_oauth_refresh_token";
33
+ export const ACP_OAUTH_EXPIRES_AT_FIELD = "claude_oauth_expires_at";
34
+ export const ACP_OAUTH_ACCESS_DIGEST_FIELD = "claude_oauth_access_digest";
35
+
17
36
  /**
18
37
  * True for the ACP vault field the "Connect Claude" flow owns
19
38
  * (`acp/claude_oauth_token`). Used to route this credential to the inline
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Renewal policy for the Claude OAuth credential the ACP spawn path injects as
3
+ * `CLAUDE_CODE_OAUTH_TOKEN`. Storage lives in `acp-claude-oauth.ts`; this
4
+ * module owns only the decision to refresh and the handling of what comes back.
5
+ *
6
+ * Setting `CLAUDE_CODE_OAUTH_TOKEN` tells the Claude Agent SDK to treat the
7
+ * value as a static bearer token: its own credential store and refresh
8
+ * machinery are skipped entirely. The SDK does expose a host-refresh hook,
9
+ * `getOAuthToken`, but `claude-agent-acp` never passes it through and it is
10
+ * absent from the SDK's public typings. With the env-var contract the adapter
11
+ * gives us, the daemon is the only thing that can renew this token.
12
+ *
13
+ * Every failure mode here returns without throwing and lets the spawn proceed
14
+ * with whatever token is stored. A dead token then fails at the adapter as a
15
+ * structured ACP `auth_required`, which raises the Connect card. Renewal is
16
+ * the fast path, not the error path, so it must never be the thing that fails
17
+ * a spawn.
18
+ *
19
+ * When the provider rejects the refresh token itself (revoked, or rotated out
20
+ * from under us), the stored refresh token is dropped. The recorded expiry is
21
+ * kept, because that pair (expired, no way to renew) is what makes
22
+ * `hasAcpClaudeToken()` answer "not connected" and keep the inline Connect
23
+ * card on screen.
24
+ */
25
+
26
+ import {
27
+ isCredentialError,
28
+ RefreshDeduplicator,
29
+ } from "@vellumai/credential-storage";
30
+
31
+ import { refreshOAuth2Token } from "../security/oauth2.js";
32
+ import { getLogger } from "../util/logger.js";
33
+ import {
34
+ CLAUDE_OAUTH_CONFIG,
35
+ clearAcpClaudeRefreshToken,
36
+ forgetAcpClaudeRenewalStateIfUnbound,
37
+ hasStoredAcpClaudeAccessToken,
38
+ isAcpClaudeTokenExpiring,
39
+ persistRefreshedAcpClaudeTokens,
40
+ readAcpClaudeRefreshToken,
41
+ } from "./acp-claude-oauth.js";
42
+ import { ACP_OAUTH_TOKEN_FIELD } from "./acp-credentials.js";
43
+ import { acpSpawnCredentialDenialReason } from "./prepare-agent-env.js";
44
+
45
+ const log = getLogger("acp:claude-token-refresh");
46
+
47
+ /**
48
+ * Single in-flight refresh across concurrent spawns. Anthropic rotates the
49
+ * refresh token on use, so two parallel refreshes would race and one would
50
+ * invalidate the other's token.
51
+ */
52
+ const deduplicator = new RefreshDeduplicator();
53
+ const REFRESH_KEY = "acp:claude";
54
+
55
+ /**
56
+ * Renew the stored Claude access token if it is expiring and a refresh token
57
+ * is available. Returns without throwing in every failure mode. Never returns
58
+ * the token: the plaintext read boundary stays with the credential broker, so
59
+ * callers re-read through it as usual.
60
+ */
61
+ export async function ensureFreshAcpClaudeToken(): Promise<void> {
62
+ // An explicit `allowedTools` that omits `acp_spawn` means the broker will
63
+ // deny the read this renewal exists to feed, so there is nothing to gain by
64
+ // spending a refresh token here. Checking first also keeps a passive spawn
65
+ // from touching a credential the workspace has fenced off.
66
+ if (acpSpawnCredentialDenialReason(ACP_OAUTH_TOKEN_FIELD) !== undefined) {
67
+ return;
68
+ }
69
+ // Companions can outlive a deleted access token. Renewal exists to replace
70
+ // that field, not to mint a new credential after the user removed it.
71
+ if (!(await hasStoredAcpClaudeAccessToken())) {
72
+ return;
73
+ }
74
+ try {
75
+ await forgetAcpClaudeRenewalStateIfUnbound();
76
+ } catch (err) {
77
+ log.debug(
78
+ { err },
79
+ "Could not drop unbound Claude OAuth renewal state before refresh",
80
+ );
81
+ }
82
+ if (!(await isAcpClaudeTokenExpiring())) {
83
+ return;
84
+ }
85
+
86
+ const refreshToken = await readAcpClaudeRefreshToken();
87
+ if (!refreshToken) {
88
+ log.info(
89
+ "Claude OAuth token is expiring and no refresh token is stored; " +
90
+ "the spawn will surface auth_required so the user can reconnect",
91
+ );
92
+ return;
93
+ }
94
+
95
+ try {
96
+ await deduplicator.deduplicate(REFRESH_KEY, () => doRefresh(refreshToken));
97
+ } catch (err) {
98
+ log.debug({ err }, "Claude OAuth token refresh did not complete");
99
+ }
100
+ }
101
+
102
+ async function doRefresh(refreshToken: string): Promise<string> {
103
+ log.info("Refreshing the Claude OAuth token for ACP");
104
+
105
+ let result;
106
+ try {
107
+ result = await refreshOAuth2Token(
108
+ CLAUDE_OAUTH_CONFIG.tokenExchangeUrl,
109
+ CLAUDE_OAUTH_CONFIG.clientId,
110
+ refreshToken,
111
+ // PKCE public client, so no secret. Anthropic's token endpoint takes a
112
+ // JSON body, matching the authorization-code exchange.
113
+ undefined,
114
+ undefined,
115
+ CLAUDE_OAUTH_CONFIG.tokenExchangeBodyFormat,
116
+ );
117
+ } catch (err) {
118
+ if (isCredentialError(err)) {
119
+ log.warn(
120
+ { err },
121
+ "Claude OAuth refresh token was rejected; dropping it so the account reads as needing a reconnect",
122
+ );
123
+ await clearAcpClaudeRefreshToken(refreshToken);
124
+ } else {
125
+ log.warn({ err }, "Claude OAuth token refresh failed transiently");
126
+ }
127
+ throw err;
128
+ }
129
+
130
+ if (!result.accessToken) {
131
+ log.warn(
132
+ "Claude OAuth refresh returned no usable access token; dropping the refresh token so the account reads as needing a reconnect",
133
+ );
134
+ await clearAcpClaudeRefreshToken(refreshToken);
135
+ throw new Error("Claude OAuth refresh returned no access token");
136
+ }
137
+
138
+ const persisted = await persistRefreshedAcpClaudeTokens(
139
+ {
140
+ accessToken: result.accessToken,
141
+ refreshToken: result.refreshToken,
142
+ expiresIn: result.expiresIn,
143
+ },
144
+ refreshToken,
145
+ );
146
+ if (persisted) {
147
+ log.info("Claude OAuth token refreshed");
148
+ }
149
+ return result.accessToken;
150
+ }
@@ -199,6 +199,26 @@ export function acpSpawnCredentialDenialReason(
199
199
  * mode, including a simply-absent credential, as `{ success: false,
200
200
  * reason }`, so callers choose whether a miss is fatal.
201
201
  */
202
+ /**
203
+ * Vault path for `CLAUDE_CODE_OAUTH_TOKEN`. Renews first so a near-expired
204
+ * stored token is replaced before the broker read. Config overrides never
205
+ * reach here, so this does not spend the vault refresh token on a spawn
206
+ * that will not use it.
207
+ */
208
+ async function injectClaudeOauthFromVault(
209
+ env: Record<string, string>,
210
+ ): Promise<string | undefined> {
211
+ const { ensureFreshAcpClaudeToken } =
212
+ await import("./claude-token-refresh.js");
213
+ await ensureFreshAcpClaudeToken();
214
+ return injectCredential(
215
+ env,
216
+ ACP_OAUTH_TOKEN_FIELD,
217
+ "CLAUDE_CODE_OAUTH_TOKEN",
218
+ ACP_CLAUDE_OAUTH_USAGE_DESCRIPTION,
219
+ );
220
+ }
221
+
202
222
  async function injectCredential(
203
223
  env: Record<string, string>,
204
224
  field: string,
@@ -371,12 +391,7 @@ export async function prepareAgentEnv(
371
391
  }
372
392
  let missReason: string | undefined;
373
393
  if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
374
- missReason = await injectCredential(
375
- env,
376
- ACP_OAUTH_TOKEN_FIELD,
377
- "CLAUDE_CODE_OAUTH_TOKEN",
378
- ACP_CLAUDE_OAUTH_USAGE_DESCRIPTION,
379
- );
394
+ missReason = await injectClaudeOauthFromVault(env);
380
395
  }
381
396
  // Any api-key-shaped value still standing here came from the vault read:
382
397
  // the config override was already dropped above, and the read only runs
@@ -6,7 +6,10 @@ import {
6
6
  HOLD_VERDICT_TOKEN,
7
7
  isIncompleteControlMarkerTail,
8
8
  MINIMIZE_ROOM_MARKER,
9
+ parseTerminalSessionControl,
10
+ type SessionControlRequest,
9
11
  stripInternalSpeechMarkers,
12
+ terminalControlMarkerLength,
10
13
  } from "../voice-control-protocol.js";
11
14
 
12
15
  describe("front-door verdict tokens", () => {
@@ -136,3 +139,62 @@ describe("createControlMarkerHoldback", () => {
136
139
  expect(chunks).toEqual([]);
137
140
  });
138
141
  });
142
+
143
+ describe("session control markers", () => {
144
+ test("strips untimed and timed mute markers", () => {
145
+ expect(stripInternalSpeechMarkers("Muted. [MUTE]").trim()).toBe("Muted.");
146
+ expect(stripInternalSpeechMarkers("Muted. [MUTE:30]").trim()).toBe(
147
+ "Muted.",
148
+ );
149
+ });
150
+
151
+ test("holds a streaming timed mute until its bracket arrives", () => {
152
+ expect(isIncompleteControlMarkerTail("[MU")).toBe(true);
153
+ expect(isIncompleteControlMarkerTail("[MUTE:3")).toBe(true);
154
+ expect(isIncompleteControlMarkerTail("[MUTE:30]")).toBe(false);
155
+ expect(isIncompleteControlMarkerTail("[UPDATES:FEW")).toBe(true);
156
+ expect(isIncompleteControlMarkerTail("[UPDATES:FEWER]")).toBe(false);
157
+ });
158
+
159
+ test.each([
160
+ ["Okay, talk soon. [END_CALL]", { action: "end" }],
161
+ ["Muted. [MUTE]", { action: "mute" }],
162
+ [
163
+ "I'll check in less. [UPDATES:FEWER]",
164
+ { action: "updates", cadence: "fewer" },
165
+ ],
166
+ [
167
+ "Updates are back on. [UPDATES:NORMAL]",
168
+ { action: "updates", cadence: "normal" },
169
+ ],
170
+ [
171
+ "Muting for half a minute. [MUTE:30] ",
172
+ { action: "mute", durationMs: 30_000 },
173
+ ],
174
+ ["Muted. [MUTE: 1.5]", { action: "mute", durationMs: 1_500 }],
175
+ // A garbled or oversized duration still mutes, just until unmuted.
176
+ ["Muted. [MUTE:soon]", { action: "mute" }],
177
+ ["Muted. [MUTE:0]", { action: "mute" }],
178
+ ["Muted. [MUTE:99999]", { action: "mute" }],
179
+ ] as Array<[string, SessionControlRequest]>)(
180
+ "parses the terminal control in %j",
181
+ (text, expected) => {
182
+ expect(parseTerminalSessionControl(text)).toEqual(expected);
183
+ },
184
+ );
185
+
186
+ test("a marker anywhere but the end controls nothing", () => {
187
+ expect(
188
+ parseTerminalSessionControl("Say [END_CALL] and I would hang up."),
189
+ ).toBeNull();
190
+ expect(parseTerminalSessionControl("No markers here.")).toBeNull();
191
+ });
192
+
193
+ test("measures the terminal marker the transcript pass strips", () => {
194
+ expect(terminalControlMarkerLength("Done [-1]")).toBe(4);
195
+ expect(terminalControlMarkerLength("Bye [END_CALL] ")).toBe(10);
196
+ expect(terminalControlMarkerLength("Muted [MUTE:30]")).toBe(9);
197
+ expect(terminalControlMarkerLength("Okay [UPDATES:FEWER]")).toBe(15);
198
+ expect(terminalControlMarkerLength("The array [-1] sorts")).toBe(0);
199
+ });
200
+ });