diff --git a/packages/cli/README.md b/packages/cli/README.md index 75d690f490..663ad9425f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -127,6 +127,7 @@ npx @pascal-app/cli editor --foreground --no-open | `pascal info [--json]` | Print platform, paths, runtime, and plugin context. | | `pascal project list [--json]` | Explicit form of `pascal projects`. | | `pascal project open ` | Explicit form of `pascal open `. | +| `pascal agent claim [--no-open] [--json]` | Link an autonomous hosted agent to the person accountable for it. | | `pascal mcp connect` | Stable local connector for MCP clients; discovers the dynamic managed service. | | `pascal mcp status [--json]` | Show managed MCP health. | | `pascal mcp config [--json]` | Print generic MCP client configuration. | @@ -172,6 +173,26 @@ Or use `pascal mcp config` for JSON-based clients. The connector also starts Pas when an agent connects while it is stopped. Ask the agent to read `pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. +## Hosted autonomous agents + +An autonomous agent registered with hosted Pascal receives its own API key and identity. The +agent can create a short-lived claim code so the person working with it can establish the +accountability link: + +```bash +PASCAL_API_KEY='sk_live_...' pascal agent claim +``` + +The CLI sends that key once to Pascal's claim endpoint, does not store or print it, and opens +the claim page. Use `--no-open` on a headless host. `--json` returns structured output without +opening a browser. A new claim request supersedes the agent's previous code; each code expires +after 15 minutes. + +Claiming lifts claim-gated capabilities for the autonomous agent. It does not transfer project +ownership, grant the agent access to the person's private projects, or grant the person access +to the agent's private projects. The local editor and its projects remain local unless a +separate hosted project action explicitly moves data. + ## Plugins The current CLI manages the local editor runtime; it does not yet download plugin code diff --git a/packages/cli/src/agent-account.test.ts b/packages/cli/src/agent-account.test.ts new file mode 100644 index 0000000000..f2682a0b93 --- /dev/null +++ b/packages/cli/src/agent-account.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test' +import { startAgentClaim } from './agent-account.js' +import { CliError } from './errors.js' + +const API_KEY = 'sk_live_private-agent-key' +const VALID_CLAIM = { + claimCode: 'BCDF-GHJK-LMNP', + claimUrl: 'https://editor.pascal.app/settings/agents/claim', + expiresAt: '2026-09-10T18:30:00.000Z', +} + +describe('agent account claims', () => { + test('starts a claim with the agent credential and returns the bounded public result', async () => { + let authorization: string | null = null + let redirect: RequestRedirect | undefined + const fetchMock: typeof fetch = async (_input, init) => { + authorization = new Headers(init?.headers).get('authorization') + redirect = init?.redirect + return Response.json({ + ...VALID_CLAIM, + agent: { name: '\u001b[2Jmalicious', client: 'openclaw' }, + message: 'server copy is not part of the CLI result', + }) + } + + const result = await startAgentClaim(API_KEY, { fetch: fetchMock }) + + expect(authorization).toBe(`Bearer ${API_KEY}`) + expect(redirect).toBe('error') + expect(result).toEqual(VALID_CLAIM) + }) + + test.each([ + [400, 'agent_claim_not_available'], + [401, 'agent_claim_unauthorized'], + [403, 'agent_claim_forbidden'], + [409, 'agent_already_claimed'], + [429, 'agent_claim_rate_limited'], + [503, 'agent_claim_failed'], + ])('maps HTTP %i without exposing the API key or response body', async (status, code) => { + const fetchMock: typeof fetch = async () => + new Response(`credential ${API_KEY} rejected`, { + headers: { 'content-type': 'text/html' }, + status, + }) + + const error = await captureError(() => startAgentClaim(API_KEY, { fetch: fetchMock })) + + expect(error.code).toBe(code) + expect(JSON.stringify(error)).not.toContain(API_KEY) + expect(error.message).not.toContain(API_KEY) + }) + + test('rejects malformed or chunked oversized responses', async () => { + const malformed: typeof fetch = async () => Response.json({ ...VALID_CLAIM, claimCode: '123' }) + const unsafeDate: typeof fetch = async () => + Response.json({ ...VALID_CLAIM, expiresAt: 'Wed, 10 Sep 2026 18:30:00 GMT (\u001b[2J)' }) + const oversized: typeof fetch = async () => { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"padding":"')) + controller.enqueue(encoder.encode('x'.repeat(33 * 1024))) + controller.close() + }, + }), + ) + } + + expect((await captureError(() => startAgentClaim(API_KEY, { fetch: malformed }))).code).toBe( + 'agent_claim_invalid_response', + ) + expect((await captureError(() => startAgentClaim(API_KEY, { fetch: unsafeDate }))).code).toBe( + 'agent_claim_invalid_response', + ) + expect((await captureError(() => startAgentClaim(API_KEY, { fetch: oversized }))).code).toBe( + 'agent_claim_invalid_response', + ) + }) + + test('reports network failures and bounded timeouts without reflecting secrets', async () => { + const unavailable: typeof fetch = async () => { + throw new Error(`failed with ${API_KEY}`) + } + const pending: typeof fetch = async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + + const networkError = await captureError(() => startAgentClaim(API_KEY, { fetch: unavailable })) + const timeoutError = await captureError(() => + startAgentClaim(API_KEY, { fetch: pending, timeoutMs: 1 }), + ) + + expect(networkError.code).toBe('agent_claim_unavailable') + expect(timeoutError.code).toBe('agent_claim_timeout') + expect(`${networkError.message}${timeoutError.message}`).not.toContain(API_KEY) + }) + + test('keeps the timeout active while reading the response body', async () => { + const stalled: typeof fetch = async (_input, init) => + new Response( + new ReadableStream({ + start(controller) { + init?.signal?.addEventListener('abort', () => controller.error(new Error('aborted')), { + once: true, + }) + }, + }), + ) + + const error = await captureError(() => + startAgentClaim(API_KEY, { fetch: stalled, timeoutMs: 1 }), + ) + + expect(error.code).toBe('agent_claim_timeout') + }) +}) + +async function captureError(run: () => Promise): Promise { + try { + await run() + throw new Error('Expected the operation to fail') + } catch (error) { + expect(error).toBeInstanceOf(CliError) + return error as CliError + } +} diff --git a/packages/cli/src/agent-account.ts b/packages/cli/src/agent-account.ts new file mode 100644 index 0000000000..d1757f2f21 --- /dev/null +++ b/packages/cli/src/agent-account.ts @@ -0,0 +1,171 @@ +import { CliError } from './errors.js' + +const CLAIM_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/claim/start' +const CLAIM_PAGE = 'https://editor.pascal.app/settings/agents/claim' +const MAX_RESPONSE_BYTES = 32 * 1024 +const DEFAULT_TIMEOUT_MS = 15_000 +const CLAIM_CODE_PATTERN = + /^[23456789BCDFGHJKLMNPQRSTVWXZ]{4}(?:-[23456789BCDFGHJKLMNPQRSTVWXZ]{4}){2}$/ + +export interface AgentClaim { + claimCode: string + claimUrl: string + expiresAt: string +} + +interface StartAgentClaimOptions { + fetch?: typeof fetch + timeoutMs?: number +} + +export async function startAgentClaim( + apiKey: string, + options: StartAgentClaimOptions = {}, +): Promise { + const credential = apiKey.trim() + if (!credential) { + throw new CliError( + 'agent_api_key_missing', + "Set PASCAL_API_KEY to this autonomous agent's API key and try again.", + ) + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + try { + let response: Response + try { + response = await (options.fetch ?? fetch)(CLAIM_ENDPOINT, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${credential}`, + }, + redirect: 'error', + signal: controller.signal, + }) + } catch { + if (controller.signal.aborted) { + throw claimTimeout() + } + throw new CliError( + 'agent_claim_unavailable', + 'Pascal could not be reached while starting the agent claim. Try again.', + ) + } + + if (!response.ok) { + if (response.body) void response.body.cancel().catch(() => {}) + throw claimResponseError(response.status) + } + const body = await readJsonResponse(response, controller.signal) + if (!isAgentClaim(body)) throw invalidResponse() + return { + claimCode: body.claimCode, + claimUrl: body.claimUrl, + expiresAt: body.expiresAt, + } + } finally { + clearTimeout(timeout) + } +} + +async function readJsonResponse(response: Response, signal: AbortSignal): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw invalidResponse() + } + + if (!response.body) throw invalidResponse() + const reader = response.body.getReader() + const decoder = new TextDecoder() + let bytes = 0 + let text = '' + while (true) { + let chunk + try { + chunk = await reader.read() + } catch { + if (signal.aborted) throw claimTimeout() + throw invalidResponse() + } + if (chunk.done) break + bytes += chunk.value.byteLength + if (bytes > MAX_RESPONSE_BYTES) { + void reader.cancel().catch(() => {}) + throw invalidResponse() + } + text += decoder.decode(chunk.value, { stream: true }) + } + text += decoder.decode() + + try { + return JSON.parse(text) as unknown + } catch { + throw invalidResponse() + } +} + +function isAgentClaim(value: unknown): value is AgentClaim { + if (!isRecord(value)) return false + if (typeof value.claimCode !== 'string' || !CLAIM_CODE_PATTERN.test(value.claimCode)) return false + if (typeof value.expiresAt !== 'string') return false + const expiresAt = Date.parse(value.expiresAt) + if (Number.isNaN(expiresAt) || new Date(expiresAt).toISOString() !== value.expiresAt) return false + return value.claimUrl === CLAIM_PAGE +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function claimResponseError(status: number): CliError { + switch (status) { + case 400: + return new CliError( + 'agent_claim_not_available', + 'This agent credential cannot start a claim.', + { status }, + ) + case 401: + return new CliError('agent_claim_unauthorized', 'PASCAL_API_KEY is invalid or revoked.', { + status, + }) + case 403: + return new CliError( + 'agent_claim_forbidden', + 'PASCAL_API_KEY must belong to an autonomous Pascal agent.', + { status }, + ) + case 409: + return new CliError('agent_already_claimed', 'This agent has already been claimed.', { + status, + }) + case 429: + return new CliError( + 'agent_claim_rate_limited', + 'Too many agent claim attempts. Wait and try again.', + { status }, + ) + default: + return new CliError( + 'agent_claim_failed', + `Pascal could not start the agent claim (HTTP ${status}).`, + { status }, + ) + } +} + +function invalidResponse(): CliError { + return new CliError( + 'agent_claim_invalid_response', + 'Pascal returned an invalid agent claim response. Try again.', + ) +} + +function claimTimeout(): CliError { + return new CliError( + 'agent_claim_timeout', + 'Pascal did not respond while starting the agent claim. Try again.', + ) +} diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index be1649c036..8c7441f56d 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' import { parseArgs } from 'node:util' +import { startAgentClaim } from '../agent-account.js' import { openBrowser } from '../browser.js' import { installGlobalPascalCommand, isNpxInvocation } from '../command-install.js' import { collectInfo, runDoctor } from '../diagnostics.js' @@ -50,6 +51,7 @@ USAGE: pascal project list [--json] pascal project open pascal project resume [id-or-name] + pascal agent claim [--no-open] [--json] pascal mcp connect | status | config | setup pascal plugin list [--json] @@ -73,14 +75,32 @@ dynamic loopback port without exposing Pascal's private local token. Documentation: https://editor.pascal.app/docs/developers/mcp ` +const AGENT_HELP = `Pascal agent — connect an autonomous agent to a person + +USAGE: + pascal agent claim [--no-open] [--json] + +Set PASCAL_API_KEY to the autonomous agent's hosted Pascal API key. The CLI +uses it once to request a 15-minute claim code and never stores it. It opens +the claim page unless --no-open or --json is set. + +Claiming records who is accountable for the agent and lifts claim-gated +capabilities. It does not transfer project ownership or grant access to either +account's private projects. + +Documentation: https://editor.pascal.app/docs/developers/mcp +` + const paths = resolvePascalPaths() +const agentApiKey = process.env.PASCAL_API_KEY +Reflect.deleteProperty(process.env, 'PASCAL_API_KEY') async function main(): Promise { const [command = 'help', ...args] = process.argv.slice(2) if (command === '--version' || command === '-v') return print(version) if (command === '--help' || command === '-h' || command === 'help') return print(HELP) if (args.includes('--help') || args.includes('-h')) { - return print(command === 'mcp' ? MCP_HELP : HELP) + return print(command === 'mcp' ? MCP_HELP : command === 'agent' ? AGENT_HELP : HELP) } switch (command) { @@ -110,6 +130,8 @@ async function main(): Promise { return runUpdate(args) case 'project': return runProject(args) + case 'agent': + return runAgent(args, agentApiKey) case 'plugin': return runPlugin(args) case 'mcp': @@ -576,6 +598,34 @@ async function runMcp(args: string[]): Promise { ) } +async function runAgent(args: string[], apiKey: string | undefined): Promise { + const [subcommand, ...rest] = args + if (subcommand !== 'claim') { + throw new CliError('unknown_command', 'Use "pascal agent claim".', undefined, 2) + } + const { values } = parseArgs({ + args: rest, + strict: true, + options: { + json: { type: 'boolean', default: false }, + 'no-open': { type: 'boolean', default: false }, + }, + }) + const claim = await startAgentClaim(apiKey ?? '') + if (!values['no-open'] && !values.json) openBrowser(claim.claimUrl) + output( + values.json, + claim, + [ + `Claim code: ${claim.claimCode}`, + `Claim page: ${claim.claimUrl}`, + `Expires: ${claim.expiresAt}`, + '', + 'Claiming links accountability. It does not transfer project ownership or grant access to private projects.', + ].join('\n'), + ) +} + async function runPlugin(args: string[]): Promise { const [subcommand, ...rest] = args if (subcommand === 'list') { diff --git a/packages/cli/src/browser.test.ts b/packages/cli/src/browser.test.ts new file mode 100644 index 0000000000..fee703b616 --- /dev/null +++ b/packages/cli/src/browser.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import type { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { openBrowser } from './browser.js' + +const spawnMock = mock(() => { + const child = new EventEmitter() as EventEmitter & { unref: () => void } + child.unref = mock(() => {}) + return child +}) as unknown as typeof spawn + +afterEach(() => spawnMock.mockClear()) + +describe('browser launch', () => { + test('removes the Pascal API key from the spawned process environment', () => { + openBrowser( + 'https://editor.pascal.app/settings/agents/claim', + { + HOME: '/tmp/pascal-home', + PASCAL_API_KEY: 'sk_live_private-agent-key', + PATH: '/usr/bin', + }, + spawnMock, + ) + + expect(spawnMock).toHaveBeenCalledTimes(1) + const options = spawnMock.mock.calls[0]?.[2] + expect(options?.env).toEqual({ HOME: '/tmp/pascal-home', PATH: '/usr/bin' }) + expect(JSON.stringify(options)).not.toContain('sk_live_private-agent-key') + }) + + test('does not spawn when browser opening is disabled', () => { + openBrowser( + 'https://editor.pascal.app/settings/agents/claim', + { + PASCAL_API_KEY: 'sk_live_private-agent-key', + PASCAL_NO_OPEN: '1', + }, + spawnMock, + ) + + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/browser.ts b/packages/cli/src/browser.ts index 25fc8d8c26..26ed824b80 100644 --- a/packages/cli/src/browser.ts +++ b/packages/cli/src/browser.ts @@ -1,11 +1,20 @@ import { spawn } from 'node:child_process' -export function openBrowser(url: string, environment: NodeJS.ProcessEnv = process.env): void { +export function openBrowser( + url: string, + environment: NodeJS.ProcessEnv = process.env, + spawnProcess: typeof spawn = spawn, +): void { if (environment.PASCAL_NO_OPEN === '1') return + const { PASCAL_API_KEY: _pascalApiKey, ...browserEnvironment } = environment const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open' const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url] - const child = spawn(command, args, { detached: true, stdio: 'ignore' }) + const child = spawnProcess(command, args, { + detached: true, + env: browserEnvironment, + stdio: 'ignore', + }) child.once('error', () => {}) child.unref() } diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index af61017279..02729c2a57 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -6,6 +6,29 @@ import path from 'node:path' const executable = path.join(import.meta.dir, 'bin/pascal.ts') const testRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-command-test-')) const testHome = path.join(testRoot, 'home') +const claimFetchPreload = path.join(testRoot, 'claim-fetch-preload.mjs') + +await writeFile( + claimFetchPreload, + `globalThis.fetch = async (input, init) => { + if (String(input) !== 'https://editor.pascal.app/api/auth/agent/claim/start') { + throw new Error('Unexpected claim endpoint') + } + if (init?.redirect !== 'error') throw new Error('Redirects must be disabled') + if (process.env.PASCAL_API_KEY !== undefined) { + throw new Error('PASCAL_API_KEY remained in the process environment') + } + const authorization = new Headers(init?.headers).get('authorization') + if (authorization !== process.env.PASCAL_CLAIM_TEST_AUTHORIZATION) { + throw new Error('Unexpected claim authorization') + } + return new Response(process.env.PASCAL_CLAIM_TEST_BODY, { + headers: { 'content-type': 'application/json' }, + status: Number(process.env.PASCAL_CLAIM_TEST_STATUS), + }) + } +`, +) afterAll(() => rm(testRoot, { recursive: true, force: true })) @@ -28,6 +51,86 @@ describe('command parsing', () => { expect(result.stdout).not.toContain('pascal plugin list') }) + test('shows focused help for hosted agent claims', async () => { + const result = await runCli('agent', '--help') + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('pascal agent claim') + expect(result.stdout).toContain('PASCAL_API_KEY') + expect(result.stdout).toContain('does not transfer project ownership') + expect(result.stdout).not.toContain('pascal plugin list') + }) + + test('requires an environment credential before starting an agent claim', async () => { + const result = await runCli('agent', 'claim', '--no-open', '--json') + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stderr)).toEqual({ + error: 'agent_api_key_missing', + message: "Set PASCAL_API_KEY to this autonomous agent's API key and try again.", + }) + expect(result.stdout).toBe('') + }) + + test('prints the exact successful JSON claim contract without opening a browser', async () => { + const claim = { + claimCode: 'BCDF-GHJK-LMNP', + claimUrl: 'https://editor.pascal.app/settings/agents/claim', + expiresAt: '2026-09-10T18:30:00.000Z', + } + + const result = await runClaimCli( + 200, + { ...claim, agent: { name: '\u001b[2J', client: 'test' } }, + '--json', + ) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toEqual(claim) + expect(result.stderr).toBe('') + }) + + test('prints a terminal-safe human claim without server-controlled identity text', async () => { + const result = await runClaimCli( + 200, + { + claimCode: 'BCDF-GHJK-LMNP', + claimUrl: 'https://editor.pascal.app/settings/agents/claim', + expiresAt: '2026-09-10T18:30:00.000Z', + agent: { name: '\u001b[2Jmalicious', client: 'test' }, + }, + '--no-open', + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Claim code: BCDF-GHJK-LMNP') + expect(result.stdout).toContain('Claiming links accountability.') + expect(result.stdout).not.toContain('malicious') + expect(result.stdout).not.toContain('\u001b') + expect(result.stderr).toBe('') + }) + + test.each([ + [401, 'agent_claim_unauthorized'], + [409, 'agent_already_claimed'], + ])('preserves the hosted HTTP %i error contract', async (status, errorCode) => { + const result = await runClaimCli(status, 'untrusted error', '--json') + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stderr)).toMatchObject({ + details: { status }, + error: errorCode, + }) + expect(result.stdout).toBe('') + }) + + test('rejects unknown agent account commands', async () => { + const result = await runCli('agent', 'login', '--json') + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stderr)).toMatchObject({ error: 'unknown_command' }) + }) + test('rejects a partially numeric port', async () => { const result = await runCli('editor', '--port', '3000junk', '--no-open', '--json') @@ -85,7 +188,12 @@ describe('command parsing', () => { async function runCli(...args: string[]) { const child = Bun.spawn([process.execPath, executable, ...args], { - env: { ...process.env, PASCAL_HOME: testHome, PASCAL_NO_OPEN: '1' }, + env: { + ...process.env, + PASCAL_API_KEY: '', + PASCAL_HOME: testHome, + PASCAL_NO_OPEN: '1', + }, stdout: 'pipe', stderr: 'pipe', }) @@ -96,3 +204,29 @@ async function runCli(...args: string[]) { ]) return { exitCode, stdout, stderr } } + +async function runClaimCli(status: number, body: unknown, ...args: string[]) { + const apiKey = 'sk_live_cli-test-key' + const child = Bun.spawn( + [process.execPath, '--preload', claimFetchPreload, executable, 'agent', 'claim', ...args], + { + env: { + ...process.env, + PASCAL_API_KEY: apiKey, + PASCAL_CLAIM_TEST_AUTHORIZATION: `Bearer ${apiKey}`, + PASCAL_CLAIM_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body), + PASCAL_CLAIM_TEST_STATUS: String(status), + PASCAL_HOME: testHome, + PASCAL_NO_OPEN: '1', + }, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + return { exitCode, stdout, stderr } +}