Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **`@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 <script>` now lives in the SDK, so other CLIs (ricky, future tools) can drive the same `.ts` / `.tsx` / `.py` execution flow in-process instead of shelling out. The relay CLI's `run` command is unchanged externally — it now delegates to the SDK function.
- **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).
Expand Down
8 changes: 6 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.1.0",
Expand Down
6 changes: 5 additions & 1 deletion packages/cloud/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
27 changes: 27 additions & 0 deletions packages/cloud/src/connect.test.ts
Original file line number Diff line number Diff line change
@@ -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)');
});
});
226 changes: 226 additions & 0 deletions packages/cloud/src/connect.ts
Original file line number Diff line number Diff line change
@@ -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 <provider>` is a thin wrapper
* around this function; other tools (e.g. `ricky connect <provider>`) 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<string, string> = {
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<AuthSshRuntime>;
}

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<string> {
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<ConnectProviderResult> {
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 };
}
34 changes: 30 additions & 4 deletions packages/cloud/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -38,4 +64,4 @@ export {
AUTH_FILE_PATH,
defaultApiUrl,
isSupportedProvider,
} from "./types.js";
} from './types.js';
Loading
Loading