@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.
- 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 +35 -1
- package/package.json +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
- package/src/__tests__/conversation-runtime-assembly.test.ts +28 -0
- 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__/host-cu-proxy.test.ts +69 -12
- 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__/subagent-tool-gate-mode.test.ts +51 -0
- package/src/__tests__/ui-channel-variants.test.ts +1 -56
- 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/calls/__tests__/voice-control-protocol.test.ts +62 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +19 -0
- package/src/calls/voice-control-protocol.ts +102 -0
- package/src/calls/voice-session-bridge.ts +33 -26
- package/src/calls/voice-triage-escalate.ts +3 -3
- package/src/cli/commands/oauth/status.ts +5 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +13 -4
- package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
- package/src/config/feature-flag-registry.json +9 -1
- package/src/config/loader.ts +1 -0
- package/src/config/schemas/services.ts +10 -0
- package/src/daemon/conversation-runtime-assembly.ts +18 -6
- package/src/daemon/conversation-surfaces.ts +6 -1
- package/src/daemon/conversation-tool-setup.ts +8 -16
- package/src/daemon/host-cu-proxy.ts +43 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +121 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +8 -7
- package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
- package/src/live-voice/__tests__/protocol.test.ts +39 -0
- package/src/live-voice/__tests__/session-controls.test.ts +79 -0
- package/src/live-voice/live-voice-session.ts +85 -7
- package/src/live-voice/protocol.ts +60 -0
- package/src/live-voice/session-controls.ts +111 -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 +110 -2
- 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/acp-claude-auth-routes.ts +11 -3
- package/src/runtime/routes/app-management-routes.ts +17 -2
- package/src/runtime/routes/credential-routes.ts +1 -4
- package/src/runtime/routes/oauth-commands-routes.ts +14 -0
- package/src/runtime/routes/oauth-providers.ts +7 -0
- package/src/tools/computer-use/definitions.ts +1 -1
- package/src/tools/ui-surface/channel-variants.ts +10 -59
- 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
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|