Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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>"`. 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 <n> --output-file <path>` 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

Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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',
);
});
17 changes: 11 additions & 6 deletions packages/cli/src/commands/attestations/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
Loading
Loading