diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index 34af3e6..08b828d 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -305,7 +305,8 @@ test('relayfileIntegrationResolver connect opens a session and polls until conne if (url.endsWith('/integrations/connect-session')) { assert.equal(init?.method, 'POST'); assert.deepEqual(JSON.parse(String(init?.body)), { - allowedIntegrations: ['notion'] + allowedIntegrations: ['notion'], + scope: { kind: 'deployer_user' } }); return okJson({ connectLink: 'https://connect.example.test/session', connectionId: 'conn-1' }); } @@ -330,6 +331,91 @@ test('relayfileIntegrationResolver connect opens a session and polls until conne assert.ok(io.messages.some((message) => message.message.includes('notion connected'))); }); +test('relayfileIntegrationResolver connect sends scope=workspace and scopes status polls (workspace source)', async () => { + const bodies: unknown[] = []; + const statusUrls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: () => undefined, + sleep: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + bodies.push(JSON.parse(String(init?.body))); + return okJson({ sessionUrl: 'https://connect.example.test/session', connectionId: 'conn-ws' }); + } + statusUrls.push(url); + return okJson({ status: 'ready', connectionId: 'conn-ws' }); + } + }); + await resolver.connect({ workspace: 'ws-1', provider: 'github', source: { kind: 'workspace' } }); + assert.deepEqual(bodies, [{ allowedIntegrations: ['github'], scope: { kind: 'workspace' } }]); + assert.ok(statusUrls.every((u) => u.includes('scope=workspace'))); +}); + +test('relayfileIntegrationResolver connect sends scope=workspace_service_account + name', async () => { + const bodies: unknown[] = []; + const statusUrls: string[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: () => undefined, + sleep: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + bodies.push(JSON.parse(String(init?.body))); + return okJson({ sessionUrl: 'https://connect.example.test/session', connectionId: 'conn-sa' }); + } + statusUrls.push(url); + return okJson({ status: 'ready', connectionId: 'conn-sa' }); + } + }); + await resolver.connect({ + workspace: 'ws-1', + provider: 'github', + source: { kind: 'workspace_service_account', name: 'release-bot' } + }); + assert.deepEqual(bodies, [ + { + allowedIntegrations: ['github'], + scope: { kind: 'workspace_service_account', name: 'release-bot' } + } + ]); + assert.ok(statusUrls.every((u) => u.includes('scope=workspace_service_account'))); + assert.ok(statusUrls.every((u) => u.includes('serviceAccountName=release-bot'))); +}); + +test('relayfileIntegrationResolver connect defaults to deployer_user when source omitted', async () => { + const bodies: unknown[] = []; + const resolver = relayfileIntegrationResolver({ + apiUrl: 'https://cloud.example.test', + workspaceId: 'ws-1', + workspaceToken: 'tok', + pollIntervalMs: 0, + timeoutMs: 100, + openUrl: () => undefined, + sleep: async () => undefined, + fetch: async (input, init) => { + const url = input.toString(); + if (url.endsWith('/integrations/connect-session')) { + bodies.push(JSON.parse(String(init?.body))); + return okJson({ sessionUrl: 'https://connect.example.test/session', connectionId: 'conn-du' }); + } + return okJson({ status: 'ready', connectionId: 'conn-du' }); + } + }); + await resolver.connect({ workspace: 'ws-1', provider: 'github' }); + assert.deepEqual(bodies, [{ allowedIntegrations: ['github'], scope: { kind: 'deployer_user' } }]); +}); + test('relayfileIntegrationResolver connect times out clearly', async () => { const resolver = relayfileIntegrationResolver({ apiUrl: 'https://cloud.example.test', diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 734d014..0eb57d6 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -53,8 +53,24 @@ export interface IntegrationConnectResolver { source?: IntegrationSource; expectedConfigKey?: string; }): Promise; - /** Run the browser-based OAuth flow and resolve when the user finishes. */ - connect(args: { workspace: string; provider: string }): Promise<{ connectionId: string }>; + /** + * Run the browser-based OAuth flow and resolve when the user finishes. + * + * `source` discriminates which table the cloud writes the new row into: + * `deployer_user` → `user_integrations`, `workspace` → `workspace_integrations`, + * `workspace_service_account` → `workspace_integrations` with a named + * service-account attribution. The CLI passes the persona's declared + * source so the connect-side scope matches the preflight read scope and + * the runtime dispatcher's resolve scope (per `cloud#1001`). + * + * Defaults to `{ kind: 'deployer_user' }` (mirrors the persona-kit + * default). + */ + connect(args: { + workspace: string; + provider: string; + source?: IntegrationSource; + }): Promise<{ connectionId: string }>; } /** @@ -136,14 +152,27 @@ export function relayfileIntegrationResolver(opts: { : {}) }); }, - async connect({ workspace, provider }) { + async connect({ workspace, provider, source }) { const workspaceId = workspace || opts.workspaceId; const token = await resolveWorkspaceToken(opts.workspaceToken); + const effectiveSource: IntegrationSource = source ?? { kind: 'deployer_user' }; + + // Tell the cloud which table to write the new row into. Per + // AgentWorkforce/cloud#1001, when `scope` is omitted the cloud + // defaults to `workspace` (today's behavior) so older clouds keep + // working. When `scope` is supplied, the cloud routes the row to + // user_integrations / workspace_integrations / service-account-named + // workspace_integrations accordingly, matching what the runtime + // dispatcher reads at tick time. + const sessionBody = { + allowedIntegrations: [provider], + scope: scopeRequest(effectiveSource) + }; const session = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent( workspaceId )}/integrations/connect-session`, token, { method: 'POST', - body: JSON.stringify({ allowedIntegrations: [provider] }) + body: JSON.stringify(sessionBody) }); const sessionUrl = readString(session, 'sessionUrl') ?? readString(session, 'connectLink') @@ -166,6 +195,14 @@ export function relayfileIntegrationResolver(opts: { workspaceId )}/integrations/${encodeURIComponent(provider)}/status`); if (sessionId) statusUrl.searchParams.set('connectionId', sessionId); + // Scope the status poll to the same table the connect-session + // wrote into. Older clouds ignore the param and read from + // workspace_integrations (today's behavior). + const scopeParam = effectiveSource.kind; + statusUrl.searchParams.set('scope', scopeParam); + if (effectiveSource.kind === 'workspace_service_account') { + statusUrl.searchParams.set('serviceAccountName', effectiveSource.name); + } const status = await requestJson( fetchImpl, statusUrl.toString(), @@ -188,6 +225,19 @@ export function relayfileIntegrationResolver(opts: { }; } +/** + * Translate the persona's typed `IntegrationSource` into the JSON shape the + * cloud connect-session endpoint accepts (per AgentWorkforce/cloud#1001). + */ +function scopeRequest( + source: IntegrationSource +): { kind: 'deployer_user' | 'workspace' | 'workspace_service_account'; name?: string } { + if (source.kind === 'workspace_service_account') { + return { kind: 'workspace_service_account', name: source.name }; + } + return { kind: source.kind }; +} + function providerHasEnvCredentials(provider: string): boolean { const upper = provider.toUpperCase(); return Boolean( @@ -362,7 +412,11 @@ export async function connectIntegrations(input: ConnectAllInput): Promise