From 0080060fbaaa6158ba935921fcf2f92db95e06d6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Thu, 10 Sep 2026 01:12:19 -0400 Subject: [PATCH] feat(cli): report hosted agent status --- packages/cli/README.md | 6 ++ packages/cli/src/agent-account.test.ts | 81 ++++++++++++++- packages/cli/src/agent-account.ts | 138 +++++++++++++++++++++++-- packages/cli/src/bin/pascal.ts | 32 +++++- packages/cli/src/cli.test.ts | 96 +++++++++++++++-- 5 files changed, 331 insertions(+), 22 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index a15fa5f12..a49e84e56 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -130,6 +130,7 @@ npx @pascal-app/cli editor --foreground --no-open | `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 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. | @@ -183,6 +184,7 @@ 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 @@ -190,6 +192,10 @@ the claim page. Use `--no-open` on a headless host. `--json` returns structured 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 diff --git a/packages/cli/src/agent-account.test.ts b/packages/cli/src/agent-account.test.ts index a7c94008e..54d6a20d0 100644 --- a/packages/cli/src/agent-account.test.ts +++ b/packages/cli/src/agent-account.test.ts @@ -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' @@ -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', () => { @@ -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(`credential ${API_KEY} rejected`, { 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): Promise { diff --git a/packages/cli/src/agent-account.ts b/packages/cli/src/agent-account.ts index ca01c26e4..fbf44f603 100644 --- a/packages/cli/src/agent-account.ts +++ b/packages/cli/src/agent-account.ts @@ -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 @@ -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 { const credential = apiKey.trim() if (!credential) { @@ -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, @@ -76,13 +85,75 @@ export async function startAgentClaim( } } -async function readJsonResponse(response: Response, signal: AbortSignal): Promise { +export async function getAgentStatus( + apiKey: string, + options: AgentAccountRequestOptions = {}, +): Promise { + 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 { 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 @@ -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 }) } @@ -108,7 +179,7 @@ async function readJsonResponse(response: Response, signal: AbortSignal): Promis try { return JSON.parse(text) as unknown } catch { - throw invalidResponse() + throw invalid() } } @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -162,6 +245,27 @@ 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', @@ -169,9 +273,23 @@ function invalidResponse(): CliError { ) } +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.', + ) +} diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index 8a84ada5b..9cc24f472 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' import { parseArgs } from 'node:util' -import { agentClaimHandoffUrl, startAgentClaim } from '../agent-account.js' +import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from '../agent-account.js' import { openBrowser } from '../browser.js' import { installGlobalPascalCommand, isNpxInvocation } from '../command-install.js' import { collectInfo, runDoctor } from '../diagnostics.js' @@ -52,6 +52,7 @@ USAGE: pascal project open pascal project resume [id-or-name] pascal agent claim [--no-open] [--json] + pascal agent status [--json] pascal mcp connect | status | config | setup pascal plugin list [--json] @@ -79,11 +80,15 @@ const AGENT_HELP = `Pascal agent — connect an autonomous agent to a person USAGE: pascal agent claim [--no-open] [--json] + pascal agent status [--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. +Use "pascal agent status" to verify whether that credential is active and +whether its autonomous agent has been claimed. + 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. @@ -600,8 +605,31 @@ async function runMcp(args: string[]): Promise { async function runAgent(args: string[], apiKey: string | undefined): Promise { const [subcommand, ...rest] = args + if (subcommand === 'status') { + const json = booleanOption(rest, 'json') + const status = await getAgentStatus(apiKey ?? '') + output( + json, + status, + [ + `Agent ID: ${JSON.stringify(status.agentId)}`, + `Mode: ${status.mode}`, + `Claimed: ${status.claimed ? 'yes' : 'no'}`, + `Organization scoped: ${status.organizationScoped ? 'yes' : 'no'}`, + ...(!status.claimed && status.mode === 'autonomous' + ? ['', 'Next: run "pascal agent claim" to link a person accountable for this agent.'] + : []), + ].join('\n'), + ) + return + } if (subcommand !== 'claim') { - throw new CliError('unknown_command', 'Use "pascal agent claim".', undefined, 2) + throw new CliError( + 'unknown_command', + 'Use "pascal agent claim" or "pascal agent status".', + undefined, + 2, + ) } const { values } = parseArgs({ args: rest, diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 002dfe821..706a99332 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -11,20 +11,21 @@ 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 (String(input) !== process.env.PASCAL_AGENT_TEST_ENDPOINT) { + throw new Error('Unexpected agent endpoint') } + if (init?.method !== process.env.PASCAL_AGENT_TEST_METHOD) throw new Error('Unexpected method') 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') + if (authorization !== process.env.PASCAL_AGENT_TEST_AUTHORIZATION) { + throw new Error('Unexpected agent authorization') } - return new Response(process.env.PASCAL_CLAIM_TEST_BODY, { + return new Response(process.env.PASCAL_AGENT_TEST_BODY, { headers: { 'content-type': 'application/json' }, - status: Number(process.env.PASCAL_CLAIM_TEST_STATUS), + status: Number(process.env.PASCAL_AGENT_TEST_STATUS), }) } `, @@ -127,6 +128,53 @@ describe('command parsing', () => { expect(result.stdout).toBe('') }) + test('prints the exact successful JSON agent status contract', async () => { + const status = { + schemaVersion: 1, + agentId: 'agent_cli_test', + mode: 'autonomous', + claimed: false, + organizationScoped: true, + } + + const result = await runStatusCli(200, { ...status, credentialName: '\u001b[2J' }, '--json') + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toEqual(status) + expect(result.stderr).toBe('') + }) + + test('prints terminal-safe human status and an unclaimed next action', async () => { + const result = await runStatusCli(200, { + schemaVersion: 1, + agentId: '\u001b[2Jmalicious', + mode: 'autonomous', + claimed: false, + organizationScoped: false, + }) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Mode: autonomous') + expect(result.stdout).toContain('Claimed: no') + expect(result.stdout).toContain('pascal agent claim') + expect(result.stdout).not.toContain('\u001b') + expect(result.stderr).toBe('') + }) + + test.each([ + [401, 'agent_status_unauthorized'], + [403, 'agent_status_forbidden'], + ])('preserves the hosted status HTTP %i error contract', async (status, errorCode) => { + const result = await runStatusCli(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') @@ -216,9 +264,39 @@ async function runClaimCli(status: number, body: unknown, ...args: string[]) { 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_AGENT_TEST_AUTHORIZATION: `Bearer ${apiKey}`, + PASCAL_AGENT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body), + PASCAL_AGENT_TEST_ENDPOINT: 'https://editor.pascal.app/api/auth/agent/claim/start', + PASCAL_AGENT_TEST_METHOD: 'POST', + PASCAL_AGENT_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 } +} + +async function runStatusCli(status: number, body: unknown, ...args: string[]) { + const apiKey = 'sk_live_cli-status-test-key' + const child = Bun.spawn( + [process.execPath, '--preload', claimFetchPreload, executable, 'agent', 'status', ...args], + { + env: { + ...process.env, + PASCAL_API_KEY: apiKey, + PASCAL_AGENT_TEST_AUTHORIZATION: `Bearer ${apiKey}`, + PASCAL_AGENT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body), + PASCAL_AGENT_TEST_ENDPOINT: 'https://editor.pascal.app/api/auth/agent/status', + PASCAL_AGENT_TEST_METHOD: 'GET', + PASCAL_AGENT_TEST_STATUS: String(status), PASCAL_HOME: testHome, PASCAL_NO_OPEN: '1', },