From ad36e7b3598adbcd928eb53f66950b57078b6273 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 02:27:30 +0200 Subject: [PATCH 1/3] feat(deploy): --mode cloud (OSS-generic persona+bundle POST) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track H: Track H — Workforce (OSS-generic implementation) See workforce/docs/plans/deploy-v1-schema-cascade-spec.md --- packages/cli/src/deploy-command.ts | 132 +++- packages/deploy/src/deploy.test.ts | 168 +++- packages/deploy/src/deploy.ts | 96 ++- packages/deploy/src/index.ts | 28 +- packages/deploy/src/login.ts | 305 ++++++++ packages/deploy/src/modes/cloud.test.ts | 548 +++++++++++++ packages/deploy/src/modes/cloud.ts | 981 +++++++++++++++++++++++- packages/deploy/src/modes/sandbox.ts | 6 +- packages/deploy/src/types.ts | 28 + 9 files changed, 2202 insertions(+), 90 deletions(-) create mode 100644 packages/deploy/src/modes/cloud.test.ts diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index e9e37e9f..03ca4af7 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -1,11 +1,15 @@ import path from 'node:path'; import { + createTerminalIO, deploy, + resolveWorkspaceToken, type DeployMode, type DeployOptions, type ModeLaunchHandle } from '@agentworkforce/deploy'; +const DEFAULT_CLOUD_URL = 'https://agentrelay.com'; + /** * Argv parser + dispatcher for `workforce deploy [flags]`. * Keeps cli.ts itself slim — the file is already a large dispatcher and @@ -62,14 +66,34 @@ export async function runLogin(args: readonly string[]): Promise { process.stdout.write(LOGIN_USAGE); process.exit(0); } - process.stderr.write( - 'The browser-based workforce login flow is rolling out in stages and is not on by default yet.\n' + - 'For now, export your workspace credentials in the shell:\n\n' + - ' export WORKFORCE_WORKSPACE_ID=\n' + - ' export WORKFORCE_WORKSPACE_TOKEN=\n\n' + - 'Then re-run `workforce deploy ./your-persona.json`.\n' + + const opts = parseLoginArgs(args); + const io = createTerminalIO(); + const workspace = opts.workspace + ?? process.env.WORKFORCE_WORKSPACE_ID?.trim() + ?? (await io.prompt('Workspace ID')).trim(); + if (!workspace) { + process.stderr.write('workforce login failed: workspace is required; pass --workspace or set WORKFORCE_WORKSPACE_ID\n'); + process.exit(1); + } + + const cloudUrl = normalizeCloudUrl( + opts.cloudUrl + ?? process.env.WORKFORCE_DEPLOY_CLOUD_URL + ?? process.env.WORKFORCE_CLOUD_URL + ?? DEFAULT_CLOUD_URL ); - process.exit(1); + + try { + await resolveWorkspaceToken({ workspace, cloudUrl, io }); + process.stdout.write(`\nlogged in: ${workspace}\n`); + process.exit(0); + } catch (err) { + process.stderr.write( + `\nworkforce login failed: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); + } } const DEPLOY_USAGE = `usage: workforce deploy [flags] @@ -83,18 +107,28 @@ Flags: --bundle-out Emit the bundle to and exit (no launch) --dry-run Validate the persona and exit before any side effects --cloud-url Override the workforce cloud base URL + --no-prompt Fail instead of prompting for cloud setup + --harness-source Cloud harness source: plan, byok, or oauth + --byok-key API key for --harness-source byok + --on-exists Existing cloud persona behavior: cancel, update, or destroy -h, --help Print this message `; -const LOGIN_USAGE = `usage: workforce login +const LOGIN_USAGE = `usage: workforce login [flags] -Connect this machine to a workforce workspace. The full OAuth flow ships -once the cloud login surface is live; until then, set: +Connect this machine to a workforce workspace using the browser OAuth flow. +The resulting workspace token is stored in the OS keychain when available, +falling back to ~/.agentworkforce/login.json. - export WORKFORCE_WORKSPACE_ID=... - export WORKFORCE_WORKSPACE_TOKEN=... +Flags: + --workspace Workforce workspace; defaults to WORKFORCE_WORKSPACE_ID or prompt + --cloud-url Override the workforce cloud base URL + -h, --help Print this message `; +const HARNESS_SOURCES = ['plan', 'byok', 'oauth'] as const; +const ON_EXISTS_CHOICES = ['update', 'destroy', 'cancel'] as const; + export function parseDeployArgs(args: readonly string[]): DeployOptions { let personaPath: string | undefined; let mode: DeployMode | undefined; @@ -105,6 +139,10 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { let bundleOut: string | undefined; let dryRun = false; let cloudUrl: string | undefined; + let noPrompt = false; + let harnessSource: DeployOptions['harnessSource']; + let byokKey: string | undefined; + let onExists: DeployOptions['onExists']; for (let i = 0; i < args.length; i += 1) { const a = args[i]; @@ -131,6 +169,23 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { dryRun = true; } else if (a === '--cloud-url') { cloudUrl = expectValue('--cloud-url', args[++i]); + } else if (a.startsWith('--cloud-url=')) { + cloudUrl = expectInlineValue('--cloud-url', a.slice('--cloud-url='.length)); + } else if (a === '--no-prompt') { + noPrompt = true; + noConnect = true; + } else if (a === '--harness-source') { + harnessSource = expectChoice('--harness-source', expectValue('--harness-source', args[++i]), HARNESS_SOURCES); + } else if (a.startsWith('--harness-source=')) { + harnessSource = expectChoice('--harness-source', expectInlineValue('--harness-source', a.slice('--harness-source='.length)), HARNESS_SOURCES); + } else if (a === '--byok-key') { + byokKey = expectValue('--byok-key', args[++i]); + } else if (a.startsWith('--byok-key=')) { + byokKey = expectInlineValue('--byok-key', a.slice('--byok-key='.length)); + } else if (a === '--on-exists') { + onExists = expectChoice('--on-exists', expectValue('--on-exists', args[++i]), ON_EXISTS_CHOICES); + } else if (a.startsWith('--on-exists=')) { + onExists = expectChoice('--on-exists', expectInlineValue('--on-exists', a.slice('--on-exists='.length)), ON_EXISTS_CHOICES); } else if (a.startsWith('--')) { die(`deploy: unknown flag "${a}"`); } else if (!personaPath) { @@ -153,7 +208,11 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { ...(detach ? { detach: true } : {}), ...(bundleOut ? { bundleOut } : {}), ...(dryRun ? { dryRun: true } : {}), - ...(cloudUrl ? { cloudUrl } : {}) + ...(cloudUrl ? { cloudUrl } : {}), + ...(noPrompt ? { noPrompt: true } : {}), + ...(harnessSource ? { harnessSource } : {}), + ...(byokKey ? { byokKey } : {}), + ...(onExists ? { onExists } : {}) }; } @@ -170,6 +229,53 @@ function expectValue(flag: string, value: string | undefined): string { return value; } +function expectInlineValue(flag: string, value: string): string { + if (!value.trim()) { + die(`${flag}: missing value`); + } + return value; +} + +function expectChoice(flag: string, value: string, allowed: readonly T[]): T { + if (!allowed.includes(value as T)) { + die(`${flag}: expected one of ${allowed.join('|')}; got "${value}"`); + } + return value as T; +} + +function parseLoginArgs(args: readonly string[]): { workspace?: string; cloudUrl?: string } { + let workspace: string | undefined; + let cloudUrl: string | undefined; + + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '-h' || a === '--help') { + process.stdout.write(LOGIN_USAGE); + process.exit(0); + } else if (a === '--workspace') { + workspace = expectValue('--workspace', args[++i]); + } else if (a.startsWith('--workspace=')) { + workspace = expectInlineValue('--workspace', a.slice('--workspace='.length)); + } else if (a === '--cloud-url') { + cloudUrl = expectValue('--cloud-url', args[++i]); + } else if (a.startsWith('--cloud-url=')) { + cloudUrl = expectInlineValue('--cloud-url', a.slice('--cloud-url='.length)); + } else { + die(`login: unknown argument "${a}"`); + } + } + + return { + ...(workspace ? { workspace } : {}), + ...(cloudUrl ? { cloudUrl } : {}) + }; +} + +function normalizeCloudUrl(url: string): string { + const trimmed = url.trim(); + return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL; +} + function die(message: string): never { process.stderr.write(`${message}\n`); process.exit(1); diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index d59cd3d1..cb374e28 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -45,6 +45,39 @@ async function withTempPersona( }; } +async function withWorkspaceEnv( + env: { workspace?: string; token?: string }, + fn: () => Promise +): Promise { + const previousWorkspace = process.env.WORKFORCE_WORKSPACE_ID; + const previousToken = process.env.WORKFORCE_WORKSPACE_TOKEN; + if (env.workspace === undefined) { + delete process.env.WORKFORCE_WORKSPACE_ID; + } else { + process.env.WORKFORCE_WORKSPACE_ID = env.workspace; + } + if (env.token === undefined) { + delete process.env.WORKFORCE_WORKSPACE_TOKEN; + } else { + process.env.WORKFORCE_WORKSPACE_TOKEN = env.token; + } + + try { + return await fn(); + } finally { + if (previousWorkspace === undefined) { + delete process.env.WORKFORCE_WORKSPACE_ID; + } else { + process.env.WORKFORCE_WORKSPACE_ID = previousWorkspace; + } + if (previousToken === undefined) { + delete process.env.WORKFORCE_WORKSPACE_TOKEN; + } else { + process.env.WORKFORCE_WORKSPACE_TOKEN = previousToken; + } + } +} + test('preflightPersona accepts a valid deploy-shaped persona', async () => { const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); try { @@ -294,42 +327,125 @@ test('deploy --bundle-out emits to the supplied dir and skips launch', async () } }); -test('--mode cloud throws a clear "not yet available" error', async () => { +test('--mode cloud skips local integration resolver and hands off to the cloud launcher', async () => { const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); const io = createBufferedIO(); + let launched = false; try { - await assert.rejects( - deploy( - { personaPath, mode: 'cloud', io }, - { - workspaceAuth: { - async resolveWorkspace() { - return { workspace: 'w', token: 't' }; - } - }, - integrations: { - async isConnected() { - return true; - }, - async connect() { - throw new Error('unreachable'); - } + const result = await deploy( + { personaPath, mode: 'cloud', io }, + { + workspaceAuth: { + async resolveWorkspace() { + return { workspace: 'w', token: 't' }; + } + }, + integrations: { + async isConnected() { + throw new Error('cloud mode should not use local integration resolver'); }, - bundle: { - async stage() { + async connect() { + throw new Error('cloud mode should not use local integration resolver'); + } + }, + bundle: { + async stage(input) { + await mkdir(input.outDir, { recursive: true }); + const runner = path.join(input.outDir, 'runner.mjs'); + const bundle = path.join(input.outDir, 'agent.bundle.mjs'); + const personaCopy = path.join(input.outDir, 'persona.json'); + const pkg = path.join(input.outDir, 'package.json'); + await Promise.all([ + writeFile(runner, '', 'utf8'), + writeFile(bundle, '', 'utf8'), + writeFile(personaCopy, '{}', 'utf8'), + writeFile(pkg, '{}', 'utf8') + ]); + return { + runnerPath: runner, + bundlePath: bundle, + personaCopyPath: personaCopy, + packageJsonPath: pkg, + sizeBytes: 0 + }; + } + }, + modes: { + cloud: { + async launch(input) { + launched = true; + assert.equal(input.workspace, 'w'); return { - runnerPath: '/tmp/r', - bundlePath: '/tmp/b', - personaCopyPath: '/tmp/p', - packageJsonPath: '/tmp/k', - sizeBytes: 0 + id: 'agent-cloud', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) }; } } } - ), - /--mode cloud is not yet available/ + } ); + assert.equal(result.mode, 'cloud'); + assert.equal(launched, true); + assert.deepEqual(result.connectedIntegrations, []); + } finally { + await cleanup(); + } +}); + +test('--mode cloud does not require an env workspace token before launching', async () => { + const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); + const io = createBufferedIO(); + let launched = false; + try { + const result = await withWorkspaceEnv({ workspace: 'w-cloud' }, () => deploy( + { personaPath, mode: 'cloud', io }, + { + bundle: { + async stage(input) { + await mkdir(input.outDir, { recursive: true }); + const runner = path.join(input.outDir, 'runner.mjs'); + const bundle = path.join(input.outDir, 'agent.bundle.mjs'); + const personaCopy = path.join(input.outDir, 'persona.json'); + const pkg = path.join(input.outDir, 'package.json'); + await Promise.all([ + writeFile(runner, '', 'utf8'), + writeFile(bundle, '', 'utf8'), + writeFile(personaCopy, '{}', 'utf8'), + writeFile(pkg, '{}', 'utf8') + ]); + return { + runnerPath: runner, + bundlePath: bundle, + personaCopyPath: personaCopy, + packageJsonPath: pkg, + sizeBytes: 0 + }; + } + }, + modes: { + cloud: { + async launch(input) { + launched = true; + assert.equal(input.workspace, 'w-cloud'); + assert.equal(input.workspaceToken, undefined); + return { + id: 'agent-cloud', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + } + } + } + )); + assert.equal(result.mode, 'cloud'); + assert.equal(result.workspace, 'w-cloud'); + assert.equal(launched, true); } finally { await cleanup(); } diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 198bf099..d2dc12fc 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -4,6 +4,7 @@ import { bundleStager } from './bundle.js'; import { connectIntegrations, envIntegrationResolver, + type ConnectAllInput, type IntegrationConnectResolver, type ProviderSubscriptionResolver } from './connect.js'; @@ -15,6 +16,7 @@ import { cloudLauncher } from './modes/cloud.js'; import { preflightPersona } from './preflight.js'; import type { BundleStager, + DeployIO, DeployMode, DeployOptions, DeployResult, @@ -128,36 +130,25 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }; } - const workspaceAuth = resolvers.workspaceAuth ?? envWorkspaceAuth(); - const { workspace } = await workspaceAuth.resolveWorkspace({ - override: opts.workspace, - io - }); + const mode: DeployMode = opts.mode ?? pickMode(opts); + const { workspace, token } = mode === 'cloud' && !resolvers.workspaceAuth + ? resolveCloudWorkspaceIdentity(opts, io) + : await (resolvers.workspaceAuth ?? envWorkspaceAuth()).resolveWorkspace({ + override: opts.workspace, + io + }); io.info(`workspace: ${workspace}`); - const connectResult = await connectIntegrations({ - persona: preflight.persona, - workspace, - noConnect: opts.noConnect === true, - io, - integrations: resolvers.integrations ?? envIntegrationResolver(), - ...(resolvers.subscription ? { subscription: resolvers.subscription } : {}) - }); - const failed = connectResult.outcomes.filter((o) => o.status === 'failed'); - if (failed.length > 0) { - throw new Error( - `deploy aborted: ${failed.length} integration(s) failed to connect: ${failed.map((f) => f.provider).join(', ')}` - ); - } - const skipped = connectResult.outcomes.filter((o) => o.status === 'skipped'); - if (skipped.length > 0) { - throw new Error( - `deploy aborted: ${skipped.length} integration(s) skipped: ${skipped.map((s) => s.provider).join(', ')}` - ); - } - const connectedIntegrations = connectResult.outcomes - .filter((o) => o.status === 'already-connected' || o.status === 'connected-now') - .map((o) => o.provider); + const connectedIntegrations = mode === 'cloud' + ? preflight.integrations + : await connectAndCollectIntegrations({ + persona: preflight.persona, + workspace, + noConnect: opts.noConnect === true, + io, + integrations: resolvers.integrations ?? envIntegrationResolver(), + ...(resolvers.subscription ? { subscription: resolvers.subscription } : {}) + }); const bundleDir = path.resolve( path.join(preflight.personaDir, '.workforce', 'build', preflight.persona.id) @@ -171,7 +162,6 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }); io.info(`bundle: staged to ${bundle.runnerPath} (${formatBytes(bundle.sizeBytes)})`); - const mode: DeployMode = opts.mode ?? pickMode(opts); io.info(`mode: ${mode}`); const launcher = resolveLauncher(mode, resolvers); const handle = await launcher.launch({ @@ -179,8 +169,16 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { bundle, workspace, io, + ...(token ? { workspaceToken: token } : {}), ...(opts.detach ? { detach: true } : {}), - ...(opts.byoSandbox ? { byoSandbox: true } : {}) + ...(opts.byoSandbox ? { byoSandbox: true } : {}), + ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}), + ...(opts.noPrompt ? { noPrompt: true } : {}), + ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), + ...(opts.byokKey ? { byokKey: opts.byokKey } : {}), + ...(opts.onExists ? { onExists: opts.onExists } : {}), + ...(opts.inputs ? { inputs: opts.inputs } : {}), + ...(opts.onLog ? { onLog: opts.onLog } : {}) }); io.info(`launched: ${mode}/${handle.id}`); @@ -209,6 +207,44 @@ function resolveLauncher(mode: DeployMode, resolvers: DeployResolvers): ModeLaun } } +function resolveCloudWorkspaceIdentity( + opts: DeployOptions, + io: DeployIO +): { workspace: string; token?: string } { + const workspace = (opts.workspace ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim(); + if (!workspace) { + io.error( + 'no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `workforce login`' + ); + throw new Error('workspace is required for cloud deploy'); + } + + const token = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); + return { + workspace, + ...(token ? { token } : {}) + }; +} + +async function connectAndCollectIntegrations(input: ConnectAllInput): Promise { + const connectResult = await connectIntegrations(input); + const failed = connectResult.outcomes.filter((o) => o.status === 'failed'); + if (failed.length > 0) { + throw new Error( + `deploy aborted: ${failed.length} integration(s) failed to connect: ${failed.map((f) => f.provider).join(', ')}` + ); + } + const skipped = connectResult.outcomes.filter((o) => o.status === 'skipped'); + if (skipped.length > 0) { + throw new Error( + `deploy aborted: ${skipped.length} integration(s) skipped: ${skipped.map((s) => s.provider).join(', ')}` + ); + } + return connectResult.outcomes + .filter((o) => o.status === 'already-connected' || o.status === 'connected-now') + .map((o) => o.provider); +} + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 34d21735..67f96db4 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -1,4 +1,11 @@ -export { deploy, pickMode, type DeployResolvers } from './deploy.js'; +import { + deploy as deployImpl, + pickMode, + type DeployResolvers +} from './deploy.js'; +import type { DeployOptions, DeployResult } from './types.js'; + +export { pickMode, type DeployResolvers }; export { preflightPersona } from './preflight.js'; export { connectIntegrations, @@ -8,7 +15,17 @@ export { type IntegrationConnectResolver, type ProviderSubscriptionResolver } from './connect.js'; -export { envWorkspaceAuth, type WorkspaceAuth } from './login.js'; +export { + envWorkspaceAuth, + loadWorkspaceToken, + loginWithBrowser, + resolveWorkspaceToken, + resolveWorkspaceTokenFromEnv, + storeWorkspaceToken, + type StoredWorkspaceLogin, + type WorkspaceAuth, + type WorkspaceAuthToken +} from './login.js'; export { createTerminalIO, createBufferedIO, type BufferedIO } from './io.js'; export { bundleStager } from './bundle.js'; export { devLauncher } from './modes/dev.js'; @@ -29,3 +46,10 @@ export type { ModeLaunchInput, ModeLauncher } from './types.js'; + +export async function deploy( + opts: DeployOptions, + resolvers: DeployResolvers = {} +): Promise { + return await deployImpl(opts, resolvers); +} diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index 47563109..73783711 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -1,3 +1,9 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { homedir, platform } from 'node:os'; +import path from 'node:path'; import type { DeployIO } from './types.js'; /** @@ -14,6 +20,20 @@ export interface WorkspaceAuth { }>; } +export interface WorkspaceAuthToken { + token: string; +} + +export interface StoredWorkspaceLogin { + workspace?: string; + token: string; + refreshToken?: string; + expiresAt?: string; + cloudUrl?: string; +} + +const LOGIN_FILE = path.join(homedir(), '.agentworkforce', 'login.json'); + /** * Environment-backed fallback resolver: reads `WORKFORCE_WORKSPACE_ID` * and `WORKFORCE_WORKSPACE_TOKEN` from `process.env`. Useful in CI and as @@ -44,3 +64,288 @@ export function envWorkspaceAuth(): WorkspaceAuth { } }; } + +/** + * Resolve the workspace token for mode launchers that need to call cloud + * APIs directly. The deploy orchestrator resolves workspace identity before + * launch; this helper keeps the token lookup in the auth module until the + * browser/keychain login flow replaces the env fallback. + */ +export function resolveWorkspaceTokenFromEnv(workspace: string): WorkspaceAuthToken { + const token = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); + if (!token) { + throw new Error( + `no workspace token resolved for ${workspace}: run \`workforce login\` or set WORKFORCE_WORKSPACE_TOKEN` + ); + } + return { token }; +} + +export async function resolveWorkspaceToken(args: { + workspace: string; + cloudUrl: string; + io: DeployIO; + noPrompt?: boolean; +}): Promise { + const fromEnv = (process.env.WORKFORCE_WORKSPACE_TOKEN ?? '').trim(); + if (fromEnv) return { token: fromEnv }; + + const stored = await loadWorkspaceToken(args.workspace); + if (stored) return { token: stored.token }; + + if (args.noPrompt) { + throw new Error( + `no workspace token resolved for ${args.workspace}: run \`workforce login\` or set WORKFORCE_WORKSPACE_TOKEN` + ); + } + + args.io.info('cloud: no workspace token found; opening workforce login'); + const login = await loginWithBrowser({ + cloudUrl: args.cloudUrl, + workspace: args.workspace, + io: args.io + }); + return { token: login.token }; +} + +export async function loadWorkspaceToken(workspace: string): Promise { + const fromKeychain = await readMacKeychainLogin(workspace); + if (fromKeychain && !isExpired(fromKeychain.expiresAt)) { + return fromKeychain; + } + + const fromFile = await readLoginFile(); + if (fromFile && workspaceMatches(fromFile, workspace) && !isExpired(fromFile.expiresAt)) { + return fromFile; + } + + return null; +} + +export async function storeWorkspaceToken(login: StoredWorkspaceLogin): Promise { + if (await writeMacKeychainLogin(login)) return; + + await mkdir(path.dirname(LOGIN_FILE), { recursive: true, mode: 0o700 }); + await writeFile(LOGIN_FILE, `${JSON.stringify(login, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600 + }); +} + +export async function loginWithBrowser(args: { + cloudUrl: string; + workspace: string; + io: DeployIO; +}): Promise { + const state = randomUUID(); + + return await new Promise((resolve, reject) => { + let settled = false; + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (requestUrl.pathname !== '/callback') { + response.statusCode = 404; + response.end('not found'); + return; + } + + if (requestUrl.searchParams.get('state') !== state) { + response.statusCode = 400; + response.end('invalid state'); + settleError(new Error('login callback returned an invalid state')); + return; + } + + const error = requestUrl.searchParams.get('error'); + if (error) { + response.statusCode = 400; + response.end('login failed'); + settleError(new Error(error)); + return; + } + + const token = requestUrl.searchParams.get('access_token')?.trim(); + const refreshToken = requestUrl.searchParams.get('refresh_token')?.trim() || undefined; + const expiresAt = requestUrl.searchParams.get('access_token_expires_at')?.trim() || undefined; + const cloudUrl = requestUrl.searchParams.get('api_url')?.trim() || args.cloudUrl; + if (!token) { + response.statusCode = 400; + response.end('missing token'); + settleError(new Error('login callback did not include a workspace token')); + return; + } + + response.statusCode = 200; + response.end('workforce login complete; you can close this tab'); + const login: StoredWorkspaceLogin = { + workspace: args.workspace, + token, + cloudUrl, + ...(refreshToken ? { refreshToken } : {}), + ...(expiresAt ? { expiresAt } : {}) + }; + void storeWorkspaceToken(login).finally(() => settle(login)); + }); + + function settle(login: StoredWorkspaceLogin): void { + if (settled) return; + settled = true; + server.close(); + resolve(login); + } + + function settleError(error: Error): void { + if (settled) return; + settled = true; + server.close(); + reject(error); + } + + server.on('error', settleError); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + settleError(new Error('failed to start local login callback server')); + return; + } + + const callback = new URL('/callback', `http://127.0.0.1:${address.port}`); + const loginUrl = new URL('/cli-auth', args.cloudUrl); + loginUrl.searchParams.set('redirect_uri', callback.toString()); + loginUrl.searchParams.set('state', state); + + args.io.info(`cloud: login URL ${loginUrl.toString()}`); + tryOpenBrowser(loginUrl.toString()); + }); + + setTimeout(() => settleError(new Error('timed out waiting for workforce login')), 5 * 60_000).unref(); + }); +} + +async function readLoginFile(): Promise { + const raw = await readFile(LOGIN_FILE, 'utf8').catch(() => ''); + if (!raw.trim()) return null; + return parseStoredLogin(raw); +} + +async function readMacKeychainLogin(workspace: string): Promise { + if (platform() !== 'darwin') return null; + const serviceNames = [ + `agentworkforce:${workspace}`, + `workforce:${workspace}`, + 'agentworkforce', + 'workforce' + ]; + + for (const service of serviceNames) { + const stdout = await execSecurity(['find-generic-password', '-s', service, '-w']); + const parsed = parseStoredLogin(stdout.trim()); + if (parsed && workspaceMatches(parsed, workspace)) return parsed; + if (stdout.trim()) { + return { + workspace, + token: stdout.trim() + }; + } + } + return null; +} + +async function writeMacKeychainLogin(login: StoredWorkspaceLogin): Promise { + if (platform() !== 'darwin') return false; + const workspace = login.workspace ?? 'default'; + const payload = JSON.stringify(login); + return await execSecurityOk([ + 'add-generic-password', + '-U', + '-s', + `agentworkforce:${workspace}`, + '-a', + workspace, + '-w', + payload + ]); +} + +function execSecurity(args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn('security', args, { stdio: ['ignore', 'pipe', 'ignore'] }); + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.on('error', () => resolve('')); + child.on('close', (code) => resolve(code === 0 ? stdout : '')); + }); +} + +function execSecurityOk(args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn('security', args, { stdio: 'ignore' }); + child.on('error', () => resolve(false)); + child.on('close', (code) => resolve(code === 0)); + }); +} + +function parseStoredLogin(raw: string): StoredWorkspaceLogin | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (!trimmed.startsWith('{')) { + return { token: trimmed }; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const record = parsed as Record; + const token = typeof record.token === 'string' + ? record.token + : typeof record.accessToken === 'string' + ? record.accessToken + : ''; + if (!token.trim()) return null; + const workspace = typeof record.workspace === 'string' + ? record.workspace + : typeof record.workspaceId === 'string' + ? record.workspaceId + : undefined; + const expiresAt = typeof record.expiresAt === 'string' + ? record.expiresAt + : typeof record.accessTokenExpiresAt === 'string' + ? record.accessTokenExpiresAt + : undefined; + return { + token: token.trim(), + ...(workspace ? { workspace } : {}), + ...(typeof record.refreshToken === 'string' ? { refreshToken: record.refreshToken } : {}), + ...(expiresAt ? { expiresAt } : {}), + ...(typeof record.cloudUrl === 'string' ? { cloudUrl: record.cloudUrl } : {}) + }; + } catch { + return null; + } +} + +function workspaceMatches(login: StoredWorkspaceLogin, workspace: string): boolean { + return !login.workspace || login.workspace === workspace; +} + +function isExpired(expiresAt: string | undefined): boolean { + if (!expiresAt) return false; + const millis = Date.parse(expiresAt); + return Number.isNaN(millis) ? false : millis <= Date.now(); +} + +function tryOpenBrowser(url: string): void { + const command = platform() === 'darwin' + ? 'open' + : platform() === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = spawn(command, args, { stdio: 'ignore', detached: true }); + child.on('error', () => { + // The login URL was already printed; browser launch is best-effort. + }); + child.unref(); +} diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts new file mode 100644 index 00000000..abc9e0fe --- /dev/null +++ b/packages/deploy/src/modes/cloud.test.ts @@ -0,0 +1,548 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { get } from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import type { PersonaSpec } from '@agentworkforce/persona-kit'; +import { createBufferedIO } from '../io.js'; +import type { BundleResult, ModeLaunchInput } from '../types.js'; +import { cloudLauncher, type CloudRunHandle } from './cloud.js'; + +type FetchCall = { + url: string; + init: RequestInit | undefined; +}; + +const ENV_KEYS = [ + 'WORKFORCE_WORKSPACE_TOKEN', + 'WORKFORCE_DEPLOY_CLOUD_URL', + 'WORKFORCE_CLOUD_URL', + 'WORKFORCE_DEPLOY_HARNESS_SOURCE', + 'WORKFORCE_DEPLOY_BYOK_KEY', + 'WORKFORCE_DEPLOY_ON_EXISTS', + 'WORKFORCE_DEPLOY_NO_PROMPT', + 'WORKFORCE_DEPLOY_INPUTS_JSON', + 'WORKFORCE_DEPLOY_POLL_INTERVAL_MS', + 'WORKFORCE_DEPLOY_POLL_TIMEOUT_MS', + 'WORKFORCE_DEPLOY_RETRY_BACKOFF_MS' +] as const; + +function persona(overrides: Record = {}): PersonaSpec { + return { + id: 'demo', + intent: 'documentation', + tags: ['documentation'], + description: 'test persona', + harness: 'codex', + model: 'openai-codex/test', + systemPrompt: 'help', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + schedules: [{ name: 'daily', cron: '0 9 * * *' }], + onEvent: './agent.ts', + ...overrides + } as PersonaSpec; +} + +async function withBundle(): Promise<{ bundle: BundleResult; cleanup: () => Promise }> { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-cloud-test-')); + const runnerPath = path.join(dir, 'runner.mjs'); + const bundlePath = path.join(dir, 'agent.bundle.mjs'); + const personaCopyPath = path.join(dir, 'persona.json'); + const packageJsonPath = path.join(dir, 'package.json'); + await Promise.all([ + writeFile(runnerPath, 'export {};', 'utf8'), + writeFile(bundlePath, 'export default {};', 'utf8'), + writeFile(personaCopyPath, '{}', 'utf8'), + writeFile(packageJsonPath, '{"type":"module"}', 'utf8') + ]); + return { + bundle: { + runnerPath, + bundlePath, + personaCopyPath, + packageJsonPath, + sizeBytes: 2 + }, + cleanup: () => rm(dir, { recursive: true, force: true }) + }; +} + +function okJson(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +function installFetch( + handler: (url: string, init: RequestInit | undefined, calls: FetchCall[]) => Response | Promise +): { calls: FetchCall[]; restore: () => void } { + const previous = globalThis.fetch; + const calls: FetchCall[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' || input instanceof URL ? input.toString() : input.url; + calls.push({ url, init }); + return await handler(url, init, calls); + }) as typeof fetch; + return { + calls, + restore() { + globalThis.fetch = previous; + } + }; +} + +async function withEnv( + env: Partial>, + fn: () => Promise +): Promise { + const previous = new Map(); + for (const key of ENV_KEYS) { + previous.set(key, process.env[key]); + delete process.env[key]; + } + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) process.env[key] = value; + } + try { + return await fn(); + } finally { + for (const key of ENV_KEYS) { + const value = previous.get(key); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +async function launch(overrides: { + persona?: PersonaSpec; + env?: Partial>; + input?: Partial; + defaultPlanCredential?: boolean; + fetch: (url: string, init: RequestInit | undefined, calls: FetchCall[]) => Response | Promise; +}) { + const { bundle, cleanup } = await withBundle(); + const io = createBufferedIO(); + const fetchMock = installFetch((url, init, calls) => { + if (overrides.defaultPlanCredential !== false && url.endsWith('/api/v1/users/me/provider_credentials')) { + assert.equal(init?.method, 'POST'); + return okJson({ id: 'cred-1' }); + } + return overrides.fetch(url, init, calls); + }); + try { + const handle = await withEnv({ + WORKFORCE_WORKSPACE_TOKEN: 'tok', + WORKFORCE_DEPLOY_HARNESS_SOURCE: 'plan', + WORKFORCE_DEPLOY_POLL_INTERVAL_MS: '0', + WORKFORCE_DEPLOY_POLL_TIMEOUT_MS: '50', + WORKFORCE_DEPLOY_RETRY_BACKOFF_MS: '0', + ...overrides.env + }, () => cloudLauncher.launch({ + persona: overrides.persona ?? persona(), + bundle, + workspace: 'ws-test', + io, + ...overrides.input + })) as CloudRunHandle; + return { handle, calls: fetchMock.calls, io }; + } finally { + fetchMock.restore(); + await cleanup(); + } +} + +test('cloud launcher POSTs a deploy bundle and returns the cloud handle', async () => { + const { handle, calls } = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' + }, + input: { inputs: { topic: 'AI' } }, + fetch(url, init) { + if (url.endsWith('/agents?persona_slug=demo')) { + return okJson({ agents: [] }); + } + assert.equal(url, 'https://cloud.example.test/api/v1/workspaces/ws-test/deployments'); + assert.equal(init?.method, 'POST'); + const body = JSON.parse(String(init?.body)) as Record; + assert.equal((body.persona as { id: string }).id, 'demo'); + assert.deepEqual(body.inputs, { topic: 'AI' }); + assert.deepEqual((body.bundle as { packageJson: unknown }).packageJson, { type: 'module' }); + return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); + } + }); + + assert.equal(handle.id, 'agent-1'); + assert.equal(handle.deploymentId, 'dep-1'); + assert.equal((await handle.done).code, 0); + assert.equal(callsForUrl(calls, '/provider_credentials'), 1); +}); + +test('cloud URL precedence is flag env, cloud env, persona deployUrl, then default', async () => { + async function deployedUrl(env: Partial>, spec = persona()) { + const { calls } = await launch({ + env, + persona: spec, + fetch(url) { + if (url.includes('/agents?persona_slug=')) return okJson({ agents: [] }); + return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); + } + }); + return calls.find((call) => call.url.endsWith('/deployments'))?.url; + } + + const personaWithUrl = persona({ cloud: { deployUrl: 'https://persona.example.test/' } as unknown }); + assert.equal( + await deployedUrl({ + WORKFORCE_DEPLOY_CLOUD_URL: 'https://flag.example.test/', + WORKFORCE_CLOUD_URL: 'https://env.example.test/' + }, personaWithUrl), + 'https://flag.example.test/api/v1/workspaces/ws-test/deployments' + ); + assert.equal( + await deployedUrl({ WORKFORCE_CLOUD_URL: 'https://env.example.test/' }, personaWithUrl), + 'https://env.example.test/api/v1/workspaces/ws-test/deployments' + ); + assert.equal( + await deployedUrl({}, personaWithUrl), + 'https://persona.example.test/api/v1/workspaces/ws-test/deployments' + ); + assert.equal( + await deployedUrl({}), + 'https://agentrelay.com/api/v1/workspaces/ws-test/deployments' + ); +}); + +test('cloud harness plan and BYOK save provider credentials through the cloud contract', async () => { + const plan = await launch({ + defaultPlanCredential: false, + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + input: { harnessSource: 'plan' }, + fetch(url, init) { + if (url.endsWith('/provider_credentials')) { + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)), { + model_provider: 'openai-codex', + auth_type: 'relay_managed' + }); + return okJson({ id: 'cred-plan' }); + } + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-plan', deploymentId: 'dep-plan', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(plan.handle.id, 'agent-plan'); + + const byok = await launch({ + defaultPlanCredential: false, + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + input: { harnessSource: 'byok', byokKey: 'sk-test' }, + fetch(url, init) { + if (url.endsWith('/provider_credentials')) { + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)), { + model_provider: 'openai-codex', + auth_type: 'byo_api_key', + api_key: 'sk-test' + }); + return okJson({ id: 'cred-byok' }); + } + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-byok', deploymentId: 'dep-byok', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(byok.handle.id, 'agent-byok'); +}); + +test('cloud harness OAuth uses provider_credentials readiness and honors no-prompt failure', async () => { + await assert.rejects( + launch({ + defaultPlanCredential: false, + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_NO_PROMPT: '1' + }, + input: { harnessSource: 'oauth' }, + fetch(url, init) { + assert.equal(url, 'https://cloud.example.test/api/v1/users/me/provider_credentials?model_provider=openai-codex'); + assert.equal(init?.method, 'GET'); + return okJson({}); + } + }), + /OAuth credentials are not connected/ + ); +}); + +test('cloud harness OAuth starts auth and polls until provider credentials are connected', async () => { + let credentialChecks = 0; + const io = createBufferedIO(); + io.scriptConfirmations([true]); + const { bundle, cleanup } = await withBundle(); + const fetchMock = installFetch((url, init) => { + if (url.endsWith('/provider_credentials?model_provider=openai-codex')) { + credentialChecks += 1; + assert.equal(init?.method, 'GET'); + return okJson(credentialChecks < 3 ? {} : { id: 'cred-oauth', status: 'connected' }); + } + if (url.endsWith('/api/v1/users/me/provider_credentials/auth-session')) { + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)), { + model_provider: 'openai-codex', + provider: 'codex', + language: 'typescript' + }); + return okJson({ authUrl: 'https://cloud.example.test/oauth/codex' }); + } + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-oauth', deploymentId: 'dep-oauth', 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(), + bundle, + workspace: 'ws-test', + io + })); + assert.equal(handle.id, 'agent-oauth'); + } finally { + fetchMock.restore(); + await cleanup(); + } + + assert.equal(credentialChecks, 3); + assert.ok(io.messages.some((message) => message.message.includes('/oauth/codex'))); +}); + +test('cloud launcher maps 401 deploy responses to the workforce login guidance', async () => { + await assert.rejects( + launch({ + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + fetch(url) { + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + return okJson({ error: 'Unauthorized' }, 401); + } + }), + /Run `workforce login`/ + ); +}); + +test('cloud launcher retries retryable network failures three times', async () => { + let deployAttempts = 0; + const { calls, handle } = await launch({ + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + fetch(url) { + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + deployAttempts += 1; + if (deployAttempts < 3) { + throw new Error('temporary network failure'); + } + return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); + } + }); + + assert.equal(handle.id, 'agent-1'); + assert.equal(callsForUrl(calls, '/deployments'), 3); +}); + +test('cloud polling resolves done with code 0 on active and 1 on failed', async () => { + for (const finalStatus of ['active', 'failed'] as const) { + const { bundle, cleanup } = await withBundle(); + const io = createBufferedIO(); + const fetchMock = installFetch((url) => { + if (url.endsWith('/api/v1/users/me/provider_credentials')) return okJson({ id: 'cred-1' }); + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: `agent-${finalStatus}`, deploymentId: `dep-${finalStatus}`, status: 'starting' }, 201); + } + if (url.endsWith(`/agents/agent-${finalStatus}`)) { + return okJson({ status: finalStatus }); + } + throw new Error(`unexpected URL ${url}`); + }); + try { + const streamedLogs: string[] = []; + const handle = await withEnv({ + WORKFORCE_WORKSPACE_TOKEN: 'tok', + WORKFORCE_DEPLOY_CLOUD_URL: `https://${finalStatus}.example.test`, + WORKFORCE_DEPLOY_HARNESS_SOURCE: 'plan', + WORKFORCE_DEPLOY_POLL_INTERVAL_MS: '0', + WORKFORCE_DEPLOY_POLL_TIMEOUT_MS: '50', + WORKFORCE_DEPLOY_RETRY_BACKOFF_MS: '0' + }, () => cloudLauncher.launch({ + persona: persona(), + bundle, + workspace: 'ws-test', + io, + onLog: (line) => streamedLogs.push(line) + })); + assert.equal((await handle.done).code, finalStatus === 'active' ? 0 : 1); + assert.ok(streamedLogs.includes(`cloud: status ${finalStatus}`)); + } finally { + fetchMock.restore(); + await cleanup(); + } + } +}); + +test('cloud stop calls the destroy agent endpoint', async () => { + const { bundle, cleanup } = await withBundle(); + const io = createBufferedIO(); + const fetchMock = installFetch((url, init) => { + if (url.endsWith('/api/v1/users/me/provider_credentials')) return okJson({ id: 'cred-1' }); + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); + } + assert.equal(url, 'https://cloud.example.test/api/v1/workspaces/ws-test/agents/agent-1/destroy'); + assert.equal(init?.method, 'POST'); + return okJson({ ok: true }); + }); + + try { + const handle = await withEnv({ + WORKFORCE_WORKSPACE_TOKEN: 'tok', + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: 'plan', + WORKFORCE_DEPLOY_POLL_INTERVAL_MS: '0', + WORKFORCE_DEPLOY_POLL_TIMEOUT_MS: '50' + }, () => cloudLauncher.launch({ + persona: persona(), + bundle, + workspace: 'ws-test', + io + })); + await handle.stop(); + assert.equal(fetchMock.calls.at(-1)?.init?.method, 'POST'); + } finally { + fetchMock.restore(); + await cleanup(); + } +}); + +test('cloud integration stage opens OAuth session and waits for readiness', async () => { + let statusChecks = 0; + const baseIo = createBufferedIO(); + const io = { + ...baseIo, + info(message: string) { + baseIo.info(message); + if (message.includes('/integrations?')) { + const rawUrl = message.match(/https:\/\/\S+/)?.[0]; + if (!rawUrl) return; + const connectUrl = new URL(rawUrl); + const returnTo = connectUrl.searchParams.get('return_to'); + if (returnTo) void hitCallback(returnTo); + } + } + }; + io.scriptConfirmations([true]); + const { bundle, cleanup } = await withBundle(); + const fetchMock = installFetch((url) => { + if (url.endsWith('/api/v1/users/me/provider_credentials')) { + return okJson({ id: 'cred-1' }); + } + if (url.endsWith('/integrations?provider=github')) { + statusChecks += 1; + return okJson(statusChecks < 2 ? { ready: false, state: 'pending' } : { ready: true, state: 'ready' }); + } + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + }); + + try { + await withEnv({ + WORKFORCE_WORKSPACE_TOKEN: 'tok', + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_HARNESS_SOURCE: 'plan', + WORKFORCE_DEPLOY_POLL_INTERVAL_MS: '0', + WORKFORCE_DEPLOY_POLL_TIMEOUT_MS: '50' + }, () => cloudLauncher.launch({ + persona: persona({ integrations: { github: { triggers: [{ on: 'pull_request.opened' }] } } }), + bundle, + workspace: 'ws-test', + io + })); + } finally { + fetchMock.restore(); + await cleanup(); + } + + assert.equal(statusChecks, 2); + assert.ok(io.messages.some((message) => message.message.includes('/integrations?provider=github'))); +}); + +test('cloud existing-persona stage honors destroy and cancel choices', async () => { + const destroy = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_ON_EXISTS: 'destroy' + }, + fetch(url, init) { + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agents: [{ id: 'agent-old' }] }); + if (url.endsWith('/agents/agent-old/destroy')) { + assert.equal(init?.method, 'POST'); + return okJson({ ok: true }); + } + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-new', deploymentId: 'dep-new', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(destroy.handle.id, 'agent-new'); + assert.equal(destroy.calls.some((call) => call.init?.method === 'POST' && call.url.endsWith('/destroy')), true); + + const cancel = await launch({ + env: { + WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test', + WORKFORCE_DEPLOY_ON_EXISTS: 'cancel' + }, + fetch(url) { + if (url.endsWith('/agents?persona_slug=demo')) return okJson({ agent: { id: 'agent-old' } }); + throw new Error(`unexpected URL ${url}`); + } + }); + assert.equal(cancel.handle.id, 'agent-old'); + assert.equal(cancel.handle.status, 'cancelled'); + assert.equal((await cancel.handle.done).code, 0); + assert.equal(cancel.calls.some((call) => call.url.endsWith('/deployments')), false); +}); + +function callsForUrl(calls: FetchCall[], suffix: string): number { + return calls.filter((call) => call.url.endsWith(suffix)).length; +} + +function hitCallback(url: string): Promise { + return new Promise((resolve, reject) => { + get(url, (res) => { + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); +} diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index f4167596..a56d2ab3 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -1,28 +1,977 @@ +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { platform } from 'node:os'; +import type { PersonaSpec } from '@agentworkforce/persona-kit'; import type { ModeLaunchInput, ModeLaunchHandle, ModeLauncher } from '../types.js'; +import { + resolveWorkspaceToken, + type WorkspaceAuthToken +} from '../login.js'; + +const DEFAULT_CLOUD_URL = 'https://agentrelay.com'; +const BUILD_YOUR_OWN_CLOUD_DOCS_URL = 'https://docs.agentworkforce.com/deploy/build-your-own-cloud'; +const USER_AGENT = 'workforce-deploy'; +const MAX_ATTEMPTS = 3; +const POLL_TIMEOUT_MS = 60_000; +const POLL_INTERVAL_MS = 2_000; + +type CloudDeployStatus = 'starting' | 'active' | 'failed' | 'cancelled'; +type HarnessSource = 'plan' | 'byok' | 'oauth'; +type OnExistsChoice = 'update' | 'destroy' | 'cancel'; + +export interface CloudRunHandle extends ModeLaunchHandle { + agentId: string; + deploymentId: string; + status: CloudDeployStatus; +} + +interface CloudDeployResponse { + agentId?: unknown; + workspaceId?: unknown; + status?: unknown; + deploymentId?: unknown; +} + +interface CloudAgentStatusResponse { + id?: unknown; + agentId?: unknown; + status?: unknown; +} + +interface ProviderCredentialsResponse { + credentials?: unknown; + providerCredentials?: unknown; + credential?: unknown; + id?: unknown; + authType?: unknown; + auth_type?: unknown; + status?: unknown; + connected?: unknown; + credentialStoredAt?: unknown; + createdAt?: unknown; +} + +interface IntegrationsResponse { + integrations?: unknown; + provider?: unknown; + ready?: unknown; + state?: unknown; + connectionId?: unknown; + currentConnectionId?: unknown; +} + +interface ExistingAgentResponse { + agent?: unknown; + agents?: unknown; +} + +interface ExistingAgent { + id: string; + status?: string; +} /** - * Workforce-cloud-hosted deploy mode. Uploads the bundle to the workforce - * cloud deployments endpoint and lets the cloud runtime host the agent. - * - * The endpoint (`POST /api/v1/workspaces/:id/deployments`) is part of the - * proactive-runtime backend roadmap and is not yet live. Until it is, - * `--mode cloud` returns a clean error that points users at the working - * modes (`--mode sandbox` and `--mode dev`). - * - * When the endpoint ships, the implementation flow is: - * 1. POST persona.json + agent.bundle.mjs + runner.mjs as multipart. - * 2. Receive `{ deploymentId, statusUrl }`. - * 3. Poll `statusUrl` until the cloud reports `running`. - * 4. Return a handle whose `stop()` calls DELETE on the deployment. + * Cloud-hosted deploy mode. Uploads the deploy-ready persona bundle to a + * workforce-compatible cloud endpoint. The implementation is intentionally + * OSS-generic: callers may point at any compatible runtime with + * `--cloud-url`, `WORKFORCE_CLOUD_URL`, or `persona.cloud.deployUrl`; the + * production AgentRelay URL is only the final default. */ export const cloudLauncher: ModeLauncher = { - async launch(_input: ModeLaunchInput): Promise { - throw new Error( - '--mode cloud is not yet available: the workforce cloud deployments endpoint is in progress. Use --mode sandbox (Daytona) or --mode dev (local) today.' + async launch(input: ModeLaunchInput): Promise { + const cloudUrl = resolveCloudUrl(input); + const noPrompt = isNoPrompt(input); + const auth = input.workspaceToken + ? { token: input.workspaceToken } + : await resolveWorkspaceToken({ + workspace: input.workspace, + cloudUrl, + io: input.io, + noPrompt + }); + + await ensureHarnessReady({ + cloudUrl, + workspaceId: input.workspace, + token: auth.token, + persona: input.persona, + io: input.io, + noPrompt, + harnessSource: input.harnessSource, + byokKey: input.byokKey + }); + + await ensureCloudIntegrations({ + cloudUrl, + workspaceId: input.workspace, + token: auth.token, + persona: input.persona, + io: input.io, + noPrompt + }); + + const existingPersona = await handleExistingPersona({ + cloudUrl, + workspaceId: input.workspace, + token: auth.token, + personaId: input.persona.id, + io: input.io, + noPrompt, + onExists: input.onExists + }); + if (existingPersona.cancelled) { + return { + id: existingPersona.agentId, + agentId: existingPersona.agentId, + deploymentId: 'cancelled', + status: 'cancelled', + async stop() { + /* no-op: user chose not to change the existing hosted persona. */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + + const endpoint = `${cloudUrl}/api/v1/workspaces/${encodeURIComponent( + input.workspace + )}/deployments`; + + const body = JSON.stringify({ + persona: input.persona, + bundle: { + runner: await readFile(input.bundle.runnerPath, 'utf8'), + agent: await readFile(input.bundle.bundlePath, 'utf8'), + packageJson: JSON.parse(await readFile(input.bundle.packageJsonPath, 'utf8')) as unknown + }, + inputs: input.inputs ?? readInputsOverride() + }); + + input.io.info(`cloud: deploying persona bundle to ${cloudUrl}`); + const deployBody = await requestJsonWithRetry( + endpoint, + { + method: 'POST', + headers: jsonHeaders(auth.token), + body + }, + { action: 'cloud deploy' } ); + + const agentId = expectString(deployBody.agentId, 'agentId'); + const deploymentId = expectString(deployBody.deploymentId, 'deploymentId'); + const initialStatus = expectStatus(deployBody.status); + input.io.info(`cloud: deployment ${deploymentId} created for agent ${agentId}`); + + let stopping = false; + const done = (async (): Promise<{ code: number }> => { + if (initialStatus === 'active') return { code: 0 }; + if (initialStatus === 'failed') return { code: 1 }; + + try { + const finalStatus = await pollAgentStatus({ + cloudUrl, + workspaceId: input.workspace, + agentId, + token: auth.token, + io: input.io, + onLog: input.onLog + }); + return { code: finalStatus === 'active' ? 0 : 1 }; + } catch (err) { + if (!stopping) { + input.io.error( + `cloud: status polling failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + return { code: 1 }; + } + })(); + + const stop = async (): Promise => { + if (stopping) return; + stopping = true; + await deleteAgent({ + cloudUrl, + workspaceId: input.workspace, + agentId, + token: auth.token, + action: 'cloud stop' + }); + }; + + return { + id: agentId, + agentId, + deploymentId, + status: initialStatus, + stop, + done + }; } }; + +function resolveCloudUrl(input: ModeLaunchInput): string { + const fromInput = input.cloudUrl?.trim(); + const fromEnv = process.env.WORKFORCE_DEPLOY_CLOUD_URL?.trim() + || process.env.WORKFORCE_CLOUD_URL?.trim(); + const fromPersona = readPersonaCloudDeployUrl(input.persona); + const raw = fromInput || fromEnv || fromPersona || DEFAULT_CLOUD_URL; + const resolved = normalizeCloudUrl(raw); + if (resolved !== DEFAULT_CLOUD_URL) { + input.io.info( + `cloud: using custom cloud URL ${resolved}. Build your own cloud docs: ${BUILD_YOUR_OWN_CLOUD_DOCS_URL}` + ); + } + return resolved; +} + +function isNoPrompt(input: ModeLaunchInput): boolean { + if (input.noPrompt) return true; + const raw = process.env.WORKFORCE_DEPLOY_NO_PROMPT?.trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes'; +} + +async function ensureHarnessReady(args: { + cloudUrl: string; + workspaceId: string; + token: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; + harnessSource?: HarnessSource; + byokKey?: string; +}): Promise { + const source = await resolveHarnessSource(args); + const modelProvider = deriveModelProvider(args.persona); + if (source === 'plan') { + await saveProviderCredential({ + cloudUrl: args.cloudUrl, + token: args.token, + modelProvider, + authType: 'relay_managed' + }); + args.io.info(`cloud: using workforce plan credentials for ${args.persona.harness}`); + return; + } + + if (source === 'byok') { + const key = await resolveByokKey(args); + await saveProviderCredential({ + cloudUrl: args.cloudUrl, + token: args.token, + modelProvider, + authType: 'byo_api_key', + apiKey: key + }); + args.io.info(`cloud: using BYOK credentials for ${args.persona.harness}`); + return; + } + + await ensureHarnessOauth(args); +} + +async function resolveHarnessSource(args: { + cloudUrl: string; + workspaceId: string; + token: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; + harnessSource?: HarnessSource; + byokKey?: string; +}): Promise { + if (args.harnessSource) return args.harnessSource; + const fromEnv = process.env.WORKFORCE_DEPLOY_HARNESS_SOURCE?.trim(); + if (fromEnv) return expectHarnessSource(fromEnv); + + const available = await isHarnessOauthConnected(args); + if (available) return 'oauth'; + + if (args.noPrompt) { + throw new Error( + `cloud: ${args.persona.harness} credentials are not connected. Re-run with --harness-source plan|byok|oauth, set WORKFORCE_DEPLOY_HARNESS_SOURCE, or run without --no-prompt.` + ); + } + + const answer = await args.io.prompt( + `${args.persona.harness} credentials are not connected. Choose harness source (plan/byok/oauth)`, + { defaultValue: 'plan' } + ); + return expectHarnessSource(answer); +} + +async function isHarnessOauthConnected(args: { + cloudUrl: string; + token: string; + persona: PersonaSpec; +}): Promise { + const url = `${args.cloudUrl}/api/v1/users/me/provider_credentials?model_provider=${encodeURIComponent( + deriveModelProvider(args.persona) + )}`; + const res = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${args.token}`, + 'user-agent': USER_AGENT + } + }); + if (res.status === 404 || res.status === 405) return false; + if (res.status === 401) { + throw new Error('cloud harness check failed: unauthorized. Run `workforce login` and retry.'); + } + if (!res.ok) { + throw new Error(`cloud harness check failed: ${res.status} ${await responseExcerpt(res)}`); + } + const body = (await res.json()) as ProviderCredentialsResponse; + return providerCredentialsReady(body); +} + +async function resolveByokKey(args: { + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; + byokKey?: string; +}): Promise { + if (args.byokKey?.trim()) return args.byokKey.trim(); + const fromEnv = process.env.WORKFORCE_DEPLOY_BYOK_KEY?.trim(); + if (fromEnv) return fromEnv; + if (args.noPrompt) { + throw new Error( + `cloud: --harness-source byok requires --byok-key or WORKFORCE_DEPLOY_BYOK_KEY for ${args.persona.harness}` + ); + } + const answer = await args.io.prompt(`API key for ${args.persona.harness}`); + if (!answer.trim()) { + throw new Error(`cloud: missing BYOK API key for ${args.persona.harness}`); + } + return answer.trim(); +} + +async function ensureHarnessOauth(args: { + cloudUrl: string; + workspaceId: string; + token: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; +}): Promise { + if (await isHarnessOauthConnected(args)) { + 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.` + ); + } + 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); + const startUrl = `${args.cloudUrl}/api/v1/users/me/provider_credentials/auth-session`; + const body = await requestJsonWithRetry>( + startUrl, + { + method: 'POST', + headers: jsonHeaders(args.token), + body: JSON.stringify({ + model_provider: modelProvider, + provider: args.persona.harness, + language: 'typescript' + }) + }, + { action: 'cloud harness OAuth start' } + ); + const connectUrl = readFirstString(body, ['connectLink', 'authUrl', 'url', 'sandboxUrl']); + if (connectUrl) { + args.io.info(`cloud: open ${connectUrl} to finish ${args.persona.harness} OAuth`); + tryOpenBrowser(connectUrl); + } + await pollUntil( + () => isHarnessOauthConnected(args), + `timed out waiting for ${args.persona.harness} OAuth credentials` + ); + args.io.info(`cloud: ${args.persona.harness} credentials connected`); +} + +async function ensureCloudIntegrations(args: { + cloudUrl: string; + workspaceId: string; + token: string; + persona: PersonaSpec; + io: ModeLaunchInput['io']; + noPrompt: boolean; +}): Promise { + const providers = Object.keys(args.persona.integrations ?? {}); + for (const provider of providers) { + const ready = await isIntegrationReady({ ...args, provider }); + if (ready) { + args.io.info(`cloud: integrations.${provider} ready`); + continue; + } + if (args.noPrompt) { + throw new Error( + `cloud: integrations.${provider} is not connected. Run without --no-prompt or connect it before deploying.` + ); + } + const ok = await args.io.confirm( + `Connect ${provider} in workforce cloud now? (opens browser)`, + { defaultValue: true } + ); + if (!ok) { + throw new Error(`cloud: integrations.${provider} is required for deploy`); + } + await connectIntegration({ ...args, provider }); + await pollUntil( + () => isIntegrationReady({ ...args, provider }), + `timed out waiting for integrations.${provider} to become ready` + ); + args.io.info(`cloud: integrations.${provider} connected`); + } +} + +async function isIntegrationReady(args: { + cloudUrl: string; + workspaceId: string; + token: string; + provider: string; +}): Promise { + const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( + args.workspaceId + )}/integrations?provider=${encodeURIComponent(args.provider)}`; + const res = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${args.token}`, + 'user-agent': USER_AGENT + } + }); + if (res.status === 401) { + throw new Error('cloud integration check failed: unauthorized. Run `workforce login` and retry.'); + } + if (res.status === 404) return false; + if (!res.ok) { + throw new Error(`cloud integration check failed: ${res.status} ${await responseExcerpt(res)}`); + } + const body = (await res.json()) as IntegrationsResponse; + return integrationReady(body, args.provider); +} + +async function connectIntegration(args: { + cloudUrl: string; + workspaceId: string; + token: string; + provider: string; + io: ModeLaunchInput['io']; +}): Promise { + await waitForOAuthCallback({ + action: `integrations.${args.provider}`, + io: args.io, + buildUrl(returnTo) { + const url = new URL('/integrations', args.cloudUrl); + url.searchParams.set('provider', args.provider); + url.searchParams.set('workspace', args.workspaceId); + url.searchParams.set('return_to', returnTo); + return url.toString(); + } + }); +} + +async function handleExistingPersona(args: { + cloudUrl: string; + workspaceId: string; + token: string; + personaId: string; + io: ModeLaunchInput['io']; + noPrompt: boolean; + onExists?: OnExistsChoice; +}): Promise<{ cancelled: false } | { cancelled: true; agentId: string }> { + const existing = await findExistingAgent(args); + if (!existing) return { cancelled: false }; + const choice = await resolveOnExists(args); + if (choice === 'cancel') { + args.io.info(`cloud: deploy cancelled because persona ${args.personaId} already exists`); + return { cancelled: true, agentId: existing.id }; + } + if (choice === 'update') { + args.io.info(`cloud: updating existing persona ${args.personaId}`); + return { cancelled: false }; + } + + args.io.info(`cloud: destroying existing persona ${args.personaId} before deploy`); + await deleteAgent({ + cloudUrl: args.cloudUrl, + workspaceId: args.workspaceId, + token: args.token, + agentId: existing.id, + action: 'cloud existing persona destroy' + }); + return { cancelled: false }; +} + +async function findExistingAgent(args: { + cloudUrl: string; + workspaceId: string; + token: string; + personaId: string; +}): Promise { + const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( + args.workspaceId + )}/agents?persona_slug=${encodeURIComponent(args.personaId)}`; + const res = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${args.token}`, + 'user-agent': USER_AGENT + } + }); + if (res.status === 404 || res.status === 405) return null; + if (res.status === 401) { + throw new Error('cloud existing persona check failed: unauthorized. Run `workforce login` and retry.'); + } + if (!res.ok) { + throw new Error(`cloud existing persona check failed: ${res.status} ${await responseExcerpt(res)}`); + } + return parseExistingAgent((await res.json()) as ExistingAgentResponse); +} + +async function resolveOnExists(args: { + personaId: string; + io: ModeLaunchInput['io']; + noPrompt: boolean; + onExists?: OnExistsChoice; +}): Promise { + if (args.onExists) return args.onExists; + const fromEnv = process.env.WORKFORCE_DEPLOY_ON_EXISTS?.trim(); + if (fromEnv) return expectOnExistsChoice(fromEnv); + if (args.noPrompt) { + return 'cancel'; + } + const answer = await args.io.prompt( + `Persona ${args.personaId} already exists. Choose update, destroy, or cancel`, + { defaultValue: 'cancel' } + ); + return expectOnExistsChoice(answer); +} + +async function deleteAgent(args: { + cloudUrl: string; + workspaceId: string; + agentId: string; + token: string; + action: string; +}): Promise { + const destroyUrl = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( + args.workspaceId + )}/agents/${encodeURIComponent(args.agentId)}/destroy`; + const res = await fetch(destroyUrl, { + method: 'POST', + headers: { + authorization: `Bearer ${args.token}`, + 'user-agent': USER_AGENT + } + }); + if (res.status === 401) { + throw new Error(`${args.action} failed: unauthorized. Run \`workforce login\` and retry.`); + } + if (res.status === 404 || res.status === 405) { + throw new Error( + `${args.action} failed: destroy not yet wired; cancel and run with --force-replace later.` + ); + } + if (!res.ok) { + throw new Error(`${args.action} failed: ${res.status} ${await responseExcerpt(res)}`); + } +} + +function parseExistingAgent(body: ExistingAgentResponse): ExistingAgent | null { + const direct = parseAgentLike(body.agent); + if (direct) return direct; + if (Array.isArray(body.agents)) { + for (const agent of body.agents) { + const parsed = parseAgentLike(agent); + if (parsed) return parsed; + } + } + return null; +} + +function parseAgentLike(value: unknown): ExistingAgent | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (typeof record.id !== 'string' || !record.id.trim()) return null; + return { + id: record.id, + ...(typeof record.status === 'string' ? { status: record.status } : {}) + }; +} + +async function saveProviderCredential(args: { + cloudUrl: string; + token: string; + modelProvider: string; + authType: 'relay_managed' | 'byo_api_key'; + apiKey?: string; +}): Promise { + await requestJsonWithRetry>( + `${args.cloudUrl}/api/v1/users/me/provider_credentials`, + { + method: 'POST', + headers: jsonHeaders(args.token), + body: JSON.stringify({ + model_provider: args.modelProvider, + auth_type: args.authType, + ...(args.apiKey ? { api_key: args.apiKey } : {}) + }) + }, + { action: 'cloud provider credentials save' } + ); +} + +function deriveModelProvider(persona: PersonaSpec): string { + const model = typeof persona.model === 'string' ? persona.model.trim() : ''; + const [provider] = model.split(/[/:]/, 1); + if (provider?.trim()) return provider.trim(); + return persona.harness; +} + +function providerCredentialsReady(body: ProviderCredentialsResponse): boolean { + const candidates = [ + body.credential, + ...(Array.isArray(body.credentials) ? body.credentials : []), + ...(Array.isArray(body.providerCredentials) ? body.providerCredentials : []), + body + ]; + return candidates.some((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; + const record = candidate as Record; + return record.connected === true + || record.status === 'connected' + || record.status === 'active' + || Boolean(record.credentialStoredAt) + || Boolean(record.createdAt) + || typeof record.id === 'string'; + }); +} + +function integrationReady(body: IntegrationsResponse, provider: string): boolean { + const candidates = [ + ...(Array.isArray(body.integrations) ? body.integrations : []), + body + ]; + return candidates.some((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return false; + const record = candidate as Record; + const recordProvider = typeof record.provider === 'string' ? record.provider : provider; + if (recordProvider !== provider) return false; + return record.ready === true + || record.state === 'ready' + || record.state === 'connected' + || typeof record.connectionId === 'string' + || typeof record.currentConnectionId === 'string'; + }); +} + +async function waitForOAuthCallback(args: { + action: string; + io: ModeLaunchInput['io']; + buildUrl(returnTo: string): string; +}): Promise { + const state = randomUUID(); + await new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + settleError(new Error(`timed out waiting for ${args.action} OAuth callback`)); + }, pollTimeoutMs()).unref(); + + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (requestUrl.pathname !== '/callback') { + response.statusCode = 404; + response.end('not found'); + return; + } + if (requestUrl.searchParams.get('state') !== state) { + response.statusCode = 400; + response.end('invalid state'); + settleError(new Error(`${args.action} OAuth callback returned an invalid state`)); + return; + } + const error = requestUrl.searchParams.get('error'); + if (error) { + response.statusCode = 400; + response.end('OAuth failed'); + settleError(new Error(error)); + return; + } + response.statusCode = 200; + response.end('workforce OAuth complete; you can close this tab'); + settleOk(); + }); + + function settleOk(): void { + if (settled) return; + settled = true; + clearTimeout(timeout); + server.close(); + resolve(); + } + + function settleError(error: Error): void { + if (settled) return; + settled = true; + clearTimeout(timeout); + server.close(); + reject(error); + } + + server.on('error', settleError); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + settleError(new Error(`failed to start ${args.action} OAuth callback server`)); + return; + } + const callback = new URL('/callback', `http://127.0.0.1:${address.port}`); + callback.searchParams.set('state', state); + const connectUrl = args.buildUrl(callback.toString()); + args.io.info(`cloud: open ${connectUrl} to finish ${args.action} OAuth`); + tryOpenBrowser(connectUrl); + }); + }); +} + +function expectHarnessSource(value: string): HarnessSource { + const normalized = value.trim().toLowerCase(); + if (normalized === 'plan' || normalized === 'byok' || normalized === 'oauth') { + return normalized; + } + throw new Error(`cloud: harness source must be one of plan|byok|oauth; got "${value}"`); +} + +function expectOnExistsChoice(value: string): OnExistsChoice { + const normalized = value.trim().toLowerCase(); + if (normalized === 'update' || normalized === 'destroy' || normalized === 'cancel') { + return normalized; + } + throw new Error(`cloud: on-exists must be one of update|destroy|cancel; got "${value}"`); +} + +function readFirstString( + body: object, + fields: readonly string[] +): string | undefined { + const record = body as Record; + for (const field of fields) { + const value = record[field]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +async function pollUntil( + check: () => Promise, + timeoutMessage: string +): Promise { + const deadline = Date.now() + pollTimeoutMs(); + while (Date.now() < deadline) { + if (await check()) return; + await sleep(pollIntervalMs()); + } + throw new Error(timeoutMessage); +} + +function readPersonaCloudDeployUrl(persona: PersonaSpec): string | undefined { + const cloud = (persona as PersonaSpec & { cloud?: unknown }).cloud; + if (cloud !== null && typeof cloud === 'object' && 'deployUrl' in cloud) { + const deployUrl = (cloud as { deployUrl?: unknown }).deployUrl; + if (typeof deployUrl === 'string' && deployUrl.trim()) { + return deployUrl.trim(); + } + } + return undefined; +} + +function normalizeCloudUrl(url: string): string { + const trimmed = url.trim(); + if (!trimmed) return DEFAULT_CLOUD_URL; + return trimmed.replace(/\/+$/, ''); +} + +function readInputsOverride(): Record | undefined { + const raw = process.env.WORKFORCE_DEPLOY_INPUTS_JSON?.trim(); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('WORKFORCE_DEPLOY_INPUTS_JSON must be a JSON object'); + } + const out: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new Error(`WORKFORCE_DEPLOY_INPUTS_JSON.${key} must be a string`); + } + out[key] = value; + } + return out; +} + +async function pollAgentStatus(args: { + cloudUrl: string; + workspaceId: string; + agentId: string; + token: WorkspaceAuthToken['token']; + io: ModeLaunchInput['io']; + onLog?: ModeLaunchInput['onLog']; +}): Promise<'active' | 'failed'> { + const statusUrl = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent( + args.workspaceId + )}/agents/${encodeURIComponent(args.agentId)}`; + const deadline = Date.now() + pollTimeoutMs(); + let lastStatus = 'starting'; + + while (Date.now() < deadline) { + await sleep(pollIntervalMs()); + const body = await requestJsonWithRetry( + statusUrl, + { + method: 'GET', + headers: { + authorization: `Bearer ${args.token}`, + 'user-agent': USER_AGENT + } + }, + { action: 'cloud status poll' } + ); + const status = expectStatus(body.status); + if (status !== lastStatus) { + emitLog(args, `cloud: status ${status}`); + lastStatus = status; + } + if (status === 'active' || status === 'failed') return status; + } + + throw new Error(`timed out after ${pollTimeoutMs() / 1000}s waiting for agent ${args.agentId}`); +} + +function jsonHeaders(token: string): Record { + return { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': USER_AGENT + }; +} + +async function requestJsonWithRetry( + url: string, + init: RequestInit, + opts: { action: string } +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + const res = await fetch(url, init); + if (res.status === 401) { + throw new Error(`${opts.action} failed: unauthorized. Run \`workforce login\` and retry.`); + } + if (res.status >= 500 && attempt < MAX_ATTEMPTS) { + lastError = new Error(`${opts.action} failed: ${res.status} ${await responseExcerpt(res)}`); + } else if (!res.ok) { + throw new Error(`${opts.action} failed: ${res.status} ${await responseExcerpt(res)}`); + } else { + return (await res.json()) as T; + } + } catch (err) { + lastError = err; + if (attempt === MAX_ATTEMPTS || !isRetryableError(err)) { + throw err; + } + } + await sleep(backoffMs(attempt)); + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +function isRetryableError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + if (err.message.includes('unauthorized')) return false; + if (err.message.includes('failed: 4')) return false; + return true; +} + +function backoffMs(attempt: number): number { + const override = numberFromEnv('WORKFORCE_DEPLOY_RETRY_BACKOFF_MS'); + return override ?? attempt * 500; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function emitLog(args: { + io: ModeLaunchInput['io']; + onLog?: ModeLaunchInput['onLog']; +}, line: string): void { + args.onLog?.(line); + args.io.info(line); +} + +function tryOpenBrowser(url: string): void { + const command = platform() === 'darwin' + ? 'open' + : platform() === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url]; + const child = spawn(command, args, { stdio: 'ignore', detached: true }); + child.on('error', () => { + // URL is printed; browser launch is best-effort. + }); + child.unref(); +} + +function pollTimeoutMs(): number { + return numberFromEnv('WORKFORCE_DEPLOY_POLL_TIMEOUT_MS') ?? POLL_TIMEOUT_MS; +} + +function pollIntervalMs(): number { + return numberFromEnv('WORKFORCE_DEPLOY_POLL_INTERVAL_MS') ?? POLL_INTERVAL_MS; +} + +function numberFromEnv(name: string): number | undefined { + const raw = process.env[name]?.trim(); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +async function responseExcerpt(res: Response): Promise { + const text = await res.text().catch(() => ''); + return text.trim().slice(0, 500); +} + +function expectString(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`cloud deploy response missing ${field}`); + } + return value; +} + +function expectStatus(value: unknown): CloudDeployStatus { + if (value === 'starting' || value === 'active' || value === 'failed' || value === 'cancelled') { + return value; + } + throw new Error(`cloud deploy response has unknown status "${String(value)}"`); +} diff --git a/packages/deploy/src/modes/sandbox.ts b/packages/deploy/src/modes/sandbox.ts index 56d7f47a..2b5d331b 100644 --- a/packages/deploy/src/modes/sandbox.ts +++ b/packages/deploy/src/modes/sandbox.ts @@ -106,7 +106,7 @@ export const sandboxLauncher: ModeLauncher = { * deploy orchestrator (and tests) can plug in an explicit choice. */ export function resolveSandboxClient( - input: Pick & Partial>, + input: Pick & Partial>, overrides: { /** Force BYO even when both BYO and workforce-managed are configured. */ forceByo?: boolean; @@ -134,13 +134,13 @@ export function resolveSandboxClient( }); } - const workspaceToken = process.env.WORKFORCE_WORKSPACE_TOKEN?.trim(); + const workspaceToken = input.workspaceToken?.trim() || process.env.WORKFORCE_WORKSPACE_TOKEN?.trim(); if (!workspaceToken) { throw new Error( 'sandbox launcher: no Daytona credentials and no workforce workspace token. Either export DAYTONA_API_KEY, or run `workforce login` (sets WORKFORCE_WORKSPACE_TOKEN) so we can mint a workforce-managed sandbox.' ); } - const cloudUrl = (process.env.WORKFORCE_CLOUD_URL?.trim() || DEFAULT_CLOUD_URL).replace(/\/$/, ''); + const cloudUrl = (input.cloudUrl?.trim() || process.env.WORKFORCE_CLOUD_URL?.trim() || DEFAULT_CLOUD_URL).replace(/\/$/, ''); return createProxySandboxClient({ cloudUrl, workspaceId: input.workspace, diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index 64643fa3..4a1871d0 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -21,6 +21,18 @@ export interface DeployOptions { dryRun?: boolean; /** Override the WORKFORCE_CLOUD_URL; defaults to env or production. */ cloudUrl?: string; + /** Fail instead of prompting for cloud auth/integration setup. */ + noPrompt?: boolean; + /** Cloud harness credential source. */ + harnessSource?: 'plan' | 'byok' | 'oauth'; + /** BYOK API key used when `harnessSource` is `byok`. */ + byokKey?: string; + /** Existing cloud persona behavior. Defaults to `cancel`. */ + onExists?: 'update' | 'destroy' | 'cancel'; + /** Runtime inputs forwarded to hosted cloud deployments. */ + inputs?: Record; + /** Runtime log streaming hook. */ + onLog?: (line: string) => void; /** Override stdout writer for tests + structured outputs. */ io?: DeployIO; } @@ -106,6 +118,22 @@ export interface ModeLaunchInput { * cloud. Mode-specific (sandbox launcher only); other modes ignore. */ byoSandbox?: boolean; + /** Workspace-scoped auth token resolved by the deploy orchestrator. */ + workspaceToken?: string; + /** Cloud base URL override. */ + cloudUrl?: string; + /** Fail instead of prompting for cloud setup. */ + noPrompt?: boolean; + /** Cloud harness credential source. */ + harnessSource?: 'plan' | 'byok' | 'oauth'; + /** BYOK API key used when `harnessSource` is `byok`. */ + byokKey?: string; + /** Existing cloud persona behavior. Defaults to `cancel`. */ + onExists?: 'update' | 'destroy' | 'cancel'; + /** Runtime inputs forwarded to launchers that support them. */ + inputs?: Record; + /** Runtime log streaming hook. */ + onLog?: (line: string) => void; } export interface ModeLaunchHandle { From 0a9d8938a958817cec9c2f2c97cf7f8125437e92 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 08:30:10 +0200 Subject: [PATCH 2/3] fix(deploy): address PR review feedback on cloud mode - login: skip keychain entry when stored workspace mismatches instead of returning the raw JSON blob as a bearer token (devin-ai) - cloud: wrap WORKFORCE_DEPLOY_INPUTS_JSON parse in try/catch for a clearer error message on malformed JSON (coderabbit) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/login.ts | 1 + packages/deploy/src/modes/cloud.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index 73783711..437f8e0b 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -241,6 +241,7 @@ async function readMacKeychainLogin(workspace: string): Promise | undefined { const raw = process.env.WORKFORCE_DEPLOY_INPUTS_JSON?.trim(); if (!raw) return undefined; - const parsed = JSON.parse(raw) as unknown; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error('WORKFORCE_DEPLOY_INPUTS_JSON is not valid JSON'); + } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('WORKFORCE_DEPLOY_INPUTS_JSON must be a JSON object'); } From b041df84282443f5f78c6add937d0f4a5aac4a6f Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 11:27:29 +0200 Subject: [PATCH 3/3] fix(persona-kit/schema): mirror parser constraints on workspace_service_account.name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (cloud#102 review) flagged that the generated JSON schema accepted empty strings for `integrations..source.name` when `kind === "workspace_service_account"` — even though parse.ts has rejected those at runtime since the source-discriminator landed. The generator emits a bare `{ "type": "string" }` because the constraints live in the parser (INTEGRATION_SOURCE_NAME_RE, max 64) and the TS source type is a union literal whose `name` field has no annotatable position for ts-json-schema-generator to pick up. Mirror the three parser rules at the schema layer via a post-process walk in emit-schema.mjs: "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } Now schema-validation rejects malformed names with the same precision the parser does, instead of pushing the failure to deploy-time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../persona-kit/schemas/persona.schema.json | 5 ++- packages/persona-kit/scripts/emit-schema.mjs | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 782eac40..b0249892 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -452,7 +452,10 @@ "const": "workspace_service_account" }, "name": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } }, "required": [ diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index b5612352..73533adb 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -46,6 +46,40 @@ if (personaSpecSchema) { ]; } +// Post-process: walk the schema and tighten the workspace_service_account.name +// constraint to match parse.ts (INTEGRATION_SOURCE_NAME_RE, max 64). The generator +// emits a bare `{ "type": "string" }` because the constraints live in the parser, +// not in the TS type. Without this, the schema accepts `""` which the parser then +// rejects at deploy time — better to fail at validation. +const SOURCE_NAME_PATTERN = '^[a-z0-9]+(?:-[a-z0-9]+)*$'; +const SOURCE_NAME_MAX = 64; +function tightenWorkspaceServiceAccountName(node) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) tightenWorkspaceServiceAccountName(child); + return; + } + // Match the object-literal variant: { kind: { const: 'workspace_service_account' }, name: { type: 'string' } } + const props = node.properties; + if ( + node.type === 'object' && + props && + props.kind && + props.kind.const === 'workspace_service_account' && + props.name && + props.name.type === 'string' + ) { + props.name = { + ...props.name, + minLength: 1, + maxLength: SOURCE_NAME_MAX, + pattern: SOURCE_NAME_PATTERN + }; + } + for (const value of Object.values(node)) tightenWorkspaceServiceAccountName(value); +} +tightenWorkspaceServiceAccountName(schema); + const serialized = `${JSON.stringify(schema, null, 2)}\n`; await mkdir(dirname(schemaPath), { recursive: true });