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
21 changes: 21 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-or-name>` | Explicit form of `pascal open <project>`. |
| `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. |
Expand Down Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions packages/cli/src/agent-account.test.ts
Original file line number Diff line number Diff line change
@@ -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(`<html>credential ${API_KEY} rejected</html>`, {
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<unknown>): Promise<CliError> {
try {
await run()
throw new Error('Expected the operation to fail')
} catch (error) {
expect(error).toBeInstanceOf(CliError)
return error as CliError
}
}
171 changes: 171 additions & 0 deletions packages/cli/src/agent-account.ts
Original file line number Diff line number Diff line change
@@ -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<AgentClaim> {
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<unknown> {
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<string, unknown> {
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.',
)
}
Loading
Loading