diff --git a/README.md b/README.md index bb7e2e8..3592caa 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ agent slack members # workspace members, with linked GitHub identi agent linear teams # teams in the connected Linear organization agent sentry orgs # connected Sentry organizations +agent asset upload shot.png # store a PNG; prints an org-gated link to paste into a PR comment +agent asset list # list stored assets (--session scopes to one run's uploads) +agent asset get -o shot.png # show one asset, or download its bytes with -o + agent sandbox variable list # list sandbox env variable names (values are write-only) agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env/.json) agent sandbox variable rm K # delete a variable diff --git a/src/cli.tsx b/src/cli.tsx index dc4f054..fefc47e 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,6 +4,7 @@ import { registerMe } from './commands/me' import { registerSession } from './commands/session' import { registerConfig } from './commands/config' import { registerSandbox } from './commands/sandbox' +import { registerAsset } from './commands/asset' import { registerHooks } from './commands/hooks' import { registerTemplate } from './commands/template' import { registerIntegrations } from './commands/integrations' @@ -28,6 +29,7 @@ registerMe(program) registerSession(program) registerConfig(program) registerSandbox(program) +registerAsset(program) registerHooks(program) registerTemplate(program) registerIntegrations(program) diff --git a/src/commands/asset.ts b/src/commands/asset.ts new file mode 100644 index 0000000..86c584d --- /dev/null +++ b/src/commands/asset.ts @@ -0,0 +1,189 @@ +import { type Command } from 'commander' +import { readFileSync, writeFileSync } from 'node:fs' +import { basename } from 'node:path' +import { ApiClient } from '../lib/api' +import { formatTs, printJson, printTable, runAction } from '../lib/output' +import type { AssetView, CreateAssetRequest, GetAssetResponse } from '../lib/types' + +// `agent asset `: persist files to Ellipsis platform storage and get +// back an org-membership-gated link (documents/eng/AGENT_ASSET_STORAGE.md in +// the ellipsis repo). The primary caller is an agent inside a sandbox that +// took a screenshot of a UI change and wants a link to paste into a PR +// comment — the injected sandbox token authenticates it with zero setup, and +// the same commands work on a laptop with a device-login token. + +// Client-side mirrors of the server limits (assets_service.py), so an +// oversized or non-PNG file fails fast with a clear message instead of a +// base64-inflated round trip to a 400. The server re-validates; these are +// UX, not enforcement. +export const MAX_ASSET_SIZE_BYTES = 10 * 1024 * 1024 +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +// Well-known magic bytes we can name in the "not a PNG" error, so an agent +// that screenshotted to the wrong format learns what it actually produced. +const KNOWN_SIGNATURES: Array<[Buffer, string]> = [ + [Buffer.from([0xff, 0xd8, 0xff]), 'JPEG'], + [Buffer.from('GIF8', 'ascii'), 'GIF'], + [Buffer.from('BM', 'ascii'), 'BMP'], + [Buffer.from('%PDF', 'ascii'), 'PDF'], + [Buffer.from('= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF') { + return bytes.subarray(8, 12).toString('ascii') === 'WEBP' ? 'WebP' : 'RIFF' + } + for (const [magic, name] of KNOWN_SIGNATURES) { + if (bytes.subarray(0, magic.length).equals(magic)) return name + } + return null +} + +// Build the upload request from a file's bytes, throwing the fast client-side +// errors (empty, oversized, not a PNG). Exported for tests. +export function buildUploadRequest(path: string, bytes: Buffer): CreateAssetRequest { + if (bytes.length === 0) throw new Error(`${path} is empty`) + if (bytes.length > MAX_ASSET_SIZE_BYTES) { + throw new Error( + `${path} is ${formatSize(bytes.length)}; the limit is ` + + `${formatSize(MAX_ASSET_SIZE_BYTES)} per asset`, + ) + } + if (!bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) { + const guessed = sniffFormat(bytes) + throw new Error( + 'only PNG images are supported' + + (guessed ? ` (got what looks like ${guessed})` : ' (bytes are not a PNG)'), + ) + } + return { + filename: basename(path), + content_type: 'image/png', + data_b64: bytes.toString('base64'), + } +} + +// Human-readable byte count for tables and error messages. Binary units to +// match the server's MiB-denominated limits. +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB` +} + +export function registerAsset(program: Command): void { + const asset = program + .command('asset') + .description('Store files on the Ellipsis platform and share them as org-gated links') + + asset + .command('upload ') + .description( + 'Upload a PNG and print its org-gated URL — paste it into a PR comment (POST /v1/assets)', + ) + .option('--json', 'output raw JSON') + .action(async (path: string, opts: { json?: boolean }) => { + await runAction(async () => { + const req = buildUploadRequest(path, readFileSync(path)) + const res = await new ApiClient().uploadAsset(req) + // The URL is the whole point — keep it the bare primary output so an + // agent (or $(...) in a script) can capture it directly. + if (opts.json) printJson(res) + else console.log(res.url) + }) + }) + + asset + .command('list') + .alias('ls') + .description("List the customer's stored assets, newest first (GET /v1/assets)") + .option('--session ', 'only assets uploaded by this agent session') + .option('--limit ', 'max results (server cap: 250)', parsePositiveInt) + .option('--json', 'output raw JSON') + .action(async (opts: { session?: string; limit?: number; json?: boolean }) => { + await runAction(async () => { + const assets = await new ApiClient().listAssets({ + agent_session_id: opts.session, + limit: opts.limit, + }) + if (opts.json) { + printJson(assets) + return + } + if (assets.length === 0) { + console.log('No assets.') + return + } + printTable( + ['ID', 'FILENAME', 'SIZE', 'CREATED', 'SESSION'], + assets.map((a) => [ + a.id, + a.filename, + formatSize(a.size_bytes), + formatTs(a.created_at), + a.agent_session_id ?? '-', + ]), + ) + }) + }) + + asset + .command('get ') + .description( + 'Show one asset; -o downloads the bytes to a file (GET /v1/assets/{id} + presigned S3 GET)', + ) + .option('-o, --output ', 'write the file contents to this path') + .option('--json', 'output raw JSON (includes the short-lived download_url)') + .action(async (assetId: string, opts: { output?: string; json?: boolean }) => { + await runAction(async () => { + const res = await new ApiClient().getAsset(assetId) + if (opts.output) { + // download_url is a ~60s presigned S3 GET — fetch it immediately, + // while it's fresh. The JSON API never carries the bytes itself. + await downloadTo(res.download_url, opts.output) + if (!opts.json) { + console.log(`✓ wrote ${opts.output} (${formatSize(res.asset.size_bytes)})`) + } + } + if (opts.json) printJson(res) + else if (!opts.output) renderAsset(res) + }) + }) +} + +function renderAsset(res: GetAssetResponse): void { + const a: AssetView = res.asset + console.log(`id: ${a.id}`) + console.log(`filename: ${a.filename}`) + console.log(`type: ${a.content_type}`) + console.log(`size: ${formatSize(a.size_bytes)}`) + console.log(`created: ${formatTs(a.created_at)}`) + if (a.agent_session_id) console.log(`session: ${a.agent_session_id}`) + console.log(`url: ${res.url}`) + console.log(`\ndownload the file with: agent asset get ${a.id} -o ${a.filename}`) +} + +// Pull the bytes from the presigned S3 URL. Deliberately bare fetch (no +// bearer header — the signature in the URL is the credential). Assets are +// ≤10 MiB, so buffering in memory is fine. +async function downloadTo(url: string, path: string): Promise { + const res = await fetch(url) + if (!res.ok) { + throw new Error( + `download failed: ${res.status} ${res.statusText}` + + (res.status === 403 + ? ' (the presigned URL likely expired — re-run the command for a fresh one)' + : ''), + ) + } + writeFileSync(path, Buffer.from(await res.arrayBuffer())) +} + +function parsePositiveInt(raw: string): number { + const n = Number.parseInt(raw, 10) + if (!Number.isFinite(n) || n <= 0) throw new Error(`invalid count '${raw}'`) + return n +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 9cfe681..91df6c3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,11 +7,15 @@ import type { AnalyticsMetricsQuery, AnalyticsPullRequestsQuery, AnalyticsReviewsQuery, + AssetView, BudgetSummary, CliAuthPoll, CliAuthStart, CreateAgentConfigRequest, + CreateAssetRequest, + CreateAssetResponse, CreatedAgentConfig, + GetAssetResponse, GetAnalyticsMetricsResponse, GetAnalyticsPullRequestsResponse, GetAnalyticsReviewsResponse, @@ -21,6 +25,8 @@ import type { ListAgentSessionsQuery, ListAgentSessionsResponse, ListAgentTemplatesResponse, + ListAssetsQuery, + ListAssetsResponse, ListGithubMembersResponse, ListGithubRepositoriesResponse, ListLinearTeamsResponse, @@ -210,6 +216,34 @@ export class ApiClient { return this.request('POST', `/v1/sessions/${encodeURIComponent(sessionId)}/stop`) } + // --------------------------------- assets -------------------------------- + // Agent asset storage: persist a file to the platform and get back an + // org-membership-gated link (documents/eng/AGENT_ASSET_STORAGE.md in the + // ellipsis repo). v1 is PNG-only with a 10 MiB cap, enforced server-side. + + uploadAsset(req: CreateAssetRequest): Promise { + return this.request('POST', '/v1/assets', req) + } + + // Newest-first metadata for the credential's customer's assets. Metadata + // only — presigned download URLs are minted per explicit getAsset. + async listAssets(query?: ListAssetsQuery): Promise { + const res = await this.request( + 'GET', + '/v1/assets', + undefined, + query as Record | undefined, + ) + return res.assets + } + + // Metadata + the gated URL + a short-lived presigned `download_url`. To pull + // the bytes locally, GET download_url immediately (it expires in ~60s; if it + // lapses, just call this again for a fresh one). + getAsset(assetId: string): Promise { + return this.request('GET', `/v1/assets/${encodeURIComponent(assetId)}`) + } + // ----------------------------- agent configs ---------------------------- async listAgentConfigs(): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 965fc8b..69da521 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -675,6 +675,59 @@ export interface GetAnalyticsReviewsResponse { } } +// --------------------------------- assets -------------------------------- +// Agent asset storage (ellipsis: documents/eng/AGENT_ASSET_STORAGE.md): files +// an agent persists beyond its sandbox's lifetime — v1 is PNG screenshots +// posted as org-gated links on PRs. Mirrors assets_service.py. + +// Caller-facing asset metadata — no storage internals (S3 key, sha, owner). +export interface AssetView { + id: string + filename: string + content_type: string + size_bytes: number + created_at: string + // The originating session, when the upload came from a sandbox. + agent_session_id: string | null +} + +export interface CreateAssetRequest { + // Original basename, display only (the S3 key derives from the server-side + // asset id, never from this). + filename: string + // v1: must be image/png; the server magic-byte-checks the decoded bytes. + content_type: string + // The raw file bytes, base64-encoded (same JSON-body precedent as session + // transcript sync). + data_b64: string +} + +export interface CreateAssetResponse { + asset: AssetView + // The fully-formed org-gated dashboard URL (app.ellipsis.dev/assets/{id}) — + // the link an agent pastes into a PR comment. + url: string +} + +export interface ListAssetsQuery { + // Scope to one run's uploads. + agent_session_id?: string + limit?: number +} + +export interface ListAssetsResponse { + assets: AssetView[] +} + +export interface GetAssetResponse { + asset: AssetView + // The gated dashboard URL (same link the upload returned). + url: string + // Short-lived (60s) presigned S3 GET for the actual bytes — fetch it + // immediately; the JSON API never carries the file itself. + download_url: string +} + // ------------------------------ cli auth -------------------------------- export interface CliAuthStart { diff --git a/test/api.test.ts b/test/api.test.ts index 9d0f20c..d3e5c66 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -292,3 +292,67 @@ describe('createAgentConfig', () => { }) }) }) + +describe('ApiClient assets', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('POSTs the upload payload and returns the gated URL', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + asset: { id: 'a1', filename: 'shot.png' }, + url: 'https://app.ellipsis.dev/assets/a1', + }), + { status: 201 }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').uploadAsset({ + filename: 'shot.png', + content_type: 'image/png', + data_b64: 'aGk=', + }) + expect(out.url).toBe('https://app.ellipsis.dev/assets/a1') + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/assets') + expect((init as RequestInit).method).toBe('POST') + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + filename: 'shot.png', + content_type: 'image/png', + data_b64: 'aGk=', + }) + }) + + it('lists assets, unwrapping the envelope and passing filters as query', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ assets: [{ id: 'a1' }] }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').listAssets({ + agent_session_id: 'session_1', + limit: 5, + }) + expect(out).toEqual([{ id: 'a1' }]) + expect(fetchMock.mock.calls[0][0]).toBe( + 'http://api.test/v1/assets?agent_session_id=session_1&limit=5', + ) + }) + + it('URL-encodes the asset id on get', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ asset: { id: 'a/1' }, url: 'u', download_url: 'd' }), + { status: 200 }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').getAsset('a/1') + expect(out.download_url).toBe('d') + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/assets/a%2F1') + }) +}) diff --git a/test/asset.test.ts b/test/asset.test.ts new file mode 100644 index 0000000..c44b8fb --- /dev/null +++ b/test/asset.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_ASSET_SIZE_BYTES, + buildUploadRequest, + formatSize, +} from '../src/commands/asset' + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +function pngBytes(extra = 8): Buffer { + return Buffer.concat([PNG_MAGIC, Buffer.alloc(extra, 1)]) +} + +describe('buildUploadRequest', () => { + it('builds the request from a valid PNG', () => { + const bytes = pngBytes() + expect(buildUploadRequest('/tmp/shots/settings.png', bytes)).toEqual({ + filename: 'settings.png', + content_type: 'image/png', + data_b64: bytes.toString('base64'), + }) + }) + + it('uses the basename, never the full path', () => { + const req = buildUploadRequest('../deep/nested/shot.png', pngBytes()) + expect(req.filename).toBe('shot.png') + }) + + it('rejects an empty file', () => { + expect(() => buildUploadRequest('empty.png', Buffer.alloc(0))).toThrow(/is empty/) + }) + + it('rejects files over the 10 MiB cap, with sizes in the message', () => { + const big = Buffer.concat([PNG_MAGIC, Buffer.alloc(MAX_ASSET_SIZE_BYTES)]) + expect(() => buildUploadRequest('big.png', big)).toThrow(/10\.0 MiB per asset/) + }) + + it('accepts a PNG exactly at the cap', () => { + const atCap = Buffer.concat([ + PNG_MAGIC, + Buffer.alloc(MAX_ASSET_SIZE_BYTES - PNG_MAGIC.length), + ]) + expect(buildUploadRequest('cap.png', atCap).content_type).toBe('image/png') + }) + + it('names the format when the bytes look like a known non-PNG', () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]) + expect(() => buildUploadRequest('shot.png', jpeg)).toThrow(/looks like JPEG/) + + const gif = Buffer.from('GIF89a-------', 'ascii') + expect(() => buildUploadRequest('shot.png', gif)).toThrow(/looks like GIF/) + + const webp = Buffer.concat([ + Buffer.from('RIFF', 'ascii'), + Buffer.alloc(4), + Buffer.from('WEBP', 'ascii'), + Buffer.alloc(4), + ]) + expect(() => buildUploadRequest('shot.png', webp)).toThrow(/looks like WebP/) + }) + + it('falls back to a generic message for unrecognized bytes', () => { + expect(() => buildUploadRequest('shot.png', Buffer.from('hello world'))).toThrow( + /bytes are not a PNG/, + ) + }) + + it('rejects a truncated PNG magic', () => { + expect(() => buildUploadRequest('shot.png', PNG_MAGIC.subarray(0, 4))).toThrow( + /not a PNG/, + ) + }) +}) + +describe('formatSize', () => { + it('renders bytes, KiB, and MiB', () => { + expect(formatSize(512)).toBe('512 B') + expect(formatSize(2048)).toBe('2.0 KiB') + expect(formatSize(10 * 1024 * 1024)).toBe('10.0 MiB') + }) + + it('uses binary units at the boundaries', () => { + expect(formatSize(1023)).toBe('1023 B') + expect(formatSize(1024)).toBe('1.0 KiB') + expect(formatSize(1024 * 1024)).toBe('1.0 MiB') + }) +})