Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand All @@ -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',
Expand Down
64 changes: 59 additions & 5 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,24 @@ export interface IntegrationConnectResolver {
source?: IntegrationSource;
expectedConfigKey?: string;
}): Promise<boolean>;
/** 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 }>;
}

/**
Expand Down Expand Up @@ -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')
Expand All @@ -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(),
Expand All @@ -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(
Expand Down Expand Up @@ -362,7 +412,11 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
}

try {
const result = await input.integrations.connect({ workspace: input.workspace, provider });
const result = await input.integrations.connect({
workspace: input.workspace,
provider,
source
});
input.io.info(`integrations.${provider}: connected (${result.connectionId})`);
outcomes.push({ provider, status: 'connected-now' });
} catch (err) {
Expand Down
Loading