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
30 changes: 29 additions & 1 deletion packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createTerminalIO,
deploy,
writeActiveWorkspace,
type CloudAuthRecoveryResolver,
type DeployMode,
type DeployOptions,
type ModeLaunchHandle
Expand Down Expand Up @@ -83,7 +84,9 @@ export async function runDeploy(args: readonly string[]): Promise<void> {
}

try {
const result = await deploy(parsed);
const result = await deploy(parsed, {
authRecovery: createDeployAuthRecovery(parsed)
});
if (parsed.dryRun) {
process.stdout.write(`\nok: ${result.deploymentId} (dry-run)\n`);
process.exit(0);
Expand Down Expand Up @@ -112,6 +115,31 @@ export async function runDeploy(args: readonly string[]): Promise<void> {
}
}

function createDeployAuthRecovery(opts: DeployOptions): CloudAuthRecoveryResolver {
return {
async recover({ workspace, cloudUrl, io, reason }) {
const ok = await io.confirm(
'Cloud login is required before deploy can check integrations. Log in now? (opens browser)',
{ defaultValue: true }
);
if (!ok) return false;

io.info(`cloud: starting login because integration auth failed (${reason})`);
const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl, { force: true });
const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl);
const activeWorkspace = opts.workspace ?? workspace;
await deployCommandDeps.writeActiveWorkspace({
workspace: activeWorkspace,
cloudUrl: apiUrl
});
io.info(`cloud: logged in for workspace ${activeWorkspace}; retrying integration check`);
return {
token: auth.accessToken
};
}
};
}

function isRunHandle(value: unknown): value is ModeLaunchHandle {
if (typeof value !== 'object' || value === null || !('done' in value)) {
return false;
Expand Down
157 changes: 157 additions & 0 deletions packages/deploy/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@ test('relayfileIntegrationResolver isConnected reads the cloud integration list'
]);
});

test('relayfileIntegrationResolver reads the latest workspace token for each request', async () => {
let token = 'old-token';
const authHeaders: string[] = [];
const resolver = relayfileIntegrationResolver({
apiUrl: 'https://cloud.example.test',
workspaceId: 'ws-1',
workspaceToken: () => token,
fetch: async (_url, init) => {
authHeaders.push(String(new Headers(init?.headers).get('authorization')));
if (authHeaders.length === 1) {
return okJson({ error: 'Unauthorized' }, 401);
}
return okJson([
{ provider: 'github', status: 'ready', connectionId: 'conn-1' }
]);
}
});

await assert.rejects(
resolver.isConnected({ workspace: 'ws-runtime', provider: 'github' }),
/unauthorized/
);
token = 'new-token';
assert.equal(await resolver.isConnected({ workspace: 'ws-runtime', provider: 'github' }), true);
assert.deepEqual(authHeaders, ['Bearer old-token', 'Bearer new-token']);
});

test('relayfileIntegrationResolver connect opens a session and polls until connected', async () => {
let polls = 0;
const opened: string[] = [];
Expand Down Expand Up @@ -169,6 +196,136 @@ test('relayfileIntegrationResolver surfaces the agentworkforce-native error on 4
);
});

test('connectIntegrations prompts auth recovery on unauthorized status checks and retries', async () => {
const io = createBufferedIO();
let checks = 0;
let recoverCalled = false;
let connectCalled = false;

const result = await connectIntegrations({
persona: {
id: 'essay',
intent: 'essay',
description: 'test persona',
tags: ['implementation'],
integrations: { notion: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
io,
integrations: {
async isConnected() {
checks += 1;
if (checks === 1) {
throw new Error(
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
);
}
return true;
},
async connect() {
connectCalled = true;
throw new Error('connect should not be called after auth recovery');
}
},
authRecovery: {
async recover({ workspace, provider }) {
recoverCalled = true;
assert.equal(workspace, 'ws-1');
assert.equal(provider, 'notion');
return true;
}
}
});

assert.equal(recoverCalled, true);
assert.equal(connectCalled, false);
assert.equal(checks, 2);
assert.deepEqual(result.outcomes, [{ provider: 'notion', status: 'already-connected' }]);
});

test('connectIntegrations does not prompt auth recovery when --no-prompt is set', async () => {
const io = createBufferedIO();
let recoverCalled = false;

const result = await connectIntegrations({
persona: {
id: 'essay',
intent: 'essay',
description: 'test persona',
tags: ['implementation'],
integrations: { notion: {} }
} as never,
workspace: 'ws-1',
noConnect: true,
noPrompt: true,
io,
integrations: {
async isConnected() {
throw new Error(
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
);
},
async connect() {
throw new Error('connect should not be called after auth failure');
}
},
authRecovery: {
async recover() {
recoverCalled = true;
return true;
}
}
});

assert.equal(recoverCalled, false);
assert.deepEqual(result.outcomes, [
{
provider: 'notion',
status: 'failed',
message:
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
}
]);
});

test('connectIntegrations fails status-check errors without opening a connect flow', async () => {
const io = createBufferedIO();
let connectCalled = false;

const result = await connectIntegrations({
persona: {
id: 'essay',
intent: 'essay',
description: 'test persona',
tags: ['implementation'],
integrations: { notion: {} }
} as never,
workspace: 'ws-1',
noConnect: false,
io,
integrations: {
async isConnected() {
throw new Error('cloud integration request failed: 503 Service Unavailable');
},
async connect() {
connectCalled = true;
throw new Error('connect should not be called after status-check failure');
}
}
});

assert.equal(connectCalled, false);
assert.deepEqual(result.outcomes, [
{
provider: 'notion',
status: 'failed',
message: 'cloud integration request failed: 503 Service Unavailable'
}
]);
assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('failed while checking connection status')));
});

test('connectIntegrations honors --no-prompt for subscription provider setup', async () => {
const io = createBufferedIO();
let confirmCalled = false;
Expand Down
97 changes: 81 additions & 16 deletions packages/deploy/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ export interface ProviderSubscriptionResolver {
connect(args: { workspace: string; providerHint?: string }): Promise<{ provider: string }>;
}

/**
* Called after a cloud integration status check gets a 401. The CLI uses
* this to run the established browser login flow, refresh the active bearer
* token, and let the status check retry once.
*/
export interface IntegrationAuthRecoveryResolver {
recover(args: { workspace: string; provider: string; reason: string }): Promise<boolean>;
}

/**
* Resolver backed by env vars. Used as the default when no higher-level
* implementation is plugged in. `isConnected` returns true exactly when
Expand All @@ -72,7 +81,7 @@ export function envIntegrationResolver(): IntegrationConnectResolver {
export function relayfileIntegrationResolver(opts: {
apiUrl: string;
workspaceId: string;
workspaceToken: string;
workspaceToken: string | (() => string | Promise<string>);
io?: Pick<DeployIO, 'info' | 'warn'>;
pollIntervalMs?: number;
timeoutMs?: number;
Expand All @@ -88,16 +97,18 @@ export function relayfileIntegrationResolver(opts: {
return {
async isConnected({ workspace, provider }) {
const workspaceId = workspace || opts.workspaceId;
const token = await resolveWorkspaceToken(opts.workspaceToken);
const body = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent(
workspaceId
)}/integrations`, opts.workspaceToken);
)}/integrations`, token);
return listHasConnectedProvider(body, provider);
},
async connect({ workspace, provider }) {
const workspaceId = workspace || opts.workspaceId;
const token = await resolveWorkspaceToken(opts.workspaceToken);
const session = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent(
workspaceId
)}/integrations/connect-session`, opts.workspaceToken, {
)}/integrations/connect-session`, token, {
method: 'POST',
body: JSON.stringify({ allowedIntegrations: [provider] })
});
Expand All @@ -122,7 +133,11 @@ export function relayfileIntegrationResolver(opts: {
workspaceId
)}/integrations/${encodeURIComponent(provider)}/status`);
if (sessionId) statusUrl.searchParams.set('connectionId', sessionId);
const status = await requestJson(fetchImpl, statusUrl.toString(), opts.workspaceToken);
const status = await requestJson(
fetchImpl,
statusUrl.toString(),
await resolveWorkspaceToken(opts.workspaceToken)
);
if (isConnectedStatus(status)) {
const connectionId = readString(status, 'connectionId')
?? readString(status, 'currentConnectionId')
Expand Down Expand Up @@ -155,6 +170,8 @@ export interface ConnectAllInput {
noPrompt?: boolean;
io: DeployIO;
integrations: IntegrationConnectResolver;
/** Optional cloud-login recovery for interactive 401s. */
authRecovery?: IntegrationAuthRecoveryResolver;
/** Required only when persona.useSubscription is true. */
subscription?: ProviderSubscriptionResolver;
}
Expand All @@ -174,7 +191,8 @@ export interface ConnectAllResult {
* Behavior summary:
* - integrations: {} or undefined → returns immediately, no prompts
* - already-connected provider → no prompt; emits `already-connected`
* - auth failure while checking status → fails without prompting
* - 401 while checking status + authRecovery → prompts login and retries once
* - other auth failure while checking status → fails without integration prompts
* - not connected + noPrompt=true → fails immediately without prompting
* - not connected + noConnect=true → fails the deploy with a clear message
* - not connected + noConnect=false → prompts; on yes runs `connect`,
Expand All @@ -187,24 +205,48 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne

for (const provider of Object.keys(integrations)) {
let statusCheckFailure: string | undefined;
const connected = await input.integrations
.isConnected({ workspace: input.workspace, provider })
.catch((err) => {
statusCheckFailure = err instanceof Error ? err.message : String(err);
input.io.warn(
`failed to check connection status for ${provider}: ${statusCheckFailure}`
);
return false;
});
let connected = await checkProviderConnected(input, provider, (message) => {
statusCheckFailure = message;
});

if (connected) {
input.io.info(`integrations.${provider}: already connected`);
outcomes.push({ provider, status: 'already-connected' });
continue;
}

if (statusCheckFailure && isIntegrationAuthFailure(statusCheckFailure)) {
input.io.error(`integrations.${provider}: auth failed while checking connection status`);
if (
statusCheckFailure
&& isIntegrationUnauthorizedFailure(statusCheckFailure)
&& !input.noPrompt
&& input.authRecovery
) {
const recovered = await input.authRecovery
.recover({ workspace: input.workspace, provider, reason: statusCheckFailure })
.catch((err) => {
input.io.error(
`integrations.${provider}: login failed: ${err instanceof Error ? err.message : String(err)}`
);
return false;
});

if (recovered) {
statusCheckFailure = undefined;
connected = await checkProviderConnected(input, provider, (message) => {
statusCheckFailure = message;
});
if (connected) {
input.io.info(`integrations.${provider}: already connected`);
outcomes.push({ provider, status: 'already-connected' });
continue;
}
}
}

if (statusCheckFailure) {
input.io.error(
`integrations.${provider}: ${isIntegrationAuthFailure(statusCheckFailure) ? 'auth failed' : 'failed'} while checking connection status`
);
outcomes.push({
provider,
status: 'failed',
Expand Down Expand Up @@ -302,6 +344,21 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
};
}

async function checkProviderConnected(
input: ConnectAllInput,
provider: string,
onFailure: (message: string) => void
): Promise<boolean> {
return await input.integrations
.isConnected({ workspace: input.workspace, provider })
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
onFailure(message);
input.io.warn(`failed to check connection status for ${provider}: ${message}`);
return false;
});
}

async function requestJson(
fetchImpl: typeof fetch,
url: string,
Expand Down Expand Up @@ -336,6 +393,14 @@ function isIntegrationAuthFailure(message: string): boolean {
return /cloud integration request failed: (unauthorized|forbidden)\b/i.test(message);
}

function isIntegrationUnauthorizedFailure(message: string): boolean {
return /cloud integration request failed: unauthorized\b/i.test(message);
}

async function resolveWorkspaceToken(token: string | (() => string | Promise<string>)): Promise<string> {
return typeof token === 'function' ? await token() : token;
}

function listHasConnectedProvider(body: unknown, provider: string): boolean {
const candidates = Array.isArray(body)
? body
Expand Down
Loading
Loading