From 866105aa5d2d39e1c85d4e828dfc6c2c85fb321c Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sun, 14 Jun 2026 14:11:27 +0200 Subject: [PATCH] fix(deploy): let --reconnect refresh a revoked harness LLM credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud marks a harness credential row `connected` even after its OAuth token is revoked server-side (it never re-validates the token), so `ensureHarnessOauth`/`ensureSubscriptionOauth` short-circuited and a plain redeploy could never refresh a dead credential. `--reconnect` only reached the integrations connect path, never the LLM harness credential — leaving a manual dashboard disconnect as the only recovery. This is the codex/ChatGPT failure mode: re-running `codex login` locally rotates and revokes the refresh token cloud baked into every fire, so the pr-reviewer (and any oauth-harness persona) fails every run with "review harness exited with code 1" (401 token_invalidated) until reconnected. Thread `reconnectProviders` into `ModeLaunchInput` and both harness-oauth paths. When the user passes `--reconnect ` matching the persona's model provider (openai/anthropic) or harness name (codex/claude), force a fresh `connectProvider` flow that overwrites the stored token even when cloud reports it connected. Under --no-prompt it fails with actionable guidance (the connect flow needs a TTY). Recovery is now one flag instead of a dashboard round-trip: agentworkforce deploy ./review/persona.ts --on-exists update --reconnect codex Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/deploy-command.ts | 4 +- packages/deploy/src/deploy.ts | 4 +- packages/deploy/src/modes/cloud.test.ts | 102 +++++++++++++++++++++++ packages/deploy/src/modes/cloud/index.ts | 89 ++++++++++++++++---- packages/deploy/src/types.ts | 7 ++ 5 files changed, 186 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index 35dcd6a2..8c3fa15a 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -221,7 +221,9 @@ Flags: --mode dev|sandbox|cloud Pick a run mode (prompts in an interactive terminal) --workspace Workforce workspace; defaults to the active workspace --no-connect Skip integration-connect prompts; fail if any are missing - --reconnect Force a fresh integration connect flow (repeatable) + --reconnect Force a fresh connect flow even if already connected, + for an integration or the harness LLM credential + (e.g. openai/codex, anthropic/claude). Repeatable. --byo-sandbox Force BYO Daytona auth even when logged in --detach Background the runner instead of streaming logs --bundle-out Emit the bundle to and exit (no launch) diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 769457aa..72fe289d 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -225,7 +225,8 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { io, noPrompt: opts.noPrompt === true || opts.noConnect === true, ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), - ...(opts.byokKey ? { byokKey: opts.byokKey } : {}) + ...(opts.byokKey ? { byokKey: opts.byokKey } : {}), + ...(opts.reconnectProviders ? { reconnectProviders: opts.reconnectProviders } : {}) }); credentialSelections = result.credentialSelections; subscription = alreadyConnectedSubscriptionResolver(result.provider); @@ -326,6 +327,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { ...(opts.noPrompt ? { noPrompt: true } : {}), ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), ...(opts.byokKey ? { byokKey: opts.byokKey } : {}), + ...(opts.reconnectProviders ? { reconnectProviders: opts.reconnectProviders } : {}), ...(opts.onExists ? { onExists: opts.onExists } : {}), ...(Object.keys(resolvedInputs).length > 0 ? { inputs: resolvedInputs } : {}), ...(credentialSelections ? { credentialSelections } : {}), diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index ca61ff87..1eed2d23 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -597,6 +597,108 @@ test('cloud harness OAuth starts auth and polls /cloud-agents until the harness assert.deepEqual(connected, ['openai']); }); +// Cloud marks a credential row `connected` even after its OAuth token is +// revoked server-side, so a plain redeploy short-circuits and never refreshes +// a dead harness credential. `--reconnect ` is the escape hatch. +test('cloud --reconnect forces a fresh harness connect even when already connected', async () => { + const connected: string[] = []; + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + connectProvider: async (options: { provider: string }) => { + connected.push(options.provider); + return { provider: options.provider, success: true }; + }, + createCloudApiClient() { + return { + async fetch(pathname: string) { + assert.equal(pathname, '/api/v1/cloud-agents'); + return okJson({ + agents: [{ id: 'cloud-agent-openai', harness: 'openai', status: 'connected' }] + }); + } + }; + } + }); + const io = createBufferedIO(); + const { bundle, cleanup } = await withBundle(); + const fetchMock = installFetch((url, init) => { + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-reconnect', deploymentId: 'dep-reconnect', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + }); + try { + const handle = await withEnv({ + WORKFORCE_WORKSPACE_TOKEN: 'tok', + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: 'oauth', + WORKFORCE_DEPLOY_POLL_INTERVAL_MS: '0', + WORKFORCE_DEPLOY_POLL_TIMEOUT_MS: '50', + WORKFORCE_DEPLOY_RETRY_BACKOFF_MS: '0' + }, () => cloudLauncher.launch({ + persona: persona(), + agent: agentSpec, + bundle, + workspace: 'ws-test', + io, + // The harness name ("codex") resolves to provider "openai"; pass the + // harness alias to prove both spellings trigger the reconnect. + reconnectProviders: ['codex'] + })); + assert.equal(handle.id, 'agent-reconnect'); + } finally { + fetchMock.restore(); + restoreDeps(); + await cleanup(); + } + // Despite cloud reporting the harness already connected, the reconnect flag + // forced a fresh connectProvider call that overwrites the stored token. + assert.deepEqual(connected, ['openai']); +}); + +test('cloud --reconnect with --no-prompt fails with actionable guidance', async () => { + const restoreDeps = configureCloudCredentialDepsForTest({ + readStoredAuth: async () => ({ + apiUrl: 'https://cloud.example.test', + accessToken: 'access', + refreshToken: 'refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z' + }), + connectProvider: async () => { + throw new Error('connectProvider must not run under --no-prompt'); + }, + createCloudApiClient() { + return { + async fetch() { + return okJson({ + agents: [{ id: 'cloud-agent-openai', harness: 'openai', status: 'connected' }] + }); + } + }; + } + }); + await assert.rejects( + launch({ + defaultPlanCredential: false, + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + input: { harnessSource: 'oauth', reconnectProviders: ['openai'] }, + fetch(url) { + throw new Error(`unexpected URL ${url}`); + } + }), + /re-run without --no-prompt/ + ).finally(restoreDeps); +}); + test('cloud launcher maps 401 deploy responses to the workforce login guidance', async () => { await assert.rejects( launch({ diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index e95accd6..40fc461f 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -146,7 +146,8 @@ export const cloudLauncher: ModeLauncher = { io: input.io, noPrompt, harnessSource: input.harnessSource, - byokKey: input.byokKey + byokKey: input.byokKey, + reconnectProviders: input.reconnectProviders }); const existingPersona = await handleExistingPersona({ @@ -288,6 +289,7 @@ async function ensureHarnessReady(args: { noPrompt: boolean; harnessSource?: HarnessSource; byokKey?: string; + reconnectProviders?: readonly string[]; }): Promise> { // Pure handler personas declare no harness (the bundled handler never calls // ctx.harness.run — it orchestrates via ctx.workflow.run / integration @@ -488,22 +490,38 @@ async function ensureHarnessOauth(args: { persona: PersonaSpec; io: ModeLaunchInput['io']; noPrompt: boolean; + reconnectProviders?: readonly string[]; }): Promise { - if (await isHarnessOauthConnected(args)) { + const reconnect = harnessReconnectRequested(args.reconnectProviders, args.persona); + const connected = await isHarnessOauthConnected(args); + // Cloud reports a credential row as `connected` even when its stored OAuth + // token was revoked server-side (cloud never re-validates it), so a plain + // redeploy can never refresh a dead harness credential. `--reconnect + // ` forces the connect flow to re-run and overwrite the stored + // token — the escape hatch for codex/ChatGPT refresh-token rotation. + if (connected && !reconnect) { args.io.info(`cloud: ${args.persona.harness} credentials already connected`); return; } if (args.noPrompt) { throw new Error( - `cloud: ${args.persona.harness} OAuth credentials are not connected. Run without --no-prompt or choose --harness-source plan/byok.` + connected + ? `cloud: --reconnect ${deriveModelProvider(args.persona)} opens a browser connect flow; re-run without --no-prompt.` + : `cloud: ${args.persona.harness} OAuth credentials are not connected. Run without --no-prompt or choose --harness-source plan/byok.` ); } - const ok = await args.io.confirm( - `Connect ${args.persona.harness} credentials now? (opens browser)`, - { defaultValue: true } - ); - if (!ok) { - throw new Error(`cloud: ${args.persona.harness} credentials are required for deploy`); + if (connected) { + args.io.info( + `cloud: reconnect requested; opening a fresh ${args.persona.harness} connection flow (replaces the stored credential)` + ); + } else { + const ok = await args.io.confirm( + `Connect ${args.persona.harness} credentials now? (opens browser)`, + { defaultValue: true } + ); + if (!ok) { + throw new Error(`cloud: ${args.persona.harness} credentials are required for deploy`); + } } const modelProvider = deriveModelProvider(args.persona); await cloudCredentialDeps.connectProvider({ @@ -522,6 +540,27 @@ async function ensureHarnessOauth(args: { args.io.info(`cloud: ${args.persona.harness} credentials connected`); } +/** + * Whether the user asked (via `--reconnect `) to force a fresh + * connection for this persona's harness LLM credential even when cloud already + * reports one connected. Matches either the resolved model provider + * ("openai"/"anthropic") or the harness name ("codex"/"claude") so both + * `--reconnect openai` and `--reconnect codex` work. + */ +function harnessReconnectRequested( + reconnectProviders: readonly string[] | undefined, + persona: PersonaSpec +): boolean { + if (!reconnectProviders?.length) return false; + const wanted = new Set( + reconnectProviders.map((p) => p.trim().toLowerCase()).filter(Boolean) + ); + if (wanted.size === 0) return false; + return [deriveModelProvider(persona), persona.harness] + .filter((key): key is string => typeof key === 'string' && key.trim().length > 0) + .some((key) => wanted.has(key.trim().toLowerCase())); +} + export function validateCloudSubscriptionSupport(args: { persona: PersonaSpec; harnessSource?: HarnessSource; @@ -538,6 +577,7 @@ export async function ensureCloudSubscriptionReady(args: { noPrompt: boolean; harnessSource?: HarnessSource; byokKey?: string; + reconnectProviders?: readonly string[]; }): Promise { const source = resolveSubscriptionHarnessSource(args); const provider = deriveModelProvider(args.persona); @@ -588,24 +628,37 @@ async function ensureSubscriptionOauth(args: { persona: PersonaSpec; io: ModeLaunchInput['io']; noPrompt: boolean; + reconnectProviders?: readonly string[]; }): Promise { const provider = deriveModelProvider(args.persona); - if (await isHarnessOauthConnected(args)) { + const reconnect = harnessReconnectRequested(args.reconnectProviders, args.persona); + const connected = await isHarnessOauthConnected(args); + // See ensureHarnessOauth: a `connected` row can hold a revoked token, so + // `--reconnect ` forces a fresh connect that overwrites it. + if (connected && !reconnect) { args.io.info(`subscription: ${provider} credentials already connected`); return; } if (args.noPrompt) { throw new Error( - `persona "${args.persona.id}" sets useSubscription:true but ${provider} credentials are not connected. ` + - 'Run without --no-prompt to connect them, pass --harness-source byok with --byok-key, or remove useSubscription to use workforce-billed inference.' + connected + ? `cloud: --reconnect ${provider} opens a browser connect flow; re-run without --no-prompt.` + : `persona "${args.persona.id}" sets useSubscription:true but ${provider} credentials are not connected. ` + + 'Run without --no-prompt to connect them, pass --harness-source byok with --byok-key, or remove useSubscription to use workforce-billed inference.' ); } - const ok = await args.io.confirm( - `Connect ${provider} credentials for useSubscription now? (opens browser)`, - { defaultValue: true } - ); - if (!ok) { - throw new Error('user declined the subscription provider connect; deploy aborted'); + if (connected) { + args.io.info( + `subscription: reconnect requested; opening a fresh ${provider} connection flow (replaces the stored credential)` + ); + } else { + const ok = await args.io.confirm( + `Connect ${provider} credentials for useSubscription now? (opens browser)`, + { defaultValue: true } + ); + if (!ok) { + throw new Error('user declined the subscription provider connect; deploy aborted'); + } } await cloudCredentialDeps.connectProvider({ provider, diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index 7beba6d3..83895a01 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -145,6 +145,13 @@ export interface ModeLaunchInput { harnessSource?: 'plan' | 'byok' | 'oauth'; /** BYOK API key used when `harnessSource` is `byok`. */ byokKey?: string; + /** + * Force a fresh OAuth connect flow for specific providers even when cloud + * already reports one connected. Covers the harness LLM credential (matched + * by model provider or harness name), so a revoked harness token can be + * refreshed without first disconnecting it in the dashboard. + */ + reconnectProviders?: string[]; /** Existing cloud persona behavior. Defaults to `cancel`. */ onExists?: 'update' | 'destroy' | 'cancel'; /** Runtime inputs forwarded to launchers that support them. */