diff --git a/.changeset/agent-auth-package.md b/.changeset/agent-auth-package.md new file mode 100644 index 000000000..c1b711cdd --- /dev/null +++ b/.changeset/agent-auth-package.md @@ -0,0 +1,5 @@ +--- +"@epilot/agent-auth": minor +--- + +New package `@epilot/agent-auth`: Agent Auth Protocol (AAP) client with Ed25519 key generation, RFC 7638 thumbprints, host/agent JWT signing, discovery caching, agent registration, capability requests, status polling (`waitForApproval`), capability execution, revoke/reactivate/rotate, plus epilot helpers (`listEpilotOrganizations`, `issueEpilotAccessToken`, `organizationAccessCapability`, `organizationGrants`, `epilotAgentAuthIssuer`) and access profiles (`ACCESS_PROFILES`, `ACCESS_PROFILE_INFO`, `access_profile` constraint, `requestOrganizationAccess` with client-side `reason_required` checks; `anonymize` is only accepted with read profiles). diff --git a/.changeset/cli-agent-auth-login.md b/.changeset/cli-agent-auth-login.md new file mode 100644 index 000000000..d06ecaf24 --- /dev/null +++ b/.changeset/cli-agent-auth-login.md @@ -0,0 +1,5 @@ +--- +"@epilot/cli": minor +--- + +Optional Agent Auth mode. `epilot auth login` is unchanged and stays the default (browser login, `--token`, `--readonly`, `--anonymize`, profiles). The new opt-in `epilot auth login --agent [--org ] [--access ] [--reason "…"]` registers the CLI as an Agent Auth agent that you approve once in the browser; tokens are then issued per organization and refreshed silently. Access profiles (`read`, `config:read`, `config:write`, `data:read`, `data:write`, `full`) scope what the tokens may do; every profile other than `read` needs a `--reason` (prompted for in a TTY) and expires; `--anonymize` is only available with read profiles. New `epilot org list|use|request|current` commands (agent mode only) switch organizations, request access with a profile and purpose (`--write` is a deprecated alias for `--access full`, `--full-pii` for unmasked read access) and show the profile and grant expiry. `auth status` shows the agent, its grants, expiry and reasons when an agent exists; `auth logout` also revokes it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cd4d23e4..85eedd080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - name: Build and test CLI if: steps.check.outputs.cli == 'true' run: | - pnpm --filter @epilot/cli build + pnpm --filter @epilot/cli... build pnpm --filter @epilot/cli test - name: Bump versions, commit, tag and push @@ -230,7 +230,7 @@ jobs: registry-url: https://registry.npmjs.org/ - run: pnpm install --frozen-lockfile - - run: pnpm --filter @epilot/cli build + - run: pnpm --filter @epilot/cli... build - name: Publish @epilot/cli run: pnpm publish --ignore-scripts --no-git-checks || true diff --git a/packages/agent-auth/LICENSE b/packages/agent-auth/LICENSE new file mode 100644 index 000000000..9169ae203 --- /dev/null +++ b/packages/agent-auth/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 epilot GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/agent-auth/README.md b/packages/agent-auth/README.md new file mode 100644 index 000000000..b80ffaf12 --- /dev/null +++ b/packages/agent-auth/README.md @@ -0,0 +1,242 @@ +# @epilot/agent-auth + +[![npm version](https://img.shields.io/npm/v/@epilot/agent-auth.svg)](https://www.npmjs.com/package/@epilot/agent-auth) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +[Agent Auth Protocol](https://agentauthprotocol.com/specification/v1.0-draft) (AAP) client for epilot. +Zero runtime dependencies, Node >= 18, ESM + CJS, TypeScript types included. + +## What is the Agent Auth Protocol? + +AAP makes agents first-class principals instead of anonymous holders of a user's token: + +- A **host** (a machine, an application, a runtime) owns an Ed25519 key pair. +- An **agent** is a scoped actor registered under a host with its own Ed25519 key pair. It is `pending` until a + user approves it, then `active` until it expires or is revoked. +- A **capability** is an action the server offers. A **grant** ties a capability to an agent, optionally with + **constraints** on the arguments (for epilot: which organization, read-only, anonymized). +- **Host JWTs** (`typ: host+jwt`) authenticate host operations (register, status, revoke, rotate). + **Agent JWTs** (`typ: agent+jwt`, <= 60 s) authenticate `request-capability` and `execute`. +- **Approval** is RFC 8628 style device authorization: the server returns a `verification_uri_complete` and a + `user_code`; the user approves in the browser; the client polls `/agent/status`. + +epilot's AAP server lives under `https://access-token.sls.epilot.io/v1/access-tokens/agent-auth` and offers two capabilities: + +| Capability | Arguments | Result | +| --- | --- | --- | +| `epilot.organizations.list` | — | the linked user's organizations annotated with this agent's grants | +| `epilot.access_token.issue` | `{organization_id, access_profile?, read_only?, anonymize?, expires_in?}` | a short-lived epilot API token for that organization | + +Full context: the epilot RFC "Agent Auth Protocol for epilot — agents as first-class principals". + +## Install + +```bash +npm install @epilot/agent-auth +``` + +## Keys + +```ts +import { generateKeyPair, keyPairFromPrivateJwk, jwkThumbprint } from '@epilot/agent-auth'; + +const hostKey = generateKeyPair(); // { publicKey, privateKey, thumbprint } +// Persist hostKey.privateKey (a private Ed25519 JWK) with mode 0600, then later: +const restored = keyPairFromPrivateJwk(storedPrivateJwk); +jwkThumbprint(hostKey.publicKey); // RFC 7638 SHA-256 thumbprint, the `iss` of your JWTs +``` + +## JWTs + +```ts +import { createHostJwt, createAgentJwt, decodeJwt } from '@epilot/agent-auth'; + +// Host JWT: iss = host thumbprint, carries host_public_key (and agent_public_key when registering) +const hostJwt = createHostJwt({ hostKey, audience: issuer, agentPublicKey: agentKey.publicKey }); + +// Agent JWT: iss = host thumbprint, sub = agent id, aud = capability location, exp <= 60 s +const agentJwt = createAgentJwt({ + agentKey, + hostThumbprint: hostKey.thumbprint, + agentId, + audience: executeUrl, + capabilities: ['epilot.organizations.list'], // optional narrowing +}); + +decodeJwt(agentJwt); // { header, payload } — no verification, for debugging +``` + +You normally do not build JWTs yourself; `AgentAuthClient` does it for every call. + +## AgentAuthClient + +```ts +import { AgentAuthClient, epilotAgentAuthIssuer } from '@epilot/agent-auth'; + +const client = new AgentAuthClient({ + baseUrl: epilotAgentAuthIssuer('production'), // or 'staging' | 'dev' + // fetch?: custom fetch, discoveryTtlMs?: 3_600_000, timeoutMs?: 15_000 +}); +``` + +| Method | Auth | Description | +| --- | --- | --- | +| `discover(force?)` | none | `GET /.well-known/agent-configuration`, cached for `discoveryTtlMs` | +| `endpoint(name)` | — | resolve an endpoint URL from discovery | +| `registerAgent(hostKey, agentKey, body)` | host JWT | register an agent; returns grants and an `approval` when pending | +| `requestCapability(identity, body)` | agent JWT | ask for more grants; returns the new grants and an `approval` | +| `getAgentStatus(hostKey, agentId)` | host JWT | full agent state incl. grants | +| `waitForApproval(hostKey, agentId, approval, options?)` | host JWT | poll status at `approval.interval` until active (or until `options.pendingGrantIds` are decided); throws `approval_expired` | +| `execute(identity, { capability, arguments }, location?)` | agent JWT | run a capability; unwraps `{data}` | +| `listCapabilities(auth?, query?)` / `describeCapability(name, auth?)` | optional | capability catalogue, with grant status when authenticated | +| `revokeAgent` / `reactivateAgent` / `rotateAgentKey` | host JWT | agent lifecycle | +| `rotateHostKey` / `revokeHost` | host JWT | host lifecycle (revoking a host revokes its agents) | +| `introspect(token, bearer?)` | server bearer | server-to-server validation of an agent JWT | + +`identity` is `{ hostKey, agentKey, agentId }`. Every error is an `AgentAuthError` with `status`, `code` +(the server's `error`, e.g. `constraint_violated`, `agent_revoked`, or `network_error`), `message` and `details`. + +## epilot helpers + +```ts +import { + EPILOT_CAPABILITIES, // { organizationsList: 'epilot.organizations.list', accessTokenIssue: 'epilot.access_token.issue' } + organizationAccessCapability, // build an `epilot.access_token.issue` request with constraints + listEpilotOrganizations, // execute epilot.organizations.list + issueEpilotAccessToken, // execute epilot.access_token.issue + organizationGrants, // map grants to { organizationId, profile, readOnly, anonymized, expiresAt, reason } + requestOrganizationAccess, // request-capability with profile + reason, validated client-side + epilotAgentAuthIssuer, // issuer URL per stage +} from '@epilot/agent-auth'; + +organizationAccessCapability({ organizationId: '739224', readOnly: true, anonymize: true }); +// → { name: 'epilot.access_token.issue', constraints: { organization_id: '739224', read_only: true, anonymize: true } } +organizationAccessCapability({ organizationId: '739224', profile: 'config:write' }); +// → { name: 'epilot.access_token.issue', constraints: { organization_id: '739224', access_profile: 'config:write' } } +organizationAccessCapability(); // no constraints: the approval page grants the user's login organization +``` + +## Access profiles and purpose + +An `epilot.access_token.issue` grant carries an `access_profile` constraint that scopes what tokens issued under it +may do. `read` is the default when the constraint is absent. + +| `access_profile` | Title | Read-only | Anonymize allowed | Escalation grant TTL | +| --- | --- | --- | --- | --- | +| `read` | Read everything you can see | yes | yes | none (agent lifetime) | +| `config:read` | Read configuration | yes | yes | 7 days | +| `config:write` | Change configuration | no | no | 24 hours | +| `data:read` | Read business data | yes | yes | 7 days | +| `data:write` | Change business data | no | no | 24 hours | +| `full` | Everything you can do | no | no | 24 hours | + +```ts +import { ACCESS_PROFILES, ACCESS_PROFILE_INFO, type AccessProfile, mostPermissiveProfile } from '@epilot/agent-auth'; + +ACCESS_PROFILES; // ['read', 'config:read', 'config:write', 'data:read', 'data:write', 'full'] +ACCESS_PROFILE_INFO['config:write']; +// → { title: 'Change configuration', description: '…', readOnly: false, anonymizeAllowed: false, escalationTtlSeconds: 86400 } +mostPermissiveProfile(['config:read', 'data:write']); // 'data:write' +``` + +**Anonymize is a read-only property.** Anonymized data must never be written back, so `anonymize: true` is only +valid with `read`, `config:read` and `data:read`. `organizationAccessCapability({ profile: 'full', anonymize: true })` +throws `AgentAuthError(400, 'invalid_capabilities', 'anonymize is only available with read profiles')` — the server +rejects such a request the same way, without silent normalisation. A connection that needs both anonymized reading and +writing holds two grants (e.g. `data:read` + anonymize and `config:write`). + +**Purpose (`reason`).** Every request for a profile other than `read` must state why (10–200 characters). The server +stores it on the grant, shows it on the approval page and copies it into the token's `actor.purpose`. +`requestOrganizationAccess` enforces both rules before anything is sent: + +```ts +import { requestOrganizationAccess } from '@epilot/agent-auth'; + +// read: anonymize defaults to true, reason optional +await requestOrganizationAccess(client, identity, { organizationId: '911210' }); + +// escalation: anonymize defaults to false for write profiles, reason required +const request = await requestOrganizationAccess(client, identity, { + organizationId: '911210', + profile: 'config:write', + reason: 'Fix the entity mapping of the PV registration journey', +}); +await client.waitForApproval(hostKey, identity.agentId, request.approval as never, { + pendingGrantIds: request.agent_capability_grants.map((g) => g.id!).filter(Boolean), +}); + +// throws reason_required +await requestOrganizationAccess(client, identity, { organizationId: '911210', profile: 'data:write' }); +``` + +Grants returned by `/agent/status` map to `{ profile, readOnly, anonymized, expiresAt, reason }` through +`organizationGrants()`; `isGrantUsable(grant)` is `true` for active grants that have not passed `expiresAt`. When +issuing a token, `issueEpilotAccessToken(client, identity, { organization_id, access_profile })` picks the grant to +issue under; omit `access_profile` to use the matched grant's profile. Asking for a profile no grant covers fails with +`403 constraint_violated`. + +## Full example flow + +```ts +import { + AgentAuthClient, + EPILOT_CAPABILITIES, + epilotAgentAuthIssuer, + generateKeyPair, + issueEpilotAccessToken, + listEpilotOrganizations, + organizationAccessCapability, + requestOrganizationAccess, +} from '@epilot/agent-auth'; +import { hostname } from 'node:os'; + +const client = new AgentAuthClient({ baseUrl: epilotAgentAuthIssuer() }); +const hostKey = generateKeyPair(); // persist this once per machine +const agentKey = generateKeyPair(); // one per agent + +// 1. Register the agent. The user has not approved anything yet → status "pending". +const registration = await client.registerAgent(hostKey, agentKey, { + name: `my-agent @ ${hostname()}`, + host_name: hostname(), + mode: 'delegated', + reason: 'Sync contacts nightly', + capabilities: [ + EPILOT_CAPABILITIES.organizationsList, + organizationAccessCapability({ readOnly: true, anonymize: true }), + ], +}); +const identity = { hostKey, agentKey, agentId: registration.agent_id }; + +// 2. Send the user to the approval page and wait. +if (registration.approval?.method === 'device_authorization') { + console.log(`Open ${registration.approval.verification_uri_complete}`); + console.log(`Code: ${registration.approval.user_code}`); + await client.waitForApproval(hostKey, identity.agentId, registration.approval); +} + +// 3. Which organizations may this agent access? +const { organizations } = await listEpilotOrganizations(client, identity); +const org = organizations.find((o) => o.access.granted)!; + +// 4. Mint an epilot API token (short-lived; call again whenever it expires). +const issued = await issueEpilotAccessToken(client, identity, { organization_id: org.organization_id }); +console.log(issued.token, issued.expires_at); + +// 5. Later: ask for more (write access to business data in another organization). The user approves again +// in the browser; the reason is shown there and is required for every profile other than `read`. +const request = await requestOrganizationAccess(client, identity, { + organizationId: '911210', + profile: 'data:write', + reason: 'Import meter readings from the portal export', +}); +await client.waitForApproval(hostKey, identity.agentId, request.approval as never, { + pendingGrantIds: request.agent_capability_grants.map((g) => g.id!).filter(Boolean), +}); + +// 6. Done with this agent. +await client.revokeAgent(hostKey, identity.agentId); +``` + +## License + +MIT diff --git a/packages/agent-auth/package.json b/packages/agent-auth/package.json new file mode 100644 index 000000000..49b7656b2 --- /dev/null +++ b/packages/agent-auth/package.json @@ -0,0 +1,58 @@ +{ + "name": "@epilot/agent-auth", + "version": "0.1.0", + "description": "Agent Auth Protocol (AAP) client for epilot: Ed25519 keys, host/agent JWTs, capability requests and epilot access token issuance", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "build:watch": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check src test", + "lint:fix": "biome check --write src test", + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm build && pnpm test" + }, + "keywords": [ + "epilot", + "agent-auth-protocol", + "aap", + "agent", + "authentication", + "ed25519" + ], + "author": "epilot GmbH", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/epilot-dev/sdk-js.git", + "directory": "packages/agent-auth" + }, + "homepage": "https://github.com/epilot-dev/sdk-js/tree/main/packages/agent-auth#readme", + "bugs": { + "url": "https://github.com/epilot-dev/sdk-js/issues" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } +} diff --git a/packages/agent-auth/src/client.ts b/packages/agent-auth/src/client.ts new file mode 100644 index 000000000..4d0ed56b5 --- /dev/null +++ b/packages/agent-auth/src/client.ts @@ -0,0 +1,276 @@ +/** + * Agent Auth Protocol HTTP client (spec §4). Uses the global fetch (Node >= 18). + */ +import { createAgentJwt, createHostJwt } from './jwt.js'; +import { + type AgentConfiguration, + type AgentIdentity, + type AgentStatusResponse, + AgentAuthError, + type Capability, + type DeviceAuthorizationApproval, + type ExecuteRequest, + type IntrospectResponse, + type KeyPair, + type RegisterAgentRequest, + type RegisterAgentResponse, + type RequestCapabilityRequest, + type RequestCapabilityResponse, +} from './types.js'; + +export interface AgentAuthClientOptions { + /** Server base URL, e.g. https://access-token.sls.epilot.io/v1/access-tokens/agent-auth. */ + baseUrl: string; + fetch?: typeof fetch; + /** Discovery cache TTL; the spec default is one hour. */ + discoveryTtlMs?: number; + timeoutMs?: number; +} + +export interface WaitForApprovalOptions { + signal?: AbortSignal; + onPoll?: (status: AgentStatusResponse) => void; + /** Wait until none of these grants is pending instead of until the agent leaves `pending`. */ + pendingGrantIds?: string[]; +} + +const trimSlash = (value: string) => value.replace(/\/+$/, ''); + +const safeJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return undefined; + } +}; + +export class AgentAuthClient { + readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly discoveryTtlMs: number; + private readonly timeoutMs: number; + private discovery?: { value: AgentConfiguration; fetchedAt: number }; + + constructor(options: AgentAuthClientOptions) { + this.baseUrl = trimSlash(options.baseUrl); + this.fetchImpl = options.fetch ?? fetch; + this.discoveryTtlMs = options.discoveryTtlMs ?? 3_600_000; + this.timeoutMs = options.timeoutMs ?? 15_000; + } + + /** GET /.well-known/agent-configuration (cached). */ + async discover(force = false): Promise { + if (!force && this.discovery && Date.now() - this.discovery.fetchedAt < this.discoveryTtlMs) { + return this.discovery.value; + } + const value = (await this.request('GET', `${this.baseUrl}/.well-known/agent-configuration`)) as AgentConfiguration; + this.discovery = { value, fetchedAt: Date.now() }; + return value; + } + + /** Resolve an endpoint from discovery; relative paths are resolved against the issuer. */ + async endpoint(name: keyof AgentConfiguration['endpoints']): Promise { + const config = await this.discover(); + const path = config.endpoints[name]; + return /^https?:\/\//.test(path) ? path : `${trimSlash(config.issuer)}${path.startsWith('/') ? '' : '/'}${path}`; + } + + /** POST /agent/register with a host JWT that carries the agent's public key. */ + async registerAgent(hostKey: KeyPair, agentKey: KeyPair, body: RegisterAgentRequest): Promise { + const config = await this.discover(); + const jwt = createHostJwt({ hostKey, audience: config.issuer, agentPublicKey: agentKey.publicKey }); + return this.request('POST', await this.endpoint('register'), body, jwt) as Promise; + } + + /** POST /agent/request-capability with an agent JWT. */ + async requestCapability(identity: AgentIdentity, body: RequestCapabilityRequest): Promise { + const config = await this.discover(); + const jwt = createAgentJwt({ + agentKey: identity.agentKey, + hostThumbprint: identity.hostKey.thumbprint, + agentId: identity.agentId, + audience: config.issuer, + }); + return this.request( + 'POST', + await this.endpoint('request_capability'), + body, + jwt, + ) as Promise; + } + + /** GET /agent/status?agent_id=… with a host JWT. */ + async getAgentStatus(hostKey: KeyPair, agentId: string): Promise { + const config = await this.discover(); + const jwt = createHostJwt({ hostKey, audience: config.issuer }); + const url = new URL(await this.endpoint('status')); + url.searchParams.set('agent_id', agentId); + return this.request('GET', url.toString(), undefined, jwt) as Promise; + } + + /** + * Poll /agent/status until the agent leaves `pending` (or, when `pendingGrantIds` + * is given, until none of those grants is pending). Honors the approval + * interval and stops at the approval expiry. + */ + async waitForApproval( + hostKey: KeyPair, + agentId: string, + approval: Pick | undefined, + options: WaitForApprovalOptions = {}, + ): Promise { + const intervalMs = Math.max(1, approval?.interval ?? 5) * 1000; + const deadline = Date.now() + (approval?.expires_in ?? 600) * 1000; + for (;;) { + if (options.signal?.aborted) throw new AgentAuthError(499, 'aborted', 'Approval wait was aborted.'); + const status = await this.getAgentStatus(hostKey, agentId); + options.onPoll?.(status); + const stillPending = options.pendingGrantIds?.length + ? status.agent_capability_grants.some( + (grant) => grant.id && options.pendingGrantIds?.includes(grant.id) && grant.status === 'pending', + ) + : status.status === 'pending'; + if (!stillPending) return status; + if (Date.now() + intervalMs > deadline) { + throw new AgentAuthError(408, 'approval_expired', 'The user did not approve the request in time.'); + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + /** POST /capability/execute with an agent JWT whose `aud` is the capability location. */ + async execute(identity: AgentIdentity, body: ExecuteRequest, location?: string): Promise { + const target = location ?? (await this.discover()).default_location; + const jwt = createAgentJwt({ + agentKey: identity.agentKey, + hostThumbprint: identity.hostKey.thumbprint, + agentId: identity.agentId, + audience: target, + capabilities: [body.capability], + }); + const result = (await this.request('POST', target, body, jwt)) as { data?: T; status?: string; result?: T }; + if (result && typeof result === 'object' && 'data' in result) return result.data as T; + if (result?.status === 'completed') return result.result as T; + return result as unknown as T; + } + + /** GET /capability/list — anonymous, or with grant status when authenticated. */ + async listCapabilities( + auth?: { hostKey: KeyPair } | AgentIdentity, + query: { query?: string; cursor?: string; limit?: number } = {}, + ): Promise<{ capabilities: Capability[]; next_cursor?: string; has_more: boolean }> { + const config = await this.discover(); + const url = new URL(await this.endpoint('capabilities')); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + const jwt = auth ? this.jwtFor(auth, config.issuer) : undefined; + return this.request('GET', url.toString(), undefined, jwt) as Promise<{ + capabilities: Capability[]; + next_cursor?: string; + has_more: boolean; + }>; + } + + /** GET /capability/describe?name=… */ + async describeCapability(name: string, auth?: { hostKey: KeyPair } | AgentIdentity): Promise { + const config = await this.discover(); + const url = new URL(await this.endpoint('describe_capability')); + url.searchParams.set('name', name); + const jwt = auth ? this.jwtFor(auth, config.issuer) : undefined; + return this.request('GET', url.toString(), undefined, jwt) as Promise; + } + + async reactivateAgent(hostKey: KeyPair, agentId: string): Promise { + return this.hostPost('reactivate', hostKey, { agent_id: agentId }) as Promise; + } + + async revokeAgent(hostKey: KeyPair, agentId: string): Promise<{ agent_id: string; status: 'revoked' }> { + return this.hostPost('revoke', hostKey, { agent_id: agentId }) as Promise<{ agent_id: string; status: 'revoked' }>; + } + + async rotateAgentKey( + hostKey: KeyPair, + agentId: string, + newAgentKey: KeyPair, + ): Promise<{ agent_id: string; status: string }> { + return this.hostPost('rotate_key', hostKey, { agent_id: agentId, public_key: newAgentKey.publicKey }) as Promise<{ + agent_id: string; + status: string; + }>; + } + + async rotateHostKey(hostKey: KeyPair, newHostKey: KeyPair): Promise<{ host_id: string; status: string }> { + return this.hostPost('rotate_host_key', hostKey, { public_key: newHostKey.publicKey }) as Promise<{ + host_id: string; + status: string; + }>; + } + + async revokeHost(hostKey: KeyPair): Promise<{ host_id: string; status: 'revoked'; agents_revoked: number }> { + return this.hostPost('revoke_host', hostKey, {}) as Promise<{ + host_id: string; + status: 'revoked'; + agents_revoked: number; + }>; + } + + /** Server-to-server: validate an agent JWT. `bearer` is whatever the server requires (epilot: an epilot token). */ + async introspect(token: string, bearer?: string): Promise { + return this.request('POST', await this.endpoint('introspect'), { token }, bearer) as Promise; + } + + private jwtFor(auth: { hostKey: KeyPair } | AgentIdentity, audience: string) { + return 'agentId' in auth + ? createAgentJwt({ + agentKey: auth.agentKey, + hostThumbprint: auth.hostKey.thumbprint, + agentId: auth.agentId, + audience, + }) + : createHostJwt({ hostKey: auth.hostKey, audience }); + } + + private async hostPost(name: keyof AgentConfiguration['endpoints'], hostKey: KeyPair, body: unknown) { + const config = await this.discover(); + const jwt = createHostJwt({ hostKey, audience: config.issuer }); + return this.request('POST', await this.endpoint(name), body, jwt); + } + + private async request(method: string, url: string, body?: unknown, bearer?: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + let response: Response; + try { + response = await this.fetchImpl(url, { + method, + headers: { + accept: 'application/json', + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + } catch (error) { + throw new AgentAuthError(0, 'network_error', `Agent Auth request failed: ${String(error)}`); + } finally { + clearTimeout(timer); + } + const text = await response.text(); + const json = text ? safeJson(text) : undefined; + if (!response.ok) { + const error = (json ?? {}) as { error?: string; message?: string; error_description?: string } & Record< + string, + unknown + >; + throw new AgentAuthError( + response.status, + error.error ?? `http_${response.status}`, + error.message ?? error.error_description ?? `Agent Auth server responded with ${response.status}.`, + error, + ); + } + return json; + } +} diff --git a/packages/agent-auth/src/epilot.ts b/packages/agent-auth/src/epilot.ts new file mode 100644 index 000000000..0e627bf23 --- /dev/null +++ b/packages/agent-auth/src/epilot.ts @@ -0,0 +1,316 @@ +/** + * epilot-specific helpers on top of the generic Agent Auth Protocol client: + * the two epilot capabilities, access profiles and the organization grant model. + */ +import type { AgentAuthClient } from './client.js'; +import { + type AgentIdentity, + AgentAuthError, + type CapabilityGrant, + type CapabilityRequest, + type RequestCapabilityResponse, +} from './types.js'; + +export const EPILOT_CAPABILITIES = { + /** The linked user's organizations annotated with this agent's grants. Host default capability. */ + organizationsList: 'epilot.organizations.list', + /** Mint a short-lived epilot access token for an organization the agent has a grant for. */ + accessTokenIssue: 'epilot.access_token.issue', +} as const; + +// ─── Access profiles ───────────────────────────────────────────────────────── + +/** + * Access profiles of an `epilot.access_token.issue` grant (constraint `access_profile`). + * `read` is the default when the constraint is absent. + */ +export const ACCESS_PROFILES = ['read', 'config:read', 'config:write', 'data:read', 'data:write', 'full'] as const; + +export type AccessProfile = (typeof ACCESS_PROFILES)[number]; + +export interface AccessProfileInfo { + /** User-facing title. */ + title: string; + /** Fallback description (the server sends its own on the approval page). */ + description: string; + /** Whether tokens issued under this profile are read-only. */ + readOnly: boolean; + /** Anonymize is a read-only property: write profiles never issue anonymized tokens. */ + anonymizeAllowed: boolean; + /** Lifetime of an escalation grant in seconds; `undefined` means the grant lives as long as the agent. */ + escalationTtlSeconds?: number; +} + +const DAY = 86_400; + +export const ACCESS_PROFILE_INFO: Readonly> = { + read: { + title: 'Read everything you can see', + description: 'Look at data and configuration you have access to. Nothing can be created or changed.', + readOnly: true, + anonymizeAllowed: true, + }, + 'config:read': { + title: 'Read configuration', + description: + 'Look at journeys, automations, workflows, schemas, portals, designs and other configuration. No business data, nothing changed.', + readOnly: true, + anonymizeAllowed: true, + escalationTtlSeconds: 7 * DAY, + }, + 'config:write': { + title: 'Change configuration', + description: + 'Create and change journeys, automations, workflows, schemas, portals, designs and other configuration. No business data.', + readOnly: false, + anonymizeAllowed: false, + escalationTtlSeconds: DAY, + }, + 'data:read': { + title: 'Read business data', + description: + 'Look at contacts, opportunities, orders, files, messages and other business data. No configuration changes.', + readOnly: true, + anonymizeAllowed: true, + escalationTtlSeconds: 7 * DAY, + }, + 'data:write': { + title: 'Change business data', + description: + 'Create and change contacts, opportunities, orders, files, messages and other business data. No configuration changes.', + readOnly: false, + anonymizeAllowed: false, + escalationTtlSeconds: DAY, + }, + full: { + title: 'Everything you can do', + description: 'Everything your own account can do, including changing data and configuration.', + readOnly: false, + anonymizeAllowed: false, + escalationTtlSeconds: DAY, + }, +}; + +/** Type guard for access profile strings. */ +export const isAccessProfile = (value: unknown): value is AccessProfile => + typeof value === 'string' && (ACCESS_PROFILES as readonly string[]).includes(value); + +/** `true` for `read`, `config:read` and `data:read`. */ +export const isReadProfile = (profile: AccessProfile): boolean => ACCESS_PROFILE_INFO[profile].readOnly; + +/** Least permissive first: read < config:read < data:read < config:write < data:write < full. */ +const PROFILE_RANK: Readonly> = { + read: 0, + 'config:read': 1, + 'data:read': 2, + 'config:write': 3, + 'data:write': 4, + full: 5, +}; + +/** The most permissive of the given profiles (ties resolved by the fixed rank above), or undefined for none. */ +export const mostPermissiveProfile = (profiles: readonly AccessProfile[]): AccessProfile | undefined => + profiles.length ? [...profiles].sort((a, b) => PROFILE_RANK[b] - PROFILE_RANK[a])[0] : undefined; + +/** Purpose (`reason`) limits enforced by the server for non-read profiles. */ +export const REASON_MIN_LENGTH = 10; +export const REASON_MAX_LENGTH = 200; + +// ─── Wire types ────────────────────────────────────────────────────────────── + +export interface EpilotOrganizationAccess { + granted: boolean; + pending: boolean; + read_only: boolean; + /** `true` only when every active grant for the organization is anonymized. */ + anonymized: boolean; + /** Most permissive active profile for the organization (absent on older servers → `read`). */ + access_profile?: AccessProfile; + /** Expiry of that grant, when it is an escalation grant. */ + expires_at?: string; +} + +export interface EpilotOrganization { + organization_id: string; + organization_name?: string; + organization_type?: string; + organization_use?: string; + access: EpilotOrganizationAccess; +} + +export interface EpilotIssueAccessTokenArguments { + organization_id: string; + /** Profile to issue under; defaults to the matched grant's profile. Must be covered by a grant. */ + access_profile?: AccessProfile; + read_only?: boolean; + anonymize?: boolean; + /** Seconds; server default 3600, server maximum 43200. */ + expires_in?: number; +} + +export interface EpilotIssuedAccessToken { + token: string; + token_id: string; + organization_id: string; + user_id: string; + read_only: boolean; + anonymize: boolean; + access_profile?: AccessProfile; + expires_at: string; + /** Email of the approving user, when the server returns it (used for token naming). */ + email?: string; +} + +// ─── Capability requests ───────────────────────────────────────────────────── + +export interface OrganizationAccessOptions { + /** Omit to let the approval page grant the user's login organization. */ + organizationId?: string; + /** Access profile; the server defaults to `read` when absent. */ + profile?: AccessProfile; + readOnly?: boolean; + /** Only valid with read profiles (`read`, `config:read`, `data:read`). */ + anonymize?: boolean; +} + +/** + * Build the constraints for an `epilot.access_token.issue` grant request. + * + * Throws `invalid_capabilities` when `anonymize: true` is combined with a write + * profile: anonymized data must never be written back. + */ +export const organizationAccessCapability = (options: OrganizationAccessOptions = {}): CapabilityRequest => { + if (options.anonymize === true && options.profile && !ACCESS_PROFILE_INFO[options.profile].anonymizeAllowed) { + throw new AgentAuthError(400, 'invalid_capabilities', 'anonymize is only available with read profiles', { + access_profile: options.profile, + }); + } + const constraints = { + ...(options.organizationId !== undefined ? { organization_id: options.organizationId } : {}), + ...(options.profile !== undefined ? { access_profile: options.profile } : {}), + ...(options.readOnly !== undefined ? { read_only: options.readOnly } : {}), + ...(options.anonymize !== undefined ? { anonymize: options.anonymize } : {}), + }; + return Object.keys(constraints).length + ? { name: EPILOT_CAPABILITIES.accessTokenIssue, constraints } + : { name: EPILOT_CAPABILITIES.accessTokenIssue }; +}; + +/** Execute `epilot.organizations.list`. */ +export const listEpilotOrganizations = (client: AgentAuthClient, identity: AgentIdentity) => + client.execute<{ organizations: EpilotOrganization[] }>(identity, { + capability: EPILOT_CAPABILITIES.organizationsList, + }); + +/** Execute `epilot.access_token.issue`. */ +export const issueEpilotAccessToken = ( + client: AgentAuthClient, + identity: AgentIdentity, + args: EpilotIssueAccessTokenArguments, +) => + client.execute(identity, { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + arguments: { ...args }, + }); + +export interface RequestOrganizationAccessOptions { + organizationId: string; + /** Default `read`. */ + profile?: AccessProfile; + /** Default: `true` for read profiles, `false` for write profiles. */ + anonymize?: boolean; + /** Purpose shown to the approving user. Required (10–200 chars) for every profile other than `read`. */ + reason?: string; + /** Passed through to the request (epilot extension: browser redirect after the approval). */ + approvalReturnUri?: string; +} + +/** + * Validate a purpose the way the server does: required (10–200 characters, + * trimmed) for profiles other than `read`, optional otherwise. + * Returns the trimmed reason or undefined. + */ +export const validateReason = (profile: AccessProfile, reason: string | undefined): string | undefined => { + const trimmed = reason?.trim() || undefined; + if (profile !== 'read' && (!trimmed || trimmed.length < REASON_MIN_LENGTH)) { + throw new AgentAuthError( + 400, + 'reason_required', + `A reason of at least ${REASON_MIN_LENGTH} characters is required when requesting the ${profile} profile.`, + { access_profile: profile }, + ); + } + if (trimmed && trimmed.length > REASON_MAX_LENGTH) { + throw new AgentAuthError(400, 'invalid_request', `The reason must not exceed ${REASON_MAX_LENGTH} characters.`); + } + return trimmed; +}; + +/** + * Request access to an organization with a profile and purpose. Enforces the + * anonymize rule and the reason requirement client-side, so a bad request never + * leaves the process. + */ +export const requestOrganizationAccess = async ( + client: AgentAuthClient, + identity: AgentIdentity, + options: RequestOrganizationAccessOptions, +): Promise => { + const profile = options.profile ?? 'read'; + const anonymize = options.anonymize ?? ACCESS_PROFILE_INFO[profile].anonymizeAllowed; + const reason = validateReason(profile, options.reason); + const capability = organizationAccessCapability({ organizationId: options.organizationId, profile, anonymize }); + return client.requestCapability(identity, { + capabilities: [capability], + ...(reason ? { reason } : {}), + ...(options.approvalReturnUri ? { approval_return_uri: options.approvalReturnUri } : {}), + }); +}; + +// ─── Grants ────────────────────────────────────────────────────────────────── + +export interface OrganizationGrant { + grant: CapabilityGrant; + organizationId?: string; + /** `read` when the grant carries no `access_profile` constraint. */ + profile: AccessProfile; + readOnly: boolean; + anonymized: boolean; + /** Escalation grant expiry; undefined for lifetime grants. */ + expiresAt?: string; + /** Purpose stated when the grant was requested. */ + reason?: string; +} + +/** Grants of `epilot.access_token.issue` mapped to their organization, profile and access level. */ +export const organizationGrants = (grants: CapabilityGrant[]): OrganizationGrant[] => + grants + .filter((grant) => grant.capability === EPILOT_CAPABILITIES.accessTokenIssue) + .map((grant) => { + const organizationId = grant.constraints?.organization_id; + const rawProfile = grant.constraints?.access_profile; + const profile: AccessProfile = isAccessProfile(rawProfile) ? rawProfile : 'read'; + const info = ACCESS_PROFILE_INFO[profile]; + const readOnly = typeof grant.constraints?.read_only === 'boolean' ? grant.constraints.read_only : info.readOnly; + return { + grant, + organizationId: typeof organizationId === 'string' ? organizationId : undefined, + profile, + readOnly, + anonymized: info.anonymizeAllowed && grant.constraints?.anonymize !== false, + expiresAt: grant.expires_at, + reason: grant.reason, + }; + }); + +/** `true` when the grant is active and not past its expiry. */ +export const isGrantUsable = (grant: OrganizationGrant, now = Date.now()): boolean => + grant.grant.status === 'active' && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now); + +export type EpilotStage = 'production' | 'staging' | 'dev'; + +/** Default epilot Agent Auth issuer per environment. */ +export const epilotAgentAuthIssuer = (stage: EpilotStage = 'production') => + stage === 'production' + ? 'https://access-token.sls.epilot.io/v1/access-tokens/agent-auth' + : `https://access-token.${stage}.sls.epilot.io/v1/access-tokens/agent-auth`; diff --git a/packages/agent-auth/src/index.ts b/packages/agent-auth/src/index.ts new file mode 100644 index 000000000..870e1c259 --- /dev/null +++ b/packages/agent-auth/src/index.ts @@ -0,0 +1,17 @@ +/** + * @epilot/agent-auth — Agent Auth Protocol (v1.0-draft) client for epilot. + * + * Spec: https://agentauthprotocol.com/specification/v1.0-draft + */ +export * from './types.js'; +export { generateKeyPair, jwkThumbprint, keyPairFromPrivateJwk, publicJwk } from './keys.js'; +export { + type AgentJwtOptions, + type HostJwtOptions, + createAgentJwt, + createHostJwt, + decodeJwt, + signJwt, +} from './jwt.js'; +export { AgentAuthClient, type AgentAuthClientOptions, type WaitForApprovalOptions } from './client.js'; +export * from './epilot.js'; diff --git a/packages/agent-auth/src/jwt.ts b/packages/agent-auth/src/jwt.ts new file mode 100644 index 000000000..6f5e9d35c --- /dev/null +++ b/packages/agent-auth/src/jwt.ts @@ -0,0 +1,99 @@ +/** + * Host and agent JWTs (spec §3.2, §3.3), signed with EdDSA over Ed25519. + */ +import { createPrivateKey, randomUUID, sign as cryptoSign } from 'node:crypto'; +import { publicJwk } from './keys.js'; +import type { Ed25519Jwk, KeyPair } from './types.js'; + +const b64url = (input: Buffer | string) => Buffer.from(input).toString('base64url'); + +const nowSeconds = () => Math.floor(Date.now() / 1000); + +/** Sign a compact JWS with the given private Ed25519 JWK. `alg` is always EdDSA. */ +export const signJwt = ( + privateJwk: Ed25519Jwk, + header: Record, + payload: Record, +): string => { + const key = createPrivateKey({ + key: { kty: 'OKP', crv: 'Ed25519', x: privateJwk.x, d: privateJwk.d }, + format: 'jwk', + }); + const encodedHeader = b64url(JSON.stringify({ alg: 'EdDSA', ...header })); + const encodedPayload = b64url(JSON.stringify(payload)); + const signature = cryptoSign(null, Buffer.from(`${encodedHeader}.${encodedPayload}`), key); + return `${encodedHeader}.${encodedPayload}.${b64url(signature)}`; +}; + +export interface HostJwtOptions { + hostKey: KeyPair; + /** Server issuer URL (the `aud`). */ + audience: string; + /** Include for registration requests: the new agent's public key. */ + agentPublicKey?: Ed25519Jwk; + ttlSeconds?: number; +} + +/** Host JWT (spec §3.2): typ host+jwt, iss = host thumbprint, inline host_public_key. */ +export const createHostJwt = ({ hostKey, audience, agentPublicKey, ttlSeconds = 60 }: HostJwtOptions): string => { + const iat = nowSeconds(); + return signJwt( + hostKey.privateKey, + { typ: 'host+jwt' }, + { + iss: hostKey.thumbprint, + aud: audience, + iat, + exp: iat + ttlSeconds, + jti: randomUUID(), + host_public_key: hostKey.publicKey, + ...(agentPublicKey ? { agent_public_key: publicJwk(agentPublicKey) } : {}), + }, + ); +}; + +export interface AgentJwtOptions { + agentKey: KeyPair; + /** Thumbprint of the host's current signing key (the `iss`). */ + hostThumbprint: string; + agentId: string; + /** Capability location or server issuer (the `aud`). */ + audience: string; + /** Optional restriction of this token to a subset of granted capabilities. */ + capabilities?: string[]; + ttlSeconds?: number; +} + +/** Agent JWT (spec §3.3): typ agent+jwt, iss = host thumbprint, sub = agent id, exp <= 60 s. */ +export const createAgentJwt = ({ + agentKey, + hostThumbprint, + agentId, + audience, + capabilities, + ttlSeconds = 60, +}: AgentJwtOptions): string => { + const iat = nowSeconds(); + return signJwt( + agentKey.privateKey, + { typ: 'agent+jwt' }, + { + iss: hostThumbprint, + sub: agentId, + aud: audience, + iat, + exp: iat + Math.min(ttlSeconds, 60), + jti: randomUUID(), + ...(capabilities ? { capabilities } : {}), + }, + ); +}; + +/** Decode a compact JWT without verifying it (header + payload). */ +export const decodeJwt = (jwt: string): { header: Record; payload: Record } => { + const [header, payload] = jwt.split('.'); + return { + header: JSON.parse(Buffer.from(header, 'base64url').toString('utf8')), + payload: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')), + }; +}; diff --git a/packages/agent-auth/src/keys.ts b/packages/agent-auth/src/keys.ts new file mode 100644 index 000000000..5f623f483 --- /dev/null +++ b/packages/agent-auth/src/keys.ts @@ -0,0 +1,29 @@ +/** + * Ed25519 key handling (spec §3.1): key generation and RFC 7638 thumbprints. + * Uses node:crypto only — no runtime dependencies. + */ +import { createHash, generateKeyPairSync } from 'node:crypto'; +import type { Ed25519Jwk, KeyPair } from './types.js'; + +/** RFC 7638 thumbprint: SHA-256 over the lexicographically ordered required members. */ +export const jwkThumbprint = (jwk: Pick): string => + createHash('sha256') + .update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x })) + .digest('base64url'); + +/** Strip the private scalar (and any kid) from a JWK. */ +export const publicJwk = (jwk: Ed25519Jwk): Ed25519Jwk => ({ kty: 'OKP', crv: 'Ed25519', x: jwk.x }); + +/** Generate a fresh Ed25519 key pair. */ +export const generateKeyPair = (): KeyPair => { + const { privateKey } = generateKeyPairSync('ed25519'); + const jwk = privateKey.export({ format: 'jwk' }) as { kty: string; crv: string; x: string; d: string }; + const privateJwk: Ed25519Jwk = { kty: 'OKP', crv: 'Ed25519', x: jwk.x, d: jwk.d }; + return { publicKey: publicJwk(privateJwk), privateKey: privateJwk, thumbprint: jwkThumbprint(privateJwk) }; +}; + +/** Rebuild a KeyPair from a stored private JWK. */ +export const keyPairFromPrivateJwk = (privateJwk: Ed25519Jwk): KeyPair => { + if (!privateJwk.d) throw new Error('A private Ed25519 JWK (with "d") is required.'); + return { publicKey: publicJwk(privateJwk), privateKey: privateJwk, thumbprint: jwkThumbprint(privateJwk) }; +}; diff --git a/packages/agent-auth/src/types.ts b/packages/agent-auth/src/types.ts new file mode 100644 index 000000000..54eae6acf --- /dev/null +++ b/packages/agent-auth/src/types.ts @@ -0,0 +1,186 @@ +/** + * Agent Auth Protocol (v1.0-draft) wire types (spec §2, §3). + * + * Spec: https://agentauthprotocol.com/specification/v1.0-draft + */ + +export interface Ed25519Jwk { + kty: 'OKP'; + crv: 'Ed25519'; + x: string; + /** Private scalar; present only in private JWKs. */ + d?: string; + kid?: string; +} + +export interface KeyPair { + publicKey: Ed25519Jwk; + privateKey: Ed25519Jwk; + /** RFC 7638 SHA-256 thumbprint of the public key (the `iss` of JWTs signed with it). */ + thumbprint: string; +} + +export type AgentMode = 'delegated' | 'autonomous'; +export type AgentStatus = 'pending' | 'active' | 'expired' | 'revoked' | 'rejected' | 'claimed'; +export type GrantStatus = 'active' | 'pending' | 'denied'; + +export type ConstraintOperator = { min?: number; max?: number; in?: unknown[]; not_in?: unknown[] }; +export type ConstraintValue = string | number | boolean | null | ConstraintOperator; +export type Constraints = Record; + +export interface CapabilityRequest { + name: string; + constraints?: Constraints; +} + +export interface Capability { + name: string; + description: string; + location?: string; + input?: Record; + output?: Record; + grant_status?: 'granted' | 'not_granted'; +} + +export interface CapabilityGrant { + id?: string; + capability: string; + status: GrantStatus; + description?: string; + input?: Record; + output?: Record; + constraints?: Constraints; + granted_by?: string; + denied_by?: string; + reason?: string; + expires_at?: string; + created_at?: string; +} + +export interface DeviceAuthorizationApproval { + method: 'device_authorization'; + verification_uri: string; + verification_uri_complete: string; + user_code: string; + expires_in: number; + interval: number; +} + +export type Approval = DeviceAuthorizationApproval | { method: string; [key: string]: unknown }; + +export interface AgentConfiguration { + version: string; + provider_name: string; + description?: string; + issuer: string; + default_location: string; + algorithms: string[]; + modes: AgentMode[]; + approval_methods: string[]; + endpoints: { + register: string; + capabilities: string; + describe_capability: string; + execute: string; + request_capability: string; + status: string; + reactivate: string; + revoke: string; + revoke_host: string; + rotate_key: string; + rotate_host_key: string; + introspect: string; + }; + jwks_uri?: string; +} + +export interface RegisterAgentRequest { + name: string; + host_name?: string; + capabilities: (string | CapabilityRequest)[]; + mode?: AgentMode; + reason?: string; + preferred_method?: string; + login_hint?: string; + binding_message?: string; + /** + * epilot extension: after the user decides on the approval page, the page + * redirects the browser here (https, or loopback http). Used by the MCP + * gateway to finish its OAuth flow. + */ + approval_return_uri?: string; +} + +export interface RegisterAgentResponse { + agent_id: string; + host_id: string; + name: string; + mode: AgentMode; + status: 'active' | 'pending'; + agent_capability_grants: CapabilityGrant[]; + approval?: Approval; +} + +export interface RequestCapabilityRequest { + capabilities: (string | CapabilityRequest)[]; + reason?: string; + preferred_method?: string; + login_hint?: string; + binding_message?: string; + approval_return_uri?: string; +} + +export interface RequestCapabilityResponse { + agent_id: string; + agent_capability_grants: CapabilityGrant[]; + approval?: Approval; +} + +export interface AgentStatusResponse { + agent_id: string; + host_id: string; + name: string; + status: AgentStatus; + mode: AgentMode; + agent_capability_grants: CapabilityGrant[]; + user_id?: string; + activated_at?: string; + created_at: string; + last_used_at?: string; + expires_at?: string; + approval?: Approval; +} + +export interface IntrospectResponse { + active: boolean; + agent_id?: string; + host_id?: string; + user_id?: string; + agent_capability_grants?: { capability: string; status: 'active' | 'pending' }[]; + mode?: AgentMode; + expires_at?: string; +} + +export interface ExecuteRequest { + capability: string; + arguments?: Record; +} + +/** Host + agent identity the client signs with. */ +export interface AgentIdentity { + hostKey: KeyPair; + agentKey: KeyPair; + agentId: string; +} + +export class AgentAuthError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly details?: Record, + ) { + super(message); + this.name = 'AgentAuthError'; + } +} diff --git a/packages/agent-auth/test/client.test.ts b/packages/agent-auth/test/client.test.ts new file mode 100644 index 000000000..7a2737b59 --- /dev/null +++ b/packages/agent-auth/test/client.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type AgentConfiguration, + AgentAuthClient, + AgentAuthError, + EPILOT_CAPABILITIES, + decodeJwt, + epilotAgentAuthIssuer, + generateKeyPair, + issueEpilotAccessToken, + listEpilotOrganizations, + organizationAccessCapability, + organizationGrants, +} from '../src/index.js'; + +const ISSUER = 'https://aap.example/v1/access-tokens/agent-auth'; + +const config: AgentConfiguration = { + version: '1.0-draft', + provider_name: 'epilot', + issuer: ISSUER, + default_location: `${ISSUER}/capability/execute`, + algorithms: ['EdDSA'], + modes: ['delegated'], + approval_methods: ['device_authorization'], + endpoints: { + register: '/agent/register', + capabilities: '/capability/list', + describe_capability: '/capability/describe', + execute: '/capability/execute', + request_capability: '/agent/request-capability', + status: `${ISSUER}/agent/status`, + reactivate: '/agent/reactivate', + revoke: '/agent/revoke', + revoke_host: '/host/revoke', + rotate_key: '/agent/rotate-key', + rotate_host_key: '/host/rotate-key', + introspect: '/agent/introspect', + }, +}; + +type Call = { method: string; url: string; headers: Record; body?: unknown }; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +/** Minimal fake AAP server: records calls, routes by pathname. */ +const fakeServer = (routes: Record Response | Promise>) => { + const calls: Call[] = []; + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const headers = Object.fromEntries( + Object.entries((init?.headers ?? {}) as Record).map(([k, v]) => [k.toLowerCase(), v]), + ); + const call: Call = { + method: init?.method ?? 'GET', + url, + headers, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + calls.push(call); + const { pathname } = new URL(url); + const handler = routes[pathname]; + if (!handler) return json(404, { error: 'not_found', message: `No route for ${pathname}` }); + return handler(call); + }); + return { calls, fetch: fetchImpl as unknown as typeof fetch }; +}; + +const discoveryRoute = { '/v1/access-tokens/agent-auth/.well-known/agent-configuration': () => json(200, config) }; + +const bearerOf = (call: Call) => call.headers.authorization?.replace(/^Bearer /, '') ?? ''; + +describe('AgentAuthClient', () => { + const hostKey = generateKeyPair(); + const agentKey = generateKeyPair(); + const identity = { hostKey, agentKey, agentId: 'agent_1' }; + + it('caches discovery for the TTL and refetches when forced', async () => { + const server = fakeServer(discoveryRoute); + const client = new AgentAuthClient({ baseUrl: `${ISSUER}/`, fetch: server.fetch }); + expect(await client.discover()).toEqual(config); + await client.discover(); + await client.endpoint('register'); + expect(server.calls).toHaveLength(1); + expect(server.calls[0].url).toBe(`${ISSUER}/.well-known/agent-configuration`); + await client.discover(true); + expect(server.calls).toHaveLength(2); + }); + + it('resolves relative and absolute endpoints against the issuer', async () => { + const server = fakeServer(discoveryRoute); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + expect(await client.endpoint('register')).toBe(`${ISSUER}/agent/register`); + expect(await client.endpoint('status')).toBe(`${ISSUER}/agent/status`); + }); + + it('registers an agent with a host JWT carrying the agent public key', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/agent/register': () => + json(201, { + agent_id: 'agent_1', + host_id: 'host_1', + name: 'epilot CLI', + mode: 'delegated', + status: 'pending', + agent_capability_grants: [], + approval: { + method: 'device_authorization', + verification_uri: 'https://portal.epilot.cloud/login?agent_approval', + verification_uri_complete: 'https://portal.epilot.cloud/login?agent_approval=WDJB-MJHT', + user_code: 'WDJB-MJHT', + expires_in: 600, + interval: 5, + }, + }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const body = { + name: 'epilot CLI', + host_name: 'laptop', + capabilities: [EPILOT_CAPABILITIES.organizationsList, organizationAccessCapability({ readOnly: true })], + mode: 'delegated' as const, + reason: 'epilot CLI login', + }; + const result = await client.registerAgent(hostKey, agentKey, body); + expect(result.status).toBe('pending'); + expect(result.approval?.method).toBe('device_authorization'); + + const call = server.calls[1]; + expect(call.method).toBe('POST'); + expect(call.url).toBe(`${ISSUER}/agent/register`); + expect(call.headers['content-type']).toBe('application/json'); + expect(call.body).toEqual(body); + const { header, payload } = decodeJwt(bearerOf(call)); + expect(header.typ).toBe('host+jwt'); + expect(payload.iss).toBe(hostKey.thumbprint); + expect(payload.aud).toBe(ISSUER); + expect(payload.host_public_key).toEqual(hostKey.publicKey); + expect(payload.agent_public_key).toEqual(agentKey.publicKey); + }); + + it('requests a capability with an agent JWT (aud = issuer)', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/agent/request-capability': () => + json(200, { + agent_id: 'agent_1', + agent_capability_grants: [ + { + id: 'grant_2', + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'pending', + constraints: { organization_id: '911210' }, + }, + ], + approval: { method: 'device_authorization', user_code: 'ABCD-EFGH', expires_in: 600, interval: 5 }, + }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const result = await client.requestCapability(identity, { + capabilities: [organizationAccessCapability({ organizationId: '911210', readOnly: false })], + reason: 'need write access', + }); + expect(result.agent_capability_grants[0].id).toBe('grant_2'); + const call = server.calls[1]; + expect(call.body).toEqual({ + capabilities: [ + { name: EPILOT_CAPABILITIES.accessTokenIssue, constraints: { organization_id: '911210', read_only: false } }, + ], + reason: 'need write access', + }); + const { header, payload } = decodeJwt(bearerOf(call)); + expect(header.typ).toBe('agent+jwt'); + expect(payload.iss).toBe(hostKey.thumbprint); + expect(payload.sub).toBe('agent_1'); + expect(payload.aud).toBe(ISSUER); + }); + + it('fetches agent status with agent_id as query parameter and a host JWT', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/agent/status': (call) => + json(200, { + agent_id: new URL(call.url).searchParams.get('agent_id'), + host_id: 'host_1', + name: 'epilot CLI', + status: 'active', + mode: 'delegated', + agent_capability_grants: [], + created_at: '2026-01-01T00:00:00Z', + }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const status = await client.getAgentStatus(hostKey, 'agent_1'); + expect(status.agent_id).toBe('agent_1'); + const call = server.calls[1]; + expect(call.method).toBe('GET'); + expect(call.body).toBeUndefined(); + expect(call.headers['content-type']).toBeUndefined(); + expect(decodeJwt(bearerOf(call)).header.typ).toBe('host+jwt'); + }); + + it('executes a capability with aud = default_location and a capabilities claim, unwrapping {data}', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/capability/execute': (call) => { + const body = call.body as { capability: string; arguments?: Record }; + if (body.capability === EPILOT_CAPABILITIES.organizationsList) { + return json(200, { + data: { + organizations: [{ organization_id: '739224', organization_name: 'ACME', access: { granted: true } }], + }, + }); + } + return json(200, { + data: { + token: 'eyJ.tok.en', + token_id: 'tok_1', + organization_id: body.arguments?.organization_id, + user_id: 'user_1', + read_only: true, + anonymize: true, + expires_at: '2026-01-01T01:00:00Z', + }, + }); + }, + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + + const orgs = await listEpilotOrganizations(client, identity); + expect(orgs.organizations[0].organization_name).toBe('ACME'); + const listCall = server.calls[1]; + expect(listCall.url).toBe(config.default_location); + expect(listCall.body).toEqual({ capability: EPILOT_CAPABILITIES.organizationsList }); + const listJwt = decodeJwt(bearerOf(listCall)).payload; + expect(listJwt.aud).toBe(config.default_location); + expect(listJwt.capabilities).toEqual([EPILOT_CAPABILITIES.organizationsList]); + + const issued = await issueEpilotAccessToken(client, identity, { organization_id: '739224', read_only: true }); + expect(issued.token).toBe('eyJ.tok.en'); + expect(issued.organization_id).toBe('739224'); + expect(server.calls[2].body).toEqual({ + capability: EPILOT_CAPABILITIES.accessTokenIssue, + arguments: { organization_id: '739224', read_only: true }, + }); + }); + + it('executes against an explicit location and unwraps async-style completed results', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/other/execute': () => json(200, { status: 'completed', result: { ok: true } }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const result = await client.execute(identity, { capability: 'x' }, 'https://aap.example/other/execute'); + expect(result).toEqual({ ok: true }); + expect(server.calls).toHaveLength(1); + expect(decodeJwt(bearerOf(server.calls[0])).payload.aud).toBe('https://aap.example/other/execute'); + }); + + it('maps HTTP errors to AgentAuthError with code, message and details', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/capability/execute': () => + json(403, { error: 'constraint_violated', message: 'read_only must be true', field: 'read_only' }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const error = (await client.execute(identity, { capability: 'x' }).catch((e) => e)) as AgentAuthError; + expect(error).toBeInstanceOf(AgentAuthError); + expect(error.status).toBe(403); + expect(error.code).toBe('constraint_violated'); + expect(error.message).toBe('read_only must be true'); + expect(error.details).toMatchObject({ field: 'read_only' }); + }); + + it('maps non-JSON errors and network failures', async () => { + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/agent/revoke': () => new Response('gateway timeout', { status: 504 }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const error = (await client.revokeAgent(hostKey, 'agent_1').catch((e) => e)) as AgentAuthError; + expect(error.code).toBe('http_504'); + expect(error.status).toBe(504); + + const failing = new AgentAuthClient({ + baseUrl: ISSUER, + fetch: (() => Promise.reject(new TypeError('fetch failed'))) as unknown as typeof fetch, + }); + const networkError = (await failing.discover().catch((e) => e)) as AgentAuthError; + expect(networkError.code).toBe('network_error'); + expect(networkError.status).toBe(0); + }); + + it('sends host-signed POSTs for revoke/reactivate/rotate and a bearer for introspect', async () => { + const newAgentKey = generateKeyPair(); + const server = fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/agent/revoke': () => json(200, { agent_id: 'agent_1', status: 'revoked' }), + '/v1/access-tokens/agent-auth/agent/reactivate': () => json(200, { agent_id: 'agent_1', status: 'active' }), + '/v1/access-tokens/agent-auth/agent/rotate-key': () => json(200, { agent_id: 'agent_1', status: 'active' }), + '/v1/access-tokens/agent-auth/agent/introspect': () => json(200, { active: true, agent_id: 'agent_1' }), + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + expect(await client.revokeAgent(hostKey, 'agent_1')).toEqual({ agent_id: 'agent_1', status: 'revoked' }); + expect(server.calls[1].body).toEqual({ agent_id: 'agent_1' }); + expect(decodeJwt(bearerOf(server.calls[1])).header.typ).toBe('host+jwt'); + + await client.reactivateAgent(hostKey, 'agent_1'); + await client.rotateAgentKey(hostKey, 'agent_1', newAgentKey); + expect(server.calls[3].body).toEqual({ agent_id: 'agent_1', public_key: newAgentKey.publicKey }); + + await client.introspect('some.agent.jwt', 'epilot-bearer'); + expect(server.calls[4].body).toEqual({ token: 'some.agent.jwt' }); + expect(server.calls[4].headers.authorization).toBe('Bearer epilot-bearer'); + }); + + describe('waitForApproval', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const statusServer = (statuses: string[], grants: () => unknown[] = () => []) => { + let polls = 0; + return { + polls: () => polls, + server: fakeServer({ + ...discoveryRoute, + '/v1/access-tokens/agent-auth/agent/status': () => { + const status = statuses[Math.min(polls, statuses.length - 1)]; + polls++; + return json(200, { + agent_id: 'agent_1', + host_id: 'host_1', + name: 'epilot CLI', + status, + mode: 'delegated', + agent_capability_grants: grants(), + created_at: '2026-01-01T00:00:00Z', + }); + }, + }), + }; + }; + + it('polls at the approval interval until the agent is active', async () => { + const { server, polls } = statusServer(['pending', 'pending', 'active']); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const onPoll = vi.fn(); + const promise = client.waitForApproval(hostKey, 'agent_1', { interval: 5, expires_in: 600 }, { onPoll }); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(5_000); + const status = await promise; + expect(status.status).toBe('active'); + expect(polls()).toBe(3); + expect(onPoll).toHaveBeenCalledTimes(3); + }); + + it('waits for specific grants when pendingGrantIds is given', async () => { + let round = 0; + const { server } = statusServer(['active'], () => [ + { id: 'grant_1', capability: EPILOT_CAPABILITIES.accessTokenIssue, status: 'active' }, + { id: 'grant_2', capability: EPILOT_CAPABILITIES.accessTokenIssue, status: round++ < 1 ? 'pending' : 'active' }, + ]); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const promise = client.waitForApproval( + hostKey, + 'agent_1', + { interval: 1, expires_in: 600 }, + { pendingGrantIds: ['grant_2'] }, + ); + await vi.advanceTimersByTimeAsync(1_000); + const status = await promise; + expect(status.agent_capability_grants[1].status).toBe('active'); + }); + + it('fails with approval_expired when the approval window closes', async () => { + const { server } = statusServer(['pending']); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const promise = client.waitForApproval(hostKey, 'agent_1', { interval: 5, expires_in: 7 }); + const rejection = expect(promise).rejects.toMatchObject({ code: 'approval_expired', status: 408 }); + await vi.advanceTimersByTimeAsync(5_000); + await rejection; + }); + + it('fails with aborted when the signal is aborted', async () => { + const { server } = statusServer(['pending']); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: server.fetch }); + const controller = new AbortController(); + const promise = client.waitForApproval( + hostKey, + 'agent_1', + { interval: 5, expires_in: 600 }, + { signal: controller.signal }, + ); + const rejection = expect(promise).rejects.toMatchObject({ code: 'aborted' }); + controller.abort(); + await vi.advanceTimersByTimeAsync(5_000); + await rejection; + }); + }); +}); + +describe('epilot helpers', () => { + it('builds organization access capability requests', () => { + expect(organizationAccessCapability({ organizationId: '1', readOnly: true, anonymize: false })).toEqual({ + name: EPILOT_CAPABILITIES.accessTokenIssue, + constraints: { organization_id: '1', read_only: true, anonymize: false }, + }); + expect(organizationAccessCapability()).toEqual({ name: EPILOT_CAPABILITIES.accessTokenIssue }); + }); + + it('maps access token grants to organizations, defaulting to read-only/anonymized', () => { + const mapped = organizationGrants([ + { capability: EPILOT_CAPABILITIES.organizationsList, status: 'active' }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '739224', read_only: true, anonymize: true }, + }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'pending', + constraints: { organization_id: '911210', read_only: false, anonymize: false }, + }, + ]); + expect(mapped).toHaveLength(2); + expect(mapped[0]).toMatchObject({ organizationId: '739224', readOnly: true, anonymized: true }); + expect(mapped[1]).toMatchObject({ organizationId: '911210', readOnly: false, anonymized: false }); + expect(mapped[1].grant.status).toBe('pending'); + }); + + it('derives the issuer per stage', () => { + expect(epilotAgentAuthIssuer()).toBe('https://access-token.sls.epilot.io/v1/access-tokens/agent-auth'); + expect(epilotAgentAuthIssuer('dev')).toBe('https://access-token.dev.sls.epilot.io/v1/access-tokens/agent-auth'); + expect(epilotAgentAuthIssuer('staging')).toBe( + 'https://access-token.staging.sls.epilot.io/v1/access-tokens/agent-auth', + ); + }); +}); diff --git a/packages/agent-auth/test/epilot.test.ts b/packages/agent-auth/test/epilot.test.ts new file mode 100644 index 000000000..d65c11716 --- /dev/null +++ b/packages/agent-auth/test/epilot.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ACCESS_PROFILES, + ACCESS_PROFILE_INFO, + type AgentConfiguration, + AgentAuthClient, + AgentAuthError, + EPILOT_CAPABILITIES, + generateKeyPair, + isAccessProfile, + isGrantUsable, + isReadProfile, + mostPermissiveProfile, + organizationAccessCapability, + organizationGrants, + requestOrganizationAccess, + validateReason, +} from '../src/index.js'; + +const ISSUER = 'https://aap.example/v1/access-tokens/agent-auth'; + +const config: AgentConfiguration = { + version: '1.0-draft', + provider_name: 'epilot', + issuer: ISSUER, + default_location: `${ISSUER}/capability/execute`, + algorithms: ['EdDSA'], + modes: ['delegated'], + approval_methods: ['device_authorization'], + endpoints: { + register: '/agent/register', + capabilities: '/capability/list', + describe_capability: '/capability/describe', + execute: '/capability/execute', + request_capability: '/agent/request-capability', + status: '/agent/status', + reactivate: '/agent/reactivate', + revoke: '/agent/revoke', + revoke_host: '/host/revoke', + rotate_key: '/agent/rotate-key', + rotate_host_key: '/host/rotate-key', + introspect: '/agent/introspect', + }, +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +const clientWithRecorder = () => { + const bodies: unknown[] = []; + const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const { pathname } = new URL(String(input)); + if (pathname.endsWith('/.well-known/agent-configuration')) return json(200, config); + if (pathname.endsWith('/agent/request-capability')) { + bodies.push(JSON.parse(String(init?.body))); + return json(200, { + agent_id: 'agent_1', + agent_capability_grants: [ + { id: 'grant_2', capability: EPILOT_CAPABILITIES.accessTokenIssue, status: 'pending' }, + ], + approval: { + method: 'device_authorization', + verification_uri: 'https://portal/login', + verification_uri_complete: 'https://portal/login?code=X', + user_code: 'X', + expires_in: 60, + interval: 1, + }, + }); + } + return json(404, { error: 'not_found' }); + }); + const client = new AgentAuthClient({ baseUrl: ISSUER, fetch: fetchImpl as unknown as typeof fetch }); + return { client, bodies }; +}; + +const identity = { hostKey: generateKeyPair(), agentKey: generateKeyPair(), agentId: 'agent_1' }; + +describe('access profiles', () => { + it('lists the six profiles with their read-only, anonymize and TTL properties', () => { + expect(ACCESS_PROFILES).toEqual(['read', 'config:read', 'config:write', 'data:read', 'data:write', 'full']); + expect(ACCESS_PROFILE_INFO.read).toMatchObject({ readOnly: true, anonymizeAllowed: true }); + expect(ACCESS_PROFILE_INFO.read.escalationTtlSeconds).toBeUndefined(); + expect(ACCESS_PROFILE_INFO['config:read']).toMatchObject({ + title: 'Read configuration', + readOnly: true, + anonymizeAllowed: true, + escalationTtlSeconds: 7 * 86_400, + }); + expect(ACCESS_PROFILE_INFO['data:read'].escalationTtlSeconds).toBe(7 * 86_400); + for (const profile of ['config:write', 'data:write', 'full'] as const) { + expect(ACCESS_PROFILE_INFO[profile]).toMatchObject({ + readOnly: false, + anonymizeAllowed: false, + escalationTtlSeconds: 86_400, + }); + } + expect(ACCESS_PROFILE_INFO.full.title).toBe('Everything you can do'); + expect(isReadProfile('data:read')).toBe(true); + expect(isReadProfile('data:write')).toBe(false); + expect(isAccessProfile('config:write')).toBe(true); + expect(isAccessProfile('admin')).toBe(false); + expect(isAccessProfile(undefined)).toBe(false); + }); + + it('picks the most permissive profile', () => { + expect(mostPermissiveProfile([])).toBeUndefined(); + expect(mostPermissiveProfile(['read'])).toBe('read'); + expect(mostPermissiveProfile(['config:read', 'read', 'data:read'])).toBe('data:read'); + expect(mostPermissiveProfile(['config:write', 'data:read'])).toBe('config:write'); + expect(mostPermissiveProfile(['data:write', 'full', 'read'])).toBe('full'); + }); +}); + +describe('organizationAccessCapability', () => { + it('writes access_profile next to the other constraints', () => { + expect(organizationAccessCapability({ organizationId: '1', profile: 'config:write' })).toEqual({ + name: EPILOT_CAPABILITIES.accessTokenIssue, + constraints: { organization_id: '1', access_profile: 'config:write' }, + }); + expect(organizationAccessCapability({ profile: 'data:read', anonymize: true, readOnly: true })).toEqual({ + name: EPILOT_CAPABILITIES.accessTokenIssue, + constraints: { access_profile: 'data:read', read_only: true, anonymize: true }, + }); + expect(organizationAccessCapability({ organizationId: '1', readOnly: true, anonymize: false })).toEqual({ + name: EPILOT_CAPABILITIES.accessTokenIssue, + constraints: { organization_id: '1', read_only: true, anonymize: false }, + }); + expect(organizationAccessCapability()).toEqual({ name: EPILOT_CAPABILITIES.accessTokenIssue }); + }); + + it('rejects anonymize with a write profile (anonymize is a read-only property)', () => { + for (const profile of ['config:write', 'data:write', 'full'] as const) { + let error: unknown; + try { + organizationAccessCapability({ organizationId: '1', profile, anonymize: true }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(AgentAuthError); + expect(error).toMatchObject({ + status: 400, + code: 'invalid_capabilities', + message: 'anonymize is only available with read profiles', + }); + } + // anonymize: false with a write profile and anonymize: true with read profiles are fine + expect(organizationAccessCapability({ profile: 'full', anonymize: false }).constraints).toEqual({ + access_profile: 'full', + anonymize: false, + }); + expect(organizationAccessCapability({ profile: 'config:read', anonymize: true }).constraints).toEqual({ + access_profile: 'config:read', + anonymize: true, + }); + }); +}); + +describe('organizationGrants', () => { + it('returns profile (default read), expiry and reason per grant', () => { + const mapped = organizationGrants([ + { capability: EPILOT_CAPABILITIES.organizationsList, status: 'active' }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '739224', read_only: true, anonymize: true }, + }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '739224', access_profile: 'config:write' }, + expires_at: '2026-09-24T10:00:00Z', + reason: 'Fix the PV registration journey mapping', + }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'pending', + constraints: { organization_id: '911210', access_profile: 'data:read', read_only: true }, + }, + ]); + expect(mapped).toHaveLength(3); + expect(mapped[0]).toMatchObject({ organizationId: '739224', profile: 'read', readOnly: true, anonymized: true }); + expect(mapped[0].expiresAt).toBeUndefined(); + expect(mapped[0].reason).toBeUndefined(); + expect(mapped[1]).toMatchObject({ + organizationId: '739224', + profile: 'config:write', + readOnly: false, + anonymized: false, + expiresAt: '2026-09-24T10:00:00Z', + reason: 'Fix the PV registration journey mapping', + }); + expect(mapped[2]).toMatchObject({ + organizationId: '911210', + profile: 'data:read', + readOnly: true, + anonymized: true, + }); + expect(mapped[2].grant.status).toBe('pending'); + }); + + it('never reports a write grant as anonymized and treats unknown profiles as read', () => { + const [full, unknown] = organizationGrants([ + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '1', access_profile: 'full', anonymize: true }, + }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '1', access_profile: 'superuser' }, + }, + ]); + expect(full.anonymized).toBe(false); + expect(full.readOnly).toBe(false); + expect(unknown.profile).toBe('read'); + expect(unknown.readOnly).toBe(true); + }); + + it('isGrantUsable requires active status and an unexpired grant', () => { + const now = Date.parse('2026-09-23T12:00:00Z'); + const [lifetime, live, expired, pending] = organizationGrants([ + { capability: EPILOT_CAPABILITIES.accessTokenIssue, status: 'active', constraints: { organization_id: '1' } }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '1', access_profile: 'full' }, + expires_at: '2026-09-24T12:00:00Z', + }, + { + capability: EPILOT_CAPABILITIES.accessTokenIssue, + status: 'active', + constraints: { organization_id: '1', access_profile: 'data:write' }, + expires_at: '2026-09-23T11:00:00Z', + }, + { capability: EPILOT_CAPABILITIES.accessTokenIssue, status: 'pending', constraints: { organization_id: '1' } }, + ]); + expect(isGrantUsable(lifetime, now)).toBe(true); + expect(isGrantUsable(live, now)).toBe(true); + expect(isGrantUsable(expired, now)).toBe(false); + expect(isGrantUsable(pending, now)).toBe(false); + }); +}); + +describe('requestOrganizationAccess', () => { + it('defaults to read + anonymized and sends no reason when none is given', async () => { + const { client, bodies } = clientWithRecorder(); + const response = await requestOrganizationAccess(client, identity, { organizationId: '911210' }); + expect(response.agent_capability_grants[0].id).toBe('grant_2'); + expect(bodies[0]).toEqual({ + capabilities: [ + { + name: EPILOT_CAPABILITIES.accessTokenIssue, + constraints: { organization_id: '911210', access_profile: 'read', anonymize: true }, + }, + ], + }); + }); + + it('defaults anonymize to false for write profiles and posts the trimmed reason', async () => { + const { client, bodies } = clientWithRecorder(); + await requestOrganizationAccess(client, identity, { + organizationId: '911210', + profile: 'config:write', + reason: ' Fix the entity mapping of the PV journey ', + }); + expect(bodies[0]).toEqual({ + capabilities: [ + { + name: EPILOT_CAPABILITIES.accessTokenIssue, + constraints: { organization_id: '911210', access_profile: 'config:write', anonymize: false }, + }, + ], + reason: 'Fix the entity mapping of the PV journey', + }); + }); + + it('lets read profiles opt out of anonymization', async () => { + const { client, bodies } = clientWithRecorder(); + await requestOrganizationAccess(client, identity, { + organizationId: '911210', + profile: 'data:read', + anonymize: false, + reason: 'Reconcile contact duplicates', + }); + expect((bodies[0] as { capabilities: { constraints: unknown }[] }).capabilities[0].constraints).toEqual({ + organization_id: '911210', + access_profile: 'data:read', + anonymize: false, + }); + }); + + it('throws reason_required for non-read profiles without a (long enough) reason', async () => { + const { client, bodies } = clientWithRecorder(); + await expect( + requestOrganizationAccess(client, identity, { organizationId: '1', profile: 'full' }), + ).rejects.toMatchObject({ status: 400, code: 'reason_required' }); + await expect( + requestOrganizationAccess(client, identity, { organizationId: '1', profile: 'data:read', reason: 'short' }), + ).rejects.toMatchObject({ code: 'reason_required' }); + await expect( + requestOrganizationAccess(client, identity, { organizationId: '1', profile: 'full', reason: 'x'.repeat(201) }), + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(bodies).toHaveLength(0); + }); + + it('throws invalid_capabilities for anonymize with a write profile before sending anything', async () => { + const { client, bodies } = clientWithRecorder(); + await expect( + requestOrganizationAccess(client, identity, { + organizationId: '1', + profile: 'data:write', + anonymize: true, + reason: 'Import meter readings from the portal', + }), + ).rejects.toMatchObject({ + code: 'invalid_capabilities', + message: 'anonymize is only available with read profiles', + }); + expect(bodies).toHaveLength(0); + }); + + it('validateReason trims and accepts optional reasons for read', () => { + expect(validateReason('read', undefined)).toBeUndefined(); + expect(validateReason('read', ' ')).toBeUndefined(); + expect(validateReason('read', ' short ')).toBe('short'); + expect(validateReason('config:read', 'Audit the journey configuration')).toBe('Audit the journey configuration'); + expect(() => validateReason('config:read', undefined)).toThrow(AgentAuthError); + }); +}); diff --git a/packages/agent-auth/test/keys-jwt.test.ts b/packages/agent-auth/test/keys-jwt.test.ts new file mode 100644 index 000000000..f0efee1bd --- /dev/null +++ b/packages/agent-auth/test/keys-jwt.test.ts @@ -0,0 +1,89 @@ +import { createHash, createPublicKey, verify } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + createAgentJwt, + createHostJwt, + decodeJwt, + generateKeyPair, + jwkThumbprint, + keyPairFromPrivateJwk, + publicJwk, +} from '../src/index.js'; + +const verifyEdDsa = (jwt: string, publicKeyJwk: { kty: string; crv: string; x: string }): boolean => { + const [header, payload, signature] = jwt.split('.'); + const key = createPublicKey({ key: publicKeyJwk, format: 'jwk' }); + return verify(null, Buffer.from(`${header}.${payload}`), key, Buffer.from(signature, 'base64url')); +}; + +describe('keys', () => { + it('generates an Ed25519 key pair with public/private JWKs', () => { + const pair = generateKeyPair(); + expect(pair.publicKey).toEqual({ kty: 'OKP', crv: 'Ed25519', x: pair.privateKey.x }); + expect(pair.privateKey.d).toBeTypeOf('string'); + expect(pair.publicKey).not.toHaveProperty('d'); + expect(pair.thumbprint).toBe(jwkThumbprint(pair.publicKey)); + }); + + it('computes the RFC 7638 thumbprint for a known Ed25519 JWK', () => { + // RFC 8037 appendix A.1 test key + const jwk = { kty: 'OKP', crv: 'Ed25519', x: '11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo' } as const; + // The thumbprint input is the JSON with required members in lexicographic order and no whitespace. + const expected = createHash('sha256') + .update('{"crv":"Ed25519","kty":"OKP","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}') + .digest('base64url'); + expect(jwkThumbprint(jwk)).toBe(expected); + // Known value from RFC 8037 §A.3 + expect(jwkThumbprint(jwk)).toBe('kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k'); + }); + + it('rebuilds a key pair from a stored private JWK and rejects public-only JWKs', () => { + const pair = generateKeyPair(); + const restored = keyPairFromPrivateJwk(pair.privateKey); + expect(restored).toEqual(pair); + expect(() => keyPairFromPrivateJwk(publicJwk(pair.privateKey))).toThrow(/private/); + }); +}); + +describe('JWTs', () => { + const hostKey = generateKeyPair(); + const agentKey = generateKeyPair(); + + it('creates a host JWT with the spec header/claims and a valid Ed25519 signature', () => { + const jwt = createHostJwt({ hostKey, audience: 'https://aap.example/v1', agentPublicKey: agentKey.publicKey }); + const { header, payload } = decodeJwt(jwt); + expect(header).toEqual({ alg: 'EdDSA', typ: 'host+jwt' }); + expect(payload.iss).toBe(hostKey.thumbprint); + expect(payload.aud).toBe('https://aap.example/v1'); + expect(payload.host_public_key).toEqual(hostKey.publicKey); + expect(payload.agent_public_key).toEqual(agentKey.publicKey); + expect(payload.jti).toMatch(/^[0-9a-f-]{36}$/); + expect((payload.exp as number) - (payload.iat as number)).toBe(60); + expect(verifyEdDsa(jwt, hostKey.publicKey)).toBe(true); + expect(verifyEdDsa(jwt, agentKey.publicKey)).toBe(false); + }); + + it('omits agent_public_key when not registering', () => { + const { payload } = decodeJwt(createHostJwt({ hostKey, audience: 'https://aap.example/v1' })); + expect(payload).not.toHaveProperty('agent_public_key'); + }); + + it('creates an agent JWT signed by the agent key, issued by the host thumbprint, capped at 60 s', () => { + const jwt = createAgentJwt({ + agentKey, + hostThumbprint: hostKey.thumbprint, + agentId: 'agent_123', + audience: 'https://aap.example/v1/capability/execute', + capabilities: ['epilot.organizations.list'], + ttlSeconds: 600, + }); + const { header, payload } = decodeJwt(jwt); + expect(header).toEqual({ alg: 'EdDSA', typ: 'agent+jwt' }); + expect(payload.iss).toBe(hostKey.thumbprint); + expect(payload.sub).toBe('agent_123'); + expect(payload.aud).toBe('https://aap.example/v1/capability/execute'); + expect(payload.capabilities).toEqual(['epilot.organizations.list']); + expect((payload.exp as number) - (payload.iat as number)).toBe(60); + expect(verifyEdDsa(jwt, agentKey.publicKey)).toBe(true); + }); +}); diff --git a/packages/agent-auth/tsconfig.json b/packages/agent-auth/tsconfig.json new file mode 100644 index 000000000..907ffdd0b --- /dev/null +++ b/packages/agent-auth/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": ".", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agent-auth/tsup.config.ts b/packages/agent-auth/tsup.config.ts new file mode 100644 index 000000000..0701ca567 --- /dev/null +++ b/packages/agent-auth/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.ts' }, + format: ['esm', 'cjs'], + target: 'node18', + platform: 'node', + dts: true, + sourcemap: true, + clean: true, + outDir: 'dist', +}); diff --git a/packages/agent-auth/vitest.config.ts b/packages/agent-auth/vitest.config.ts new file mode 100644 index 000000000..de879913a --- /dev/null +++ b/packages/agent-auth/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: {}, +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index 5e26458c1..cd6949c93 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -60,6 +60,13 @@ COMMANDS profile Manage named profiles completion Generate shell completion scripts +AGENT MODE (optional) + auth login --agent Register this CLI as an Agent Auth agent (org switching, silent token refresh, scoped access profiles) + org list List your organizations and this CLI's access + org use Switch the active organization + org request Request access (--access --reason "…") + org current Show the active organization + APIs access-token Access Token API address Address API @@ -120,6 +127,8 @@ EXAMPLES $ epilot entity searchEntities -d '{"q":"*"}' $ epilot entity searchEntities --jsonata 'results[0]._title' $ echo '{"q":"*"}' | epilot entity searchEntities + $ epilot auth login --agent --org 739224 # agent mode (optional) + $ epilot org request 739224 --access config:write --reason "Fix the PV journey mapping" Run epilot to list available operations. Run epilot --help for operation details. @@ -155,6 +164,82 @@ Token resolution order: 4. Stored credentials (`~/.config/epilot/credentials.json`) 5. Interactive prompt (if TTY) +### Agent mode (optional) + +`epilot auth login --agent` registers the CLI through the +[Agent Auth Protocol](https://agentauthprotocol.com/specification/v1.0-draft) (via +[`@epilot/agent-auth`](../agent-auth)): your machine is a **host**, the CLI is an **agent** you approve once in the +browser, and short-lived epilot tokens are issued per organization and refreshed silently. In return you get +organization switching, scoped **access profiles** and sessions that outlive a single token. Plain logins are not +affected: a profile without an agent behaves exactly as before. + +```bash +# Register the CLI as an agent and approve it in the browser (read access, your login organization) +epilot auth login --agent + +# A specific organization, an access profile and the purpose shown to the approving user +epilot auth login --agent --org 739224 --access config:write --reason "Fix the PV registration journey mapping" + +# Non-interactive (CI / agents): requires --org; prints the approval URL and polls until approved +epilot auth login --agent --org 739224 --no-interactive --json + +# Status shows the agent, its grants (profile, expiry, reason); logout revokes the agent +epilot auth status +epilot auth logout +``` + +Access profiles scope what the issued tokens may do. `read` is the default and lives as long as the agent; every +other profile is an escalation that requires a `--reason` (10–200 characters, prompted for in a TTY) and expires. +The flag is `--access` because `--profile` already selects a [named CLI profile](#profiles): + +| `--access` | Meaning | Read-only | Expires after | +| --- | --- | --- | --- | +| `read` | Read everything you can see | yes | never (agent lifetime) | +| `config:read` | Read configuration (journeys, automations, workflows, schemas, portals, designs) | yes | 7 days | +| `config:write` | Change configuration | no | 24 hours | +| `data:read` | Read business data (contacts, opportunities, orders, files, messages) | yes | 7 days | +| `data:write` | Change business data | no | 24 hours | +| `full` | Everything your account can do | no | 24 hours | + +Anonymization is a read-only property: `--anonymize` (and `org request` without `--full-pii`) only applies to read +profiles. Combining `--anonymize` with a write profile is an error ("anonymize is only available with read +profiles"), because an agent must never write masked data back. `--readonly` with a write profile downgrades it to +its read sibling (`config:write` → `config:read`, `full` → `read`). On the approval page the user can only narrow what +the CLI asked for; the CLI then issues tokens for the profile that was actually granted. + +#### Organizations + +One approval grants access to one organization. Switching or adding organizations goes through `epilot org` +(agent mode only — without an agent the commands exit 1 with "This profile has no agent identity. Run +`epilot auth login --agent` first.", or `{"error":"agent_required"}` with `--json`): + +```bash +epilot org list # organizations with access (profile, anonymized, "expires in 23h", pending) +epilot org current # the active organization and its profile +epilot org use 911210 # switch: issues a token under the most permissive grant (asks for access otherwise) +epilot org use 911210 --access data:read # ...or under a specific granted profile +epilot org request 911210 # ask for read, anonymized access (approved in the browser) +epilot org request 911210 --full-pii # read access with unmasked personal data +epilot org request 911210 --access data:write --reason "Import meter readings from the portal export" +epilot org request 911210 --access full --reason "…" --no-interactive --json # prints the approval, exits 0 (pending) +``` + +`--write` is a deprecated alias for `--access full`. Write profiles always get full personal data (`--full-pii` is +implied). After an approval that happened outside the CLI (e.g. from a `--no-interactive` request), +`epilot org use ` completes the switch; `auth status` lists every grant with its profile, expiry and reason. + +#### Silent refresh + +Issued tokens are short-lived. Whenever a command runs and the stored token of an agent-mode profile is missing or +expires within two minutes, the CLI issues a fresh one through the agent (same organization and profile) and stores +it — you stay logged in for as long as the agent and its grant are active. If the agent was revoked or expired, run +`epilot auth login --agent` again. A plain `epilot auth login` (or `--token`) on the same profile revokes that +profile's agent, so plain tokens are never replaced. + +Local state lives in `~/.config/epilot/agent-auth/` (`host.json` for the machine key, `agents/.json` for +the per-profile agent; both mode 0600). `EPILOT_AGENT_AUTH_ISSUER` overrides the Agent Auth server URL (defaults per +stage: `--use-dev`, `--use-staging`). + ## Profiles Manage multiple environments, similar to AWS CLI profiles: @@ -377,6 +462,9 @@ Full documentation with sample calls and responses for all APIs: # Install dependencies pnpm install +# Build the workspace dependency once (bundled into the CLI by tsup; also needed by tests and `pnpm dev`) +pnpm --filter @epilot/agent-auth build + # Generate API commands + definitions + docs from client specs pnpm generate diff --git a/packages/cli/bin/epilot.ts b/packages/cli/bin/epilot.ts index d0ca477a7..ffd177d87 100644 --- a/packages/cli/bin/epilot.ts +++ b/packages/cli/bin/epilot.ts @@ -124,6 +124,15 @@ function printRootHelp() { w(` ${CYAN}completion${R} Generate shell completion scripts\n`); w(` ${CYAN}upgrade${R} Upgrade to the latest version\n`); w(`\n`); + w(`${BOLD}AGENT MODE${R} ${DIM}(optional)${R}\n`); + w( + ` ${CYAN}auth login --agent${R} Register this CLI as an Agent Auth agent ${DIM}(org switching, silent token refresh, scoped access profiles)${R}\n`, + ); + w(` ${CYAN}org list${R} List your organizations and this CLI's access\n`); + w(` ${CYAN}org use${R} Switch the active organization\n`); + w(` ${CYAN}org request${R} Request access ${DIM}(--access --reason "…")${R}\n`); + w(` ${CYAN}org current${R} Show the active organization\n`); + w(`\n`); w(`${BOLD}APIs${R}\n`); // Print APIs in columns @@ -143,6 +152,8 @@ function printRootHelp() { w(` ${YELLOW}$${R} echo '{"q":"*"}' | epilot entity searchEntities\n`); w(` ${YELLOW}$${R} epilot entity searchEntities --use-dev ${DIM}# target dev environment${R}\n`); w(` ${YELLOW}$${R} epilot config set stage dev ${DIM}# persist dev as default${R}\n`); + w(` ${YELLOW}$${R} epilot auth login --agent --org 739224 ${DIM}# agent mode (optional)${R}\n`); + w(` ${YELLOW}$${R} epilot org request 739224 --access config:write --reason "Fix the PV journey mapping"\n`); w(`\n`); w(`Run ${CYAN}epilot ${R} to list available operations.\n`); w(`Run ${CYAN}epilot --help${R} for operation details.\n`); diff --git a/packages/cli/package.json b/packages/cli/package.json index ff5f8e167..0bed92d45 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,7 @@ "openapi-client-axios": "^7.8.0" }, "devDependencies": { + "@epilot/agent-auth": "workspace:^", "msw": "^2.12.10", "tsup": "^8.0.0", "tsx": "^4.0.0", diff --git a/packages/cli/scripts/generate.ts b/packages/cli/scripts/generate.ts index 3e6119b1f..aa9a71bbc 100644 --- a/packages/cli/scripts/generate.ts +++ b/packages/cli/scripts/generate.ts @@ -438,6 +438,7 @@ export const main = defineCommand({ }, subCommands: { auth: () => import('./commands/auth.js').then((m) => m.default), + org: () => import('./commands/org.js').then((m) => m.default), profile: () => import('./commands/profile.js').then((m) => m.default), config: () => import('./commands/config.js').then((m) => m.default), completion: () => import('./commands/completion.js').then((m) => m.default), @@ -820,6 +821,13 @@ const updateReadme = (clients: ClientInfo[]): void => { ' profile Manage named profiles', ' completion Generate shell completion scripts', '', + 'AGENT MODE (optional)', + ' auth login --agent Register this CLI as an Agent Auth agent (org switching, silent token refresh, scoped access profiles)', + " org list List your organizations and this CLI's access", + ' org use Switch the active organization', + ' org request Request access (--access --reason "…")', + ' org current Show the active organization', + '', 'APIs', ...apiLines, '', @@ -830,6 +838,8 @@ const updateReadme = (clients: ClientInfo[]): void => { ' $ epilot entity searchEntities -d \'{"q":"*"}\'', " $ epilot entity searchEntities --jsonata 'results[0]._title'", ' $ echo \'{"q":"*"}\' | epilot entity searchEntities', + ' $ epilot auth login --agent --org 739224 # agent mode (optional)', + ' $ epilot org request 739224 --access config:write --reason "Fix the PV journey mapping"', '', 'Run epilot to list available operations.', 'Run epilot --help for operation details.', diff --git a/packages/cli/src/commands/auth-login.ts b/packages/cli/src/commands/auth-login.ts index a50ab4142..26e78b50b 100644 --- a/packages/cli/src/commands/auth-login.ts +++ b/packages/cli/src/commands/auth-login.ts @@ -1,10 +1,43 @@ import { defineCommand } from 'citty'; import { randomBytes } from 'node:crypto'; import { createServer } from 'node:http'; +import { hostname } from 'node:os'; +import { + ACCESS_PROFILES, + ACCESS_PROFILE_INFO, + type AccessProfile, + type AgentStatusResponse, + type DeviceAuthorizationApproval, + EPILOT_CAPABILITIES, + type EpilotOrganization, + AgentAuthError, + generateKeyPair, + isAccessProfile, + isReadProfile, + listEpilotOrganizations, + organizationAccessCapability, + validateReason, +} from '@epilot/agent-auth'; +import { + type AgentRecord, + agentProfileKey, + ensureHostKey, + forgetAgentForProfile, + formatExpiresIn, + getAgentAuthClient, + issueTokenForOrg, + loadAgentIdentity, + resolveAgentAuthIssuer, + saveAgentRecord, +} from '../lib/agent-auth.js'; import { saveCredentials } from '../lib/auth-store.js'; import { type Environment, getPortalUrl, resolveEnvironment } from '../lib/environment.js'; +import { isInteractive } from '../lib/interactive.js'; import { BOLD, RESET, GREEN, RED, DIM, YELLOW, CYAN } from '../lib/utils.js'; +export const AGENT_FLAG_DESCRIPTION = + 'Register this CLI as an Agent Auth agent (org switching, silent token refresh, scoped access profiles)'; + export default defineCommand({ meta: { name: 'login', description: 'Authenticate with epilot' }, args: { @@ -15,6 +48,25 @@ export default defineCommand({ type: 'boolean', description: 'Generate an anonymized token (personal data is masked in all API responses)', }, + agent: { type: 'boolean', description: AGENT_FLAG_DESCRIPTION }, + org: { + type: 'string', + description: 'Agent mode: organization ID to request access to (default: your login organization)', + }, + access: { + type: 'string', + description: `Agent mode: access profile (${ACCESS_PROFILES.join(', ')}; default: read)`, + }, + reason: { + type: 'string', + description: 'Agent mode: purpose shown to the approving user (required for profiles other than read)', + }, + json: { type: 'boolean', description: 'Agent mode: output the login result as JSON' }, + interactive: { + type: 'boolean', + default: true, + description: 'Agent mode: interactive approval (--no-interactive to disable)', + }, 'use-dev': { type: 'boolean', description: 'Use dev environment (portal.dev.epilot.cloud)' }, 'use-staging': { type: 'boolean', description: 'Use staging environment (portal.staging.epilot.cloud)' }, }, @@ -26,31 +78,417 @@ export default defineCommand({ // Manual token input if (args.token) { + await forgetAgentForProfile(profileName); saveCredentials({ token: args.token }, profileName); const suffix = profileName ? ` to profile "${profileName}"` : ''; process.stdout.write(`${GREEN}Token saved${suffix}.${RESET}\n`); return; } - if (!process.stdin.isTTY) { - process.stderr.write(`${RED}Browser login requires an interactive terminal.${RESET}\n`); + if (!args.agent) { + const agentOnly = (['org', 'access', 'reason'] as const).filter((flag) => args[flag] !== undefined); + if (agentOnly.length) { + process.stderr.write( + `${RED}${agentOnly.map((f) => `--${f}`).join(', ')} require${agentOnly.length === 1 ? 's' : ''} --agent.${RESET}\n`, + ); + process.stderr.write( + `Run ${BOLD}epilot auth login --agent ${agentOnly.map((f) => `--${f} …`).join(' ')}${RESET}.\n`, + ); + process.exit(1); + } + if (!process.stdin.isTTY) { + process.stderr.write(`${RED}Browser login requires an interactive terminal.${RESET}\n`); + process.stderr.write( + `Use ${BOLD}epilot auth login --token ${RESET} or ${BOLD}epilot auth token${RESET} instead.\n`, + ); + process.exit(1); + } + const token = await browserLogin(profileName, env, readonly, anonymize); + if (token) { + await forgetAgentForProfile(profileName); + process.stdout.write(`${GREEN}${BOLD}Login successful!${RESET}\n`); + } else { + process.stderr.write(`${RED}Login failed or was cancelled.${RESET}\n`); + process.exit(1); + } + return; + } + + // ── Agent mode ── + const interactive = isInteractive({ interactive: args.interactive }) && !!process.stdin.isTTY; + if (!interactive && !args.org) { + process.stderr.write(`${RED}Non-interactive agent login requires --org .${RESET}\n`); process.stderr.write( - `Use ${BOLD}epilot auth login --token ${RESET} or ${BOLD}epilot auth token${RESET} instead.\n`, + `Run ${BOLD}epilot auth login --agent --org --no-interactive${RESET}, or use ${BOLD}--token ${RESET}.\n`, ); process.exit(1); } - const token = await browserLogin(profileName, env, readonly, anonymize); - if (token) { - process.stdout.write(`${GREEN}${BOLD}Login successful!${RESET}\n`); - } else { - process.stderr.write(`${RED}Login failed or was cancelled.${RESET}\n`); + try { + const profile = resolveLoginProfile(args.access, readonly, anonymize); + const reason = await resolveReason(profile, args.reason, interactive && !args.json); + const result = await agentLogin({ + profileName, + env, + org: args.org, + profile, + reason, + readonly, + anonymize, + interactive, + json: Boolean(args.json), + }); + if (args.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } + } catch (error) { + if (error instanceof AgentAuthError) { + process.stderr.write(`${RED}Login failed: ${error.message}${RESET} ${DIM}(${error.code})${RESET}\n`); + } else { + process.stderr.write(`${RED}Login failed: ${error instanceof Error ? error.message : String(error)}${RESET}\n`); + } process.exit(1); } }, }); -const browserLogin = async ( +// ── Access profile helpers (shared with `epilot org`) ───────────────────────── + +/** Read-only sibling of a profile: `config:write` → `config:read`, `full` → `read`. */ +export const readOnlySibling = (profile: AccessProfile): AccessProfile => + profile === 'config:write' + ? 'config:read' + : profile === 'data:write' + ? 'data:read' + : profile === 'full' + ? 'read' + : profile; + +/** Parse `--access`; unknown values are an error. */ +export const parseAccessProfile = (value: unknown): AccessProfile | undefined => { + if (value === undefined || value === null || value === '') return undefined; + if (isAccessProfile(value)) return value; + throw new AgentAuthError( + 400, + 'invalid_access_profile', + `Unknown access profile "${String(value)}". Use one of: ${ACCESS_PROFILES.join(', ')}.`, + ); +}; + +/** + * Profile for a login: `--access` (default read); `--readonly` downgrades a + * write profile to its read sibling; `--anonymize` is only valid with read profiles. + */ +export const resolveLoginProfile = (access: unknown, readonly: boolean, anonymize: boolean): AccessProfile => { + let profile = parseAccessProfile(access) ?? 'read'; + if (readonly && !isReadProfile(profile)) { + const downgraded = readOnlySibling(profile); + process.stderr.write(`${DIM}--readonly downgrades ${profile} to ${downgraded}.${RESET}\n`); + profile = downgraded; + } + if (anonymize && !ACCESS_PROFILE_INFO[profile].anonymizeAllowed) { + throw new AgentAuthError(400, 'invalid_capabilities', 'anonymize is only available with read profiles'); + } + return profile; +}; + +/** + * A purpose is required for every profile other than `read`: prompt for it in + * a TTY, fail with `reason_required` otherwise. + */ +export const resolveReason = async ( + profile: AccessProfile, + reason: string | undefined, + canPrompt: boolean, +): Promise => { + try { + return validateReason(profile, reason); + } catch (error) { + if (!(error instanceof AgentAuthError) || error.code !== 'reason_required' || !canPrompt) { + if (error instanceof AgentAuthError && error.code === 'reason_required') { + throw new AgentAuthError( + 400, + 'reason_required', + `${error.message} Pass --reason "".`, + ); + } + throw error; + } + } + const { input } = await import('@inquirer/prompts'); + const answer = await input({ + message: `Why do you need ${profile} (${ACCESS_PROFILE_INFO[profile].title}) access? Shown to the approving user:`, + validate: (value) => { + try { + validateReason(profile, value); + return true; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + return validateReason(profile, answer); +}; + +/** "config:write (Change configuration), anonymized, expires in 23h" */ +export const describeAccess = (options: { + profile?: AccessProfile | string; + anonymized?: boolean; + expiresAt?: string; + readOnly?: boolean; +}): string => { + const profile: AccessProfile = isAccessProfile(options.profile) ? options.profile : 'read'; + const parts = [`${profile} ${DIM}(${ACCESS_PROFILE_INFO[profile].title})${RESET}`]; + if (options.anonymized) parts.push(`${YELLOW}anonymized${RESET}`); + const expiry = formatExpiresIn(options.expiresAt); + if (expiry) parts.push(`${DIM}${expiry}${RESET}`); + return parts.join(', '); +}; + +// ── Agent Auth login ────────────────────────────────────────────────────────── + +export type AgentLoginOptions = { + profileName?: string; + env: Environment; + /** Organization to request; when omitted the approval page grants the login organization. */ + org?: string; + /** Access profile to request; default `read`. */ + profile?: AccessProfile; + /** Purpose; required for profiles other than `read` (validated by the caller). */ + reason?: string; + readonly: boolean; + anonymize: boolean; + interactive: boolean; + /** Progress goes to stderr so stdout stays machine-readable. */ + json: boolean; +}; + +export type AgentLoginResult = { + agent_id: string; + host_id: string; + issuer: string; + organization_id: string; + organization_name?: string; + user_id: string; + access_profile?: AccessProfile; + read_only: boolean; + anonymize: boolean; + expires_at: string; + grant_expires_at?: string; + reason?: string; + profile?: string; +}; + +const isDeviceAuthorization = (approval: unknown): approval is DeviceAuthorizationApproval => + !!approval && (approval as { method?: string }).method === 'device_authorization'; + +/** Print the approval URL and user code the way the browser login prints its verification code. */ +export const printApproval = (approval: DeviceAuthorizationApproval, out: (s: string) => void): void => { + out('\n'); + out(` ${YELLOW}Verification code: ${BOLD}${approval.user_code}${RESET}\n`); + out('\n'); + out(`${DIM}Verify this code matches what is shown in your browser before approving.${RESET}\n`); + out(`${DIM}This ensures you are approving the correct CLI session.${RESET}\n`); + out('\n'); + out(`${DIM}Approval URL: ${approval.verification_uri_complete}${RESET}\n\n`); +}; + +export const openBrowser = async (url: string, out: (s: string) => void): Promise => { + try { + const open = (await import('open')).default; + await open(url); + out(`${CYAN}Browser opened.${RESET} Waiting for approval`); + } catch { + out(`Could not open browser. Please visit this URL manually:\n\n ${url}\n\nWaiting for approval`); + } +}; + +/** + * Register an agent for this machine, wait for the user's approval in the + * browser, pick the active organization and store an access token. + */ +export const agentLogin = async (options: AgentLoginOptions): Promise => { + const out = (s: string) => (options.json ? process.stderr : process.stdout).write(s); + const profile = options.profile ?? 'read'; + const suffix = options.profileName ? ` ${DIM}(profile: ${options.profileName})${RESET}` : ''; + out(`\n${BOLD}epilot CLI Login${RESET} ${DIM}(agent mode)${RESET}${suffix}\n\n`); + out('This registers the epilot CLI on this machine as an agent and asks you to approve it in your browser.\n'); + out(` Access: ${describeAccess({ profile, anonymized: options.anonymize })}\n`); + if (options.reason) out(` Reason: ${options.reason}\n`); + if (options.anonymize) { + out(`${YELLOW}Anonymize mode: personal data will be masked in all API responses for this CLI session.${RESET}\n`); + } + + const hostKey = ensureHostKey(); + const agentKey = generateKeyPair(); + const issuer = resolveAgentAuthIssuer(options.env); + const client = getAgentAuthClient(issuer); + const machine = hostname(); + + const registration = await client.registerAgent(hostKey, agentKey, { + name: `epilot CLI @ ${machine}`, + host_name: machine, + mode: 'delegated', + reason: options.reason ?? 'epilot CLI login', + capabilities: [ + EPILOT_CAPABILITIES.organizationsList, + organizationAccessCapability({ + organizationId: options.org, + profile, + readOnly: options.readonly, + anonymize: options.anonymize, + }), + ], + }); + + const record: AgentRecord = { + agent_id: registration.agent_id, + host_id: registration.host_id, + privateKey: agentKey.privateKey, + issuer, + name: registration.name, + created_at: new Date().toISOString(), + }; + + let status: AgentStatusResponse | undefined; + if (registration.status === 'pending') { + if (!isDeviceAuthorization(registration.approval)) { + throw new AgentAuthError( + 0, + 'unsupported_approval', + `The server requires an unsupported approval method (${String(registration.approval?.method)}).`, + ); + } + printApproval(registration.approval, out); + if (options.interactive) { + await openBrowser(registration.approval.verification_uri_complete, out); + } else { + out('Waiting for approval'); + } + status = await client.waitForApproval(hostKey, registration.agent_id, registration.approval, { + onPoll: () => out('.'), + }); + out('\n'); + } + + const agentStatus = status?.status ?? registration.status; + if (agentStatus !== 'active') { + throw new AgentAuthError(0, `agent_${agentStatus}`, `The agent was not approved (status: ${agentStatus}).`); + } + + // Persist the identity before issuing so a failed org selection can be completed with `epilot org use`. + saveAgentRecord(record, options.profileName); + const loaded = loadAgentIdentity(options.profileName); + if (!loaded) throw new Error('Failed to store the agent identity.'); + + const { organizations } = await listEpilotOrganizations(client, loaded.identity); + const org = await chooseOrganization(organizations, options); + + // The approving user may have downgraded the profile on the approval page: issue what was granted. + const grantedProfile = org.access?.access_profile ?? profile; + const issued = await issueTokenForOrg(loaded, org.organization_id, { + profile: grantedProfile, + ...(isReadProfile(grantedProfile) && options.anonymize ? { anonymize: true } : {}), + profileName: options.profileName, + }); + if (grantedProfile !== profile) { + out(`${YELLOW}Access was granted as ${grantedProfile} instead of the requested ${profile}.${RESET}\n`); + } + + out(`${GREEN}${BOLD}Login successful!${RESET}\n`); + out( + ` Organization: ${org.organization_name ? `${org.organization_name} ${DIM}(${org.organization_id})${RESET}` : org.organization_id}\n`, + ); + out( + ` Access: ${describeAccess({ + profile: issued.access_profile ?? grantedProfile, + anonymized: issued.anonymize, + expiresAt: org.access?.expires_at, + })}\n`, + ); + out(` Token expires: ${issued.expires_at} ${DIM}(refreshed automatically while the agent is active)${RESET}\n`); + out(`${DIM}Switch organizations with ${RESET}epilot org list${DIM} / ${RESET}epilot org use ${DIM}.${RESET}\n`); + + return { + agent_id: registration.agent_id, + host_id: registration.host_id, + issuer, + organization_id: org.organization_id, + organization_name: org.organization_name, + user_id: issued.user_id, + access_profile: issued.access_profile ?? grantedProfile, + read_only: issued.read_only, + anonymize: issued.anonymize, + expires_at: issued.expires_at, + ...(org.access?.expires_at ? { grant_expires_at: org.access.expires_at } : {}), + ...(options.reason ? { reason: options.reason } : {}), + profile: agentProfileKey(options.profileName), + }; +}; + +/** --org flag → the only granted organization → interactive select → error. */ +export const chooseOrganization = async ( + organizations: EpilotOrganization[], + options: { org?: string; interactive: boolean }, +): Promise => { + const granted = organizations.filter((o) => o.access?.granted); + + if (options.org) { + const match = organizations.find((o) => o.organization_id === options.org); + if (!match) { + throw new AgentAuthError( + 0, + 'organization_not_found', + `Organization ${options.org} is not available to your user.`, + ); + } + if (!match.access?.granted) { + const hint = match.access?.pending ? 'is still pending approval' : 'was not granted'; + throw new AgentAuthError( + 0, + 'organization_not_granted', + `Access to organization ${options.org} ${hint}. Run \`epilot org request ${options.org}\` to ask for access.`, + ); + } + return match; + } + + if (granted.length === 1) return granted[0]; + if (granted.length === 0) { + throw new AgentAuthError( + 0, + 'no_organization_granted', + 'No organization was granted. Approve the request in your browser or run `epilot org request `.', + ); + } + + if (!options.interactive) { + throw new AgentAuthError( + 0, + 'organization_required', + `Several organizations are granted (${granted.map((o) => o.organization_id).join(', ')}). Pass --org .`, + ); + } + + const { select } = await import('@inquirer/prompts'); + const organizationId = await select({ + message: 'Select the organization to use:', + choices: granted.map((o) => ({ + name: `${o.organization_name ?? o.organization_id} ${DIM}${o.organization_id}${accessLabel(o)}${RESET}`, + value: o.organization_id, + })), + }); + return granted.find((o) => o.organization_id === organizationId)!; +}; + +const accessLabel = (o: EpilotOrganization): string => { + const parts = [o.access?.access_profile ?? 'read', o.access?.anonymized ? 'anonymized' : ''].filter(Boolean); + return parts.length ? ` · ${parts.join(', ')}` : ''; +}; + +// ── Browser callback login (default) ────────────────────────────────────────── + +export const browserLogin = async ( profileName?: string, env: Environment = 'production', readonly = false, diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 7d344937d..2a1505028 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -1,5 +1,8 @@ import { defineCommand } from 'citty'; +import { ACCESS_PROFILE_INFO, AgentAuthError, isAccessProfile, organizationGrants } from '@epilot/agent-auth'; +import { deleteAgentRecord, formatExpiresIn, loadAgentIdentity } from '../lib/agent-auth.js'; import { loadCredentials, removeCredentials } from '../lib/auth-store.js'; +import { getResolvedProfile, resolveProfileName, upsertProfile } from '../lib/profiles.js'; import { BOLD, RESET, GREEN, RED, DIM, YELLOW } from '../lib/utils.js'; export default defineCommand({ @@ -12,9 +15,40 @@ export default defineCommand({ token: () => import('./auth-token.js').then((m) => m.default), logout: defineCommand({ meta: { name: 'logout', description: 'Remove stored credentials' }, - run: () => { + args: { + profile: { type: 'string', description: 'Profile to log out (or EPILOT_PROFILE env)' }, + }, + run: async ({ args }) => { + const profileName = args.profile || process.env.EPILOT_PROFILE; + + // Agent mode only: revoke the Agent Auth agent (best effort), forget it locally and clear the token it + // issued into the profile. The host key stays. Without an agent, logout behaves exactly as before. + const loaded = loadAgentIdentity(profileName); + if (loaded) { + try { + await loaded.client.revokeAgent(loaded.identity.hostKey, loaded.identity.agentId); + process.stdout.write(`${DIM}Agent ${loaded.record.agent_id} revoked.${RESET}\n`); + } catch (error) { + const reason = error instanceof AgentAuthError ? error.code : 'error'; + process.stdout.write( + `${DIM}Could not revoke agent ${loaded.record.agent_id} (${reason}); removing it locally.${RESET}\n`, + ); + } + deleteAgentRecord(profileName); + const resolvedName = resolveProfileName(profileName); + if (resolvedName && getResolvedProfile(profileName)?.token) { + upsertProfile(resolvedName, { + token: undefined, + org_id: undefined, + user_id: undefined, + expires_at: undefined, + access_profile: undefined, + }); + } + } + const removed = removeCredentials(); - if (removed) { + if (removed || loaded) { process.stdout.write(`${GREEN}Logged out successfully.${RESET}\n`); } else { process.stdout.write(`No stored credentials found.\n`); @@ -23,72 +57,161 @@ export default defineCommand({ }), status: defineCommand({ meta: { name: 'status', description: 'Show authentication status' }, - run: () => { - const creds = loadCredentials(); - if (!creds) { - process.stdout.write(`${YELLOW}Not authenticated.${RESET}\n`); - process.stdout.write(`Run ${BOLD}epilot auth login${RESET} to authenticate.\n`); + args: { + profile: { type: 'string', description: 'Profile to inspect (or EPILOT_PROFILE env)' }, + }, + run: async ({ args }) => { + const profileName = args.profile || process.env.EPILOT_PROFILE; + const loaded = loadAgentIdentity(profileName); + + // Without an agent: exactly the status output of a plain login. + if (!loaded) { + const plain = loadCredentials(); + if (!plain) { + process.stdout.write(`${YELLOW}Not authenticated.${RESET}\n`); + process.stdout.write(`Run ${BOLD}epilot auth login${RESET} to authenticate.\n`); + return; + } + printTokenStatus(plain, { agent: false }); return; } - const claims = parseJwtPayload(creds.token); - const isApiToken = claims?.token_type === 'api'; - const isCognitoToken = typeof claims?.iss === 'string' && claims.iss.includes('cognito-idp'); - const tokenType = isApiToken ? 'API Token' : isCognitoToken ? 'User Token' : 'Token'; - - process.stdout.write(`${GREEN}${BOLD}Authenticated${RESET} ${DIM}(${tokenType})${RESET}\n`); - - // Resolve fields from JWT claims (API token vs Cognito token vs stored creds) - const name = (claims?.token_name || claims?.email || claims?.['cognito:username'] || creds.name) as - | string - | undefined; - const orgId = (claims?.org_id || claims?.['custom:ivy_org_id'] || creds.org_id) as string | undefined; - const userId = (claims?.user_id || claims?.['custom:ivy_user_id'] || creds.user_id) as string | undefined; - const tokenId = claims?.token_id as string | undefined; - const adminEmail = claims?.admin_email as string | undefined; - const tokenUse = claims?.token_use as string | undefined; - const roles = claims?.assume_roles as string[] | undefined; - const readOnly = claims?.read_only === true; - const anonymize = claims?.anonymize === true; - - if (name) process.stdout.write(` Name: ${name}\n`); - if (adminEmail && adminEmail !== name) process.stdout.write(` Email: ${adminEmail}\n`); - if (orgId) process.stdout.write(` Org: ${orgId}\n`); - if (userId) process.stdout.write(` User: ${userId}\n`); - if (tokenId && tokenId !== userId) process.stdout.write(` Token ID: ${tokenId}\n`); - if (tokenUse) process.stdout.write(` Use: ${tokenUse}\n`); - if (roles?.length) process.stdout.write(` Roles: ${roles.join(', ')}\n`); - process.stdout.write(` Access: ${readOnly ? `${YELLOW}read-only${RESET}` : `${GREEN}read-write${RESET}`}\n`); - if (anonymize) process.stdout.write(` Data: ${YELLOW}anonymized${RESET}\n`); - - // Expiry - if (creds.expires_at) { - const expiry = new Date(creds.expires_at); - const now = new Date(); - const days = Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - process.stdout.write(` Expires: ${creds.expires_at} ${DIM}(${days} days)${RESET}\n`); - } else if (claims?.exp) { - const expiry = new Date((claims.exp as number) * 1000); - const now = new Date(); - const diffMs = expiry.getTime() - now.getTime(); - const label = - diffMs < 0 - ? `${RED}expired${RESET}` - : diffMs < 86400000 - ? `${Math.floor(diffMs / 3600000)}h ${Math.floor((diffMs % 3600000) / 60000)}m` - : `${Math.floor(diffMs / 86400000)} days`; - process.stdout.write(` Expires: ${expiry.toISOString()} ${DIM}(${label})${RESET}\n`); - } else if (claims?.iat && !claims?.exp) { - const issued = new Date((claims.iat as number) * 1000); - process.stdout.write(` Issued: ${issued.toISOString()} ${DIM}(no expiry)${RESET}\n`); + const creds = loadCredentials() ?? profileCredentials(profileName); + if (creds) { + printTokenStatus(creds, { agent: true }); + } else { + process.stdout.write(`${YELLOW}No valid token stored${RESET} ${DIM}(issued on next API call)${RESET}\n`); + } + process.stdout.write(`\n${BOLD}Agent Auth${RESET}\n`); + process.stdout.write(` Agent: ${loaded.record.agent_id} ${DIM}(${loaded.record.name})${RESET}\n`); + process.stdout.write(` Host: ${loaded.record.host_id}\n`); + process.stdout.write(` Issuer: ${loaded.record.issuer}\n`); + try { + const status = await loaded.client.getAgentStatus(loaded.identity.hostKey, loaded.identity.agentId); + const color = status.status === 'active' ? GREEN : status.status === 'pending' ? YELLOW : RED; + process.stdout.write(` Status: ${color}${status.status}${RESET}`); + if (status.expires_at) process.stdout.write(` ${DIM}(expires ${status.expires_at})${RESET}`); + process.stdout.write('\n'); + const grants = organizationGrants(status.agent_capability_grants); + if (grants.length) { + process.stdout.write(` Grants:\n`); + for (const g of grants) { + const expiry = formatExpiresIn(g.expiresAt); + const expired = expiry === 'expired'; + const level = [g.profile, g.anonymized ? 'anonymized' : '', expiry ?? ''].filter(Boolean); + const statusColor = + g.grant.status === 'active' && !expired ? GREEN : g.grant.status === 'pending' ? YELLOW : RED; + const active = + g.organizationId && + g.organizationId === creds?.org_id && + g.profile === (creds?.access_profile ?? 'read') + ? ` ${DIM}(active)${RESET}` + : ''; + const reason = g.reason ? ` ${DIM}— "${g.reason}"${RESET}` : ''; + process.stdout.write( + ` ${(g.organizationId ?? 'login organization').padEnd(12)} ${statusColor}${expired ? 'expired' : g.grant.status}${RESET} ${DIM}${level.join(', ')}${RESET}${active}${reason}\n`, + ); + } + } + } catch (error) { + const reason = error instanceof AgentAuthError ? `${error.message} (${error.code})` : String(error); + process.stdout.write(` Status: ${RED}unavailable${RESET} ${DIM}${reason}${RESET}\n`); } - - process.stdout.write(` Token: ${creds.token.substring(0, 20)}...${RESET}\n`); }, }), }, }); +const profileCredentials = (profileName?: string) => { + const profile = getResolvedProfile(profileName); + if (!profile?.token) return null; + if (profile.expires_at && new Date(profile.expires_at) < new Date()) return null; + return { + token: profile.token, + org_id: profile.org_id, + user_id: profile.user_id, + expires_at: profile.expires_at, + access_profile: profile.access_profile, + }; +}; + +const printTokenStatus = ( + creds: { + token: string; + org_id?: string; + user_id?: string; + name?: string; + expires_at?: string; + access_profile?: string; + }, + { agent }: { agent: boolean }, +): void => { + const claims = parseJwtPayload(creds.token); + const isApiToken = claims?.token_type === 'api'; + const isCognitoToken = typeof claims?.iss === 'string' && claims.iss.includes('cognito-idp'); + const tokenType = isApiToken ? 'API Token' : isCognitoToken ? 'User Token' : 'Token'; + + process.stdout.write(`${GREEN}${BOLD}Authenticated${RESET} ${DIM}(${tokenType})${RESET}\n`); + + // Resolve fields from JWT claims (API token vs Cognito token vs stored creds) + const name = (claims?.token_name || claims?.email || claims?.['cognito:username'] || creds.name) as + | string + | undefined; + const orgId = (claims?.org_id || claims?.['custom:ivy_org_id'] || creds.org_id) as string | undefined; + const userId = (claims?.user_id || claims?.['custom:ivy_user_id'] || creds.user_id) as string | undefined; + const tokenId = claims?.token_id as string | undefined; + const adminEmail = claims?.admin_email as string | undefined; + const tokenUse = claims?.token_use as string | undefined; + const roles = claims?.assume_roles as string[] | undefined; + const readOnly = claims?.read_only === true; + const anonymize = claims?.anonymize === true; + + if (name) process.stdout.write(` Name: ${name}\n`); + if (adminEmail && adminEmail !== name) process.stdout.write(` Email: ${adminEmail}\n`); + if (orgId) process.stdout.write(` Org: ${orgId}\n`); + if (userId) process.stdout.write(` User: ${userId}\n`); + if (tokenId && tokenId !== userId) process.stdout.write(` Token ID: ${tokenId}\n`); + if (tokenUse) process.stdout.write(` Use: ${tokenUse}\n`); + if (roles?.length) process.stdout.write(` Roles: ${roles.join(', ')}\n`); + process.stdout.write(` Access: ${readOnly ? `${YELLOW}read-only${RESET}` : `${GREEN}read-write${RESET}`}\n`); + if (isAccessProfile(creds.access_profile)) { + process.stdout.write( + ` Profile: ${creds.access_profile} ${DIM}(${ACCESS_PROFILE_INFO[creds.access_profile].title})${RESET}\n`, + ); + } + if (anonymize) process.stdout.write(` Data: ${YELLOW}anonymized${RESET}\n`); + + // Expiry + if (creds.expires_at) { + const expiry = new Date(creds.expires_at); + const now = new Date(); + const diffMs = expiry.getTime() - now.getTime(); + // Agent-issued tokens live for an hour: show hours/minutes. Plain logins keep the day count. + const label = agent + ? diffMs < 86400000 + ? `${Math.floor(diffMs / 3600000)}h ${Math.floor((diffMs % 3600000) / 60000)}m` + : `${Math.floor(diffMs / 86400000)} days` + : `${Math.floor(diffMs / (1000 * 60 * 60 * 24))} days`; + process.stdout.write(` Expires: ${creds.expires_at} ${DIM}(${label})${RESET}\n`); + } else if (claims?.exp) { + const expiry = new Date((claims.exp as number) * 1000); + const now = new Date(); + const diffMs = expiry.getTime() - now.getTime(); + const label = + diffMs < 0 + ? `${RED}expired${RESET}` + : diffMs < 86400000 + ? `${Math.floor(diffMs / 3600000)}h ${Math.floor((diffMs % 3600000) / 60000)}m` + : `${Math.floor(diffMs / 86400000)} days`; + process.stdout.write(` Expires: ${expiry.toISOString()} ${DIM}(${label})${RESET}\n`); + } else if (claims?.iat && !claims?.exp) { + const issued = new Date((claims.iat as number) * 1000); + process.stdout.write(` Issued: ${issued.toISOString()} ${DIM}(no expiry)${RESET}\n`); + } + + process.stdout.write(` Token: ${creds.token.substring(0, 20)}...${RESET}\n`); +}; + /** * Decode a JWT payload without verifying the signature. * Returns null if the token is not a valid JWT. diff --git a/packages/cli/src/commands/org.ts b/packages/cli/src/commands/org.ts new file mode 100644 index 000000000..381d942ff --- /dev/null +++ b/packages/cli/src/commands/org.ts @@ -0,0 +1,431 @@ +import { defineCommand } from 'citty'; +import { + ACCESS_PROFILES, + type AccessProfile, + type Approval, + type CapabilityGrant, + type DeviceAuthorizationApproval, + type EpilotOrganization, + AgentAuthError, + isGrantUsable, + isReadProfile, + listEpilotOrganizations, + mostPermissiveProfile, + organizationGrants, + requestOrganizationAccess, +} from '@epilot/agent-auth'; +import { type LoadedAgentIdentity, formatExpiresIn, issueTokenForOrg, loadAgentIdentity } from '../lib/agent-auth.js'; +import { loadCredentials } from '../lib/auth-store.js'; +import { isInteractive } from '../lib/interactive.js'; +import { getResolvedProfile } from '../lib/profiles.js'; +import { BOLD, RESET, GREEN, RED, DIM, YELLOW, CYAN } from '../lib/utils.js'; +import { describeAccess, openBrowser, parseAccessProfile, printApproval, resolveReason } from './auth-login.js'; + +const commonArgs = { + profile: { type: 'string', description: 'Named profile whose agent identity to use (or EPILOT_PROFILE env)' }, + json: { type: 'boolean', description: 'Output raw JSON' }, + interactive: { type: 'boolean', default: true, description: 'Interactive mode (--no-interactive to disable)' }, +} as const; + +const accessArg = { + access: { + type: 'string', + description: `Access profile (${ACCESS_PROFILES.join(', ')})`, + }, +} as const; + +type CommonArgs = { profile?: string; json?: boolean; interactive?: boolean }; + +export const AGENT_REQUIRED_MESSAGE = 'This profile has no agent identity. Run `epilot auth login --agent` first.'; + +const fail = (message: string, code?: string, json?: boolean): never => { + if (json) { + process.stdout.write(`${JSON.stringify({ error: code ?? 'error', message })}\n`); + } else { + process.stderr.write(`${RED}${message}${RESET}${code ? ` ${DIM}(${code})${RESET}` : ''}\n`); + } + process.exit(1); +}; + +const handleError = (error: unknown, json?: boolean): never => { + if (error instanceof AgentAuthError) return fail(error.message, error.code, json); + return fail(error instanceof Error ? error.message : String(error), undefined, json); +}; + +/** Load the agent identity for the profile or exit with the `--agent` login hint. */ +export const requireAgent = (profileName?: string, json?: boolean): LoadedAgentIdentity => { + const loaded = loadAgentIdentity(profileName); + if (!loaded) { + if (json) { + process.stdout.write(`${JSON.stringify({ error: 'agent_required', message: AGENT_REQUIRED_MESSAGE })}\n`); + } else { + process.stderr.write( + `${YELLOW}This profile has no agent identity.${RESET} Run ${BOLD}epilot auth login --agent${RESET} first.\n`, + ); + } + process.exit(1); + } + return loaded; +}; + +/** Organization the current credentials belong to (profile first, then credentials.json, then the agent record). */ +export const currentOrgId = (loaded: LoadedAgentIdentity | null, profileName?: string): string | undefined => + getResolvedProfile(profileName)?.org_id ?? loadCredentials()?.org_id ?? loaded?.record.org_id; + +const accessCell = (o: EpilotOrganization): string => { + if (o.access?.granted) { + const details = [ + o.access.access_profile ?? 'read', + o.access.anonymized ? 'anonymized' : '', + formatExpiresIn(o.access.expires_at) ?? '', + ].filter(Boolean); + const pending = o.access.pending ? ` ${YELLOW}+ pending request${RESET}` : ''; + return `${GREEN}granted${RESET} ${DIM}(${details.join(', ')})${RESET}${pending}`; + } + if (o.access?.pending) return `${YELLOW}pending${RESET}`; + return `${DIM}no access${RESET}`; +}; + +export const formatOrgTable = (organizations: EpilotOrganization[], activeId?: string): string => { + const idWidth = Math.max(2, ...organizations.map((o) => o.organization_id.length)); + const nameWidth = Math.max(4, ...organizations.map((o) => (o.organization_name ?? '').length)); + const typeWidth = Math.max(4, ...organizations.map((o) => (o.organization_type ?? '').length)); + const lines = [ + ` ${BOLD}${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} ${'TYPE'.padEnd(typeWidth)} ACCESS${RESET}`, + ]; + for (const o of organizations) { + const marker = o.organization_id === activeId ? `${CYAN}*${RESET}` : ' '; + lines.push( + `${marker} ${o.organization_id.padEnd(idWidth)} ${(o.organization_name ?? '').padEnd(nameWidth)} ${( + o.organization_type ?? '' + ).padEnd(typeWidth)} ${accessCell(o)}`, + ); + } + if (activeId) lines.push(`\n${DIM}* active organization${RESET}`); + return `${lines.join('\n')}\n`; +}; + +const isDeviceAuthorization = (approval: Approval | undefined): approval is DeviceAuthorizationApproval => + !!approval && approval.method === 'device_authorization'; + +/** Most permissive usable grant profile for an organization according to /agent/status. */ +const grantedProfile = async (loaded: LoadedAgentIdentity, orgId: string): Promise => { + const status = await loaded.client.getAgentStatus(loaded.identity.hostKey, loaded.identity.agentId); + const usable = organizationGrants(status.agent_capability_grants).filter( + (g) => g.organizationId === orgId && isGrantUsable(g), + ); + return mostPermissiveProfile(usable.map((g) => g.profile)); +}; + +/** `--write` is sugar for `--access full` (deprecated); `--access` wins when both are given. */ +export const resolveRequestProfile = (args: { access?: unknown; write?: boolean }): AccessProfile => { + const explicit = parseAccessProfile(args.access); + if (args.write) { + process.stderr.write( + `${DIM}--write is deprecated; use --access full (or a narrower profile such as config:write / data:write).${RESET}\n`, + ); + return explicit ?? 'full'; + } + return explicit ?? 'read'; +}; + +export type RequestAccessOptions = CommonArgs & { + orgId: string; + /** Default `read`. */ + accessProfile?: AccessProfile; + /** Read profiles only: request unmasked personal data (write profiles always get full PII). */ + fullPii?: boolean; + reason?: string; +}; + +/** + * Ask for access to an organization. Interactive: open the browser, wait for + * the approval, then issue and store a token for the granted profile. + * Non-interactive / --json: print the approval and exit 0 with status "pending". + */ +export const requestOrgAccess = async (loaded: LoadedAgentIdentity, options: RequestAccessOptions): Promise => { + const profile = options.accessProfile ?? 'read'; + const readProfile = isReadProfile(profile); + const anonymize = readProfile ? !options.fullPii : false; + const interactive = isInteractive({ interactive: options.interactive }) && !options.json; + const out = (s: string) => (options.json ? process.stderr : process.stdout).write(s); + + if (options.fullPii && !readProfile) { + out(`${DIM}--full-pii is implied by ${profile}: write access is never anonymized.${RESET}\n`); + } + const reason = + (await resolveReason(profile, options.reason, interactive && !!process.stdin.isTTY)) ?? + `epilot CLI: access to organization ${options.orgId}`; + + const response = await requestOrganizationAccess(loaded.client, loaded.identity, { + organizationId: options.orgId, + profile, + anonymize, + reason, + }); + + const grants = response.agent_capability_grants ?? []; + const pendingGrants = grants.filter((g: CapabilityGrant) => g.status === 'pending'); + const alreadyActive = grants.length > 0 && pendingGrants.length === 0 && grants.every((g) => g.status === 'active'); + let effectiveProfile: AccessProfile | undefined = profile; + + if (!alreadyActive) { + if (!isDeviceAuthorization(response.approval)) { + return fail('The server did not return a device authorization approval.', 'unsupported_approval', options.json); + } + + out(`\n${BOLD}Access request for organization ${options.orgId}${RESET}\n`); + out(` Access: ${describeAccess({ profile, anonymized: anonymize })}`); + out(readProfile && !anonymize ? `, ${YELLOW}full personal data${RESET}\n` : '\n'); + out(` Reason: ${reason}\n`); + printApproval(response.approval, out); + + if (!interactive) { + const pending = { + status: 'pending' as const, + organization_id: options.orgId, + requested: { organization_id: options.orgId, access_profile: profile, anonymize, reason }, + approval: response.approval, + grants, + next: `epilot org use ${options.orgId}`, + }; + if (options.json) { + process.stdout.write(`${JSON.stringify(pending, null, 2)}\n`); + } else { + out( + `${DIM}Approve the request in your browser, then run ${RESET}epilot org use ${options.orgId}${DIM}.${RESET}\n`, + ); + } + return; + } + + await openBrowser(response.approval.verification_uri_complete, out); + const status = await loaded.client.waitForApproval( + loaded.identity.hostKey, + loaded.identity.agentId, + response.approval, + { + pendingGrantIds: pendingGrants.map((g) => g.id).filter((id): id is string => !!id), + onPoll: () => out('.'), + }, + ); + out('\n'); + + const decided = status.agent_capability_grants.filter((g) => pendingGrants.some((p) => p.id && p.id === g.id)); + if (decided.length > 0 && decided.every((g) => g.status === 'denied')) { + return fail(`Access to organization ${options.orgId} was denied.`, 'grant_denied', options.json); + } + // The approving user may have downgraded the profile: issue what was actually granted. + const granted = organizationGrants(decided).filter((g) => isGrantUsable(g)); + effectiveProfile = mostPermissiveProfile(granted.map((g) => g.profile)) ?? profile; + if (effectiveProfile !== profile) { + out(`${YELLOW}Access was granted as ${effectiveProfile} instead of the requested ${profile}.${RESET}\n`); + } + } + + await switchToOrg(loaded, options.orgId, { profile: options.profile, json: options.json }, undefined, { + profile: effectiveProfile, + anonymize: isReadProfile(effectiveProfile) && anonymize ? true : undefined, + }); +}; + +/** Issue a token for the organization and store it as the active credentials. */ +export const switchToOrg = async ( + loaded: LoadedAgentIdentity, + orgId: string, + options: CommonArgs, + org?: EpilotOrganization, + access: { profile?: AccessProfile; anonymize?: boolean } = {}, +): Promise => { + const profile = access.profile ?? org?.access?.access_profile ?? (await grantedProfile(loaded, orgId)); + // Only force anonymization when every grant for the organization is anonymized (or the caller asked for it); + // otherwise the server applies the matched grant's setting. + const anonymize = access.anonymize ?? (org?.access?.anonymized === true ? true : undefined); + const issued = await issueTokenForOrg(loaded, orgId, { + ...(profile ? { profile } : {}), + ...(anonymize !== undefined ? { anonymize } : {}), + profileName: options.profile, + }); + const issuedProfile = issued.access_profile ?? profile ?? 'read'; + if (options.json) { + process.stdout.write( + `${JSON.stringify( + { + status: 'active', + organization_id: issued.organization_id ?? orgId, + organization_name: org?.organization_name, + user_id: issued.user_id, + access_profile: issuedProfile, + read_only: issued.read_only, + anonymize: issued.anonymize, + expires_at: issued.expires_at, + ...(org?.access?.expires_at ? { grant_expires_at: org.access.expires_at } : {}), + }, + null, + 2, + )}\n`, + ); + return; + } + const label = org?.organization_name ? `${org.organization_name} ${DIM}(${orgId})${RESET}` : orgId; + process.stdout.write(`${GREEN}${BOLD}Switched to organization ${RESET}${label}\n`); + process.stdout.write( + ` Access: ${describeAccess({ profile: issuedProfile, anonymized: issued.anonymize, expiresAt: org?.access?.expires_at })}\n`, + ); + process.stdout.write(` Token expires: ${issued.expires_at} ${DIM}(refreshed automatically)${RESET}\n`); +}; + +export default defineCommand({ + meta: { + name: 'org', + description: 'List, switch and request access to organizations (agent mode: `epilot auth login --agent`)', + }, + subCommands: { + list: defineCommand({ + meta: { name: 'list', description: 'List your organizations and the access this CLI has to them' }, + args: commonArgs, + run: async ({ args }) => { + const loaded = requireAgent(args.profile, args.json); + try { + const { organizations } = await listEpilotOrganizations(loaded.client, loaded.identity); + const active = currentOrgId(loaded, args.profile); + if (args.json) { + process.stdout.write( + `${JSON.stringify( + organizations.map((o) => ({ ...o, active: o.organization_id === active })), + null, + 2, + )}\n`, + ); + return; + } + if (organizations.length === 0) { + process.stdout.write('No organizations found for your user.\n'); + return; + } + process.stdout.write(formatOrgTable(organizations, active)); + } catch (error) { + handleError(error, args.json); + } + }, + }), + use: defineCommand({ + meta: { + name: 'use', + description: 'Switch the active organization (issues a token for its most permissive grant)', + }, + args: { + id: { type: 'positional', description: 'Organization ID', required: true }, + ...accessArg, + ...commonArgs, + }, + run: async ({ args }) => { + const loaded = requireAgent(args.profile, args.json); + const orgId = String(args.id); + try { + const explicit = parseAccessProfile(args.access); + const { organizations } = await listEpilotOrganizations(loaded.client, loaded.identity); + const org = organizations.find((o) => o.organization_id === orgId); + if (!org) { + return fail(`Organization ${orgId} is not available to your user.`, 'organization_not_found', args.json); + } + if (org.access?.granted) { + await switchToOrg(loaded, orgId, args, org, { profile: explicit }); + return; + } + process.stdout.write( + `${YELLOW}This CLI has no access to organization ${orgId} yet${org.access?.pending ? ' (request pending)' : ''}.${RESET} Requesting access...\n`, + ); + await requestOrgAccess(loaded, { ...args, orgId, accessProfile: explicit }); + } catch (error) { + handleError(error, args.json); + } + }, + }), + request: defineCommand({ + meta: { name: 'request', description: 'Request access to an organization (approved in your browser)' }, + args: { + id: { type: 'positional', description: 'Organization ID', required: true }, + ...accessArg, + write: { type: 'boolean', description: 'Deprecated: same as --access full' }, + reason: { + type: 'string', + description: 'Purpose shown to the approving user (required for profiles other than read)', + }, + 'full-pii': { + type: 'boolean', + description: 'Read profiles: request unmasked personal data (default: anonymized; implied by write profiles)', + }, + ...commonArgs, + }, + run: async ({ args }) => { + const loaded = requireAgent(args.profile, args.json); + try { + await requestOrgAccess(loaded, { + orgId: String(args.id), + accessProfile: resolveRequestProfile(args), + fullPii: args['full-pii'], + reason: args.reason, + profile: args.profile, + json: args.json, + interactive: args.interactive, + }); + } catch (error) { + handleError(error, args.json); + } + }, + }), + current: defineCommand({ + meta: { name: 'current', description: 'Show the active organization' }, + args: commonArgs, + run: async ({ args }) => { + const loaded = requireAgent(args.profile, args.json); + const orgId = currentOrgId(loaded, args.profile); + if (!orgId) { + if (args.json) { + process.stdout.write(`${JSON.stringify({ organization_id: null, agent_id: loaded.record.agent_id })}\n`); + return; + } + process.stdout.write(`${YELLOW}No active organization.${RESET} Run ${BOLD}epilot org use ${RESET}.\n`); + return; + } + let org: EpilotOrganization | undefined; + try { + const { organizations } = await listEpilotOrganizations(loaded.client, loaded.identity); + org = organizations.find((o) => o.organization_id === orgId); + } catch { + // Offline or agent gone: still print what we know locally. + } + const accessProfile = loaded.record.access_profile ?? org?.access?.access_profile ?? 'read'; + if (args.json) { + process.stdout.write( + `${JSON.stringify( + { + organization_id: orgId, + organization_name: org?.organization_name, + access_profile: accessProfile, + read_only: loaded.record.read_only ?? org?.access?.read_only, + anonymize: loaded.record.anonymize ?? org?.access?.anonymized, + grant_expires_at: org?.access?.expires_at, + agent_id: loaded.record.agent_id, + }, + null, + 2, + )}\n`, + ); + return; + } + const label = org?.organization_name ? `${org.organization_name} ${DIM}(${orgId})${RESET}` : orgId; + process.stdout.write(`${BOLD}Active organization:${RESET} ${label}\n`); + process.stdout.write( + ` Access: ${describeAccess({ + profile: accessProfile, + anonymized: loaded.record.anonymize ?? org?.access?.anonymized, + expiresAt: org?.access?.expires_at, + })}\n`, + ); + if (org) process.stdout.write(` Grants: ${accessCell(org)}\n`); + }, + }), + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5c2854d21..ea9a40ebd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,7 @@ export const main = defineCommand({ }, subCommands: { auth: () => import('./commands/auth.js').then((m) => m.default), + org: () => import('./commands/org.js').then((m) => m.default), profile: () => import('./commands/profile.js').then((m) => m.default), config: () => import('./commands/config.js').then((m) => m.default), completion: () => import('./commands/completion.js').then((m) => m.default), diff --git a/packages/cli/src/lib/agent-auth.ts b/packages/cli/src/lib/agent-auth.ts new file mode 100644 index 000000000..c24d31279 --- /dev/null +++ b/packages/cli/src/lib/agent-auth.ts @@ -0,0 +1,297 @@ +/** + * Agent Auth Protocol support for the CLI (opt-in via `epilot auth login --agent`): + * host/agent key storage under ~/.config/epilot/agent-auth/, client construction, + * token issuance and silent refresh. + * + * - Host = this machine (one key pair, `agent-auth/host.json`). + * - Agent = one per profile (`agent-auth/agents/.json`). + * + * Profiles without an agent record (plain browser login, `--token`, `auth token`) + * are never touched by anything in here. + */ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + type AccessProfile, + type AgentIdentity, + AgentAuthClient, + AgentAuthError, + type Ed25519Jwk, + type EpilotIssuedAccessToken, + type KeyPair, + epilotAgentAuthIssuer, + generateKeyPair, + isAccessProfile, + issueEpilotAccessToken, + keyPairFromPrivateJwk, +} from '@epilot/agent-auth'; +import { type Credentials, saveCredentials } from './auth-store.js'; +import type { Environment } from './environment.js'; +import { getConfigDir, getResolvedProfile, resolveProfileName } from './profiles.js'; +import { BOLD, DIM, RESET, YELLOW } from './utils.js'; + +export type HostRecord = { + privateKey: Ed25519Jwk; + thumbprint: string; + created_at: string; +}; + +export type AgentRecord = { + agent_id: string; + host_id: string; + privateKey: Ed25519Jwk; + issuer: string; + name: string; + created_at: string; + /** Organization the current token was issued for (used for silent refresh). */ + org_id?: string; + /** Access profile the current token was issued with (re-used on refresh). */ + access_profile?: AccessProfile; + /** Access level the current token was issued with (re-used on refresh). */ + read_only?: boolean; + anonymize?: boolean; +}; + +export type LoadedAgentIdentity = { + identity: AgentIdentity; + record: AgentRecord; + client: AgentAuthClient; + /** Storage key: the resolved profile name, or "default". */ + profileKey: string; +}; + +/** Refresh when the token is missing or expires within this window. */ +export const REFRESH_WINDOW_MS = 2 * 60 * 1000; + +const agentAuthDir = (): string => join(getConfigDir(), 'agent-auth'); +const hostPath = (): string => join(agentAuthDir(), 'host.json'); +const agentsDir = (): string => join(agentAuthDir(), 'agents'); + +/** Storage key for the agent of a profile. */ +export const agentProfileKey = (profileName?: string): string => resolveProfileName(profileName) ?? 'default'; + +export const agentPath = (profileName?: string): string => join(agentsDir(), `${agentProfileKey(profileName)}.json`); + +const readJson = (path: string): T | null => { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return null; + } +}; + +const writeSecret = (path: string, value: unknown): void => { + mkdirSync(join(path, '..'), { recursive: true, mode: 0o700 }); + writeFileSync(path, JSON.stringify(value, null, 2), { mode: 0o600 }); +}; + +// ─── Host key ──────────────────────────────────────────────────────────────── + +export const loadHostKey = (): KeyPair | null => { + const record = readJson(hostPath()); + if (!record?.privateKey?.d) return null; + try { + return keyPairFromPrivateJwk(record.privateKey); + } catch { + return null; + } +}; + +/** Load the host key for this machine, creating it on first use. */ +export const ensureHostKey = (): KeyPair => { + const existing = loadHostKey(); + if (existing) return existing; + const key = generateKeyPair(); + const record: HostRecord = { + privateKey: key.privateKey, + thumbprint: key.thumbprint, + created_at: new Date().toISOString(), + }; + writeSecret(hostPath(), record); + return key; +}; + +// ─── Agent records ─────────────────────────────────────────────────────────── + +export const loadAgentRecord = (profileName?: string): AgentRecord | null => { + const record = readJson(agentPath(profileName)); + return record?.agent_id && record.privateKey?.d ? record : null; +}; + +export const saveAgentRecord = (record: AgentRecord, profileName?: string): void => { + writeSecret(agentPath(profileName), record); +}; + +export const deleteAgentRecord = (profileName?: string): boolean => { + const path = agentPath(profileName); + if (!existsSync(path)) return false; + rmSync(path); + return true; +}; + +// ─── Client ────────────────────────────────────────────────────────────────── + +/** Issuer for an environment; `EPILOT_AGENT_AUTH_ISSUER` overrides (useful for local servers). */ +export const resolveAgentAuthIssuer = (env: Environment = 'production'): string => + process.env.EPILOT_AGENT_AUTH_ISSUER || epilotAgentAuthIssuer(env); + +export const getAgentAuthClient = (envOrIssuer: Environment | string = 'production'): AgentAuthClient => { + const baseUrl = /^https?:\/\//.test(envOrIssuer) ? envOrIssuer : resolveAgentAuthIssuer(envOrIssuer as Environment); + return new AgentAuthClient({ baseUrl }); +}; + +/** Load the agent identity (keys + client) stored for a profile, or null when the profile has no agent. */ +export const loadAgentIdentity = (profileName?: string): LoadedAgentIdentity | null => { + const record = loadAgentRecord(profileName); + const hostKey = loadHostKey(); + if (!record || !hostKey) return null; + try { + const agentKey = keyPairFromPrivateJwk(record.privateKey); + return { + identity: { hostKey, agentKey, agentId: record.agent_id }, + record, + client: getAgentAuthClient(record.issuer), + profileKey: agentProfileKey(profileName), + }; + } catch { + return null; + } +}; + +/** + * A plain login (browser callback, `--token`, `auth token`) supersedes an agent + * that was registered for the same profile: revoke it (best effort) and forget + * it locally, so the silent refresh never replaces the plain token. + */ +export const forgetAgentForProfile = async ( + profileName: string | undefined, + out: (s: string) => void = (s) => process.stdout.write(s), +): Promise => { + const loaded = loadAgentIdentity(profileName); + if (!loaded) return false; + try { + await loaded.client.revokeAgent(loaded.identity.hostKey, loaded.identity.agentId); + out(`${DIM}Agent ${loaded.record.agent_id} from a previous --agent login revoked.${RESET}\n`); + } catch (error) { + const reason = error instanceof AgentAuthError ? error.code : 'error'; + out(`${DIM}Could not revoke agent ${loaded.record.agent_id} (${reason}); removing it locally.${RESET}\n`); + } + deleteAgentRecord(profileName); + return true; +}; + +// ─── Token issuance & refresh ──────────────────────────────────────────────── + +export type IssueTokenOptions = { + /** Access profile to issue under; defaults to the matched grant's profile server-side. */ + profile?: AccessProfile; + readOnly?: boolean; + anonymize?: boolean; + /** Profile to store the credentials in (same resolution as `epilot auth login --profile`). */ + profileName?: string; +}; + +/** + * Issue an epilot access token for an organization through the agent and + * persist it exactly where `epilot auth login` stores credentials. Also + * remembers the organization/profile/access level on the agent record for refresh. + */ +export const issueTokenForOrg = async ( + loaded: LoadedAgentIdentity, + orgId: string, + options: IssueTokenOptions = {}, +): Promise => { + const issued = await issueEpilotAccessToken(loaded.client, loaded.identity, { + organization_id: orgId, + ...(options.profile !== undefined ? { access_profile: options.profile } : {}), + ...(options.readOnly !== undefined ? { read_only: options.readOnly } : {}), + ...(options.anonymize !== undefined ? { anonymize: options.anonymize } : {}), + }); + + const accessProfile = isAccessProfile(issued.access_profile) ? issued.access_profile : options.profile; + const creds: Credentials = { + token: issued.token, + org_id: issued.organization_id ?? orgId, + user_id: issued.user_id, + expires_at: issued.expires_at, + ...(accessProfile ? { access_profile: accessProfile } : {}), + ...(issued.email ? { name: issued.email } : {}), + }; + saveCredentials(creds, options.profileName); + + saveAgentRecord( + { + ...loaded.record, + org_id: creds.org_id, + access_profile: accessProfile, + read_only: issued.read_only ?? options.readOnly, + anonymize: issued.anonymize ?? options.anonymize, + }, + options.profileName, + ); + return issued; +}; + +const expiresSoon = (expiresAt?: string): boolean => { + if (!expiresAt) return false; + const expiry = new Date(expiresAt).getTime(); + return Number.isNaN(expiry) || expiry - Date.now() < REFRESH_WINDOW_MS; +}; + +/** + * Silent refresh: when the resolved profile has an agent identity and its + * token is missing or about to expire, issue a fresh token for the profile's + * organization (same access profile as before) and store it. Returns the + * valid token, or null when the profile has no agent or no organization to + * issue for. + * + * Throws AgentAuthError when issuance fails (e.g. agent_revoked). + */ +export const refreshTokenIfNeeded = async (flagProfile?: string): Promise => { + const loaded = loadAgentIdentity(flagProfile); + if (!loaded) return null; + + const profile = getResolvedProfile(flagProfile); + const current = profile?.token ? profile : loadStoredCredentials(); + if (current?.token && !expiresSoon(current.expires_at)) return current.token; + + const orgId = current?.org_id ?? loaded.record.org_id; + if (!orgId) return null; + + const issued = await issueTokenForOrg(loaded, orgId, { + profile: loaded.record.access_profile, + readOnly: loaded.record.read_only, + anonymize: loaded.record.anonymize, + profileName: flagProfile, + }); + return issued.token; +}; + +/** Read credentials.json without the expiry filter (the refresh needs the org id of an expired token). */ +const loadStoredCredentials = (): Credentials | null => readJson(join(getConfigDir(), 'credentials.json')); + +/** Codes after which the agent cannot be used anymore and the user has to log in again. */ +export const isAgentGoneError = (error: unknown): error is AgentAuthError => + error instanceof AgentAuthError && + ['agent_revoked', 'agent_expired', 'agent_rejected', 'agent_not_found', 'host_revoked'].includes(error.code); + +export const printLoginAgainHint = (error: AgentAuthError): void => { + process.stderr.write(`${YELLOW}Agent session is no longer valid (${error.code}).${RESET} `); + process.stderr.write(`Run ${BOLD}epilot auth login --agent${RESET} to authenticate again.\n`); +}; + +// ─── Formatting ────────────────────────────────────────────────────────────── + +/** "expires in 23h" / "expires in 6d" / "expires in 12m" / "expired" for an ISO timestamp. */ +export const formatExpiresIn = (expiresAt: string | undefined, now = Date.now()): string | undefined => { + if (!expiresAt) return undefined; + const diffMs = new Date(expiresAt).getTime() - now; + if (Number.isNaN(diffMs)) return undefined; + if (diffMs <= 0) return 'expired'; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return `expires in ${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `expires in ${hours}h`; + return `expires in ${Math.floor(hours / 24)}d`; +}; diff --git a/packages/cli/src/lib/auth-store.ts b/packages/cli/src/lib/auth-store.ts index ad726e9b6..8de9b53a6 100644 --- a/packages/cli/src/lib/auth-store.ts +++ b/packages/cli/src/lib/auth-store.ts @@ -9,6 +9,8 @@ export type Credentials = { user_id?: string; name?: string; expires_at?: string; + /** Access profile the token was issued with (Agent Auth logins only). */ + access_profile?: string; }; const getConfigDir = (): string => { @@ -53,6 +55,7 @@ export const saveCredentials = (creds: Credentials, profileName?: string): void org_id: creds.org_id, user_id: creds.user_id, expires_at: creds.expires_at, + access_profile: creds.access_profile, }); } @@ -99,3 +102,25 @@ export const resolveToken = (flagToken?: string, flagProfile?: string): string | const creds = loadCredentials(); return creds?.token ?? null; }; + +/** + * Like `resolveToken`, but silently refreshes the token first when the + * resolved profile has an Agent Auth identity and its token is missing or + * about to expire. Flags and env vars still win. Profiles without an agent + * (plain `epilot auth login`, `--token`, `auth token`) are untouched. + */ +export const resolveTokenAsync = async (flagToken?: string, flagProfile?: string): Promise => { + if (flagToken) return flagToken; + if (process.env.EPILOT_TOKEN) return process.env.EPILOT_TOKEN; + + const { refreshTokenIfNeeded, isAgentGoneError, printLoginAgainHint } = await import('./agent-auth.js'); + try { + const refreshed = await refreshTokenIfNeeded(flagProfile); + if (refreshed) return refreshed; + } catch (error) { + if (isAgentGoneError(error)) printLoginAgainHint(error); + // Any other failure (network, server) falls back to whatever token is stored. + } + + return resolveToken(flagToken, flagProfile); +}; diff --git a/packages/cli/src/lib/call.ts b/packages/cli/src/lib/call.ts index 453126903..ce2ebdc78 100644 --- a/packages/cli/src/lib/call.ts +++ b/packages/cli/src/lib/call.ts @@ -7,7 +7,7 @@ const OpenAPIClientAxios = OpenAPIClientAxiosModule; import { loadDefinition } from './definition-loader.js'; -import { resolveToken } from './auth-store.js'; +import { resolveTokenAsync } from './auth-store.js'; import { getResolvedProfile, getStage } from './profiles.js'; import { collectParams, getOperationParams, getMissingRequired } from './param-collector.js'; import { resolveBody, getRequestBodyInfo } from './body-handler.js'; @@ -426,7 +426,7 @@ export const callApi = async (apiName: string, args: CallArgs): Promise => } // Resolve auth (--token > EPILOT_TOKEN > profile > credentials.json > interactive prompt) - let token = resolveToken(args.token, args.profile); + let token = await resolveTokenAsync(args.token, args.profile); if (!token) { if (isInteractive({ interactive: args.interactive })) { const { promptToken } = await import('./interactive.js'); diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts index 725263d27..74928fc65 100644 --- a/packages/cli/src/lib/profiles.ts +++ b/packages/cli/src/lib/profiles.ts @@ -15,6 +15,8 @@ export type Profile = { user_id?: string; /** Token expiry */ expires_at?: string; + /** Access profile the token was issued with (Agent Auth logins only, e.g. "read", "config:write") */ + access_profile?: string; /** Custom headers */ headers?: Record; }; @@ -28,7 +30,8 @@ export type ProfileConfig = { stage?: string; }; -const getConfigDir = (): string => { +/** CLI config directory: `$XDG_CONFIG_HOME/epilot` or `~/.config/epilot`. */ +export const getConfigDir = (): string => { const xdgConfig = process.env.XDG_CONFIG_HOME; const base = xdgConfig || join(homedir(), '.config'); return join(base, 'epilot'); diff --git a/packages/cli/src/lib/reorder-args.ts b/packages/cli/src/lib/reorder-args.ts index acd16beda..abee91e8e 100644 --- a/packages/cli/src/lib/reorder-args.ts +++ b/packages/cli/src/lib/reorder-args.ts @@ -26,6 +26,9 @@ const VALUE_TAKING_FLAGS = new Set([ '--server', '--jsonata', '--definition', + '--org', + '--access', + '--reason', // short '-t', '-s', diff --git a/packages/cli/test/agent-auth-lib.test.ts b/packages/cli/test/agent-auth-lib.test.ts new file mode 100644 index 000000000..a66ac7b6e --- /dev/null +++ b/packages/cli/test/agent-auth-lib.test.ts @@ -0,0 +1,220 @@ +import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + REFRESH_WINDOW_MS, + agentPath, + agentProfileKey, + deleteAgentRecord, + ensureHostKey, + getAgentAuthClient, + issueTokenForOrg, + loadAgentIdentity, + loadAgentRecord, + loadHostKey, + refreshTokenIfNeeded, + resolveAgentAuthIssuer, + saveAgentRecord, +} from '../src/lib/agent-auth.js'; +import { resolveTokenAsync } from '../src/lib/auth-store.js'; +import { loadProfiles, upsertProfile, setActiveProfile } from '../src/lib/profiles.js'; +import { + ISSUER, + createState, + startFakeAap, + useTempConfigDir, + captureOutput, + type FakeState, +} from './helpers/fake-aap.js'; +import { generateKeyPair } from '@epilot/agent-auth'; + +let state: FakeState; +let server: ReturnType; +let tmp: ReturnType; +let output: ReturnType; + +beforeAll(() => { + state = createState({ agentStatus: 'active' }); + server = startFakeAap(state); +}); +afterAll(() => server.close()); + +beforeEach(() => { + tmp = useTempConfigDir(); + output = captureOutput(); + process.env.EPILOT_AGENT_AUTH_ISSUER = ISSUER; + Object.assign(state, createState({ agentStatus: 'active' })); + state.grants = [ + { id: 'grant_1', capability: 'epilot.organizations.list', status: 'active' }, + { + id: 'grant_2', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '739224', read_only: true, anonymize: true }, + }, + ]; +}); +afterEach(() => { + output.restore(); + tmp.restore(); + delete process.env.EPILOT_AGENT_AUTH_ISSUER; + delete process.env.EPILOT_TOKEN; +}); + +const sampleRecord = (overrides: Record = {}) => ({ + agent_id: 'agent_1', + host_id: 'host_1', + privateKey: generateKeyPair().privateKey, + issuer: ISSUER, + name: 'epilot CLI @ test', + created_at: '2026-01-01T00:00:00Z', + ...overrides, +}); + +describe('host key storage', () => { + it('creates host.json with mode 0600 on first use and reuses it afterwards', () => { + expect(loadHostKey()).toBeNull(); + const key = ensureHostKey(); + const path = join(tmp.configDir, 'agent-auth', 'host.json'); + expect(existsSync(path)).toBe(true); + expect(statSync(path).mode & 0o777).toBe(0o600); + const stored = JSON.parse(readFileSync(path, 'utf-8')); + expect(stored).toMatchObject({ thumbprint: key.thumbprint, privateKey: key.privateKey }); + expect(stored.created_at).toMatch(/^\d{4}-/); + expect(ensureHostKey().thumbprint).toBe(key.thumbprint); + }); +}); + +describe('agent record storage', () => { + it('stores one agent per profile under agent-auth/agents/.json', () => { + expect(agentProfileKey()).toBe('default'); + expect(agentPath('staging')).toBe(join(tmp.configDir, 'agent-auth', 'agents', 'staging.json')); + + const record = sampleRecord(); + saveAgentRecord(record); + saveAgentRecord(sampleRecord({ agent_id: 'agent_staging' }), 'staging'); + expect(statSync(agentPath()).mode & 0o777).toBe(0o600); + expect(loadAgentRecord()?.agent_id).toBe('agent_1'); + expect(loadAgentRecord('staging')?.agent_id).toBe('agent_staging'); + + expect(deleteAgentRecord('staging')).toBe(true); + expect(deleteAgentRecord('staging')).toBe(false); + expect(loadAgentRecord('staging')).toBeNull(); + }); + + it('resolves the agent of the active profile and EPILOT_PROFILE', () => { + upsertProfile('work', { token: 'x' }); + setActiveProfile('work'); + expect(agentProfileKey()).toBe('work'); + process.env.EPILOT_PROFILE = 'other'; + expect(agentProfileKey()).toBe('other'); + expect(agentProfileKey('explicit')).toBe('explicit'); + }); + + it('loadAgentIdentity needs both a host key and an agent record', () => { + saveAgentRecord(sampleRecord()); + expect(loadAgentIdentity()).toBeNull(); + ensureHostKey(); + const loaded = loadAgentIdentity(); + expect(loaded?.identity.agentId).toBe('agent_1'); + expect(loaded?.identity.hostKey.thumbprint).toBe(loadHostKey()?.thumbprint); + expect(loaded?.client.baseUrl).toBe(ISSUER); + expect(loaded?.profileKey).toBe('default'); + }); + + it('ignores corrupt files', () => { + mkdirSync(join(tmp.configDir, 'agent-auth', 'agents'), { recursive: true }); + writeFileSync(join(tmp.configDir, 'agent-auth', 'host.json'), '{not json'); + writeFileSync(agentPath(), JSON.stringify({ agent_id: 'x' })); + expect(loadHostKey()).toBeNull(); + expect(loadAgentRecord()).toBeNull(); + }); +}); + +describe('issuer resolution', () => { + it('uses the env override, else the stage default', () => { + expect(resolveAgentAuthIssuer('dev')).toBe(ISSUER); + delete process.env.EPILOT_AGENT_AUTH_ISSUER; + expect(resolveAgentAuthIssuer('dev')).toBe('https://access-token.dev.sls.epilot.io/v1/access-tokens/agent-auth'); + expect(resolveAgentAuthIssuer()).toBe('https://access-token.sls.epilot.io/v1/access-tokens/agent-auth'); + expect(getAgentAuthClient('https://custom.example/aap').baseUrl).toBe('https://custom.example/aap'); + expect(getAgentAuthClient('staging').baseUrl).toBe( + 'https://access-token.staging.sls.epilot.io/v1/access-tokens/agent-auth', + ); + }); +}); + +describe('token issuance and refresh', () => { + it('issueTokenForOrg stores token, org, user and expiry in credentials.json and the profile', async () => { + ensureHostKey(); + saveAgentRecord(sampleRecord(), 'work'); + const loaded = loadAgentIdentity('work')!; + + const issued = await issueTokenForOrg(loaded, '739224', { readOnly: true, anonymize: true, profileName: 'work' }); + expect(issued.token).toBe('tok_739224_1'); + expect(state.executions.at(-1)?.body).toEqual({ + capability: 'epilot.access_token.issue', + arguments: { organization_id: '739224', read_only: true, anonymize: true }, + }); + + const creds = JSON.parse(readFileSync(join(tmp.configDir, 'credentials.json'), 'utf-8')); + expect(creds).toMatchObject({ + token: 'tok_739224_1', + org_id: '739224', + user_id: 'user_1', + name: 'dev@epilot.cloud', + }); + expect(creds.expires_at).toBe(issued.expires_at); + expect(loadProfiles().profiles.work).toMatchObject({ token: 'tok_739224_1', org_id: '739224', user_id: 'user_1' }); + expect(loadAgentRecord('work')).toMatchObject({ org_id: '739224', read_only: true, anonymize: true }); + }); + + it('refreshTokenIfNeeded keeps a valid token and re-issues an expiring one', async () => { + ensureHostKey(); + saveAgentRecord(sampleRecord({ org_id: '739224', read_only: true, anonymize: true })); + const farFuture = new Date(Date.now() + 3600_000).toISOString(); + upsertProfile('default', { token: 'still-valid', org_id: '739224', expires_at: farFuture }); + + expect(await refreshTokenIfNeeded()).toBe('still-valid'); + expect(state.executions).toHaveLength(0); + + const soon = new Date(Date.now() + REFRESH_WINDOW_MS / 2).toISOString(); + upsertProfile('default', { token: 'expiring', org_id: '739224', expires_at: soon }); + expect(await refreshTokenIfNeeded()).toBe('tok_739224_1'); + expect(state.executions.at(-1)?.body.arguments).toEqual({ + organization_id: '739224', + read_only: true, + anonymize: true, + }); + expect(loadProfiles().profiles.default.token).toBe('tok_739224_1'); + }); + + it('refreshTokenIfNeeded issues a token when none is stored, using the org from the agent record', async () => { + ensureHostKey(); + saveAgentRecord(sampleRecord({ org_id: '739224' })); + expect(await refreshTokenIfNeeded()).toBe('tok_739224_1'); + }); + + it('refreshTokenIfNeeded is a no-op without an agent identity', async () => { + expect(await refreshTokenIfNeeded()).toBeNull(); + }); + + it('resolveTokenAsync prefers flag and env, refreshes otherwise, and hints on a revoked agent', async () => { + ensureHostKey(); + saveAgentRecord(sampleRecord({ org_id: '739224' })); + expect(await resolveTokenAsync('flag-token')).toBe('flag-token'); + process.env.EPILOT_TOKEN = 'env-token'; + expect(await resolveTokenAsync()).toBe('env-token'); + delete process.env.EPILOT_TOKEN; + expect(state.executions).toHaveLength(0); + + expect(await resolveTokenAsync()).toBe('tok_739224_1'); + + // Expired stored token + revoked agent → hint, then fall back to the stored resolution (null: expired). + state.agentStatus = 'revoked'; + upsertProfile('default', { token: 'old', org_id: '739224', expires_at: new Date(Date.now() - 1000).toISOString() }); + expect(await resolveTokenAsync()).toBeNull(); + expect(output.out.stderr).toContain('agent_revoked'); + expect(output.out.stderr).toContain('epilot auth login'); + }); +}); diff --git a/packages/cli/test/auth-login.test.ts b/packages/cli/test/auth-login.test.ts new file mode 100644 index 000000000..611ed0530 --- /dev/null +++ b/packages/cli/test/auth-login.test.ts @@ -0,0 +1,387 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateKeyPair } from '@epilot/agent-auth'; +import { ensureHostKey, loadAgentRecord, loadHostKey, saveAgentRecord } from '../src/lib/agent-auth.js'; +import { loadProfiles } from '../src/lib/profiles.js'; +import { + ISSUER, + createState, + startFakeAap, + useTempConfigDir, + captureOutput, + stripAnsi, + type FakeState, +} from './helpers/fake-aap.js'; + +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), password: vi.fn(), confirm: vi.fn(), input: vi.fn() })); + +let state: FakeState; +let server: ReturnType; +let tmp: ReturnType; +let output: ReturnType; +let exitSpy: ReturnType; + +const originalStdinTTY = process.stdin.isTTY; +const originalStdoutTTY = process.stdout.isTTY; +const setTTY = (value: boolean) => { + Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, configurable: true }); +}; + +beforeAll(() => { + state = createState(); + server = startFakeAap(state); +}); +afterAll(() => server.close()); + +beforeEach(() => { + tmp = useTempConfigDir(); + output = captureOutput(); + process.env.EPILOT_AGENT_AUTH_ISSUER = ISSUER; + Object.assign(state, createState()); + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + vi.clearAllMocks(); +}); +afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinTTY, writable: true, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutTTY, writable: true, configurable: true }); + exitSpy.mockRestore(); + output.restore(); + tmp.restore(); + delete process.env.EPILOT_AGENT_AUTH_ISSUER; +}); + +const loadLogin = async () => (await import('../src/commands/auth-login.js')).default; +const run = async (args: Record) => (await loadLogin()).run!({ args } as any); + +describe('epilot auth login (plain, default)', () => { + it('--token saves directly without touching Agent Auth', async () => { + await run({ token: 'manual-token', profile: 'p1' }); + expect(loadProfiles().profiles.p1.token).toBe('manual-token'); + expect(output.out.stdout).toContain('Token saved'); + expect(state.registrations).toHaveLength(0); + expect(existsSync(join(tmp.configDir, 'agent-auth'))).toBe(false); + }); + + it('opens the browser callback flow and does not register an agent', async () => { + setTTY(true); + const { confirm } = await import('@inquirer/prompts'); + (confirm as ReturnType).mockResolvedValue(false); // user declines to open the browser + await expect(run({})).rejects.toThrow('process.exit called'); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('epilot CLI Login'); + expect(plain).toContain('This will open your browser to authenticate with epilot.'); + expect(plain).toMatch(/Verification code: [A-F0-9]{6}/); + expect(plain).not.toContain('agent mode'); + expect(output.out.stderr).toContain('Login failed or was cancelled'); + expect(state.registrations).toHaveLength(0); + expect(loadHostKey()).toBeNull(); + }); + + it('requires a TTY and points at --token / auth token (same message as before)', async () => { + setTTY(false); + await expect(run({})).rejects.toThrow('process.exit called'); + expect(stripAnsi(output.out.stderr)).toBe( + 'Browser login requires an interactive terminal.\n' + + 'Use epilot auth login --token or epilot auth token instead.\n', + ); + expect(state.registrations).toHaveLength(0); + }); + + it('rejects agent-only flags without --agent', async () => { + setTTY(true); + await expect(run({ org: '739224' })).rejects.toThrow('process.exit called'); + expect(stripAnsi(output.out.stderr)).toContain('--org requires --agent'); + await expect(run({ access: 'full', reason: 'x' })).rejects.toThrow('process.exit called'); + expect(stripAnsi(output.out.stderr)).toContain('--access, --reason require --agent'); + expect(state.registrations).toHaveLength(0); + }); + + it('a plain login supersedes an agent registered for the same profile', async () => { + ensureHostKey(); + saveAgentRecord({ + agent_id: 'agent_1', + host_id: 'host_1', + privateKey: generateKeyPair().privateKey, + issuer: ISSUER, + name: 'epilot CLI @ test', + created_at: '2026-01-01T00:00:00Z', + }); + await run({ token: 'manual-token' }); + expect(state.revoked).toEqual(['agent_1']); + expect(loadAgentRecord()).toBeNull(); + expect(output.out.stdout).toContain('revoked'); + expect(output.out.stdout).toContain('Token saved'); + }); +}); + +describe('epilot auth login --agent', () => { + it('registers, waits for approval, issues a token for the granted org and persists everything', async () => { + const { agentLogin } = await import('../src/commands/auth-login.js'); + const open = (await import('open')).default as ReturnType; + + const result = await agentLogin({ + env: 'production', + readonly: false, + anonymize: false, + interactive: true, + json: false, + }); + + // Registration request + expect(state.registrations).toHaveLength(1); + const registration = state.registrations[0].body; + expect(registration.name).toMatch(/^epilot CLI @ .+/); + expect(registration.host_name).toBe(registration.name.replace('epilot CLI @ ', '')); + expect(registration.mode).toBe('delegated'); + expect(registration.reason).toBe('epilot CLI login'); + expect(registration.capabilities).toEqual([ + 'epilot.organizations.list', + { + name: 'epilot.access_token.issue', + constraints: { access_profile: 'read', read_only: false, anonymize: false }, + }, + ]); + expect(state.registrations[0].authorization).toMatch(/^Bearer /); + + // Approval UX: code + URL shown, browser opened, polled until active + expect(output.out.stdout).toContain('agent mode'); + expect(output.out.stdout).toContain('Verification code: \x1b[1mWDJB-MJHT'); + expect(output.out.stdout).toContain('agent_approval=WDJB-MJHT'); + expect(open).toHaveBeenCalledWith(expect.stringContaining('agent_approval=WDJB-MJHT')); + expect(state.polls).toBeGreaterThanOrEqual(2); + + // Capabilities executed: list then issue for the single granted org, under the granted profile + expect(state.executions.map((e) => e.body.capability)).toEqual([ + 'epilot.organizations.list', + 'epilot.access_token.issue', + ]); + expect(state.executions[1].body.arguments).toEqual({ organization_id: '739224', access_profile: 'read' }); + + // Result + persisted state + expect(result).toMatchObject({ + agent_id: 'agent_1', + host_id: 'host_1', + issuer: ISSUER, + organization_id: '739224', + organization_name: 'ACME Energy', + user_id: 'user_1', + access_profile: 'read', + read_only: true, + profile: 'default', + }); + expect(loadHostKey()).not.toBeNull(); + expect(loadAgentRecord()).toMatchObject({ + agent_id: 'agent_1', + host_id: 'host_1', + issuer: ISSUER, + org_id: '739224', + access_profile: 'read', + }); + const creds = JSON.parse(readFileSync(join(tmp.configDir, 'credentials.json'), 'utf-8')); + expect(creds).toMatchObject({ + token: 'tok_739224_1', + org_id: '739224', + user_id: 'user_1', + name: 'dev@epilot.cloud', + access_profile: 'read', + }); + expect(output.out.stdout).toContain('Login successful'); + expect(stripAnsi(output.out.stdout)).toContain('Access: read (Read everything you can see)'); + }); + + it('requests a specific organization with --org/--readonly/--anonymize and stores into the profile', async () => { + const { agentLogin } = await import('../src/commands/auth-login.js'); + await agentLogin({ + profileName: 'work', + env: 'dev', + org: '911210', + readonly: true, + anonymize: true, + interactive: false, + json: true, + }); + expect(state.registrations[0].body.capabilities[1]).toEqual({ + name: 'epilot.access_token.issue', + constraints: { organization_id: '911210', access_profile: 'read', read_only: true, anonymize: true }, + }); + expect(state.executions[1].body.arguments).toEqual({ + organization_id: '911210', + access_profile: 'read', + anonymize: true, + }); + expect(loadProfiles().profiles.work).toMatchObject({ token: 'tok_911210_1', org_id: '911210' }); + expect(existsSync(join(tmp.configDir, 'agent-auth', 'agents', 'work.json'))).toBe(true); + // JSON mode: progress on stderr only + expect(output.out.stdout).toBe(''); + expect(output.out.stderr).toContain('Verification code'); + }); + + it('registers a scoped profile with its reason and issues under that profile', async () => { + const { agentLogin } = await import('../src/commands/auth-login.js'); + const result = await agentLogin({ + env: 'production', + org: '739224', + profile: 'config:write', + reason: 'Fix the PV registration journey mapping', + readonly: false, + anonymize: false, + interactive: false, + json: false, + }); + const registration = state.registrations[0].body; + expect(registration.reason).toBe('Fix the PV registration journey mapping'); + expect(registration.capabilities[1]).toEqual({ + name: 'epilot.access_token.issue', + constraints: { organization_id: '739224', access_profile: 'config:write', read_only: false, anonymize: false }, + }); + expect(state.executions[1].body.arguments).toEqual({ organization_id: '739224', access_profile: 'config:write' }); + expect(result).toMatchObject({ + access_profile: 'config:write', + read_only: false, + anonymize: false, + reason: 'Fix the PV registration journey mapping', + }); + expect(result.grant_expires_at).toMatch(/^\d{4}-/); + expect(loadAgentRecord()).toMatchObject({ access_profile: 'config:write', read_only: false }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('Reason: Fix the PV registration journey mapping'); + expect(plain).toMatch(/Access: {7}config:write \(Change configuration\), expires in 23h/); + }); + + it('prompts for the organization when several are granted', async () => { + const { select } = await import('@inquirer/prompts'); + (select as ReturnType).mockResolvedValue('911210'); + const { agentLogin } = await import('../src/commands/auth-login.js'); + state.extraGrantsOnApproval = [ + { + id: 'grant_x', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '911210', read_only: true, anonymize: true }, + }, + ]; + const result = await agentLogin({ + env: 'production', + readonly: true, + anonymize: true, + interactive: true, + json: false, + }); + expect(select).toHaveBeenCalledTimes(1); + const choices = (select as ReturnType).mock.calls[0][0].choices as { value: string }[]; + expect(choices.map((c) => c.value)).toEqual(['739224', '911210']); + expect(result.organization_id).toBe('911210'); + expect(state.executions[1].body.arguments.organization_id).toBe('911210'); + }); + + it('fails without a prompt when several orgs are granted in non-interactive mode', async () => { + const { chooseOrganization } = await import('../src/commands/auth-login.js'); + const orgs = [ + { organization_id: '1', access: { granted: true, pending: false, read_only: true, anonymized: true } }, + { organization_id: '2', access: { granted: true, pending: false, read_only: true, anonymized: true } }, + ]; + await expect(chooseOrganization(orgs, { interactive: false })).rejects.toMatchObject({ + code: 'organization_required', + }); + await expect(chooseOrganization(orgs, { interactive: false, org: '2' })).resolves.toMatchObject({ + organization_id: '2', + }); + await expect(chooseOrganization(orgs, { interactive: false, org: '3' })).rejects.toMatchObject({ + code: 'organization_not_found', + }); + await expect( + chooseOrganization( + [{ organization_id: '1', access: { granted: false, pending: true, read_only: true, anonymized: true } }], + { interactive: false, org: '1' }, + ), + ).rejects.toMatchObject({ code: 'organization_not_granted' }); + }); + + it('command: non-interactive without --org errors, --json prints the result', async () => { + await expect(run({ agent: true, interactive: false })).rejects.toThrow('process.exit called'); + expect(output.out.stderr).toContain('--org'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(state.registrations).toHaveLength(0); + + output.out.stdout = ''; + await run({ agent: true, interactive: false, org: '739224', json: true }); + const printed = JSON.parse(output.out.stdout); + expect(printed).toMatchObject({ agent_id: 'agent_1', organization_id: '739224', access_profile: 'read' }); + expect(printed).not.toHaveProperty('token'); + }); + + it('command: a non-read profile without --reason fails non-interactively and prompts in a TTY', async () => { + await expect(run({ agent: true, interactive: false, org: '739224', access: 'data:write' })).rejects.toThrow( + 'process.exit called', + ); + expect(output.out.stderr).toContain('reason_required'); + expect(output.out.stderr).toContain('--reason'); + expect(state.registrations).toHaveLength(0); + + setTTY(true); + const { input } = await import('@inquirer/prompts'); + (input as ReturnType).mockResolvedValue('Import meter readings from the portal export'); + await run({ agent: true, org: '739224', access: 'data:write' }); + expect(input).toHaveBeenCalledTimes(1); + expect(state.registrations[0].body.reason).toBe('Import meter readings from the portal export'); + expect(state.registrations[0].body.capabilities[1].constraints.access_profile).toBe('data:write'); + }); + + it('command: --anonymize with a write profile is an error, unknown profiles too', async () => { + await expect( + run({ agent: true, interactive: false, org: '739224', access: 'full', anonymize: true, reason: 'Full sync run' }), + ).rejects.toThrow('process.exit called'); + expect(output.out.stderr).toContain('anonymize is only available with read profiles'); + expect(state.registrations).toHaveLength(0); + + await expect(run({ agent: true, interactive: false, org: '739224', access: 'admin' })).rejects.toThrow( + 'process.exit called', + ); + expect(output.out.stderr).toContain('Unknown access profile "admin"'); + }); + + it('command: --readonly downgrades a write profile to its read sibling', async () => { + await run({ + agent: true, + interactive: false, + org: '739224', + access: 'config:write', + readonly: true, + anonymize: true, + reason: 'Audit the journey configuration', + }); + expect(output.out.stderr).toContain('--readonly downgrades config:write to config:read'); + expect(state.registrations[0].body.capabilities[1].constraints).toEqual({ + organization_id: '739224', + access_profile: 'config:read', + read_only: true, + anonymize: true, + }); + }); + + it('command: reports a rejected agent as a failure', async () => { + // Make the status endpoint report rejection on the first poll + const { http, HttpResponse } = await import('msw'); + server.use( + http.get(`${ISSUER}/agent/status`, () => + HttpResponse.json({ + agent_id: 'agent_1', + host_id: 'host_1', + name: 'x', + status: 'rejected', + mode: 'delegated', + agent_capability_grants: [], + created_at: '2026-01-01T00:00:00Z', + }), + ), + ); + await expect(run({ agent: true, interactive: false, org: '739224' })).rejects.toThrow('process.exit called'); + expect(output.out.stderr).toContain('agent_rejected'); + server.resetHandlers(); + }); +}); diff --git a/packages/cli/test/auth-status.test.ts b/packages/cli/test/auth-status.test.ts new file mode 100644 index 000000000..ddd3986e2 --- /dev/null +++ b/packages/cli/test/auth-status.test.ts @@ -0,0 +1,176 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { generateKeyPair } from '@epilot/agent-auth'; +import { ensureHostKey, loadAgentRecord, saveAgentRecord } from '../src/lib/agent-auth.js'; +import { loadCredentials, saveCredentials } from '../src/lib/auth-store.js'; +import { loadProfiles } from '../src/lib/profiles.js'; +import { + ISSUER, + createState, + startFakeAap, + useTempConfigDir, + captureOutput, + stripAnsi, + type FakeState, +} from './helpers/fake-aap.js'; + +let state: FakeState; +let server: ReturnType; +let tmp: ReturnType; +let output: ReturnType; + +const HOUR = 3600_000; + +beforeAll(() => { + state = createState({ agentStatus: 'active' }); + server = startFakeAap(state); +}); +afterAll(() => server.close()); + +beforeEach(() => { + tmp = useTempConfigDir(); + output = captureOutput(); + process.env.EPILOT_AGENT_AUTH_ISSUER = ISSUER; + Object.assign(state, createState({ agentStatus: 'active' })); +}); +afterEach(() => { + output.restore(); + tmp.restore(); + delete process.env.EPILOT_AGENT_AUTH_ISSUER; +}); + +const authSub = async (name: 'status' | 'logout') => { + const cmd = (await import('../src/commands/auth.js')).default; + return (cmd.subCommands as any)[name] as { run: (ctx: any) => Promise }; +}; + +const seedAgent = () => { + ensureHostKey(); + saveAgentRecord({ + agent_id: 'agent_1', + host_id: 'host_1', + privateKey: generateKeyPair().privateKey, + issuer: ISSUER, + name: 'epilot CLI @ test', + created_at: '2026-01-01T00:00:00Z', + org_id: '739224', + access_profile: 'config:write', + }); +}; + +describe('epilot auth status', () => { + it('plain login: prints the token status without an agent block', async () => { + saveCredentials({ token: 'manual-token', org_id: '739224', user_id: 'user_1' }); + await (await authSub('status')).run({ args: {} }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('Authenticated'); + expect(plain).toContain('Org: 739224'); + expect(plain).not.toContain('Agent Auth'); + expect(plain).not.toContain('Profile:'); + expect(state.polls).toBe(0); + }); + + it('plain login: keeps the day-count expiry label and the "Not authenticated" hint', async () => { + await (await authSub('status')).run({ args: {} }); + expect(stripAnsi(output.out.stdout)).toBe('Not authenticated.\nRun epilot auth login to authenticate.\n'); + + output.out.stdout = ''; + const expiresAt = new Date(Date.now() + 3.5 * 24 * HOUR).toISOString(); + saveCredentials({ token: 'manual-token', expires_at: expiresAt }); + await (await authSub('status')).run({ args: {} }); + expect(stripAnsi(output.out.stdout)).toContain(`Expires: ${expiresAt} (3 days)`); + expect(state.polls).toBe(0); + }); + + it('agent login: prints the access profile, grants with expiry and reason', async () => { + seedAgent(); + saveCredentials({ + token: 'tok_739224_1', + org_id: '739224', + user_id: 'user_1', + access_profile: 'config:write', + expires_at: new Date(Date.now() + HOUR).toISOString(), + }); + state.grants = [ + { id: 'grant_1', capability: 'epilot.organizations.list', status: 'active' }, + { + id: 'grant_2', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '739224', read_only: true, anonymize: true }, + }, + { + id: 'grant_3', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '739224', access_profile: 'config:write' }, + expires_at: new Date(Date.now() + 23.5 * HOUR).toISOString(), + reason: 'Fix the PV registration journey mapping', + }, + { + id: 'grant_4', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '911210', access_profile: 'data:read' }, + expires_at: new Date(Date.now() - HOUR).toISOString(), + }, + { + id: 'grant_5', + capability: 'epilot.access_token.issue', + status: 'pending', + constraints: { organization_id: '911210', access_profile: 'full' }, + reason: 'Import meter readings from the portal export', + }, + ]; + await (await authSub('status')).run({ args: {} }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('Profile: config:write (Change configuration)'); + expect(plain).toContain('Agent Auth'); + expect(plain).toContain('Agent: agent_1'); + expect(plain).toContain('Status: active'); + expect(plain).toMatch(/739224\s+active read, anonymized/); + expect(plain).toMatch( + /739224\s+active config:write, expires in 23h \(active\) — "Fix the PV registration journey mapping"/, + ); + expect(plain).toMatch(/911210\s+expired data:read, anonymized, expired/); + expect(plain).toMatch(/911210\s+pending full — "Import meter readings from the portal export"/); + }); +}); + +describe('epilot auth logout', () => { + it('plain login: removes credentials without contacting Agent Auth', async () => { + saveCredentials({ token: 'manual-token' }, 'work'); + await (await authSub('logout')).run({ args: { profile: 'work' } }); + expect(output.out.stdout).toContain('Logged out successfully'); + expect(output.out.stdout).not.toContain('revoked'); + expect(state.revoked).toEqual([]); + expect(loadCredentials()).toBeNull(); + // Same as before Agent Auth: only credentials.json is removed, named profiles keep their token. + expect(loadProfiles().profiles.work.token).toBe('manual-token'); + expect(stripAnsi(output.out.stdout)).toBe('Logged out successfully.\n'); + }); + + it('agent login: revokes the agent, forgets it and clears the profile', async () => { + seedAgent(); + saveCredentials({ token: 'tok', org_id: '739224', access_profile: 'config:write' }); + await (await authSub('logout')).run({ args: {} }); + expect(state.revoked).toEqual(['agent_1']); + expect(loadAgentRecord()).toBeNull(); + expect(loadCredentials()).toBeNull(); + expect(output.out.stdout).toContain('Agent agent_1 revoked'); + expect(output.out.stdout).toContain('Logged out successfully'); + }); + + it('agent login on a named profile: clears the token the agent stored in that profile', async () => { + process.env.EPILOT_PROFILE = 'work'; + try { + seedAgent(); + saveCredentials({ token: 'tok', org_id: '739224', access_profile: 'config:write' }, 'work'); + await (await authSub('logout')).run({ args: {} }); + expect(state.revoked).toEqual(['agent_1']); + expect(loadProfiles().profiles.work.token).toBeUndefined(); + expect(loadProfiles().profiles.work.access_profile).toBeUndefined(); + } finally { + delete process.env.EPILOT_PROFILE; + } + }); +}); diff --git a/packages/cli/test/helpers/fake-aap.ts b/packages/cli/test/helpers/fake-aap.ts new file mode 100644 index 000000000..d5d21b4d5 --- /dev/null +++ b/packages/cli/test/helpers/fake-aap.ts @@ -0,0 +1,363 @@ +/** + * Fake Agent Auth Protocol server for CLI tests, implemented with msw. + * + * Point the CLI at it with `process.env.EPILOT_AGENT_AUTH_ISSUER = ISSUER`. + * Implements the access-profile rules of the shared spec: `access_profile` + * constraint (default read), reason required for non-read profiles, anonymize + * only with read profiles, escalation grants expire per profile TTL. + */ +import { ACCESS_PROFILE_INFO, type AccessProfile, isAccessProfile, mostPermissiveProfile } from '@epilot/agent-auth'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export const ISSUER = 'https://aap.test/v1/access-tokens/agent-auth'; + +export type FakeGrant = { + id: string; + capability: string; + status: 'active' | 'pending' | 'denied'; + constraints?: Record; + reason?: string; + expires_at?: string; +}; + +export type FakeOrg = { organization_id: string; organization_name: string; organization_type?: string }; + +export type FakeState = { + agentStatus: 'pending' | 'active' | 'revoked' | 'rejected'; + /** Status polls that still return the current state before the agent becomes active. */ + pollsUntilActive: number; + /** Status polls after which pending grants become active (per grant id). */ + approveGrantsAfterPolls: number; + polls: number; + grants: FakeGrant[]; + /** Extra grants the approval page adds when the agent becomes active (e.g. a second organization). */ + extraGrantsOnApproval: FakeGrant[]; + orgs: FakeOrg[]; + registrations: { body: any; authorization: string }[]; + capabilityRequests: { body: any; authorization: string }[]; + executions: { body: any; authorization: string }[]; + revoked: string[]; + tokenCounter: number; +}; + +export const createState = (overrides: Partial = {}): FakeState => ({ + agentStatus: 'pending', + pollsUntilActive: 1, + approveGrantsAfterPolls: 1, + polls: 0, + grants: [], + extraGrantsOnApproval: [], + orgs: [ + { organization_id: '739224', organization_name: 'ACME Energy', organization_type: 'Vendor' }, + { organization_id: '911210', organization_name: 'Beta Grid', organization_type: 'Partner' }, + ], + registrations: [], + capabilityRequests: [], + executions: [], + revoked: [], + tokenCounter: 0, + ...overrides, +}); + +const approval = (userCode = 'WDJB-MJHT') => ({ + method: 'device_authorization', + verification_uri: 'https://portal.epilot.cloud/login?agent_approval', + verification_uri_complete: `https://portal.epilot.cloud/login?agent_approval=${userCode}&agent_issuer=${encodeURIComponent(ISSUER)}`, + user_code: userCode, + expires_in: 60, + interval: 1, +}); + +const grantProfile = (g: FakeGrant): AccessProfile => + isAccessProfile(g.constraints?.access_profile) ? g.constraints.access_profile : 'read'; + +const grantAnonymized = (g: FakeGrant): boolean => + ACCESS_PROFILE_INFO[grantProfile(g)].anonymizeAllowed && g.constraints?.anonymize !== false; + +const usable = (g: FakeGrant) => + g.status === 'active' && (!g.expires_at || new Date(g.expires_at).getTime() > Date.now()); + +const orgGrants = (state: FakeState, orgId: string) => + state.grants.filter((g) => g.capability === 'epilot.access_token.issue' && g.constraints?.organization_id === orgId); + +const orgAccess = (state: FakeState, orgId: string) => { + const grants = orgGrants(state, orgId); + const active = grants.filter(usable); + const pending = grants.some((g) => g.status === 'pending'); + const profile = mostPermissiveProfile(active.map(grantProfile)); + const best = active.find((g) => grantProfile(g) === profile); + return { + granted: active.length > 0, + pending, + read_only: profile ? ACCESS_PROFILE_INFO[profile].readOnly : true, + // anonymized only when every active grant for the org is anonymized (a write grant makes it false) + anonymized: active.length > 0 && active.every(grantAnonymized), + ...(profile ? { access_profile: profile } : {}), + ...(best?.expires_at ? { expires_at: best.expires_at } : {}), + }; +}; + +/** Escalation grants expire per profile TTL when they become active. */ +const activate = (g: FakeGrant): FakeGrant => { + const ttl = ACCESS_PROFILE_INFO[grantProfile(g)].escalationTtlSeconds; + return { + ...g, + status: 'active', + ...(ttl ? { expires_at: new Date(Date.now() + ttl * 1000).toISOString() } : {}), + }; +}; + +/** Spec §1/§3 request validation; returns an error response or undefined. */ +const validateCapabilities = (capabilities: any[], reason: unknown) => { + for (const cap of capabilities) { + if (typeof cap === 'string' || cap.name !== 'epilot.access_token.issue') continue; + const profile = cap.constraints?.access_profile; + if (profile !== undefined && !isAccessProfile(profile)) { + return HttpResponse.json( + { error: 'invalid_capabilities', message: `Unknown profile ${profile}` }, + { status: 400 }, + ); + } + const effective: AccessProfile = isAccessProfile(profile) ? profile : 'read'; + if (cap.constraints?.anonymize === true && !ACCESS_PROFILE_INFO[effective].anonymizeAllowed) { + return HttpResponse.json( + { error: 'invalid_capabilities', message: 'anonymize is only available with read profiles' }, + { status: 400 }, + ); + } + if (effective !== 'read' && (typeof reason !== 'string' || reason.trim().length < 10)) { + return HttpResponse.json({ error: 'reason_required', message: 'A reason is required' }, { status: 400 }); + } + } + return undefined; +}; + +const agentStatusBody = (state: FakeState) => ({ + agent_id: 'agent_1', + host_id: 'host_1', + name: 'epilot CLI @ test', + status: state.agentStatus, + mode: 'delegated', + agent_capability_grants: state.grants, + user_id: 'user_1', + created_at: '2026-01-01T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', +}); + +export const handlers = (state: FakeState) => [ + http.get(`${ISSUER}/.well-known/agent-configuration`, () => + HttpResponse.json({ + version: '1.0-draft', + provider_name: 'epilot', + issuer: ISSUER, + default_location: `${ISSUER}/capability/execute`, + algorithms: ['EdDSA'], + modes: ['delegated'], + approval_methods: ['device_authorization'], + endpoints: { + register: '/agent/register', + capabilities: '/capability/list', + describe_capability: '/capability/describe', + execute: '/capability/execute', + request_capability: '/agent/request-capability', + status: '/agent/status', + reactivate: '/agent/reactivate', + revoke: '/agent/revoke', + revoke_host: '/host/revoke', + rotate_key: '/agent/rotate-key', + rotate_host_key: '/host/rotate-key', + introspect: '/agent/introspect', + }, + }), + ), + + http.post(`${ISSUER}/agent/register`, async ({ request }) => { + const body = (await request.json()) as any; + state.registrations.push({ body, authorization: request.headers.get('authorization') ?? '' }); + const invalid = validateCapabilities(body.capabilities, body.reason); + if (invalid) return invalid; + // Requested capabilities become pending grants (organizations.list is a host default → active). + state.grants = body.capabilities.map((cap: any, index: number) => { + const name = typeof cap === 'string' ? cap : cap.name; + return { + id: `grant_${index + 1}`, + capability: name, + status: name === 'epilot.organizations.list' ? 'active' : 'pending', + constraints: typeof cap === 'string' ? undefined : cap.constraints, + ...(typeof cap !== 'string' && body.reason ? { reason: body.reason } : {}), + }; + }); + return HttpResponse.json( + { + agent_id: 'agent_1', + host_id: 'host_1', + name: body.name, + mode: 'delegated', + status: state.agentStatus, + agent_capability_grants: state.grants, + ...(state.agentStatus === 'pending' ? { approval: approval() } : {}), + }, + { status: 201 }, + ); + }), + + http.get(`${ISSUER}/agent/status`, ({ request }) => { + if (!request.headers.get('authorization')?.startsWith('Bearer ')) { + return HttpResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + state.polls++; + if (state.agentStatus === 'pending' && state.polls > state.pollsUntilActive) { + state.agentStatus = 'active'; + // The approval page grants the login organization when none was requested. + state.grants = state.grants.map((g) => + g.status === 'pending' + ? activate({ + ...g, + constraints: { organization_id: state.orgs[0].organization_id, ...(g.constraints ?? {}) }, + }) + : g, + ); + state.grants.push(...state.extraGrantsOnApproval); + } else if (state.agentStatus === 'active' && state.polls > state.approveGrantsAfterPolls) { + state.grants = state.grants.map((g) => (g.status === 'pending' ? activate(g) : g)); + } + return HttpResponse.json(agentStatusBody(state)); + }), + + http.post(`${ISSUER}/agent/request-capability`, async ({ request }) => { + const body = (await request.json()) as any; + state.capabilityRequests.push({ body, authorization: request.headers.get('authorization') ?? '' }); + const invalid = validateCapabilities(body.capabilities, body.reason); + if (invalid) return invalid; + state.polls = 0; + const newGrants: FakeGrant[] = body.capabilities.map((cap: any, index: number) => ({ + id: `grant_${state.grants.length + index + 1}`, + capability: cap.name, + status: 'pending' as const, + constraints: cap.constraints, + ...(body.reason ? { reason: body.reason } : {}), + })); + state.grants.push(...newGrants); + return HttpResponse.json({ + agent_id: 'agent_1', + agent_capability_grants: newGrants, + approval: approval('REQQ-1234'), + }); + }), + + http.post(`${ISSUER}/capability/execute`, async ({ request }) => { + const body = (await request.json()) as any; + state.executions.push({ body, authorization: request.headers.get('authorization') ?? '' }); + if (state.agentStatus !== 'active') { + return HttpResponse.json( + { error: `agent_${state.agentStatus}`, message: 'Agent is not active' }, + { status: 403 }, + ); + } + if (body.capability === 'epilot.organizations.list') { + return HttpResponse.json({ + data: { organizations: state.orgs.map((o) => ({ ...o, access: orgAccess(state, o.organization_id) })) }, + }); + } + if (body.capability === 'epilot.access_token.issue') { + const orgId = body.arguments?.organization_id; + const access = orgAccess(state, orgId); + if (!access.granted) { + return HttpResponse.json({ error: 'grant_missing', message: `No grant for ${orgId}` }, { status: 403 }); + } + const requested = body.arguments?.access_profile; + const active = orgGrants(state, orgId).filter(usable); + const grant = requested + ? active.find((g) => grantProfile(g) === requested) + : active.find((g) => grantProfile(g) === access.access_profile); + if (!grant) { + return HttpResponse.json( + { error: 'constraint_violated', message: `No grant covers profile ${requested}` }, + { status: 403 }, + ); + } + const profile = grantProfile(grant); + const info = ACCESS_PROFILE_INFO[profile]; + if (body.arguments?.read_only === false && info.readOnly) { + return HttpResponse.json({ error: 'constraint_violated', message: 'read_only must be true' }, { status: 403 }); + } + if (body.arguments?.anonymize === false && grantAnonymized(grant)) { + return HttpResponse.json({ error: 'constraint_violated', message: 'anonymize must be true' }, { status: 403 }); + } + state.tokenCounter++; + return HttpResponse.json({ + data: { + token: `tok_${orgId}_${state.tokenCounter}`, + token_id: `tokid_${state.tokenCounter}`, + organization_id: orgId, + user_id: 'user_1', + email: 'dev@epilot.cloud', + access_profile: profile, + read_only: info.readOnly, + anonymize: info.anonymizeAllowed ? (body.arguments?.anonymize ?? grantAnonymized(grant)) : false, + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }, + }); + } + return HttpResponse.json({ error: 'unknown_capability' }, { status: 404 }); + }), + + http.post(`${ISSUER}/agent/revoke`, async ({ request }) => { + const body = (await request.json()) as any; + state.revoked.push(body.agent_id); + state.agentStatus = 'revoked'; + return HttpResponse.json({ agent_id: body.agent_id, status: 'revoked' }); + }), +]; + +export const startFakeAap = (state: FakeState) => { + const server = setupServer(...handlers(state)); + server.listen({ onUnhandledRequest: 'error' }); + return server; +}; + +/** Isolated XDG config dir so tests never touch ~/.config/epilot. */ +export const useTempConfigDir = () => { + const dir = mkdtempSync(join(tmpdir(), 'epilot-cli-test-')); + const previous = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, EPILOT_PROFILE: process.env.EPILOT_PROFILE }; + process.env.XDG_CONFIG_HOME = dir; + delete process.env.EPILOT_PROFILE; + return { + dir, + configDir: join(dir, 'epilot'), + restore: () => { + rmSync(dir, { recursive: true, force: true }); + if (previous.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previous.XDG_CONFIG_HOME; + if (previous.EPILOT_PROFILE !== undefined) process.env.EPILOT_PROFILE = previous.EPILOT_PROFILE; + }, + }; +}; + +/** Capture stdout/stderr writes. */ +export const captureOutput = () => { + const out = { stdout: '', stderr: '' }; + const stdoutWrite = process.stdout.write.bind(process.stdout); + const stderrWrite = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: any) => { + out.stdout += String(chunk); + return true; + }) as any; + process.stderr.write = ((chunk: any) => { + out.stderr += String(chunk); + return true; + }) as any; + return { + out, + restore: () => { + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + }, + }; +}; + +export const stripAnsi = (s: string) => s.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), ''); diff --git a/packages/cli/test/org.test.ts b/packages/cli/test/org.test.ts new file mode 100644 index 000000000..465f203db --- /dev/null +++ b/packages/cli/test/org.test.ts @@ -0,0 +1,434 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateKeyPair } from '@epilot/agent-auth'; +import { ensureHostKey, loadAgentRecord, saveAgentRecord } from '../src/lib/agent-auth.js'; +import { upsertProfile } from '../src/lib/profiles.js'; +import { saveCredentials } from '../src/lib/auth-store.js'; +import { + ISSUER, + createState, + startFakeAap, + useTempConfigDir, + captureOutput, + stripAnsi, + type FakeState, +} from './helpers/fake-aap.js'; + +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@inquirer/prompts', () => ({ input: vi.fn(), select: vi.fn() })); + +let state: FakeState; +let server: ReturnType; +let tmp: ReturnType; +let output: ReturnType; +let exitSpy: ReturnType; + +const originalStdoutTTY = process.stdout.isTTY; +const originalStdinTTY = process.stdin.isTTY; +const HOUR = 3600_000; + +beforeAll(() => { + state = createState({ agentStatus: 'active' }); + server = startFakeAap(state); +}); +afterAll(() => server.close()); + +beforeEach(() => { + tmp = useTempConfigDir(); + output = captureOutput(); + process.env.EPILOT_AGENT_AUTH_ISSUER = ISSUER; + Object.assign(state, createState({ agentStatus: 'active' })); + state.grants = [ + { id: 'grant_1', capability: 'epilot.organizations.list', status: 'active' }, + { + id: 'grant_2', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '739224', read_only: true, anonymize: true }, + }, + ]; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true, configurable: true }); + vi.clearAllMocks(); +}); +afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutTTY, writable: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinTTY, writable: true, configurable: true }); + exitSpy.mockRestore(); + output.restore(); + tmp.restore(); + delete process.env.EPILOT_AGENT_AUTH_ISSUER; +}); + +const seedAgent = () => { + ensureHostKey(); + saveAgentRecord({ + agent_id: 'agent_1', + host_id: 'host_1', + privateKey: generateKeyPair().privateKey, + issuer: ISSUER, + name: 'epilot CLI @ test', + created_at: '2026-01-01T00:00:00Z', + org_id: '739224', + access_profile: 'read', + anonymize: true, + }); + saveCredentials({ + token: 'tok_739224_0', + org_id: '739224', + access_profile: 'read', + expires_at: new Date(Date.now() + HOUR).toISOString(), + }); +}; + +const org = async () => (await import('../src/commands/org.js')).default; +const sub = async (name: 'list' | 'use' | 'request' | 'current') => { + const cmd = await org(); + return (cmd.subCommands as any)[name] as { run: (ctx: any) => Promise }; +}; + +describe('epilot org', () => { + it('list: prints profile, expiry and pending state per organization and marks the active one', async () => { + seedAgent(); + state.grants.push({ + id: 'grant_3', + capability: 'epilot.access_token.issue', + status: 'pending', + constraints: { organization_id: '911210', access_profile: 'data:write' }, + }); + await (await sub('list')).run({ args: {} }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toMatch(/ID\s+NAME\s+TYPE\s+ACCESS/); + expect(plain).toMatch(/\* 739224\s+ACME Energy\s+Vendor\s+granted \(read, anonymized\)/); + expect(plain).toMatch(/ {2}911210\s+Beta Grid\s+Partner\s+pending/); + expect(plain).toContain('* active organization'); + + // An escalation grant shows its profile and expiry; "anonymized" only when every grant is anonymized. + state.grants.push({ + id: 'grant_4', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '739224', access_profile: 'config:write' }, + expires_at: new Date(Date.now() + 23.5 * HOUR).toISOString(), + reason: 'Fix the PV registration journey mapping', + }); + output.out.stdout = ''; + await (await sub('list')).run({ args: {} }); + expect(stripAnsi(output.out.stdout)).toMatch( + /739224\s+ACME Energy\s+Vendor\s+granted \(config:write, expires in 23h\)/, + ); + }); + + it('list --json: emits organizations with an active flag and access profile', async () => { + seedAgent(); + await (await sub('list')).run({ args: { json: true } }); + const parsed = JSON.parse(output.out.stdout); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatchObject({ + organization_id: '739224', + active: true, + access: { granted: true, access_profile: 'read', anonymized: true }, + }); + expect(parsed[1]).toMatchObject({ organization_id: '911210', active: false, access: { granted: false } }); + }); + + it('requires an agent identity: hint on stderr, {error: "agent_required"} with --json', async () => { + for (const name of ['list', 'use', 'request', 'current'] as const) { + output.out.stdout = ''; + output.out.stderr = ''; + await expect((await sub(name)).run({ args: { id: '911210' } })).rejects.toThrow('process.exit called'); + expect(stripAnsi(output.out.stderr)).toContain( + 'This profile has no agent identity. Run epilot auth login --agent first.', + ); + expect(output.out.stdout).toBe(''); + expect(exitSpy).toHaveBeenLastCalledWith(1); + } + // A plain-login profile (token only) is not enough either. + upsertProfile('default', { token: 'manual', org_id: '555' }); + output.out.stdout = ''; + await expect((await sub('current')).run({ args: { json: true } })).rejects.toThrow('process.exit called'); + expect(JSON.parse(output.out.stdout)).toMatchObject({ error: 'agent_required' }); + expect(state.executions).toHaveLength(0); + }); + + it('use : issues a token under the most permissive grant and stores profile + token', async () => { + seedAgent(); + state.grants.push( + { + id: 'grant_3', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '911210', access_profile: 'data:read', anonymize: true }, + expires_at: new Date(Date.now() + 6 * 24 * HOUR).toISOString(), + }, + { + id: 'grant_4', + capability: 'epilot.access_token.issue', + status: 'active', + constraints: { organization_id: '911210', access_profile: 'config:write' }, + expires_at: new Date(Date.now() + 20 * HOUR).toISOString(), + }, + ); + await (await sub('use')).run({ args: { id: '911210' } }); + expect(state.executions.map((e) => e.body.capability)).toEqual([ + 'epilot.organizations.list', + 'epilot.access_token.issue', + ]); + expect(state.executions[1].body.arguments).toEqual({ organization_id: '911210', access_profile: 'config:write' }); + const creds = JSON.parse(readFileSync(join(tmp.configDir, 'credentials.json'), 'utf-8')); + expect(creds).toMatchObject({ token: 'tok_911210_1', org_id: '911210', access_profile: 'config:write' }); + expect(loadAgentRecord()).toMatchObject({ org_id: '911210', access_profile: 'config:write', read_only: false }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('Switched to organization'); + expect(plain).toContain('Beta Grid'); + expect(plain).toMatch(/Access: config:write \(Change configuration\), expires in (19|20)h/); + + // --access picks a specific granted profile; an uncovered one is refused by the server. + output.out.stdout = ''; + await (await sub('use')).run({ args: { id: '911210', access: 'data:read', json: true } }); + expect(state.executions.at(-1)?.body.arguments).toEqual({ organization_id: '911210', access_profile: 'data:read' }); + expect(JSON.parse(output.out.stdout)).toMatchObject({ + status: 'active', + access_profile: 'data:read', + read_only: true, + anonymize: true, + }); + await expect((await sub('use')).run({ args: { id: '911210', access: 'full' } })).rejects.toThrow( + 'process.exit called', + ); + expect(output.out.stderr).toContain('constraint_violated'); + }); + + it('use : runs the request flow for an organization without a grant (interactive)', async () => { + seedAgent(); + const open = (await import('open')).default as ReturnType; + await (await sub('use')).run({ args: { id: '911210' } }); + expect(state.capabilityRequests).toHaveLength(1); + expect(state.capabilityRequests[0].body).toEqual({ + capabilities: [ + { + name: 'epilot.access_token.issue', + constraints: { organization_id: '911210', access_profile: 'read', anonymize: true }, + }, + ], + reason: 'epilot CLI: access to organization 911210', + }); + expect(open).toHaveBeenCalledWith(expect.stringContaining('agent_approval=REQQ-1234')); + expect(output.out.stdout).toContain('Verification code: \x1b[1mREQQ-1234'); + // waited for the grant, then issued under the granted profile + expect(state.executions.at(-1)?.body).toEqual({ + capability: 'epilot.access_token.issue', + arguments: { organization_id: '911210', access_profile: 'read', anonymize: true }, + }); + expect(JSON.parse(readFileSync(join(tmp.configDir, 'credentials.json'), 'utf-8')).org_id).toBe('911210'); + }); + + it('use : fails for an unknown organization', async () => { + seedAgent(); + await expect((await sub('use')).run({ args: { id: '000' } })).rejects.toThrow('process.exit called'); + expect(output.out.stderr).toContain('organization_not_found'); + }); + + it('request --access config:write --reason: posts profile + reason, waits and switches', async () => { + seedAgent(); + await (await sub('request')).run({ + args: { id: '911210', access: 'config:write', reason: 'Fix the PV registration journey mapping' }, + }); + expect(state.capabilityRequests[0].body).toEqual({ + capabilities: [ + { + name: 'epilot.access_token.issue', + constraints: { organization_id: '911210', access_profile: 'config:write', anonymize: false }, + }, + ], + reason: 'Fix the PV registration journey mapping', + }); + expect(state.polls).toBeGreaterThanOrEqual(2); + expect(state.executions.at(-1)?.body.arguments).toEqual({ + organization_id: '911210', + access_profile: 'config:write', + }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('Access: config:write (Change configuration)'); + expect(plain).toContain('Reason: Fix the PV registration journey mapping'); + expect(plain).toContain('Switched to organization'); + expect(loadAgentRecord()).toMatchObject({ org_id: '911210', access_profile: 'config:write' }); + }); + + it('request --write --full-pii: deprecated alias for --access full, PII implied', async () => { + seedAgent(); + await (await sub('request')).run({ + args: { id: '911210', write: true, 'full-pii': true, reason: 'Import meter readings' }, + }); + expect(stripAnsi(output.out.stderr)).toContain('--write is deprecated; use --access full'); + expect(stripAnsi(output.out.stdout)).toContain('--full-pii is implied by full'); + expect(state.capabilityRequests[0].body.capabilities[0].constraints).toEqual({ + organization_id: '911210', + access_profile: 'full', + anonymize: false, + }); + expect(state.executions.at(-1)?.body.arguments).toEqual({ organization_id: '911210', access_profile: 'full' }); + }); + + it('request --full-pii: read profile without anonymization', async () => { + seedAgent(); + await (await sub('request')).run({ args: { id: '911210', 'full-pii': true } }); + expect(state.capabilityRequests[0].body.capabilities[0].constraints).toEqual({ + organization_id: '911210', + access_profile: 'read', + anonymize: false, + }); + expect(stripAnsi(output.out.stdout)).toContain('full personal data'); + }); + + it('request --access data:write without --reason: errors non-interactively, prompts in a TTY', async () => { + seedAgent(); + await expect( + (await sub('request')).run({ args: { id: '911210', access: 'data:write', interactive: false } }), + ).rejects.toThrow('process.exit called'); + expect(output.out.stderr).toContain('reason_required'); + expect(output.out.stderr).toContain('--reason'); + expect(state.capabilityRequests).toHaveLength(0); + + output.out.stderr = ''; + await expect( + (await sub('request')).run({ args: { id: '911210', access: 'data:write', reason: 'short', json: true } }), + ).rejects.toThrow('process.exit called'); + expect(JSON.parse(output.out.stdout)).toMatchObject({ error: 'reason_required' }); + expect(state.capabilityRequests).toHaveLength(0); + + const { input } = await import('@inquirer/prompts'); + (input as ReturnType).mockResolvedValue('Import meter readings from the portal export'); + await (await sub('request')).run({ args: { id: '911210', access: 'data:write' } }); + expect(input).toHaveBeenCalledTimes(1); + expect(state.capabilityRequests[0].body.reason).toBe('Import meter readings from the portal export'); + }); + + it('request : rejects unknown profiles before contacting the server', async () => { + seedAgent(); + await expect((await sub('request')).run({ args: { id: '911210', access: 'root' } })).rejects.toThrow( + 'process.exit called', + ); + expect(output.out.stderr).toContain('Unknown access profile "root"'); + expect(state.capabilityRequests).toHaveLength(0); + }); + + it('request --no-interactive --json: prints the pending approval with the requested constraint and exits 0', async () => { + seedAgent(); + const open = (await import('open')).default as ReturnType; + await (await sub('request')).run({ + args: { + id: '911210', + access: 'config:read', + reason: 'Audit the journey configuration', + interactive: false, + json: true, + }, + }); + expect(open).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + const printed = JSON.parse(output.out.stdout); + expect(printed.status).toBe('pending'); + expect(printed.organization_id).toBe('911210'); + expect(printed.requested).toEqual({ + organization_id: '911210', + access_profile: 'config:read', + anonymize: true, + reason: 'Audit the journey configuration', + }); + expect(printed.approval).toMatchObject({ method: 'device_authorization', user_code: 'REQQ-1234' }); + expect(printed.grants[0]).toMatchObject({ status: 'pending', reason: 'Audit the journey configuration' }); + expect(printed.next).toBe('epilot org use 911210'); + expect(state.executions).toHaveLength(0); + + // After the user approved in the browser, `org use` completes the switch with the granted profile. + state.grants = state.grants.map((g) => ({ ...g, status: 'active' as const })); + output.out.stdout = ''; + await (await sub('use')).run({ args: { id: '911210', json: true } }); + expect(JSON.parse(output.out.stdout)).toMatchObject({ + status: 'active', + organization_id: '911210', + access_profile: 'config:read', + anonymize: true, + }); + }); + + it('request : reports a denied grant', async () => { + seedAgent(); + const { http, HttpResponse } = await import('msw'); + server.use( + http.get(`${ISSUER}/agent/status`, () => + HttpResponse.json({ + agent_id: 'agent_1', + host_id: 'host_1', + name: 'x', + status: 'active', + mode: 'delegated', + agent_capability_grants: state.grants.map((g) => (g.status === 'pending' ? { ...g, status: 'denied' } : g)), + created_at: '2026-01-01T00:00:00Z', + }), + ), + ); + await expect((await sub('request')).run({ args: { id: '911210' } })).rejects.toThrow('process.exit called'); + expect(output.out.stderr).toContain('denied'); + server.resetHandlers(); + }); + + it('request : issues under the downgraded profile when the approver narrowed the request', async () => { + seedAgent(); + const { http, HttpResponse } = await import('msw'); + // The approval page narrowed config:write to config:read. + const narrow = () => { + state.grants = state.grants.map((g) => + g.status === 'pending' + ? { ...g, status: 'active' as const, constraints: { ...g.constraints, access_profile: 'config:read' } } + : g, + ); + return state.grants; + }; + server.use( + http.get(`${ISSUER}/agent/status`, () => + HttpResponse.json({ + agent_id: 'agent_1', + host_id: 'host_1', + name: 'x', + status: 'active', + mode: 'delegated', + agent_capability_grants: narrow(), + created_at: '2026-01-01T00:00:00Z', + }), + ), + ); + await (await sub('request')).run({ + args: { id: '911210', access: 'config:write', reason: 'Fix the PV registration journey mapping' }, + }); + expect(stripAnsi(output.out.stdout)).toContain('granted as config:read instead of the requested config:write'); + expect(state.executions.at(-1)?.body.arguments).toEqual({ + organization_id: '911210', + access_profile: 'config:read', + }); + server.resetHandlers(); + }); + + it('current: shows the active organization with its profile', async () => { + seedAgent(); + await (await sub('current')).run({ args: {} }); + const plain = stripAnsi(output.out.stdout); + expect(plain).toContain('Active organization: ACME Energy (739224)'); + expect(plain).toContain('Access: read (Read everything you can see), anonymized'); + + output.out.stdout = ''; + await (await sub('current')).run({ args: { json: true } }); + expect(JSON.parse(output.out.stdout)).toMatchObject({ + organization_id: '739224', + organization_name: 'ACME Energy', + access_profile: 'read', + anonymize: true, + agent_id: 'agent_1', + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5d0cd340..b76098d17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2691,6 +2691,18 @@ importers: specifier: ^4.10.0 version: 4.10.0(webpack@5.105.4) + packages/agent-auth: + devDependencies: + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.7.3) + typescript: + specifier: ^5.3.0 + version: 5.7.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@24.2.0)(jsdom@26.1.0)(terser@5.46.0) + packages/app-bridge: dependencies: common-tags: @@ -2780,6 +2792,9 @@ importers: specifier: ^7.8.0 version: 7.9.0(axios@1.13.6)(js-yaml@4.1.1) devDependencies: + '@epilot/agent-auth': + specifier: workspace:^ + version: link:../agent-auth msw: specifier: ^2.12.10 version: 2.12.10(@types/node@24.2.0)(typescript@5.7.3) @@ -12642,7 +12657,7 @@ snapshots: sucrase@3.35.1: dependencies: - '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 lines-and-columns: 1.2.4 mz: 2.7.0