From 66ac035a5e9e65017890413988b1caf8df2c7e42 Mon Sep 17 00:00:00 2001 From: Multi-Repo Pushback Bot Date: Mon, 27 Apr 2026 22:29:45 -0700 Subject: [PATCH 1/2] feat(cloud): expose connectProvider() in @agent-relay/cloud SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the Daytona-brokered provider auth flow out of the relay CLI and into @agent-relay/cloud so other CLIs (ricky, future tools) can drive it directly via `import { connectProvider } from '@agent-relay/cloud'` instead of shelling out to `agent-relay cloud connect`. - New exports: connectProvider, getProviderHelpText, normalizeProvider, runInteractiveSession, formatShellInvocation, wrapWithLaunchCheckpoint, loadSSH2, createAskpassScript, buildSystemSshArgs, plus SSH-related types. - ssh2 is now an optionalDependency of @agent-relay/cloud — the package falls back to system ssh when ssh2 isn't installed (existing behavior, just newly exposed to consumers). - Cloud SDK bumped 6.0.2 → 6.1.0. - The `agent-relay cloud connect` command body shrinks from ~150 LOC to a thin wrapper around connectProvider() that keeps telemetry attribution. - src/cli/lib/{ssh-interactive,auth-ssh}.ts now re-export the SDK helpers (rather than redefining them) so the existing `auth` command path is unchanged. ssh-interactive.test.ts moved to packages/cloud alongside the implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 36 +- package-lock.json | 8 +- package.json | 2 +- packages/cloud/package.json | 6 +- packages/cloud/src/connect.test.ts | 27 + packages/cloud/src/connect.ts | 226 ++++++++ packages/cloud/src/index.ts | 34 +- .../cloud/src}/lib/ssh-interactive.test.ts | 0 packages/cloud/src/lib/ssh-interactive.ts | 514 +++++++++++++++++ packages/cloud/src/lib/ssh-runtime.ts | 76 +++ src/cli/commands/cloud.ts | 189 +------ src/cli/lib/auth-ssh.ts | 73 +-- src/cli/lib/ssh-interactive.ts | 521 +----------------- 13 files changed, 951 insertions(+), 761 deletions(-) create mode 100644 packages/cloud/src/connect.test.ts create mode 100644 packages/cloud/src/connect.ts rename {src/cli => packages/cloud/src}/lib/ssh-interactive.test.ts (100%) create mode 100644 packages/cloud/src/lib/ssh-interactive.ts create mode 100644 packages/cloud/src/lib/ssh-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afecfcd1..1001bd142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`@agent-relay/cloud` provider connect SDK** (6.1.0): Exposes `connectProvider()`, `runInteractiveSession()`, and SSH runtime helpers (`loadSSH2`, `createAskpassScript`, `buildSystemSshArgs`) so other CLIs can drive the same Daytona-brokered provider auth flow that powers `agent-relay cloud connect`. `ssh2` is now an `optionalDependency` of the cloud package. - **CLI SSH Authentication**: New SSH-based authentication for CLI local auth workflows, enabling secure agent spawning and communication (#648e7782). - **Multi-Repository Spawning**: Agents can now be spawned across multiple repositories in a single operation, improving orchestration flexibility (#2d2bf610). - **Model Hotswap**: Runtime model switching for agents, allowing dynamic provider and model changes without restart (#5a80bdc0). @@ -39,12 +40,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.0.2] - 2026-04-25 ### Product Perspective + #### User-Impacting Fixes + - Drop darwin-x64 verify leg (macos-13 queue stuck again) - Re-add @agent-relay/cloud to publish-packages matrix (#788) ### Technical Perspective + #### Releases + - v6.0.2 --- @@ -52,24 +57,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.0.1] - 2026-04-25 ### Product Perspective + #### Breaking Changes -- **Drop legacy agent-relay/broker* exports and shipped workspace dirs** + +- **Drop legacy agent-relay/broker\* exports and shipped workspace dirs** #### User-Facing Features & Improvements -- **Restore agent-relay/* subpath exports via shim re-exports** + +- **Restore agent-relay/\* subpath exports via shim re-exports** #### User-Impacting Fixes + - Drop dead linkResult reference - Allow shipped workspace packages declared as regular deps -- Unbundle @agent-relay/* to restore optional-dep broker resolution +- Unbundle @agent-relay/\* to restore optional-dep broker resolution - Walk ancestor node_modules for shadowed broker packages - Install broker optional-deps for CLI users ### Technical Perspective + #### Performance & Reliability + - Fix stale broker checks and PyPI retry #### Releases + - v6.0.1 --- @@ -77,11 +89,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [6.0.0] - 2026-04-24 ### Product Perspective + #### User-Facing Features & Improvements + - **ApplySiblingLinks — link sibling-repo packages during workflow setup (#776)** (#776) - **Split broker binaries into per-platform optional-dep packages** (#770) #### User-Impacting Fixes + - Keep SIGWINCH on unix, background-thread poll on Windows - Unbreak Windows build - Convert rewrites to direct redirects @@ -91,16 +106,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keep broker packages as workspaces so npm ci passes ### Technical Perspective + #### Performance & Reliability + - Drop darwin-x64 smoke test - Cross-platform post-publish verification of @agent-relay/sdk -- Skip dist check for broker-* packages in package-validation +- Skip dist check for broker-\* packages in package-validation - Add cross-platform smoke test for broker optional-deps #### Dependencies & Tooling + - Update Cursor models to latest (#777) (#777) #### Releases + - v6.0.0 --- @@ -108,16 +127,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.0.0] - 2026-04-22 ### Product Perspective + #### User-Impacting Fixes + - Repair pre-existing test failures on main - Address Copilot review on broker resolution (#769) - Ship per-platform wheels with embedded broker (drop runtime download) (#769) ### Technical Perspective + #### Performance & Reliability + - Include publish-sdk-py in summary job #### Releases + - v5.0.0 --- @@ -125,11 +149,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [4.0.40] - 2026-04-22 ### Product Perspective + #### User-Facing Features & Improvements + - **Add browser and github workflow primitives (#718)** (#718) ### Technical Perspective + #### Releases + - v4.0.40 --- diff --git a/package-lock.json b/package-lock.json index ce8bca476..4a7d526bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "web" ], "dependencies": { - "@agent-relay/cloud": "6.0.2", + "@agent-relay/cloud": "6.1.0", "@agent-relay/config": "6.0.2", "@agent-relay/hooks": "6.0.2", "@agent-relay/sdk": "6.0.2", @@ -15488,7 +15488,7 @@ }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "6.0.2", + "version": "6.1.0", "dependencies": { "@agent-relay/config": "6.0.2", "@aws-sdk/client-s3": "3.1020.0", @@ -15497,7 +15497,11 @@ }, "devDependencies": { "@types/node": "^22.19.3", + "@types/ssh2": "^1.15.5", "vitest": "^3.2.4" + }, + "optionalDependencies": { + "ssh2": "^1.17.0" } }, "packages/config": { diff --git a/package.json b/package.json index 052be33d1..7fb499f13 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ }, "homepage": "https://github.com/AgentWorkforce/relay#readme", "dependencies": { - "@agent-relay/cloud": "6.0.2", + "@agent-relay/cloud": "6.1.0", "@agent-relay/config": "6.0.2", "@agent-relay/hooks": "6.0.2", "@agent-relay/sdk": "6.0.2", diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 42a81d9bf..04d7e9586 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@agent-relay/cloud", - "version": "6.0.2", + "version": "6.1.0", "description": "Cloud SDK for Agent Relay — auth, workflow execution, and provider connections", "type": "module", "main": "dist/index.js", @@ -28,8 +28,12 @@ "ignore": "^7.0.5", "tar": "^7.5.10" }, + "optionalDependencies": { + "ssh2": "^1.17.0" + }, "devDependencies": { "@types/node": "^22.19.3", + "@types/ssh2": "^1.15.5", "vitest": "^3.2.4" }, "publishConfig": { diff --git a/packages/cloud/src/connect.test.ts b/packages/cloud/src/connect.test.ts new file mode 100644 index 000000000..5f9a7f4cd --- /dev/null +++ b/packages/cloud/src/connect.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getProviderHelpText, normalizeProvider } from './connect.js'; + +describe('normalizeProvider', () => { + it('maps friendly aliases to canonical provider ids', () => { + expect(normalizeProvider('claude')).toBe('anthropic'); + expect(normalizeProvider('codex')).toBe('openai'); + expect(normalizeProvider('gemini')).toBe('google'); + }); + + it('lowercases and trims unknown values without rewriting them', () => { + expect(normalizeProvider(' Anthropic ')).toBe('anthropic'); + expect(normalizeProvider('OpenAI')).toBe('openai'); + expect(normalizeProvider('something-new')).toBe('something-new'); + }); +}); + +describe('getProviderHelpText', () => { + it('lists known providers with their CLI aliases', () => { + const help = getProviderHelpText(); + + expect(help).toContain('anthropic (alias: claude)'); + expect(help).toContain('openai (alias: codex)'); + expect(help).toContain('google (alias: gemini)'); + }); +}); diff --git a/packages/cloud/src/connect.ts b/packages/cloud/src/connect.ts new file mode 100644 index 000000000..f5abdd338 --- /dev/null +++ b/packages/cloud/src/connect.ts @@ -0,0 +1,226 @@ +/** + * Provider connect orchestration — provisions a Daytona sandbox via the + * Cloud API, opens an interactive SSH session that runs the provider CLI, + * and finalizes the auth state with Cloud. + * + * The CLI command in `agent-relay cloud connect ` is a thin wrapper + * around this function; other tools (e.g. `ricky connect `) can + * import it directly and drive the same flow. + */ + +import { CLI_AUTH_CONFIG } from '@agent-relay/config/cli-auth-config'; + +import { ensureAuthenticated, authorizedApiFetch } from './auth.js'; +import { defaultApiUrl, type AuthSessionResponse } from './types.js'; +import { runInteractiveSession } from './lib/ssh-interactive.js'; +import type { AuthSshRuntime } from './lib/ssh-runtime.js'; + +const PROVIDER_ALIASES: Record = { + claude: 'anthropic', + codex: 'openai', + gemini: 'google', +}; + +export function getProviderHelpText(): string { + return Object.keys(CLI_AUTH_CONFIG) + .sort() + .map((id) => { + const alias = Object.entries(PROVIDER_ALIASES).find(([, target]) => target === id); + return alias ? `${id} (alias: ${alias[0]})` : id; + }) + .join(', '); +} + +export function normalizeProvider(providerArg: string): string { + const providerInput = providerArg.toLowerCase().trim(); + return PROVIDER_ALIASES[providerInput] || providerInput; +} + +export interface ConnectProviderIo { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +export interface ConnectProviderOptions { + /** Provider id or alias (`anthropic`/`claude`, `openai`/`codex`, `google`/`gemini`, …). */ + provider: string; + /** Override the Cloud API URL. Defaults to `defaultApiUrl()`. */ + apiUrl?: string; + /** Sandbox language/image. Defaults to `'typescript'`. */ + language?: string; + /** Auth timeout in milliseconds. Defaults to 5 minutes. */ + timeoutMs?: number; + /** Logger sink. Defaults to `console.log` / `console.error`. */ + io?: ConnectProviderIo; + /** Override SSH/network runtime hooks (used in tests). */ + runtime?: Partial; +} + +export interface ConnectProviderResult { + /** Normalized provider id used for the request. */ + provider: string; + /** Whether the interactive session reported a positive auth pattern match. */ + success: boolean; +} + +const color = { + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, +}; + +const DEFAULT_IO: ConnectProviderIo = { + log: (...args: unknown[]) => console.log(...args), + error: (...args: unknown[]) => console.error(...args), +}; + +async function getErrorDetails(response: Response): Promise { + let body: string; + try { + body = await response.text(); + } catch { + return response.statusText; + } + if (!body) return response.statusText; + try { + const json = JSON.parse(body) as { error?: string; message?: string }; + return json.error || json.message || response.statusText; + } catch { + return body; + } +} + +/** + * Connect a provider via interactive SSH session. + * + * Throws on any failure. Returns `{ provider, success }` when the auth flow + * completed successfully — `success` is always `true` on resolved promises; + * an unsuccessful auth attempt rejects with a descriptive Error. + */ +export async function connectProvider(options: ConnectProviderOptions): Promise { + const io = options.io ?? DEFAULT_IO; + const language = options.language ?? 'typescript'; + const timeoutMs = options.timeoutMs ?? 300_000; + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('connectProvider requires an interactive terminal (TTY).'); + } + + const provider = normalizeProvider(options.provider); + const providerConfig = CLI_AUTH_CONFIG[provider]; + if (!providerConfig) { + const known = Object.keys(CLI_AUTH_CONFIG).sort(); + throw new Error(`Unknown provider: ${options.provider}. Supported providers: ${known.join(', ')}`); + } + + const apiUrl = options.apiUrl || defaultApiUrl(); + + io.log(''); + io.log(color.cyan('═══════════════════════════════════════════════════')); + io.log(color.cyan(' Provider Authentication (Daytona Connect)')); + io.log(color.cyan('═══════════════════════════════════════════════════')); + io.log(''); + io.log(`Provider: ${providerConfig.displayName} (${provider})`); + io.log(`Language: ${color.dim(language)}`); + io.log(color.dim(`Cloud: ${apiUrl}`)); + io.log(''); + io.log('Requesting sandbox from cloud...'); + + let auth = await ensureAuthenticated(apiUrl); + + const { response: createResponse, auth: refreshedAuth } = await authorizedApiFetch( + auth, + '/api/v1/cli/auth', + { + method: 'POST', + body: JSON.stringify({ provider, language }), + } + ); + auth = refreshedAuth; + + const start = (await createResponse.json().catch(() => null)) as + | (AuthSessionResponse & { error?: string; message?: string }) + | null; + + if (!createResponse.ok || !start?.sessionId) { + const detail = start?.error || start?.message || `${createResponse.status} ${createResponse.statusText}`; + throw new Error(detail); + } + + const sshPort = + typeof start.ssh?.port === 'string' + ? Number.parseInt(start.ssh.port as unknown as string, 10) + : start.ssh?.port; + if (!start.ssh?.host || !sshPort || !start.ssh.user || !start.ssh.password) { + throw new Error('Cloud returned invalid SSH session details.'); + } + + io.log(color.green('✓ Sandbox ready')); + io.log(color.dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`)); + io.log(''); + io.log(color.yellow('Connecting via SSH...')); + io.log(color.dim(` Running: ${start.remoteCommand}`)); + io.log(''); + + let sessionResult; + try { + sessionResult = await runInteractiveSession({ + ssh: { + host: start.ssh.host, + port: sshPort, + user: start.ssh.user, + password: start.ssh.password, + }, + remoteCommand: start.remoteCommand, + successPatterns: providerConfig.successPatterns || [], + errorPatterns: providerConfig.errorPatterns || [], + timeoutMs, + io, + runtime: options.runtime, + }); + } catch (error) { + throw new Error(`Failed to connect via SSH: ${error instanceof Error ? error.message : String(error)}`); + } + + io.log(''); + const authSuccess = sessionResult.authDetected; + + io.log('Finalizing authentication with cloud...'); + const { response: completeResponse } = await authorizedApiFetch(auth, '/api/v1/cli/auth/complete', { + method: 'POST', + body: JSON.stringify({ sessionId: start.sessionId, success: authSuccess }), + }); + + if (!completeResponse.ok) { + throw new Error(await getErrorDetails(completeResponse)); + } + + if (!authSuccess) { + const exitCode = sessionResult.exitCode; + if (typeof exitCode === 'number' && exitCode !== 0) { + io.error(color.red(`Remote auth command exited with code ${exitCode}.`)); + } + if (sessionResult.exitCode === 127) { + io.log( + color.yellow( + `The ${providerConfig.displayName} CLI ("${providerConfig.command}") is not installed on the sandbox.` + ) + ); + io.log(color.dim('Check the sandbox snapshot includes the required CLI tools.')); + } + throw new Error(`Provider auth for ${provider} did not complete successfully`); + } + + io.log(''); + io.log(color.green('═══════════════════════════════════════════════════')); + io.log(color.green(' Authentication Complete!')); + io.log(color.green('═══════════════════════════════════════════════════')); + io.log(''); + io.log(`${providerConfig.displayName} credentials are now stored and encrypted.`); + io.log(color.dim('Your workflows will automatically use these credentials.')); + io.log(''); + + return { provider, success: true }; +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 871c75df2..ba4c9121e 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -5,14 +5,14 @@ export { refreshStoredAuth, ensureAuthenticated, authorizedApiFetch, -} from "./auth.js"; +} from './auth.js'; export { CloudApiClient, buildApiUrl, type CloudApiClientOptions, type CloudApiClientSnapshot, -} from "./api-client.js"; +} from './api-client.js'; export { runWorkflow, @@ -23,7 +23,33 @@ export { resolveWorkflowInput, inferWorkflowFileType, shouldSyncCodeByDefault, -} from "./workflows.js"; +} from './workflows.js'; + +export { + connectProvider, + getProviderHelpText, + normalizeProvider, + type ConnectProviderIo, + type ConnectProviderOptions, + type ConnectProviderResult, +} from './connect.js'; + +export { + runInteractiveSession, + formatShellInvocation, + wrapWithLaunchCheckpoint, + type SshConnectionInfo, + type InteractiveSessionOptions, + type InteractiveSessionResult, +} from './lib/ssh-interactive.js'; + +export { + loadSSH2, + createAskpassScript, + buildSystemSshArgs, + DEFAULT_SSH_RUNTIME, + type AuthSshRuntime, +} from './lib/ssh-runtime.js'; export { type StoredAuth, @@ -38,4 +64,4 @@ export { AUTH_FILE_PATH, defaultApiUrl, isSupportedProvider, -} from "./types.js"; +} from './types.js'; diff --git a/src/cli/lib/ssh-interactive.test.ts b/packages/cloud/src/lib/ssh-interactive.test.ts similarity index 100% rename from src/cli/lib/ssh-interactive.test.ts rename to packages/cloud/src/lib/ssh-interactive.test.ts diff --git a/packages/cloud/src/lib/ssh-interactive.ts b/packages/cloud/src/lib/ssh-interactive.ts new file mode 100644 index 000000000..2975a3120 --- /dev/null +++ b/packages/cloud/src/lib/ssh-interactive.ts @@ -0,0 +1,514 @@ +/** + * SSH Interactive Session — reusable SSH+PTY runner. + * + * Powers both `agent-relay auth ` and `agent-relay cloud connect `, + * and is published from `@agent-relay/cloud` so other CLIs can drive the same flow. + */ + +import { createServer } from 'node:net'; +import { spawn as spawnProcess } from 'node:child_process'; +import { stripAnsiCodes, findMatchingError, type ErrorPattern } from '@agent-relay/config/cli-auth-config'; +import { loadSSH2, createAskpassScript, buildSystemSshArgs, type AuthSshRuntime } from './ssh-runtime.js'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface SshConnectionInfo { + host: string; + port: number; + user: string; + password: string; +} + +export interface InteractiveSessionOptions { + ssh: SshConnectionInfo; + remoteCommand: string; + successPatterns: RegExp[]; + errorPatterns: ErrorPattern[]; + timeoutMs: number; + io: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; + tunnelPort?: number; + runtime?: Partial; +} + +export interface InteractiveSessionResult { + exitCode: number | null; + exitSignal: string | null; + authDetected: boolean; +} + +// ── Debug (env-gated) ──────────────────────────────────────────────────────── + +const DEBUG = process.env.AGENT_RELAY_DEBUG_SSH === '1'; +function dbg(event: string, fields: Record = {}): void { + if (!DEBUG) return; + const ts = new Date().toISOString(); + const parts = Object.entries(fields) + .map(([k, v]) => `${k}=${typeof v === 'string' ? JSON.stringify(v) : v}`) + .join(' '); + process.stderr.write(`[ssh-debug ${ts}] ${event}${parts ? ' ' + parts : ''}\n`); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const color = { + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, +}; + +function getSshErrorMessage(host: string, port: number, err: Error): string { + if (err.message.includes('Authentication')) { + return 'SSH authentication failed.'; + } + if (err.message.includes('ECONNREFUSED')) { + return `Cannot connect to SSH server at ${host}:${port}. Is the workspace running and SSH enabled?`; + } + if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) { + return `Cannot resolve hostname: ${host}. Check network connectivity.`; + } + if (err.message.includes('ETIMEDOUT')) { + return `Connection timed out to ${host}:${port}. Is the workspace running?`; + } + return `SSH error: ${err.message}`; +} + +// ── Main function ──────────────────────────────────────────────────────────── + +const DEFAULT_RUNTIME: Pick< + AuthSshRuntime, + 'loadSSH2' | 'createAskpassScript' | 'buildSystemSshArgs' | 'spawnProcess' | 'createServer' | 'setTimeout' +> = { + loadSSH2, + createAskpassScript, + buildSystemSshArgs, + spawnProcess, + createServer, + setTimeout, +}; + +/** + * Format a remote command for execution inside an ssh2 shell() PTY. + * + * Wraps the command in `exec sh -c '…'` so the PTY closes cleanly when the + * target CLI exits (no shell-teardown race with a TUI's alt-screen flush) + * while still letting `sh` parse leading prefix assignments like + * `PATH=/foo/bin claude`. A bare `exec PATH=… claude` does not work in zsh + * because zsh's exec builtin treats `PATH=…` as the command name instead of + * a prefix assignment. + * + * We intentionally use `shell()` rather than `exec(cmd, { pty })` because + * Daytona's sandbox sshd only populates the full login-shell environment + * (including nvm-managed PATH entries where `claude` / `codex` actually live) + * for interactive shell sessions. An `exec` channel with a PTY gets a + * stripped-down environment and the target CLI fails to start silently. + */ +export function formatShellInvocation(command: string): string { + const escaped = command.replace(/'/g, `'\\''`); + return `exec sh -c '${escaped}'\n`; +} + +/** + * Wrap the remote command with a visible checkpoint so the user sees proof + * the ssh pipeline reached the sandbox before the provider CLI takes over + * the terminal. Without this, claude/codex enter alt-screen immediately and + * the user sees zero output — indistinguishable from a hang. + * + * The printf runs before the exec that launches the provider CLI, so the + * user gets one visible line ("launching provider CLI…") right before + * alt-screen engages. When the provider CLI later exits and the alt-screen + * tears down, this line remains in scrollback as a breadcrumb. + */ +export function wrapWithLaunchCheckpoint(command: string): string { + // Escape single quotes for inclusion in the printf argument. + return `printf '\\033[2m[agent-relay] launching provider CLI…\\033[0m\\n' >&2; ${command}`; +} + +/** + * Run an interactive SSH session with PTY. + * + * Connects via ssh2 (if available) or falls back to system ssh, + * sets up a local port tunnel, and runs the remote command in a PTY. + * Monitors output for success/error patterns. + */ +export async function runInteractiveSession( + options: InteractiveSessionOptions +): Promise { + const { ssh, successPatterns, errorPatterns, timeoutMs, io, tunnelPort = 1455 } = options; + + const runtime = { ...DEFAULT_RUNTIME, ...options.runtime }; + + // Wrap the remote command with a visible checkpoint so the user sees proof + // the ssh pipeline is alive before the provider CLI enters alt-screen. + const remoteCommand = wrapWithLaunchCheckpoint(options.remoteCommand); + + const ssh2 = await runtime.loadSSH2(); + + io.log(color.yellow('Starting interactive authentication...')); + io.log(color.dim(`Transport: ${ssh2 ? 'ssh2 (bundled)' : 'system ssh (fallback)'}`)); + io.log(color.dim('The provider CLI may take 5-15s to render its first screen after connecting.')); + io.log( + color.dim('A welcome / theme picker may appear before the sign-in step. Follow the on-screen prompts.') + ); + io.log(color.dim('Wait for the CLI to render before pressing Ctrl+C.')); + io.log(''); + + let execResult: InteractiveSessionResult | null = null; + let execError: Error | null = null; + + if (ssh2) { + const { Client } = ssh2; + const sshClient = new Client(); + let sshReady = false; + const tunnel: { server: ReturnType | null } = { server: null }; + + const sshReadyPromise = new Promise((resolve, reject) => { + sshClient.on('ready', () => { + sshReady = true; + + tunnel.server = runtime.createServer((localSocket) => { + sshClient.forwardOut('127.0.0.1', tunnelPort, 'localhost', tunnelPort, (err, stream) => { + if (err) { + localSocket.end(); + return; + } + localSocket.pipe(stream).pipe(localSocket); + }); + }); + + tunnel.server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + io.log(color.dim(`Note: Port ${tunnelPort} in use, OAuth callbacks may not work.`)); + } + resolve(); + }); + + tunnel.server.listen(tunnelPort, '127.0.0.1', () => { + resolve(); + }); + }); + + sshClient.on('error', (err) => { + reject(new Error(getSshErrorMessage(ssh.host, ssh.port, err))); + }); + + sshClient.on('close', () => { + if (!sshReady) { + reject(new Error(`SSH connection to ${ssh.host}:${ssh.port} closed unexpectedly.`)); + } + }); + }); + + try { + sshClient.connect({ + host: ssh.host, + port: ssh.port, + username: ssh.user, + password: ssh.password, + readyTimeout: 10000, + hostVerifier: () => true, + }); + + await Promise.race([ + sshReadyPromise, + new Promise((_, reject) => + runtime.setTimeout(() => reject(new Error('SSH connection timeout')), 15000) + ), + ]); + } catch (err) { + io.error(color.red(`Failed to connect via SSH: ${err instanceof Error ? err.message : String(err)}`)); + if (tunnel.server) tunnel.server.close(); + sshClient.end(); + throw err; + } + + const execInteractive = async (command: string, commandTimeoutMs: number) => + await new Promise((resolve, reject) => { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + const term = process.env.TERM || 'xterm-256color'; + + dbg('shell-request', { term, cols, rows }); + // Use shell() so the remote side sources its login-shell init files + // (/etc/profile, ~/.zprofile, nvm setup, …). Daytona's sandbox image + // populates the nvm-managed PATH (/usr/local/share/nvm/current/bin) + // from those init files, and without them the target CLIs (claude, + // codex) are not on PATH and fail to start silently. An exec channel + // with `{ pty }` was tried and produced zero output for this reason. + sshClient.shell({ term, cols, rows }, (err, stream) => { + if (err) { + dbg('shell-error', { message: err.message }); + return reject(err); + } + dbg('shell-opened'); + + let exitCode: number | null = null; + let exitSignal: string | null = null; + let authDetected = false; + let outputBuffer = ''; + // Gate pattern matching so shell MOTD (e.g. "Last logged in …") + // does not trigger the broad `/logged\s*in/i` success pattern + // before the target CLI has even started. + let patternMatchingEnabled = false; + // Track whether we've drawn the dim "waiting" hint so we can clear + // it the moment the remote CLI starts producing real output. + let hintVisible = false; + + const stdin = process.stdin; + const stdout = process.stdout; + const stderr = process.stderr; + + const wasRaw = (stdin as unknown as { isRaw?: boolean }).isRaw ?? false; + + const onStdinData = (data: Buffer) => { + if (authDetected && (data[0] === 0x1b || data[0] === 0x03)) { + cleanup(); + clearTimeout(timer); + try { + stream.close(); + } catch { + // ignore + } + return; + } + stream.write(data); + }; + + const cleanup = () => { + stdin.off('data', onStdinData); + stdout.off('resize', onResize); + try { + stdin.setRawMode?.(wasRaw); + } catch { + // ignore + } + stdin.pause(); + }; + + const closeOnAuthSuccess = () => { + authDetected = true; + stdout.write('\n'); + stdout.write(color.green(' ✓ Authentication successful!') + '\n'); + stdout.write(color.dim(' Press Escape or Ctrl+C to exit.') + '\n'); + stdout.write('\n'); + }; + + let totalBytes = 0; + let firstByteAt: number | null = null; + const sessionStart = Date.now(); + + stream.on('data', (data: Buffer) => { + totalBytes += data.length; + if (firstByteAt === null) { + firstByteAt = Date.now(); + dbg('first-byte', { + elapsedMs: firstByteAt - sessionStart, + bytes: data.length, + preview: data.toString('utf8').slice(0, 120), + }); + if (hintVisible) { + // Clear the dim "waiting" hint line before the remote CLI + // paints its own UI. \r moves to col 0, \x1b[2K clears the + // line, so the subsequent bytes (including any alt-screen + // switch) render from a known-clean state. + stdout.write('\r\x1b[2K'); + hintVisible = false; + } + } else if (DEBUG) { + dbg('data-out', { bytes: data.length, totalBytes }); + } + stdout.write(data); + + outputBuffer += data.toString(); + if (outputBuffer.length > 8192) { + outputBuffer = outputBuffer.slice(-8192); + } + + if (patternMatchingEnabled && !authDetected && successPatterns.length > 0) { + const clean = stripAnsiCodes(outputBuffer); + for (const pattern of successPatterns) { + if (pattern.test(clean)) { + closeOnAuthSuccess(); + break; + } + } + } + + if (patternMatchingEnabled && !authDetected && errorPatterns.length > 0) { + const matched = findMatchingError(outputBuffer, errorPatterns); + if (matched) { + clearTimeout(timer); + cleanup(); + try { + stream.close(); + } catch { + // ignore + } + reject(new Error(matched.message + (matched.hint ? ` ${matched.hint}` : ''))); + } + } + }); + + stream.stderr.on('data', (data: Buffer) => { + dbg('stderr-out', { bytes: data.length }); + stderr.write(data); + }); + + const onResize = () => { + try { + stream.setWindow(stdout.rows || 24, stdout.columns || 80, 0, 0); + } catch { + // ignore + } + }; + + stream.on('exit', (code: unknown, signal?: unknown) => { + dbg('stream-exit', { code, signal }); + if (typeof code === 'number') exitCode = code; + if (typeof signal === 'string') exitSignal = signal; + }); + + stream.on('close', () => { + dbg('stream-close', { + totalBytes, + firstByteAt: firstByteAt !== null ? firstByteAt - sessionStart : null, + exitCode, + exitSignal, + authDetected, + }); + clearTimeout(timer); + cleanup(); + if (totalBytes === 0 && !authDetected) { + io.log(''); + io.error( + color.red('No output received from the remote auth command before the session closed.') + ); + io.error( + color.dim( + ' This usually means the remote CLI failed to start. Re-run with AGENT_RELAY_DEBUG_SSH=1 for details.' + ) + ); + } + resolve({ exitCode, exitSignal, authDetected }); + }); + + stream.on('error', (streamErr: unknown) => { + dbg('stream-error', { + message: streamErr instanceof Error ? streamErr.message : String(streamErr), + }); + clearTimeout(timer); + cleanup(); + reject(streamErr instanceof Error ? streamErr : new Error(String(streamErr))); + }); + + stdout.on('resize', onResize); + stdin.on('data', onStdinData); + + try { + stdin.setRawMode?.(true); + } catch { + // ignore + } + stdin.resume(); + + const timer = runtime.setTimeout(() => { + cleanup(); + try { + stream.close(); + } catch { + // ignore + } + reject(new Error(`Authentication timed out after ${Math.floor(commandTimeoutMs / 1000)}s`)); + }, commandTimeoutMs); + + const invocation = formatShellInvocation(command); + dbg('shell-write', { bytes: invocation.length, preview: invocation.slice(0, 200) }); + stream.write(invocation); + // Reset the output buffer so pattern matching only considers output + // produced by the command we just wrote, not the shell's MOTD. + outputBuffer = ''; + patternMatchingEnabled = true; + + // Show a single-line dim hint so the user can see something is + // happening while the remote shell starts. As soon as the first + // byte comes back from the target CLI, we clear this line (see + // stream.on('data')) and hand the terminal over to the remote. + stdout.write(color.dim(' Waiting for provider CLI to launch…')); + hintVisible = true; + }); + }); + + try { + execResult = await execInteractive(remoteCommand, timeoutMs); + } catch (err) { + execError = err instanceof Error ? err : new Error(String(err)); + io.log(''); + io.error(color.red(`Remote auth command failed: ${execError.message}`)); + } finally { + if (tunnel.server) tunnel.server.close(); + sshClient.end(); + } + } else { + // Fallback: system ssh + const askpassPath = runtime.createAskpassScript(ssh.password); + try { + const sshArgs = runtime.buildSystemSshArgs({ + host: ssh.host, + port: ssh.port, + username: ssh.user, + localPort: tunnelPort, + remotePort: tunnelPort, + }); + sshArgs.push('-tt'); + sshArgs.push(`${ssh.user}@${ssh.host}`); + sshArgs.push(remoteCommand); + + const child = runtime.spawnProcess('ssh', sshArgs, { + stdio: 'inherit', + env: { + ...process.env, + SSH_ASKPASS: askpassPath, + SSH_ASKPASS_REQUIRE: 'force', + DISPLAY: process.env.DISPLAY || ':0', + }, + }); + + execResult = await new Promise((resolve) => { + child.on('exit', (code, signal) => { + resolve({ + exitCode: code, + exitSignal: signal ? String(signal) : null, + authDetected: code === 0, + }); + }); + child.on('error', (err) => { + io.error(color.red(`Failed to launch ssh: ${err.message}`)); + resolve({ exitCode: 1, exitSignal: null, authDetected: false }); + }); + }); + } catch (err) { + execError = err instanceof Error ? err : new Error(String(err)); + io.log(''); + io.error(color.red(`SSH error: ${execError.message}`)); + } finally { + try { + const fs = await import('node:fs'); + fs.unlinkSync(askpassPath); + } catch { + // ignore + } + } + } + + // Authentication is only considered successful when the interactive session + // reported a positive pattern match. A shell exit code of 0 is NOT trusted: + // zsh stays alive after a failed `exec` in interactive mode, and a user + // closing the session with Ctrl+D produces exit 0 even though nothing was + // authenticated. Callers currently always supply `successPatterns`. + return { + exitCode: execError ? 1 : (execResult?.exitCode ?? null), + exitSignal: execResult?.exitSignal ?? null, + authDetected: execError === null && execResult?.authDetected === true, + }; +} diff --git a/packages/cloud/src/lib/ssh-runtime.ts b/packages/cloud/src/lib/ssh-runtime.ts new file mode 100644 index 000000000..b367b924b --- /dev/null +++ b/packages/cloud/src/lib/ssh-runtime.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawn as spawnProcess } from 'node:child_process'; +import { createServer } from 'node:net'; + +export interface AuthSshRuntime { + fetch: typeof fetch; + loadSSH2: () => Promise; + createAskpassScript: (password: string) => string; + buildSystemSshArgs: (options: { + host: string; + port: number; + username: string; + localPort?: number; + remotePort?: number; + }) => string[]; + spawnProcess: typeof spawnProcess; + createServer: typeof createServer; + setTimeout: typeof setTimeout; +} + +export async function loadSSH2(): Promise { + try { + return await import('ssh2'); + } catch { + return null; + } +} + +/** + * Create a temporary SSH_ASKPASS helper script that echoes the given password. + * Returns the script path. Caller must clean up. + */ +export function createAskpassScript(password: string): string { + const askpassPath = path.join(tmpdir(), `ar-askpass-${process.pid}-${Date.now()}`); + const escaped = password.replace(/'/g, "'\"'\"'"); + fs.writeFileSync(askpassPath, `#!/bin/sh\nprintf '%s\\n' '${escaped}'\n`, { mode: 0o700 }); + return askpassPath; +} + +/** + * Build SSH args common to both auth and connect commands. + */ +export function buildSystemSshArgs(options: { + host: string; + port: number; + username: string; + localPort?: number; + remotePort?: number; +}): string[] { + const args = [ + '-o', + 'StrictHostKeyChecking=no', + '-o', + 'UserKnownHostsFile=/dev/null', + '-o', + 'LogLevel=ERROR', + '-p', + String(options.port), + ]; + if (options.localPort && options.remotePort) { + args.push('-L', `${options.localPort}:localhost:${options.remotePort}`); + } + return args; +} + +export const DEFAULT_SSH_RUNTIME: AuthSshRuntime = { + fetch: (input: Parameters[0], init?: Parameters[1]) => fetch(input, init), + loadSSH2, + createAskpassScript, + buildSystemSshArgs, + spawnProcess, + createServer, + setTimeout, +}; diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index 832ac491e..d933e2146 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Command, InvalidArgumentError } from 'commander'; -import { CLI_AUTH_CONFIG } from '@agent-relay/config/cli-auth-config'; import { track } from '@agent-relay/telemetry'; import { @@ -18,12 +17,13 @@ import { getRunLogs, syncWorkflowPatch, cancelWorkflow, + connectProvider, + getProviderHelpText, + normalizeProvider, type WhoAmIResponse, - type AuthSessionResponse, type WorkflowFileType, } from '@agent-relay/cloud'; -import { runInteractiveSession } from '../lib/ssh-interactive.js'; import { defaultExit } from '../lib/exit.js'; import { errorClassName } from '../lib/telemetry-helpers.js'; @@ -39,14 +39,6 @@ export interface CloudDependencies { // ── Helpers ────────────────────────────────────────────────────────────────── -const color = { - cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, - green: (s: string) => `\x1b[32m${s}\x1b[0m`, - yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, - red: (s: string) => `\x1b[31m${s}\x1b[0m`, - dim: (s: string) => `\x1b[2m${s}\x1b[0m`, -}; - function withDefaults(overrides: Partial = {}): CloudDependencies { return { log: (...args: unknown[]) => console.log(...args), @@ -56,25 +48,6 @@ function withDefaults(overrides: Partial = {}): CloudDependen }; } -const PROVIDER_ALIASES: Record = { - claude: 'anthropic', - codex: 'openai', - gemini: 'google', -}; - -const PROVIDER_HELP_TEXT = Object.keys(CLI_AUTH_CONFIG) - .sort() - .map((id) => { - const alias = Object.entries(PROVIDER_ALIASES).find(([, target]) => target === id); - return alias ? `${id} (alias: ${alias[0]})` : id; - }) - .join(', '); - -function normalizeProvider(providerArg: string): string { - const providerInput = providerArg.toLowerCase().trim(); - return PROVIDER_ALIASES[providerInput] || providerInput; -} - function parsePositiveInteger(value: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { @@ -102,22 +75,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function getErrorDetails(response: Response): Promise { - let body: string; - try { - body = await response.text(); - } catch { - return response.statusText; - } - if (!body) return response.statusText; - try { - const json = JSON.parse(body) as { error?: string; message?: string }; - return json.error || json.message || response.statusText; - } catch { - return body; - } -} - // ── Command registration ───────────────────────────────────────────────────── export function registerCloudCommands(program: Command, overrides: Partial = {}): void { @@ -269,7 +226,7 @@ export function registerCloudCommands(program: Command, overrides: Partial', `Provider to connect (${PROVIDER_HELP_TEXT})`) + .argument('', `Provider to connect (${getProviderHelpText()})`) .option('--api-url ', 'Cloud API base URL') .option('--language ', 'Sandbox language/image', 'typescript') .option('--timeout ', 'Connection timeout in seconds', parsePositiveInteger, 300) @@ -277,136 +234,16 @@ export function registerCloudCommands(program: Command, overrides: Partial null)) as - | (AuthSessionResponse & { error?: string; message?: string }) - | null; - - if (!createResponse.ok || !start?.sessionId) { - const detail = - start?.error || start?.message || `${createResponse.status} ${createResponse.statusText}`; - throw new Error(detail); - } - - const sshPort = - typeof start.ssh?.port === 'string' - ? Number.parseInt(start.ssh.port as unknown as string, 10) - : start.ssh?.port; - if (!start.ssh?.host || !sshPort || !start.ssh.user || !start.ssh.password) { - throw new Error('Cloud returned invalid SSH session details.'); - } - - io.log(color.green('✓ Sandbox ready')); - io.log(color.dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`)); - io.log(''); - io.log(color.yellow('Connecting via SSH...')); - io.log(color.dim(` Running: ${start.remoteCommand}`)); - io.log(''); - - let sessionResult; - try { - sessionResult = await runInteractiveSession({ - ssh: { - host: start.ssh.host, - port: sshPort, - user: start.ssh.user, - password: start.ssh.password, - }, - remoteCommand: start.remoteCommand, - successPatterns: providerConfig.successPatterns || [], - errorPatterns: providerConfig.errorPatterns || [], - timeoutMs, - io, - }); - } catch (error) { - throw new Error( - `Failed to connect via SSH: ${error instanceof Error ? error.message : String(error)}` - ); - } - - io.log(''); - const authSuccess = sessionResult.authDetected; - - io.log('Finalizing authentication with cloud...'); - const { response: completeResponse } = await authorizedApiFetch(auth, '/api/v1/cli/auth/complete', { - method: 'POST', - body: JSON.stringify({ sessionId: start.sessionId, success: authSuccess }), + const result = await connectProvider({ + provider: providerArg, + apiUrl: options.apiUrl, + language: options.language, + timeoutMs: options.timeout * 1000, + io: { log: deps.log, error: deps.error }, }); - - if (!completeResponse.ok) { - throw new Error(await getErrorDetails(completeResponse)); - } - - if (!authSuccess) { - const exitCode = sessionResult.exitCode; - if (typeof exitCode === 'number' && exitCode !== 0) { - io.error(color.red(`Remote auth command exited with code ${exitCode}.`)); - } - if (sessionResult.exitCode === 127) { - io.log( - color.yellow( - `The ${providerConfig.displayName} CLI ("${providerConfig.command}") is not installed on the sandbox.` - ) - ); - io.log(color.dim('Check the sandbox snapshot includes the required CLI tools.')); - } - throw new Error(`Provider auth for ${provider} did not complete successfully`); - } - - io.log(''); - io.log(color.green('═══════════════════════════════════════════════════')); - io.log(color.green(' Authentication Complete!')); - io.log(color.green('═══════════════════════════════════════════════════')); - io.log(''); - io.log(`${providerConfig.displayName} credentials are now stored and encrypted.`); - io.log(color.dim('Your workflows will automatically use these credentials.')); - io.log(''); - success = true; + success = result.success; } catch (err) { errorClass = errorClassName(err); throw err; @@ -415,7 +252,7 @@ export function registerCloudCommands(program: Command, overrides: Partial Promise; - createAskpassScript: (password: string) => string; - buildSystemSshArgs: (options: { - host: string; - port: number; - username: string; - localPort?: number; - remotePort?: number; - }) => string[]; - spawnProcess: typeof spawnProcess; - createServer: typeof createServer; - setTimeout: typeof setTimeout; -} - const DEFAULT_RUNTIME: AuthSshRuntime = { fetch: (input: Parameters[0], init?: Parameters[1]) => fetch(input, init), loadSSH2, @@ -82,51 +74,6 @@ function readCloudConfig(configPath: string): { apiKey?: string; cloudUrl?: stri return JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { apiKey?: string; cloudUrl?: string }; } -export async function loadSSH2(): Promise { - try { - return await import('ssh2'); - } catch { - return null; - } -} - -/** - * Create a temporary SSH_ASKPASS helper script that echoes the given password. - * Returns the script path. Caller must clean up. - */ -export function createAskpassScript(password: string): string { - const askpassPath = path.join(tmpdir(), `ar-askpass-${process.pid}-${Date.now()}`); - const escaped = password.replace(/'/g, "'\"'\"'"); - fs.writeFileSync(askpassPath, `#!/bin/sh\nprintf '%s\\n' '${escaped}'\n`, { mode: 0o700 }); - return askpassPath; -} - -/** - * Build SSH args common to both auth and connect commands. - */ -export function buildSystemSshArgs(options: { - host: string; - port: number; - username: string; - localPort?: number; - remotePort?: number; -}): string[] { - const args = [ - '-o', - 'StrictHostKeyChecking=no', - '-o', - 'UserKnownHostsFile=/dev/null', - '-o', - 'LogLevel=ERROR', - '-p', - String(options.port), - ]; - if (options.localPort && options.remotePort) { - args.push('-L', `${options.localPort}:localhost:${options.remotePort}`); - } - return args; -} - function normalizeProvider(providerArg: string): string { const providerInput = providerArg.toLowerCase().trim(); const providerMap: Record = { diff --git a/src/cli/lib/ssh-interactive.ts b/src/cli/lib/ssh-interactive.ts index e05eefef1..0186f533e 100644 --- a/src/cli/lib/ssh-interactive.ts +++ b/src/cli/lib/ssh-interactive.ts @@ -1,514 +1,15 @@ /** - * SSH Interactive Session — Reusable SSH+PTY logic. + * Re-exports the SSH interactive session helpers from `@agent-relay/cloud`. * - * Extracted from auth-ssh.ts so it can be shared between the `auth` command - * (cloud API broker) and the `connect` command (direct Daytona broker). + * The implementation lives in the cloud SDK so external CLIs can import it + * directly. This module exists to keep existing relative imports stable. */ -import { createServer } from 'node:net'; -import { spawn as spawnProcess } from 'node:child_process'; -import { stripAnsiCodes, findMatchingError, type ErrorPattern } from '@agent-relay/config/cli-auth-config'; -import { loadSSH2, createAskpassScript, buildSystemSshArgs, type AuthSshRuntime } from './auth-ssh.js'; - -// ── Types ──────────────────────────────────────────────────────────────────── - -export interface SshConnectionInfo { - host: string; - port: number; - user: string; - password: string; -} - -export interface InteractiveSessionOptions { - ssh: SshConnectionInfo; - remoteCommand: string; - successPatterns: RegExp[]; - errorPatterns: ErrorPattern[]; - timeoutMs: number; - io: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; - tunnelPort?: number; - runtime?: Partial; -} - -export interface InteractiveSessionResult { - exitCode: number | null; - exitSignal: string | null; - authDetected: boolean; -} - -// ── Debug (env-gated) ──────────────────────────────────────────────────────── - -const DEBUG = process.env.AGENT_RELAY_DEBUG_SSH === '1'; -function dbg(event: string, fields: Record = {}): void { - if (!DEBUG) return; - const ts = new Date().toISOString(); - const parts = Object.entries(fields) - .map(([k, v]) => `${k}=${typeof v === 'string' ? JSON.stringify(v) : v}`) - .join(' '); - process.stderr.write(`[ssh-debug ${ts}] ${event}${parts ? ' ' + parts : ''}\n`); -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const color = { - cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, - green: (s: string) => `\x1b[32m${s}\x1b[0m`, - yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, - red: (s: string) => `\x1b[31m${s}\x1b[0m`, - dim: (s: string) => `\x1b[2m${s}\x1b[0m`, -}; - -function getSshErrorMessage(host: string, port: number, err: Error): string { - if (err.message.includes('Authentication')) { - return 'SSH authentication failed.'; - } - if (err.message.includes('ECONNREFUSED')) { - return `Cannot connect to SSH server at ${host}:${port}. Is the workspace running and SSH enabled?`; - } - if (err.message.includes('ENOTFOUND') || err.message.includes('getaddrinfo')) { - return `Cannot resolve hostname: ${host}. Check network connectivity.`; - } - if (err.message.includes('ETIMEDOUT')) { - return `Connection timed out to ${host}:${port}. Is the workspace running?`; - } - return `SSH error: ${err.message}`; -} - -// ── Main function ──────────────────────────────────────────────────────────── - -const DEFAULT_RUNTIME: Pick< - AuthSshRuntime, - 'loadSSH2' | 'createAskpassScript' | 'buildSystemSshArgs' | 'spawnProcess' | 'createServer' | 'setTimeout' -> = { - loadSSH2, - createAskpassScript, - buildSystemSshArgs, - spawnProcess, - createServer, - setTimeout, -}; - -/** - * Format a remote command for execution inside an ssh2 shell() PTY. - * - * Wraps the command in `exec sh -c '…'` so the PTY closes cleanly when the - * target CLI exits (no shell-teardown race with a TUI's alt-screen flush) - * while still letting `sh` parse leading prefix assignments like - * `PATH=/foo/bin claude`. A bare `exec PATH=… claude` does not work in zsh - * because zsh's exec builtin treats `PATH=…` as the command name instead of - * a prefix assignment. - * - * We intentionally use `shell()` rather than `exec(cmd, { pty })` because - * Daytona's sandbox sshd only populates the full login-shell environment - * (including nvm-managed PATH entries where `claude` / `codex` actually live) - * for interactive shell sessions. An `exec` channel with a PTY gets a - * stripped-down environment and the target CLI fails to start silently. - */ -export function formatShellInvocation(command: string): string { - const escaped = command.replace(/'/g, `'\\''`); - return `exec sh -c '${escaped}'\n`; -} - -/** - * Wrap the remote command with a visible checkpoint so the user sees proof - * the ssh pipeline reached the sandbox before the provider CLI takes over - * the terminal. Without this, claude/codex enter alt-screen immediately and - * the user sees zero output — indistinguishable from a hang. - * - * The printf runs before the exec that launches the provider CLI, so the - * user gets one visible line ("launching provider CLI…") right before - * alt-screen engages. When the provider CLI later exits and the alt-screen - * tears down, this line remains in scrollback as a breadcrumb. - */ -export function wrapWithLaunchCheckpoint(command: string): string { - // Escape single quotes for inclusion in the printf argument. - return `printf '\\033[2m[agent-relay] launching provider CLI…\\033[0m\\n' >&2; ${command}`; -} - -/** - * Run an interactive SSH session with PTY. - * - * Connects via ssh2 (if available) or falls back to system ssh, - * sets up a local port tunnel, and runs the remote command in a PTY. - * Monitors output for success/error patterns. - */ -export async function runInteractiveSession( - options: InteractiveSessionOptions -): Promise { - const { ssh, successPatterns, errorPatterns, timeoutMs, io, tunnelPort = 1455 } = options; - - const runtime = { ...DEFAULT_RUNTIME, ...options.runtime }; - - // Wrap the remote command with a visible checkpoint so the user sees proof - // the ssh pipeline is alive before the provider CLI enters alt-screen. - const remoteCommand = wrapWithLaunchCheckpoint(options.remoteCommand); - - const ssh2 = await runtime.loadSSH2(); - - io.log(color.yellow('Starting interactive authentication...')); - io.log(color.dim(`Transport: ${ssh2 ? 'ssh2 (bundled)' : 'system ssh (fallback)'}`)); - io.log(color.dim('The provider CLI may take 5-15s to render its first screen after connecting.')); - io.log( - color.dim('A welcome / theme picker may appear before the sign-in step. Follow the on-screen prompts.') - ); - io.log(color.dim('Wait for the CLI to render before pressing Ctrl+C.')); - io.log(''); - - let execResult: InteractiveSessionResult | null = null; - let execError: Error | null = null; - - if (ssh2) { - const { Client } = ssh2; - const sshClient = new Client(); - let sshReady = false; - const tunnel: { server: ReturnType | null } = { server: null }; - - const sshReadyPromise = new Promise((resolve, reject) => { - sshClient.on('ready', () => { - sshReady = true; - - tunnel.server = runtime.createServer((localSocket) => { - sshClient.forwardOut('127.0.0.1', tunnelPort, 'localhost', tunnelPort, (err, stream) => { - if (err) { - localSocket.end(); - return; - } - localSocket.pipe(stream).pipe(localSocket); - }); - }); - - tunnel.server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - io.log(color.dim(`Note: Port ${tunnelPort} in use, OAuth callbacks may not work.`)); - } - resolve(); - }); - - tunnel.server.listen(tunnelPort, '127.0.0.1', () => { - resolve(); - }); - }); - - sshClient.on('error', (err) => { - reject(new Error(getSshErrorMessage(ssh.host, ssh.port, err))); - }); - - sshClient.on('close', () => { - if (!sshReady) { - reject(new Error(`SSH connection to ${ssh.host}:${ssh.port} closed unexpectedly.`)); - } - }); - }); - - try { - sshClient.connect({ - host: ssh.host, - port: ssh.port, - username: ssh.user, - password: ssh.password, - readyTimeout: 10000, - hostVerifier: () => true, - }); - - await Promise.race([ - sshReadyPromise, - new Promise((_, reject) => - runtime.setTimeout(() => reject(new Error('SSH connection timeout')), 15000) - ), - ]); - } catch (err) { - io.error(color.red(`Failed to connect via SSH: ${err instanceof Error ? err.message : String(err)}`)); - if (tunnel.server) tunnel.server.close(); - sshClient.end(); - throw err; - } - - const execInteractive = async (command: string, commandTimeoutMs: number) => - await new Promise((resolve, reject) => { - const cols = process.stdout.columns || 80; - const rows = process.stdout.rows || 24; - const term = process.env.TERM || 'xterm-256color'; - - dbg('shell-request', { term, cols, rows }); - // Use shell() so the remote side sources its login-shell init files - // (/etc/profile, ~/.zprofile, nvm setup, …). Daytona's sandbox image - // populates the nvm-managed PATH (/usr/local/share/nvm/current/bin) - // from those init files, and without them the target CLIs (claude, - // codex) are not on PATH and fail to start silently. An exec channel - // with `{ pty }` was tried and produced zero output for this reason. - sshClient.shell({ term, cols, rows }, (err, stream) => { - if (err) { - dbg('shell-error', { message: err.message }); - return reject(err); - } - dbg('shell-opened'); - - let exitCode: number | null = null; - let exitSignal: string | null = null; - let authDetected = false; - let outputBuffer = ''; - // Gate pattern matching so shell MOTD (e.g. "Last logged in …") - // does not trigger the broad `/logged\s*in/i` success pattern - // before the target CLI has even started. - let patternMatchingEnabled = false; - // Track whether we've drawn the dim "waiting" hint so we can clear - // it the moment the remote CLI starts producing real output. - let hintVisible = false; - - const stdin = process.stdin; - const stdout = process.stdout; - const stderr = process.stderr; - - const wasRaw = (stdin as unknown as { isRaw?: boolean }).isRaw ?? false; - - const onStdinData = (data: Buffer) => { - if (authDetected && (data[0] === 0x1b || data[0] === 0x03)) { - cleanup(); - clearTimeout(timer); - try { - stream.close(); - } catch { - // ignore - } - return; - } - stream.write(data); - }; - - const cleanup = () => { - stdin.off('data', onStdinData); - stdout.off('resize', onResize); - try { - stdin.setRawMode?.(wasRaw); - } catch { - // ignore - } - stdin.pause(); - }; - - const closeOnAuthSuccess = () => { - authDetected = true; - stdout.write('\n'); - stdout.write(color.green(' ✓ Authentication successful!') + '\n'); - stdout.write(color.dim(' Press Escape or Ctrl+C to exit.') + '\n'); - stdout.write('\n'); - }; - - let totalBytes = 0; - let firstByteAt: number | null = null; - const sessionStart = Date.now(); - - stream.on('data', (data: Buffer) => { - totalBytes += data.length; - if (firstByteAt === null) { - firstByteAt = Date.now(); - dbg('first-byte', { - elapsedMs: firstByteAt - sessionStart, - bytes: data.length, - preview: data.toString('utf8').slice(0, 120), - }); - if (hintVisible) { - // Clear the dim "waiting" hint line before the remote CLI - // paints its own UI. \r moves to col 0, \x1b[2K clears the - // line, so the subsequent bytes (including any alt-screen - // switch) render from a known-clean state. - stdout.write('\r\x1b[2K'); - hintVisible = false; - } - } else if (DEBUG) { - dbg('data-out', { bytes: data.length, totalBytes }); - } - stdout.write(data); - - outputBuffer += data.toString(); - if (outputBuffer.length > 8192) { - outputBuffer = outputBuffer.slice(-8192); - } - - if (patternMatchingEnabled && !authDetected && successPatterns.length > 0) { - const clean = stripAnsiCodes(outputBuffer); - for (const pattern of successPatterns) { - if (pattern.test(clean)) { - closeOnAuthSuccess(); - break; - } - } - } - - if (patternMatchingEnabled && !authDetected && errorPatterns.length > 0) { - const matched = findMatchingError(outputBuffer, errorPatterns); - if (matched) { - clearTimeout(timer); - cleanup(); - try { - stream.close(); - } catch { - // ignore - } - reject(new Error(matched.message + (matched.hint ? ` ${matched.hint}` : ''))); - } - } - }); - - stream.stderr.on('data', (data: Buffer) => { - dbg('stderr-out', { bytes: data.length }); - stderr.write(data); - }); - - const onResize = () => { - try { - stream.setWindow(stdout.rows || 24, stdout.columns || 80, 0, 0); - } catch { - // ignore - } - }; - - stream.on('exit', (code: unknown, signal?: unknown) => { - dbg('stream-exit', { code, signal }); - if (typeof code === 'number') exitCode = code; - if (typeof signal === 'string') exitSignal = signal; - }); - - stream.on('close', () => { - dbg('stream-close', { - totalBytes, - firstByteAt: firstByteAt !== null ? firstByteAt - sessionStart : null, - exitCode, - exitSignal, - authDetected, - }); - clearTimeout(timer); - cleanup(); - if (totalBytes === 0 && !authDetected) { - io.log(''); - io.error( - color.red('No output received from the remote auth command before the session closed.') - ); - io.error( - color.dim( - ' This usually means the remote CLI failed to start. Re-run with AGENT_RELAY_DEBUG_SSH=1 for details.' - ) - ); - } - resolve({ exitCode, exitSignal, authDetected }); - }); - - stream.on('error', (streamErr: unknown) => { - dbg('stream-error', { - message: streamErr instanceof Error ? streamErr.message : String(streamErr), - }); - clearTimeout(timer); - cleanup(); - reject(streamErr instanceof Error ? streamErr : new Error(String(streamErr))); - }); - - stdout.on('resize', onResize); - stdin.on('data', onStdinData); - - try { - stdin.setRawMode?.(true); - } catch { - // ignore - } - stdin.resume(); - - const timer = runtime.setTimeout(() => { - cleanup(); - try { - stream.close(); - } catch { - // ignore - } - reject(new Error(`Authentication timed out after ${Math.floor(commandTimeoutMs / 1000)}s`)); - }, commandTimeoutMs); - - const invocation = formatShellInvocation(command); - dbg('shell-write', { bytes: invocation.length, preview: invocation.slice(0, 200) }); - stream.write(invocation); - // Reset the output buffer so pattern matching only considers output - // produced by the command we just wrote, not the shell's MOTD. - outputBuffer = ''; - patternMatchingEnabled = true; - - // Show a single-line dim hint so the user can see something is - // happening while the remote shell starts. As soon as the first - // byte comes back from the target CLI, we clear this line (see - // stream.on('data')) and hand the terminal over to the remote. - stdout.write(color.dim(' Waiting for provider CLI to launch…')); - hintVisible = true; - }); - }); - - try { - execResult = await execInteractive(remoteCommand, timeoutMs); - } catch (err) { - execError = err instanceof Error ? err : new Error(String(err)); - io.log(''); - io.error(color.red(`Remote auth command failed: ${execError.message}`)); - } finally { - if (tunnel.server) tunnel.server.close(); - sshClient.end(); - } - } else { - // Fallback: system ssh - const askpassPath = runtime.createAskpassScript(ssh.password); - try { - const sshArgs = runtime.buildSystemSshArgs({ - host: ssh.host, - port: ssh.port, - username: ssh.user, - localPort: tunnelPort, - remotePort: tunnelPort, - }); - sshArgs.push('-tt'); - sshArgs.push(`${ssh.user}@${ssh.host}`); - sshArgs.push(remoteCommand); - - const child = runtime.spawnProcess('ssh', sshArgs, { - stdio: 'inherit', - env: { - ...process.env, - SSH_ASKPASS: askpassPath, - SSH_ASKPASS_REQUIRE: 'force', - DISPLAY: process.env.DISPLAY || ':0', - }, - }); - - execResult = await new Promise((resolve) => { - child.on('exit', (code, signal) => { - resolve({ - exitCode: code, - exitSignal: signal ? String(signal) : null, - authDetected: code === 0, - }); - }); - child.on('error', (err) => { - io.error(color.red(`Failed to launch ssh: ${err.message}`)); - resolve({ exitCode: 1, exitSignal: null, authDetected: false }); - }); - }); - } catch (err) { - execError = err instanceof Error ? err : new Error(String(err)); - io.log(''); - io.error(color.red(`SSH error: ${execError.message}`)); - } finally { - try { - const fs = await import('node:fs'); - fs.unlinkSync(askpassPath); - } catch { - // ignore - } - } - } - - // Authentication is only considered successful when the interactive session - // reported a positive pattern match. A shell exit code of 0 is NOT trusted: - // zsh stays alive after a failed `exec` in interactive mode, and a user - // closing the session with Ctrl+D produces exit 0 even though nothing was - // authenticated. Callers currently always supply `successPatterns`. - return { - exitCode: execError ? 1 : (execResult?.exitCode ?? null), - exitSignal: execResult?.exitSignal ?? null, - authDetected: execError === null && execResult?.authDetected === true, - }; -} +export { + runInteractiveSession, + formatShellInvocation, + wrapWithLaunchCheckpoint, + type SshConnectionInfo, + type InteractiveSessionOptions, + type InteractiveSessionResult, +} from '@agent-relay/cloud'; From b8a840bd06c889ff77773ff79a91f9dccda63318 Mon Sep 17 00:00:00 2001 From: Multi-Repo Pushback Bot Date: Tue, 28 Apr 2026 18:32:16 -0700 Subject: [PATCH 2/2] Merge branch 'main' into feat/cloud-connect-sdk Resolves CHANGELOG.md by keeping both 6.1.0 entries (cloud connect SDK and sdk/workflows script runner). Other files auto-merged. --- .github/workflows/codegen-models.yml | 4 +- CHANGELOG.md | 1 + package-lock.json | 15 +- package.json | 4 +- packages/acp-bridge/package.json | 2 +- packages/browser-primitive/package.json | 2 +- packages/gateway/package.json | 2 +- packages/hooks/package.json | 2 +- packages/openclaw/package.json | 2 +- packages/sdk/package.json | 2 +- .../workflows/__tests__/run-script.test.ts | 98 ++++ packages/sdk/src/workflows/index.ts | 13 + packages/sdk/src/workflows/run-script.ts | 462 ++++++++++++++++++ src/cli/commands/setup.ts | 424 +--------------- 14 files changed, 610 insertions(+), 423 deletions(-) create mode 100644 packages/sdk/src/workflows/__tests__/run-script.test.ts create mode 100644 packages/sdk/src/workflows/run-script.ts diff --git a/.github/workflows/codegen-models.yml b/.github/workflows/codegen-models.yml index ad2cbe46f..05d739a33 100644 --- a/.github/workflows/codegen-models.yml +++ b/.github/workflows/codegen-models.yml @@ -35,7 +35,7 @@ jobs: - name: Check for changes id: changes run: | - if git diff --quiet packages/config/src/cli-registry.generated.ts packages/sdk-py/agent_relay/models.py packages/sdk-py/agent_relay/__init__.py; then + if git diff --quiet packages/config/src/cli-registry.generated.ts packages/sdk-py/src/agent_relay/models.py; then echo "changed=false" >> $GITHUB_OUTPUT else echo "changed=true" >> $GITHUB_OUTPUT @@ -46,7 +46,7 @@ jobs: run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" - git add packages/config/src/cli-registry.generated.ts packages/sdk-py/agent_relay/models.py packages/sdk-py/agent_relay/__init__.py + git add packages/config/src/cli-registry.generated.ts packages/sdk-py/src/agent_relay/models.py git commit -m "chore: regenerate models from cli-registry.yaml [skip ci]" git push diff --git a/CHANGELOG.md b/CHANGELOG.md index 1001bd142..b037e575f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`@agent-relay/cloud` provider connect SDK** (6.1.0): Exposes `connectProvider()`, `runInteractiveSession()`, and SSH runtime helpers (`loadSSH2`, `createAskpassScript`, `buildSystemSshArgs`) so other CLIs can drive the same Daytona-brokered provider auth flow that powers `agent-relay cloud connect`. `ssh2` is now an `optionalDependency` of the cloud package. +- **`@agent-relay/sdk/workflows` script runner** (6.1.0): Exposes `runScriptWorkflow()`, `parseTsxStderr`, `formatWorkflowParseError`, `findLocalSdkWorkspace`, `ensureLocalSdkWorkflowRuntime`, plus `RunScriptWorkflowOptions` / `ParsedWorkflowError` types. The body of `agent-relay run