Skip to content

Commit 94cc423

Browse files
khaliqgantRicky Schema Cascade
andauthored
fix(deploy): prompt cloud login on integration auth failure (#116)
* Prompt cloud login on deploy integration auth failures * Address deploy auth recovery review feedback --------- Co-authored-by: Ricky Schema Cascade <ricky@agent-relay.com>
1 parent a0610cc commit 94cc423

6 files changed

Lines changed: 406 additions & 28 deletions

File tree

packages/cli/src/deploy-command.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createTerminalIO,
1414
deploy,
1515
writeActiveWorkspace,
16+
type CloudAuthRecoveryResolver,
1617
type DeployMode,
1718
type DeployOptions,
1819
type ModeLaunchHandle
@@ -83,7 +84,9 @@ export async function runDeploy(args: readonly string[]): Promise<void> {
8384
}
8485

8586
try {
86-
const result = await deploy(parsed);
87+
const result = await deploy(parsed, {
88+
authRecovery: createDeployAuthRecovery(parsed)
89+
});
8790
if (parsed.dryRun) {
8891
process.stdout.write(`\nok: ${result.deploymentId} (dry-run)\n`);
8992
process.exit(0);
@@ -112,6 +115,31 @@ export async function runDeploy(args: readonly string[]): Promise<void> {
112115
}
113116
}
114117

118+
function createDeployAuthRecovery(opts: DeployOptions): CloudAuthRecoveryResolver {
119+
return {
120+
async recover({ workspace, cloudUrl, io, reason }) {
121+
const ok = await io.confirm(
122+
'Cloud login is required before deploy can check integrations. Log in now? (opens browser)',
123+
{ defaultValue: true }
124+
);
125+
if (!ok) return false;
126+
127+
io.info(`cloud: starting login because integration auth failed (${reason})`);
128+
const auth = await deployCommandDeps.ensureAuthenticated(cloudUrl, { force: true });
129+
const apiUrl = normalizeCloudUrl(auth.apiUrl || cloudUrl);
130+
const activeWorkspace = opts.workspace ?? workspace;
131+
await deployCommandDeps.writeActiveWorkspace({
132+
workspace: activeWorkspace,
133+
cloudUrl: apiUrl
134+
});
135+
io.info(`cloud: logged in for workspace ${activeWorkspace}; retrying integration check`);
136+
return {
137+
token: auth.accessToken
138+
};
139+
}
140+
};
141+
}
142+
115143
function isRunHandle(value: unknown): value is ModeLaunchHandle {
116144
if (typeof value !== 'object' || value === null || !('done' in value)) {
117145
return false;

packages/deploy/src/connect.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,33 @@ test('relayfileIntegrationResolver isConnected reads the cloud integration list'
3131
]);
3232
});
3333

34+
test('relayfileIntegrationResolver reads the latest workspace token for each request', async () => {
35+
let token = 'old-token';
36+
const authHeaders: string[] = [];
37+
const resolver = relayfileIntegrationResolver({
38+
apiUrl: 'https://cloud.example.test',
39+
workspaceId: 'ws-1',
40+
workspaceToken: () => token,
41+
fetch: async (_url, init) => {
42+
authHeaders.push(String(new Headers(init?.headers).get('authorization')));
43+
if (authHeaders.length === 1) {
44+
return okJson({ error: 'Unauthorized' }, 401);
45+
}
46+
return okJson([
47+
{ provider: 'github', status: 'ready', connectionId: 'conn-1' }
48+
]);
49+
}
50+
});
51+
52+
await assert.rejects(
53+
resolver.isConnected({ workspace: 'ws-runtime', provider: 'github' }),
54+
/unauthorized/
55+
);
56+
token = 'new-token';
57+
assert.equal(await resolver.isConnected({ workspace: 'ws-runtime', provider: 'github' }), true);
58+
assert.deepEqual(authHeaders, ['Bearer old-token', 'Bearer new-token']);
59+
});
60+
3461
test('relayfileIntegrationResolver connect opens a session and polls until connected', async () => {
3562
let polls = 0;
3663
const opened: string[] = [];
@@ -169,6 +196,136 @@ test('relayfileIntegrationResolver surfaces the agentworkforce-native error on 4
169196
);
170197
});
171198

199+
test('connectIntegrations prompts auth recovery on unauthorized status checks and retries', async () => {
200+
const io = createBufferedIO();
201+
let checks = 0;
202+
let recoverCalled = false;
203+
let connectCalled = false;
204+
205+
const result = await connectIntegrations({
206+
persona: {
207+
id: 'essay',
208+
intent: 'essay',
209+
description: 'test persona',
210+
tags: ['implementation'],
211+
integrations: { notion: {} }
212+
} as never,
213+
workspace: 'ws-1',
214+
noConnect: false,
215+
io,
216+
integrations: {
217+
async isConnected() {
218+
checks += 1;
219+
if (checks === 1) {
220+
throw new Error(
221+
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
222+
);
223+
}
224+
return true;
225+
},
226+
async connect() {
227+
connectCalled = true;
228+
throw new Error('connect should not be called after auth recovery');
229+
}
230+
},
231+
authRecovery: {
232+
async recover({ workspace, provider }) {
233+
recoverCalled = true;
234+
assert.equal(workspace, 'ws-1');
235+
assert.equal(provider, 'notion');
236+
return true;
237+
}
238+
}
239+
});
240+
241+
assert.equal(recoverCalled, true);
242+
assert.equal(connectCalled, false);
243+
assert.equal(checks, 2);
244+
assert.deepEqual(result.outcomes, [{ provider: 'notion', status: 'already-connected' }]);
245+
});
246+
247+
test('connectIntegrations does not prompt auth recovery when --no-prompt is set', async () => {
248+
const io = createBufferedIO();
249+
let recoverCalled = false;
250+
251+
const result = await connectIntegrations({
252+
persona: {
253+
id: 'essay',
254+
intent: 'essay',
255+
description: 'test persona',
256+
tags: ['implementation'],
257+
integrations: { notion: {} }
258+
} as never,
259+
workspace: 'ws-1',
260+
noConnect: true,
261+
noPrompt: true,
262+
io,
263+
integrations: {
264+
async isConnected() {
265+
throw new Error(
266+
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
267+
);
268+
},
269+
async connect() {
270+
throw new Error('connect should not be called after auth failure');
271+
}
272+
},
273+
authRecovery: {
274+
async recover() {
275+
recoverCalled = true;
276+
return true;
277+
}
278+
}
279+
});
280+
281+
assert.equal(recoverCalled, false);
282+
assert.deepEqual(result.outcomes, [
283+
{
284+
provider: 'notion',
285+
status: 'failed',
286+
message:
287+
'cloud integration request failed: unauthorized. Your active workspace session is invalid or expired. Run `agentworkforce login --workspace <id-or-slug>` to refresh, then retry.'
288+
}
289+
]);
290+
});
291+
292+
test('connectIntegrations fails status-check errors without opening a connect flow', async () => {
293+
const io = createBufferedIO();
294+
let connectCalled = false;
295+
296+
const result = await connectIntegrations({
297+
persona: {
298+
id: 'essay',
299+
intent: 'essay',
300+
description: 'test persona',
301+
tags: ['implementation'],
302+
integrations: { notion: {} }
303+
} as never,
304+
workspace: 'ws-1',
305+
noConnect: false,
306+
io,
307+
integrations: {
308+
async isConnected() {
309+
throw new Error('cloud integration request failed: 503 Service Unavailable');
310+
},
311+
async connect() {
312+
connectCalled = true;
313+
throw new Error('connect should not be called after status-check failure');
314+
}
315+
}
316+
});
317+
318+
assert.equal(connectCalled, false);
319+
assert.deepEqual(result.outcomes, [
320+
{
321+
provider: 'notion',
322+
status: 'failed',
323+
message: 'cloud integration request failed: 503 Service Unavailable'
324+
}
325+
]);
326+
assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('failed while checking connection status')));
327+
});
328+
172329
test('connectIntegrations honors --no-prompt for subscription provider setup', async () => {
173330
const io = createBufferedIO();
174331
let confirmCalled = false;

packages/deploy/src/connect.ts

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ export interface ProviderSubscriptionResolver {
4646
connect(args: { workspace: string; providerHint?: string }): Promise<{ provider: string }>;
4747
}
4848

49+
/**
50+
* Called after a cloud integration status check gets a 401. The CLI uses
51+
* this to run the established browser login flow, refresh the active bearer
52+
* token, and let the status check retry once.
53+
*/
54+
export interface IntegrationAuthRecoveryResolver {
55+
recover(args: { workspace: string; provider: string; reason: string }): Promise<boolean>;
56+
}
57+
4958
/**
5059
* Resolver backed by env vars. Used as the default when no higher-level
5160
* implementation is plugged in. `isConnected` returns true exactly when
@@ -72,7 +81,7 @@ export function envIntegrationResolver(): IntegrationConnectResolver {
7281
export function relayfileIntegrationResolver(opts: {
7382
apiUrl: string;
7483
workspaceId: string;
75-
workspaceToken: string;
84+
workspaceToken: string | (() => string | Promise<string>);
7685
io?: Pick<DeployIO, 'info' | 'warn'>;
7786
pollIntervalMs?: number;
7887
timeoutMs?: number;
@@ -88,16 +97,18 @@ export function relayfileIntegrationResolver(opts: {
8897
return {
8998
async isConnected({ workspace, provider }) {
9099
const workspaceId = workspace || opts.workspaceId;
100+
const token = await resolveWorkspaceToken(opts.workspaceToken);
91101
const body = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent(
92102
workspaceId
93-
)}/integrations`, opts.workspaceToken);
103+
)}/integrations`, token);
94104
return listHasConnectedProvider(body, provider);
95105
},
96106
async connect({ workspace, provider }) {
97107
const workspaceId = workspace || opts.workspaceId;
108+
const token = await resolveWorkspaceToken(opts.workspaceToken);
98109
const session = await requestJson(fetchImpl, `${apiUrl}/api/v1/workspaces/${encodeURIComponent(
99110
workspaceId
100-
)}/integrations/connect-session`, opts.workspaceToken, {
111+
)}/integrations/connect-session`, token, {
101112
method: 'POST',
102113
body: JSON.stringify({ allowedIntegrations: [provider] })
103114
});
@@ -122,7 +133,11 @@ export function relayfileIntegrationResolver(opts: {
122133
workspaceId
123134
)}/integrations/${encodeURIComponent(provider)}/status`);
124135
if (sessionId) statusUrl.searchParams.set('connectionId', sessionId);
125-
const status = await requestJson(fetchImpl, statusUrl.toString(), opts.workspaceToken);
136+
const status = await requestJson(
137+
fetchImpl,
138+
statusUrl.toString(),
139+
await resolveWorkspaceToken(opts.workspaceToken)
140+
);
126141
if (isConnectedStatus(status)) {
127142
const connectionId = readString(status, 'connectionId')
128143
?? readString(status, 'currentConnectionId')
@@ -155,6 +170,8 @@ export interface ConnectAllInput {
155170
noPrompt?: boolean;
156171
io: DeployIO;
157172
integrations: IntegrationConnectResolver;
173+
/** Optional cloud-login recovery for interactive 401s. */
174+
authRecovery?: IntegrationAuthRecoveryResolver;
158175
/** Required only when persona.useSubscription is true. */
159176
subscription?: ProviderSubscriptionResolver;
160177
}
@@ -174,7 +191,8 @@ export interface ConnectAllResult {
174191
* Behavior summary:
175192
* - integrations: {} or undefined → returns immediately, no prompts
176193
* - already-connected provider → no prompt; emits `already-connected`
177-
* - auth failure while checking status → fails without prompting
194+
* - 401 while checking status + authRecovery → prompts login and retries once
195+
* - other auth failure while checking status → fails without integration prompts
178196
* - not connected + noPrompt=true → fails immediately without prompting
179197
* - not connected + noConnect=true → fails the deploy with a clear message
180198
* - not connected + noConnect=false → prompts; on yes runs `connect`,
@@ -187,24 +205,48 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
187205

188206
for (const provider of Object.keys(integrations)) {
189207
let statusCheckFailure: string | undefined;
190-
const connected = await input.integrations
191-
.isConnected({ workspace: input.workspace, provider })
192-
.catch((err) => {
193-
statusCheckFailure = err instanceof Error ? err.message : String(err);
194-
input.io.warn(
195-
`failed to check connection status for ${provider}: ${statusCheckFailure}`
196-
);
197-
return false;
198-
});
208+
let connected = await checkProviderConnected(input, provider, (message) => {
209+
statusCheckFailure = message;
210+
});
199211

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

206-
if (statusCheckFailure && isIntegrationAuthFailure(statusCheckFailure)) {
207-
input.io.error(`integrations.${provider}: auth failed while checking connection status`);
218+
if (
219+
statusCheckFailure
220+
&& isIntegrationUnauthorizedFailure(statusCheckFailure)
221+
&& !input.noPrompt
222+
&& input.authRecovery
223+
) {
224+
const recovered = await input.authRecovery
225+
.recover({ workspace: input.workspace, provider, reason: statusCheckFailure })
226+
.catch((err) => {
227+
input.io.error(
228+
`integrations.${provider}: login failed: ${err instanceof Error ? err.message : String(err)}`
229+
);
230+
return false;
231+
});
232+
233+
if (recovered) {
234+
statusCheckFailure = undefined;
235+
connected = await checkProviderConnected(input, provider, (message) => {
236+
statusCheckFailure = message;
237+
});
238+
if (connected) {
239+
input.io.info(`integrations.${provider}: already connected`);
240+
outcomes.push({ provider, status: 'already-connected' });
241+
continue;
242+
}
243+
}
244+
}
245+
246+
if (statusCheckFailure) {
247+
input.io.error(
248+
`integrations.${provider}: ${isIntegrationAuthFailure(statusCheckFailure) ? 'auth failed' : 'failed'} while checking connection status`
249+
);
208250
outcomes.push({
209251
provider,
210252
status: 'failed',
@@ -302,6 +344,21 @@ export async function connectIntegrations(input: ConnectAllInput): Promise<Conne
302344
};
303345
}
304346

347+
async function checkProviderConnected(
348+
input: ConnectAllInput,
349+
provider: string,
350+
onFailure: (message: string) => void
351+
): Promise<boolean> {
352+
return await input.integrations
353+
.isConnected({ workspace: input.workspace, provider })
354+
.catch((err) => {
355+
const message = err instanceof Error ? err.message : String(err);
356+
onFailure(message);
357+
input.io.warn(`failed to check connection status for ${provider}: ${message}`);
358+
return false;
359+
});
360+
}
361+
305362
async function requestJson(
306363
fetchImpl: typeof fetch,
307364
url: string,
@@ -336,6 +393,14 @@ function isIntegrationAuthFailure(message: string): boolean {
336393
return /cloud integration request failed: (unauthorized|forbidden)\b/i.test(message);
337394
}
338395

396+
function isIntegrationUnauthorizedFailure(message: string): boolean {
397+
return /cloud integration request failed: unauthorized\b/i.test(message);
398+
}
399+
400+
async function resolveWorkspaceToken(token: string | (() => string | Promise<string>)): Promise<string> {
401+
return typeof token === 'function' ? await token() : token;
402+
}
403+
339404
function listHasConnectedProvider(body: unknown, provider: string): boolean {
340405
const candidates = Array.isArray(body)
341406
? body

0 commit comments

Comments
 (0)