From fcd4800bc14cc9d7e6ce25d4f75d24a69e14beef Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 27 May 2026 18:03:10 +0200 Subject: [PATCH] fix(deploy): prepare subscription credentials before integrations --- packages/deploy/src/connect.test.ts | 78 +++++++++++ packages/deploy/src/connect.ts | 89 +++++++------ packages/deploy/src/deploy.test.ts | 160 +++++++++++++++++++++++ packages/deploy/src/deploy.ts | 71 +++++++++- packages/deploy/src/modes/cloud/index.ts | 107 ++++++++++++++- packages/deploy/src/types.ts | 2 + 6 files changed, 461 insertions(+), 46 deletions(-) diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index 9ac695d..995c796 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -673,6 +673,84 @@ test('connectIntegrations fails status-check errors without opening a connect fl assert.ok(io.messages.some((message) => message.level === 'error' && message.message.includes('failed while checking connection status'))); }); +test('connectIntegrations fails useSubscription without a resolver before integration checks', async () => { + const io = createBufferedIO(); + let integrationChecked = false; + let integrationConnected = false; + + await assert.rejects( + connectIntegrations({ + persona: { + id: 'essay', + intent: 'essay', + description: 'test persona', + tags: ['implementation'], + useSubscription: true, + integrations: { notion: {} } + } as never, + workspace: 'ws-1', + noConnect: false, + io, + integrations: { + async isConnected() { + integrationChecked = true; + return false; + }, + async connect() { + integrationConnected = true; + return { connectionId: 'conn-notion' }; + } + } + }), + /useSubscription:true.*no subscription connector/ + ); + + assert.equal(integrationChecked, false); + assert.equal(integrationConnected, false); +}); + +test('connectIntegrations connects subscription provider before integration checks', async () => { + const io = createBufferedIO(); + const order: string[] = []; + + const result = await connectIntegrations({ + persona: { + id: 'essay', + intent: 'essay', + description: 'test persona', + tags: ['implementation'], + useSubscription: true, + integrations: { notion: {} } + } as never, + workspace: 'ws-1', + noConnect: false, + io, + integrations: { + async isConnected() { + order.push('integration-check'); + return true; + }, + async connect() { + order.push('integration-connect'); + return { connectionId: 'conn-notion' }; + } + }, + subscription: { + async isConnected() { + order.push('subscription-check'); + return false; + }, + async connect() { + order.push('subscription-connect'); + return { provider: 'anthropic' }; + } + } + }); + + assert.deepEqual(order, ['subscription-check', 'subscription-connect', 'integration-check']); + assert.equal(result.subscriptionProvider, 'anthropic'); +}); + 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 de88742..0322f32 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -328,6 +328,12 @@ export interface ConnectAllResult { export async function connectIntegrations(input: ConnectAllInput): Promise { const integrations = input.persona.integrations ?? {}; const outcomes: IntegrationConnectOutcome[] = []; + const subscription = input.persona.useSubscription + ? requireSubscriptionResolver(input.persona.id, input.subscription) + : undefined; + const subscriptionProvider = subscription + ? await connectSubscriptionProvider(input, subscription) + : undefined; for (const provider of Object.keys(integrations)) { const integrationEntry = integrations[provider] ?? {}; @@ -447,51 +453,56 @@ export async function connectIntegrations(input: ConnectAllInput): Promise false); - if (!isConn) { - if (input.noPrompt) { - throw new Error( - 'persona requires a subscription provider connection, but --no-prompt was passed. Connect it before deploying or run without --no-prompt.' - ); - } - if (input.noConnect) { - throw new Error( - 'persona requires a subscription provider connection, but --no-connect was passed' - ); - } - const ok = await input.io.confirm( - 'persona has useSubscription:true — connect your LLM provider now?', - { defaultValue: true } - ); - if (!ok) { - throw new Error('user declined the subscription provider connect; deploy aborted'); - } - const result = await input.subscription.connect({ workspace: input.workspace }); - subscriptionProvider = result.provider; - input.io.info(`subscription: connected (${result.provider})`); - } else { - input.io.info('subscription: already connected'); - } - } - return { outcomes, ...(subscriptionProvider ? { subscriptionProvider } : {}) }; } +async function connectSubscriptionProvider( + input: ConnectAllInput, + subscription: ProviderSubscriptionResolver +): Promise { + const isConn = await subscription + .isConnected({ workspace: input.workspace }) + .catch(() => false); + if (isConn) { + input.io.info('subscription: already connected'); + return undefined; + } + if (input.noPrompt) { + throw new Error( + 'persona requires a subscription provider connection, but --no-prompt was passed. Connect it before deploying or run without --no-prompt.' + ); + } + if (input.noConnect) { + throw new Error( + 'persona requires a subscription provider connection, but --no-connect was passed' + ); + } + const ok = await input.io.confirm( + 'persona has useSubscription:true — connect your LLM provider now?', + { defaultValue: true } + ); + if (!ok) { + throw new Error('user declined the subscription provider connect; deploy aborted'); + } + const result = await subscription.connect({ workspace: input.workspace }); + input.io.info(`subscription: connected (${result.provider})`); + return result.provider; +} + +function requireSubscriptionResolver( + personaId: string, + subscription: ProviderSubscriptionResolver | undefined +): ProviderSubscriptionResolver { + if (subscription) return subscription; + throw new Error( + `persona "${personaId}" sets useSubscription:true, but no subscription connector is available. ` + + 'Use the deploy orchestrator cloud mode, provide a subscription resolver, or remove useSubscription to use workforce-billed inference.' + ); +} + async function checkProviderConnected( input: ConnectAllInput, provider: string, diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 5f6c62f..915d6ae 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -263,6 +263,166 @@ test('deploy --dry-run validates persona and exits before side effects', async ( } }); +test('deploy --dry-run accepts useSubscription personas in cloud mode', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ useSubscription: true }) + ); + const io = createBufferedIO(); + try { + const result = await deploy({ personaPath, mode: 'cloud', dryRun: true, io }); + assert.equal(result.deploymentId, 'demo'); + assert.equal(result.mode, 'cloud'); + assert.ok(io.messages.find((m) => m.message.includes('--dry-run'))); + assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:'))); + } finally { + await cleanup(); + } +}); + +test('deploy --dry-run rejects useSubscription when cloud mode is not selected', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ useSubscription: true }) + ); + const io = createBufferedIO(); + try { + await assert.rejects( + deploy({ personaPath, mode: 'dev', dryRun: true, io }), + /requires --mode cloud/ + ); + assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:'))); + } finally { + await cleanup(); + } +}); + +test('deploy --dry-run rejects useSubscription with workforce plan credentials', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ useSubscription: true }) + ); + const io = createBufferedIO(); + try { + await assert.rejects( + deploy({ personaPath, mode: 'cloud', dryRun: true, harnessSource: 'plan', io }), + /use --harness-source oauth/ + ); + assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:'))); + } finally { + await cleanup(); + } +}); + +test('deploy accepts useSubscription when a subscription resolver is supplied', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ useSubscription: true }) + ); + const io = createBufferedIO(); + try { + const result = await deploy( + { personaPath, dryRun: true, io }, + { + subscription: { + async isConnected() { + throw new Error('dry-run should not check subscription status'); + }, + async connect() { + throw new Error('dry-run should not connect subscriptions'); + } + } + } + ); + assert.equal(result.deploymentId, 'demo'); + assert.ok(io.messages.find((m) => m.message.includes('--dry-run'))); + } finally { + await cleanup(); + } +}); + +test('deploy prepares useSubscription BYOK credentials before integration side effects', async () => { + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + useSubscription: true, + integrations: { github: { triggers: [{ on: 'pull_request.opened' }] } } + }) + ); + const io = createBufferedIO(); + const originalFetch = globalThis.fetch; + const order: string[] = []; + let launchedSelections: Record | undefined; + try { + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith('/provider-credentials/byok')) { + order.push('subscription-byok'); + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)), { + modelProvider: 'anthropic', + model_provider: 'anthropic', + key: 'sk-test', + api_key: 'sk-test' + }); + return jsonResponse({ providerCredentialId: 'cred-byok' }, 201); + } + throw new Error(`unexpected URL ${url}`); + }) as typeof fetch; + + const result = await deploy( + { + personaPath, + mode: 'cloud', + harnessSource: 'byok', + byokKey: 'sk-test', + cloudUrl: 'https://cloud.example.test', + io + }, + { + workspaceAuth: { + async resolveWorkspace() { + order.push('workspace'); + return { workspace: 'ws-test', token: 'tok' }; + } + }, + providerConfigKeys: { + async resolve() { + return undefined; + } + }, + integrations: { + async isConnected() { + order.push('integration-check'); + return true; + }, + async connect() { + order.push('integration-connect'); + return { connectionId: 'conn-github' }; + } + }, + bundle: successfulBundleStager(), + modes: { + cloud: { + async launch(input) { + order.push('launch'); + launchedSelections = input.credentialSelections; + return { + id: 'cloud-1', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + } + } + } + ); + assert.equal(result.deploymentId, 'demo'); + assert.deepEqual(order, ['workspace', 'subscription-byok', 'integration-check', 'launch']); + assert.deepEqual(launchedSelections, { anthropic: 'cred-byok' }); + } finally { + globalThis.fetch = originalFetch; + await cleanup(); + } +}); + test('deploy fails clearly when integration is not connected and --no-connect is set', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { github: { triggers: [{ on: 'pull_request.opened' }] } } }) diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 532b664..12a912c 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { mkdir } from 'node:fs/promises'; import { defaultApiUrl } from '@agent-relay/cloud'; +import type { PersonaSpec } from '@agentworkforce/persona-kit'; import { bundleStager } from './bundle.js'; import { resolveCloudUrl } from './cloud-url.js'; import { @@ -22,7 +23,11 @@ import { } from './login.js'; import { devLauncher } from './modes/dev.js'; import { sandboxLauncher } from './modes/sandbox.js'; -import { cloudLauncher } from './modes/cloud/index.js'; +import { + cloudLauncher, + ensureCloudSubscriptionReady, + validateCloudSubscriptionSupport +} from './modes/cloud/index.js'; import { preflightPersona } from './preflight.js'; import type { BundleStager, @@ -107,6 +112,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { io.info(`workforce deploy → ${opts.personaPath}`); const preflight = await preflightPersona(opts.personaPath); + const mode: DeployMode = opts.mode ?? pickMode(opts); warnings.push(...preflight.warnings); for (const w of preflight.warnings) io.warn(w); @@ -114,11 +120,17 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { `persona ${preflight.persona.id}: ${preflight.integrations.length} integration(s), ${preflight.schedules.length} schedule(s)` ); + validateSubscriptionSupport(preflight.persona, { + mode, + subscription: resolvers.subscription, + ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}) + }); + if (opts.dryRun) { io.info('--dry-run: persona validated; exiting before any side effects'); return { deploymentId: preflight.persona.id, - mode: opts.mode ?? pickMode(opts), + mode, workspace: opts.workspace ?? '(dry-run)', bundleDir: '(dry-run)', connectedIntegrations: [], @@ -143,7 +155,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { io.info(`--bundle-out: bundle ready at ${bundleDir}; skipping launch`); return { deploymentId: preflight.persona.id, - mode: opts.mode ?? pickMode(opts), + mode, workspace: opts.workspace ?? '(bundle-only)', bundleDir, connectedIntegrations: [], @@ -152,7 +164,6 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }; } - const mode: DeployMode = opts.mode ?? pickMode(opts); const active = await readActiveWorkspace().catch(() => null); const cloudUrl = resolveCloudUrl({ ...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}), @@ -184,6 +195,23 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { } io.info(`workspace: ${workspace}`); let activeToken = resolvedAuth.token; + let subscription = resolvers.subscription; + let credentialSelections: Record | undefined; + + if (preflight.persona.useSubscription && !subscription) { + const result = await ensureCloudSubscriptionReady({ + cloudUrl: normalizeCloudUrl(cloudUrl ?? defaultApiUrl()), + workspaceId: workspace, + token: activeToken, + persona: preflight.persona, + io, + noPrompt: opts.noPrompt === true || opts.noConnect === true, + ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), + ...(opts.byokKey ? { byokKey: opts.byokKey } : {}) + }); + credentialSelections = result.credentialSelections; + subscription = alreadyConnectedSubscriptionResolver(result.provider); + } const connectedIntegrations = await connectAndCollectIntegrations({ persona: preflight.persona, @@ -209,7 +237,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { ) } : {}), - ...(resolvers.subscription ? { subscription: resolvers.subscription } : {}), + ...(subscription ? { subscription } : {}), ...(resolvers.providerConfigKeys ? { providerConfigKeys: resolvers.providerConfigKeys } : mode === 'cloud' @@ -251,6 +279,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { ...(opts.byokKey ? { byokKey: opts.byokKey } : {}), ...(opts.onExists ? { onExists: opts.onExists } : {}), ...(opts.inputs ? { inputs: opts.inputs } : {}), + ...(credentialSelections ? { credentialSelections } : {}), ...(opts.onLog ? { onLog: opts.onLog } : {}) }); io.info(`launched: ${mode}/${handle.id}`); @@ -315,6 +344,38 @@ function defaultIntegrationResolver(args: { }); } +function validateSubscriptionSupport( + persona: PersonaSpec, + args: { + mode: DeployMode; + subscription?: ProviderSubscriptionResolver; + harnessSource?: 'plan' | 'byok' | 'oauth'; + } +): void { + if (!persona.useSubscription || args.subscription) return; + if (args.mode !== 'cloud') { + throw new Error( + `persona "${persona.id}" sets useSubscription:true, which requires --mode cloud so the deploy CLI can connect an LLM provider. ` + + 'Use --mode cloud with --harness-source oauth or byok, or remove useSubscription to use workforce-billed inference.' + ); + } + validateCloudSubscriptionSupport({ + persona, + ...(args.harnessSource ? { harnessSource: args.harnessSource } : {}) + }); +} + +function alreadyConnectedSubscriptionResolver(provider: string): ProviderSubscriptionResolver { + return { + async isConnected() { + return true; + }, + async connect() { + return { provider }; + } + }; +} + function authRecoveryForIntegrations( resolver: CloudAuthRecoveryResolver, currentToken: () => string, diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index c497533..8c96343 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -25,9 +25,14 @@ const POLL_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 2_000; type CloudDeployStatus = 'ready' | 'starting' | 'active' | 'failed' | 'cancelled'; -type HarnessSource = 'plan' | 'byok' | 'oauth'; +export type HarnessSource = 'plan' | 'byok' | 'oauth'; type OnExistsChoice = 'update' | 'destroy' | 'cancel'; +export interface CloudSubscriptionReadyResult { + provider: string; + credentialSelections?: Record; +} + export interface CloudRunHandle extends ModeLaunchHandle { agentId: string; deploymentId: string; @@ -131,7 +136,7 @@ export const cloudLauncher: ModeLauncher = { noPrompt }); - const credentialSelections = await ensureHarnessReady({ + const credentialSelections = input.credentialSelections ?? await ensureHarnessReady({ cloudUrl, workspaceId: input.workspace, token: auth.token, @@ -446,6 +451,104 @@ async function ensureHarnessOauth(args: { args.io.info(`cloud: ${args.persona.harness} credentials connected`); } +export function validateCloudSubscriptionSupport(args: { + persona: PersonaSpec; + harnessSource?: HarnessSource; +}): void { + resolveSubscriptionHarnessSource(args); +} + +export async function ensureCloudSubscriptionReady(args: { + cloudUrl: string; + workspaceId: string; + token: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; + harnessSource?: HarnessSource; + byokKey?: string; +}): Promise { + const source = resolveSubscriptionHarnessSource(args); + const provider = deriveModelProvider(args.persona); + + if (source === 'byok') { + const key = await resolveByokKey(args); + const credentialId = await saveProviderCredential({ + cloudUrl: args.cloudUrl, + workspaceId: args.workspaceId, + token: args.token, + modelProvider: provider, + authType: 'byo_api_key', + apiKey: key + }); + args.io.info(`subscription: using BYOK credentials for ${provider}`); + return { + provider, + credentialSelections: { [provider]: credentialId } + }; + } + + await ensureSubscriptionOauth(args); + return { provider }; +} + +function resolveSubscriptionHarnessSource(args: { + persona: PersonaSpec; + harnessSource?: HarnessSource; +}): Exclude { + const rawSource = args.harnessSource ?? process.env.WORKFORCE_DEPLOY_HARNESS_SOURCE?.trim(); + const source = rawSource ? expectHarnessSource(rawSource) : 'oauth'; + if (source === 'plan') { + throw new Error( + `persona "${args.persona.id}" sets useSubscription:true; use --harness-source oauth to connect your LLM provider, ` + + 'use --harness-source byok with --byok-key, or remove useSubscription to use workforce-billed inference.' + ); + } + return source; +} + +async function ensureSubscriptionOauth(args: { + cloudUrl: string; + workspaceId: string; + token: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; +}): Promise { + const provider = deriveModelProvider(args.persona); + if (await isHarnessOauthConnected(args)) { + 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.' + ); + } + 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, + apiUrl: args.cloudUrl, + language: 'typescript', + io: { + log: (...parts: unknown[]) => args.io.info(parts.map(String).join(' ')), + error: (...parts: unknown[]) => args.io.error(parts.map(String).join(' ')) + } + }); + await pollUntil( + () => isHarnessOauthConnected(args), + `timed out waiting for ${provider} OAuth credentials` + ); + args.io.info(`subscription: ${provider} credentials connected`); +} + async function handleExistingPersona(args: { cloudUrl: string; workspaceId: string; diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index 461ae96..f8be20f 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -132,6 +132,8 @@ export interface ModeLaunchInput { onExists?: 'update' | 'destroy' | 'cancel'; /** Runtime inputs forwarded to launchers that support them. */ inputs?: Record; + /** Provider credential selections resolved before launch. Cloud mode includes these in the deploy request. */ + credentialSelections?: Record; /** Runtime log streaming hook. */ onLog?: (line: string) => void; }