From f25853f8f2e04bc7bdd9410d3f23316cf376dcae Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 23 May 2026 00:57:00 +0200 Subject: [PATCH] fix(deploy): pass persona source.kind into connect-session + status poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the third leg of the integration-scope contract. The persona declares source (deployer_user / workspace / workspace_service_account) and persona-kit + the cloud's runtime dispatcher both honor it — but the cloud's connect-session endpoint ignores it and always writes to workspace_integrations. So a persona declaring (or defaulting to) deployer_user runs OAuth, the row lands in workspace_integrations, /me/integrations stays empty, the preflight re-prompts on the next deploy, and the runtime dispatcher silently has nothing to resolve at tick time. This change threads the persona's declared `source` from each integration declaration through to `relayfileIntegrationResolver.connect`, which now posts `scope: { kind, name? }` in the connect-session body and appends `scope=...` (+ optional `serviceAccountName`) to the status poll URL. The cloud half — accepting and honoring those fields — is tracked separately as AgentWorkforce/cloud#1001 and must merge first. DO NOT MERGE until cloud#1001 ships. Until it does, sending the new fields is a no-op on the server side (today's behavior preserved), so this PR is safe to land in either order, but the user-visible bug only resolves once both halves are live. Tests: 121/121 pass, 3 new covering each scope.kind variant going out on the wire (deployer_user default, workspace explicit, and workspace_service_account with name attribution). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/connect.test.ts | 88 ++++++++++++++++++++++++++++- packages/deploy/src/connect.ts | 64 +++++++++++++++++++-- 2 files changed, 146 insertions(+), 6 deletions(-) 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