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
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ npx @pascal-app/cli editor --foreground --no-open
| `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 agent status [--json]` | Verify the hosted agent credential and inspect its claim and organization scope. |
| `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 @@ -183,13 +184,18 @@ accountability link:

```bash
PASCAL_API_KEY='sk_live_...' pascal agent claim
PASCAL_API_KEY='sk_live_...' pascal agent status
```

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.

`pascal agent status` confirms that the credential remains active and reports the agent ID,
autonomous or delegated mode, claim state, and whether the key is scoped to an organization.
It does not expose the accountable person's identity or inspect local editor projects.

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
Expand Down
81 changes: 80 additions & 1 deletion packages/cli/src/agent-account.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { agentClaimHandoffUrl, startAgentClaim } from './agent-account.js'
import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from './agent-account.js'
import { CliError } from './errors.js'

const API_KEY = 'sk_live_private-agent-key'
Expand All @@ -8,6 +8,13 @@ const VALID_CLAIM = {
claimUrl: 'https://editor.pascal.app/settings/agents/claim',
expiresAt: '2026-09-10T18:30:00.000Z',
}
const VALID_STATUS = {
schemaVersion: 1 as const,
agentId: 'agent_test',
mode: 'autonomous' as const,
claimed: false,
organizationScoped: true,
}

describe('agent account claims', () => {
test('builds a prefilled handoff URL without changing the API result', () => {
Expand Down Expand Up @@ -123,6 +130,78 @@ describe('agent account claims', () => {

expect(error.code).toBe('agent_claim_timeout')
})

test('checks status with the agent credential and returns the bounded public result', async () => {
let endpoint = ''
let method = ''
let authorization: string | null = null
let redirect: RequestRedirect | undefined
const fetchMock: typeof fetch = async (input, init) => {
endpoint = String(input)
method = init?.method ?? ''
authorization = new Headers(init?.headers).get('authorization')
redirect = init?.redirect
return Response.json({
...VALID_STATUS,
agentName: '\u001b[2Jmalicious',
credentialName: API_KEY,
})
}

const result = await getAgentStatus(API_KEY, { fetch: fetchMock })

expect(endpoint).toBe('https://editor.pascal.app/api/auth/agent/status')
expect(method).toBe('GET')
expect(authorization).toBe(`Bearer ${API_KEY}`)
expect(redirect).toBe('error')
expect(result).toEqual(VALID_STATUS)
expect(JSON.stringify(result)).not.toContain(API_KEY)
})

test.each([
[401, 'agent_status_unauthorized'],
[403, 'agent_status_forbidden'],
[503, 'agent_status_failed'],
])('maps status 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>`, { status })

const error = await captureError(() => getAgentStatus(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 and oversized status responses', async () => {
const malformed: typeof fetch = async () => Response.json({ ...VALID_STATUS, claimed: 'false' })
const oversized: typeof fetch = async () =>
new Response(JSON.stringify({ ...VALID_STATUS, padding: 'x'.repeat(33 * 1024) }))

expect((await captureError(() => getAgentStatus(API_KEY, { fetch: malformed }))).code).toBe(
'agent_status_invalid_response',
)
expect((await captureError(() => getAgentStatus(API_KEY, { fetch: oversized }))).code).toBe(
'agent_status_invalid_response',
)
})

test('reports status network failures and bounded timeouts', 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 })
})

expect((await captureError(() => getAgentStatus(API_KEY, { fetch: unavailable }))).code).toBe(
'agent_status_unavailable',
)
expect(
(await captureError(() => getAgentStatus(API_KEY, { fetch: pending, timeoutMs: 1 }))).code,
).toBe('agent_status_timeout')
})
})

async function captureError(run: () => Promise<unknown>): Promise<CliError> {
Expand Down
138 changes: 128 additions & 10 deletions packages/cli/src/agent-account.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CliError } from './errors.js'

const CLAIM_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/claim/start'
const STATUS_ENDPOINT = 'https://editor.pascal.app/api/auth/agent/status'
const CLAIM_PAGE = 'https://editor.pascal.app/settings/agents/claim'
const MAX_RESPONSE_BYTES = 32 * 1024
const DEFAULT_TIMEOUT_MS = 15_000
Expand All @@ -13,20 +14,28 @@ export interface AgentClaim {
expiresAt: string
}

export interface AgentStatus {
schemaVersion: 1
agentId: string
mode: 'autonomous' | 'delegated'
claimed: boolean
organizationScoped: boolean
}

export function agentClaimHandoffUrl(claim: AgentClaim): string {
const url = new URL(claim.claimUrl)
url.searchParams.set('code', claim.claimCode)
return url.toString()
}

interface StartAgentClaimOptions {
interface AgentAccountRequestOptions {
fetch?: typeof fetch
timeoutMs?: number
}

export async function startAgentClaim(
apiKey: string,
options: StartAgentClaimOptions = {},
options: AgentAccountRequestOptions = {},
): Promise<AgentClaim> {
const credential = apiKey.trim()
if (!credential) {
Expand Down Expand Up @@ -64,7 +73,7 @@ export async function startAgentClaim(
if (response.body) void response.body.cancel().catch(() => {})
throw claimResponseError(response.status)
}
const body = await readJsonResponse(response, controller.signal)
const body = await readJsonResponse(response, controller.signal, invalidResponse, claimTimeout)
if (!isAgentClaim(body)) throw invalidResponse()
return {
claimCode: body.claimCode,
Expand All @@ -76,13 +85,75 @@ export async function startAgentClaim(
}
}

async function readJsonResponse(response: Response, signal: AbortSignal): Promise<unknown> {
export async function getAgentStatus(
apiKey: string,
options: AgentAccountRequestOptions = {},
): Promise<AgentStatus> {
const credential = apiKey.trim()
if (!credential) {
throw new CliError(
'agent_api_key_missing',
"Set PASCAL_API_KEY to this 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)(STATUS_ENDPOINT, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${credential}`,
},
redirect: 'error',
signal: controller.signal,
})
} catch {
if (controller.signal.aborted) throw statusTimeout()
throw new CliError(
'agent_status_unavailable',
'Pascal could not be reached while checking the agent status. Try again.',
)
}

if (!response.ok) {
if (response.body) void response.body.cancel().catch(() => {})
throw statusResponseError(response.status)
}
const body = await readJsonResponse(
response,
controller.signal,
invalidStatusResponse,
statusTimeout,
)
if (!isAgentStatus(body)) throw invalidStatusResponse()
return {
schemaVersion: 1,
agentId: body.agentId,
mode: body.mode,
claimed: body.claimed,
organizationScoped: body.organizationScoped,
}
} finally {
clearTimeout(timeout)
}
}

async function readJsonResponse(
response: Response,
signal: AbortSignal,
invalid: () => CliError,
timeout: () => CliError,
): Promise<unknown> {
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
throw invalidResponse()
throw invalid()
}

if (!response.body) throw invalidResponse()
if (!response.body) throw invalid()
const reader = response.body.getReader()
const decoder = new TextDecoder()
let bytes = 0
Expand All @@ -92,14 +163,14 @@ async function readJsonResponse(response: Response, signal: AbortSignal): Promis
try {
chunk = await reader.read()
} catch {
if (signal.aborted) throw claimTimeout()
throw invalidResponse()
if (signal.aborted) throw timeout()
throw invalid()
}
if (chunk.done) break
bytes += chunk.value.byteLength
if (bytes > MAX_RESPONSE_BYTES) {
void reader.cancel().catch(() => {})
throw invalidResponse()
throw invalid()
}
text += decoder.decode(chunk.value, { stream: true })
}
Expand All @@ -108,7 +179,7 @@ async function readJsonResponse(response: Response, signal: AbortSignal): Promis
try {
return JSON.parse(text) as unknown
} catch {
throw invalidResponse()
throw invalid()
}
}

Expand All @@ -121,6 +192,18 @@ function isAgentClaim(value: unknown): value is AgentClaim {
return value.claimUrl === CLAIM_PAGE
}

function isAgentStatus(value: unknown): value is AgentStatus {
return (
isRecord(value) &&
value.schemaVersion === 1 &&
typeof value.agentId === 'string' &&
value.agentId.length > 0 &&
(value.mode === 'autonomous' || value.mode === 'delegated') &&
typeof value.claimed === 'boolean' &&
typeof value.organizationScoped === 'boolean'
)
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
Expand Down Expand Up @@ -162,16 +245,51 @@ function claimResponseError(status: number): CliError {
}
}

function statusResponseError(status: number): CliError {
switch (status) {
case 401:
return new CliError('agent_status_unauthorized', 'PASCAL_API_KEY is invalid or revoked.', {
status,
})
case 403:
return new CliError(
'agent_status_forbidden',
'PASCAL_API_KEY must belong to a Pascal agent.',
{ status },
)
default:
return new CliError(
'agent_status_failed',
`Pascal could not check the agent status (HTTP ${status}).`,
{ status },
)
}
}

function invalidResponse(): CliError {
return new CliError(
'agent_claim_invalid_response',
'Pascal returned an invalid agent claim response. Try again.',
)
}

function invalidStatusResponse(): CliError {
return new CliError(
'agent_status_invalid_response',
'Pascal returned an invalid agent status response. Try again.',
)
}

function claimTimeout(): CliError {
return new CliError(
'agent_claim_timeout',
'Pascal did not respond while starting the agent claim. Try again.',
)
}

function statusTimeout(): CliError {
return new CliError(
'agent_status_timeout',
'Pascal did not respond while checking the agent status. Try again.',
)
}
Loading
Loading