@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,111 @@
1
+ import {
2
+ END_CALL_MARKER,
3
+ FEWER_UPDATES_MARKER,
4
+ MUTE_MARKER,
5
+ NORMAL_UPDATES_MARKER,
6
+ parseTerminalSessionControl,
7
+ type SessionControlRequest,
8
+ } from "../calls/voice-control-protocol.js";
9
+ import type { VoiceProgressConfig } from "../config/schemas/voice.js";
10
+ import type { LiveVoiceSessionControl } from "./protocol.js";
11
+
12
+ /**
13
+ * Spoken session controls: the user asks out loud to end the call, mute their
14
+ * microphone, or hear fewer progress updates, the reply acknowledges it and
15
+ * ends with a marker, and the session carries it out once that
16
+ * acknowledgement has been spoken.
17
+ *
18
+ * A marker rather than a tool, because these are the turns a front-door leg
19
+ * answers on its own: "okay, I'm gonna go" should not wait on an escalation to
20
+ * a stronger model before the call can end. The marker is judged by the
21
+ * model, never by matching the user's words, so "I'm all done with that
22
+ * email" does not hang up.
23
+ */
24
+
25
+ const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
26
+ end: `- To end the call (for example "I'm all done" or "okay, I'm gonna go"), say a brief goodbye, then end your reply with ${END_CALL_MARKER}. Being done with a task is not the same as leaving the call; end only when they are leaving.`,
27
+ mute: `- To mute their microphone (for example "mute for 30 seconds" or "mute yourself, I need to take this"), confirm in a few words, then end your reply with [MUTE:<seconds>] when they gave a duration or ${MUTE_MARKER} when they did not. While muted you cannot hear them, so mention they can unmute from the call controls unless the mute is timed.`,
28
+ };
29
+
30
+ // Always taught: narration is the session's own, so no client has to be able
31
+ // to carry it out.
32
+ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (for example "don't give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with ${FEWER_UPDATES_MARKER}. If they later want regular updates back, confirm and end with ${NORMAL_UPDATES_MARKER}.`;
33
+
34
+ /**
35
+ * The control-prompt block that teaches the session controls: the ones the
36
+ * client declared plus the progress-update cadence. Every leg gets it,
37
+ * including the toolless front-door leg: these turns are its to answer.
38
+ *
39
+ * The no-other-markers rule is withheld from the front-door leg, whose
40
+ * routing rule teaches it leading verdict tokens and already confines
41
+ * everything else to speech.
42
+ */
43
+ export function sessionControlTeaching(
44
+ controls: readonly LiveVoiceSessionControl[],
45
+ leg: { frontDoor?: boolean },
46
+ ): string {
47
+ return [
48
+ "The user can also control this call by asking you. Only when they clearly ask:",
49
+ ...controls.map((control) => CLIENT_CONTROL_LINES[control]),
50
+ UPDATES_LINE,
51
+ `The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
52
+ ].join("\n");
53
+ }
54
+
55
+ /** A session control the client carries out, sent as a `session_control` frame. */
56
+ export type ClientSessionControlRequest = Exclude<
57
+ SessionControlRequest,
58
+ { action: "updates" }
59
+ >;
60
+
61
+ /**
62
+ * The control a completed leg's raw text asks for; null when there is none or
63
+ * when it is a client control the client did not declare. An undeclared
64
+ * control is dropped rather than sent: the client said it cannot carry it out.
65
+ * The update cadence always passes, since the session carries it out itself.
66
+ */
67
+ export function requestedSessionControl(
68
+ rawText: string,
69
+ controls: readonly LiveVoiceSessionControl[],
70
+ ): SessionControlRequest | null {
71
+ const request = parseTerminalSessionControl(rawText);
72
+ if (request === null) {
73
+ return null;
74
+ }
75
+ if (request.action === "updates") {
76
+ return request;
77
+ }
78
+ return controls.includes(request.action) ? request : null;
79
+ }
80
+
81
+ /**
82
+ * Silence (ms) a session that asked for fewer updates waits through before a
83
+ * progress update. Long enough that a minute-long task runs without a word;
84
+ * short enough that a really long one still proves the call is alive.
85
+ */
86
+ export const FEWER_UPDATES_INTERVAL_MS = 60_000;
87
+
88
+ /**
89
+ * The progress config a turn runs on under the session's update cadence.
90
+ *
91
+ * Fewer updates means no update for tool activity (a burst of ops or a long
92
+ * op finishing, each normally its own beat) and the silence tick stretched to
93
+ * {@link FEWER_UPDATES_INTERVAL_MS}, so the only thing that speaks is a long
94
+ * stretch of silence. Done is still said: the reply itself is the "it's done".
95
+ */
96
+ export function progressConfigForCadence(
97
+ config: VoiceProgressConfig,
98
+ cadence: "fewer" | "normal",
99
+ ): VoiceProgressConfig {
100
+ if (cadence === "normal") {
101
+ return config;
102
+ }
103
+ const intervalMs = Math.max(FEWER_UPDATES_INTERVAL_MS, config.maxSilenceMs);
104
+ return {
105
+ ...config,
106
+ opsThreshold: Number.MAX_SAFE_INTEGER,
107
+ longOpMs: Number.MAX_SAFE_INTEGER,
108
+ idleIntervalMs: intervalMs,
109
+ maxSilenceMs: intervalMs,
110
+ };
111
+ }
@@ -60,6 +60,26 @@ describe("PROVIDER_SEED_DATA managed mode wiring", () => {
60
60
  );
61
61
  }
62
62
 
63
+ // `folders:read` is documented too, but the app's scope picker only
64
+ // offers `folder_metadata:read` under Folders, so Figma answers
65
+ // "Invalid scopes for app" whenever it is requested.
66
+ expect(figma.defaultScopes).not.toContain("folders:read");
67
+ if (Array.isArray(availableScopes)) {
68
+ expect(availableScopes.map(({ scope }) => scope)).toContain(
69
+ "folders:read",
70
+ );
71
+ }
72
+
73
+ // `file_code_connect:write` is only offered to apps owned by an
74
+ // Organization-plan team. Managed apps in other environments are not,
75
+ // so requesting it fails the whole authorization there.
76
+ expect(figma.defaultScopes).not.toContain("file_code_connect:write");
77
+ if (Array.isArray(availableScopes)) {
78
+ expect(availableScopes.map(({ scope }) => scope)).toContain(
79
+ "file_code_connect:write",
80
+ );
81
+ }
82
+
63
83
  // GET /v1/me backs both the ping and the identity label.
64
84
  expect(figma.defaultScopes).toContain("current_user:read");
65
85
  });
@@ -181,6 +201,81 @@ describe("PROVIDER_SEED_DATA managed mode wiring", () => {
181
201
  expect(template?.hostPattern).toBe("*.myshopify.com");
182
202
  });
183
203
 
204
+ test("quickbooks is wired up for managed mode behind its flag", () => {
205
+ const quickbooks = PROVIDER_SEED_DATA.quickbooks;
206
+ expect(quickbooks).toBeDefined();
207
+ expect(quickbooks.managedServiceConfigKey).toBe("quickbooks-oauth");
208
+ expect("quickbooks-oauth" in ServicesSchema.shape).toBe(true);
209
+ // Hidden until the platform side and client ids are live, like Figma
210
+ // and Shopify.
211
+ expect(quickbooks.featureFlag).toBe("quickbooks-oauth");
212
+ });
213
+
214
+ test("quickbooks uses Intuit's OAuth endpoints with HTTP Basic", () => {
215
+ const quickbooks = PROVIDER_SEED_DATA.quickbooks;
216
+ expect(quickbooks.authorizeUrl).toBe(
217
+ "https://appcenter.intuit.com/connect/oauth2",
218
+ );
219
+ expect(quickbooks.tokenExchangeUrl).toBe(
220
+ "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
221
+ );
222
+ expect(quickbooks.refreshUrl).toBe(quickbooks.tokenExchangeUrl);
223
+ expect(quickbooks.tokenEndpointAuthMethod).toBe("client_secret_basic");
224
+ });
225
+
226
+ test("quickbooks keeps its per-company URL templates", () => {
227
+ // Every Accounting API path is scoped to the realm the user picked on
228
+ // Intuit's consent screen. The platform fills {realm_id} from the
229
+ // connection; a hardcoded realm here would send every company's calls
230
+ // to the wrong books.
231
+ const quickbooks = PROVIDER_SEED_DATA.quickbooks;
232
+ for (const url of [quickbooks.baseUrl, quickbooks.identityUrl]) {
233
+ expect(url).toContain("/v3/company/{realm_id}");
234
+ }
235
+ });
236
+
237
+ test("quickbooks seeds no ping or revoke URL", () => {
238
+ // The ping route sends the URL's origin as a base override and cannot
239
+ // fill {realm_id}; the daemon's revoke helper posts an unauthenticated
240
+ // form body that Intuit's JSON + Basic-auth endpoint rejects. Either
241
+ // would fail silently on every your-own connection.
242
+ const quickbooks = PROVIDER_SEED_DATA.quickbooks;
243
+ expect(quickbooks.pingUrl).toBeUndefined();
244
+ expect(quickbooks.revokeUrl).toBeUndefined();
245
+ });
246
+
247
+ test("quickbooks requests only the accounting scope by default", () => {
248
+ // Payments needs a Payments-enabled app and the OpenID scopes only add
249
+ // the user's profile; requesting either widens the consent screen for
250
+ // nothing the Accounting API needs.
251
+ const quickbooks = PROVIDER_SEED_DATA.quickbooks;
252
+ expect(quickbooks.defaultScopes).toEqual([
253
+ "com.intuit.quickbooks.accounting",
254
+ ]);
255
+ const available = quickbooks.availableScopes;
256
+ expect(Array.isArray(available)).toBe(true);
257
+ if (Array.isArray(available)) {
258
+ expect(available.map(({ scope }) => scope)).toContain(
259
+ "com.intuit.quickbooks.payment",
260
+ );
261
+ }
262
+ });
263
+
264
+ test("quickbooks allows the credential on both Intuit API hosts", () => {
265
+ // Development keys only work against sandbox companies, which live on
266
+ // the sandbox host. Both hosts belong to Intuit and take the same
267
+ // bearer token.
268
+ const templates = PROVIDER_SEED_DATA.quickbooks.injectionTemplates ?? [];
269
+ expect(templates.map((t) => t.hostPattern).sort()).toEqual([
270
+ "quickbooks.api.intuit.com",
271
+ "sandbox-quickbooks.api.intuit.com",
272
+ ]);
273
+ for (const template of templates) {
274
+ expect(template.headerName).toBe("Authorization");
275
+ expect(template.valuePrefix).toBe("Bearer ");
276
+ }
277
+ });
278
+
184
279
  test("every managedServiceConfigKey resolves to a ServicesSchema key", () => {
185
280
  // Cross-repo invariant: a provider with managedServiceConfigKey but no
186
281
  // matching ServicesSchema entry silently falls back to BYO mode in
@@ -72,6 +72,7 @@ import { setConfig } from "../__tests__/helpers/set-config.js";
72
72
  import { BYOOAuthConnection } from "./byo-connection.js";
73
73
  import {
74
74
  formatNoConnectionError,
75
+ platformProxyBaseUrl,
75
76
  resolveEffectiveBaseUrl,
76
77
  resolveOAuthConnection,
77
78
  resolveOAuthConnectionWithMeta,
@@ -755,6 +756,32 @@ describe("resolveEffectiveBaseUrl", () => {
755
756
  });
756
757
  });
757
758
 
759
+ describe("platformProxyBaseUrl", () => {
760
+ test("forwards a concrete seed base URL", () => {
761
+ expect(platformProxyBaseUrl("https://api.figma.com")).toBe(
762
+ "https://api.figma.com",
763
+ );
764
+ });
765
+
766
+ test("withholds a templated base URL so the platform fills it", () => {
767
+ // Shopify's host and QuickBooks' realm are pinned to the connection on
768
+ // the platform. Forwarding the unfilled template would fail the proxy's
769
+ // allowlist check and shadow the platform's own default.
770
+ expect(platformProxyBaseUrl("https://{tenant_host}")).toBeUndefined();
771
+ expect(
772
+ platformProxyBaseUrl(
773
+ "https://quickbooks.api.intuit.com/v3/company/{realm_id}",
774
+ ),
775
+ ).toBeUndefined();
776
+ });
777
+
778
+ test("treats a missing base URL as no override", () => {
779
+ expect(platformProxyBaseUrl(undefined)).toBeUndefined();
780
+ expect(platformProxyBaseUrl(null)).toBeUndefined();
781
+ expect(platformProxyBaseUrl("")).toBeUndefined();
782
+ });
783
+ });
784
+
758
785
  describe("resolveTokenHeader", () => {
759
786
  const shopify = JSON.stringify([
760
787
  {
@@ -129,7 +129,10 @@ export async function resolveOAuthConnectionWithMeta(
129
129
  accountInfo: resolution.accountLabel ?? account ?? null,
130
130
  client,
131
131
  connectionId: resolution.id,
132
- baseUrl: providerRow?.baseUrl ?? undefined,
132
+ // A templated base URL (`https://{tenant_host}`, `.../{realm_id}`) is
133
+ // filled in by the platform from what it pinned to the connection;
134
+ // sent as-is it would fail the proxy's allowlist and shadow that.
135
+ baseUrl: platformProxyBaseUrl(providerRow?.baseUrl),
133
136
  });
134
137
  return {
135
138
  connection,
@@ -317,6 +320,24 @@ function hostMatchesPattern(host: string, pattern: string): boolean {
317
320
  return host === p;
318
321
  }
319
322
 
323
+ /**
324
+ * The base URL a managed connection sends to the platform proxy, or
325
+ * `undefined` to let the proxy use the provider's configured default.
326
+ *
327
+ * Per-tenant and per-realm providers seed a template the platform fills from
328
+ * the connection (`{tenant_host}`, `{realm_id}`). Forwarding the unfilled
329
+ * template would fail the proxy's allowlist check, so those fall through to
330
+ * the platform's own default.
331
+ */
332
+ export function platformProxyBaseUrl(
333
+ seedBaseUrl: string | null | undefined,
334
+ ): string | undefined {
335
+ if (!seedBaseUrl || /\{[a-z_]+\}/.test(seedBaseUrl)) {
336
+ return undefined;
337
+ }
338
+ return seedBaseUrl;
339
+ }
340
+
320
341
  /**
321
342
  * Resolve the effective API base URL for a connection, preferring per-tenant
322
343
  * values stored on the connection's `metadata` over the provider's static
@@ -394,6 +415,9 @@ interface PlatformConnectionEntry {
394
415
  /** Scopes the platform actually granted this connection. May be absent for
395
416
  * older connections or providers that don't report scopes. */
396
417
  scopes_granted?: string[] | null;
418
+ /** Provider-supplied values the connection is scoped by (QuickBooks'
419
+ * `realm_id`). Absent from older platforms; empty for most providers. */
420
+ provider_params?: Record<string, string> | null;
397
421
  }
398
422
 
399
423
  interface PlatformConnectionResolution {
@@ -20,6 +20,8 @@ export type SerializedProvider = ReturnType<typeof serializeProvider> &
20
20
 
21
21
  import { isChannelBotProvider } from "@vellumai/service-contracts/channels";
22
22
 
23
+ import { PROVIDER_SEED_DATA } from "./seed-providers.js";
24
+
23
25
  /**
24
26
  * Lightweight summary projection of an OAuth provider, suitable for API
25
27
  * list responses where full detail is not needed. All keys are snake_case
@@ -36,6 +38,12 @@ export interface SerializedProviderSummary {
36
38
  supports_managed_mode: boolean;
37
39
  managed_service_is_paid: boolean;
38
40
  feature_flag: string | null;
41
+ /**
42
+ * Per-tenant providers only (Shopify): what a client must collect before
43
+ * starting a managed connect, since the provider's OAuth endpoints live on
44
+ * the customer's own host. `null` for providers with one global host.
45
+ */
46
+ tenant_host: { pattern: string; label: string; placeholder: string } | null;
39
47
  /**
40
48
  * Which sense of "connected" this provider represents: `assistant` for a bot
41
49
  * credential people reach the assistant through, `user` for a grant letting
@@ -164,6 +172,7 @@ export function serializeProviderSummary(
164
172
  supports_managed_mode: !!row.managedServiceConfigKey,
165
173
  managed_service_is_paid: !!row.managedServiceIsPaid,
166
174
  feature_flag: row.featureFlag ?? null,
175
+ tenant_host: PROVIDER_SEED_DATA[row.provider]?.tenantHost ?? null,
167
176
  acts_as: isChannelBotProvider(row.provider) ? "assistant" : "user",
168
177
  };
169
178
  }
@@ -90,6 +90,13 @@ export const PROVIDER_SEED_DATA: Record<
90
90
  }>;
91
91
  appType?: string;
92
92
  setupNotes?: string[];
93
+ /**
94
+ * Per-tenant providers only: the host the user supplies at connect time,
95
+ * which the platform substitutes into `{tenant_host}` URL placeholders.
96
+ * `pattern` mirrors the platform registry's validation so clients can
97
+ * reject a malformed host before the request leaves the browser.
98
+ */
99
+ tenantHost?: { pattern: string; label: string; placeholder: string };
93
100
  identityUrl?: string;
94
101
  identityMethod?: string;
95
102
  identityHeaders?: Record<string, string>;
@@ -1003,7 +1010,12 @@ export const PROVIDER_SEED_DATA: Record<
1003
1010
  // default, because Figma rejects the whole authorization request if the
1004
1011
  // app cannot grant a requested scope. `selections:read` is withheld for
1005
1012
  // that same reason: it is not offered in the app's OAuth scope list, so
1006
- // requesting it would fail the entire authorization.
1013
+ // requesting it would fail the entire authorization. `folders:read` is
1014
+ // withheld likewise: Figma documents it, but the app's scope picker only
1015
+ // offers `folder_metadata:read` under Folders. `file_code_connect:write`
1016
+ // is withheld because Figma only offers it to apps owned by an
1017
+ // Organization-plan team, which not every managed app is, and nothing
1018
+ // here uses Code Connect; it stays in availableScopes for BYO apps.
1007
1019
  defaultScopes: [
1008
1020
  "current_user:read",
1009
1021
  "file_content:read",
@@ -1013,7 +1025,6 @@ export const PROVIDER_SEED_DATA: Record<
1013
1025
  "file_comments:write",
1014
1026
  "file_dev_resources:read",
1015
1027
  "file_dev_resources:write",
1016
- "folders:read",
1017
1028
  "folder_metadata:read",
1018
1029
  "library_content:read",
1019
1030
  "library_assets:read",
@@ -1053,6 +1064,10 @@ export const PROVIDER_SEED_DATA: Record<
1053
1064
  scope: "file_dev_resources:write",
1054
1065
  description: "Write dev resources to files",
1055
1066
  },
1067
+ {
1068
+ scope: "file_code_connect:write",
1069
+ description: "Write and change component code (Code Connect)",
1070
+ },
1056
1071
  {
1057
1072
  scope: "folders:read",
1058
1073
  description: "List folders and files in folders",
@@ -1485,6 +1500,12 @@ export const PROVIDER_SEED_DATA: Record<
1485
1500
  ],
1486
1501
  loopbackPort: 17341,
1487
1502
  managedServiceConfigKey: "shopify-oauth",
1503
+ // Mirrors `extra_config.tenant_host` in the platform provider registry.
1504
+ tenantHost: {
1505
+ pattern: "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$",
1506
+ label: "Shop domain",
1507
+ placeholder: "your-store.myshopify.com",
1508
+ },
1488
1509
  injectionTemplates: [
1489
1510
  {
1490
1511
  hostPattern: "*.myshopify.com",
@@ -1508,6 +1529,93 @@ export const PROVIDER_SEED_DATA: Record<
1508
1529
  // cannot complete.
1509
1530
  featureFlag: "shopify-oauth",
1510
1531
  },
1532
+
1533
+ quickbooks: {
1534
+ provider: "quickbooks",
1535
+ authorizeUrl: "https://appcenter.intuit.com/connect/oauth2",
1536
+ tokenExchangeUrl:
1537
+ "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
1538
+ // Access tokens live an hour; refresh tokens 100 days and are rotated on
1539
+ // every refresh, which the platform persists. Both token endpoints
1540
+ // authenticate the client with HTTP Basic.
1541
+ refreshUrl: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
1542
+ tokenEndpointAuthMethod: "client_secret_basic",
1543
+ // No revokeUrl: Intuit's revocation endpoint wants a JSON body under HTTP
1544
+ // Basic client auth, and the daemon's revoke helper only posts an
1545
+ // unauthenticated form body, so a seeded URL would fail silently on every
1546
+ // your-own disconnect. The platform registry revokes managed tokens itself.
1547
+ // Every Accounting API path is scoped to the company (realm) the user
1548
+ // picked on Intuit's consent screen. The realm arrives only as the
1549
+ // callback's `realmId` query parameter; the platform captures it into the
1550
+ // connection's `provider_params` and fills the `{realm_id}` placeholder
1551
+ // server-side, so managed callers send paths relative to the company
1552
+ // (`/query`, `/customer/123`). Production and sandbox keys use different
1553
+ // hosts; the platform picks the host per environment.
1554
+ baseUrl: "https://quickbooks.api.intuit.com/v3/company/{realm_id}",
1555
+ // No pingUrl: the only company-independent probe would still need the
1556
+ // realm in its path, which the ping route cannot fill in (it sends the
1557
+ // URL's origin as a per-request base override, which would also pin a
1558
+ // managed sandbox connection to the production host).
1559
+ displayLabel: "QuickBooks",
1560
+ description: "Invoices, customers, vendors, and accounting data",
1561
+ dashboardUrl: "https://developer.intuit.com/app/developer/dashboard",
1562
+ clientIdPlaceholder: null,
1563
+ logoUrl: "https://cdn.simpleicons.org/quickbooks",
1564
+ // The Accounting scope covers the QuickBooks Online API. Payments is a
1565
+ // separate product the app must be enabled for, and the OpenID scopes
1566
+ // only add the signing-in user's profile, so none are on by default.
1567
+ defaultScopes: ["com.intuit.quickbooks.accounting"],
1568
+ availableScopes: [
1569
+ {
1570
+ scope: "com.intuit.quickbooks.accounting",
1571
+ description:
1572
+ "Read and write QuickBooks Online accounting data: customers, vendors, invoices, bills, payments, items, accounts, and reports",
1573
+ },
1574
+ {
1575
+ scope: "com.intuit.quickbooks.payment",
1576
+ description:
1577
+ "QuickBooks Payments: charges, refunds, bank accounts, and cards (requires a Payments-enabled app)",
1578
+ },
1579
+ { scope: "openid", description: "Sign in with Intuit (OpenID Connect)" },
1580
+ { scope: "profile", description: "The signing-in user's name" },
1581
+ { scope: "email", description: "The signing-in user's email address" },
1582
+ { scope: "phone", description: "The signing-in user's phone number" },
1583
+ { scope: "address", description: "The signing-in user's address" },
1584
+ ],
1585
+ loopbackPort: 17342,
1586
+ managedServiceConfigKey: "quickbooks-oauth",
1587
+ injectionTemplates: [
1588
+ {
1589
+ hostPattern: "quickbooks.api.intuit.com",
1590
+ injectionType: "header",
1591
+ headerName: "Authorization",
1592
+ valuePrefix: "Bearer ",
1593
+ },
1594
+ {
1595
+ hostPattern: "sandbox-quickbooks.api.intuit.com",
1596
+ injectionType: "header",
1597
+ headerName: "Authorization",
1598
+ valuePrefix: "Bearer ",
1599
+ },
1600
+ ],
1601
+ appType: "App",
1602
+ setupNotes: [
1603
+ "QuickBooks scopes every Accounting API call to the company (realm) chosen on Intuit's consent screen. Managed connections carry the realm as provider_params.realm_id and requests are sent relative to /v3/company/{realmId}.",
1604
+ "Intuit development keys only authorize sandbox companies, which live on sandbox-quickbooks.api.intuit.com; production keys use quickbooks.api.intuit.com.",
1605
+ "The Accounting API returns XML unless the request carries Accept: application/json.",
1606
+ ],
1607
+ // CompanyInfo does not repeat the realm (its Id is always "1"); the
1608
+ // platform keys the connection on the captured realm and labels it with
1609
+ // the company name.
1610
+ identityUrl:
1611
+ "https://quickbooks.api.intuit.com/v3/company/{realm_id}/companyinfo/{realm_id}",
1612
+ identityHeaders: { Accept: "application/json" },
1613
+ identityResponsePaths: ["CompanyInfo.CompanyName", "CompanyInfo.LegalName"],
1614
+ // Gated like figma/shopify: the platform side lands separately, and until
1615
+ // the client ids are live a visible tile would offer a connect flow that
1616
+ // cannot complete.
1617
+ featureFlag: "quickbooks-oauth",
1618
+ },
1511
1619
  };
1512
1620
 
1513
1621
  export const SEEDED_PROVIDER_KEYS = new Set(Object.keys(PROVIDER_SEED_DATA));
@@ -48,7 +48,7 @@ mock.module("../../../security/oauth2.js", () => ({
48
48
 
49
49
  const actualClaudeOauth = await import("../../../acp/acp-claude-oauth.js");
50
50
  const { CLAUDE_MANUAL_REDIRECT_URI, CLAUDE_OAUTH_CONFIG } = actualClaudeOauth;
51
- const storeAcpClaudeTokenMock = mock(async (_token: string) => {});
51
+ const storeAcpClaudeTokenMock = mock(async (_tokens: unknown) => {});
52
52
  // The connect-status route reads token presence; mock it so the route test
53
53
  // doesn't reach real secure storage.
54
54
  const hasAcpClaudeTokenMock = mock(async () => false);
@@ -228,9 +228,12 @@ describe("loopback capture", () => {
228
228
  const status = await waitForStatus(state, "connected");
229
229
  expect(status).toEqual({ status: "connected" });
230
230
 
231
- // Access token persisted via storeAcpClaudeToken.
232
231
  expect(storeAcpClaudeTokenMock).toHaveBeenCalledTimes(1);
233
- expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith("sk-ant-oat-access");
232
+ expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith({
233
+ accessToken: "sk-ant-oat-access",
234
+ refreshToken: "refresh-xyz",
235
+ expiresIn: 3600,
236
+ });
234
237
  });
235
238
 
236
239
  test("flips status to error when the exchange fails", async () => {
@@ -339,7 +342,11 @@ describe("acp_claude_auth_exchange", () => {
339
342
  expect(call[1]).toBe("auth-code-123");
340
343
  expect(call[2]).toBe(CLAUDE_MANUAL_REDIRECT_URI);
341
344
  expect(call[3]).toBeTruthy(); // PKCE verifier from the start call
342
- expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith("sk-ant-oat-manual");
345
+ expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith({
346
+ accessToken: "sk-ant-oat-manual",
347
+ refreshToken: "refresh-manual",
348
+ expiresIn: 3600,
349
+ });
343
350
 
344
351
  // The pending entry is consumed — a second exchange fails.
345
352
  await expect(
@@ -360,7 +367,11 @@ describe("acp_claude_auth_exchange", () => {
360
367
  string,
361
368
  ];
362
369
  expect(call[1]).toBe("raw-code-xyz");
363
- expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith("sk-ant-oat-manual");
370
+ expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith({
371
+ accessToken: "sk-ant-oat-manual",
372
+ refreshToken: "refresh-manual",
373
+ expiresIn: 3600,
374
+ });
364
375
  });
365
376
 
366
377
  test("malformed paste (no `#`, no state) is rejected", async () => {
@@ -5,7 +5,7 @@
5
5
  * implementations unless this file's tests are running.
6
6
  */
7
7
 
8
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import {
@@ -33,6 +33,9 @@ const realCompiler = { ...(await import("../../../bundler/app-compiler.js")) };
33
33
  const realNotify = {
34
34
  ...(await import("../../../daemon/app-change-notify.js")),
35
35
  };
36
+ const realPinStore = {
37
+ ...(await import("../../../apps/app-pin-store.js")),
38
+ };
36
39
 
37
40
  const compileApp = mock(
38
41
  async (
@@ -70,6 +73,16 @@ mock.module("../../../daemon/app-change-notify.js", () => ({
70
73
  },
71
74
  }));
72
75
 
76
+ mock.module("../../../apps/app-pin-store.js", () => ({
77
+ ...realPinStore,
78
+ listAppPins: () => {
79
+ if (!mockActive) {
80
+ return realPinStore.listAppPins();
81
+ }
82
+ return [];
83
+ },
84
+ }));
85
+
73
86
  const { createApp } = await import("../../../apps/app-store.js");
74
87
  const { getWorkspacePluginsDir } = await import("../../../util/platform.js");
75
88
  const { ROUTES } = await import("../app-management-routes.js");
@@ -83,6 +96,7 @@ function findHandler(operationId: string) {
83
96
  }
84
97
 
85
98
  const handleRefreshApp = findHandler("apps_refresh");
99
+ const handleListApps = findHandler("apps_list");
86
100
 
87
101
  let workspaceDir: string;
88
102
  let previousWorkspaceDir: string | undefined;
@@ -142,6 +156,30 @@ describe("apps_refresh route", () => {
142
156
  });
143
157
  });
144
158
 
159
+ test("advances the workspace app list revision when compiled output changes", async () => {
160
+ const created = createApp({
161
+ name: "Budget",
162
+ schemaJson: "{}",
163
+ htmlDefinition: "<h1>Budget</h1>",
164
+ });
165
+ const builtAt = Math.ceil(created.updatedAt) + 1_000;
166
+ compileApp.mockImplementationOnce(async (appDir) => {
167
+ const distDir = join(appDir, "dist");
168
+ mkdirSync(distDir, { recursive: true });
169
+ utimesSync(distDir, new Date(builtAt), new Date(builtAt));
170
+ return { ok: true, errors: [], warnings: [], durationMs: 11 };
171
+ });
172
+
173
+ await handleRefreshApp({ pathParams: { id: created.id } });
174
+ const listed = (await handleListApps({})) as {
175
+ apps: Array<{ id: string; updatedAt: number }>;
176
+ };
177
+
178
+ expect(listed.apps.find((app) => app.id === created.id)?.updatedAt).toBe(
179
+ builtAt,
180
+ );
181
+ });
182
+
145
183
  test("compiles a plugin app in place", async () => {
146
184
  const pluginName = `charts-${Math.random().toString(36).slice(2, 8)}`;
147
185
  const pluginDir = join(getWorkspacePluginsDir(), pluginName);
@@ -157,9 +195,7 @@ describe("apps_refresh route", () => {
157
195
  pathParams: { id: pluginAppId },
158
196
  });
159
197
 
160
- expect(compileApp).toHaveBeenCalledWith(
161
- join(pluginDir, "apps", "viewer"),
162
- );
198
+ expect(compileApp).toHaveBeenCalledWith(join(pluginDir, "apps", "viewer"));
163
199
  expect(notifyAppSurfacesChanged).toHaveBeenCalledWith(pluginAppId, {
164
200
  fileChange: true,
165
201
  });
@@ -171,6 +207,40 @@ describe("apps_refresh route", () => {
171
207
  });
172
208
  });
173
209
 
210
+ test("advances the plugin app list revision when compiled output changes", async () => {
211
+ const pluginName = `charts-${Math.random().toString(36).slice(2, 8)}`;
212
+ const appDir = join(getWorkspacePluginsDir(), pluginName, "apps", "viewer");
213
+ mkdirSync(appDir, { recursive: true });
214
+ writeFileSync(
215
+ join(getWorkspacePluginsDir(), pluginName, "package.json"),
216
+ JSON.stringify({ name: pluginName, version: "1.0.0" }),
217
+ );
218
+ const pluginAppId = `plugins~${pluginName}~viewer`;
219
+ const before = (await handleListApps({})) as {
220
+ apps: Array<{ id: string; updatedAt: number }>;
221
+ };
222
+ const previousRevision = before.apps.find(
223
+ (app) => app.id === pluginAppId,
224
+ )?.updatedAt;
225
+ expect(previousRevision).toBeDefined();
226
+ const builtAt = Math.ceil(previousRevision!) + 1_000;
227
+ compileApp.mockImplementationOnce(async (sourceDir) => {
228
+ const distDir = join(sourceDir, "dist");
229
+ mkdirSync(distDir, { recursive: true });
230
+ utimesSync(distDir, new Date(builtAt), new Date(builtAt));
231
+ return { ok: true, errors: [], warnings: [], durationMs: 11 };
232
+ });
233
+
234
+ await handleRefreshApp({ pathParams: { id: pluginAppId } });
235
+ const after = (await handleListApps({})) as {
236
+ apps: Array<{ id: string; updatedAt: number }>;
237
+ };
238
+
239
+ expect(after.apps.find((app) => app.id === pluginAppId)?.updatedAt).toBe(
240
+ builtAt,
241
+ );
242
+ });
243
+
174
244
  test("returns compile errors without throwing", async () => {
175
245
  const created = createApp({
176
246
  name: "Budget",