diff --git a/CLAUDE.md b/CLAUDE.md index 018c5255..726fb15e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Commands in `packages/cli/src/cli.tsx` (incur framework). Each has two output mo - **Interactive** (default): Ink/React components from `packages/cli/src/commands/` - **JSON** (`--format json`): JSON to stdout, errors as JSON with `code` and `message` fields with exit code 1 -Commands: `auth login|logout|status`, `user-info retrieve`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request`, `identity credentials request`, `report`, `serve`. +Commands: `auth login|logout|status`, `user-info retrieve`, `spend-request create|update|retrieve|request-approval|cancel`, `payment-methods list`, `shipping-address list`, `mpp pay|decode`, `identity attestations request|list|take`, `identity credentials request|list`, `report`, `serve`. The CLI also runs as an MCP server (`--mcp`) and serves skill files via `skills` subcommand, both provided by incur. @@ -135,10 +135,11 @@ Unlisted: omitted from `--help`, `--llms`, and MCP tool lists unless `LINK_IDENT - `attestations-crypto.ts` implements the RFC 9578 type `0x0002` client flow: PSS-encode, blind, unblind, verify, then assemble the token. Issuer keys must be 2048-bit RSA-PSS with SHA-384, MGF1-SHA-384, and a 48-byte salt. - Blind signatures are verified after unblinding before final tokens are returned. - Output is a versioned artifact: issuer, `token_key_id`, and each complete base64url token plus `authorization: PrivateToken token=""`. Token bytes are preserved exactly. -- Token artifacts are written with mode 0600 to uniquely named files in `~/.link-cli/attestations`; the directory uses mode 0700. Command output contains the artifact path and non-secret metadata, not raw tokens. +- Default requests append batches to `~/.link-cli/attestations/pool.json` (version 2, mode 0600; directory mode 0700). `request --count --output-file ` exports a version-1 batch outside that directory without adding it to the pool. Exports use exclusive creation; existing files are not overwritten. Request output contains the path and metadata. +- Unlisted `identity attestations take` removes one pooled token and returns its bytes, generated `authorization` header, issuer, and key ID in both terminal and structured output. It uses no API resource and returns `ATTESTATION_POOL_EMPTY` when empty. `storage.ts` serializes append/take with an exclusive directory lock, fsyncs a private temporary file, atomically renames it, and fsyncs the directory on POSIX before returning. Windows uses file fsync and atomic rename because Node cannot fsync a directory there. Locks are never stolen based on age; after a crash, remove `pool.json.lock` only after ensuring no attestation commands are running. A crash after commit may lose a token; never reinsert it on output failure. - Server-side max batch is 100. Issuance does not require an additional OAuth scope. - Auth: standard CLI authentication (`LINK_ACCESS_TOKEN` or stored credentials). -- Unlisted local inspection: `identity attestations list` reports saved batch paths, issuer/key identifiers, per-file `stored_token_count` and aggregate `total_token_count`, and per-file `errors`. It reads JSON batches in `~/.link-cli/attestations`. Counts describe stored tokens; external usage is untracked. The command works without auth or API calls, prints metadata in terminals and structured output (`outputPolicy: 'all'`), and preserves the feature gate and MCP exclusion. Read schemas live beside inspection logic in `inspect.ts`; shared file reading lives in `identity/artifact-reader.ts`. +- Unlisted local inspection: `identity attestations list` reports saved batch paths, issuer/key identifiers, per-file `stored_token_count` and aggregate `total_token_count`, and per-file `errors`. It reads JSON batches in `~/.link-cli/attestations`, expanding the pool into batches marked `storage: pool`; legacy exports are marked `storage: export` and never imported automatically. Counts describe stored tokens; external usage is untracked. The command works without auth or API calls, prints metadata in terminals and structured output (`outputPolicy: 'all'`), and preserves the feature gate and MCP exclusion. The version-1 export schema lives in `export.ts`, the version-2 pool schema in `storage.ts`, and shared file reading in `identity/artifact-reader.ts`. ### report command diff --git a/README.md b/README.md index 97340bee..ec2a42f2 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,23 @@ Unlisted commands: set `LINK_IDENTITY_COMMANDS=1` to enable them in `--help` and LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 ``` -Attestation tokens can be used to respond to attestation challenges presented by downstream services. Token artifacts are written to `~/.link-cli/attestations`. +Each request adds tokens to the CLI-managed pool at `~/.link-cli/attestations/pool.json`. Take one token when you need to answer an attestation challenge: + +```bash +LINK_IDENTITY_COMMANDS=1 link-cli identity attestations take --format json +``` + +`take` removes one token before returning its `token`, ready-to-use `authorization` header, issuer, and issuer-key ID. Pass `authorization` as the `Authorization` header in your browser automation or HTTP client. An empty pool returns `ATTESTATION_POOL_EMPTY`; refill it with `request --count 10`. + +For agent-managed tokens, export a batch to a new file outside the CLI storage directory: + +```bash +LINK_IDENTITY_COMMANDS=1 link-cli identity attestations request --count 10 --output-file ./aats.json +``` + +Exported tokens never enter the CLI pool. The agent owns their consumption and cleanup. Existing exports remain separate and are never automatically imported. Wallet credentials keep their existing storage and behavior. + +Pool updates are serialized and saved atomically. A crash after removal can lose a token; `take` never returns it to the pool. If a crash leaves `pool.json.lock`, ensure no attestation commands are running before removing that lock directory. **User info that has been signed, proving it comes from Link**: @@ -275,7 +291,7 @@ LINK_IDENTITY_COMMANDS=1 link-cli identity attestations list --format json These commands inspect local files without login or Link API calls and display metadata in both terminal and structured output. Credential inspection reports the saved `~/.link-cli/credentials/current.json` path, issuer, cached expiry/`expired` status, holder-key path/thumbprint, and claim names. Private keys are never opened; credentials, tokens, and claim values are never printed. Inspection does not modify files, verify signatures, or filter artifacts by the active account. -Attestation inspection reports paths, issuer/key identifiers, per-batch `stored_token_count`, and aggregate `total_token_count` for JSON batches in `~/.link-cli/attestations`. Counts describe stored tokens; external usage is untracked and AATs have no embedded expiry. Empty stores return empty lists. Lists include per-file `errors` alongside valid entries. +Attestation inspection reports paths, issuer/key identifiers, per-batch `stored_token_count`, aggregate `total_token_count`, and `storage` (`pool` or `export`) for JSON batches in `~/.link-cli/attestations`. Counts describe stored tokens; external usage is untracked and AATs have no embedded expiry. Empty stores return empty lists. Lists include per-file `errors` alongside valid entries. ### Spend request lifecycle diff --git a/packages/cli/src/commands/attestations/__tests__/commands.test.ts b/packages/cli/src/commands/attestations/__tests__/commands.test.ts new file mode 100644 index 00000000..422f1f83 --- /dev/null +++ b/packages/cli/src/commands/attestations/__tests__/commands.test.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createAttestationsCli } from '..'; +import { getPoolPath, readAttestationPool } from '../storage'; + +let directory: string; +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'link-pool-command-')); + vi.spyOn(os, 'homedir').mockReturnValue(directory); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(directory, { recursive: true, force: true }); +}); + +it('requests into the pool, takes without an API resource, and exports independently', async () => { + const request = vi.fn().mockResolvedValue({ + issuer: 'https://api.link.com', + token_key_id: 'key-id', + count: 2, + tokens: ['secret-one', 'secret-two'], + }); + const resource = vi.fn(() => ({ request })); + const cli = createAttestationsCli(resource); + async function run(args: string[]) { + let stdout = ''; + await cli.serve([...args, '--format', 'json'], { + stdout: (text) => { + stdout += text; + }, + exit: (code) => { + expect(code).toBe(0); + }, + }); + return JSON.parse(stdout); + } + + expect(await run(['request', '--count', '2'])).toMatchObject({ + count: 2, + output_file: getPoolPath(), + }); + expect(request).toHaveBeenCalledWith({ count: 2 }); + resource.mockClear(); + expect(await run(['list'])).toMatchObject({ + total_token_count: 2, + attestations: [{ storage: 'pool', stored_token_count: 2 }], + }); + expect(await run(['take'])).toEqual({ + issuer: 'https://api.link.com', + token_key_id: 'key-id', + token: 'secret-one', + authorization: 'PrivateToken token="secret-one=="', + }); + expect(resource).not.toHaveBeenCalled(); + expect((await readAttestationPool()).batches[0]?.count).toBe(1); + + const before = await fs.readFile(getPoolPath()); + const exported = path.join(directory, 'agent.json'); + request.mockResolvedValue({ + issuer: 'https://api.link.com', + token_key_id: 'key-id', + count: 1, + tokens: ['exported'], + }); + const result = await run([ + 'request', + '--count', + '1', + '--output-file', + exported, + ]); + expect(result).toMatchObject({ count: 1, output_file: exported }); + expect(result).not.toHaveProperty('tokens'); + expect(await fs.readFile(getPoolPath())).toEqual(before); + expect(JSON.parse(await fs.readFile(exported, 'utf8')).tokens[0].token).toBe( + 'exported', + ); +}); diff --git a/packages/cli/src/commands/attestations/__tests__/schema.test.ts b/packages/cli/src/commands/attestations/__tests__/schema.test.ts index 6ccf680d..24737980 100644 --- a/packages/cli/src/commands/attestations/__tests__/schema.test.ts +++ b/packages/cli/src/commands/attestations/__tests__/schema.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest'; import { requestOptions } from '../schema'; describe('attestation request options', () => { - it('only accepts the token count', () => { - expect(Object.keys(requestOptions.shape)).toEqual(['count']); - expect(requestOptions.shape).not.toHaveProperty('issuer'); - expect(requestOptions.shape).not.toHaveProperty('accessToken'); - expect(requestOptions.shape).not.toHaveProperty('outputFile'); - expect(requestOptions.shape).not.toHaveProperty('force'); + it('accepts a count and optional export path', () => { + expect(Object.keys(requestOptions.shape)).toEqual(['count', 'outputFile']); + expect(requestOptions.parse({ count: '10' })).toEqual({ count: 10 }); + expect( + requestOptions.parse({ count: 10, outputFile: './aats.json' }), + ).toEqual({ count: 10, outputFile: './aats.json' }); + expect(requestOptions.safeParse({ count: 0 }).success).toBe(false); + expect(requestOptions.safeParse({ count: 101 }).success).toBe(false); + expect(requestOptions.safeParse({ count: 1, outputFile: '' }).success).toBe( + false, + ); }); }); diff --git a/packages/cli/src/commands/attestations/__tests__/storage.test.ts b/packages/cli/src/commands/attestations/__tests__/storage.test.ts index 6649aebd..2f6fb2dc 100644 --- a/packages/cli/src/commands/attestations/__tests__/storage.test.ts +++ b/packages/cli/src/commands/attestations/__tests__/storage.test.ts @@ -1,53 +1,254 @@ +import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AttestationExport } from '../export'; -import { writeAttestationArtifact } from '../storage'; - -const artifact: AttestationExport = { - version: 1, - issuer: 'https://api.link.com', - token_key_id: 'key-id', - count: 1, - tokens: [{ token: 'token', authorization: 'PrivateToken token="token"' }], -}; - -describe('attestation artifact storage', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'link-attestations-')); - vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); - }); +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { type AttestationExport, authorizationHeader } from '../export'; +import { + addAttestationsToPool, + exportAttestationArtifact, + getPoolPath, + readAttestationPool, + takeAttestation, +} from '../storage'; - afterEach(async () => { - vi.restoreAllMocks(); - await fs.rm(tmpDir, { recursive: true, force: true }); - }); +let directory: string; +function batch( + tokens = ['token-one', 'token-two'], + key = 'key-id', +): AttestationExport { + return { + version: 1, + issuer: 'https://api.link.com', + token_key_id: key, + count: tokens.length, + tokens: tokens.map((token) => ({ + token, + authorization: authorizationHeader(token), + })), + }; +} - it('creates unique artifacts in a private directory', async () => { - const directory = path.join(tmpDir, '.link-cli', 'attestations'); - const first = await writeAttestationArtifact(artifact); - const second = await writeAttestationArtifact(artifact); +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), 'link-pool-')); + vi.spyOn(os, 'homedir').mockReturnValue(directory); +}); +afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(directory, { recursive: true, force: true }); +}); - expect(first).not.toBe(second); - expect(path.dirname(first)).toBe(directory); - expect(JSON.parse(await fs.readFile(first, 'utf8'))).toEqual(artifact); - expect((await fs.stat(directory)).mode & 0o777).toBe(0o700); - expect((await fs.stat(first)).mode & 0o777).toBe(0o600); +it('appends batches to one private file and drains them with their original issuer keys', async () => { + const file = await addAttestationsToPool(batch()); + expect(await addAttestationsToPool(batch(['third'], 'rotated-key'))).toBe( + file, + ); + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + expect((await fs.stat(path.dirname(file))).mode & 0o777).toBe(0o700); + expect(await fs.readdir(path.dirname(file))).toEqual(['pool.json']); + expect(await takeAttestation()).toEqual({ + issuer: 'https://api.link.com', + token_key_id: 'key-id', + token: 'token-one', + authorization: authorizationHeader('token-one'), }); + expect((await readAttestationPool()).batches[0]?.count).toBe(1); + expect((await takeAttestation()).token).toBe('token-two'); + expect((await takeAttestation()).token_key_id).toBe('rotated-key'); + expect((await readAttestationPool()).batches).toEqual([]); + await expect(takeAttestation()).rejects.toMatchObject({ + code: 'ATTESTATION_POOL_EMPTY', + }); +}); - it('rejects a symbolic-link output directory', async () => { - const target = path.join(tmpDir, 'target'); - const directory = path.join(tmpDir, '.link-cli', 'attestations'); - await fs.mkdir(target); - await fs.mkdir(path.dirname(directory)); - await fs.symlink(target, directory); +it('does not import old exports into a missing pool', async () => { + const file = getPoolPath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + const exported = path.join(path.dirname(file), 'legacy.json'); + await fs.writeFile(exported, JSON.stringify(batch())); + await expect(takeAttestation()).rejects.toMatchObject({ + code: 'ATTESTATION_POOL_EMPTY', + }); + expect(JSON.parse(await fs.readFile(exported, 'utf8'))).toEqual(batch()); + await expect(fs.stat(file)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +it('exports to an explicit file without changing the managed pool', async () => { + await addAttestationsToPool(batch()); + const before = await fs.readFile(getPoolPath()); + const file = path.join(directory, 'export.json'); + await exportAttestationArtifact(batch(['exported']), file); + expect(JSON.parse(await fs.readFile(file, 'utf8'))).toEqual( + batch(['exported']), + ); + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + expect(await fs.readFile(getPoolPath())).toEqual(before); + await expect(exportAttestationArtifact(batch(), file)).rejects.toThrow( + 'OUTPUT_FILE_EXISTS', + ); +}); - await expect(writeAttestationArtifact(artifact)).rejects.toThrow( - 'ATTESTATION_OUTPUT_DIRECTORY_INVALID', +it('reserves the pool, lock, and storage directory from exports, even before pool creation', async () => { + for (const file of [ + getPoolPath(), + `${getPoolPath()}.lock`, + path.dirname(getPoolPath()), + ]) { + await expect(exportAttestationArtifact(batch(), file)).rejects.toThrow( + 'outside the CLI attestation storage', ); - expect(await fs.readdir(target)).toEqual([]); + } + expect(await fs.readdir(directory)).toEqual([]); +}); + +it('rejects exports through aliases of the pool and lock paths', async () => { + await fs.mkdir(path.dirname(getPoolPath()), { recursive: true }); + const alias = path.join(directory, 'alias'); + await fs.symlink(path.dirname(getPoolPath()), alias); + for (const name of ['pool.json', 'pool.json.lock']) { + await expect( + exportAttestationArtifact(batch(), path.join(alias, name)), + ).rejects.toThrow('outside the CLI attestation storage'); + } + expect(await fs.readdir(path.dirname(getPoolPath()))).toEqual([]); +}); + +it('fails closed for corrupt pools without replacing them or revealing token contents', async () => { + await addAttestationsToPool(batch()); + await fs.writeFile(getPoolPath(), 'secret-token-invalid-json'); + await expect(takeAttestation()).rejects.toThrow( + `Invalid JSON in ${getPoolPath()}.`, + ); + await expect(addAttestationsToPool(batch(['another']))).rejects.toThrow( + 'Invalid JSON', + ); + expect(await fs.readFile(getPoolPath(), 'utf8')).toBe( + 'secret-token-invalid-json', + ); +}); + +it('rejects duplicate and malformed tokens without committing a mutation', async () => { + await addAttestationsToPool(batch()); + const before = await fs.readFile(getPoolPath()); + await expect(addAttestationsToPool(batch())).rejects.toThrow( + 'duplicate tokens', + ); + expect(await fs.readFile(getPoolPath())).toEqual(before); + await fs.writeFile( + getPoolPath(), + JSON.stringify({ version: 2, batches: [batch(['invalid\ntoken'])] }), + ); + const invalid = await fs.readFile(getPoolPath()); + await expect(takeAttestation()).rejects.toThrow( + 'Invalid attestation token encoding', + ); + expect(await fs.readFile(getPoolPath())).toEqual(invalid); +}); + +it('leaves the original pool intact when atomic replacement fails', async () => { + await addAttestationsToPool(batch()); + const before = await fs.readFile(getPoolPath()); + vi.spyOn(fs, 'rename').mockRejectedValueOnce(new Error('disk unavailable')); + await expect(takeAttestation()).rejects.toThrow('disk unavailable'); + expect(await fs.readFile(getPoolPath())).toEqual(before); + expect(await fs.readdir(path.dirname(getPoolPath()))).toEqual(['pool.json']); + expect((await takeAttestation()).token).toBe('token-one'); +}); + +it('rejects symbolic-link pool files and directories without writing to their targets', async () => { + const target = path.join(directory, 'target.json'); + await fs.writeFile(target, 'untouched'); + await fs.mkdir(path.dirname(getPoolPath()), { recursive: true }); + await fs.symlink(target, getPoolPath()); + await expect(addAttestationsToPool(batch())).rejects.toThrow('symbolic link'); + await expect(takeAttestation()).rejects.toThrow('symbolic link'); + expect(await fs.readFile(target, 'utf8')).toBe('untouched'); + await fs.rm(path.dirname(getPoolPath()), { recursive: true }); + await fs.mkdir(path.join(directory, 'target')); + await fs.symlink(path.join(directory, 'target'), path.dirname(getPoolPath())); + await expect(addAttestationsToPool(batch())).rejects.toThrow( + 'DIRECTORY_INVALID', + ); + expect(await fs.readdir(path.join(directory, 'target'))).toEqual([]); +}); + +it('does not steal an old lock from a paused writer', async () => { + await addAttestationsToPool(batch()); + const before = await fs.readFile(getPoolPath()); + const lock = `${getPoolPath()}.lock`; + await fs.mkdir(lock); + await fs.utimes(lock, new Date(0), new Date(0)); + await expect(takeAttestation()).rejects.toMatchObject({ + code: 'ATTESTATION_POOL_LOCKED', }); + expect(await fs.readFile(getPoolPath())).toEqual(before); + expect((await fs.stat(lock)).isDirectory()).toBe(true); +}, 10_000); + +it('serializes separate CLI processes so a token is handed out at most once', async () => { + const tokens = Array.from({ length: 6 }, (_, i) => `token-${i}`); + await addAttestationsToPool(batch(tokens)); + const preload = ` + import os from 'node:os'; + import {syncBuiltinESMExports} from 'node:module'; + os.homedir = () => ${JSON.stringify(directory)}; + syncBuiltinESMExports(); + const nativeFetch = globalThis.fetch; + globalThis.fetch = (url, options) => { + if (String(url).startsWith('data:')) return nativeFetch(url, options); + throw new Error('Unexpected API request'); + }; + `; + const args = [ + '--import', + `data:text/javascript,${encodeURIComponent(preload)}`, + fileURLToPath(new URL('../../../../dist/cli.js', import.meta.url)), + 'identity', + 'attestations', + 'take', + '--format', + 'json', + ]; + const env = { + ...process.env, + LINK_IDENTITY_COMMANDS: '1', + LINK_AUTH_FILE: path.join(directory, 'auth.json'), + LINK_ACCESS_TOKEN: undefined, + LINK_REFRESH_TOKEN: undefined, + NODE_OPTIONS: undefined, + NO_UPDATE_NOTIFIER: '1', + }; + const results = await Promise.allSettled( + Array.from({ length: 8 }, () => + promisify(execFile)(process.execPath, args, { env, timeout: 15_000 }), + ), + ); + const successful = results.filter((result) => result.status === 'fulfilled'); + expect( + successful.map((result) => JSON.parse(result.value.stdout).token).sort(), + ).toEqual(tokens); + const failed = results.filter((result) => result.status === 'rejected'); + expect(failed).toHaveLength(2); + for (const result of failed) { + expect(JSON.parse(result.reason.stdout).code).toBe( + 'ATTESTATION_POOL_EMPTY', + ); + } + expect((await readAttestationPool()).batches).toEqual([]); +}, 30_000); + +it('serializes concurrent appends and takes without losing new batches', async () => { + await addAttestationsToPool(batch(['initial'])); + const updates = Array.from({ length: 6 }, (_, i) => + addAttestationsToPool(batch([`new-${i}`])), + ); + await Promise.all([...updates, takeAttestation()]); + const remaining = (await readAttestationPool()).batches.flatMap((item) => + item.tokens.map(({ token }) => token), + ); + expect(remaining.sort()).toEqual( + Array.from({ length: 6 }, (_, i) => `new-${i}`), + ); }); diff --git a/packages/cli/src/commands/attestations/export.ts b/packages/cli/src/commands/attestations/export.ts index 29acedc1..4f0292a0 100644 --- a/packages/cli/src/commands/attestations/export.ts +++ b/packages/cli/src/commands/attestations/export.ts @@ -1,3 +1,5 @@ +import { z } from 'incur'; + export const ATTESTATION_ARTIFACT_VERSION = 1 as const; export function base64urlPad(value: Uint8Array | string): string { @@ -47,3 +49,20 @@ export function exportAttestationTokens(result: { })), }; } + +export const savedAttestationSchema = z + .object({ + version: z.literal(1), + issuer: z.url(), + token_key_id: z.string().min(1), + count: z.number().int().min(0).max(100), + tokens: z + .array( + z.object({ + token: z.string().min(1), + authorization: z.string().min(1), + }), + ) + .max(100), + }) + .refine((artifact) => artifact.count === artifact.tokens.length); diff --git a/packages/cli/src/commands/attestations/index.tsx b/packages/cli/src/commands/attestations/index.tsx index 7c2b1da3..b018ee26 100644 --- a/packages/cli/src/commands/attestations/index.tsx +++ b/packages/cli/src/commands/attestations/index.tsx @@ -1,12 +1,18 @@ import type { IAttestationsResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; import { renderInteractive } from '../../utils/render-interactive'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import { inspectionError } from '../identity/artifact-reader'; import { SavedArtifact } from '../identity/saved-artifact'; import { exportAttestationTokens } from './export'; import { listAttestations } from './inspect'; import { requestOptions } from './schema'; -import { writeAttestationArtifact } from './storage'; +import { + addAttestationsToPool, + exportAttestationArtifact, + takeAttestation, + validateExportPath, +} from './storage'; export function createAttestationsCli( createResource: () => IAttestationsResource, @@ -29,6 +35,19 @@ export function createAttestationsCli( }, }); + cli.command('take', { + description: 'Remove and return one attestation token from the CLI pool.', + mcp: false, + outputPolicy: 'all' as const, + async run(c) { + try { + return sanitizeDeep(await takeAttestation()); + } catch (error) { + return c.error(inspectionError(error)); + } + }, + }); + cli.command('request', { description: 'Get privacy-preserving tokens that show Link attests to your agent.', @@ -36,14 +55,17 @@ export function createAttestationsCli( mcp: false, outputPolicy: 'agent-only' as const, async run(c) { - const { count } = c.options; + const { count, outputFile: exportFile } = c.options; + if (exportFile) await validateExportPath(exportFile); const artifact = exportAttestationTokens( await createResource().request({ count, }), ); - const outputFile = await writeAttestationArtifact(artifact); + const outputFile = exportFile + ? await exportAttestationArtifact(artifact, exportFile) + : await addAttestationsToPool(artifact); const result = { issuer: artifact.issuer, token_key_id: artifact.token_key_id, @@ -58,7 +80,11 @@ export function createAttestationsCli( ) { return renderInteractive( , diff --git a/packages/cli/src/commands/attestations/inspect.ts b/packages/cli/src/commands/attestations/inspect.ts index 1d09a082..1e430096 100644 --- a/packages/cli/src/commands/attestations/inspect.ts +++ b/packages/cli/src/commands/attestations/inspect.ts @@ -1,46 +1,39 @@ -import { z } from 'incur'; import { sanitizeDeep } from '../../utils/sanitize-text'; import { inspectionError, listArtifactFiles, readArtifact, } from '../identity/artifact-reader'; -import { getOutputDirectory } from './storage'; - -const savedAttestationSchema = z - .object({ - version: z.literal(1), - issuer: z.url(), - token_key_id: z.string().min(1), - count: z.number().int().min(0).max(100), - tokens: z - .array( - z.object({ - token: z.string().min(1), - authorization: z.string().min(1), - }), - ) - .max(100), - }) - .refine((artifact) => artifact.count === artifact.tokens.length); +import { savedAttestationSchema } from './export'; +import { + getOutputDirectory, + getPoolPath, + readAttestationPool, +} from './storage'; async function inspectFile(file: string) { - const artifact = await readArtifact(file, savedAttestationSchema); - return sanitizeDeep({ - output_file: file, - issuer: artifact.issuer, - token_key_id: artifact.token_key_id, - stored_token_count: artifact.tokens.length, - }); + const managed = file === getPoolPath(); + const artifacts = managed + ? (await readAttestationPool()).batches + : [await readArtifact(file, savedAttestationSchema)]; + return artifacts.map((artifact) => + sanitizeDeep({ + output_file: file, + storage: managed ? ('pool' as const) : ('export' as const), + issuer: artifact.issuer, + token_key_id: artifact.token_key_id, + stored_token_count: artifact.tokens.length, + }), + ); } export async function listAttestations() { const files = await listArtifactFiles(getOutputDirectory()); - const attestations: Awaited>[] = []; + const attestations: Awaited> = []; const errors: { output_file: string; code: string; message: string }[] = []; for (const file of files) { try { - attestations.push(await inspectFile(file)); + attestations.push(...(await inspectFile(file))); } catch (error) { errors.push( sanitizeDeep({ output_file: file, ...inspectionError(error) }), diff --git a/packages/cli/src/commands/attestations/schema.ts b/packages/cli/src/commands/attestations/schema.ts index 246c73d0..b83472c1 100644 --- a/packages/cli/src/commands/attestations/schema.ts +++ b/packages/cli/src/commands/attestations/schema.ts @@ -7,4 +7,11 @@ export const requestOptions = z.object({ .positive() .max(100) .describe('Number of tokens to request'), + outputFile: z + .string() + .min(1) + .optional() + .describe( + 'Export a batch to a new file for agent-managed use instead of adding it to the CLI pool', + ), }); diff --git a/packages/cli/src/commands/attestations/storage.ts b/packages/cli/src/commands/attestations/storage.ts index 807bd1be..922e5d1d 100644 --- a/packages/cli/src/commands/attestations/storage.ts +++ b/packages/cli/src/commands/attestations/storage.ts @@ -2,13 +2,41 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { z } from 'incur'; import { writeCredentialFile } from '../../utils/credential-output'; -import type { AttestationExport } from './export'; +import { readArtifact } from '../identity/artifact-reader'; +import { + type AttestationExport, + authorizationHeader, + savedAttestationSchema, +} from './export'; + +const poolSchema = z + .object({ + version: z.literal(2), + batches: z.array(savedAttestationSchema.refine((batch) => batch.count > 0)), + }) + .refine((pool) => { + const tokens = pool.batches.flatMap((batch) => + batch.tokens.map(({ token }) => token), + ); + return new Set(tokens).size === tokens.length; + }); +type AttestationPool = z.infer; export function getOutputDirectory(): string { return path.join(os.homedir(), '.link-cli', 'attestations'); } +export function getPoolPath(): string { + return path.join(getOutputDirectory(), 'pool.json'); +} + +export function readAttestationPool() { + return readArtifact(getPoolPath(), poolSchema); +} + async function prepareOutputDirectory(): Promise { const directory = getOutputDirectory(); await fs.mkdir(directory, { recursive: true, mode: 0o700 }); @@ -22,13 +50,148 @@ async function prepareOutputDirectory(): Promise { return directory; } -export async function writeAttestationArtifact( +async function writePool(pool: AttestationPool) { + const temporary = path.join( + getOutputDirectory(), + `.pool-${randomUUID()}.tmp`, + ); + const handle = await fs.open(temporary, 'wx', 0o600); + try { + try { + await handle.writeFile(JSON.stringify(pool)); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temporary, getPoolPath()); + // Node cannot open directories for fsync on Windows. + if (process.platform !== 'win32') { + const directory = await fs.open(getOutputDirectory(), 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } + } finally { + await fs.rm(temporary, { force: true }); + } +} + +async function lockPool() { + const lockPath = `${getPoolPath()}.lock`; + for (let attempt = 0; ; attempt++) { + try { + await fs.mkdir(lockPath, { mode: 0o700 }); + return () => fs.rmdir(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + if (attempt === 20) { + throw Object.assign( + new Error( + `Attestation pool is locked at ${lockPath}. If a command crashed, ensure no attestation commands are running before removing that lock directory.`, + ), + { code: 'ATTESTATION_POOL_LOCKED' }, + ); + } + // Never steal a lock based on age: its owner may be paused and resume. + await delay(250); + } + } +} + +async function updatePool(update: (pool: AttestationPool) => T): Promise { + await prepareOutputDirectory(); + const release = await lockPool(); + try { + let pool: AttestationPool; + try { + pool = await readAttestationPool(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + pool = { version: 2, batches: [] }; + } + const result = update(pool); + if (!poolSchema.safeParse(pool).success) { + throw new Error('Invalid attestation pool or duplicate tokens.'); + } + await writePool(pool); + return result; + } finally { + await release(); + } +} + +export async function addAttestationsToPool( artifact: AttestationExport, ): Promise { - const directory = await prepareOutputDirectory(); - const outputFile = path.join( - directory, - `attestations-${Date.now()}-${randomUUID()}.json`, - ); + await updatePool((pool) => { + pool.batches.push(artifact); + }); + return getPoolPath(); +} + +export async function takeAttestation() { + // Commit removal before returning any token bytes. A crash can lose a token, + // but retrying must never return that token to another caller. + return updatePool((pool) => { + const batch = pool.batches[0]; + const token = batch?.tokens.shift(); + if (!batch || !token) { + throw Object.assign( + new Error( + 'The attestation pool is empty. Run identity attestations request --count 10 to refill it.', + ), + { code: 'ATTESTATION_POOL_EMPTY' }, + ); + } + batch.count = batch.tokens.length; + if (batch.count === 0) pool.batches.shift(); + if (!/^[A-Za-z0-9_-]+={0,2}$/.test(token.token)) { + throw new Error('Invalid attestation token encoding.'); + } + return { + issuer: batch.issuer, + token_key_id: batch.token_key_id, + token: token.token, + authorization: authorizationHeader(token.token), + }; + }); +} + +async function canonicalPath(file: string): Promise { + try { + return await fs.realpath(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = path.dirname(file); + if (parent === file) throw error; + return path.join(await canonicalPath(parent), path.basename(file)); + } +} + +export async function validateExportPath(outputFile: string): Promise { + const [directory, destination] = await Promise.all([ + canonicalPath(getOutputDirectory()), + canonicalPath(path.resolve(outputFile)), + ]); + const relative = path.relative(directory, destination); + if ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ) { + throw new Error( + 'Choose an export path outside the CLI attestation storage directory.', + ); + } +} + +export async function exportAttestationArtifact( + artifact: AttestationExport, + outputFile: string, +): Promise { + await validateExportPath(outputFile); return writeCredentialFile(outputFile, artifact, false); } diff --git a/packages/cli/src/commands/credentials/__tests__/output.test.ts b/packages/cli/src/commands/credentials/__tests__/output.test.ts index 3481680c..36db985b 100644 --- a/packages/cli/src/commands/credentials/__tests__/output.test.ts +++ b/packages/cli/src/commands/credentials/__tests__/output.test.ts @@ -160,7 +160,7 @@ it('preserves the credential in a requested full-output envelope', async () => { ); }); -it('prints the saved attestation path without exposing raw tokens', async () => { +it('prints the attestation pool path without exposing raw tokens', async () => { const home = path.join(state.directory, 'attestation-home'); vi.spyOn(os, 'homedir').mockReturnValue(home); const cli = createAttestationsCli(() => ({ @@ -182,16 +182,16 @@ it('prints the saved attestation path without exposing raw tokens', async () => details: SavedArtifactDetail[]; }>; expect(view.type).toBe(SavedArtifact); - expect(view.props.message).toBe('Attestation token saved'); - expect(view.props.outputFile).toContain( - '.link-cli/attestations/attestations-', + expect(view.props.message).toBe('Attestation tokens added to pool'); + expect(view.props.outputFile).toBe( + path.join(home, '.link-cli', 'attestations', 'pool.json'), ); expect(view.props.details).toEqual([{ label: 'Count', value: 1 }]); const directory = path.join(home, '.link-cli', 'attestations'); const files = await fs.readdir(directory); - expect(files).toHaveLength(1); - expect( - JSON.parse(await fs.readFile(path.join(directory, files[0]), 'utf8')) - .tokens, - ).toHaveLength(1); + expect(files).toEqual(['pool.json']); + const pool = JSON.parse(await fs.readFile(view.props.outputFile, 'utf8')); + expect(pool.version).toBe(2); + expect(pool.batches).toHaveLength(1); + expect(pool.batches[0].tokens).toHaveLength(1); }); diff --git a/packages/cli/src/commands/identity/__tests__/inspect.test.ts b/packages/cli/src/commands/identity/__tests__/inspect.test.ts index fd16d784..dcea29cd 100644 --- a/packages/cli/src/commands/identity/__tests__/inspect.test.ts +++ b/packages/cli/src/commands/identity/__tests__/inspect.test.ts @@ -108,6 +108,7 @@ it('lists all saved batches in filename order with per-file and total stored cou expect(result.note).toContain('not tracked'); expect(result.attestations[1]).toEqual({ output_file: second, + storage: 'export', issuer: attestation.issuer, token_key_id: attestation.token_key_id, stored_token_count: 2,