diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index fb4aa21..dc929c1 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -13,6 +13,7 @@ import { createTerminalIO, deploy, writeActiveWorkspace, + type CloudAuthRecoveryResolver, type DeployMode, type DeployOptions, type ModeLaunchHandle @@ -83,7 +84,9 @@ export async function runDeploy(args: readonly string[]): Promise { } 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); @@ -112,6 +115,31 @@ export async function runDeploy(args: readonly string[]): Promise { } } +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; diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index 08ff22f..c7e0d5e 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -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[] = []; @@ -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 ` 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 ` 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 ` 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; diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 4319483..2ce86a5 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -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; +} + /** * Resolver backed by env vars. Used as the default when no higher-level * implementation is plugged in. `isConnected` returns true exactly when @@ -72,7 +81,7 @@ export function envIntegrationResolver(): IntegrationConnectResolver { export function relayfileIntegrationResolver(opts: { apiUrl: string; workspaceId: string; - workspaceToken: string; + workspaceToken: string | (() => string | Promise); io?: Pick; pollIntervalMs?: number; timeoutMs?: number; @@ -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] }) }); @@ -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') @@ -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; } @@ -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`, @@ -187,15 +205,9 @@ export async function connectIntegrations(input: ConnectAllInput): Promise { - 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`); @@ -203,8 +215,38 @@ export async function connectIntegrations(input: ConnectAllInput): Promise { + 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', @@ -302,6 +344,21 @@ export async function connectIntegrations(input: ConnectAllInput): Promise void +): Promise { + 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, @@ -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)): Promise { + return typeof token === 'function' ? await token() : token; +} + function listHasConnectedProvider(body: unknown, provider: string): boolean { const candidates = Array.isArray(body) ? body diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 11867e4..a008698 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -8,7 +8,9 @@ import { createBufferedIO } from './io.js'; import { preflightPersona } from './preflight.js'; import type { BundleStager, + CloudAuthRecoveryResolver, IntegrationConnectResolver, + ModeLaunchInput, ModeLauncher, WorkspaceAuth } from './index.js'; @@ -118,6 +120,13 @@ function successfulDevLauncher(onLaunch?: () => void): ModeLauncher { }; } +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + test('preflightPersona accepts a valid deploy-shaped persona', async () => { const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); try { @@ -366,6 +375,73 @@ test('deploy treats --no-prompt as fail-fast for missing integration connects', } }); +test('deploy can recover cloud integration auth by logging in and retrying with the fresh token', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ integrations: { notion: {} } }) + ); + const io = createBufferedIO(); + const originalFetch = globalThis.fetch; + const authHeaders: string[] = []; + let recovered = false; + let launchedToken: string | undefined; + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'stale-token' }; + } + }; + const authRecovery: CloudAuthRecoveryResolver = { + async recover({ workspace, provider, reason }) { + recovered = true; + assert.equal(workspace, 'ws-test'); + assert.equal(provider, 'notion'); + assert.match(reason, /unauthorized/); + return { token: 'fresh-token' }; + } + }; + globalThis.fetch = (async (_input, init) => { + authHeaders.push(String(new Headers(init?.headers).get('authorization'))); + if (authHeaders.length === 1) { + return jsonResponse({ error: 'Unauthorized' }, 401); + } + return jsonResponse([ + { provider: 'notion', status: 'ready', connectionId: 'conn-notion' } + ]); + }) as typeof fetch; + + try { + const result = await deploy( + { personaPath, mode: 'cloud', io }, + { + workspaceAuth, + authRecovery, + bundle: successfulBundleStager(), + modes: { + cloud: { + async launch(input: ModeLaunchInput) { + launchedToken = input.workspaceToken; + return { + id: 'cloud-1', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + } + } + } + ); + + assert.equal(recovered, true); + assert.deepEqual(authHeaders, ['Bearer stale-token', 'Bearer fresh-token']); + assert.equal(launchedToken, 'fresh-token'); + assert.deepEqual(result.connectedIntegrations, ['notion']); + } finally { + globalThis.fetch = originalFetch; + await cleanup(); + } +}); + test('deploy stages a bundle and hands off to the resolved launcher', async () => { const { personaPath, dir, cleanup } = await withTempPersona(basePersonaJson()); const io = createBufferedIO(); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 28b62f7..52ec36b 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -7,6 +7,7 @@ import { envIntegrationResolver, relayfileIntegrationResolver, type ConnectAllInput, + type IntegrationAuthRecoveryResolver, type IntegrationConnectResolver, type ProviderSubscriptionResolver } from './connect.js'; @@ -36,11 +37,22 @@ import type { export interface DeployResolvers { workspaceAuth?: WorkspaceAuth; integrations?: IntegrationConnectResolver; + authRecovery?: CloudAuthRecoveryResolver; subscription?: ProviderSubscriptionResolver; bundle?: BundleStager; modes?: Partial>; } +export interface CloudAuthRecoveryResolver { + recover(args: { + workspace: string; + cloudUrl: string; + io: DeployIO; + provider: string; + reason: string; + }): Promise<{ token: string } | false | null | undefined>; +} + /** * Pick the run mode for this deploy. Per the deploy-v1 spec: * - Explicit `--mode` always wins. @@ -138,6 +150,13 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { io }); io.info(`workspace: ${workspace}`); + let activeToken = token; + const cloudUrl = normalizeCloudUrl( + opts.cloudUrl + ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL + ?? process.env.WORKFORCE_CLOUD_URL + ?? defaultApiUrl() + ); const connectedIntegrations = await connectAndCollectIntegrations({ persona: preflight.persona, @@ -148,10 +167,21 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { integrations: resolvers.integrations ?? defaultIntegrationResolver({ mode, workspace, - token, - cloudUrl: opts.cloudUrl, + token: () => activeToken, + cloudUrl, io }), + ...(resolvers.authRecovery + ? { + authRecovery: authRecoveryForIntegrations( + resolvers.authRecovery, + () => activeToken, + (nextToken) => { activeToken = nextToken; }, + cloudUrl, + io + ) + } + : {}), ...(resolvers.subscription ? { subscription: resolvers.subscription } : {}) }); @@ -174,7 +204,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { bundle, workspace, io, - ...(token ? { workspaceToken: token } : {}), + ...(activeToken ? { workspaceToken: activeToken } : {}), ...(opts.detach ? { detach: true } : {}), ...(opts.byoSandbox ? { byoSandbox: true } : {}), ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}), @@ -234,24 +264,44 @@ async function connectAndCollectIntegrations(input: ConnectAllInput): Promise string | Promise); cloudUrl?: string; io: DeployIO; }): IntegrationConnectResolver { if (args.mode !== 'cloud') return envIntegrationResolver(); return relayfileIntegrationResolver({ - apiUrl: normalizeCloudUrl( - args.cloudUrl - ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL - ?? process.env.WORKFORCE_CLOUD_URL - ?? defaultApiUrl() - ), + apiUrl: normalizeCloudUrl(args.cloudUrl ?? defaultApiUrl()), workspaceId: args.workspace, workspaceToken: args.token, io: args.io }); } +function authRecoveryForIntegrations( + resolver: CloudAuthRecoveryResolver, + currentToken: () => string, + setToken: (token: string) => void, + cloudUrl: string, + io: DeployIO +): IntegrationAuthRecoveryResolver { + return { + async recover({ workspace, provider, reason }) { + const result = await resolver.recover({ + workspace, + cloudUrl, + io, + provider, + reason + }); + if (!result) return false; + if (result.token && result.token !== currentToken()) { + setToken(result.token); + } + return true; + } + }; +} + function normalizeCloudUrl(url: string): string { const trimmed = url.trim(); return trimmed ? trimmed.replace(/\/+$/, '') : defaultApiUrl().replace(/\/+$/, ''); diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 8706f8b..d6236ab 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -1,6 +1,7 @@ import { deploy as deployInternal, pickMode, + type CloudAuthRecoveryResolver, type DeployResolvers } from './deploy.js'; import { preflightPersona } from './preflight.js'; @@ -14,7 +15,7 @@ import type { ModeLauncher } from './types.js'; -export { pickMode, type DeployResolvers }; +export { pickMode, type CloudAuthRecoveryResolver, type DeployResolvers }; export { preflightPersona }; export { connectIntegrations, @@ -22,6 +23,7 @@ export { relayfileIntegrationResolver, type ConnectAllInput, type ConnectAllResult, + type IntegrationAuthRecoveryResolver, type IntegrationConnectResolver, type ProviderSubscriptionResolver } from './connect.js';