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
4 changes: 3 additions & 1 deletion packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ Flags:
--mode dev|sandbox|cloud Pick a run mode (prompts in an interactive terminal)
--workspace <name> Workforce workspace; defaults to the active workspace
--no-connect Skip integration-connect prompts; fail if any are missing
--reconnect <provider> Force a fresh integration connect flow (repeatable)
--reconnect <provider> 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 <dir> Emit the bundle to <dir> and exit (no launch)
Expand Down
4 changes: 3 additions & 1 deletion packages/deploy/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid replaying the harness reconnect after subscription setup

When useSubscription:true uses an OAuth provider that does not produce credentialSelections (notably openai/codex with no connected anthropic fallback, where resolveOauthCredentialSelections returns {}), deploy() first calls ensureCloudSubscriptionReady() with reconnectProviders and opens the fresh provider connect flow, but then launches without credentialSelections and still forwards the same reconnect request here. cloudLauncher.launch() treats missing selections as a signal to run ensureHarnessReady(), so it sees the still-connected row plus reconnectProviders and opens the same browser reconnect flow a second time in one deploy.

Useful? React with 👍 / 👎.

...(opts.onExists ? { onExists: opts.onExists } : {}),
...(Object.keys(resolvedInputs).length > 0 ? { inputs: resolvedInputs } : {}),
...(credentialSelections ? { credentialSelections } : {}),
Expand Down
102 changes: 102 additions & 0 deletions packages/deploy/src/modes/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <provider>` 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({
Expand Down
89 changes: 71 additions & 18 deletions packages/deploy/src/modes/cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -288,6 +289,7 @@ async function ensureHarnessReady(args: {
noPrompt: boolean;
harnessSource?: HarnessSource;
byokKey?: string;
reconnectProviders?: readonly string[];
}): Promise<Record<string, string>> {
// Pure handler personas declare no harness (the bundled handler never calls
// ctx.harness.run — it orchestrates via ctx.workflow.run / integration
Expand Down Expand Up @@ -488,22 +490,38 @@ async function ensureHarnessOauth(args: {
persona: PersonaSpec;
io: ModeLaunchInput['io'];
noPrompt: boolean;
reconnectProviders?: readonly string[];
}): Promise<void> {
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
// <provider>` 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({
Expand All @@ -522,6 +540,27 @@ async function ensureHarnessOauth(args: {
args.io.info(`cloud: ${args.persona.harness} credentials connected`);
}

/**
* Whether the user asked (via `--reconnect <provider>`) 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;
Expand All @@ -538,6 +577,7 @@ export async function ensureCloudSubscriptionReady(args: {
noPrompt: boolean;
harnessSource?: HarnessSource;
byokKey?: string;
reconnectProviders?: readonly string[];
}): Promise<CloudSubscriptionReadyResult> {
const source = resolveSubscriptionHarnessSource(args);
const provider = deriveModelProvider(args.persona);
Expand Down Expand Up @@ -588,24 +628,37 @@ async function ensureSubscriptionOauth(args: {
persona: PersonaSpec;
io: ModeLaunchInput['io'];
noPrompt: boolean;
reconnectProviders?: readonly string[];
}): Promise<void> {
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 <provider>` 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,
Expand Down
7 changes: 7 additions & 0 deletions packages/deploy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading