From fb2da73c0547434bb08c624c65867eb9240c7ab4 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Fri, 22 May 2026 14:34:17 -0700 Subject: [PATCH 01/43] feat(email): show "Owlette" as sender display name All transactional/alert emails sent via the shared FROM_EMAIL constant showed the bare address (noreply@mail.owlette.app) in recipients' inboxes. Compose the from header as "Owlette " so the friendly name appears, while respecting an already-named RESEND_FROM_EMAIL value verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/lib/resendClient.server.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/lib/resendClient.server.ts b/web/lib/resendClient.server.ts index 87934e1a..cd654708 100644 --- a/web/lib/resendClient.server.ts +++ b/web/lib/resendClient.server.ts @@ -18,5 +18,15 @@ export const isProduction = process.env.NODE_ENV === 'production' && !process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN?.includes('dev'); -export const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'; +// Friendly display name shown in recipients' inboxes (the part before the address). +// External-facing, so the product name keeps its normal casing. +const FROM_NAME = 'Owlette'; + +const RESEND_FROM_ADDRESS = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'; + +// Resend accepts an RFC 5322 "Name " string. If the configured value already +// carries a display name, respect it verbatim; otherwise prepend the friendly name. +export const FROM_EMAIL = RESEND_FROM_ADDRESS.includes('<') + ? RESEND_FROM_ADDRESS + : `${FROM_NAME} <${RESEND_FROM_ADDRESS}>`; export const ENV_LABEL = isProduction ? 'PRODUCTION' : 'DEVELOPMENT'; From a0f1c3aa5518e68c173968a77ccf2fe93f468310 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Fri, 22 May 2026 15:50:28 -0700 Subject: [PATCH 02/43] fix(api): align roost publish + screenshot routes with CLI contracts versions route: deterministic content-addressed versionId (drop client-stamped createdAt); tri-state expectedCurrentVersionId CAS (string / explicit null = expect-empty / absent, 400 otherwise); 3-way publish transaction (no-op / promote-existing-version / create) with all reads before writes. screenshot command-status route: mint result.screenshot_url from the agent's result.storage_path so 'owlette machine screenshot' completes. Surfaced by the pre-publish CLI review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/sites-machines-commands.test.ts | 22 ++ web/__tests__/api/versions.test.ts | 307 ++++++++++++++++-- .../api/roosts/[roostId]/versions/route.ts | 213 +++++++++--- .../[machineId]/commands/[commandId]/route.ts | 6 +- 4 files changed, 472 insertions(+), 76 deletions(-) diff --git a/web/__tests__/api/sites-machines-commands.test.ts b/web/__tests__/api/sites-machines-commands.test.ts index 09329b16..f13f4976 100644 --- a/web/__tests__/api/sites-machines-commands.test.ts +++ b/web/__tests__/api/sites-machines-commands.test.ts @@ -856,6 +856,28 @@ describe('GET /api/sites/{siteId}/machines/{machineId}/commands/{commandId}', () expect(ttlMs).toBeLessThanOrEqual(60 * 60 * 1000 + 1000); // ≤ 1h + slack }); + it('200 completed capture_screenshot accepts agent result.storage_path', async () => { + const storagePath = `screenshots/${SITE}/${MACHINE}/1700000000000-storage-path.jpg`; + queueGetSnapshots(null, { + [CID]: { + type: 'capture_screenshot', + status: 'completed', + result: { storage_path: storagePath }, + }, + }); + const req = createMockRequest( + `http://localhost/api/sites/${SITE}/machines/${MACHINE}/commands/${CID}`, + ); + const res = await commandStatusGET(req, { + params: Promise.resolve({ siteId: SITE, machineId: MACHINE, commandId: CID }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.result.screenshot_url).toMatch(/^https:\/\/signed\.example\/read-/); + expect(fakeBucket.file).toHaveBeenLastCalledWith(storagePath); + }); + it('200 failed shape surfaces error string', async () => { queueGetSnapshots(null, { [CID]: { type: 'reboot_machine', status: 'failed', error: 'reboot blocked: kiosk lock' }, diff --git a/web/__tests__/api/versions.test.ts b/web/__tests__/api/versions.test.ts index 05659781..acdee927 100644 --- a/web/__tests__/api/versions.test.ts +++ b/web/__tests__/api/versions.test.ts @@ -3,12 +3,67 @@ import { createMockRequest } from './helpers/utils'; import { mocks, - mockDbFactory, docSnapshot, } from './helpers/firestore-mock'; const mockEmitMutation = jest.fn(); +function mockBuildCollection( + path = '', + parent: Record | null = null, +): Record { + const parts = path.split('/').filter(Boolean); + const collection: Record = { + __path: path, + id: parts[parts.length - 1] ?? path, + parent, + orderBy: mocks.orderBy, + limit: mocks.limit, + startAfter: mocks.startAfter, + where: mocks.where, + get: mocks.collectionGet, + }; + collection.doc = (id = 'auto') => mockBuildDoc(`${path}/${id}`, collection); + return collection; +} + +function mockBuildDoc( + path: string, + parent: Record | null, +): Record { + const parts = path.split('/').filter(Boolean); + const ref: Record = { + __path: path, + id: parts[parts.length - 1] ?? path, + parent, + get: () => { + if (parts.length === 2 && parts[0] === 'sites') { + if (mocks.siteDocs.has(parts[1])) { + return Promise.resolve(docSnapshot(parts[1], mocks.siteDocs.get(parts[1]) ?? null)); + } + return Promise.resolve(docSnapshot(parts[1], {})); + } + return mocks.get(); + }, + set: mocks.set, + update: mocks.update, + delete: mocks.del, + }; + ref.collection = (sub: string) => mockBuildCollection(`${path}/${sub}`, ref); + return ref; +} + +function mockPathDbFactory(): Record { + return { + collection: (name: string) => mockBuildCollection(name), + batch: () => ({ + set: mocks.batchSet, + delete: mocks.batchDelete, + commit: mocks.batchCommit, + }), + }; +} + jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn(), @@ -29,14 +84,34 @@ const txState = { versionWrites: [] as Array>, /** Captured `tx.set(roostRef, ...)` payloads, in call order. */ roostWrites: [] as Array>, + /** Stored version docs keyed by versionId for transaction reads. */ + versionDocs: new Map>(), }; +function isVersionDocWrite(payload: Record): boolean { + return Object.prototype.hasOwnProperty.call(payload, 'versionId'); +} + +function transactionSnapshotFor( + ref: unknown, + roostData: Record, +): ReturnType { + const path = + typeof (ref as { __path?: unknown }).__path === 'string' + ? ((ref as { __path: string }).__path) + : ''; + if (path.includes('/versions/')) { + const versionId = path.split('/').filter(Boolean).pop() ?? 'version'; + return docSnapshot(versionId, txState.versionDocs.get(versionId) ?? null); + } + return docSnapshot('rst_test', roostData); +} + const mockRunTransaction = jest.fn( async (cb: (tx: unknown) => Promise): Promise => { - let nthSetCall = 0; const tx = { - get: async () => - docSnapshot('rst_test', { + get: async (ref: unknown) => + transactionSnapshotFor(ref, { versionCounter: txState.versionCounter, currentVersionId: txState.currentVersionId, previousVersionId: txState.previousVersionId, @@ -44,10 +119,14 @@ const mockRunTransaction = jest.fn( targets: [], }), set: jest.fn((_ref: unknown, payload: Record) => { - // The route writes to versions sub-collection FIRST, then roost doc. - if (nthSetCall === 0) txState.versionWrites.push(payload); - else txState.roostWrites.push(payload); - nthSetCall++; + if (isVersionDocWrite(payload)) { + txState.versionWrites.push(payload); + if (typeof payload.versionId === 'string') { + txState.versionDocs.set(payload.versionId, { ...payload }); + } + } else { + txState.roostWrites.push(payload); + } }), update: jest.fn(), }; @@ -58,7 +137,7 @@ const mockRunTransaction = jest.fn( jest.mock('@/lib/firebase-admin', () => ({ getAdminDb: () => { - const base = mockDbFactory() as Record; + const base = mockPathDbFactory() as Record; return { ...base, runTransaction: mockRunTransaction }; }, getAdminAuth: () => ({ verifyIdToken: jest.fn().mockRejectedValue(new Error('n/a')) }), @@ -94,7 +173,7 @@ const SITE = 'site-alpha'; const ROOST = 'rst_test_0000000001'; const CHUNK_HASH = 'a'.repeat(64); -function buildVersionEnvelope(): Record { +function buildVersionEnvelope(hash = CHUNK_HASH): Record { // Minimal valid OCI-shaped version body. The route validates schemaVersion // + mediaType + config object + non-empty files[] with hash (64-char // lowercase hex) + positive size on every chunk. @@ -106,7 +185,7 @@ function buildVersionEnvelope(): Record { { path: 'main.toe', size: 4, - chunks: [{ hash: CHUNK_HASH, size: 4 }], + chunks: [{ hash, size: 4 }], }, ], }; @@ -125,6 +204,7 @@ beforeEach(() => { txState.previousVersionId = null; txState.versionWrites.length = 0; txState.roostWrites.length = 0; + txState.versionDocs.clear(); mocks.set.mockResolvedValue(undefined); mocks.update.mockResolvedValue(undefined); }); @@ -134,10 +214,13 @@ beforeEach(() => { /* ========================================================================== */ describe('POST /versions — version-number monotonicity', () => { - async function publish(): Promise<{ status: number; body: Record }> { + async function publish( + hash = CHUNK_HASH, + fields: Record = {}, + ): Promise<{ status: number; body: Record }> { const req = createMockRequest(`http://localhost/api/roosts/${ROOST}/versions`, { method: 'POST', - body: { siteId: SITE, version: buildVersionEnvelope() }, + body: { siteId: SITE, version: buildVersionEnvelope(hash), ...fields }, }); const res = await createPOST(req, { params: Promise.resolve({ roostId: ROOST }) }); return { status: res.status, body: (await res.json()) as Record }; @@ -194,7 +277,7 @@ describe('POST /versions — version-number monotonicity', () => { txState.previousVersionId = txState.currentVersionId; txState.currentVersionId = String(r1.body.versionId); - const r2 = await publish(); + const r2 = await publish('b'.repeat(64)); expect(r2.body.versionNumber).toBe(2); expect(txState.versionWrites[1]!.versionNumber).toBe(2); expect(txState.roostWrites[1]!.versionCounter).toBe(2); @@ -202,7 +285,7 @@ describe('POST /versions — version-number monotonicity', () => { it('three publishes in a row stay monotonic 1, 2, 3', async () => { for (const n of [1, 2, 3]) { - const r = await publish(); + const r = await publish(String.fromCharCode(96 + n).repeat(64)); expect(r.body.versionNumber).toBe(n); txState.versionCounter = n; txState.previousVersionId = txState.currentVersionId; @@ -246,12 +329,11 @@ describe('POST /versions — version-number monotonicity', () => { const versionCounterAtRead = txState.versionCounter; const currentAtRead = txState.currentVersionId; const previousAtRead = txState.previousVersionId; - let nthSetCall = 0; const pendingVersionWrites: Array> = []; const pendingRoostWrites: Array> = []; const tx = { - get: async () => - docSnapshot('rst_test', { + get: async (ref: unknown) => + transactionSnapshotFor(ref, { versionCounter: versionCounterAtRead, currentVersionId: currentAtRead, previousVersionId: previousAtRead, @@ -260,9 +342,11 @@ describe('POST /versions — version-number monotonicity', () => { }), set: jest.fn( (_ref: unknown, payload: Record) => { - if (nthSetCall === 0) pendingVersionWrites.push(payload); - else pendingRoostWrites.push(payload); - nthSetCall++; + if (isVersionDocWrite(payload)) { + pendingVersionWrites.push(payload); + } else { + pendingRoostWrites.push(payload); + } }, ), update: jest.fn(), @@ -271,7 +355,12 @@ describe('POST /versions — version-number monotonicity', () => { // CAS check: only commit if txState.versionCounter hasn't moved // since we read it. if (txState.versionCounter === versionCounterAtRead) { - for (const w of pendingVersionWrites) txState.versionWrites.push(w); + for (const w of pendingVersionWrites) { + txState.versionWrites.push(w); + if (typeof w.versionId === 'string') { + txState.versionDocs.set(w.versionId, { ...w }); + } + } for (const w of pendingRoostWrites) { txState.roostWrites.push(w); if (typeof w.versionCounter === 'number') { @@ -290,7 +379,10 @@ describe('POST /versions — version-number monotonicity', () => { }, ); - const [a, b] = await Promise.all([publish(), publish()]); + const [a, b] = await Promise.all([ + publish('a'.repeat(64)), + publish('b'.repeat(64)), + ]); // Both publishes must succeed (the loser is retried internally). expect(a.status).toBe(201); @@ -308,6 +400,175 @@ describe('POST /versions — version-number monotonicity', () => { expect(txState.versionWrites).toHaveLength(2); expect(txState.roostWrites).toHaveLength(2); }); + + it('identical content already at head is a no-op, not a new version number', async () => { + const first = await publish(); + expect(first.status).toBe(201); + expect(first.body.versionNumber).toBe(1); + + txState.versionCounter = 1; + txState.previousVersionId = null; + txState.currentVersionId = String(first.body.versionId); + mockEmitMutation.mockClear(); + mocks.batchSet.mockClear(); + + const second = await publish(); + + expect(second.status).toBe(200); + expect(second.body.versionId).toBe(first.body.versionId); + expect(second.body.versionNumber).toBe(1); + expect(second.body.previousVersionId).toBeNull(); + expect(txState.versionWrites).toHaveLength(1); + expect(txState.roostWrites).toHaveLength(1); + expect(mockEmitMutation).not.toHaveBeenCalled(); + expect(mocks.batchSet).not.toHaveBeenCalled(); + }); + + async function publishTwoVersionHistory(): Promise<{ + v1: { status: number; body: Record }; + v2: { status: number; body: Record }; + }> { + const v1 = await publish('a'.repeat(64)); + txState.versionCounter = 1; + txState.previousVersionId = null; + txState.currentVersionId = String(v1.body.versionId); + + const v2 = await publish('b'.repeat(64)); + txState.versionCounter = 2; + txState.previousVersionId = String(v1.body.versionId); + txState.currentVersionId = String(v2.body.versionId); + + return { v1, v2 }; + } + + it('promotes an existing non-current version without rewriting history', async () => { + const { v1, v2 } = await publishTwoVersionHistory(); + expect(v1.status).toBe(201); + expect(v2.status).toBe(201); + + mockEmitMutation.mockClear(); + mocks.batchSet.mockClear(); + const versionWriteCount = txState.versionWrites.length; + const versionDocCount = txState.versionDocs.size; + + const promoted = await publish('a'.repeat(64), { + name: 'promoted lobby', + targets: ['machine-1'], + extractPath: 'show/scene', + }); + + expect(promoted.status).toBe(200); + expect(promoted.body.versionId).toBe(v1.body.versionId); + expect(promoted.body.versionNumber).toBe(1); + expect(promoted.body.currentVersionId).toBe(v1.body.versionId); + expect(promoted.body.previousVersionId).toBe(v2.body.versionId); + + expect(txState.versionWrites).toHaveLength(versionWriteCount); + expect(txState.versionDocs.size).toBe(versionDocCount); + expect(txState.versionDocs.get(String(v1.body.versionId))!.versionNumber).toBe(1); + expect(txState.versionCounter).toBe(2); + + const roostWrite = txState.roostWrites[txState.roostWrites.length - 1]!; + expect(roostWrite.currentVersionId).toBe(v1.body.versionId); + expect(roostWrite.previousVersionId).toBe(v2.body.versionId); + expect(roostWrite.currentVersionNumber).toBe(1); + expect(roostWrite).not.toHaveProperty('versionCounter'); + expect(roostWrite.name).toBe('promoted lobby'); + expect(roostWrite.targets).toEqual(['machine-1']); + expect(roostWrite.extractPath).toBe('show/scene'); + + expect(mocks.batchSet).not.toHaveBeenCalled(); + expect(mockEmitMutation).toHaveBeenCalledTimes(1); + expect(mockEmitMutation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'roost_mutated', + siteId: SITE, + targetId: v1.body.versionId, + attributes: expect.objectContaining({ + verb: 'version_promote', + roostId: ROOST, + versionNumber: 1, + previousVersionId: v2.body.versionId, + }), + }), + ); + }); + + it('promote respects expectedCurrentVersionId CAS', async () => { + const { v1 } = await publishTwoVersionHistory(); + + mockEmitMutation.mockClear(); + mocks.batchSet.mockClear(); + const versionWriteCount = txState.versionWrites.length; + const roostWriteCount = txState.roostWrites.length; + + const stale = await publish('a'.repeat(64), { + expectedCurrentVersionId: v1.body.versionId, + }); + + expect(stale.status).toBe(412); + expect(stale.body.code).toBe('version_stale'); + expect(txState.versionWrites).toHaveLength(versionWriteCount); + expect(txState.roostWrites).toHaveLength(roostWriteCount); + expect(mocks.batchSet).not.toHaveBeenCalled(); + expect(mockEmitMutation).not.toHaveBeenCalled(); + }); +}); + +/* ========================================================================== */ +/* POST /versions - expectedCurrentVersionId CAS */ +/* ========================================================================== */ + +describe('POST /versions - expectedCurrentVersionId CAS', () => { + async function publish( + fields: Record = {}, + ): Promise<{ status: number; body: Record }> { + const req = createMockRequest(`http://localhost/api/roosts/${ROOST}/versions`, { + method: 'POST', + body: { siteId: SITE, version: buildVersionEnvelope(), ...fields }, + }); + const res = await createPOST(req, { params: Promise.resolve({ roostId: ROOST }) }); + return { status: res.status, body: (await res.json()) as Record }; + } + + it('accepts explicit null when the roost is still empty', async () => { + const res = await publish({ expectedCurrentVersionId: null }); + + expect(res.status).toBe(201); + expect(res.body.previousVersionId).toBeNull(); + expect(txState.versionWrites).toHaveLength(1); + expect(txState.roostWrites).toHaveLength(1); + }); + + it('rejects explicit null when a head already exists', async () => { + txState.versionCounter = 3; + txState.currentVersionId = 'vrs_existing'; + + const res = await publish({ expectedCurrentVersionId: null }); + + expect(res.status).toBe(412); + expect(res.body.code).toBe('version_stale'); + expect(txState.versionWrites).toHaveLength(0); + expect(txState.roostWrites).toHaveLength(0); + }); + + it('skips CAS when expectedCurrentVersionId is absent', async () => { + txState.versionCounter = 3; + txState.currentVersionId = 'vrs_existing'; + + const res = await publish(); + + expect(res.status).toBe(201); + expect(txState.versionWrites[0]!.parentVersionId).toBe('vrs_existing'); + expect(txState.roostWrites[0]!.previousVersionId).toBe('vrs_existing'); + }); + + it('rejects non-string non-null expectedCurrentVersionId', async () => { + const res = await publish({ expectedCurrentVersionId: 123 }); + + expect(res.status).toBe(400); + expect(mockRunTransaction).not.toHaveBeenCalled(); + }); }); /* ========================================================================== */ diff --git a/web/app/api/roosts/[roostId]/versions/route.ts b/web/app/api/roosts/[roostId]/versions/route.ts index 660ec64b..d64df237 100644 --- a/web/app/api/roosts/[roostId]/versions/route.ts +++ b/web/app/api/roosts/[roostId]/versions/route.ts @@ -272,6 +272,22 @@ export async function POST(request: NextRequest, { params }: RouteParams) { deployDescription = trimmed.length > 0 ? trimmed : null; } + const hasExpectedHead = Object.prototype.hasOwnProperty.call( + body, + 'expectedCurrentVersionId', + ); + let expectedHead: string | null | undefined; + if (typeof body.expectedCurrentVersionId === 'string') { + expectedHead = body.expectedCurrentVersionId; + } else if (hasExpectedHead && body.expectedCurrentVersionId === null) { + expectedHead = null; + } else if (hasExpectedHead && body.expectedCurrentVersionId !== undefined) { + return problemValidation( + 'expectedCurrentVersionId must be a string or null when provided', + { 'body.expectedCurrentVersionId': ['must be a string or null'] }, + ); + } + // Verify every referenced chunk exists in R2. Missing chunks = caller // didn't finish uploading; reject with a listing of missing hashes so // the client can retry the missing set via /chunks/upload-urls. @@ -317,18 +333,42 @@ export async function POST(request: NextRequest, { params }: RouteParams) { .doc(site.siteId) .collection('roosts') .doc(roostId); + const versionDocRef = roostRef.collection('versions').doc(versionId); const totalSize = m.files.reduce((n, f) => n + f.size, 0); - const expectedHead = - typeof body.expectedCurrentVersionId === 'string' - ? body.expectedCurrentVersionId - : undefined; const result = await db.runTransaction(async (tx) => { - const roostSnap = await tx.get(roostRef); + const [roostSnap, versionSnap] = await Promise.all([ + tx.get(roostRef), + tx.get(versionDocRef), + ]); const existing = roostSnap.exists ? roostSnap.data() ?? {} : {}; const currentId = (existing.currentVersionId as string | undefined) ?? null; + // Content-addressed no-op: publishing bytes that are already the + // current head must not advance versionCounter or overwrite the same + // version doc with a new versionNumber. + if (currentId === versionId) { + const existingNumber = + typeof existing.currentVersionNumber === 'number' + ? existing.currentVersionNumber + : typeof existing.versionCounter === 'number' + ? existing.versionCounter + : 0; + const previousVersionId = + typeof existing.previousVersionId === 'string' + ? existing.previousVersionId + : null; + return { + conflict: false as const, + outcome: 'noop' as const, + versionId, + versionNumber: existingNumber, + currentVersionId: versionId, + previousVersionId, + }; + } + // optimistic concurrency: if the client passed an expected head // and the current head doesn't match, 412. Prevents two operators // racing to publish over each other. Runs inside the tx so the @@ -338,6 +378,87 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return { conflict: true as const, currentId }; } + // Always overwrite name/targets/extractPath when the client provides + // them (each deploy is an explicit re-statement of intent). If the + // client omits them, retain existing values - this lets a rollback + // or version-only republish keep the prior config. + const nameField = + deployName !== undefined + ? { name: deployName } + : existing.name !== undefined + ? {} + : { name: roostId }; + const targetsField = + deployTargets !== undefined + ? { targets: deployTargets } + : existing.targets !== undefined + ? {} + : { targets: [] }; + const extractPathField = + deployExtractPath !== undefined ? { extractPath: deployExtractPath } : {}; + + // Content-addressed promote: the requested content already exists in + // history but is not the current head. Move only the roost pointer and + // denormalised current-version summary; keep the historical version doc + // immutable and do not advance versionCounter. + if (versionSnap.exists) { + const existingVersion = versionSnap.data() ?? {}; + const existingVersionNumber = + typeof existingVersion.versionNumber === 'number' + ? existingVersion.versionNumber + : 0; + const existingDescription = + typeof existingVersion.description === 'string' + ? existingVersion.description + : null; + const existingVersionUrl = + typeof existingVersion.versionUrl === 'string' + ? existingVersion.versionUrl + : versionUrl; + const existingTotalFiles = + typeof existingVersion.totalFiles === 'number' + ? existingVersion.totalFiles + : m.files.length; + const existingTotalSize = + typeof existingVersion.totalSize === 'number' + ? existingVersion.totalSize + : totalSize; + + tx.set( + roostRef, + { + schemaVersion: 2, + currentVersionId: versionId, + currentVersionNumber: existingVersionNumber, + currentVersionDescription: existingDescription, + previousVersionId: currentId, + versionUrl: existingVersionUrl, + totalFiles: existingTotalFiles, + totalSize: existingTotalSize, + updatedAt: FieldValue.serverTimestamp(), + ...nameField, + ...targetsField, + ...extractPathField, + ...(roostSnap.exists + ? {} + : { + createdAt: FieldValue.serverTimestamp(), + createdBy: auth.userId, + }), + }, + { merge: true }, + ); + + return { + conflict: false as const, + outcome: 'promote' as const, + versionId, + versionNumber: existingVersionNumber, + currentVersionId: versionId, + previousVersionId: currentId, + }; + } + // Monotonic 1-indexed version number per roost. starts at 0 (new // roost, no versions yet) so the first publish lands as v1. The // counter only advances inside a successful tx — if the tx retries @@ -347,7 +468,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) { typeof existing.versionCounter === 'number' ? existing.versionCounter : 0; const nextNumber = currentCounter + 1; - const versionDocRef = roostRef.collection('versions').doc(versionId); tx.set(versionDocRef, { versionId, versionNumber: nextNumber, @@ -360,25 +480,6 @@ export async function POST(request: NextRequest, { params }: RouteParams) { parentVersionId: currentId, }); - // Always overwrite name/targets/extractPath when the client provides - // them (each deploy is an explicit re-statement of intent). If the - // client omits them, retain existing values — this lets a rollback - // or version-only republish keep the prior config. - const nameField = - deployName !== undefined - ? { name: deployName } - : existing.name !== undefined - ? {} - : { name: roostId }; - const targetsField = - deployTargets !== undefined - ? { targets: deployTargets } - : existing.targets !== undefined - ? {} - : { targets: [] }; - const extractPathField = - deployExtractPath !== undefined ? { extractPath: deployExtractPath } : {}; - tx.set( roostRef, { @@ -413,6 +514,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return { conflict: false as const, + outcome: 'create' as const, versionId, versionNumber: nextNumber, currentVersionId: versionId, @@ -433,15 +535,17 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); } - await writeVersionChunkReferrers( - db, - site.siteId, - roostId, - result.versionId, - result.versionNumber, - auth.userId, - chunkReferrers, - ); + if (result.outcome === 'create') { + await writeVersionChunkReferrers( + db, + site.siteId, + roostId, + result.versionId, + result.versionNumber, + auth.userId, + chunkReferrers, + ); + } const response = applyAuthDeprecations( NextResponse.json( @@ -451,28 +555,33 @@ export async function POST(request: NextRequest, { params }: RouteParams) { currentVersionId: result.currentVersionId, previousVersionId: result.previousVersionId, }, - { status: 201 }, + { status: result.outcome === 'create' ? 201 : 200 }, ), auth.scopeCheck, ); if (idem.mode === 'proceed') await saveIdempotency(idem.token, response); - emitMutation({ - kind: 'roost_mutated', - siteId: site.siteId, - actor: auditActorIdentifier(auth.auth), - targetId: result.versionId, - attributes: { - verb: 'version_publish', - endpoint: request.nextUrl.pathname, - method: request.method, - roostId, - versionNumber: result.versionNumber, - previousVersionId: result.previousVersionId, - totalFiles: m.files.length, - totalSize, - hasDescription: deployDescription !== null, - }, - }); + if (result.outcome !== 'noop') { + emitMutation({ + kind: 'roost_mutated', + siteId: site.siteId, + actor: auditActorIdentifier(auth.auth), + targetId: result.versionId, + attributes: { + verb: + result.outcome === 'promote' + ? 'version_promote' + : 'version_publish', + endpoint: request.nextUrl.pathname, + method: request.method, + roostId, + versionNumber: result.versionNumber, + previousVersionId: result.previousVersionId, + totalFiles: m.files.length, + totalSize, + hasDescription: deployDescription !== null, + }, + }); + } return response; } catch (err) { return problemFromError(err, 'v2/roosts/[roostId]/versions (POST)'); diff --git a/web/app/api/sites/[siteId]/machines/[machineId]/commands/[commandId]/route.ts b/web/app/api/sites/[siteId]/machines/[machineId]/commands/[commandId]/route.ts index 466472a7..4ed52680 100644 --- a/web/app/api/sites/[siteId]/machines/[machineId]/commands/[commandId]/route.ts +++ b/web/app/api/sites/[siteId]/machines/[machineId]/commands/[commandId]/route.ts @@ -94,7 +94,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) { ? cmd.screenshot_path : typeof baseResult.screenshot_path === 'string' ? (baseResult.screenshot_path as string) - : null; + : typeof baseResult.storage_path === 'string' + ? (baseResult.storage_path as string) + : typeof cmd.storage_path === 'string' + ? cmd.storage_path + : null; if (storagePath) { const signed = await issueScreenshotReadUrl(storagePath); if (signed) { From 2a6747626b3dee0c9da92e8f101b79ffaa8a8934 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Fri, 22 May 2026 15:50:29 -0700 Subject: [PATCH 03/43] fix(cli): pre-publish hardening of @owlette/cli (6-wave review) Remove the non-functional 'key' commands (key management is session/dashboard-only). Fix machine screenshot, chat send, roost push (CAS + idempotency + deterministic manifest), rollback, deploy, process, listen, trigger, audit-log, quota. Cross-cutting: consistent exit codes (2 = local usage error), idempotency-key safety + lost-response surfacing on mutating commands, request timeouts on non-streaming calls, shell-safe browser open, shared http/output helpers. ~3 critical + ~30 major issues fixed; 256 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/__tests__/commands/audit-log-http.test.ts | 75 +++ cli/__tests__/commands/chat-http.test.ts | 71 +++ cli/__tests__/commands/deploy-http.test.ts | 34 ++ .../commands/installer-deploy-http.test.ts | 41 +- cli/__tests__/commands/installer-http.test.ts | 24 +- cli/__tests__/commands/key-http.test.ts | 187 ------- .../commands/machine-mutations-http.test.ts | 69 ++- cli/__tests__/commands/process-http.test.ts | 56 ++- cli/__tests__/commands/push-http.test.ts | 348 +++++++++++++ cli/__tests__/commands/quota-http.test.ts | 14 + cli/__tests__/commands/rollback-http.test.ts | 126 +++++ cli/__tests__/commands/trigger-http.test.ts | 24 +- cli/__tests__/commands/user-http.test.ts | 41 +- cli/__tests__/configWriter.test.ts | 14 + cli/__tests__/key-parse.test.ts | 133 ----- cli/__tests__/output.test.ts | 38 ++ cli/__tests__/versionBuilder.test.ts | 11 +- cli/src/commands/audit-log.ts | 31 +- cli/src/commands/auth.ts | 26 +- cli/src/commands/chat.ts | 214 +++++--- cli/src/commands/deploy.ts | 163 ++++-- cli/src/commands/installer.ts | 131 +++-- cli/src/commands/key.ts | 464 ------------------ cli/src/commands/listen.ts | 5 + cli/src/commands/machine.ts | 122 +++-- cli/src/commands/process.ts | 176 +++++-- cli/src/commands/push.ts | 265 +++++++++- cli/src/commands/quota.ts | 15 +- cli/src/commands/rollback.ts | 93 +++- cli/src/commands/roost-deploy.ts | 50 +- cli/src/commands/roost.ts | 15 +- cli/src/commands/site.ts | 5 +- cli/src/commands/trigger.ts | 19 +- cli/src/commands/user.ts | 205 +++++--- cli/src/commands/version.ts | 3 +- cli/src/commands/whoami.ts | 3 +- cli/src/configWriter.ts | 6 +- cli/src/index.ts | 2 - cli/src/lib/http.ts | 26 + cli/src/lib/output.ts | 27 + cli/src/lib/versionBuilder.ts | 23 +- docs/cli/overview.md | 7 +- docs/cli/readiness.md | 8 +- docs/cli/reference/auth.md | 4 +- docs/cli/reference/key.md | 136 +---- docs/cli/reference/listen.md | 4 +- docs/cli/reference/machine.md | 19 +- docs/cli/reference/rollback.md | 9 +- docs/cli/reference/whoami.md | 2 +- 49 files changed, 2198 insertions(+), 1386 deletions(-) delete mode 100644 cli/__tests__/commands/key-http.test.ts create mode 100644 cli/__tests__/commands/push-http.test.ts create mode 100644 cli/__tests__/commands/rollback-http.test.ts delete mode 100644 cli/__tests__/key-parse.test.ts delete mode 100644 cli/src/commands/key.ts create mode 100644 cli/src/lib/http.ts diff --git a/cli/__tests__/commands/audit-log-http.test.ts b/cli/__tests__/commands/audit-log-http.test.ts index a007213a..fea101f9 100644 --- a/cli/__tests__/commands/audit-log-http.test.ts +++ b/cli/__tests__/commands/audit-log-http.test.ts @@ -182,6 +182,81 @@ describe('owlette audit-log list', () => { expect(calls[0]!.url).toContain('page_token=tok_42'); }); + + it('returns last emitted hash as nextPageToken when --limit stops inside a page', async () => { + const firstHash = 'a'.repeat(64); + const secondHash = 'b'.repeat(64); + installFetchStub({ + siteId: 'site-1', + records: [ + { + hash: firstHash, + kind: 'api_key_used', + actor: 'u_1', + siteId: 'site-1', + occurredAt: 1, + recordedAt: 1, + attributes: {}, + }, + { + hash: secondHash, + kind: 'api_key_used', + actor: 'u_1', + siteId: 'site-1', + occurredAt: 2, + recordedAt: 2, + attributes: {}, + }, + ], + nextPageToken: 'server-end-of-page', + }); + const writes: string[] = []; + (process.stdout.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + writes.push(chunk); + return true; + }); + const program = buildProgram(); + + await program.parseAsync( + ['--json', 'audit-log', 'list', '--site', 'site-1', '--limit', '1'], + { from: 'user' }, + ); + + const out = JSON.parse(writes.join('')) as { nextPageToken: string }; + expect(out.nextPageToken).toBe(firstHash); + }); + + it('prints the full record hash in table output', async () => { + const hash = 'c'.repeat(64); + installFetchStub({ + siteId: 'site-1', + records: [ + { + hash, + kind: 'api_key_used', + actor: 'u_1', + siteId: 'site-1', + occurredAt: 1, + recordedAt: 1, + attributes: {}, + }, + ], + nextPageToken: '', + }); + const writes: string[] = []; + (process.stdout.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + writes.push(chunk); + return true; + }); + const program = buildProgram(); + + await program.parseAsync( + ['audit-log', 'list', '--site', 'site-1'], + { from: 'user' }, + ); + + expect(writes.join('')).toContain(hash); + }); }); describe('owlette audit-log get', () => { diff --git a/cli/__tests__/commands/chat-http.test.ts b/cli/__tests__/commands/chat-http.test.ts index 0ef10a42..bc3106c1 100644 --- a/cli/__tests__/commands/chat-http.test.ts +++ b/cli/__tests__/commands/chat-http.test.ts @@ -81,6 +81,7 @@ beforeEach(() => { process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; process.env.OWLETTE_API_URL = 'https://dev.test'; process.env.OWLETTE_PROFILE = 'default'; + process.exitCode = 0; jest.spyOn(process.stdout, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true); }); @@ -89,6 +90,7 @@ afterEach(() => { delete process.env.OWLETTE_TOKEN; delete process.env.OWLETTE_API_URL; delete process.env.OWLETTE_PROFILE; + process.exitCode = 0; jest.restoreAllMocks(); }); @@ -256,6 +258,17 @@ describe('owlette chat list', () => { expect(out.conversations).toEqual(conversations); expect(out.nextPageToken).toBe('next-1'); }); + + it('rejects invalid --limit with exit 2 before firing fetch', async () => { + const calls = installFetchStub({}); + const program = buildProgram(); + await program.parseAsync( + ['chat', 'list', '--site', 'site-1', '--limit', 'banana'], + { from: 'user' }, + ); + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + }); }); /* -------------------- send -------------------- */ @@ -302,6 +315,31 @@ describe('owlette chat send', () => { expect(out.endsWith('\n')).toBe(true); }); + it('flushes AI SDK UI-message SSE text deltas to stdout as they arrive', async () => { + installStreamingFetchStub([ + `data: {"type":"text-start","id":"txt1"}\n\n`, + `data: {"type":"text-delta","id":"txt1","delta":"hello "}\n\n`, + `data: {"type":"text-delta","id":"txt1","delta":"world"}\n\n`, + `data: {"type":"finish"}\n\n`, + `data: [DONE]\n\n`, + ]); + const writes: string[] = []; + (process.stdout.write as unknown as jest.Mock).mockImplementation( + (chunk: string) => { + writes.push(chunk); + return true; + }, + ); + const program = buildProgram(); + await program.parseAsync( + ['chat', 'send', 'conv_1', 'hi'], + { from: 'user' }, + ); + const out = writes.join(''); + expect(out).toContain('hello world'); + expect(out.endsWith('\n')).toBe(true); + }); + it('aggregates the full reply into the json envelope in --json mode', async () => { installStreamingFetchStub([ `0:"step 1 "\n`, @@ -323,6 +361,39 @@ describe('owlette chat send', () => { const out = JSON.parse(writes.join('')); expect(out).toEqual({ conversationId: 'conv_1', content: 'step 1 step 2' }); }); + + it('tells users to inspect the conversation after an unconfirmed send failure', async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => { + throw new Error('network timeout'); + }); + const stderr: string[] = []; + (process.stderr.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + stderr.push(chunk); + return true; + }); + + const program = buildProgram(); + await program.parseAsync( + [ + 'chat', + 'send', + 'conv_1', + 'hi', + '--idempotency-key', + 'chat-send-key', + ], + { from: 'user' }, + ); + + const err = stderr.join(''); + expect(err).toContain('did not return a confirmed response'); + expect(err).toContain('inspect the conversation before retrying'); + expect(err).toContain('owlette chat list'); + expect(err).toContain('may append the message twice'); + expect(err).not.toContain('retry safely with:'); + expect(err).not.toContain('Idempotency-Key:'); + expect(process.exitCode).toBe(1); + }); }); /* -------------------- delete -------------------- */ diff --git a/cli/__tests__/commands/deploy-http.test.ts b/cli/__tests__/commands/deploy-http.test.ts index ad3c3c89..b551d965 100644 --- a/cli/__tests__/commands/deploy-http.test.ts +++ b/cli/__tests__/commands/deploy-http.test.ts @@ -43,6 +43,7 @@ beforeEach(() => { process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; process.env.OWLETTE_API_URL = 'https://dev.test'; process.env.OWLETTE_PROFILE = 'default'; + process.exitCode = 0; jest.spyOn(process.stdout, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true); }); @@ -51,6 +52,7 @@ afterEach(() => { delete process.env.OWLETTE_TOKEN; delete process.env.OWLETTE_API_URL; delete process.env.OWLETTE_PROFILE; + process.exitCode = 0; jest.restoreAllMocks(); }); @@ -112,6 +114,38 @@ describe('owlette roost deploy (dry-run)', () => { expect(headers['Idempotency-Key']).toMatch(/^cli-deploy-/); }); + it('does not surface idempotency retry guidance for dry-run unconfirmed failures', async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => { + throw new Error('socket timeout'); + }); + const stderr: string[] = []; + (process.stderr.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + stderr.push(chunk); + return true; + }); + + const program = buildProgram(); + await program.parseAsync( + [ + 'roost', + 'deploy', + 'rst_testrs01234', + '--site', + 'site-1', + '--dry-run', + '--idempotency-key', + 'dry-run-key', + ], + { from: 'user' }, + ); + + const err = stderr.join(''); + expect(err).toContain('POST /api/roosts/rst_testrs01234/deploy failed'); + expect(err).not.toContain('Idempotency-Key:'); + expect(err).not.toContain('retry safely with:'); + expect(process.exitCode).toBe(1); + }); + it('respects --version, --machines and --at overrides', async () => { const calls = installFetchStub({ rolloutId: 'vrs_01', diff --git a/cli/__tests__/commands/installer-deploy-http.test.ts b/cli/__tests__/commands/installer-deploy-http.test.ts index 16d248c1..f8b9ec32 100644 --- a/cli/__tests__/commands/installer-deploy-http.test.ts +++ b/cli/__tests__/commands/installer-deploy-http.test.ts @@ -237,6 +237,43 @@ describe('owlette deploy create', () => { { from: 'user' }, ); expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + }); + + it('surfaces the idempotency key and retry command on unconfirmed failure', async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => { + throw new Error('request timed out after 30000ms'); + }); + const stderr: string[] = []; + (process.stderr.write as unknown as jest.Mock).mockImplementation((c: string | Uint8Array) => { + stderr.push(typeof c === 'string' ? c : Buffer.from(c).toString('utf-8')); + return true; + }); + const program = buildProgram(); + await program.parseAsync( + [ + 'deploy', + 'create', + '--site', + 'site-1', + '--name', + 'r', + '--installer-url', + 'https://cdn/x.exe', + '--installer-name', + 'x.exe', + '--silent-flags', + '/S', + '--machines', + 'm-1', + ], + { from: 'user' }, + ); + const out = stderr.join(''); + expect(out).toContain('did not return a confirmed response'); + expect(out).toContain('Idempotency-Key: cli-deploy-create-'); + expect(out).toContain('re-run your original command with `--idempotency-key cli-deploy-create-'); + expect(out).not.toContain('owlette deploy create'); expect(process.exitCode).toBe(1); }); }); @@ -463,7 +500,7 @@ describe('owlette deploy uninstall', () => { from: 'user', }); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); + expect(process.exitCode).toBe(2); } finally { if (isTTY) Object.defineProperty(process.stdin, 'isTTY', isTTY); } @@ -504,7 +541,7 @@ describe('owlette deploy delete', () => { from: 'user', }); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); + expect(process.exitCode).toBe(2); } finally { if (isTTY) Object.defineProperty(process.stdin, 'isTTY', isTTY); } diff --git a/cli/__tests__/commands/installer-http.test.ts b/cli/__tests__/commands/installer-http.test.ts index 44de6717..2cfe6817 100644 --- a/cli/__tests__/commands/installer-http.test.ts +++ b/cli/__tests__/commands/installer-http.test.ts @@ -145,6 +145,14 @@ describe('owlette installer list', () => { expect(out).toContain('hint:'); expect(process.exitCode).toBe(1); }); + + it('rejects a non-numeric --limit with exit 2 before firing fetch', async () => { + const calls = installFetchStub({}); + const program = buildProgram(); + await program.parseAsync(['installer', 'list', '--limit', 'banana'], { from: 'user' }); + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + }); }); /* --------------------------------------------------------------------- */ @@ -335,7 +343,7 @@ describe('owlette installer set-latest', () => { const program = buildProgram(); await program.parseAsync(['installer', 'set-latest', '2.11.0'], { from: 'user' }); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); + expect(process.exitCode).toBe(2); } finally { if (isTTY) Object.defineProperty(process.stdin, 'isTTY', isTTY); } @@ -402,4 +410,18 @@ describe('owlette installer delete', () => { expect(out).toContain('already deleted'); expect(process.exitCode).toBe(0); }); + + it('refuses to run silently without --yes when stdin is not a tty using exit 2', async () => { + const calls = installFetchStub({}, 200); + const isTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + try { + const program = buildProgram(); + await program.parseAsync(['installer', 'delete', '2.10.0'], { from: 'user' }); + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + } finally { + if (isTTY) Object.defineProperty(process.stdin, 'isTTY', isTTY); + } + }); }); diff --git a/cli/__tests__/commands/key-http.test.ts b/cli/__tests__/commands/key-http.test.ts deleted file mode 100644 index 5888d3f3..00000000 --- a/cli/__tests__/commands/key-http.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Command } from 'commander'; -import { registerKeyCommands } from '../../src/commands/key'; -import { _resetConfigCache } from '../../src/config'; - -function buildProgram(): Command { - const program = new Command(); - program.name('owlette').exitOverride().option('--profile ').option('--json'); - registerKeyCommands(program); - return program; -} - -interface FetchCall { - url: string; - init: RequestInit; -} - -function installFetchStub(payload: unknown, status = 200): FetchCall[] { - const calls: FetchCall[] = []; - (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( - async (url: string, init: RequestInit = {}) => { - calls.push({ url, init }); - return { - ok: status >= 200 && status < 300, - status, - json: async () => payload, - text: async () => JSON.stringify(payload), - } as Response; - }, - ); - return calls; -} - -let originalFetch: typeof global.fetch; -beforeAll(() => { - originalFetch = global.fetch; -}); -afterAll(() => { - global.fetch = originalFetch; -}); - -beforeEach(() => { - _resetConfigCache(); - process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; - process.env.OWLETTE_API_URL = 'https://dev.test'; - process.env.OWLETTE_PROFILE = 'default'; - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); -}); - -afterEach(() => { - delete process.env.OWLETTE_TOKEN; - delete process.env.OWLETTE_API_URL; - delete process.env.OWLETTE_PROFILE; - jest.restoreAllMocks(); -}); - -describe('owlette key list', () => { - it('GETs /api/keys with Bearer auth', async () => { - const calls = installFetchStub({ success: true, keys: [] }); - const program = buildProgram(); - await program.parseAsync(['--json', 'key', 'list'], { from: 'user' }); - expect(calls[0]!.url).toBe('https://dev.test/api/keys'); - const headers = calls[0]!.init.headers as Record; - expect(headers.Authorization).toBe('Bearer owk_live_testtoken'); - }); -}); - -describe('owlette key create', () => { - it('POSTs /api/keys with name/scopes/ttl/environment from --preset', async () => { - const calls = installFetchStub({ - success: true, - key: 'owk_live_NEW', - keyId: 'k1', - name: 'ci', - environment: 'live', - scopes: [], - expiresAt: Date.now() + 60_000, - keyPrefix: 'owk_live_NEWXX', - }); - const program = buildProgram(); - await program.parseAsync( - [ - '--json', - 'key', - 'create', - '--name', - 'ci', - '--preset', - 'publisher', - '--ttl-days', - '30', - '--environment', - 'live', - ], - { from: 'user' }, - ); - expect(calls[0]!.url).toBe('https://dev.test/api/keys'); - expect(calls[0]!.init.method).toBe('POST'); - const body = JSON.parse(String(calls[0]!.init.body)); - expect(body.name).toBe('ci'); - expect(body.ttlDays).toBe(30); - expect(body.environment).toBe('live'); - // publisher preset expands to wildcard scopes over common CLI resource types - expect(body.scopes).toHaveLength(4); - expect(body.scopes.map((s: { resource: string }) => s.resource).sort()).toEqual([ - 'chat', - 'machine', - 'roost', - 'site', - ]); - expect(body.scopes.every((s: { id: string }) => s.id === '*')).toBe(true); - const perms = new Set(); - for (const s of body.scopes as Array<{ permissions: string[] }>) { - for (const p of s.permissions) perms.add(p); - } - expect([...perms].sort()).toEqual(['read', 'write']); - }); - - it('POSTs /api/keys with body scopes from --scope', async () => { - const calls = installFetchStub({ - success: true, - key: 'owk_live_NEW', - keyId: 'k1', - name: 'ci', - environment: 'live', - scopes: [], - expiresAt: Date.now() + 60_000, - keyPrefix: 'owk_live_NEWXX', - }); - const program = buildProgram(); - await program.parseAsync( - [ - '--json', - 'key', - 'create', - '--name', - 'ci', - '--scope', - 'roost=rst_testrs01234:write,deploy', - '--scope', - 'site=*:read', - ], - { from: 'user' }, - ); - const body = JSON.parse(String(calls[0]!.init.body)); - expect(body.scopes).toEqual([ - { resource: 'roost', id: 'rst_testrs01234', permissions: ['write', 'deploy'] }, - { resource: 'site', id: '*', permissions: ['read'] }, - ]); - }); -}); - -describe('owlette key rotate', () => { - it('POSTs /api/keys/:id/rotate with ttlDays body', async () => { - const calls = installFetchStub({ - success: true, - key: 'owk_live_ROTATED', - keyId: 'k-new', - name: 'ci', - environment: 'live', - scopes: [], - expiresAt: Date.now() + 60_000, - rotatedFromKeyId: 'k-old', - previousKey: { keyId: 'k-old', retiresAt: Date.now() + 24 * 60 * 60 * 1000 }, - }); - const program = buildProgram(); - await program.parseAsync( - ['--json', 'key', 'rotate', 'k-old', '--ttl-days', '180'], - { from: 'user' }, - ); - expect(calls[0]!.url).toBe('https://dev.test/api/keys/k-old/rotate'); - expect(calls[0]!.init.method).toBe('POST'); - expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ ttlDays: 180 }); - }); -}); - -describe('owlette key revoke', () => { - it('DELETEs /api/keys/:id when --yes is supplied', async () => { - const calls = installFetchStub({ success: true }); - const program = buildProgram(); - await program.parseAsync( - ['--json', 'key', 'revoke', 'k-doomed', '--yes'], - { from: 'user' }, - ); - expect(calls[0]!.url).toBe('https://dev.test/api/keys/k-doomed'); - expect(calls[0]!.init.method).toBe('DELETE'); - }); -}); diff --git a/cli/__tests__/commands/machine-mutations-http.test.ts b/cli/__tests__/commands/machine-mutations-http.test.ts index 96070659..974603b1 100644 --- a/cli/__tests__/commands/machine-mutations-http.test.ts +++ b/cli/__tests__/commands/machine-mutations-http.test.ts @@ -87,6 +87,7 @@ beforeEach(() => { process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; process.env.OWLETTE_API_URL = 'https://dev.test'; process.env.OWLETTE_PROFILE = 'default'; + process.exitCode = 0; jest.spyOn(process.stdout, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true); // The screenshot polling loop sleeps between attempts. Replace @@ -106,6 +107,7 @@ afterEach(() => { delete process.env.OWLETTE_TOKEN; delete process.env.OWLETTE_API_URL; delete process.env.OWLETTE_PROFILE; + process.exitCode = 0; jest.restoreAllMocks(); }); @@ -245,7 +247,7 @@ describe('owlette machine screenshot', () => { '--site', 'site-1', '--monitor', - 'primary', + '1', '--output', outPath, ], @@ -259,7 +261,7 @@ describe('owlette machine screenshot', () => { const queueBody = JSON.parse(String(calls[0]!.init.body)); expect(queueBody).toEqual({ type: 'capture_screenshot', - params: { monitor: 'primary' }, + params: { monitor: 1 }, }); expect(calls[1]!.url).toBe(`${COMMANDS_URL}/cmd_xyz`); expect((calls[1]!.init.method ?? 'GET').toUpperCase()).toBe('GET'); @@ -415,6 +417,36 @@ describe('owlette machine screenshot', () => { process.exitCode = 0; }); + it('surfaces screenshot read+write scope hint on scope_insufficient', async () => { + installFetchStub(() => ({ + status: 403, + payload: { + type: 'about:blank', + title: 'scope_insufficient', + status: 403, + code: 'scope_insufficient', + detail: 'API key is missing machine=m-1:write scope', + }, + })); + const stderr: string[] = []; + (process.stderr.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + stderr.push(chunk); + return true; + }); + const program = buildProgram(); + + await program.parseAsync( + ['machine', 'screenshot', 'm-1', '--site', 'site-1'], + { from: 'user' }, + ); + + const errOut = stderr.join(''); + expect(errOut).toContain('code=scope_insufficient'); + expect(errOut).toContain('screenshot requires both machine=:write and machine=:read scopes'); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); + it('times out and exits 1 after MAX_ATTEMPTS poll attempts of pending status', async () => { const calls = installFetchStub((call, idx) => { if (idx === 0) { @@ -471,8 +503,31 @@ describe('owlette machine screenshot', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); + }); + + it('rejects named --monitor values before issuing http', async () => { + const calls = installFetchStub(() => ({ + status: 202, + payload: { ok: true, data: { commandId: 'cmd_xyz', status: 'pending' } }, + })); + const program = buildProgram(); + + await program.parseAsync( + [ + 'machine', + 'screenshot', + 'm-1', + '--site', + 'site-1', + '--monitor', + 'primary', + ], + { from: 'user' }, + ); + + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); }); }); @@ -481,9 +536,9 @@ describe('owlette machine screenshot', () => { /* -------------------------------------------------------------------- */ describe('machine helpers', () => { - it('parseMonitorOpt accepts all|primary|integer and rejects bad input', () => { - expect(machineInternals.parseMonitorOpt('all')).toBe('all'); - expect(machineInternals.parseMonitorOpt('primary')).toBe('primary'); + it('parseMonitorOpt accepts non-negative integers and rejects named values', () => { + expect(String(machineInternals.parseMonitorOpt('all'))).toMatch(/^error:/); + expect(String(machineInternals.parseMonitorOpt('primary'))).toMatch(/^error:/); expect(machineInternals.parseMonitorOpt('0')).toBe(0); expect(machineInternals.parseMonitorOpt('3')).toBe(3); expect(String(machineInternals.parseMonitorOpt('-1'))).toMatch(/^error:/); diff --git a/cli/__tests__/commands/process-http.test.ts b/cli/__tests__/commands/process-http.test.ts index f1fc0e01..4088ee2d 100644 --- a/cli/__tests__/commands/process-http.test.ts +++ b/cli/__tests__/commands/process-http.test.ts @@ -67,6 +67,7 @@ beforeEach(() => { process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; process.env.OWLETTE_API_URL = 'https://dev.test'; process.env.OWLETTE_PROFILE = 'default'; + process.exitCode = 0; jest.spyOn(process.stdout, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true); // Ensure `process delete` without --yes on a non-tty bails (we test @@ -77,6 +78,7 @@ afterEach(() => { delete process.env.OWLETTE_TOKEN; delete process.env.OWLETTE_API_URL; delete process.env.OWLETTE_PROFILE; + process.exitCode = 0; jest.restoreAllMocks(); }); @@ -274,8 +276,7 @@ describe('owlette process create', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); }); @@ -324,8 +325,7 @@ describe('owlette process update', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); }); @@ -372,8 +372,7 @@ describe('owlette process delete', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); }); @@ -428,6 +427,42 @@ describe.each([ expect(k1).toMatch(new RegExp(`^cli-process-${verb}-`)); expect(k2).toMatch(new RegExp(`^cli-process-${verb}-`)); }); + + it(`surfaces the idempotency key and append-only retry guidance when ${verb} is unconfirmed`, async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => { + throw new Error('socket hang up'); + }); + const stderr: string[] = []; + (process.stderr.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + stderr.push(chunk); + return true; + }); + const program = buildProgram(); + + await program.parseAsync( + [ + 'process', + verb, + 'proc_abc', + '--site', + 'site-1', + '--machine', + 'm-1', + '--idempotency-key', + 'pinned-process-key', + ], + { from: 'user' }, + ); + + const errOut = stderr.join(''); + expect(errOut).toContain('did not return a confirmed response'); + expect(errOut).toContain('Idempotency-Key: pinned-process-key'); + expect(errOut).toContain( + 're-run your original command with `--idempotency-key pinned-process-key` appended', + ); + expect(errOut).not.toContain(`owlette process ${verb} proc_abc`); + expect(process.exitCode).toBe(1); + }); }); /* -------------------------------------------------------------------- */ @@ -518,8 +553,7 @@ describe('owlette process schedule', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); it('rejects an invalid --mode value before issuing http', async () => { @@ -542,8 +576,7 @@ describe('owlette process schedule', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); it('rejects --blocks that is not valid json before issuing http', async () => { @@ -568,8 +601,7 @@ describe('owlette process schedule', () => { ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); }); diff --git a/cli/__tests__/commands/push-http.test.ts b/cli/__tests__/commands/push-http.test.ts new file mode 100644 index 00000000..ba392d4b --- /dev/null +++ b/cli/__tests__/commands/push-http.test.ts @@ -0,0 +1,348 @@ +import { _internals as pushInternals } from '../../src/commands/push'; +import { createHash } from 'crypto'; +import { buildVersion, versionIdForVersion } from '../../src/lib/versionBuilder'; + +interface FetchCall { + url: string; + init: RequestInit; +} + +function jsonResponse(status: number, payload: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => payload, + text: async () => JSON.stringify(payload), + } as Response; +} + +function publishInput() { + return { + apiUrl: 'https://dev.test', + token: 'owk_live_testtoken', + siteId: 'site-1', + roostId: 'rst_new', + version: buildVersion({ files: [], cliVersion: 'test' }), + }; +} + +let originalFetch: typeof global.fetch; + +beforeAll(() => { + originalFetch = global.fetch; +}); + +afterAll(() => { + global.fetch = originalFetch; +}); + +afterEach(() => { + process.exitCode = 0; + jest.restoreAllMocks(); +}); + +describe('push publishWithRetry', () => { + it('publishes a first version when the roost head read returns 404', async () => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return jsonResponse(404, { detail: 'roost not found' }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const body = JSON.parse(String(init.body)); + expect(body.expectedCurrentVersionId).toBeNull(); + return jsonResponse(201, { + versionId: 'vrs_first', + versionNumber: 1, + currentVersionId: 'vrs_first', + previousVersionId: null, + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await pushInternals.publishWithRetry(publishInput()); + + expect(result.versionId).toBe('vrs_first'); + expect(calls).toHaveLength(2); + }); + + it.each([ + ['null head', { currentVersionId: null }], + ['absent head', {}], + ])( + 'sends expect-empty when the roost head read returns 200 with %s', + async (_case, headBody) => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return jsonResponse(200, headBody); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const body = JSON.parse(String(init.body)); + expect(body.expectedCurrentVersionId).toBeNull(); + return jsonResponse(201, { + versionId: 'vrs_empty', + versionNumber: 1, + currentVersionId: 'vrs_empty', + previousVersionId: null, + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await pushInternals.publishWithRetry(publishInput()); + + expect(result.versionId).toBe('vrs_empty'); + expect(calls).toHaveLength(2); + }, + ); + + it('publishes when a write-only key gets 403 reading the roost head', async () => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return jsonResponse(403, { detail: 'missing roost:read' }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const body = JSON.parse(String(init.body)); + expect(body.expectedCurrentVersionId).toBeUndefined(); + return jsonResponse(201, { + versionId: 'vrs_write_only', + versionNumber: 2, + currentVersionId: 'vrs_write_only', + previousVersionId: 'vrs_first', + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await pushInternals.publishWithRetry(publishInput()); + + expect(result.versionId).toBe('vrs_write_only'); + expect(calls).toHaveLength(2); + }); + + it('omits expectedCurrentVersionId when the roost head read is unknown', async () => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return jsonResponse(500, { detail: 'temporary failure' }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const body = JSON.parse(String(init.body)); + expect(body.expectedCurrentVersionId).toBeUndefined(); + return jsonResponse(201, { + versionId: 'vrs_unknown_head', + versionNumber: 2, + currentVersionId: 'vrs_unknown_head', + previousVersionId: 'vrs_first', + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await pushInternals.publishWithRetry(publishInput()); + + expect(result.versionId).toBe('vrs_unknown_head'); + expect(calls).toHaveLength(2); + }); + + it('does not retry non-stale 412 publish errors such as missing chunks', async () => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return jsonResponse(200, { currentVersionId: 'abc123' }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + return jsonResponse(412, { + detail: '2 referenced chunk(s) are not present in R2.', + missingChunks: ['hash-a', 'hash-b'], + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + await expect(pushInternals.publishWithRetry(publishInput())).rejects.toThrow( + /missingChunks/, + ); + + expect(calls).toHaveLength(2); + expect(calls.filter((call) => call.url.endsWith('/versions'))).toHaveLength(1); + }); + + it('aborts a stale 412 when the current head cannot be determined', async () => { + const calls: FetchCall[] = []; + let headReads = 0; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + headReads += 1; + if (headReads === 1) { + return jsonResponse(200, { currentVersionId: 'vrs_old' }); + } + return jsonResponse(500, { detail: 'head read unavailable' }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const body = JSON.parse(String(init.body)); + expect(body.expectedCurrentVersionId).toBe('vrs_old'); + return jsonResponse(412, { + code: 'version_stale', + detail: 'expectedCurrentVersionId did not match; re-read + retry.', + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + await expect(pushInternals.publishWithRetry(publishInput())).rejects.toThrow( + /current head could not be determined/, + ); + + expect(calls.filter((call) => call.url.endsWith('/versions'))).toHaveLength(1); + }); + + it('converges on a stale 412 retry using a fresh idempotency key per attempt', async () => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return jsonResponse(200, { currentVersionId: 'vrs_old' }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const body = JSON.parse(String(init.body)); + const headers = init.headers as Record; + const publishCalls = calls.filter((call) => + call.url.endsWith('/versions'), + ); + if (publishCalls.length === 1) { + expect(headers['Idempotency-Key']).toBe('push-key'); + expect(body.expectedCurrentVersionId).toBe('vrs_old'); + return jsonResponse(412, { + code: 'version_stale', + detail: + 'expectedCurrentVersionId did not match the current head (vrs_new). re-read + retry.', + }); + } + expect(headers['Idempotency-Key']).toBe('push-key-1'); + expect(body.expectedCurrentVersionId).toBe('vrs_new'); + return jsonResponse(201, { + versionId: 'vrs_published', + versionNumber: 3, + currentVersionId: 'vrs_published', + previousVersionId: 'vrs_new', + }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await pushInternals.publishWithRetry({ + ...publishInput(), + idempotencyKey: 'push-key', + }); + + expect(result.versionId).toBe('vrs_published'); + expect(calls.filter((call) => call.url.endsWith('/versions'))).toHaveLength(2); + }); + + it('surfaces the failed attempt key so a manual retry can replay the publish response', async () => { + const calls: FetchCall[] = []; + const stderr: string[] = []; + let committed = false; + let replayed = false; + let cachedRawBody: string | null = null; + let cachedBodyHash: string | null = null; + const firstInput = { + ...publishInput(), + dir: './dist', + idempotencyKey: 'manual-retry-key', + idempotencyKeyWasProvided: false, + }; + const deterministicVersionId = versionIdForVersion(firstInput.version); + const cachedResponse = { + versionId: deterministicVersionId, + versionNumber: 2, + currentVersionId: deterministicVersionId, + previousVersionId: 'vrs_base', + }; + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url === 'https://dev.test/api/roosts/rst_new?siteId=site-1') { + return committed + ? jsonResponse(200, { + currentVersionId: deterministicVersionId, + previousVersionId: 'vrs_base', + }) + : jsonResponse(200, { + currentVersionId: 'vrs_base', + previousVersionId: 'vrs_older', + }); + } + if (url === 'https://dev.test/api/roosts/rst_new/versions') { + const headers = init.headers as Record; + const rawBody = String(init.body); + const bodyHash = createHash('sha256').update(rawBody).digest('hex'); + expect(headers['Idempotency-Key']).toBe('manual-retry-key'); + if (!committed) { + cachedRawBody = rawBody; + cachedBodyHash = bodyHash; + committed = true; + throw new Error('socket closed after commit'); + } + expect(rawBody).toBe(cachedRawBody); + expect(bodyHash).toBe(cachedBodyHash); + replayed = true; + return jsonResponse(201, cachedResponse); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + await expect(pushInternals.publishWithRetry(firstInput)).rejects.toThrow( + /unconfirmed publish failure handled/, + ); + + const err = stderr.join(''); + expect(err).toContain('Idempotency-Key: manual-retry-key'); + expect(err).toContain( + 're-run your original command with `--idempotency-key manual-retry-key` appended', + ); + expect(err).not.toContain('owlette roost push ./dist'); + + process.exitCode = 0; + const result = await pushInternals.publishWithRetry({ + ...firstInput, + dir: './dist', + idempotencyKey: 'manual-retry-key', + idempotencyKeyWasProvided: true, + }); + + expect(replayed).toBe(true); + expect(result.versionId).toBe(deterministicVersionId); + expect(calls.filter((call) => call.url.endsWith('/versions'))).toHaveLength(2); + }); +}); diff --git a/cli/__tests__/commands/quota-http.test.ts b/cli/__tests__/commands/quota-http.test.ts index cd5ba8fc..cdb4d5b0 100644 --- a/cli/__tests__/commands/quota-http.test.ts +++ b/cli/__tests__/commands/quota-http.test.ts @@ -155,4 +155,18 @@ describe('owlette quota history', () => { expect(calls[0]!.url).toBe('https://dev.test/api/sites/site-1/quota/history?period=7d'); expect(JSON.parse(writes.join(''))).toEqual(history); }); + + it('exits 2 when --period is invalid', async () => { + const calls = installFetchStub({ siteId: 'site-1', period: '30d', days: 30, daily: [] }); + const program = buildProgram(); + + await program.parseAsync( + ['quota', 'history', '--site', 'site-1', '--period', 'banana'], + { from: 'user' }, + ); + + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + process.exitCode = 0; + }); }); diff --git a/cli/__tests__/commands/rollback-http.test.ts b/cli/__tests__/commands/rollback-http.test.ts new file mode 100644 index 00000000..8ffd0687 --- /dev/null +++ b/cli/__tests__/commands/rollback-http.test.ts @@ -0,0 +1,126 @@ +import { Command } from 'commander'; +import { registerRollbackCommand } from '../../src/commands/rollback'; +import { _resetConfigCache } from '../../src/config'; + +function buildProgram(): Command { + const program = new Command(); + program.name('owlette').exitOverride().option('--profile ').option('--json'); + registerRollbackCommand(program); + return program; +} + +interface FetchCall { + url: string; + init: RequestInit; +} + +function jsonResponse(payload: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + } as Response; +} + +let originalFetch: typeof global.fetch; +beforeAll(() => { + originalFetch = global.fetch; +}); +afterAll(() => { + global.fetch = originalFetch; +}); + +beforeEach(() => { + _resetConfigCache(); + process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; + process.env.OWLETTE_API_URL = 'https://dev.test'; + process.env.OWLETTE_PROFILE = 'default'; + process.exitCode = 0; + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + delete process.env.OWLETTE_TOKEN; + delete process.env.OWLETTE_API_URL; + delete process.env.OWLETTE_PROFILE; + process.exitCode = 0; + jest.restoreAllMocks(); +}); + +describe('owlette rollback', () => { + it('surfaces resolved --to and idempotency key on unconfirmed rollback failure', async () => { + const calls: FetchCall[] = []; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (calls.length === 1) { + return jsonResponse({ + roostId: 'rst_1', + siteId: 'site-1', + name: 'demo', + currentVersionId: 'vrs_current', + previousVersionId: 'vrs_previous', + deletedAt: null, + }); + } + if (calls.length === 2) { + return jsonResponse({ + versionId: 'vrs_resolved_previous', + toVersion: 'vrs_resolved_previous', + fromVersion: 'vrs_current', + against: 'vrs_current', + roostId: 'rst_1', + siteId: 'site-1', + summary: { + added: 0, + removed: 0, + changed: 1, + unchanged: 0, + hasChanges: true, + netBytesDelta: 0, + }, + added: [], + removed: [], + modified: [], + }); + } + throw new Error('socket hang up'); + }, + ); + const stderr: string[] = []; + (process.stderr.write as unknown as jest.Mock).mockImplementation((chunk: string) => { + stderr.push(chunk); + return true; + }); + const program = buildProgram(); + + await program.parseAsync( + [ + 'rollback', + 'rst_1', + '--site', + 'site-1', + '--to', + 'previous', + '--yes', + '--idempotency-key', + 'retry-key', + ], + { from: 'user' }, + ); + + expect(calls).toHaveLength(3); + expect(calls[1]!.url).toContain('/api/roosts/rst_1/versions/previous/diff'); + expect(JSON.parse(String(calls[2]!.init.body))).toEqual({ + siteId: 'site-1', + targetVersion: 'vrs_resolved_previous', + }); + const errOut = stderr.join(''); + expect(errOut).toContain('did not return a confirmed response'); + expect(errOut).toContain('Idempotency-Key: retry-key'); + expect(errOut).toContain('`--to vrs_resolved_previous --idempotency-key retry-key`'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/cli/__tests__/commands/trigger-http.test.ts b/cli/__tests__/commands/trigger-http.test.ts index 22788f2f..c1cddf50 100644 --- a/cli/__tests__/commands/trigger-http.test.ts +++ b/cli/__tests__/commands/trigger-http.test.ts @@ -57,7 +57,7 @@ afterEach(() => { describe('owlette trigger (server-probe mode)', () => { it('POSTs /api/webhooks/probe?siteId=... with url, event, and payload', async () => { - const calls = installFetchStub({ success: true }); + const calls = installFetchStub({ status: 204, durationMs: 3 }); const program = buildProgram(); await program.parseAsync( [ @@ -83,6 +83,28 @@ describe('owlette trigger (server-probe mode)', () => { expect(body.payload.roostId).toBeDefined(); expect(body.payload.siteId).toBe('site-1'); // placeholder filled at runtime }); + + it('exits 1 when the server probe reaches a failing receiver', async () => { + installFetchStub({ status: 500, durationMs: 12, responseBody: 'nope' }); + const program = buildProgram(); + + await program.parseAsync( + [ + '--json', + 'trigger', + 'version.published', + '--site', + 'site-1', + '--to', + 'https://hooks.example/roost', + '--via-api', + ], + { from: 'user' }, + ); + + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); }); describe('owlette trigger (direct mode)', () => { diff --git a/cli/__tests__/commands/user-http.test.ts b/cli/__tests__/commands/user-http.test.ts index 09486c3d..63f04953 100644 --- a/cli/__tests__/commands/user-http.test.ts +++ b/cli/__tests__/commands/user-http.test.ts @@ -56,6 +56,7 @@ beforeEach(() => { process.env.OWLETTE_TOKEN = 'owk_live_testtoken'; process.env.OWLETTE_API_URL = 'https://dev.test'; process.env.OWLETTE_PROFILE = 'default'; + process.exitCode = 0; jest.spyOn(process.stdout, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true); }); @@ -64,6 +65,7 @@ afterEach(() => { delete process.env.OWLETTE_TOKEN; delete process.env.OWLETTE_API_URL; delete process.env.OWLETTE_PROFILE; + process.exitCode = 0; jest.restoreAllMocks(); }); @@ -139,6 +141,14 @@ describe('owlette user list', () => { expect(parsed.users).toEqual(users); expect(parsed.nextPageToken).toBe('tok-next'); }); + + it('rejects an invalid --limit with exit 2 before firing fetch', async () => { + const calls = installFetchStub({}); + const program = buildProgram(); + await program.parseAsync(['user', 'list', '--limit', 'banana'], { from: 'user' }); + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + }); }); /* -------------------- get -------------------- */ @@ -221,8 +231,7 @@ describe('owlette user promote', () => { { from: 'user' }, ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); }); @@ -313,6 +322,17 @@ describe('owlette user assign-sites', () => { }); }); + it('rejects empty --sites with exit 2 before firing fetch', async () => { + const calls = installFetchStub({}); + const program = buildProgram(); + await program.parseAsync( + ['user', 'assign-sites', 'u_1', '--sites', ' , , '], + { from: 'user' }, + ); + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + }); + it('surfaces 400 unknown_site clearly with the offending sites listed', async () => { installFetchStub( { @@ -374,8 +394,7 @@ describe('owlette user remove-sites', () => { { from: 'user' }, ); expect(calls).toHaveLength(0); - expect(process.exitCode).toBe(1); - process.exitCode = 0; + expect(process.exitCode).toBe(2); }); }); @@ -451,4 +470,18 @@ describe('owlette user delete', () => { expect(process.exitCode).toBe(1); process.exitCode = 0; }); + + it('refuses non-tty delete without --yes using exit 2 before firing fetch', async () => { + const calls = installFetchStub({}); + const isTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + try { + const program = buildProgram(); + await program.parseAsync(['user', 'delete', 'u_1'], { from: 'user' }); + expect(calls).toHaveLength(0); + expect(process.exitCode).toBe(2); + } finally { + if (isTTY) Object.defineProperty(process.stdin, 'isTTY', isTTY); + } + }); }); diff --git a/cli/__tests__/configWriter.test.ts b/cli/__tests__/configWriter.test.ts index 13a3d5e2..01dd6a18 100644 --- a/cli/__tests__/configWriter.test.ts +++ b/cli/__tests__/configWriter.test.ts @@ -73,6 +73,20 @@ api_url = "https://dev.owlette.app" const parsed = parseToml(raw) as { profiles?: Record }; expect(parsed.profiles?.default?.token).toBe('owk_live_x"y\\z'); }); + + it('quotes profile table names that are not bare TOML keys', () => { + const path = tmpPath(); + writeTokenToConfig({ + configPath: path, + profile: 'release candidate.1', + token: 'owk_live_spacey', + }); + + const raw = readFileSync(path, 'utf-8'); + expect(raw).toContain('[profiles."release candidate.1"]'); + const parsed = parseToml(raw) as { profiles?: Record }; + expect(parsed.profiles?.['release candidate.1']?.token).toBe('owk_live_spacey'); + }); }); describe('clearTokenFromConfig', () => { diff --git a/cli/__tests__/key-parse.test.ts b/cli/__tests__/key-parse.test.ts deleted file mode 100644 index a4c5b76b..00000000 --- a/cli/__tests__/key-parse.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { _internals } from '../src/commands/key'; - -const { parseScopeSpec, summariseScopes, statusOf, PRESETS } = _internals; - -describe('parseScopeSpec', () => { - it('parses a canonical spec with one permission', () => { - const result = parseScopeSpec('roost=rst_abc:write'); - expect(result).toEqual({ resource: 'roost', id: 'rst_abc', permissions: ['write'] }); - }); - - it('parses multiple comma-separated permissions + dedups', () => { - const result = parseScopeSpec('roost=rst_abc:write,deploy,write'); - expect(result).toEqual({ - resource: 'roost', - id: 'rst_abc', - permissions: ['write', 'deploy'], - }); - }); - - it('accepts wildcard id', () => { - const result = parseScopeSpec('site=*:read'); - expect(result).toEqual({ resource: 'site', id: '*', permissions: ['read'] }); - }); - - it('rejects unknown resource', () => { - expect(parseScopeSpec('widget=x:read')).toMatch(/resource must be one of/); - }); - - it.each(['chat', 'deploy', 'process', 'user', 'installer'] as const)( - 'accepts public-api resource %s', - (resource) => { - expect(parseScopeSpec(`${resource}=*:read`)).toEqual({ - resource, - id: '*', - permissions: ['read'], - }); - }, - ); - - it('rejects missing : separator', () => { - expect(parseScopeSpec('roost=rst_abc')).toMatch(/must include.*perm/); - }); - - it('rejects missing = separator', () => { - expect(parseScopeSpec('roost:rst_abc:write')).toMatch( - /must be '=:/, - ); - }); - - it('rejects empty id (falls through to colon-check when id is empty)', () => { - // `roost=:write` fails the colon-position test first because `:` is - // at position 0 of the trailing segment — validator reports the - // more generic "must include :" message. - const result = parseScopeSpec('roost=:write'); - expect(typeof result).toBe('string'); - }); - - it('rejects unknown permission', () => { - expect(parseScopeSpec('roost=rst:teleport')).toMatch(/'teleport' not in/); - }); - - it('rejects empty perm list', () => { - expect(parseScopeSpec('roost=rst:')).toMatch(/at least one permission/); - }); - - it('trims whitespace', () => { - expect(parseScopeSpec(' roost = rst_abc : write , deploy ')).toEqual({ - resource: 'roost', - id: 'rst_abc', - permissions: ['write', 'deploy'], - }); - }); -}); - -describe('PRESETS', () => { - it('covers every resource type with the preset permissions', () => { - for (const preset of ['readonly', 'publisher', 'operator', 'admin'] as const) { - const scopes = PRESETS[preset]; - expect(scopes).toHaveLength(4); - expect(scopes.map((s) => s.resource).sort()).toEqual([ - 'chat', - 'machine', - 'roost', - 'site', - ]); - for (const s of scopes) expect(s.id).toBe('*'); - } - }); - - it('admin includes every permission', () => { - const perms = PRESETS.admin[0]!.permissions; - expect(perms.sort()).toEqual(['admin', 'deploy', 'read', 'rollback', 'write']); - }); - - it('readonly is read-only', () => { - expect(PRESETS.readonly[0]!.permissions).toEqual(['read']); - }); -}); - -describe('summariseScopes', () => { - it('renders each scope as resource=id:perm+perm', () => { - expect( - summariseScopes([ - { resource: 'roost', id: 'rst_abc', permissions: ['write', 'deploy'] }, - { resource: 'site', id: '*', permissions: ['read'] }, - ]), - ).toBe('roost=rst_abc:write+deploy site=*:read'); - }); - - it('marks empty scopes as legacy', () => { - expect(summariseScopes([])).toBe('legacy (full access)'); - }); -}); - -describe('statusOf', () => { - it('prioritizes revoked > expired > retired > rotated > active', () => { - expect(statusOf({ expired: false, retired: false, rotatedAt: null, revokedAt: 1 })).toBe( - 'revoked', - ); - expect(statusOf({ expired: true, retired: false, rotatedAt: null, revokedAt: null })).toBe( - 'expired', - ); - expect(statusOf({ expired: false, retired: true, rotatedAt: null, revokedAt: null })).toBe( - 'retired', - ); - expect(statusOf({ expired: false, retired: false, rotatedAt: 1, revokedAt: null })).toBe( - 'rotated', - ); - expect( - statusOf({ expired: false, retired: false, rotatedAt: null, revokedAt: null }), - ).toBe('active'); - }); -}); diff --git a/cli/__tests__/output.test.ts b/cli/__tests__/output.test.ts index e5a263a7..460cd328 100644 --- a/cli/__tests__/output.test.ts +++ b/cli/__tests__/output.test.ts @@ -4,6 +4,8 @@ import { isJson, renderTable, truncate, + unconfirmedMutationFatal, + usageFatal, } from '../src/lib/output'; describe('humanBytes', () => { @@ -61,3 +63,39 @@ describe('isJson', () => { expect(isJson(program)).toBe(true); }); }); + +describe('fatal helpers', () => { + beforeEach(() => { + process.exitCode = 0; + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + process.exitCode = 0; + jest.restoreAllMocks(); + }); + + it('usageFatal writes stderr and exits 2', () => { + usageFatal('bad flag'); + expect(process.stderr.write).toHaveBeenCalledWith('owlette: bad flag\n'); + expect(process.exitCode).toBe(2); + }); + + it('unconfirmedMutationFatal prints the key and append-only retry guidance', () => { + unconfirmedMutationFatal({ + operation: 'POST /x', + idempotencyKey: 'k-1', + cause: new Error('timeout'), + }); + const out = (process.stderr.write as unknown as jest.Mock).mock.calls + .map((call) => String(call[0])) + .join(''); + expect(out).toContain('Idempotency-Key: k-1'); + expect(out).toContain('may or may not have completed'); + expect(out).toContain( + 're-run your original command with `--idempotency-key k-1` appended', + ); + expect(out).not.toContain('owlette x --idempotency-key'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/cli/__tests__/versionBuilder.test.ts b/cli/__tests__/versionBuilder.test.ts index f39d1e67..0df5f565 100644 --- a/cli/__tests__/versionBuilder.test.ts +++ b/cli/__tests__/versionBuilder.test.ts @@ -4,6 +4,7 @@ import { buildVersion, summariseVersion, uniqueHashes, + versionIdForVersion, } from '../src/lib/versionBuilder'; const FILES = [ @@ -24,7 +25,7 @@ describe('buildVersion', () => { expect(m.files.map((f) => f.path)).toEqual(['a/first.bin', 'middle.bin', 'z/last.bin']); }); - it('config carries producer + cliVersion + createdAt', () => { + it('config carries deterministic producer metadata', () => { const m = buildVersion({ files: FILES, cliVersion: '1.2.3', @@ -35,7 +36,13 @@ describe('buildVersion', () => { expect(m.config.cliVersion).toBe('1.2.3'); expect(m.config.hostname).toBe('my-laptop'); expect(m.config.platform).toBe('darwin'); - expect(typeof m.config.createdAt).toBe('string'); + expect(m.config.createdAt).toBeUndefined(); + }); + + it('builds identical versionIds for identical input', () => { + const a = buildVersion({ files: FILES, cliVersion: '0.0.1' }); + const b = buildVersion({ files: FILES, cliVersion: '0.0.1' }); + expect(versionIdForVersion(a)).toBe(versionIdForVersion(b)); }); it('merges extra config fields into config', () => { diff --git a/cli/src/commands/audit-log.ts b/cli/src/commands/audit-log.ts index 0a10f4d2..a8c1f985 100644 --- a/cli/src/commands/audit-log.ts +++ b/cli/src/commands/audit-log.ts @@ -25,7 +25,8 @@ import { Command } from 'commander'; import { loadConfig } from '../config'; -import { isJson, renderTable, truncate } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { isJson, renderTable, truncate, usageFatal } from '../lib/output'; interface AuditLogRecord { hash: string; @@ -123,6 +124,7 @@ export function registerAuditLogCommands(program: Command): void { const collected: AuditLogRecord[] = []; let cursor = typeof opts.cursor === 'string' ? opts.cursor : ''; let nextPageToken = ''; + let limitReached = false; for (;;) { const qs = new URLSearchParams({ @@ -133,7 +135,7 @@ export function registerAuditLogCommands(program: Command): void { if (typeof opts.actor === 'string' && opts.actor.length > 0) qs.set('actor', opts.actor); if (sinceIso) qs.set('since', sinceIso); - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/audit-log?${qs}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -147,15 +149,26 @@ export function registerAuditLogCommands(program: Command): void { return; } - for (const r of data.records ?? []) { + const pageRecords = data.records ?? []; + let stoppedInsidePage = false; + for (let i = 0; i < pageRecords.length; i++) { + const r = pageRecords[i]!; if (clientKindSet && !clientKindSet.has(r.kind)) continue; if (untilMs !== null && r.recordedAt !== null && r.recordedAt > untilMs) continue; collected.push(r); - if (Number.isFinite(limit) && collected.length >= limit) break; + if (Number.isFinite(limit) && collected.length >= limit) { + limitReached = true; + stoppedInsidePage = i < pageRecords.length - 1; + break; + } } - nextPageToken = data.next_page_token ?? data.nextPageToken ?? ''; + const serverNextPageToken = data.next_page_token ?? data.nextPageToken ?? ''; + nextPageToken = + limitReached && (stoppedInsidePage || serverNextPageToken) + ? collected[collected.length - 1]?.hash ?? '' + : serverNextPageToken; if (!nextPageToken) break; - if (Number.isFinite(limit) && collected.length >= limit) break; + if (limitReached) break; cursor = nextPageToken; } @@ -175,7 +188,7 @@ export function registerAuditLogCommands(program: Command): void { formatTimestamp(r.recordedAt), r.kind, truncate(r.actor, 32), - truncate(r.hash, 12), + r.hash, ]); process.stdout.write(renderTable(['recordedAt', 'kind', 'actor', 'hash'], rows)); if (nextPageToken) { @@ -193,7 +206,7 @@ export function registerAuditLogCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/audit-log/${encodeURIComponent(recordHash)}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -304,7 +317,7 @@ function parseWhen(raw: string, flag: string): string | null { } const parsed = Date.parse(raw); if (Number.isNaN(parsed)) { - fatal(`${flag} must be iso 8601 or a relative duration (e.g. 24h, 7d, 30m); got '${raw}'`); + usageFatal(`${flag} must be iso 8601 or a relative duration (e.g. 24h, 7d, 30m); got '${raw}'`); return null; } return new Date(parsed).toISOString(); diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index da641937..814ca38e 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -11,7 +11,7 @@ */ import { Command } from 'commander'; -import { exec } from 'child_process'; +import { spawn } from 'child_process'; import { createDecipheriv, hkdfSync } from 'crypto'; import { platform } from 'os'; import { @@ -26,6 +26,7 @@ import { writeStoredCredential, type WriteCredentialResult, } from '../credentialStore'; +import { fetchWithTimeout } from '../lib/http'; import { runWhoami } from './whoami'; const DEVICE_CODE_WRAP_VERSION = 'v1'; @@ -100,13 +101,26 @@ interface PollEncryptedResponse { } function tryOpenBrowser(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return; + const p = platform(); - const cmd = - p === 'win32' ? `start "" "${url}"` : p === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`; + const command = p === 'win32' ? 'explorer.exe' : p === 'darwin' ? 'open' : 'xdg-open'; try { - exec(cmd, () => { - /* best-effort — ignore errors; the user has the url to copy-paste */ + const child = spawn(command, [parsed.toString()], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }); + child.on('error', () => { + /* best-effort - the user has the url to copy-paste */ }); + child.unref(); } catch { /* ignore */ } @@ -122,7 +136,7 @@ async function post( path: string, body: Record, ): Promise<{ status: number; data: T }> { - const res = await fetch(`${apiUrl}${path}`, { + const res = await fetchWithTimeout(`${apiUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/cli/src/commands/chat.ts b/cli/src/commands/chat.ts index 5fa20ff6..79a93077 100644 --- a/cli/src/commands/chat.ts +++ b/cli/src/commands/chat.ts @@ -9,24 +9,30 @@ * PATCH /api/cortex/conversations/{conversationId} — rename * DELETE /api/cortex/conversations/{conversationId} — soft delete * - * `send` consumes the AI-SDK v3 line-prefixed stream protocol the server - * emits via `result.toUIMessageStreamResponse()`: + * `send` consumes both stream protocols the server can emit today: * `0:""\n` → text delta (write to stdout immediately) * `d:{...}\n` → end-of-stream marker * `3:""\n` → upstream error frame + * `data: {"type":"text-delta","delta":"..."}` → AI SDK 6 UI-message SSE * The CLI flushes deltas as they arrive so users see the model think rather * than waiting for the full reply. * * Mutations carry an auto-generated `Idempotency-Key` header so a network - * retry doesn't double-create / double-delete. `chat send` sends the header - * for replay safety even though the server skips the cache for streaming - * responses (see `web/app/api/cortex/conversations/[conversationId]/route.ts`). + * retry doesn't double-create / double-delete. `chat send` also sends the + * header for tracing/proxy tooling, but streamed responses are not safely + * replayable because the server does not cache them. */ import { Command } from 'commander'; import { randomUUID } from 'crypto'; import { loadConfig } from '../config'; -import { isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { + isJson, + renderTable, + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; interface ConversationSummary { conversationId: string; @@ -99,17 +105,28 @@ export function registerChatCommands(program: Command): void { if (opts.machine) body.machineId = opts.machine; if (opts.title) body.title = opts.title; - const res = await fetch(`${apiUrl}/api/cortex/conversations`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-chat-new-${randomUUID()}`, - }, - body: JSON.stringify(body), - }); + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-chat-new-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout(`${apiUrl}/api/cortex/conversations`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: 'POST /api/cortex/conversations', + idempotencyKey, + cause: err, + }); + return; + } const raw = (await res.json().catch(() => ({}))) as { ok?: boolean; data?: NewResponse; @@ -156,14 +173,14 @@ export function registerChatCommands(program: Command): void { if (opts.limit !== undefined) { const n = Number(opts.limit); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 100) { - fatal('--limit must be an integer between 1 and 100'); + usageFatal('--limit must be an integer between 1 and 100'); return; } params.set('page_size', String(n)); } if (opts.cursor) params.set('page_token', String(opts.cursor)); - const res = await fetch(`${apiUrl}/api/cortex/conversations?${params.toString()}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/cortex/conversations?${params.toString()}`, { headers: { Authorization: `Bearer ${token}` }, }); const raw = (await res.json().catch(() => ({}))) as { @@ -229,23 +246,35 @@ export function registerChatCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( - `${apiUrl}/api/cortex/conversations/${encodeURIComponent(conversationId)}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - // Server skips idempotency caching on streamed responses, but - // the header is still safe to send and useful for downstream - // proxies / replay tooling. - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-chat-send-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-chat-send-${randomUUID()}`; + let res: Response; + try { + res = await fetch( + `${apiUrl}/api/cortex/conversations/${encodeURIComponent(conversationId)}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + // Server skips idempotency caching on streamed responses; keep + // the header only for downstream tracing/proxy tooling. + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ role: 'user', content: message }), }, - body: JSON.stringify({ role: 'user', content: message }), - }, - ); + ); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + process.stderr.write( + `owlette: POST /api/cortex/conversations/${conversationId} did not return a confirmed response: ${detail}\n` + + ' inspect the conversation before retrying: run `owlette chat list` and view the conversation in the UI.\n' + + ' retrying may append the message twice.\n', + ); + process.exitCode = 1; + return; + } if (!res.ok) { const data = (await res.json().catch(() => ({}))) as { @@ -268,6 +297,19 @@ export function registerChatCommands(program: Command): void { const decoder = new TextDecoder(); let pending = ''; + const emitDelta = (delta: string): void => { + if (json) { + collected.push(delta); + } else { + process.stdout.write(delta); + } + }; + + const emitStreamError = (detail: string): void => { + process.stderr.write(`\nowlette: cortex error — ${detail}\n`); + process.exitCode = 1; + }; + const consume = (line: string): void => { if (!line) return; if (line.startsWith('0:')) { @@ -275,11 +317,7 @@ export function registerChatCommands(program: Command): void { try { const parsed = JSON.parse(line.slice(2)); if (typeof parsed === 'string') { - if (json) { - collected.push(parsed); - } else { - process.stdout.write(parsed); - } + emitDelta(parsed); } } catch { // Drop malformed delta — never crash the stream. @@ -293,8 +331,26 @@ export function registerChatCommands(program: Command): void { } catch { /* keep raw */ } - process.stderr.write(`\nowlette: cortex error — ${detail}\n`); - process.exitCode = 1; + emitStreamError(detail); + } else if (line.startsWith('data:')) { + const raw = line.slice(5).trimStart(); + if (!raw || raw === '[DONE]') return; + try { + const parsed = JSON.parse(raw) as Record; + if (parsed.type === 'text-delta' && typeof parsed.delta === 'string') { + emitDelta(parsed.delta); + } else if (parsed.type === 'error') { + const detail = + typeof parsed.errorText === 'string' + ? parsed.errorText + : typeof parsed.error === 'string' + ? parsed.error + : JSON.stringify(parsed); + emitStreamError(detail); + } + } catch { + // Drop malformed SSE data — never crash the stream. + } } // `d:` and any other prefix → ignore (end markers / tool frames). }; @@ -339,7 +395,7 @@ export function registerChatCommands(program: Command): void { if (!opts.yes) { if (!process.stdin.isTTY) { - fatal( + usageFatal( 'stdin is not a tty and --yes was not supplied; refusing to delete silently', ); return; @@ -353,18 +409,29 @@ export function registerChatCommands(program: Command): void { } } - const res = await fetch( - `${apiUrl}/api/cortex/conversations/${encodeURIComponent(conversationId)}`, - { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-chat-delete-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-chat-delete-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/cortex/conversations/${encodeURIComponent(conversationId)}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'Idempotency-Key': idempotencyKey, + }, }, - }, - ); + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `DELETE /api/cortex/conversations/${conversationId}`, + idempotencyKey, + cause: err, + }); + return; + } const raw = (await res.json().catch(() => ({}))) as { ok?: boolean; data?: MutationResponse; @@ -405,20 +472,31 @@ export function registerChatCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( - `${apiUrl}/api/cortex/conversations/${encodeURIComponent(conversationId)}`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-chat-rename-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-chat-rename-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/cortex/conversations/${encodeURIComponent(conversationId)}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ title }), }, - body: JSON.stringify({ title }), - }, - ); + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `PATCH /api/cortex/conversations/${conversationId}`, + idempotencyKey, + cause: err, + }); + return; + } const raw = (await res.json().catch(() => ({}))) as { ok?: boolean; data?: MutationResponse; @@ -464,7 +542,7 @@ function resolveAuth(cmd: Command): { apiUrl: string; token: string | null; json async function promptYesNo(question: string): Promise { const { createInterface } = await import('readline'); return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); diff --git a/cli/src/commands/deploy.ts b/cli/src/commands/deploy.ts index 8dd95e1c..1273635d 100644 --- a/cli/src/commands/deploy.ts +++ b/cli/src/commands/deploy.ts @@ -24,7 +24,13 @@ import { Command } from 'commander'; import { randomUUID } from 'crypto'; import { loadConfig } from '../config'; -import { isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { + isJson, + renderTable, + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; interface DeploymentTarget { machineId: string; @@ -105,17 +111,17 @@ export function registerDeployCommands(program: Command): void { const machines = parseCsv(opts.machines); if (machines.length === 0) { - fatal('--machines must contain at least one non-empty id'); + usageFatal('--machines must contain at least one non-empty id'); return; } const closeProcesses = parseCsv(opts.closeProcesses); if (opts.closeProcesses !== undefined && closeProcesses.length === 0) { - fatal('--close-processes must contain at least one non-empty value when supplied'); + usageFatal('--close-processes must contain at least one non-empty value when supplied'); return; } const suppressProjects = parseCsv(opts.suppressProjects); if (opts.suppressProjects !== undefined && suppressProjects.length === 0) { - fatal('--suppress-projects must contain at least one non-empty value when supplied'); + usageFatal('--suppress-projects must contain at least one non-empty value when supplied'); return; } @@ -132,20 +138,31 @@ export function registerDeployCommands(program: Command): void { if (suppressProjects.length > 0) body.suppress_projects = suppressProjects; if (opts.parallel) body.parallel_install = true; + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-deploy-create-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-deploy-create-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments`; - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/deployments`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as Record & { deploymentId?: string; siteId?: string; @@ -200,7 +217,7 @@ export function registerDeployCommands(program: Command): void { if (opts.limit !== undefined) { const n = Number(opts.limit); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) { - fatal('--limit must be a positive integer'); + usageFatal('--limit must be a positive integer'); return; } qs.set('page_size', String(n)); @@ -210,7 +227,7 @@ export function registerDeployCommands(program: Command): void { const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments` + (qs.toString() ? `?${qs.toString()}` : ''); - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as { @@ -265,7 +282,7 @@ export function registerDeployCommands(program: Command): void { if (!token) return; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments/${encodeURIComponent(deploymentId)}`; - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as DeploymentDetail & { @@ -302,20 +319,31 @@ export function registerDeployCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-deploy-retry-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-deploy-retry-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments/${encodeURIComponent(deploymentId)}/retry`; - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({}), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify({}), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/deployments/${deploymentId}/retry`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { deploymentId?: string; siteId?: string; @@ -367,24 +395,35 @@ export function registerDeployCommands(program: Command): void { return; } } else if (!opts.yes && !process.stdin.isTTY) { - fatal('stdin is not a tty and --yes was not supplied; refusing to cancel silently'); + usageFatal('stdin is not a tty and --yes was not supplied; refusing to cancel silently'); return; } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-deploy-cancel-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-deploy-cancel-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments/${encodeURIComponent(deploymentId)}/cancel`; - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({}), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify({}), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/deployments/${deploymentId}/cancel`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { deploymentId?: string; siteId?: string; @@ -436,24 +475,35 @@ export function registerDeployCommands(program: Command): void { return; } } else if (!opts.yes && !process.stdin.isTTY) { - fatal('stdin is not a tty and --yes was not supplied; refusing to uninstall silently'); + usageFatal('stdin is not a tty and --yes was not supplied; refusing to uninstall silently'); return; } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-deploy-uninstall-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-deploy-uninstall-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments/${encodeURIComponent(deploymentId)}/uninstall`; - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({}), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify({}), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/deployments/${deploymentId}/uninstall`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { deploymentId?: string; siteId?: string; @@ -512,24 +562,35 @@ export function registerDeployCommands(program: Command): void { return; } } else if (!opts.yes && !process.stdin.isTTY) { - fatal('stdin is not a tty and --yes was not supplied; refusing to delete silently'); + usageFatal('stdin is not a tty and --yes was not supplied; refusing to delete silently'); return; } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-deploy-delete-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-deploy-delete-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/deployments/${encodeURIComponent(deploymentId)}`; - const res = await fetch(url, { - method: 'DELETE', - headers, - body: JSON.stringify({}), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'DELETE', + headers, + body: JSON.stringify({}), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `DELETE /api/sites/${opts.site}/deployments/${deploymentId}`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { deploymentId?: string; siteId?: string; @@ -617,7 +678,7 @@ function fatal(msg: string): void { async function promptYesNo(question: string): Promise { const { createInterface } = await import('readline'); return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); diff --git a/cli/src/commands/installer.ts b/cli/src/commands/installer.ts index 3d93c7f0..ade14ca0 100644 --- a/cli/src/commands/installer.ts +++ b/cli/src/commands/installer.ts @@ -20,7 +20,14 @@ import { createHash, randomUUID } from 'crypto'; import { readFileSync, statSync } from 'fs'; import { basename } from 'path'; import { loadConfig } from '../config'; -import { humanBytes, isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { + humanBytes, + isJson, + renderTable, + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; interface InstallerVersion { version: string; @@ -72,7 +79,7 @@ export function registerInstallerCommands(program: Command): void { if (opts.limit !== undefined) { const n = Number(opts.limit); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) { - fatal('--limit must be a positive integer'); + usageFatal('--limit must be a positive integer'); return; } qs.set('page_size', String(n)); @@ -80,7 +87,7 @@ export function registerInstallerCommands(program: Command): void { if (opts.cursor) qs.set('page_token', String(opts.cursor)); const url = `${apiUrl}/api/installer` + (qs.toString() ? `?${qs.toString()}` : ''); - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as { @@ -141,7 +148,7 @@ export function registerInstallerCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch(`${apiUrl}/api/installer/latest`, { + const res = await fetchWithTimeout(`${apiUrl}/api/installer/latest`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as InstallerVersion & { @@ -213,7 +220,7 @@ export function registerInstallerCommands(program: Command): void { buffer = readFileSync(file); fileSize = statSync(file).size; } catch (err) { - fatal( + usageFatal( `cannot read installer file '${file}': ${err instanceof Error ? err.message : String(err)}`, ); return; @@ -242,15 +249,25 @@ export function registerInstallerCommands(program: Command): void { if (opts.releaseNotes !== undefined) startBody.releaseNotes = opts.releaseNotes; if (opts.setLatest !== undefined) startBody.setAsLatest = Boolean(opts.setLatest); - const startRes = await fetch(`${apiUrl}/api/installer/upload`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': idempotencyKey, - }, - body: JSON.stringify(startBody), - }); + let startRes: Response; + try { + startRes = await fetchWithTimeout(`${apiUrl}/api/installer/upload`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(startBody), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: 'POST /api/installer/upload', + idempotencyKey, + cause: err, + }); + return; + } const startData = (await startRes.json().catch(() => ({}))) as { uploadUrl?: string; uploadId?: string; @@ -298,15 +315,25 @@ export function registerInstallerCommands(program: Command): void { // ── step 3: finalize ───────────────────────────────────────────── if (!json) process.stdout.write('owlette: finalising…\n'); - const finalizeRes = await fetch(`${apiUrl}/api/installer/upload`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': idempotencyKey, - }, - body: JSON.stringify({ uploadId: startData.uploadId, checksum_sha256: checksum }), - }); + let finalizeRes: Response; + try { + finalizeRes = await fetchWithTimeout(`${apiUrl}/api/installer/upload`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ uploadId: startData.uploadId, checksum_sha256: checksum }), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: 'PUT /api/installer/upload (finalize)', + idempotencyKey, + cause: err, + }); + return; + } const finalizeData = (await finalizeRes.json().catch(() => ({}))) as { version?: string; download_url?: string; @@ -358,24 +385,35 @@ export function registerInstallerCommands(program: Command): void { return; } } else if (!opts.yes && !process.stdin.isTTY) { - fatal('stdin is not a tty and --yes was not supplied; refusing to set-latest silently'); + usageFatal('stdin is not a tty and --yes was not supplied; refusing to set-latest silently'); return; } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-installer-set-latest-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-installer-set-latest-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/installer/${encodeURIComponent(version)}/set-latest`; - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({}), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers, + body: JSON.stringify({}), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/installer/${version}/set-latest`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { version?: string; latest?: unknown; @@ -428,22 +466,33 @@ export function registerInstallerCommands(program: Command): void { return; } } else if (!opts.yes && !process.stdin.isTTY) { - fatal('stdin is not a tty and --yes was not supplied; refusing to delete silently'); + usageFatal('stdin is not a tty and --yes was not supplied; refusing to delete silently'); return; } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-installer-delete-${randomUUID()}`; const headers: Record = { Authorization: `Bearer ${token}`, - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-installer-delete-${randomUUID()}`, + 'Idempotency-Key': idempotencyKey, }; const url = `${apiUrl}/api/installer/${encodeURIComponent(version)}`; - const res = await fetch(url, { - method: 'DELETE', - headers, - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'DELETE', + headers, + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `DELETE /api/installer/${version}`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { version?: string; deletedAt?: number | null; @@ -512,7 +561,7 @@ function fatal(msg: string): void { async function promptYesNo(question: string): Promise { const { createInterface } = await import('readline'); return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); diff --git a/cli/src/commands/key.ts b/cli/src/commands/key.ts deleted file mode 100644 index e3c2a281..00000000 --- a/cli/src/commands/key.ts +++ /dev/null @@ -1,464 +0,0 @@ -/** - * `owlette key create | list | rotate | revoke`. - * - * create POST /api/keys { name, scopes[], ttlDays, environment } - * list GET /api/keys - * rotate POST /api/keys/{keyId}/rotate { ttlDays? } - * revoke DELETE /api/keys/{keyId} - * - * Scope input modes: - * --preset readonly|publisher|operator|admin - * → uses the canonical presets from lib/apiKeyTypes.SCOPE_PRESETS - * (wildcard against every resource type with the preset permissions). - * --scope (repeatable) - * → spec = `=:,,...` - * resource ∈ roost | site | machine | chat | deploy | process | user | installer - * id = '*' or a specific resource id - * perms ⊂ read | write | deploy | rollback | admin - * e.g. `--scope roost=rst_abc:write,deploy --scope site=site-1:read` - * - * `--preset` and `--scope` are mutually exclusive for create; --preset is - * quicker for common cases, --scope gives the fine-grained control. - */ - -import { Command } from 'commander'; -import { loadConfig } from '../config'; -import { isJson, renderTable as sharedRenderTable } from '../lib/output'; - -const VALID_RESOURCES = [ - 'roost', - 'site', - 'machine', - 'chat', - 'deploy', - 'process', - 'user', - 'installer', -] as const; -const PRESET_RESOURCES = ['roost', 'site', 'machine', 'chat'] as const; -const VALID_PERMISSIONS = ['read', 'write', 'deploy', 'rollback', 'admin'] as const; -const VALID_PRESETS = ['readonly', 'publisher', 'operator', 'admin'] as const; - -type Resource = (typeof VALID_RESOURCES)[number]; -type Permission = (typeof VALID_PERMISSIONS)[number]; -type Preset = (typeof VALID_PRESETS)[number]; - -interface ScopeSpec { - resource: Resource; - id: string; - permissions: Permission[]; -} - -/* --------------------------------------------------------------------- */ -/* shared presets — kept in sync with web/lib/apiKeyTypes.SCOPE_PRESETS */ -/* --------------------------------------------------------------------- */ - -function wildcardScopes(perms: readonly Permission[]): ScopeSpec[] { - return PRESET_RESOURCES.map((resource) => ({ - resource, - id: '*', - permissions: [...perms], - })); -} - -const PRESETS: Record = { - readonly: wildcardScopes(['read']), - publisher: wildcardScopes(['read', 'write']), - operator: wildcardScopes(['read', 'write', 'deploy', 'rollback']), - admin: wildcardScopes(['read', 'write', 'deploy', 'rollback', 'admin']), -}; - -/* --------------------------------------------------------------------- */ -/* parser: `roost=rst_abc:write,deploy` → ScopeSpec */ -/* --------------------------------------------------------------------- */ - -export function parseScopeSpec(raw: string): ScopeSpec | string { - const trimmed = raw.trim(); - const eq = trimmed.indexOf('='); - if (eq <= 0) { - return `scope '${raw}' must be '=:[,...]'`; - } - const resource = trimmed.slice(0, eq).trim(); - const rest = trimmed.slice(eq + 1).trim(); - if (!VALID_RESOURCES.includes(resource as Resource)) { - return `scope '${raw}': resource must be one of ${VALID_RESOURCES.join(', ')}`; - } - - const colon = rest.indexOf(':'); - if (colon <= 0) { - return `scope '${raw}' must include ':[,...]'`; - } - const id = rest.slice(0, colon).trim(); - const permsRaw = rest.slice(colon + 1).trim(); - if (!id) return `scope '${raw}': id is required (use '*' for wildcard)`; - - const perms = permsRaw - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - if (perms.length === 0) { - return `scope '${raw}': at least one permission required`; - } - for (const p of perms) { - if (!VALID_PERMISSIONS.includes(p as Permission)) { - return `scope '${raw}': '${p}' not in ${VALID_PERMISSIONS.join(', ')}`; - } - } - return { - resource: resource as Resource, - id, - permissions: [...new Set(perms as Permission[])], - }; -} - -/* --------------------------------------------------------------------- */ -/* command registration */ -/* --------------------------------------------------------------------- */ - -export function registerKeyCommands(program: Command): void { - const existingKey = program.commands.find((c) => c.name() === 'key'); - if (existingKey) { - const list = program.commands as Command[]; - const idx = list.indexOf(existingKey); - if (idx >= 0) list.splice(idx, 1); - } - - const key = program.command('key').description('manage api keys'); - - /* -------------------- create -------------------- */ - - key - .command('create') - .description('mint a new scoped api key (prints the raw key once)') - .requiredOption('--name ', 'human-readable label for the key') - .option( - '--scope ', - 'repeatable scope spec: `=:,...` (mutually exclusive with --preset)', - collectScope, - [] as string[], - ) - .option( - '--preset ', - `canonical preset: ${VALID_PRESETS.join(' | ')} (mutually exclusive with --scope)`, - ) - .option('--ttl-days ', 'lifetime in days (default 90, max 365)', '90') - .option('--environment ', `'live' or 'test' (default: live)`, 'live') - .action(async (opts, cmd) => { - const globals = cmd.optsWithGlobals(); - const { apiUrl, token } = loadConfig({ profile: globals.profile }); - if (!token) return noTokenExit(); - const json = globals.json === true; - - const scopeStrings: string[] = Array.isArray(opts.scope) ? opts.scope : []; - const hasScopes = scopeStrings.length > 0; - const hasPreset = typeof opts.preset === 'string'; - if (hasScopes && hasPreset) { - return fatal('--scope and --preset are mutually exclusive — pick one'); - } - if (!hasScopes && !hasPreset) { - return fatal('one of --scope or --preset is required'); - } - - let scopes: ScopeSpec[]; - if (hasPreset) { - if (!VALID_PRESETS.includes(opts.preset as Preset)) { - return fatal( - `--preset must be one of ${VALID_PRESETS.join(', ')}`, - ); - } - scopes = PRESETS[opts.preset as Preset]; - } else { - const parsed: ScopeSpec[] = []; - for (const s of scopeStrings) { - const result = parseScopeSpec(s); - if (typeof result === 'string') return fatal(result); - parsed.push(result); - } - scopes = parsed; - } - - const ttlDays = Number(opts.ttlDays); - if (!Number.isFinite(ttlDays) || !Number.isInteger(ttlDays) || ttlDays < 1 || ttlDays > 365) { - return fatal('--ttl-days must be an integer between 1 and 365'); - } - - const environment = opts.environment; - if (environment !== 'live' && environment !== 'test') { - return fatal(`--environment must be 'live' or 'test'`); - } - - const res = await fetch(`${apiUrl}/api/keys`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: opts.name, - scopes, - ttlDays, - environment, - }), - }); - const data = (await res.json().catch(() => ({}))) as { - success?: boolean; - key?: string; - keyId?: string; - name?: string; - environment?: string; - scopes?: ScopeSpec[]; - expiresAt?: number; - keyPrefix?: string; - detail?: string; - error?: string; - }; - if (!res.ok) { - return fatal( - `POST /api/keys failed (${res.status}): ${data.detail ?? data.error ?? JSON.stringify(data)}`, - ); - } - - if (json) { - process.stdout.write(JSON.stringify(data, null, 2) + '\n'); - return; - } - - process.stdout.write( - `owlette: key created — copy it NOW, it will not be shown again.\n\n` + - ` ${data.key}\n\n` + - ` keyId ${data.keyId}\n` + - ` name ${data.name}\n` + - ` environment ${data.environment}\n` + - ` expires at ${data.expiresAt ? new Date(data.expiresAt).toISOString() : '(unknown)'}\n` + - ` scopes ${summariseScopes(data.scopes ?? [])}\n`, - ); - }); - - /* -------------------- list -------------------- */ - - key - .command('list') - .description('list api keys for the authenticated user') - .action(async (_opts, cmd) => { - const globals = cmd.optsWithGlobals(); - const { apiUrl, token } = loadConfig({ profile: globals.profile }); - if (!token) return noTokenExit(); - const json = globals.json === true; - - const res = await fetch(`${apiUrl}/api/keys`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const data = (await res.json().catch(() => ({}))) as { - success?: boolean; - keys?: Array<{ - id: string; - name: string | null; - keyPrefix: string | null; - environment: string | null; - scopes: ScopeSpec[] | null; - expiresAt: number | null; - lastUsedAt: number | null; - rotatedAt: number | null; - revokedAt: number | null; - expired: boolean; - retired: boolean; - }>; - detail?: string; - }; - if (!res.ok) { - return fatal( - `GET /api/keys failed (${res.status}): ${data.detail ?? JSON.stringify(data)}`, - ); - } - - const keys = data.keys ?? []; - if (json) { - process.stdout.write(JSON.stringify({ keys }, null, 2) + '\n'); - return; - } - if (keys.length === 0) { - process.stdout.write('(no keys)\n'); - return; - } - - const rows = keys.map((k) => [ - k.id.slice(0, 12), - k.name ?? '', - k.environment ?? '', - statusOf(k), - k.expiresAt ? new Date(k.expiresAt).toISOString().slice(0, 10) : '', - k.lastUsedAt ? new Date(k.lastUsedAt).toISOString().slice(0, 10) : 'never', - summariseScopes(k.scopes ?? []), - ]); - process.stdout.write( - renderTable( - ['keyId', 'name', 'env', 'status', 'expires', 'last used', 'scope summary'], - rows, - ), - ); - }); - - /* -------------------- rotate -------------------- */ - - key - .command('rotate ') - .description('issue a new key with the same scopes; old works for a 24h grace') - .option('--ttl-days ', 'lifetime in days (default 90, max 365)', '90') - .action(async (keyId: string, opts, cmd) => { - const globals = cmd.optsWithGlobals(); - const { apiUrl, token } = loadConfig({ profile: globals.profile }); - if (!token) return noTokenExit(); - const json = globals.json === true; - - const ttlDays = Number(opts.ttlDays); - if (!Number.isFinite(ttlDays) || !Number.isInteger(ttlDays) || ttlDays < 1 || ttlDays > 365) { - return fatal('--ttl-days must be an integer between 1 and 365'); - } - - const res = await fetch(`${apiUrl}/api/keys/${encodeURIComponent(keyId)}/rotate`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ ttlDays }), - }); - const data = (await res.json().catch(() => ({}))) as { - success?: boolean; - key?: string; - keyId?: string; - name?: string; - environment?: string; - scopes?: ScopeSpec[]; - expiresAt?: number; - rotatedFromKeyId?: string; - previousKey?: { keyId: string; retiresAt: number }; - detail?: string; - }; - if (!res.ok) { - return fatal( - `POST /api/keys/${keyId}/rotate failed (${res.status}): ${data.detail ?? JSON.stringify(data)}`, - ); - } - - if (json) { - process.stdout.write(JSON.stringify(data, null, 2) + '\n'); - return; - } - - process.stdout.write( - `owlette: key rotated — new key shown ONCE. old key works for 24h.\n\n` + - ` ${data.key}\n\n` + - ` new keyId ${data.keyId}\n` + - ` old keyId ${data.rotatedFromKeyId ?? keyId}\n` + - ` old retires at ${data.previousKey?.retiresAt ? new Date(data.previousKey.retiresAt).toISOString() : '(unknown)'}\n` + - ` new expires at ${data.expiresAt ? new Date(data.expiresAt).toISOString() : '(unknown)'}\n`, - ); - }); - - /* -------------------- revoke -------------------- */ - - key - .command('revoke ') - .description('delete an api key immediately (no grace period)') - .option('--yes', 'skip the confirmation prompt') - .action(async (keyId: string, opts, cmd) => { - const globals = cmd.optsWithGlobals(); - const { apiUrl, token } = loadConfig({ profile: globals.profile }); - if (!token) return noTokenExit(); - const json = globals.json === true; - - if (!opts.yes && process.stdin.isTTY) { - const ok = await promptYesNo( - `revoke key ${keyId}? this takes effect immediately, with no grace period. [y/N] `, - ); - if (!ok) { - process.stdout.write('revoke cancelled\n'); - return; - } - } else if (!opts.yes && !process.stdin.isTTY) { - return fatal( - 'stdin is not a tty and --yes was not supplied; refusing to revoke silently', - ); - } - - const res = await fetch(`${apiUrl}/api/keys/${encodeURIComponent(keyId)}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - const data = (await res.json().catch(() => ({}))) as { - success?: boolean; - detail?: string; - }; - if (!res.ok) { - return fatal( - `DELETE /api/keys/${keyId} failed (${res.status}): ${data.detail ?? JSON.stringify(data)}`, - ); - } - - if (json) { - process.stdout.write(JSON.stringify({ keyId, revoked: true }, null, 2) + '\n'); - } else { - process.stdout.write(`owlette: key ${keyId} revoked\n`); - } - }); -} - -/* --------------------------------------------------------------------- */ -/* helpers */ -/* --------------------------------------------------------------------- */ - -function collectScope(value: string, previous: string[] = []): string[] { - return [...previous, value]; -} - -function summariseScopes(scopes: readonly ScopeSpec[]): string { - if (scopes.length === 0) return 'legacy (full access)'; - return scopes - .map((s) => `${s.resource}=${s.id}:${s.permissions.join('+')}`) - .join(' '); -} - -function statusOf(k: { - expired: boolean; - retired: boolean; - rotatedAt: number | null; - revokedAt: number | null; -}): string { - if (k.revokedAt) return 'revoked'; - if (k.expired) return 'expired'; - if (k.retired) return 'retired'; - if (k.rotatedAt) return 'rotated'; - return 'active'; -} - -// Delegate ascii-table rendering to the shared lib/output helper so every -// command emits byte-identical tables (makes jq/grep pipelines simpler). -const renderTable = sharedRenderTable; - -async function promptYesNo(question: string): Promise { - const { createInterface } = await import('readline'); - return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - rl.question(question, (answer) => { - rl.close(); - const normalized = answer.trim().toLowerCase(); - resolve(normalized === 'y' || normalized === 'yes'); - }); - }); -} - -function noTokenExit(): void { - process.stderr.write( - 'owlette: no token configured. run `owlette auth login` or set OWLETTE_TOKEN.\n', - ); - process.exitCode = 2; -} - -function fatal(msg: string): void { - process.stderr.write(`owlette: ${msg}\n`); - process.exitCode = 1; -} - -/** Export for tests. */ -export const _internals = { parseScopeSpec, summariseScopes, statusOf, PRESETS }; diff --git a/cli/src/commands/listen.ts b/cli/src/commands/listen.ts index d3c9e957..6bdd62e8 100644 --- a/cli/src/commands/listen.ts +++ b/cli/src/commands/listen.ts @@ -195,6 +195,11 @@ export function registerListenCommand(program: Command): void { } } + if (!stopping && process.exitCode !== 1) { + process.stderr.write('owlette: stream closed by server\n'); + process.exitCode = 1; + } + process.stderr.write( `owlette: listener stopped. ` + `connected=${counts.connected} events=${counts.event} ` + diff --git a/cli/src/commands/machine.ts b/cli/src/commands/machine.ts index ba246b35..4d9aaf7e 100644 --- a/cli/src/commands/machine.ts +++ b/cli/src/commands/machine.ts @@ -27,7 +27,13 @@ import { randomUUID } from 'crypto'; import { writeFile } from 'fs/promises'; import * as path from 'path'; import { loadConfig } from '../config'; -import { isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { + isJson, + renderTable, + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; import { stubExit } from '../lib/stubExit'; interface RoostSummary { @@ -171,7 +177,7 @@ export function registerMachineCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -224,7 +230,7 @@ export function registerMachineCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(machineId)}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -256,7 +262,7 @@ export function registerMachineCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(machineId)}/deployments`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -319,9 +325,13 @@ export function registerMachineCommands(program: Command): void { .requiredOption('--site ', 'site id that owns the machine') .option( '--monitor ', - 'monitor target: `all`, `primary`, or a non-negative integer index', + 'non-negative integer monitor index (0 captures all monitors)', ) .option('--output ', 'path to write the png (default: screenshot--.png in cwd)') + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (machineId: string, opts, cmd) => { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; @@ -330,25 +340,43 @@ export function registerMachineCommands(program: Command): void { if (opts.monitor !== undefined) { const monitor = parseMonitorOpt(String(opts.monitor)); if (typeof monitor === 'string' && monitor.startsWith('error:')) { - return fatal(monitor.slice('error:'.length)); + return usageFatal(monitor.slice('error:'.length)); } params.monitor = monitor; } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-machine-screenshot-${randomUUID()}`; const queueUrl = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(machineId)}/commands`; - const queueRes = await fetch(queueUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `cli-machine-screenshot-${randomUUID()}`, - }, - body: JSON.stringify({ type: 'capture_screenshot', params }), - }); + let queueRes: Response; + try { + queueRes = await fetchWithTimeout(queueUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ type: 'capture_screenshot', params }), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/machines/${machineId}/commands`, + idempotencyKey, + cause: err, + }); + return; + } const queueData = (await queueRes.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!queueRes.ok) { - return fatalProblem(`POST /api/sites/${opts.site}/machines/${machineId}/commands`, queueRes.status, queueData); + return fatalProblem( + `POST /api/sites/${opts.site}/machines/${machineId}/commands`, + queueRes.status, + queueData, + 'screenshot', + ); } const commandId = queueData.data?.commandId; @@ -365,12 +393,12 @@ export function registerMachineCommands(program: Command): void { await sleep(SCREENSHOT_POLL_INTERVAL_MS); } if (!json) process.stdout.write('.'); - const pollRes = await fetch(pollUrl, { headers: { Authorization: `Bearer ${token}` } }); + const pollRes = await fetchWithTimeout(pollUrl, { headers: { Authorization: `Bearer ${token}` } }); const pollData = (await pollRes.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!pollRes.ok) { if (!json) process.stdout.write('\n'); - return fatalProblem(`GET ${pollUrl}`, pollRes.status, pollData); + return fatalProblem(`GET ${pollUrl}`, pollRes.status, pollData, 'screenshot'); } const status = pollData.data?.status; if (status === 'completed' || status === 'failed') { @@ -457,6 +485,10 @@ function registerSimpleCommandVerb( '--delay-seconds ', 'delay before the agent fires the command (default: 0)', ) + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (machineId: string, opts, cmd) => { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; @@ -465,21 +497,34 @@ function registerSimpleCommandVerb( if (opts.delaySeconds !== undefined) { const n = Number(opts.delaySeconds); if (!Number.isFinite(n) || n < 0) { - return fatal('--delay-seconds must be a non-negative number'); + return usageFatal('--delay-seconds must be a non-negative number'); } params.delay_seconds = Math.floor(n); } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-machine-${cfg.verb}-${randomUUID()}`; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(machineId)}/commands`; - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `cli-machine-${cfg.verb}-${randomUUID()}`, - }, - body: JSON.stringify({ type: cfg.commandType, params }), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ type: cfg.commandType, params }), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/machines/${machineId}/commands`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!res.ok) { @@ -583,10 +628,15 @@ function fatal(msg: string): void { * (or any other server route that uses the canonical envelope). Pulls * `code` + `detail` and adds a hint for the stable codes we surface. */ -function fatalProblem(operation: string, status: number, env: ProblemEnvelope): void { +function fatalProblem( + operation: string, + status: number, + env: ProblemEnvelope, + context?: 'screenshot', +): void { const code = env.code ?? '(no code)'; const detail = env.detail ?? JSON.stringify(env); - const hint = hintForCode(code); + const hint = hintForCode(code, context); const suffix = hint ? `\n hint: ${hint}` : ''; process.stderr.write( `owlette: ${operation} failed (${status}, code=${code}): ${detail}${suffix}\n`, @@ -594,13 +644,16 @@ function fatalProblem(operation: string, status: number, env: ProblemEnvelope): process.exitCode = 1; } -function hintForCode(code: string): string | null { +function hintForCode(code: string, context?: 'screenshot'): string | null { switch (code) { case 'machine_offline': return 'machine appears offline; check the dashboard heartbeat'; case 'unsupported_command_type': return 'supported types: reboot_machine, shutdown_machine, capture_screenshot'; case 'scope_insufficient': + if (context === 'screenshot') { + return 'screenshot requires both machine=:write and machine=:read scopes'; + } return 'your key is missing the required scope: machine=:write'; default: return null; @@ -608,15 +661,16 @@ function hintForCode(code: string): string | null { } /** - * Parse `--monitor` value: `all`, `primary`, or a non-negative integer. + * Parse `--monitor` value as a non-negative integer. The agent treats + * monitor 0 as the all-monitors virtual display; named values cannot be + * represented by its current command contract. * On error, returns `error:` so the caller can surface it via * `fatal()`. */ function parseMonitorOpt(raw: string): string | number { - if (raw === 'all' || raw === 'primary') return raw; const n = Number(raw); if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) { - return `error:--monitor must be 'all', 'primary', or a non-negative integer`; + return `error:--monitor must be a non-negative integer (0 captures all monitors; named monitors are not supported)`; } return n; } diff --git a/cli/src/commands/process.ts b/cli/src/commands/process.ts index 0eb6a384..f806ebed 100644 --- a/cli/src/commands/process.ts +++ b/cli/src/commands/process.ts @@ -30,7 +30,13 @@ import { Command } from 'commander'; import { randomUUID } from 'crypto'; import { loadConfig } from '../config'; -import { isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { + isJson, + renderTable, + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; interface ProcessSummary { processId: string; @@ -122,7 +128,7 @@ export function registerProcessCommands(program: Command): void { if (!token) return; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes`; - const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` } }); const data = (await res.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!res.ok) { @@ -166,7 +172,7 @@ export function registerProcessCommands(program: Command): void { if (!token) return; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes/${encodeURIComponent(processId)}`; - const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` } }); const data = (await res.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!res.ok) { @@ -203,12 +209,16 @@ export function registerProcessCommands(program: Command): void { .option('--priority ', 'process priority (idle|below|normal|above|high|realtime)') .option('--visibility ', 'window visibility (visible|hidden|minimized|maximized)') .option('--launch-mode ', 'launch mode (off|always|scheduled)') + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (opts, cmd) => { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; if (opts.launchMode && !VALID_SCHEDULE_MODES.includes(opts.launchMode as ScheduleMode)) { - return fatal(`--launch-mode must be one of ${VALID_SCHEDULE_MODES.join(', ')}`); + return usageFatal(`--launch-mode must be one of ${VALID_SCHEDULE_MODES.join(', ')}`); } const body: Record = { @@ -220,16 +230,29 @@ export function registerProcessCommands(program: Command): void { if (opts.visibility !== undefined) body.visibility = opts.visibility; if (opts.launchMode !== undefined) body.launch_mode = opts.launchMode; + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-process-create-${randomUUID()}`; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes`; - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `cli-process-create-${randomUUID()}`, - }, - body: JSON.stringify(body), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/machines/${opts.machine}/processes`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as OkEnvelope<{ processId: string }> & ProblemEnvelope; if (!res.ok) { @@ -262,6 +285,10 @@ export function registerProcessCommands(program: Command): void { .option('--priority ', 'process priority (idle|below|normal|above|high|realtime)') .option('--visibility ', 'window visibility (visible|hidden|minimized|maximized)') .option('--launch-mode ', 'launch mode (off|always|scheduled)') + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (processId: string, opts, cmd) => { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; @@ -270,7 +297,7 @@ export function registerProcessCommands(program: Command): void { // unknown options out so a user can't pass `--id` directly. We also // guard `--launch-mode` for early feedback. if (opts.launchMode && !VALID_SCHEDULE_MODES.includes(opts.launchMode as ScheduleMode)) { - return fatal(`--launch-mode must be one of ${VALID_SCHEDULE_MODES.join(', ')}`); + return usageFatal(`--launch-mode must be one of ${VALID_SCHEDULE_MODES.join(', ')}`); } const body: Record = {}; @@ -282,19 +309,32 @@ export function registerProcessCommands(program: Command): void { if (opts.launchMode !== undefined) body.launch_mode = opts.launchMode; if (Object.keys(body).length === 0) { - return fatal('at least one field flag is required (--name, --exe, --cwd, --priority, --visibility, --launch-mode)'); + return usageFatal('at least one field flag is required (--name, --exe, --cwd, --priority, --visibility, --launch-mode)'); } + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-process-update-${randomUUID()}`; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes/${encodeURIComponent(processId)}`; - const res = await fetch(url, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `cli-process-update-${randomUUID()}`, - }, - body: JSON.stringify(body), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `PATCH /api/sites/${opts.site}/machines/${opts.machine}/processes/${processId}`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as OkEnvelope<{ processId: string }> & ProblemEnvelope; if (!res.ok) { @@ -333,11 +373,11 @@ export function registerProcessCommands(program: Command): void { return; } } else if (!opts.yes && !process.stdin.isTTY) { - return fatal('stdin is not a tty and --yes was not supplied; refusing to delete silently'); + return usageFatal('stdin is not a tty and --yes was not supplied; refusing to delete silently'); } const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes/${encodeURIComponent(processId)}`; - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); @@ -383,13 +423,17 @@ export function registerProcessCommands(program: Command): void { '--blocks ', 'schedule blocks as inline json (required when --mode=scheduled)', ) + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (processId: string, opts, cmd) => { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; const mode = opts.mode; if (!VALID_SCHEDULE_MODES.includes(mode as ScheduleMode)) { - return fatal(`--mode must be one of ${VALID_SCHEDULE_MODES.join(', ')}`); + return usageFatal(`--mode must be one of ${VALID_SCHEDULE_MODES.join(', ')}`); } let blocks: unknown = undefined; @@ -397,32 +441,45 @@ export function registerProcessCommands(program: Command): void { try { blocks = JSON.parse(String(opts.blocks)); } catch (err) { - return fatal( + return usageFatal( `--blocks must be valid json: ${(err as Error).message}`, ); } if (!Array.isArray(blocks)) { - return fatal('--blocks must be a json array of schedule blocks'); + return usageFatal('--blocks must be a json array of schedule blocks'); } } if (mode === 'scheduled' && (!Array.isArray(blocks) || blocks.length === 0)) { - return fatal('--blocks is required and must be a non-empty json array when --mode=scheduled'); + return usageFatal('--blocks is required and must be a non-empty json array when --mode=scheduled'); } const body: Record = { mode }; if (blocks !== undefined) body.blocks = blocks; + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-process-schedule-${randomUUID()}`; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes/${encodeURIComponent(processId)}/schedule`; - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `cli-process-schedule-${randomUUID()}`, - }, - body: JSON.stringify(body), - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify(body), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/machines/${opts.machine}/processes/${processId}/schedule`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!res.ok) { @@ -455,22 +512,39 @@ function registerControlVerb( .description(description) .requiredOption('--site ', 'site id that owns the machine') .requiredOption('--machine ', 'machine id that owns the process') + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (processId: string, opts, cmd) => { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-process-${verb}-${randomUUID()}`; const url = `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/machines/${encodeURIComponent(opts.machine)}/processes/${encodeURIComponent(processId)}/${verb}`; - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `cli-process-${verb}-${randomUUID()}`, - }, - // No body, but the server reads `request.text()` for idempotency key - // hashing — sending an empty string keeps that consistent. - body: '', - }); + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + // No body, but the server reads `request.text()` for idempotency key + // hashing — sending an empty string keeps that consistent. + body: '', + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/sites/${opts.site}/machines/${opts.machine}/processes/${processId}/${verb}`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as OkEnvelope & ProblemEnvelope; if (!res.ok) { @@ -574,7 +648,7 @@ function hintForCode(code: string): string | null { async function promptYesNo(question: string): Promise { const { createInterface } = await import('readline'); return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index c2658533..e11f15f2 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -17,6 +17,7 @@ */ import { createReadStream, promises as fs } from 'fs'; +import { createHash, randomUUID } from 'crypto'; import { hostname, platform } from 'os'; import { join } from 'path'; import { Command } from 'commander'; @@ -30,13 +31,17 @@ import { buildVersion, summariseVersion, uniqueHashes, + versionIdForVersion, } from '../lib/versionBuilder'; +import { fetchWithTimeout } from '../lib/http'; +import { unconfirmedMutationFatal } from '../lib/output'; const UPLOAD_CONCURRENCY = 8; const CHECK_BATCH_SIZE = 900; // server cap is 1000 — stay under. const PUSH_MAX_RETRIES = 5; const CLI_VERSION = '0.1.0'; const MAX_DESCRIPTION_LENGTH = 500; +const SIGNED_URL_REFRESH_SKEW_MS = 60_000; export function registerPushCommand(program: Command): void { const roost = (program.commands.find((c) => c.name() === 'roost') as Command) ?? program.command('roost'); @@ -72,6 +77,10 @@ export function registerPushCommand(program: Command): void { 'comma-separated list of target machine ids (overrides roost.targets)', ) .option('--extract-path ', 'extract root override') + .option( + '--idempotency-key ', + 'optional Idempotency-Key header for the publish request', + ) .action(async (dir: string, opts, cmd) => { const globals = cmd.optsWithGlobals(); const { apiUrl, token, profile } = loadConfig({ profile: globals.profile }); @@ -114,6 +123,13 @@ export function registerPushCommand(program: Command): void { } input.description = desc; } + if (opts.idempotencyKey) { + input.idempotencyKey = String(opts.idempotencyKey); + input.idempotencyKeyWasProvided = true; + } else { + input.idempotencyKey = `cli-push-${randomUUID()}`; + input.idempotencyKeyWasProvided = false; + } await runPush(input); }); } @@ -133,6 +149,8 @@ interface PushInputs { targets?: string[]; extractPath?: string; description?: string; + idempotencyKey?: string; + idempotencyKeyWasProvided?: boolean; json: boolean; } @@ -187,12 +205,15 @@ async function runPush(input: PushInputs): Promise { if (missing.length > 0) { log(json, 'owlette: minting signed upload urls…'); - const urls = await mintUploadUrls({ apiUrl, token, siteId, hashes: missing }); + const uploadUrls = await mintUploadUrls({ apiUrl, token, siteId, hashes: missing }); log(json, `owlette: uploading ${missing.length} chunks (${UPLOAD_CONCURRENCY}-wide)…`); await uploadChunksInParallel({ missing, - urls, + uploadUrls, + apiUrl, + token, + siteId, dir, files, json, @@ -212,13 +233,25 @@ async function runPush(input: PushInputs): Promise { token, siteId, roostId, + dir, version, }; if (input.name) publishInput.name = input.name; if (input.targets) publishInput.targets = input.targets; if (input.extractPath) publishInput.extractPath = input.extractPath; if (input.description !== undefined) publishInput.description = input.description; - const result = await publishWithRetry(publishInput); + if (input.idempotencyKey) publishInput.idempotencyKey = input.idempotencyKey; + if (input.idempotencyKeyWasProvided !== undefined) { + publishInput.idempotencyKeyWasProvided = input.idempotencyKeyWasProvided; + } + let result: PublishResult; + try { + result = await publishWithRetry(publishInput); + } catch (err) { + if (err instanceof HandledFatalError) return; + fatal((err as Error).message); + return; + } if (json) { process.stdout.write(JSON.stringify(result, null, 2) + '\n'); @@ -241,12 +274,14 @@ async function apiPost( path: string, token: string, body: Record, + extraHeaders: Record = {}, ): Promise<{ status: number; data: T; headers: Headers }> { - const res = await fetch(`${apiUrl}${path}`, { + const res = await fetchWithTimeout(`${apiUrl}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', + ...extraHeaders, }, body: JSON.stringify(body), }); @@ -288,11 +323,17 @@ interface MintUploadUrlsInput { hashes: readonly string[]; } -async function mintUploadUrls(input: MintUploadUrlsInput): Promise> { +interface UploadUrlBatch { + urls: Record; + expiresAtMs: number | null; +} + +async function mintUploadUrls(input: MintUploadUrlsInput): Promise { const all: Record = {}; + let earliestExpiresAtMs: number | null = null; for (let i = 0; i < input.hashes.length; i += CHECK_BATCH_SIZE) { const batch = input.hashes.slice(i, i + CHECK_BATCH_SIZE); - const res = await apiPost<{ urls?: Record }>( + const res = await apiPost<{ urls?: Record; expiresAt?: string }>( input.apiUrl, '/api/chunks/upload-urls', input.token, @@ -304,13 +345,25 @@ async function mintUploadUrls(input: MintUploadUrlsInput): Promise; + uploadUrls: UploadUrlBatch; + apiUrl: string; + token: string; + siteId: string; dir: string; files: readonly ChunkedFileEntry[]; json: boolean; @@ -343,16 +396,45 @@ async function uploadChunksInParallel(input: UploadChunksInput): Promise { const total = input.missing.length; const queue = [...input.missing]; + async function refreshUrl(hash: string): Promise { + const refreshed = await mintUploadUrls({ + apiUrl: input.apiUrl, + token: input.token, + siteId: input.siteId, + hashes: [hash], + }); + Object.assign(input.uploadUrls.urls, refreshed.urls); + input.uploadUrls.expiresAtMs = refreshed.expiresAtMs; + const nextUrl = input.uploadUrls.urls[hash]; + if (!nextUrl) throw new Error(`server did not return a refreshed upload url for ${hash}`); + return nextUrl; + } + async function worker(): Promise { for (;;) { const hash = queue.shift(); if (!hash) return; const source = sourceByHash.get(hash); - const url = input.urls[hash]; + let url = input.uploadUrls.urls[hash]; if (!source || !url) { throw new Error(`internal: no source for chunk ${hash}`); } - await putChunk(hash, source.absPath, source.offset, source.size, url); + if ( + input.uploadUrls.expiresAtMs !== null && + Date.now() + SIGNED_URL_REFRESH_SKEW_MS >= input.uploadUrls.expiresAtMs + ) { + url = await refreshUrl(hash); + } + try { + await putChunk(hash, source.absPath, source.offset, source.size, url); + } catch (err) { + if (err instanceof ChunkPutError && (err.status === 401 || err.status === 403)) { + const refreshedUrl = await refreshUrl(hash); + await putChunk(hash, source.absPath, source.offset, source.size, refreshedUrl); + } else { + throw err; + } + } uploaded += 1; if (!input.json && uploaded % 10 === 0) { process.stderr.write(` uploaded ${uploaded}/${total}\n`); @@ -370,6 +452,15 @@ async function uploadChunksInParallel(input: UploadChunksInput): Promise { } } +class ChunkPutError extends Error { + constructor( + message: string, + public status?: number, + ) { + super(message); + } +} + async function putChunk( hash: string, absPath: string, @@ -394,6 +485,10 @@ async function putChunk( `chunk ${hash}: expected ${size} bytes, read ${body.length} from ${absPath}`, ); } + const actualHash = createHash('sha256').update(body).digest('hex'); + if (actualHash !== hash) { + throw new Error(`chunk ${hash}: source bytes changed while reading ${absPath}`); + } // One retry — covers transient R2 5xx / connection resets. let lastErr: Error | null = null; @@ -405,12 +500,18 @@ async function putChunk( headers: { 'Content-Type': 'application/octet-stream' }, }); if (!res.ok) { - throw new Error(`PUT ${hash} → ${res.status} ${await res.text().catch(() => '')}`); + const detail = await res.text().catch(() => ''); + const err = new ChunkPutError(`PUT ${hash} → ${res.status} ${detail}`, res.status); + if (res.status >= 500 && attempt === 0) throw err; + throw err; } return; } catch (err) { lastErr = err as Error; - if (attempt === 0) await new Promise((r) => setTimeout(r, 250)); + const retryable = + !(err instanceof ChunkPutError) || (err.status !== undefined && err.status >= 500); + if (attempt === 0 && retryable) await new Promise((r) => setTimeout(r, 250)); + else break; } } throw lastErr ?? new Error(`PUT ${hash}: unknown error`); @@ -421,11 +522,14 @@ interface PublishInput { token: string; siteId: string; roostId: string; + dir?: string; version: ReturnType; name?: string; targets?: string[]; extractPath?: string; description?: string; + idempotencyKey?: string; + idempotencyKeyWasProvided?: boolean; } interface PublishResult { @@ -435,30 +539,59 @@ interface PublishResult { previousVersionId: string | null; } +class HandledFatalError extends Error {} + async function publishWithRetry(input: PublishInput): Promise { - let expectedCurrent: string | null = null; + const localVersionId = versionIdForVersion(input.version); + let expectedCurrent = expectedHeadForPublish( + await fetchRoostHead(input), + localVersionId, + input.idempotencyKey !== undefined && input.idempotencyKeyWasProvided !== false, + ); let lastStatus = 0; let lastBody: unknown = null; + const baseIdempotencyKey = input.idempotencyKey ?? `cli-push-${randomUUID()}`; for (let attempt = 0; attempt < PUSH_MAX_RETRIES; attempt++) { + const attemptIdempotencyKey = + attempt === 0 ? baseIdempotencyKey : `${baseIdempotencyKey}-${attempt}`; const payload: Record = { siteId: input.siteId, version: input.version, }; - if (expectedCurrent !== null) payload.expectedCurrentVersionId = expectedCurrent; + if (expectedCurrent !== undefined) payload.expectedCurrentVersionId = expectedCurrent; if (input.name) payload.name = input.name; if (input.targets && input.targets.length > 0) payload.targets = input.targets; if (input.extractPath) payload.extractPath = input.extractPath; if (input.description !== undefined) payload.description = input.description; - const res = await apiPost< - PublishResult & { currentId?: string | null; detail?: string } - >( - input.apiUrl, - `/api/roosts/${input.roostId}/versions`, - input.token, - payload, - ); + let res: { + status: number; + data: PublishResult & { + code?: string; + currentId?: string | null; + detail?: string; + }; + headers: Headers; + }; + try { + res = await apiPost< + PublishResult & { code?: string; currentId?: string | null; detail?: string } + >( + input.apiUrl, + `/api/roosts/${input.roostId}/versions`, + input.token, + payload, + { 'Idempotency-Key': attemptIdempotencyKey }, + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/roosts/${input.roostId}/versions`, + idempotencyKey: attemptIdempotencyKey, + cause: err, + }); + throw new HandledFatalError('unconfirmed publish failure handled'); + } if (res.status === 201 || res.status === 200) { const result: PublishResult = { @@ -475,12 +608,29 @@ async function publishWithRetry(input: PublishInput): Promise { lastStatus = res.status; lastBody = res.data; - // 412 = head changed mid-flight → refresh expected head + retry. + // 412 = head changed mid-flight -> refresh expected head + retry. + // Other 412s, such as missing chunks, are real publish failures. if (res.status === 412) { - const detail = (res.data as { detail?: string; currentId?: string }).detail ?? ''; - const matched = /\((?[a-f0-9-]+|null)\)/.exec(detail)?.groups?.cur ?? null; - expectedCurrent = - matched && matched !== 'null' ? matched : (res.data as { currentId?: string }).currentId ?? null; + const problem = res.data as { + code?: string; + detail?: string; + currentId?: string | null; + }; + let nextExpected = currentHeadFromProblem(problem); + if (problem.code !== 'version_stale' && nextExpected === undefined) { + throw new Error( + `version publish failed (${res.status}): ${JSON.stringify(res.data)}`, + ); + } + if (nextExpected === undefined) { + nextExpected = (await fetchRoostHead(input))?.currentVersionId; + } + if (nextExpected === undefined) { + throw new Error( + 'publish conflicted (stale head) and the current head could not be determined; re-run `owlette roost push`', + ); + } + expectedCurrent = nextExpected; continue; } @@ -493,6 +643,62 @@ async function publishWithRetry(input: PublishInput): Promise { ); } +function currentHeadFromProblem(problem: { + detail?: string; + currentId?: string | null; +}): string | null | undefined { + const matched = /current head \((?[^)]+)\)/.exec(problem.detail ?? '')?.groups + ?.cur; + if (matched !== undefined) return matched === 'null' ? null : matched; + if (typeof problem.currentId === 'string') return problem.currentId; + if (problem.currentId === null) return null; + return undefined; +} + +interface RoostHead { + currentVersionId: string | null; + previousVersionId: string | null; +} + +function expectedHeadForPublish( + head: RoostHead | undefined, + localVersionId: string, + explicitReplayKey: boolean, +): string | null | undefined { + if (!head) return undefined; + if (explicitReplayKey && head.currentVersionId === localVersionId) { + return head.previousVersionId; + } + return head.currentVersionId; +} + +async function fetchRoostHead(input: PublishInput): Promise { + try { + const qs = new URLSearchParams({ siteId: input.siteId }); + const res = await fetchWithTimeout( + `${input.apiUrl}/api/roosts/${encodeURIComponent(input.roostId)}?${qs}`, + { headers: { Authorization: `Bearer ${input.token}` } }, + ); + if (res.status === 404) return { currentVersionId: null, previousVersionId: null }; + if (!res.ok) return undefined; + const data = (await res.json().catch(() => ({}))) as { + currentVersionId?: unknown; + previousVersionId?: unknown; + }; + let currentVersionId: string | null; + if (typeof data.currentVersionId === 'string') currentVersionId = data.currentVersionId; + else if (data.currentVersionId === null || data.currentVersionId === undefined) { + currentVersionId = null; + } else return undefined; + + const previousVersionId = + typeof data.previousVersionId === 'string' ? data.previousVersionId : null; + return { currentVersionId, previousVersionId }; + } catch { + return undefined; + } +} + /* --------------------------------------------------------------------- */ /* small utilities */ /* --------------------------------------------------------------------- */ @@ -512,6 +718,11 @@ function humanBytes(n: number): string { return `${v.toFixed(v < 10 && u > 0 ? 2 : 1)} ${units[u]}`; } +function fatal(msg: string): void { + process.stderr.write(`owlette: ${msg}\n`); + process.exitCode = 1; +} + /** Export for tests. */ export const _internals = { CHUNK_SIZE_BYTES, diff --git a/cli/src/commands/quota.ts b/cli/src/commands/quota.ts index 89deb9ef..d852dfa1 100644 --- a/cli/src/commands/quota.ts +++ b/cli/src/commands/quota.ts @@ -17,7 +17,8 @@ import { Command } from 'commander'; import { loadConfig } from '../config'; -import { humanBytes, isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { humanBytes, isJson, renderTable, usageFatal } from '../lib/output'; interface QuotaAlarm { id: string; @@ -87,7 +88,7 @@ export function registerQuotaCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/quota`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -126,14 +127,12 @@ export function registerQuotaCommands(program: Command): void { const period = opts.period as string; if (!VALID_PERIODS.includes(period as QuotaPeriod)) { - fatal( - `--period must be one of ${VALID_PERIODS.join(', ')} (got '${period}')`, - ); + usageFatal(`--period must be one of ${VALID_PERIODS.join(', ')} (got '${period}')`); return; } const qs = new URLSearchParams({ period }); - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/sites/${encodeURIComponent(opts.site)}/quota/history?${qs}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -253,9 +252,9 @@ function resolveAuth(cmd: Command): { apiUrl: string; token: string | null; json return { apiUrl, token, json: isJson(cmd) }; } -function fatal(msg: string): void { +function fatal(msg: string, exitCode = 1): void { process.stderr.write(`owlette: ${msg}\n`); - process.exitCode = 1; + process.exitCode = exitCode; } /** Export for unit tests. */ diff --git a/cli/src/commands/rollback.ts b/cli/src/commands/rollback.ts index 8f2cf433..9b319093 100644 --- a/cli/src/commands/rollback.ts +++ b/cli/src/commands/rollback.ts @@ -15,9 +15,9 @@ * prompt; otherwise `--yes` is required (no silent rollbacks from * pipes). * 5. POST /api/roosts/{id}/rollback with { siteId, targetVersion }. - * `targetVersion` is the raw operator input — the server resolves - * number-or-id-or-alias into a concrete versionId inside the - * rollback handler, so the client stays dumb about the grammar. + * The diff preview resolves number-or-id-or-alias refs first; the + * mutation uses that concrete version id so idempotent retries can + * replay the same body. * * Exit codes: * 0 — rollback succeeded (or user said 'no' to the prompt) @@ -26,8 +26,11 @@ */ import { Command } from 'commander'; +import { randomUUID } from 'crypto'; import { createInterface } from 'readline'; import { loadConfig } from '../config'; +import { fetchWithTimeout } from '../lib/http'; +import { usageFatal } from '../lib/output'; import { _internals as roostInternals } from './roost'; interface RoostDetail { @@ -91,6 +94,10 @@ export function registerRollbackCommand(program: Command): void { 'explicit target version (id, #N, vN, "current", "previous", "first"); default: previousVersionId', ) .option('--yes', 'skip the confirmation prompt (required when stdin is not a tty)') + .option( + '--idempotency-key ', + 'optional Idempotency-Key header (auto-generated if omitted)', + ) .action(async (roostId: string, opts, cmd) => { const globals = cmd.optsWithGlobals(); const { apiUrl, token } = loadConfig({ profile: globals.profile }); @@ -116,7 +123,7 @@ export function registerRollbackCommand(program: Command): void { return; } if (!roost.currentVersionId) { - fatal(`roost ${roostId} has no currentVersionId — nothing to roll back from`); + usageFatal(`roost ${roostId} has no currentVersionId — nothing to roll back from`); return; } @@ -128,7 +135,7 @@ export function registerRollbackCommand(program: Command): void { (typeof opts.to === 'string' && opts.to.length > 0 ? opts.to : null) ?? roost.previousVersionId; if (!targetRef) { - fatal( + usageFatal( 'no rollback target: the roost has no previousVersionId. pass --to explicitly.', ); return; @@ -154,21 +161,14 @@ export function registerRollbackCommand(program: Command): void { // Refuse a no-op rollback — the diff endpoint resolved both refs // and reported identical versions, so there's nothing to flip. if (diff.toVersion && diff.fromVersion && diff.toVersion === diff.fromVersion) { - fatal( + usageFatal( `target version resolves to ${diff.toVersion}, which is already the current version. pass --to to a different version.`, ); return; } + const resolvedTargetVersionId = diff.toVersion ?? diff.versionId; - if (json) { - process.stdout.write( - JSON.stringify( - { action: 'plan', roost, target: targetRef, diff }, - null, - 2, - ) + '\n', - ); - } else { + if (!json) { process.stdout.write( `about to roll back roost '${roost.name}' (${roostId})\n` + ` current ${roost.currentVersionId}\n` + @@ -180,20 +180,47 @@ export function registerRollbackCommand(program: Command): void { // 4. Confirm. if (!opts.yes) { if (!process.stdin.isTTY) { - fatal( + usageFatal( 'stdin is not a tty and --yes was not supplied; refusing to roll back silently.', ); return; } const ok = await promptYesNo('proceed with rollback? [y/N] '); if (!ok) { - process.stdout.write('rollback cancelled\n'); + if (json) { + process.stdout.write( + JSON.stringify({ action: 'cancelled', roost, target: targetRef, diff }, null, 2) + '\n', + ); + } else { + process.stdout.write('rollback cancelled\n'); + } return; } } // 5. Fire. - const result = await performRollback(apiUrl, token, roostId, siteId, targetRef); + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-rollback-${randomUUID()}`; + let result: RollbackResponse | null; + try { + result = await performRollback( + apiUrl, + token, + roostId, + siteId, + resolvedTargetVersionId, + idempotencyKey, + ); + } catch (err) { + unconfirmedRollbackFatal({ + operation: `POST /api/roosts/${roostId}/rollback`, + idempotencyKey, + targetVersionId: resolvedTargetVersionId, + cause: err, + }); + return; + } if (!result) { process.exitCode = 1; return; @@ -201,7 +228,7 @@ export function registerRollbackCommand(program: Command): void { if (json) { process.stdout.write( - JSON.stringify({ action: 'rolled_back', result }, null, 2) + '\n', + JSON.stringify({ action: 'rolled_back', roost, target: targetRef, diff, result }, null, 2) + '\n', ); } else { process.stdout.write( @@ -224,7 +251,7 @@ async function fetchRoost( siteId: string, ): Promise { const qs = new URLSearchParams({ siteId }); - const res = await fetch(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}?${qs}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}?${qs}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as RoostDetail; @@ -246,7 +273,7 @@ async function fetchDiff( from: string, ): Promise { const qs = new URLSearchParams({ siteId, against: from }); - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/versions/${encodeURIComponent(to)}/diff?${qs}`, { headers: { Authorization: `Bearer ${token}` } }, ); @@ -264,12 +291,14 @@ async function performRollback( roostId: string, siteId: string, targetVersion: string, + idempotencyKey: string, ): Promise { - const res = await fetch(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/rollback`, { + const res = await fetchWithTimeout(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/rollback`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ siteId, targetVersion }), }); @@ -294,7 +323,7 @@ async function performRollback( */ function promptYesNo(question: string): Promise { return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); @@ -303,8 +332,24 @@ function promptYesNo(question: string): Promise { }); } -function fatal(msg: string): void { +function fatal(msg: string, exitCode = 1): void { process.stderr.write(`owlette: ${msg}\n`); + process.exitCode = exitCode; +} + +function unconfirmedRollbackFatal(input: { + operation: string; + idempotencyKey: string; + targetVersionId: string; + cause: unknown; +}): void { + const detail = input.cause instanceof Error ? input.cause.message : String(input.cause); + process.stderr.write( + `owlette: ${input.operation} did not return a confirmed response: ${detail}\n` + + ` The request may or may not have completed.\n` + + ` Idempotency-Key: ${input.idempotencyKey}\n` + + ` To retry safely, re-run your original command with \`--to ${input.targetVersionId} --idempotency-key ${input.idempotencyKey}\` appended.\n`, + ); process.exitCode = 1; } diff --git a/cli/src/commands/roost-deploy.ts b/cli/src/commands/roost-deploy.ts index 39c1bc6b..7a4279ac 100644 --- a/cli/src/commands/roost-deploy.ts +++ b/cli/src/commands/roost-deploy.ts @@ -25,6 +25,11 @@ import { Command } from 'commander'; import { randomUUID } from 'crypto'; import { loadConfig } from '../config'; +import { fetchWithTimeout } from '../lib/http'; +import { + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; interface DeployResponse { rolloutId: string; @@ -92,7 +97,7 @@ export function registerRoostDeployCommand(program: Command): void { .map((m: string) => m.trim()) .filter(Boolean); if (machines.length === 0) { - fatal('--machines must contain at least one non-empty id when provided'); + usageFatal('--machines must contain at least one non-empty id when provided'); return; } body.machines = machines; @@ -101,7 +106,7 @@ export function registerRoostDeployCommand(program: Command): void { if (opts.at) { const parsed = Date.parse(opts.at); if (Number.isNaN(parsed)) { - fatal(`--at '${opts.at}' is not a valid iso8601 timestamp`); + usageFatal(`--at '${opts.at}' is not a valid iso8601 timestamp`); return; } body.scheduleAt = new Date(parsed).toISOString(); @@ -113,23 +118,42 @@ export function registerRoostDeployCommand(program: Command): void { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }; + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : opts.dryRun + ? null + : `cli-deploy-${randomUUID()}`; if (opts.idempotencyKey) { - headers['Idempotency-Key'] = String(opts.idempotencyKey); - } else if (!opts.dryRun) { + headers['Idempotency-Key'] = idempotencyKey!; + } else if (idempotencyKey) { // Auto-key non-dry-run deploys so an accidental retry (network // blip, ctrl-c → rerun) doesn't create a second rollout. Dry // runs don't mutate anything, so no caching benefit there. - headers['Idempotency-Key'] = `cli-deploy-${randomUUID()}`; + headers['Idempotency-Key'] = idempotencyKey; } - const res = await fetch( - `${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/deploy`, - { - method: 'POST', - headers, - body: JSON.stringify(body), - }, - ); + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/deploy`, + { + method: 'POST', + headers, + body: JSON.stringify(body), + }, + ); + } catch (err) { + if (idempotencyKey && !opts.dryRun) { + unconfirmedMutationFatal({ + operation: `POST /api/roosts/${roostId}/deploy`, + idempotencyKey, + cause: err, + }); + } else { + fatal(`POST /api/roosts/${roostId}/deploy failed: ${(err as Error).message}`); + } + return; + } const data = (await res.json().catch(() => ({}))) as DeployResponse; if (!res.ok) { diff --git a/cli/src/commands/roost.ts b/cli/src/commands/roost.ts index 2134cb0a..cc7c8c44 100644 --- a/cli/src/commands/roost.ts +++ b/cli/src/commands/roost.ts @@ -17,7 +17,8 @@ import { Command } from 'commander'; import { loadConfig } from '../config'; -import { humanBytes, isJson, renderTable, truncate } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { humanBytes, isJson, renderTable, truncate, usageFatal } from '../lib/output'; interface RoostListItem { roostId: string; @@ -146,7 +147,7 @@ export function registerRoostInspectCommands(program: Command): void { if (cursor) qs.set('cursor', cursor); if (opts.includeDeleted) qs.set('includeDeleted', 'true'); - const res = await fetch(`${apiUrl}/api/roosts?${qs}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/roosts?${qs}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as { @@ -199,7 +200,7 @@ export function registerRoostInspectCommands(program: Command): void { if (!token) return; const qs = new URLSearchParams({ siteId: opts.site }); - const res = await fetch(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}?${qs}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}?${qs}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as RoostDetail & { detail?: string }; @@ -235,7 +236,7 @@ export function registerRoostInspectCommands(program: Command): void { let toRef = opts.version as string | undefined; if (!toRef) { const qs = new URLSearchParams({ siteId: opts.site }); - const res = await fetch(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}?${qs}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/roosts/${encodeURIComponent(roostId)}?${qs}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as RoostDetail & { detail?: string }; @@ -247,13 +248,13 @@ export function registerRoostInspectCommands(program: Command): void { } toRef = data.currentVersionId ?? undefined; if (!toRef) { - fatal('roost has no currentVersionId; pass --version explicitly'); + usageFatal('roost has no currentVersionId; pass --version explicitly'); return; } } const qs = new URLSearchParams({ siteId: opts.site, against: opts.against }); - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/versions/${encodeURIComponent( toRef, )}/diff?${qs}`, @@ -297,7 +298,7 @@ export function registerRoostInspectCommands(program: Command): void { }); if (cursor) qs.set('cursor', cursor); - const res = await fetch( + const res = await fetchWithTimeout( `${apiUrl}/api/roosts/${encodeURIComponent(roostId)}/versions?${qs}`, { headers: { Authorization: `Bearer ${token}` } }, ); diff --git a/cli/src/commands/site.ts b/cli/src/commands/site.ts index a03cf321..85586e40 100644 --- a/cli/src/commands/site.ts +++ b/cli/src/commands/site.ts @@ -13,6 +13,7 @@ import { Command } from 'commander'; import { loadConfig } from '../config'; +import { fetchWithTimeout } from '../lib/http'; import { isJson, renderTable } from '../lib/output'; interface SiteListItem { @@ -61,7 +62,7 @@ export function registerSiteCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch(`${apiUrl}/api/sites`, { + const res = await fetchWithTimeout(`${apiUrl}/api/sites`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as { @@ -104,7 +105,7 @@ export function registerSiteCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch(`${apiUrl}/api/sites/${encodeURIComponent(siteId)}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/sites/${encodeURIComponent(siteId)}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as SiteDetail & { detail?: string }; diff --git a/cli/src/commands/trigger.ts b/cli/src/commands/trigger.ts index 34c7fb78..b0bb5dbc 100644 --- a/cli/src/commands/trigger.ts +++ b/cli/src/commands/trigger.ts @@ -22,6 +22,7 @@ import { Command } from 'commander'; import { createHmac, randomUUID } from 'crypto'; import { readFileSync } from 'fs'; import { loadConfig } from '../config'; +import { fetchWithTimeout } from '../lib/http'; const CANNED_PAYLOADS: Record> = { 'version.published': { @@ -339,7 +340,7 @@ async function fireServerProbe(opts: FireProbeOpts): Promise { try { const probeUrl = new URL(`${opts.apiUrl}/api/webhooks/probe`); probeUrl.searchParams.set('siteId', opts.siteId); - const res = await fetch(probeUrl.toString(), { + const res = await fetchWithTimeout(probeUrl.toString(), { method: 'POST', headers: { Authorization: `Bearer ${opts.token}`, @@ -365,7 +366,21 @@ async function fireServerProbe(opts: FireProbeOpts): Promise { } else { process.stderr.write(`owlette: ← ${res.status} ${JSON.stringify(data)}\n`); } - if (!res.ok) process.exitCode = 1; + const deliveryStatus = typeof data.status === 'number' ? data.status : null; + const networkError = typeof data.networkError === 'string' ? data.networkError : null; + const deliveryFailed = + networkError !== null || + deliveryStatus === null || + deliveryStatus < 200 || + deliveryStatus >= 300; + if (deliveryFailed && !opts.json) { + process.stderr.write( + `owlette: webhook probe delivery failed: ${ + networkError ?? `receiver returned ${deliveryStatus ?? 'no status'}` + }\n`, + ); + } + if (!res.ok || deliveryFailed) process.exitCode = 1; } catch (err) { process.stderr.write(`owlette: probe post failed: ${(err as Error).message}\n`); process.exitCode = 1; diff --git a/cli/src/commands/user.ts b/cli/src/commands/user.ts index 205d5149..34cef6b6 100644 --- a/cli/src/commands/user.ts +++ b/cli/src/commands/user.ts @@ -24,7 +24,13 @@ import { Command } from 'commander'; import { randomUUID } from 'crypto'; import { loadConfig } from '../config'; -import { isJson, renderTable } from '../lib/output'; +import { fetchWithTimeout } from '../lib/http'; +import { + isJson, + renderTable, + unconfirmedMutationFatal, + usageFatal, +} from '../lib/output'; interface UserListItem { uid: string; @@ -98,7 +104,7 @@ export function registerUserCommands(program: Command): void { if (opts.limit !== undefined) { const n = Number(opts.limit); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 100) { - fatal('--limit must be an integer between 1 and 100'); + usageFatal('--limit must be an integer between 1 and 100'); return; } params.set('page_size', String(n)); @@ -109,7 +115,7 @@ export function registerUserCommands(program: Command): void { ? `${apiUrl}/api/users?${params.toString()}` : `${apiUrl}/api/users`; - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as ListResponse; @@ -162,7 +168,7 @@ export function registerUserCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch(`${apiUrl}/api/users/${encodeURIComponent(uid)}`, { + const res = await fetchWithTimeout(`${apiUrl}/api/users/${encodeURIComponent(uid)}`, { headers: { Authorization: `Bearer ${token}` }, }); const data = (await res.json().catch(() => ({}))) as UserDetail & { @@ -199,24 +205,35 @@ export function registerUserCommands(program: Command): void { if (!token) return; if (!PROMOTE_ROLES.has(opts.role)) { - fatal(`--role must be one of: ${[...PROMOTE_ROLES].join(', ')}`); + usageFatal(`--role must be one of: ${[...PROMOTE_ROLES].join(', ')}`); return; } - const res = await fetch( - `${apiUrl}/api/users/${encodeURIComponent(uid)}/promote`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-user-promote-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-user-promote-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/users/${encodeURIComponent(uid)}/promote`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ role: opts.role }), }, - body: JSON.stringify({ role: opts.role }), - }, - ); + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/users/${uid}/promote`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { uid?: string; role?: string; @@ -257,20 +274,31 @@ export function registerUserCommands(program: Command): void { const { apiUrl, token, json } = resolveAuth(cmd); if (!token) return; - const res = await fetch( - `${apiUrl}/api/users/${encodeURIComponent(uid)}/demote`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-user-demote-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-user-demote-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/users/${encodeURIComponent(uid)}/demote`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({}), }, - body: JSON.stringify({}), - }, - ); + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/users/${uid}/demote`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { uid?: string; role?: string; @@ -323,24 +351,35 @@ export function registerUserCommands(program: Command): void { const siteIds = parseCsv(opts.sites); if (siteIds.length === 0) { - fatal('--sites must contain at least one site id'); + usageFatal('--sites must contain at least one site id'); return; } - const res = await fetch( - `${apiUrl}/api/users/${encodeURIComponent(uid)}/assign-sites`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-user-assign-sites-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-user-assign-sites-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/users/${encodeURIComponent(uid)}/assign-sites`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ siteIds }), }, - body: JSON.stringify({ siteIds }), - }, - ); + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/users/${uid}/assign-sites`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { uid?: string; assignedSiteIds?: string[]; @@ -389,24 +428,35 @@ export function registerUserCommands(program: Command): void { const siteIds = parseCsv(opts.sites); if (siteIds.length === 0) { - fatal('--sites must contain at least one site id'); + usageFatal('--sites must contain at least one site id'); return; } - const res = await fetch( - `${apiUrl}/api/users/${encodeURIComponent(uid)}/remove-sites`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-user-remove-sites-${randomUUID()}`, + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-user-remove-sites-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout( + `${apiUrl}/api/users/${encodeURIComponent(uid)}/remove-sites`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ siteIds }), }, - body: JSON.stringify({ siteIds }), - }, - ); + ); + } catch (err) { + unconfirmedMutationFatal({ + operation: `POST /api/users/${uid}/remove-sites`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { uid?: string; removedSiteIds?: string[]; @@ -453,7 +503,7 @@ export function registerUserCommands(program: Command): void { if (!opts.yes) { if (!process.stdin.isTTY) { - fatal( + usageFatal( 'stdin is not a tty and --yes was not supplied; refusing to delete silently', ); return; @@ -473,17 +523,28 @@ export function registerUserCommands(program: Command): void { ? `${apiUrl}/api/users/${encodeURIComponent(uid)}?${params.toString()}` : `${apiUrl}/api/users/${encodeURIComponent(uid)}`; - const res = await fetch(url, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': opts.idempotencyKey - ? String(opts.idempotencyKey) - : `cli-user-delete-${randomUUID()}`, - }, - body: JSON.stringify({}), - }); + const idempotencyKey = opts.idempotencyKey + ? String(opts.idempotencyKey) + : `cli-user-delete-${randomUUID()}`; + let res: Response; + try { + res = await fetchWithTimeout(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({}), + }); + } catch (err) { + unconfirmedMutationFatal({ + operation: `DELETE /api/users/${uid}`, + idempotencyKey, + cause: err, + }); + return; + } const data = (await res.json().catch(() => ({}))) as { uid?: string; alreadyDeleted?: boolean; @@ -591,7 +652,7 @@ function resolveAuth(cmd: Command): { apiUrl: string; token: string | null; json async function promptYesNo(question: string): Promise { const { createInterface } = await import('readline'); return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: process.stderr }); rl.question(question, (answer) => { rl.close(); const normalized = answer.trim().toLowerCase(); diff --git a/cli/src/commands/version.ts b/cli/src/commands/version.ts index 80157555..57da0717 100644 --- a/cli/src/commands/version.ts +++ b/cli/src/commands/version.ts @@ -19,6 +19,7 @@ import { readFileSync } from 'fs'; import { dirname, join } from 'path'; import { Command } from 'commander'; import { loadConfig } from '../config'; +import { fetchWithTimeout } from '../lib/http'; import { isJson } from '../lib/output'; /** @@ -71,7 +72,7 @@ export function registerVersionCommand(program: Command): void { let res: Response; try { - res = await fetch(`${apiUrl}/api/version`, { headers }); + res = await fetchWithTimeout(`${apiUrl}/api/version`, { headers }); } catch (err) { fatal(`GET /api/version failed: ${(err as Error).message}`); return; diff --git a/cli/src/commands/whoami.ts b/cli/src/commands/whoami.ts index 816d5c19..70d09c81 100644 --- a/cli/src/commands/whoami.ts +++ b/cli/src/commands/whoami.ts @@ -17,6 +17,7 @@ import { Command } from 'commander'; import { loadConfig } from '../config'; +import { fetchWithTimeout } from '../lib/http'; import { errLine, isJson, printJson, printLine } from '../lib/output'; interface ApiKeyScopeLite { @@ -73,7 +74,7 @@ export async function runWhoami(cmd: Command): Promise { let res: Response; try { - res = await fetch(`${apiUrl}/api/whoami`, { + res = await fetchWithTimeout(`${apiUrl}/api/whoami`, { headers: { Authorization: `Bearer ${token}` }, }); } catch (err) { diff --git a/cli/src/configWriter.ts b/cli/src/configWriter.ts index 92a467b3..2738cc5f 100644 --- a/cli/src/configWriter.ts +++ b/cli/src/configWriter.ts @@ -65,6 +65,10 @@ function tomlString(value: string): string { return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } +function tomlKeySegment(value: string): string { + return /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value); +} + function serialise(config: ConfigFile): string { const lines: string[] = []; @@ -88,7 +92,7 @@ function serialise(config: ConfigFile): string { for (const name of profileNames) { const profile = profiles[name]; if (!profile) continue; - lines.push(`[profiles.${name}]`); + lines.push(`[profiles.${tomlKeySegment(name)}]`); for (const [key, value] of Object.entries(profile)) { if (typeof value === 'string') { lines.push(`${key} = ${tomlString(value)}`); diff --git a/cli/src/index.ts b/cli/src/index.ts index 53e0e14a..3473596d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,7 +13,6 @@ import { registerRoostInspectCommands } from './commands/roost'; import { registerRollbackCommand } from './commands/rollback'; import { registerRoostDeployCommand } from './commands/roost-deploy'; import { registerDeployCommands } from './commands/deploy'; -import { registerKeyCommands } from './commands/key'; import { registerListenCommand } from './commands/listen'; import { registerTriggerCommand } from './commands/trigger'; import { registerSiteCommands } from './commands/site'; @@ -81,7 +80,6 @@ export function buildProgram(): Command { // top-level verbs (kept top-level for muscle memory; may move under // nouns in a future restructure) registerRollbackCommand(program); - registerKeyCommands(program); registerListenCommand(program); registerTriggerCommand(program); diff --git a/cli/src/lib/http.ts b/cli/src/lib/http.ts new file mode 100644 index 00000000..6de4edac --- /dev/null +++ b/cli/src/lib/http.ts @@ -0,0 +1,26 @@ +const DEFAULT_FETCH_TIMEOUT_MS = 30_000; + +export interface FetchWithTimeoutInit extends RequestInit { + timeoutMs?: number; +} + +export async function fetchWithTimeout( + input: string | URL | Request, + init: FetchWithTimeoutInit = {}, +): Promise { + const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = callerSignal + ? AbortSignal.any([callerSignal, timeoutSignal]) + : timeoutSignal; + + try { + return await fetch(input, { ...rest, signal }); + } catch (err) { + if (timeoutSignal.aborted) { + throw new Error(`request timed out after ${timeoutMs}ms`); + } + throw err; + } +} + diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts index bebcf87f..358957c9 100644 --- a/cli/src/lib/output.ts +++ b/cli/src/lib/output.ts @@ -40,6 +40,33 @@ export function errLine(line: string): void { process.stderr.write(line + '\n'); } +/** Print a local usage/validation/refusal error and mark the command as usage-failed. */ +export function usageFatal(msg: string): void { + errLine(`owlette: ${msg}`); + process.exitCode = 2; +} + +interface UnconfirmedMutationFatalInput { + operation: string; + idempotencyKey: string; + cause: unknown; +} + +/** + * Surface the idempotency key after a mutating request fails before the CLI + * receives a confirmed HTTP response. + */ +export function unconfirmedMutationFatal(input: UnconfirmedMutationFatalInput): void { + const detail = input.cause instanceof Error ? input.cause.message : String(input.cause); + process.stderr.write( + `owlette: ${input.operation} did not return a confirmed response: ${detail}\n` + + ` The request may or may not have completed.\n` + + ` Idempotency-Key: ${input.idempotencyKey}\n` + + ` To retry safely, re-run your original command with \`--idempotency-key ${input.idempotencyKey}\` appended.\n`, + ); + process.exitCode = 1; +} + /** * ASCII table renderer: pads each column to the widest cell, draws a * dash separator row under the headers, preserves insertion order. diff --git a/cli/src/lib/versionBuilder.ts b/cli/src/lib/versionBuilder.ts index d3a2799c..e81c0962 100644 --- a/cli/src/lib/versionBuilder.ts +++ b/cli/src/lib/versionBuilder.ts @@ -12,11 +12,12 @@ * } * * `config` carries free-form metadata about the push (cli version, - * source host, timestamp). The server ignores unknown keys but writes + * source host). The server ignores unknown keys but writes * the whole object into the version body in R2 — useful for auditing * how a given version was produced. */ +import { createHash } from 'crypto'; import type { ChunkedFileEntry } from './chunker'; export const VERSION_MEDIA_TYPE = 'application/vnd.owlette.version.v1+json'; @@ -41,7 +42,6 @@ export function buildVersion(input: BuildVersionInput): BuiltVersion { const config: Record = { producer: 'owlette-cli', cliVersion: input.cliVersion, - createdAt: new Date().toISOString(), }; if (input.hostname) config.hostname = input.hostname; if (input.platform) config.platform = input.platform; @@ -62,6 +62,16 @@ export function buildVersion(input: BuildVersionInput): BuiltVersion { }; } +/** Canonical JSON form used by the server when deriving the content address. */ +export function canonicalVersionJson(version: BuiltVersion): string { + return JSON.stringify(sortForCanonical(version)); +} + +/** SHA-256 content address for a built version body. */ +export function versionIdForVersion(version: BuiltVersion): string { + return createHash('sha256').update(canonicalVersionJson(version)).digest('hex'); +} + /** Dedup the set of chunk hashes referenced by a version's files. */ export function uniqueHashes(files: readonly ChunkedFileEntry[]): string[] { const set = new Set(); @@ -93,3 +103,12 @@ export function summariseVersion(files: readonly ChunkedFileEntry[]): { uniqueChunks: unique.size, }; } + +function sortForCanonical(v: unknown): unknown { + if (v === null || typeof v !== 'object') return v; + if (Array.isArray(v)) return v.map(sortForCanonical); + const out: Record = {}; + const keys = Object.keys(v as Record).sort(); + for (const k of keys) out[k] = sortForCanonical((v as Record)[k]); + return out; +} diff --git a/docs/cli/overview.md b/docs/cli/overview.md index d1b27492..6e57bfd5 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -5,7 +5,7 @@ hide: # owlette cli — overview -`owlette` is the command-line client for the [owlette.app](https://owlette.app) api. It runs on macOS, linux, and windows and lets you authenticate, push roosts, manage sites and machines, mint api keys, and inspect audit logs from your terminal or ci pipeline. +`owlette` is the command-line client for the [owlette.app](https://owlette.app) api. It runs on macOS, linux, and windows and lets you authenticate, push roosts, manage sites and machines, and inspect audit logs from your terminal or ci pipeline. This page is the 15-minute onboarding: install → log in → push your first roost. For per-command reference, start with [auth](reference/auth.md), [roost](reference/roost.md), [machine](reference/machine.md), [listen](reference/listen.md), or [trigger](reference/trigger.md). For route/stub/deferred status across the whole CLI, see the [readiness matrix](readiness.md). @@ -173,7 +173,8 @@ Every command lives under one of these top-level groups. **`[ready]`** verbs hit | noun | tier | verbs | what it does | |---|---|---|---| | [`site`](reference/site.md) | ready | `list` `get` | sites you have access to | -| [`key`](reference/key.md) | ready | `create` `list` `rotate` `revoke` | your api keys | + +API key creation, rotation, and revocation are dashboard-only. See [key management](reference/key.md). ### superadmin nouns @@ -218,7 +219,7 @@ Commands that accept `--json` write one JSON document to stdout. Newer commands { "ok": true, "data": { /* command-specific payload */ } } ``` -Examples in source include `roost list`, `key list`, `site list`, `machine list`, `whoami`, and `trigger`, which print raw payloads or command-specific objects rather than a universal `{ ok, data }` envelope. +Examples in source include `roost list`, `site list`, `machine list`, `whoami`, and `trigger`, which print raw payloads or command-specific objects rather than a universal `{ ok, data }` envelope. **wrapped failure** ```json diff --git a/docs/cli/readiness.md b/docs/cli/readiness.md index 719406a1..1679a7ab 100644 --- a/docs/cli/readiness.md +++ b/docs/cli/readiness.md @@ -104,10 +104,10 @@ Legend: | command | status | reference page | public route or reason | |---|---|---|---| -| `owlette key create` | ready | [key](reference/key.md) | `POST /api/keys` | -| `owlette key list` | ready | [key](reference/key.md) | `GET /api/keys` | -| `owlette key rotate ` | ready | [key](reference/key.md) | `POST /api/keys/{keyId}/rotate` | -| `owlette key revoke ` | ready | [key](reference/key.md) | `DELETE /api/keys/{keyId}` | +| `owlette key create` | not available | [key](reference/key.md) | key management is dashboard/session-only; no CLI command is registered | +| `owlette key list` | not available | [key](reference/key.md) | key management is dashboard/session-only; no CLI command is registered | +| `owlette key rotate ` | not available | [key](reference/key.md) | key management is dashboard/session-only; no CLI command is registered | +| `owlette key revoke ` | not available | [key](reference/key.md) | key management is dashboard/session-only; no CLI command is registered | | `owlette user list` | ready | [user](reference/user.md) | `GET /api/users` | | `owlette user get ` | ready | [user](reference/user.md) | `GET /api/users/{uid}` | | `owlette user promote ` | ready | [user](reference/user.md) | `POST /api/users/{uid}/promote` | diff --git a/docs/cli/reference/auth.md b/docs/cli/reference/auth.md index ff950384..2439b40a 100644 --- a/docs/cli/reference/auth.md +++ b/docs/cli/reference/auth.md @@ -78,7 +78,7 @@ backing: `GET /api/whoami`. ## auth logout -clears the stored credential for the active profile. local-only — does not revoke the key server-side. to actually revoke a key, use `owlette key revoke `. +clears the stored credential for the active profile. local-only — does not revoke the key server-side. to actually revoke a key, use dashboard key management. ```bash owlette auth logout @@ -116,4 +116,4 @@ backing: none — local file write only. - **tier**: `[ready]` for all three verbs - **token storage**: OS keychain when available, otherwise `~/.config/owlette/credentials.json` with file mode forced to `0o600`. legacy `token` fields in `config.toml` are still read but new logins do not write them - **profile selection**: `--profile ` (or `OWLETTE_PROFILE`) selects which `[profiles.X]` block reads/writes -- **related**: [`whoami`](whoami.md) (identical to `auth status`), [`key`](key.md) for server-side key management, [overview](../overview.md) for config precedence +- **related**: [`whoami`](whoami.md) (identical to `auth status`), [dashboard key management](key.md), [overview](../overview.md) for config precedence diff --git a/docs/cli/reference/key.md b/docs/cli/reference/key.md index e7c7bdef..68e3916e 100644 --- a/docs/cli/reference/key.md +++ b/docs/cli/reference/key.md @@ -3,138 +3,18 @@ hide: - navigation --- -# key +# key management -manage your own api keys — mint scoped `owk_*` keys, rotate with a 24-hour grace period, or revoke immediately. **scope**: user-scoped (you manage your own keys; admins manage everyone's via the dashboard). **tier**: A — every verb hits a public api today. +API key management is dashboard-only. The CLI does not register a `key` command group. -> ⚠️ **`key create` and `key rotate` print the raw `owk_*` key exactly once.** the server never returns it again. copy it into a secret manager or env file immediately — there is no recovery path if you lose it. +Use the dashboard to create, inspect, rotate, or revoke `owk_*` keys. The CLI can then use one of those keys through `owlette auth login`, `OWLETTE_TOKEN`, or the active profile's credential store. ---- - -## scope spec grammar - -`--scope ` accepts the same grammar everywhere it appears: - -``` -=:[,...] -``` - -- `` — `roost` | `site` | `machine` | `chat` | `deploy` | `process` | `user` | `installer` -- `` — a specific resource id (e.g. `rst_abc`, `site-1`, `m_abc123`) or `*` for wildcard -- `` — one or more of `read`, `write`, `deploy`, `rollback`, `admin` - -`--scope` is repeatable. examples: `roost=rst_abc:write,deploy`, `site=*:read`, `machine=m_abc123:write`, `chat=site-1:read,write`, `installer=*:admin`. - -`--preset` is a shortcut that wildcards the common CLI resources (`roost`, `site`, `machine`, `chat`) with a canonical permission set: `readonly` (read), `publisher` (read+write), `operator` (read+write+deploy+rollback), `admin` (all). `--preset` and `--scope` are mutually exclusive — pick one. Platform scopes (`user`, `installer`) are superadmin-only at mint time and must use `*` as the id. - ---- - -## create - -mint a new scoped api key. server returns the raw key in the response body **once**. - -**synopsis** — `owlette key create --name (--scope ... | --preset

) [--ttl-days ] [--environment ]` - -| flag | required | purpose | -|---|---|---| -| `--name ` | yes | human-readable label for the key | -| `--scope ` | one of | repeatable scope spec (mutually exclusive with `--preset`) | -| `--preset

` | one of | `readonly` \| `publisher` \| `operator` \| `admin` (mutually exclusive with `--scope`) | -| `--ttl-days ` | no | lifetime in days (default 90, min 1, max 365) | -| `--environment ` | no | `live` or `test` (default `live`) | +For local credential state, use: ```bash -# mint a deploy-capable key for one specific roost, 30-day ttl -owlette key create --name "ci-deployer" --scope roost=rst_abc:write,deploy --ttl-days 30 - -# mint a fleet-wide read-only key using the preset shortcut -owlette key create --name "metrics-scraper" --preset readonly - -# mint a multi-resource key — flag is repeatable -owlette key create --name "site-1 publisher" \ - --scope site=site-1:read \ - --scope roost=*:write \ - --json | jq -r '.key' > .env.owlette -``` - -**backing endpoint**: `POST /api/keys` - ---- - -## list - -list api keys for the authenticated user, with status (`active`, `rotated`, `expired`, `retired`, `revoked`), last-used date, and a one-line scope summary. raw key bytes are never returned by `list`. - -**synopsis** — `owlette key list [--json]` - -(no flags; uses global `--profile` / `--json`.) - -```bash -owlette key list -owlette key list --json | jq '.keys[] | select(.expired == true) | .id' +owlette auth login +owlette auth logout +owlette whoami ``` -**backing endpoint**: `GET /api/keys` - ---- - -## rotate - -issue a new key with the **same scopes** as the rotated one. the new key is shown once, just like `create`. the **old key continues to work for a 24-hour grace period** so you can roll out the replacement without downtime — after 24h the old key auto-retires. - -**synopsis** — `owlette key rotate [--ttl-days ]` - -| flag | required | purpose | -|---|---|---| -| `--ttl-days ` | no | lifetime in days for the **new** key (default 90, min 1, max 365) | - -```bash -# standard rotation — old key retires in 24h -owlette key rotate k_abc123 - -# rotate with a shorter ttl on the new key -owlette key rotate k_abc123 --ttl-days 30 --json | jq -r '.key' > .env.new -``` - -**backing endpoint**: `POST /api/keys/{keyId}/rotate` - ---- - -## revoke - -delete an api key **immediately**. there is no grace period — pending requests with that key will start failing with `unauthorized` as soon as the call completes. for graceful cutover, prefer `rotate`. - -**synopsis** — `owlette key revoke [--yes]` - -| flag | required | purpose | -|---|---|---| -| `--yes` | no | skip the interactive confirmation prompt (required when stdin is not a tty) | - -```bash -owlette key revoke k_abc123 # interactive confirm -owlette key revoke k_abc123 --yes # script-friendly -owlette key revoke k_abc123 --yes --json -``` - -**backing endpoint**: `DELETE /api/keys/{keyId}` - ---- - -## exit codes - -- `0` — success -- `1` — generic error (network, api 5xx, unexpected response shape) -- `2` — usage error (missing required flag, both `--scope` and `--preset` passed, bad scope spec, invalid `--ttl-days`, invalid `--environment`, refusing to revoke without `--yes` from a non-tty) - -`key` has no stub verbs and no exit-3 paths. - ---- - -## notes - -- **scope of these commands**: user-scoped — you can only create/list/rotate/revoke your own keys. site admins manage other users' keys via the dashboard. -- **raw key emission**: `create` and `rotate` are the **only** commands that ever return the raw `owk_*` value. `list` returns `keyPrefix` (first ~12 chars) and metadata only. lose the raw key, rotate or revoke it. -- **rotation grace**: 24h hard-coded server-side. plan your roll-out window accordingly — at hour 24 the old key returns `token_expired`. -- **mutual exclusion**: `--scope` and `--preset` are exclusive on `create`. `rotate` cannot change scopes — if you need different scopes, `create` a new key and `revoke` the old one. -- **ttl bounds**: `--ttl-days` must be an integer between 1 and 365 inclusive. anything outside that range fails with exit 2 before the http call. -- see [overview](../overview.md) for global flags, config precedence, and the json envelope schema. +See [auth](auth.md), [whoami](whoami.md), and the [overview](../overview.md) for config precedence and credential storage details. diff --git a/docs/cli/reference/listen.md b/docs/cli/reference/listen.md index 96ec77f8..0553d06f 100644 --- a/docs/cli/reference/listen.md +++ b/docs/cli/reference/listen.md @@ -100,8 +100,8 @@ owlette: listener stopped. connected=1 events=3 forwarded=3 forwardErrors=0 keep ## exit codes -- `0` - clean shutdown with `Ctrl-C`; also used when the stream ends without a connection-level error -- `1` - failed to open the stream, non-2xx stream response, or stream parser/read error +- `0` - clean shutdown with `Ctrl-C` +- `1` - failed to open the stream, non-2xx stream response, stream parser/read error, or the server stream ending cleanly - `2` - no token configured for the active profile or an invalid `--forward-to` URL --- diff --git a/docs/cli/reference/machine.md b/docs/cli/reference/machine.md index 3b456d7b..7590dfcc 100644 --- a/docs/cli/reference/machine.md +++ b/docs/cli/reference/machine.md @@ -70,14 +70,15 @@ owlette machine deployments m_abc123 --site site-1 --json | jq '.deployments[] | ## reboot -queue a `reboot_machine` command on the machine. the command is allowlisted in wave-2A commands and auto-tagged with an `Idempotency-Key` so retrying after a network blip is safe. +queue a `reboot_machine` command on the machine. the command is allowlisted in wave-2A commands and auto-tagged with an `Idempotency-Key`. if the cli reports an unconfirmed failure, retry safely only by reusing the same key with `--idempotency-key`. -**synopsis** — `owlette machine reboot --site [--delay-seconds ]` +**synopsis** — `owlette machine reboot --site [--delay-seconds ] [--idempotency-key ]` | flag | required | purpose | |---|---|---| | `--site ` | yes | site id that owns the machine | | `--delay-seconds ` | no | non-negative integer; agent waits this long before firing (default 0) | +| `--idempotency-key ` | no | reuse the printed key when retrying an unconfirmed mutation | ```bash owlette machine reboot m_abc123 --site site-1 @@ -90,14 +91,15 @@ owlette machine reboot m_abc123 --site site-1 --delay-seconds 60 --json ## shutdown -queue a `shutdown_machine` command on the machine. same idempotency + delay semantics as `reboot`. +queue a `shutdown_machine` command on the machine. same delay semantics as `reboot`; retry an unconfirmed mutation only with the same `--idempotency-key`. -**synopsis** — `owlette machine shutdown --site [--delay-seconds ]` +**synopsis** — `owlette machine shutdown --site [--delay-seconds ] [--idempotency-key ]` | flag | required | purpose | |---|---|---| | `--site ` | yes | site id that owns the machine | | `--delay-seconds ` | no | non-negative integer; agent waits this long before firing (default 0) | +| `--idempotency-key ` | no | reuse the printed key when retrying an unconfirmed mutation | ```bash owlette machine shutdown m_abc123 --site site-1 @@ -112,17 +114,18 @@ owlette machine shutdown m_abc123 --site site-1 --delay-seconds 30 capture a screenshot from the machine and download it locally. this is a queue → poll → download flow: the cli posts a `capture_screenshot` command, polls the command-state endpoint every 1.5s for up to 60s, then fetches the bytes from the signed url the agent uploaded to. -**synopsis** — `owlette machine screenshot --site [--monitor ] [--output ]` +**synopsis** — `owlette machine screenshot --site [--monitor ] [--output ] [--idempotency-key ]` | flag | required | purpose | |---|---|---| | `--site ` | yes | site id that owns the machine | -| `--monitor ` | no | `all`, `primary`, or a non-negative integer index. when omitted, the cli sends no monitor value and the agent defaults to `0` / all monitors. | +| `--monitor ` | no | non-negative integer index. `0` captures all monitors; named values such as `all` or `primary` are not accepted. when omitted, the cli sends no monitor value and the agent defaults to `0` / all monitors. | | `--output ` | no | path to write the png (default: `screenshot--.png` in cwd) | +| `--idempotency-key ` | no | reuse the printed key when retrying an unconfirmed mutation | ```bash owlette machine screenshot m_abc123 --site site-1 -owlette machine screenshot m_abc123 --site site-1 --monitor all --output /tmp/wall.png +owlette machine screenshot m_abc123 --site site-1 --monitor 1 --output /tmp/monitor-1.png owlette machine screenshot m_abc123 --site site-1 --monitor 0 --json ``` @@ -172,6 +175,6 @@ stable problem+json codes surfaced with a hint: `machine_offline`, `unsupported_ - **scope**: site-scoped. mutations require an api key with `machine=:write` (or `site=:write`). - **tier**: A for `list`, `get`, `deployments`, `reboot`, `shutdown`, `screenshot`. C stub for `live-view`. -- **idempotency**: every mutation auto-generates an `Idempotency-Key`, so retrying a failed `reboot`/`shutdown`/`screenshot` will not double-fire. +- **idempotency**: every mutation auto-generates an `Idempotency-Key`. retrying is safe only after an unconfirmed failure and only when reusing the same printed key with `--idempotency-key`; a fresh retry can queue a second command. - **screenshot ttl**: the signed url returned by the agent expires shortly after capture — the cli downloads inline so you do not need to handle expiry yourself. - see [overview](../overview.md) for global flags (`--profile`, `--json`, `--api-url`) and config precedence. diff --git a/docs/cli/reference/rollback.md b/docs/cli/reference/rollback.md index e09f319c..120f1b2c 100644 --- a/docs/cli/reference/rollback.md +++ b/docs/cli/reference/rollback.md @@ -7,13 +7,14 @@ hide: roll a roost back to a previous version from the top-level `owlette rollback` helper. this command is site-scoped and route-backed. -**synopsis** - `owlette rollback --site [--to ] [--yes] [--json]` +**synopsis** - `owlette rollback --site [--to ] [--yes] [--idempotency-key ] [--json]` | flag | required | purpose | |---|---|---| | `--site ` | yes | site id that owns the roost | | `--to ` | no | target version id, number, `#N`, `vN`, `current`, `previous`, or `first`; defaults to the roost's previous version | | `--yes` | no | skip the interactive confirmation; required when stdin is not a tty | +| `--idempotency-key ` | no | pin the rollback mutation key; auto-generated when omitted | ```bash owlette rollback rst_my_project --site site-1 @@ -29,13 +30,15 @@ owlette --json rollback rst_my_project --site site-1 --to previous --yes The CLI always previews the diff before firing the mutation. In human mode it prompts `[y/N]`; in non-tty contexts pass `--yes` so scripts cannot roll back silently. +Rollback mutations auto-generate an `Idempotency-Key`. If the POST times out or fails before the CLI receives a confirmed response, stderr prints the generated key and a retry command that includes `--to ` and `--idempotency-key `. Use that exact retry form. The `--to` value is the concrete version id resolved during the first attempt, so the replayed idempotent request body stays identical. A bare retry without the same idempotency key is unsafe because a committed-but-lost rollback can change which version `previous` resolves to. + --- ## exit codes - `0` - rollback succeeded, or the operator declined the confirmation prompt -- `1` - api call failed or the rollback target resolves to the current version -- `2` - usage/auth problem, no rollback target, or non-tty execution without `--yes` +- `1` - api call failed +- `2` - usage/auth/refusal problem, no rollback target, rollback target resolves to the current version, or non-tty execution without `--yes` --- diff --git a/docs/cli/reference/whoami.md b/docs/cli/reference/whoami.md index 973eb162..dea2a7b5 100644 --- a/docs/cli/reference/whoami.md +++ b/docs/cli/reference/whoami.md @@ -99,4 +99,4 @@ emits the historical envelope `auth status` has always produced: - **tier**: `[ready]` - **alias**: [`auth status`](auth.md#auth-status) — same code path, byte-identical stdout/stderr - **token source**: precedence is `OWLETTE_TOKEN` env var -> OS keychain/token-file credential store -> legacy active profile `token` field in `config.toml`. `--profile ` picks the profile; the bare command uses `default` -- **related**: [`auth login`](auth.md#auth-login) to mint + store the token this command introspects, [`key list`](key.md) for the server-side view of every key on your account +- **related**: [`auth login`](auth.md#auth-login) to mint + store the token this command introspects, [dashboard key management](key.md) for account key inventory From d4fad4a08e70429fc11788401bae413c0f28f7b2 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Fri, 22 May 2026 15:50:29 -0700 Subject: [PATCH 04/43] ci: add OIDC trusted-publishing workflows for @owlette/cli and SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag-driven publish for @owlette/cli, @owlette/sdk (npm) and owlette-sdk (PyPI) via OIDC trusted publishing — no NPM_TOKEN. Dispatch inputs routed through env (no shell injection); real publishes require a tag matching the package version; dry-run default. docs/api/distribution.md documents the flow and the npm first-publish bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cli-publish.yml | 69 +++++++++---- .github/workflows/node-sdk-publish.yml | 136 +++++++++++++++++++++++++ .github/workflows/py-sdk-publish.yml | 123 ++++++++++++++++++++++ docs/api/distribution.md | 36 ++++--- 4 files changed, 332 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/node-sdk-publish.yml create mode 100644 .github/workflows/py-sdk-publish.yml diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 49e0a31f..51f6d9de 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -1,15 +1,29 @@ name: cli publish -# Publish @owlette/cli to npm from a release tag (`cli-vX.Y.Z`) or via -# manual dispatch. Manual dispatch defaults to dry-run so the first -# operator-triggered execution can validate package contents without -# publishing. Tag pushes always publish after the package version is -# verified against the tag. +# Publish @owlette/cli to npm from a release tag (`cli-vX.Y.Z` or +# `cli-vX.Y.Z-prerelease`) or via manual dispatch. +# +# Authentication uses npm OIDC trusted publishing — there is no NPM_TOKEN +# secret. A trusted publisher for @owlette/cli that points at this workflow +# must be configured under the package settings on npmjs.com first. npm +# requires the package to already exist before a trusted publisher can be +# attached, so the very first publish of the package must be bootstrapped +# once with a token / local `npm publish` (see docs/api/distribution.md). +# Every release after that goes through this workflow tokenlessly, with +# provenance attestations generated automatically. +# +# Prerelease versions (e.g. 1.0.0-rc.0) publish under the matching dist-tag +# (`rc`); stable versions publish under `latest`. +# +# Manual dispatch defaults to dry-run so the package contents can be +# validated before a real publish. Tag pushes always publish after the +# package version is verified against the tag. on: push: tags: - 'cli-v[0-9]+.[0-9]+.[0-9]+' + - 'cli-v[0-9]+.[0-9]+.[0-9]+-*' workflow_dispatch: inputs: tag: @@ -42,9 +56,13 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' registry-url: 'https://registry.npmjs.org' + - name: Upgrade npm to a trusted-publishing-capable version + # Trusted publishing requires npm >= 11.5.1; node 22 ships npm 10.x. + run: npm install -g npm@latest + - name: Install dependencies working-directory: cli run: npm ci @@ -59,24 +77,32 @@ jobs: - name: Verify tag matches package version shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + INPUT_TAG: ${{ inputs.tag }} run: | set -euo pipefail tag="" - if [ "${{ github.event_name }}" = "push" ]; then + if [ "$EVENT_NAME" = "push" ]; then tag="${GITHUB_REF_NAME}" - elif [ -n "${{ inputs.tag }}" ]; then - tag="${{ inputs.tag }}" + elif [ -n "$INPUT_TAG" ]; then + tag="$INPUT_TAG" tag="${tag#refs/tags/}" fi if [ -z "$tag" ]; then - echo "no tag supplied on workflow_dispatch; skipping tag/version check" - exit 0 + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_DRY_RUN" = "true" ]; then + echo "no tag supplied on workflow_dispatch dry run; skipping tag/version check" + exit 0 + fi + echo "refusing to publish: real publishes require a tag that matches cli/package.json" + exit 1 fi - if [[ ! "$tag" =~ ^cli-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "refusing to publish: tag '$tag' must match cli-vX.Y.Z" + if [[ ! "$tag" =~ ^cli-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "refusing to publish: tag '$tag' must match cli-vX.Y.Z or cli-vX.Y.Z-prerelease" exit 1 fi @@ -93,14 +119,23 @@ jobs: working-directory: cli shell: bash env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail + version="$(node -p "require('./package.json').version")" + if [[ "$version" == *-* ]]; then + pre="${version#*-}" + dist_tag="${pre%%.*}" + else + dist_tag="latest" + fi + echo "publishing @owlette/cli@$version under dist-tag '$dist_tag'" + dry_run_flag="" - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_DRY_RUN" = "true" ]; then dry_run_flag="--dry-run" fi - npm publish --access public --provenance $dry_run_flag + npm publish --provenance --access public --tag "$dist_tag" $dry_run_flag diff --git a/.github/workflows/node-sdk-publish.yml b/.github/workflows/node-sdk-publish.yml new file mode 100644 index 00000000..6c5f443d --- /dev/null +++ b/.github/workflows/node-sdk-publish.yml @@ -0,0 +1,136 @@ +name: node sdk publish + +# Publish @owlette/sdk (Node SDK) to npm from a release tag +# (`node-sdk-vX.Y.Z` or `node-sdk-vX.Y.Z-prerelease`) or via manual dispatch. +# +# Authentication uses npm OIDC trusted publishing — there is no NPM_TOKEN +# secret. A trusted publisher for @owlette/sdk that points at this workflow +# must be configured under the package settings on npmjs.com first. npm +# requires the package to already exist before a trusted publisher can be +# attached, so the very first publish must be bootstrapped once with a +# token / local `npm publish` (see docs/api/distribution.md). Every release +# after that goes through this workflow tokenlessly, with provenance. +# +# Prerelease versions publish under the matching dist-tag (`rc`); stable +# versions publish under `latest`. Manual dispatch defaults to dry-run. + +on: + push: + tags: + - 'node-sdk-v[0-9]+.[0-9]+.[0-9]+' + - 'node-sdk-v[0-9]+.[0-9]+.[0-9]+-*' + workflow_dispatch: + inputs: + tag: + description: 'optional tag or ref to publish from' + required: false + type: string + dry_run: + description: 'run npm publish with --dry-run' + required: false + type: boolean + default: true + +permissions: + contents: read + id-token: write + +concurrency: + group: node-sdk-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Upgrade npm to a trusted-publishing-capable version + # Trusted publishing requires npm >= 11.5.1; node 22 ships npm 10.x. + run: npm install -g npm@latest + + - name: Install dependencies + working-directory: sdks/node + run: npm ci + + - name: Build + working-directory: sdks/node + run: npm run build + + - name: Test + working-directory: sdks/node + run: npm test + + - name: Verify tag matches package version + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + + tag="" + if [ "$EVENT_NAME" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "$INPUT_TAG" ]; then + tag="$INPUT_TAG" + tag="${tag#refs/tags/}" + fi + + if [ -z "$tag" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_DRY_RUN" = "true" ]; then + echo "no tag supplied on workflow_dispatch dry run; skipping tag/version check" + exit 0 + fi + echo "refusing to publish: real publishes require a tag that matches sdks/node/package.json" + exit 1 + fi + + if [[ ! "$tag" =~ ^node-sdk-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "refusing to publish: tag '$tag' must match node-sdk-vX.Y.Z or node-sdk-vX.Y.Z-prerelease" + exit 1 + fi + + expected="${tag#node-sdk-v}" + actual="$(node -p "require('./sdks/node/package.json').version")" + if [ "$actual" != "$expected" ]; then + echo "refusing to publish: sdks/node/package.json version '$actual' does not match tag '$tag' (expected '$expected')" + exit 1 + fi + + echo "package version $actual matches $tag" + + - name: Publish + working-directory: sdks/node + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + version="$(node -p "require('./package.json').version")" + if [[ "$version" == *-* ]]; then + pre="${version#*-}" + dist_tag="${pre%%.*}" + else + dist_tag="latest" + fi + echo "publishing @owlette/sdk@$version under dist-tag '$dist_tag'" + + dry_run_flag="" + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_DRY_RUN" = "true" ]; then + dry_run_flag="--dry-run" + fi + + npm publish --provenance --access public --tag "$dist_tag" $dry_run_flag diff --git a/.github/workflows/py-sdk-publish.yml b/.github/workflows/py-sdk-publish.yml new file mode 100644 index 00000000..e937746a --- /dev/null +++ b/.github/workflows/py-sdk-publish.yml @@ -0,0 +1,123 @@ +name: python sdk publish + +# Publish owlette-sdk (Python SDK) to PyPI from a release tag +# (`py-sdk-v`, e.g. `py-sdk-v1.0.0rc0`) or via manual +# dispatch. +# +# Authentication uses PyPI OIDC trusted publishing. Unlike npm, PyPI +# supports a "pending publisher" that creates the project on the first +# publish, so no API token is ever required. Configure the trusted +# publisher on PyPI before the first tag push: +# project: owlette-sdk +# owner: +# repo: +# workflow: py-sdk-publish.yml +# environment: (leave blank — this workflow does not pin one) +# A pending publisher does not reserve the name until first publish, so +# push the release tag promptly after configuring it. +# +# Manual dispatch defaults to a build-only dry run. Set target=testpypi to +# publish to TestPyPI (needs a matching pending publisher on TestPyPI). + +on: + push: + tags: + - 'py-sdk-v*' + workflow_dispatch: + inputs: + tag: + description: 'optional tag or ref to publish from' + required: false + type: string + target: + description: 'publish target' + required: false + type: choice + options: + - dry-run + - testpypi + - pypi + default: dry-run + +permissions: + contents: read + id-token: write + +concurrency: + group: py-sdk-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build sdist and wheel + working-directory: sdks/python + run: | + python -m pip install --upgrade pip build twine + python -m build --sdist --wheel + python -m twine check dist/* + + - name: Verify tag matches package version + shell: bash + working-directory: sdks/python + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_TARGET: ${{ inputs.target }} + run: | + set -euo pipefail + + tag="" + if [ "$EVENT_NAME" = "push" ]; then + tag="${GITHUB_REF_NAME}" + elif [ -n "$INPUT_TAG" ]; then + tag="$INPUT_TAG" + tag="${tag#refs/tags/}" + fi + + if [ -z "$tag" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_TARGET" = "dry-run" ]; then + echo "no tag supplied on workflow_dispatch dry run; skipping tag/version check" + exit 0 + fi + echo "refusing to publish: real publishes require a tag that matches pyproject.toml" + exit 1 + fi + + if [[ ! "$tag" =~ ^py-sdk-v.+$ ]]; then + echo "refusing to publish: tag '$tag' must match py-sdk-v" + exit 1 + fi + + expected="${tag#py-sdk-v}" + actual="$(python -c "import tomllib,sys;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + if [ "$actual" != "$expected" ]; then + echo "refusing to publish: pyproject.toml version '$actual' does not match tag '$tag' (expected '$expected')" + exit 1 + fi + + echo "package version $actual matches $tag" + + - name: Publish to TestPyPI + if: ${{ github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdks/python/dist + repository-url: https://test.pypi.org/legacy/ + + - name: Publish to PyPI + if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdks/python/dist diff --git a/docs/api/distribution.md b/docs/api/distribution.md index 2dfe8175..27e8a3d8 100644 --- a/docs/api/distribution.md +++ b/docs/api/distribution.md @@ -70,32 +70,38 @@ Do not commit generated `dist/`, `.egg-info/`, or package archive files. They ar ## publish sequence -Prefer a release workflow with npm provenance and PyPI trusted publishing. If a local emergency publish is approved, record the operator, registry account, package, version, and reason in the sprint log. +Publishing is driven by CI from release tags using OIDC trusted publishing — no long-lived registry tokens live in GitHub secrets. The dist-tag is derived from the package version automatically: a prerelease (e.g. `1.0.0-rc.0`) publishes under the `rc` tag, a stable version under `latest`. -```powershell -cd cli -npm publish --tag rc --access public -``` +| package | workflow | release tag | +|---|---|---| +| `@owlette/cli` | `.github/workflows/cli-publish.yml` | `cli-vX.Y.Z[-pre]` | +| `@owlette/sdk` | `.github/workflows/node-sdk-publish.yml` | `node-sdk-vX.Y.Z[-pre]` | +| `owlette-sdk` | `.github/workflows/py-sdk-publish.yml` | `py-sdk-v` (e.g. `py-sdk-v1.0.0rc0`) | -```powershell -cd sdks/node -npm publish --tag rc --access public -``` +Each workflow also supports `workflow_dispatch` with a dry-run default so package contents can be validated before a real publish. + +### one-time trusted-publisher setup + +- **npm** cannot attach a trusted publisher to a package that does not exist yet, so the first publish of each npm package must be bootstrapped once. Sign in locally with the package owner account (2FA enabled) and run a single `npm publish --tag rc --access public` from `cli/` and from `sdks/node/`. Then, in each package's settings on npmjs.com, add a GitHub Actions trusted publisher pointing at the workflow above. Every release after that goes through CI tokenlessly with provenance. +- **PyPI** supports a "pending publisher" that creates the project on first publish, so no bootstrap and no token is needed. Configure the pending publisher (project `owlette-sdk`, this repo's owner/name, workflow `py-sdk-publish.yml`, environment left blank) before pushing the first `py-sdk-v*` tag. The name is not reserved until that first publish, so tag promptly. + +### releasing ```powershell -cd sdks/python -python -m build --sdist --wheel -python -m twine upload --repository testpypi dist/* -python -m twine upload dist/* +# example: cut the CLI rc release +git tag cli-v1.0.0-rc.0 +git push origin cli-v1.0.0-rc.0 # → cli-publish.yml publishes @owlette/cli@rc with provenance ``` -After npm publishes, confirm the `rc` tag points at the intended version: +After npm publishes, confirm the dist-tag points at the intended version: ```powershell npm view @owlette/cli dist-tags npm view @owlette/sdk dist-tags ``` +If a local emergency publish is approved (registry/CI outage), record the operator, registry account, package, version, and reason in the sprint log before running `npm publish` / `twine upload` by hand. + --- ## install verification @@ -134,4 +140,4 @@ Homebrew, Scoop, and winget remain blocked until there is a stable release artif ## current status -The repository is prepared for RC package dry-runs, but no registry publish has been executed from this sprint environment. Until npm, PyPI, Homebrew, Scoop, and winget installs work or receive an explicit launch waiver, 5.3 remains launch-blocked and not externally complete. +OIDC trusted-publishing workflows are wired for all three packages and the RC builds pass clean-checkout dry-runs. No registry publish has been executed yet. Remaining owner steps before the first publish: create the `@owlette` npm org with 2FA, bootstrap the first npm publish of each package, configure the npm trusted publishers, and configure the PyPI pending publisher for `owlette-sdk`; PyPI creates the project on first OIDC publish, so publish promptly after the pending publisher is set. Until npm, PyPI, Homebrew, Scoop, and winget installs work or receive an explicit launch waiver, 5.3 remains launch-blocked and not externally complete. From 927d4b46dfbfb6232abd93ae09c09087550852cc Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Sat, 23 May 2026 10:56:56 -0700 Subject: [PATCH 05/43] feat(web): add scoped full-text search to logs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a search box (expanding magnifying-glass button in the header) that substring-matches across action, machine, process, level, and details. Firestore has no full-text query, so search runs client-side over a 'search pool' — the full set of logs matching the active date/level/machine/action filters, loaded on demand and capped at 2,000 — falling back to on-screen rows until it lands. Header stats recount against matches, infinite scroll pauses while searching, and a notice appears when the scope exceeds the cap. Also extracts shared buildLogsQuery/applyClientScope helpers (used by both the live listener and the search-pool fetch) and fixes details-tooltip clipping by wrapping long unbreakable strings. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/app/logs/page.tsx | 356 +++++++++++++++++++++++++++++++++--------- 1 file changed, 278 insertions(+), 78 deletions(-) diff --git a/web/app/logs/page.tsx b/web/app/logs/page.tsx index e3570bf1..7de24a58 100644 --- a/web/app/logs/page.tsx +++ b/web/app/logs/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { useSites } from '@/hooks/useFirestore'; @@ -10,7 +10,7 @@ import { PageHeader } from '@/components/PageHeader'; import { collection, query, orderBy, limit, getDocs, where, startAfter, Query, DocumentData, Timestamp, onSnapshot } from 'firebase/firestore'; import { db } from '@/lib/firebase'; import { Button } from '@/components/ui/button'; -import { ChevronDown, ChevronsUpDown, ChevronsDownUp, Filter, X, Trash2, ScrollText, AlertTriangle, AlertCircle, Camera } from 'lucide-react'; +import { ChevronDown, ChevronsUpDown, ChevronsDownUp, Filter, X, Trash2, ScrollText, AlertTriangle, AlertCircle, Camera, Search } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; @@ -152,6 +152,65 @@ const formatAction = (action: string) => { .join(' '); }; +// When a search is active we load the full set of logs matching the current +// server-side filters (not just the visible 50) so search covers the whole +// scope. Bounded so a busy site can't trigger an unbounded read. +const SEARCH_POOL_CAP = 2000; + +// Build the Firestore query for a site's logs honouring the active filters. +// When non-date filters (action/machine/level) are active we can't combine them +// with orderBy('timestamp') without composite indexes, so we drop the orderBy +// and re-sort + date-filter client-side afterwards (see applyClientScope). +function buildLogsQuery( + logsRef: Query, + filters: { + action: string; + machine: string; + level: string; + datePreset: DatePreset; + dateFrom: string; + dateTo: string; + }, + max: number +): { q: Query; hasNonDateFilters: boolean; dateRange: { from: Date | null; to: Date | null } } { + const dateRange = getDateRange(filters.datePreset, filters.dateFrom, filters.dateTo); + const hasNonDateFilters = + filters.action !== 'all' || filters.machine !== 'all' || filters.level !== 'all'; + + let q: Query = hasNonDateFilters + ? query(logsRef, limit(max)) + : query(logsRef, orderBy('timestamp', 'desc'), limit(max)); + + if (filters.action !== 'all') q = query(q, where('action', '==', filters.action)); + if (filters.machine !== 'all') q = query(q, where('machineId', '==', filters.machine)); + if (filters.level !== 'all') q = query(q, where('level', '==', filters.level)); + if (!hasNonDateFilters) { + if (dateRange.from) q = query(q, where('timestamp', '>=', Timestamp.fromDate(dateRange.from))); + if (dateRange.to) q = query(q, where('timestamp', '<=', Timestamp.fromDate(dateRange.to))); + } + return { q, hasNonDateFilters, dateRange }; +} + +// Mirror, on the client, the ordering + date window Firestore couldn't apply +// server-side when non-date filters forced us to drop the orderBy. +function applyClientScope( + docs: LogEvent[], + hasNonDateFilters: boolean, + dateRange: { from: Date | null; to: Date | null } +): LogEvent[] { + if (!hasNonDateFilters) return docs; + let out = [...docs].sort((a, b) => b.timestamp.toMillis() - a.timestamp.toMillis()); + if (dateRange.from || dateRange.to) { + out = out.filter((log) => { + const ts = log.timestamp.toMillis(); + if (dateRange.from && ts < dateRange.from.getTime()) return false; + if (dateRange.to && ts > dateRange.to.getTime()) return false; + return true; + }); + } + return out; +} + // Extracted + memoized so toggling one row's expanded state doesn't re-render // every other row in the list. Without this, a click burns ~100–300ms on a // full page of logs before Radix can flip `data-state` and the animation can @@ -219,7 +278,7 @@ const LogRow = React.memo(function LogRow({ {log.details} -

{log.details}

+

{log.details}

)} @@ -304,6 +363,22 @@ export default function LogsPage() { const [filterDateTo, setFilterDateTo] = useState(''); const [showFilters, setShowFilters] = useState(false); + // Free-text search. `searchQuery` mirrors the input; `searchTerm` is the + // debounced, normalised value the filter actually runs against. `searchActive` + // toggles the collapsed button ↔ expanded field. + const [searchQuery, setSearchQuery] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [searchActive, setSearchActive] = useState(false); + const [searchCollapsedW, setSearchCollapsedW] = useState(); + const searchInputRef = useRef(null); + const searchWrapperRef = useRef(null); + const searchBtnRef = useRef(null); + // Full filtered scope loaded on demand while searching (see effect below). + const [searchPool, setSearchPool] = useState(null); + const [searchPoolLoading, setSearchPoolLoading] = useState(false); + const [searchPoolTruncated, setSearchPoolTruncated] = useState(false); + const isSearching = searchTerm.length > 0; + // Clear logs confirmation dialog const [showClearDialog, setShowClearDialog] = useState(false); const [screenshotModalUrl, setScreenshotModalUrl] = useState(null); @@ -335,15 +410,67 @@ export default function LogsPage() { }); }, []); - const allExpanded = logs.length > 0 && expandedLogIds.size === logs.length; + // Debounce the search input so typing doesn't re-filter/re-render on every + // keystroke once a large batch is loaded. + useEffect(() => { + const id = setTimeout(() => setSearchTerm(searchQuery.trim().toLowerCase()), 150); + return () => clearTimeout(id); + }, [searchQuery]); + + // Focus the field as it expands. + useEffect(() => { + if (searchActive) searchInputRef.current?.focus(); + }, [searchActive]); + + // Measure the collapsed button's natural width so expand/collapse can animate + // between real pixel widths — CSS can't transition to/from `auto`. Measured + // after paint while the wrapper is hugging content, so the value is exact and + // there's no layout shift. + useEffect(() => { + if (searchBtnRef.current) setSearchCollapsedW(searchBtnRef.current.offsetWidth); + }, []); + + // Collapse back to a button on outside click — but only when empty, so an + // active search is never silently hidden (e.g. clicking a log row to expand + // it while filtering). + useEffect(() => { + if (!searchActive) return; + const onMouseDown = (e: MouseEvent) => { + if (searchWrapperRef.current?.contains(e.target as Node)) return; + if (!searchQuery) setSearchActive(false); + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [searchActive, searchQuery]); + + // Client-side substring filter. Firestore has no full-text query, so we match + // in JS against the search pool (the full set matching the active server-side + // filters, loaded on demand) — falling back to the on-screen logs until it + // arrives. Matches the formatted action label, raw action, machine, process, + // level, and details. + const filteredLogs = useMemo(() => { + if (!searchTerm) return logs; + const source = searchPool ?? logs; + return source.filter(log => + formatAction(log.action).toLowerCase().includes(searchTerm) || + log.action.toLowerCase().includes(searchTerm) || + log.machineName?.toLowerCase().includes(searchTerm) || + log.machineId?.toLowerCase().includes(searchTerm) || + log.processName?.toLowerCase().includes(searchTerm) || + log.details?.toLowerCase().includes(searchTerm) || + log.level.toLowerCase().includes(searchTerm) + ); + }, [logs, searchPool, searchTerm]); + + const allExpanded = filteredLogs.length > 0 && filteredLogs.every(l => expandedLogIds.has(l.id)); const toggleAllExpanded = useCallback(() => { if (allExpanded) { setExpandedLogIds(new Set()); } else { - setExpandedLogIds(new Set(logs.map(l => l.id))); + setExpandedLogIds(new Set(filteredLogs.map(l => l.id))); } - }, [allExpanded, logs]); + }, [allExpanded, filteredLogs]); // Redirect if not logged in useEffect(() => { @@ -376,66 +503,20 @@ export default function LogsPage() { setLogsLoading(true); setExpandedLogIds(new Set()); - // Compute date range from preset - const dateRange = getDateRange(filterDatePreset, filterDateFrom, filterDateTo); - - // Check if any non-date filters are active (date filters use orderBy-compatible where clauses) - const hasNonDateFilters = filterAction !== 'all' || filterMachine !== 'all' || filterLevel !== 'all'; - - // Build query with filters const logsRef = collection(db, 'sites', currentSiteId, 'logs'); - let q: Query; - - // Always use orderBy('timestamp', 'desc') — date range filters are compatible with it. - // Only skip orderBy when non-date filters are active without a composite index. - if (hasNonDateFilters) { - q = query(logsRef, limit(LOGS_PER_PAGE + 1)); - } else { - q = query(logsRef, orderBy('timestamp', 'desc'), limit(LOGS_PER_PAGE + 1)); - } - - // Apply filters - if (filterAction !== 'all') { - q = query(q, where('action', '==', filterAction)); - } - if (filterMachine !== 'all') { - q = query(q, where('machineId', '==', filterMachine)); - } - if (filterLevel !== 'all') { - q = query(q, where('level', '==', filterLevel)); - } - // Date range filters — applied client-side when non-date filters are active (no composite index), - // or via Firestore where clauses when only date filters are active - if (!hasNonDateFilters) { - if (dateRange.from) { - q = query(q, where('timestamp', '>=', Timestamp.fromDate(dateRange.from))); - } - if (dateRange.to) { - q = query(q, where('timestamp', '<=', Timestamp.fromDate(dateRange.to))); - } - } + const { q, hasNonDateFilters, dateRange } = buildLogsQuery( + logsRef, + { action: filterAction, machine: filterMachine, level: filterLevel, datePreset: filterDatePreset, dateFrom: filterDateFrom, dateTo: filterDateTo }, + LOGS_PER_PAGE + 1 + ); // Set up real-time listener const unsubscribe = onSnapshot(q, (snapshot) => { - let docsData = snapshot.docs.map(doc => ({ - id: doc.id, - ...doc.data() - } as LogEvent)); - - // Sort client-side by timestamp if non-date filters are active - if (hasNonDateFilters) { - docsData.sort((a, b) => b.timestamp.toMillis() - a.timestamp.toMillis()); - } - - // Apply date range client-side when non-date filters are active - if (hasNonDateFilters && (dateRange.from || dateRange.to)) { - docsData = docsData.filter(log => { - const ts = log.timestamp.toMillis(); - if (dateRange.from && ts < dateRange.from.getTime()) return false; - if (dateRange.to && ts > dateRange.to.getTime()) return false; - return true; - }); - } + const docsData = applyClientScope( + snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as LogEvent)), + hasNonDateFilters, + dateRange + ); // Check if there are more pages const hasMoreData = docsData.length > LOGS_PER_PAGE; @@ -443,7 +524,6 @@ export default function LogsPage() { // Remove the extra document used for pagination check const displayLogs = hasMoreData ? docsData.slice(0, LOGS_PER_PAGE) : docsData; - setLogs(displayLogs); // Set pagination marker for infinite scroll @@ -461,6 +541,53 @@ export default function LogsPage() { return () => unsubscribe(); }, [currentSiteId, filterAction, filterMachine, filterLevel, filterDatePreset, filterDateFrom, filterDateTo]); + // While searching, load the full set of logs matching the current server-side + // filters (capped) so search spans the whole scope, not just the visible 50. + // Re-runs when the filters change, not on every keystroke (the text filters + // the pool client-side in `filteredLogs`). + useEffect(() => { + if (!isSearching || !currentSiteId || !db) { + setSearchPool(null); + setSearchPoolTruncated(false); + setSearchPoolLoading(false); + return; + } + + let cancelled = false; + setSearchPoolLoading(true); + + (async () => { + try { + const logsRef = collection(db, 'sites', currentSiteId, 'logs'); + const { q, hasNonDateFilters, dateRange } = buildLogsQuery( + logsRef, + { action: filterAction, machine: filterMachine, level: filterLevel, datePreset: filterDatePreset, dateFrom: filterDateFrom, dateTo: filterDateTo }, + SEARCH_POOL_CAP + 1 + ); + const snapshot = await getDocs(q); + if (cancelled) return; + + const docsData = applyClientScope( + snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as LogEvent)), + hasNonDateFilters, + dateRange + ); + setSearchPoolTruncated(docsData.length > SEARCH_POOL_CAP); + setSearchPool(docsData.slice(0, SEARCH_POOL_CAP)); + } catch (error) { + if (!cancelled) { + console.error('Error loading search pool:', error); + setSearchPool(null); + } + } finally { + if (!cancelled) setSearchPoolLoading(false); + } + })(); + + return () => { cancelled = true; }; + // `isSearching` (not `searchTerm`) so we don't refetch on every keystroke. + }, [isSearching, currentSiteId, filterAction, filterMachine, filterLevel, filterDatePreset, filterDateFrom, filterDateTo]); + // Infinite scroll — load more logs const loadMore = useCallback(async () => { if (!currentSiteId || !db || !lastDoc || !hasMore || isFetchingMore) return; @@ -503,7 +630,9 @@ export default function LogsPage() { // IntersectionObserver for infinite scroll sentinel useEffect(() => { const sentinel = sentinelRef.current; - if (!sentinel) return; + // Pause infinite scroll while searching: a short filtered list keeps the + // sentinel on-screen, which would otherwise auto-load every remaining page. + if (!sentinel || searchTerm) return; const observer = new IntersectionObserver( (entries) => { @@ -516,7 +645,7 @@ export default function LogsPage() { observer.observe(sentinel); return () => observer.disconnect(); - }, [hasMore, isFetchingMore, loadMore]); + }, [hasMore, isFetchingMore, loadMore, searchTerm]); const resetFilters = () => { setFilterAction('all'); @@ -571,9 +700,14 @@ export default function LogsPage() { } }; - // Get unique machines for filter + // Get unique machines for filter — drawn from the full loaded set (not the + // search-filtered view) so the dropdown doesn't collapse as you type. const uniqueMachines = Array.from(new Set(logs.map(log => log.machineId))); + // Header stats reflect the currently shown (search-filtered) logs. + const warningCount = filteredLogs.filter(l => l.level === 'warning').length; + const errorCount = filteredLogs.filter(l => l.level === 'error').length; + if (loading || sitesLoading) { return (
@@ -635,12 +769,12 @@ export default function LogsPage() {
-
0 ? 'bg-accent-cyan/10 text-accent-cyan' : 'bg-muted text-muted-foreground'}`}> +
0 ? 'bg-accent-cyan/10 text-accent-cyan' : 'bg-muted text-muted-foreground'}`}>
- {logs.length} + {filteredLogs.length}

events

@@ -649,12 +783,12 @@ export default function LogsPage() {
-
l.level === 'warning').length > 0 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-muted text-muted-foreground'}`}> +
0 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-muted text-muted-foreground'}`}>
- l.level === 'warning').length > 0 ? 'text-yellow-400' : 'text-foreground'}`}>{logs.filter(l => l.level === 'warning').length} + 0 ? 'text-yellow-400' : 'text-foreground'}`}>{warningCount}

warnings

@@ -663,12 +797,12 @@ export default function LogsPage() {
-
l.level === 'error').length > 0 ? 'bg-red-500/10 text-red-400' : 'bg-muted text-muted-foreground'}`}> +
0 ? 'bg-red-500/10 text-red-400' : 'bg-muted text-muted-foreground'}`}>
- l.level === 'error').length > 0 ? 'text-red-400' : 'text-foreground'}`}>{logs.filter(l => l.level === 'error').length} + 0 ? 'text-red-400' : 'text-foreground'}`}>{errorCount}

errors

@@ -677,7 +811,7 @@ export default function LogsPage() {
- {logs.length > 0 && ( + {filteredLogs.length > 0 && ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchQuery(''); + setSearchActive(false); + } + }} + tabIndex={searchActive ? 0 : -1} + data-testid="logs-search" + className="h-9 w-full pl-9 pr-9 bg-muted border-border" + /> + {searchQuery && ( + + )} +
+
- {/* Filters */} - {showFilters && ( + {/* Filters — animated expand/collapse via Radix Collapsible, reusing the + shared collapsible-down/up keyframes in globals.css */} + +
@@ -1010,7 +1013,8 @@ export default function LogsPage() {
)} - )} + + {/* Search scope notice — only when the matching scope exceeds the cap */} {isSearching && searchPoolTruncated && ( From a58bc9d527f1a689aebb76804a0027c8ad8f4cb8 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Sat, 23 May 2026 11:44:20 -0700 Subject: [PATCH 07/43] fix(web): repair dark-mode button hover, standardize on secondary rollover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outline/destructive buttons had no hover in dark mode: the resting dark:bg-input/30 (and dark:bg-destructive/60) ties hover:bg-* on specificity (both 0,2,0 under @custom-variant dark (&:is(.dark *))), and Tailwind emits dark: after hover:, so the resting bg won and the hover bg never rendered. Fixed by adding higher-specificity dark:hover:* (0,3,0), matching the pattern ghost already used. Standardizes the neutral hover on the secondary token to match the header rollover (PageHeader hover:bg-secondary): outline + ghost now roll over to bg-secondary everywhere; default (cyan) and destructive (red) keep their color-based hovers. Removes the dead hover:bg-muted overrides on the logs toolbar and makes clear-logs dark-hover-aware so it stays red. Adds a Playwright regression test asserting outline toolbar buttons change background on hover (would have failed in dark mode pre-fix). Amends CLAUDE.md: ui/* are owned, customizable shadcn primitives — button.tsx is the single source of truth for button styling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 6 +++- web/app/logs/page.tsx | 8 ++--- web/components/ui/button.tsx | 6 ++-- web/e2e/specs/visual/button-hover.spec.ts | 43 +++++++++++++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 web/e2e/specs/visual/button-hover.spec.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2f59d251..1645b8b7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -164,7 +164,6 @@ curl -s -X PUT "$BASE_URL/api/installer/upload" \ ## Don'ts / Guardrails ### Files You Must Not Touch -- `web/components/ui/*` — auto-generated by shadcn/ui - `firestore.rules` — don't modify without explicit request - `.tokens.enc` / credential files — never read, log, or commit - `owlette_installer.iss` — only modify if you understand the full build pipeline @@ -181,6 +180,11 @@ curl -s -X PUT "$BASE_URL/api/installer/upload" \ - Exceptions (keep normal casing): proper nouns/product names in external contexts, acronyms (`LLM`, `API`, `URL`, `GPU`, `OAuth`), code identifiers, machine IDs / site IDs / user-entered strings, and legal/compliance text where casing is load-bearing. - When adding new copy, default to lowercase. When editing existing strings, match the surrounding casing — don't mix sentence case into a lowercase screen or vice versa. +### Design System (shadcn/ui) +- `web/components/ui/*` are shadcn primitives **copied into the repo — we own them.** Editing them for theming, variants, hover/focus states, and standardization is the *intended* shadcn workflow (they're scaffolding you customize, not auto-generated black boxes — they've been hand-tuned before). +- Caveats when editing: changes are **app-wide**, so verify broadly; re-running `npx shadcn add ` **overwrites** that file, so port upstream fixes by hand; prefer CSS-variable tokens (`web/app/globals.css`) over hardcoded values. +- **`button.tsx` variants are the single source of truth for button styling.** Standardize there — don't sprinkle per-instance `hover:*`/`bg-*` overrides on individual ` @@ -240,7 +240,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB } }} aria-label="copy owlette agent download link" - className="text-muted-foreground hover:text-foreground hover:bg-secondary cursor-pointer p-1.5" + className="text-muted-foreground cursor-pointer p-1.5" > @@ -314,7 +314,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB size="sm" onClick={() => copyToClipboard(generatedPhrase, 'Phrase')} aria-label="copy pairing phrase" - className="border-border text-foreground hover:bg-secondary cursor-pointer shrink-0" + className="border-border text-foreground cursor-pointer shrink-0" > @@ -336,7 +336,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB 'Command' )} aria-label="copy silent install command" - className="border-border text-foreground hover:bg-secondary cursor-pointer shrink-0" + className="border-border text-foreground cursor-pointer shrink-0" > @@ -351,7 +351,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB variant="ghost" size="sm" onClick={() => { setGenerateSuccess(false); setGeneratedPhrase(''); handleGenerate(); }} - className="text-muted-foreground hover:text-foreground hover:bg-secondary cursor-pointer h-7 px-2 text-xs" + className="text-muted-foreground cursor-pointer h-7 px-2 text-xs" > regenerate diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx index 8c02568d..5304fc81 100644 --- a/web/app/dashboard/page.tsx +++ b/web/app/dashboard/page.tsx @@ -941,7 +941,7 @@ export default function DashboardPage() { variant="ghost" size="sm" onClick={toggleAllExpanded} - className="cursor-pointer text-muted-foreground hover:bg-secondary hover:text-foreground" + className="cursor-pointer text-muted-foreground" > {allExpanded ? : } @@ -957,7 +957,7 @@ export default function DashboardPage() { variant="ghost" size="sm" onClick={() => handleViewChange('card')} - className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > @@ -973,7 +973,7 @@ export default function DashboardPage() { size="sm" onClick={() => handleViewChange('list')} data-testid="view-toggle-list" - className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > diff --git a/web/app/demo/page.tsx b/web/app/demo/page.tsx index 34d8bfff..673323dc 100644 --- a/web/app/demo/page.tsx +++ b/web/app/demo/page.tsx @@ -254,7 +254,7 @@ export default function DemoPage() { size="sm" onClick={toggleAllProcesses} aria-label={allExpanded ? 'collapse all' : 'expand all'} - className="cursor-pointer text-muted-foreground hover:bg-secondary hover:text-foreground" + className="cursor-pointer text-muted-foreground" > {allExpanded ? : } @@ -271,7 +271,7 @@ export default function DemoPage() { size="sm" onClick={() => setViewType('card')} aria-label="card view" - className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > @@ -287,7 +287,7 @@ export default function DemoPage() { size="sm" onClick={() => setViewType('list')} aria-label="list view" - className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index c912aff3..cab4dd3d 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -255,7 +255,7 @@ function LoginForm() { diff --git a/web/components/PasskeyManager.tsx b/web/components/PasskeyManager.tsx index eb968bfd..3d4e3c91 100644 --- a/web/components/PasskeyManager.tsx +++ b/web/components/PasskeyManager.tsx @@ -242,7 +242,7 @@ export function PasskeyManager({ userId, compact = false }: PasskeyManagerProps) From dceb5834e8836f096c03aae47d02330ce2198596 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Sat, 23 May 2026 17:24:49 -0700 Subject: [PATCH 09/43] feat(web): sunken machine card/list surfaces with section enclosures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a --card-sunken token and reworks the machine cards into a clear surface hierarchy: the card/list container recedes (bg-card-sunken) while content stays at bg-card and reads as raised. Metrics and processes are now single enclosed panels with hairline dividers (divide-border/60) instead of floating cards over dark gaps — the process tree connector is dropped (the enclosure already groups them under the machine). Sections align at px-6 so left edges and the expand chevrons line up. Borders unified: subtle /30 panel borders, full borders softened (icon buttons/controls -> /50, card outer -> /60). Row hover is now a subtle bg-secondary/25 fill (::after) instead of a square ring; removed the dead getUsageRingClass. List view gets the same sunken container + light rows. Demo toolbar -> bg-card-sunken. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/components/MachineCardView.tsx | 80 ++++++++----------- .../dashboard/components/MachineListView.tsx | 8 +- web/app/demo/page.tsx | 2 +- web/app/globals.css | 3 + 4 files changed, 40 insertions(+), 53 deletions(-) diff --git a/web/app/dashboard/components/MachineCardView.tsx b/web/app/dashboard/components/MachineCardView.tsx index 054132dd..4334f214 100644 --- a/web/app/dashboard/components/MachineCardView.tsx +++ b/web/app/dashboard/components/MachineCardView.tsx @@ -28,7 +28,7 @@ import { SparklineChart } from '@/components/charts'; import { ChevronDown, ChevronUp, Pencil, Square, Plus, Clock, AlertTriangle, X, RotateCcw, RotateCw, Settings2, BellOff } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; import { formatTemperature, getTemperatureColorClass } from '@/lib/temperatureUtils'; -import { getUsageColorClass, getUsageRingClass } from '@/lib/usageColorUtils'; +import { getUsageColorClass } from '@/lib/usageColorUtils'; import { formatHeartbeatTime, formatMachineLocalClock, formatTimezoneShortName, getDisplayTimezone } from '@/lib/timeUtils'; import { formatThroughput } from '@/lib/networkUtils'; import { DISK_IO_COLORS, formatDiskIO } from '@/lib/diskIOUtils'; @@ -225,7 +225,7 @@ function MachineCard({ ); return ( - +
@@ -344,7 +344,7 @@ function MachineCard({ {!statsExpanded && ( - @@ -852,7 +837,7 @@ function MachineCard({ size="sm" onClick={() => onRestartProcess(process.id, process.name)} aria-label={`restart ${process.name}`} - className="bg-card border border-border text-foreground disabled:cursor-not-allowed disabled:opacity-50 p-2" + className="bg-card border border-border/50 text-foreground disabled:cursor-not-allowed disabled:opacity-50 p-2" disabled={process.status !== 'RUNNING' && process.status !== 'LAUNCHING' && process.status !== 'STALLED'} > @@ -869,7 +854,7 @@ function MachineCard({ size="sm" onClick={() => onKillProcess(process.id, process.name)} aria-label={`kill ${process.name}`} - className="bg-card border border-border text-red-400 hover:bg-red-950/50 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-50 p-2" + className="bg-card border border-border/50 text-red-400 hover:bg-red-950/50 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-50 p-2" disabled={process.status !== 'RUNNING' && process.status !== 'LAUNCHING' && process.status !== 'STALLED'} > @@ -881,7 +866,6 @@ function MachineCard({
-
))}
{/* add process Button */} @@ -890,7 +874,7 @@ function MachineCard({ variant="ghost" size="sm" onClick={onCreateProcess} - className="bg-card border border-border text-accent-cyan hover:bg-accent-cyan/15 hover:text-accent-cyan" + className="bg-card border border-border/50 text-accent-cyan hover:bg-accent-cyan/15 hover:text-accent-cyan" > add process @@ -903,12 +887,12 @@ function MachineCard({ {/* add process button for machines with no processes */} {(!machine.processes || machine.processes.length === 0) && ( -
+
From 2b9f99241970a1d68d6d6083132f8b96e5f3c0b6 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 00:52:05 -0700 Subject: [PATCH 31/43] feat(cortex): tier-3 tool-approval gate + sidebar/UX fixes Privileged tier-3 Cortex tools (run_powershell, execute_script, reboot, etc.) now pause for explicit in-chat approval before running, via the AI SDK v6 native approval API (needsApproval + addToolApprovalResponse + sendAutomaticallyWhen). Tier 1/2 keep auto-running; autonomous Cortex (separate buildAutonomousTools) is intentionally not gated. Server: - buildExecutableTools marks tier-3 tools needsApproval - per-site flag sites/{siteId}/settings/cortex.requireTier3Approval (default on) via getCortexRequireTier3Approval + setCortexRequireTier3Approval action + admin-gated PATCH /api/sites/{siteId}/cortex-settings - /api/cortex migrated to the UIMessage protocol + convertToModelMessages so the approval round-trip carries the pending tool call + decision back to resume; local Cortex is skipped when approval is required so the gate can fire (mirrored in cortexStream.server.ts) Client/UI: - approval card (approve/deny) in ToolCallCard/ChatWindow; output-error renders as a failed card instead of green success - per-site admin approval toggle (CortexApprovalToggle) + read hook Sidebar/UX (from a 10-agent review): - hoist CortexChatView into a persistent app/cortex/layout.tsx so navigating /cortex <-> /cortex/{id} no longer remounts and resets sidebar/collapse state or drops the optimistic new-chat row; routing refs reworked accordingly - persist sidebar open + per-category collapse to devicePrefs (useCortexSidebarPrefs) - active conversation auto-expands its group + scrolls into view; collapsed group holding the active chat is flagged; collapse-all label/action desync fixed - conversation rows made keyboard/SR accessible; button hover fixes; hexagon neuron loader with reduced-motion fallback Tests added for tier-3 needsApproval mapping, the approval-flag default/fail-safe, and the setCortexRequireTier3Approval action. Lint, tsc, full jest suite, and production build all clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../setCortexRequireTier3Approval.test.ts | 84 +++++++++ web/__tests__/lib/cortex-utils.test.ts | 60 +++++++ web/app/api/cortex/route.ts | 65 +++---- .../sites/[siteId]/cortex-settings/route.ts | 82 +++++++++ web/app/cortex/[chatId]/page.tsx | 14 +- web/app/cortex/components/ChatWindow.tsx | 36 +++- .../components/CortexApprovalToggle.tsx | 100 +++++++++++ web/app/cortex/components/CortexChatView.tsx | 150 ++++++++++++---- .../cortex/components/SynapticIndicator.tsx | 164 ++++++++---------- web/app/cortex/components/ToolCallCard.tsx | 97 +++++++++-- web/app/cortex/layout.tsx | 29 ++++ web/app/cortex/page.tsx | 9 +- web/components/ConfirmDialog.tsx | 2 +- web/hooks/useCortex.ts | 97 ++++++----- web/hooks/useCortexApprovalSetting.ts | 34 ++++ web/hooks/useCortexSidebarPrefs.ts | 134 ++++++++++++++ .../setCortexRequireTier3Approval.server.ts | 73 ++++++++ web/lib/cortex-utils.server.ts | 43 +++++ web/lib/cortexStream.server.ts | 12 +- 19 files changed, 1051 insertions(+), 234 deletions(-) create mode 100644 web/__tests__/lib/actions/setCortexRequireTier3Approval.test.ts create mode 100644 web/app/api/sites/[siteId]/cortex-settings/route.ts create mode 100644 web/app/cortex/components/CortexApprovalToggle.tsx create mode 100644 web/app/cortex/layout.tsx create mode 100644 web/hooks/useCortexApprovalSetting.ts create mode 100644 web/hooks/useCortexSidebarPrefs.ts create mode 100644 web/lib/actions/setCortexRequireTier3Approval.server.ts diff --git a/web/__tests__/lib/actions/setCortexRequireTier3Approval.test.ts b/web/__tests__/lib/actions/setCortexRequireTier3Approval.test.ts new file mode 100644 index 00000000..f76bf008 --- /dev/null +++ b/web/__tests__/lib/actions/setCortexRequireTier3Approval.test.ts @@ -0,0 +1,84 @@ +/** @jest-environment node */ + +import type { Actor } from '@/lib/capabilities'; + +const mockSet = jest.fn().mockResolvedValue(undefined); +const mockEmitMutation = jest.fn(); + +jest.mock('@/lib/firebase-admin', () => ({ + getAdminDb: () => ({ + collection: () => ({ + doc: () => ({ + collection: () => ({ + doc: () => ({ set: mockSet }), + }), + }), + }), + }), +})); +jest.mock('firebase-admin/firestore', () => ({ + FieldValue: { serverTimestamp: jest.fn(() => '__SERVER_TS__') }, +})); +jest.mock('@/lib/auditLogClient', () => ({ + emitMutation: (...args: unknown[]) => mockEmitMutation(...args), +})); +jest.mock('@/lib/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +import { setCortexRequireTier3Approval } from '@/lib/actions/setCortexRequireTier3Approval.server'; +import { ActionInputError, type ActionContext } from '@/lib/actions/createProcess.server'; + +const SITE = 'site-a'; +const ACTOR: Actor = { type: 'user', userId: 'uid', role: 'admin', sites: [SITE] }; +const CTX: ActionContext = { siteId: SITE, actor: ACTOR, auditActor: 'user:uid' }; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('setCortexRequireTier3Approval', () => { + it('merge-writes requireTier3Approval=true to the site cortex settings doc', async () => { + const result = await setCortexRequireTier3Approval(CTX, { requireTier3Approval: true }); + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ requireTier3Approval: true }), + { merge: true }, + ); + expect(result).toEqual({ siteId: SITE, requireTier3Approval: true }); + }); + + it('writes requireTier3Approval=false to disable the gate', async () => { + await setCortexRequireTier3Approval(CTX, { requireTier3Approval: false }); + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ requireTier3Approval: false }), + { merge: true }, + ); + }); + + it('emits an audit with verb=set_cortex_require_tier3_approval', async () => { + await setCortexRequireTier3Approval(CTX, { requireTier3Approval: false }); + expect(mockEmitMutation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'site_mutated', + siteId: SITE, + targetId: SITE, + attributes: expect.objectContaining({ + verb: 'set_cortex_require_tier3_approval', + method: 'PATCH', + requireTier3Approval: false, + }), + }), + ); + }); + + it('rejects a non-boolean value and does not write or audit', async () => { + await expect( + // @ts-expect-error — testing runtime validation + setCortexRequireTier3Approval(CTX, { requireTier3Approval: 'yes' }), + ).rejects.toThrow(ActionInputError); + expect(mockSet).not.toHaveBeenCalled(); + expect(mockEmitMutation).not.toHaveBeenCalled(); + }); +}); diff --git a/web/__tests__/lib/cortex-utils.test.ts b/web/__tests__/lib/cortex-utils.test.ts index 3b97999e..0e9f8646 100644 --- a/web/__tests__/lib/cortex-utils.test.ts +++ b/web/__tests__/lib/cortex-utils.test.ts @@ -141,6 +141,7 @@ import { buildExecutableTools, verifyUserSiteAccess, resolveCortexMaxTier, + getCortexRequireTier3Approval, COMMAND_TIMEOUT_MS, } from '@/lib/cortex-utils.server'; @@ -289,6 +290,16 @@ describe('buildExecutableTools', () => { expect(Object.keys(tools)).toHaveLength(allTools.length); }); + it('marks tier-3 tools needsApproval and leaves tier-1/2 auto-running', () => { + const tools = buildExecutableTools({} as unknown as FirebaseFirestore.Firestore, 's1', 'm1', 'c1', allTools); + for (const def of allTools) { + expect(tools[def.name].needsApproval).toBe(def.tier >= 3); + } + // Sanity: the fixture actually exercises both sides of the gate. + expect(allTools.some((t) => t.tier >= 3)).toBe(true); + expect(allTools.some((t) => t.tier < 3)).toBe(true); + }); + it('executes update_process server-side and resolves process_name to processId', async () => { mockUpdateProcess.mockResolvedValue({ processId: 'proc-1' }); const { db } = createProcessConfigDb([ @@ -480,3 +491,52 @@ describe('resolveCortexMaxTier', () => { ).toBe(1); }); }); + +// ─── getCortexRequireTier3Approval ────────────────────────────────────────── + +/** db stub for sites/{siteId}/settings/cortex.get(). Pass 'throw' to simulate a read error. */ +function makeCortexSettingsDb( + cortexDoc: { exists: boolean; data?: () => unknown } | 'throw', +) { + return { + collection: () => ({ + doc: () => ({ + collection: () => ({ + doc: () => ({ + get: async () => { + if (cortexDoc === 'throw') throw new Error('firestore down'); + return cortexDoc; + }, + }), + }), + }), + }), + } as unknown as FirebaseFirestore.Firestore; +} + +describe('getCortexRequireTier3Approval', () => { + it('defaults to true (gate on) when the settings doc is absent', async () => { + const db = makeCortexSettingsDb({ exists: false }); + expect(await getCortexRequireTier3Approval(db, 's1')).toBe(true); + }); + + it('defaults to true when the field is absent', async () => { + const db = makeCortexSettingsDb({ exists: true, data: () => ({}) }); + expect(await getCortexRequireTier3Approval(db, 's1')).toBe(true); + }); + + it('returns false only when explicitly disabled', async () => { + const db = makeCortexSettingsDb({ exists: true, data: () => ({ requireTier3Approval: false }) }); + expect(await getCortexRequireTier3Approval(db, 's1')).toBe(false); + }); + + it('returns true when explicitly enabled', async () => { + const db = makeCortexSettingsDb({ exists: true, data: () => ({ requireTier3Approval: true }) }); + expect(await getCortexRequireTier3Approval(db, 's1')).toBe(true); + }); + + it('fails safe (true) when the read throws', async () => { + const db = makeCortexSettingsDb('throw'); + expect(await getCortexRequireTier3Approval(db, 's1')).toBe(true); + }); +}); diff --git a/web/app/api/cortex/route.ts b/web/app/api/cortex/route.ts index 01a539ef..8270bfb6 100644 --- a/web/app/api/cortex/route.ts +++ b/web/app/api/cortex/route.ts @@ -11,7 +11,7 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { streamText, stepCountIs, type ModelMessage } from 'ai'; +import { streamText, stepCountIs, convertToModelMessages, type ModelMessage, type UIMessage } from 'ai'; import { resolveAuth, requireScope } from '@/lib/apiAuth.server'; import { getAdminDb } from '@/lib/firebase-admin'; import { FieldValue } from 'firebase-admin/firestore'; @@ -25,6 +25,7 @@ import { isMachineOnline, isCortexEnabled, getOnlineMachines, + getCortexRequireTier3Approval, buildExecutableTools, type SiteAccessLevel, } from '@/lib/cortex-utils.server'; @@ -124,14 +125,14 @@ export async function POST(request: NextRequest) { machineName, chatId, } = body as { - messages: ModelMessage[]; // content can be string or Array + messages: UIMessage[]; // AI SDK UIMessages (text + file + tool/approval parts) siteId: string; machineId: string; machineName: string; chatId: string; }; - if (!messages || !siteId || !chatId) { + if (!messages || !Array.isArray(messages) || messages.length === 0 || !siteId || !chatId) { return NextResponse.json( { error: 'messages, siteId, and chatId are required' }, { status: 400 }, @@ -157,7 +158,7 @@ export async function POST(request: NextRequest) { // ─── Site-Wide Mode (unchanged — web-side LLM) ───────────────────── if (isSiteMode) { - return handleSiteWideMode(db, userId, siteId, messages, chatId, effectiveAccess); + return handleSiteWideMode(db, userId, siteId, await convertToModelMessages(messages), chatId, effectiveAccess); } // ─── Single Machine Mode ─────────────────────────────────────────── @@ -184,11 +185,18 @@ export async function POST(request: NextRequest) { ); } - // Non-admins are forced through the server-side LLM path so the tier - // cap (tier 1, read-only) is actually enforced. The local Cortex path - // runs tools inside the agent and does not yet honor a per-user tier - // cap — routing members through it would reopen the tier-3 exposure. - const cortexLocal = effectiveAccess.isSiteAdmin + // Routing into the local Cortex path requires BOTH: + // 1. site-admin caller — the local path runs tools inside the agent and + // does not honor a per-user tier cap, so non-admins are forced through + // the server-side LLM path where the tier cap (tier 1) is enforced. + // 2. tier-3 approval NOT required for this site — the local path executes + // tools inside the agent, so the web-side `needsApproval` gate cannot + // see or pause them. When approval is required (the default), admins are + // routed server-side so the AI SDK approval gate fires. See + // getCortexRequireTier3Approval / the §6 decision in the PR. + const localPathAllowed = + effectiveAccess.isSiteAdmin && !(await getCortexRequireTier3Approval(db, siteId)); + const cortexLocal = localPathAllowed ? await isCortexLocal(db, siteId, machineId) : false; @@ -197,7 +205,7 @@ export async function POST(request: NextRequest) { return handleLocalCortex(db, siteId, machineId, machineName, messages, chatId); } else { // ─── Fallback: Server-side LLM (existing approach) ──────────── - return handleServerSideLLM(db, userId, siteId, machineId, machineName, messages, chatId, effectiveAccess); + return handleServerSideLLM(db, userId, siteId, machineId, machineName, await convertToModelMessages(messages), chatId, effectiveAccess); } } catch (error: unknown) { return apiError(error, 'cortex'); @@ -220,7 +228,7 @@ async function handleLocalCortex( siteId: string, machineId: string, machineName: string, - messages: ModelMessage[], + messages: UIMessage[], chatId: string, ): Promise { const activeChatRef = db @@ -231,34 +239,29 @@ async function handleLocalCortex( .collection('cortex') .doc('active-chat'); - // Extract user message text and images + // Extract the latest user message text + images from its UIMessage parts. const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); - const msgContent = (lastUserMsg as { content?: unknown })?.content; let userText = ''; const images: Array<{ url: string; mediaType: string }> = []; - if (typeof msgContent === 'string') { - userText = msgContent; - } else if (Array.isArray(msgContent)) { - for (const block of msgContent) { - if (block.type === 'text') userText += block.text || ''; - if (block.type === 'image' && block.image) { - images.push({ url: String(block.image), mediaType: block.mediaType || 'image/jpeg' }); - } + for (const part of lastUserMsg?.parts ?? []) { + if (part.type === 'text') { + userText += part.text || ''; + } else if (part.type === 'file' && part.mediaType?.startsWith('image/') && part.url) { + images.push({ url: part.url, mediaType: part.mediaType }); } } - // Serialize messages for Firestore (flatten multimodal to text for history) - const serializedMessages = messages.map((m) => { - const c = (m as { content?: unknown }).content; - if (typeof c === 'string') return { role: m.role, content: c }; - if (Array.isArray(c)) { - const text = c.filter((b: { type: string }) => b.type === 'text').map((b: { text?: string }) => b.text || '').join(''); - return { role: m.role, content: text }; - } - return { role: m.role, content: '' }; - }); + // Serialize messages for Firestore (flatten to text history for the agent, + // which consumes `[{ role, content }]` — preserves the existing contract). + const serializedMessages = messages.map((m) => ({ + role: m.role, + content: m.parts + .filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text || '') + .join(''), + })); // Write pending message for local Cortex to pick up await activeChatRef.set( diff --git a/web/app/api/sites/[siteId]/cortex-settings/route.ts b/web/app/api/sites/[siteId]/cortex-settings/route.ts new file mode 100644 index 00000000..92154f1d --- /dev/null +++ b/web/app/api/sites/[siteId]/cortex-settings/route.ts @@ -0,0 +1,82 @@ +/** + * PATCH /api/sites/{siteId}/cortex-settings + * + * Update per-site Cortex policy. Currently exposes a single field, + * `requireTier3Approval`: when `true` (the default), privileged tier-3 tool + * calls pause for explicit in-chat approval and single-machine admin chats are + * routed server-side so the approval gate can fire. When `false`, local Cortex + * is allowed and the gate does not apply. + * + * Gated by `MACHINE_CONFIG_WRITE` — the same site-scoped capability that backs + * the per-machine cortex-enabled toggle, so site admins manage their own site's + * Cortex policy. Writes go to `sites/{siteId}/settings/cortex` (service-account + * only at the Firestore-rules layer; clients read it directly). + * + * Request body: + * { "requireTier3Approval": boolean } + */ +import { NextResponse } from 'next/server'; +import { withRateLimit } from '@/lib/withRateLimit'; +import { resolveAuth } from '@/lib/apiAuth.server'; +import { authorizedSiteHandler } from '@/lib/authorizedHandler.server'; +import { setCortexRequireTier3Approval } from '@/lib/actions/setCortexRequireTier3Approval.server'; +import { ActionInputError } from '@/lib/actions/createProcess.server'; + +const patchWrapped = authorizedSiteHandler<{ siteId: string }>({ + capability: 'MACHINE_CONFIG_WRITE', + siteIdParam: 'path', +})(async (request, ctx) => { + try { + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return problem(400, 'invalid_body', 'Request body must be valid JSON.'); + } + + if (typeof body.requireTier3Approval !== 'boolean') { + return problem( + 400, + 'invalid_require_tier3_approval', + 'Field `requireTier3Approval` must be a boolean.', + ); + } + + const auth = await resolveAuth(request); + const auditActor = auth.keyContext + ? `apiKey:${auth.keyContext.keyId}` + : `user:${auth.userId}`; + + try { + const result = await setCortexRequireTier3Approval( + { siteId: ctx.siteId, actor: ctx.actor, auditActor }, + { requireTier3Approval: body.requireTier3Approval }, + ); + return NextResponse.json({ ok: true, data: result }); + } catch (e) { + if (e instanceof ActionInputError) { + return problem(e.status, e.code, e.message); + } + throw e; + } + } catch (error: unknown) { + console.error('sites/cortex-settings PATCH:', error); + return problem( + 500, + 'internal_error', + error instanceof Error ? error.message : 'Internal server error', + ); + } +}); + +export const PATCH = withRateLimit(patchWrapped, { + strategy: 'api', + identifier: 'ip', +}); + +function problem(status: number, code: string, detail: string): NextResponse { + return NextResponse.json( + { type: 'about:blank', title: code, status, code, detail }, + { status, headers: { 'Content-Type': 'application/problem+json' } }, + ); +} diff --git a/web/app/cortex/[chatId]/page.tsx b/web/app/cortex/[chatId]/page.tsx index 053d51d9..76b33f7e 100644 --- a/web/app/cortex/[chatId]/page.tsx +++ b/web/app/cortex/[chatId]/page.tsx @@ -1,10 +1,6 @@ -import { CortexChatView } from '../components/CortexChatView'; - -export default async function CortexChatPage({ - params, -}: { - params: Promise<{ chatId: string }>; -}) { - const { chatId } = await params; - return ; +// The Cortex view is rendered by the persistent layout (app/cortex/layout.tsx), +// which derives the active chat id from the pathname. This page exists only so +// the /cortex/[chatId] route resolves. +export default function CortexChatPage() { + return null; } diff --git a/web/app/cortex/components/ChatWindow.tsx b/web/app/cortex/components/ChatWindow.tsx index 442b7f6b..4b826cdd 100644 --- a/web/app/cortex/components/ChatWindow.tsx +++ b/web/app/cortex/components/ChatWindow.tsx @@ -25,9 +25,13 @@ interface ChatWindowProps { isLoading: boolean; hasApiKey?: boolean | null; onOpenSettings?: () => void; + /** Approve/deny a pending tier-3 tool call by its approvalId. */ + onToolApproval?: (approvalId: string, approved: boolean) => void; + /** Where tool calls run, shown in the approval prompt (machine / "all machines"). */ + approvalTargetLabel?: string; } -export function ChatWindow({ messages, isLoading }: ChatWindowProps) { +export function ChatWindow({ messages, isLoading, onToolApproval, approvalTargetLabel }: ChatWindowProps) { const { user } = useAuth(); const bottomRef = useRef(null); const topRef = useRef(null); @@ -230,13 +234,31 @@ export function ChatWindow({ messages, isLoading }: ChatWindowProps) { if (part.type.startsWith('tool-') || part.type === 'dynamic-tool') { // v6: static tool parts have type 'tool-{name}', dynamic ones have type 'dynamic-tool' with toolName - const toolPart = part as { type: string; toolName?: string; toolCallId?: string; args?: unknown; input?: unknown; output?: unknown; state?: string }; + const toolPart = part as { + type: string; toolName?: string; toolCallId?: string; + args?: unknown; input?: unknown; output?: unknown; errorText?: string; state?: string; + approval?: { id: string; approved?: boolean }; + }; const toolName = toolPart.type === 'dynamic-tool' ? (toolPart.toolName || 'unknown') : toolPart.type.slice(5); // strip 'tool-' prefix const args = (toolPart.args || toolPart.input || {}) as Record; - const result = toolPart.output; - const hasResult = toolPart.state === 'output-available' || toolPart.state === 'output-error' || toolPart.state === 'result' || result !== undefined; + const state = toolPart.state; + // 'output-error' carries the message in `errorText` (not `output`); + // surface it as an { error } result so the card renders the failed state. + const result = state === 'output-error' + ? { error: toolPart.errorText || 'tool execution failed' } + : toolPart.output; + const hasResult = state === 'output-available' || state === 'output-error' || state === 'result' || toolPart.output !== undefined; + + // Tier-3 approval gate (AI SDK human-in-the-loop): + // approval-requested → show approve/deny + // approval-responded (denied) / output-denied → declined + // approval-responded (approved) → resuming → loading until output + const awaitingApproval = state === 'approval-requested'; + const denied = state === 'output-denied' + || (state === 'approval-responded' && toolPart.approval?.approved === false); + const approvalId = toolPart.approval?.id; return ( onToolApproval?.(approvalId, true) : undefined} + onDeny={awaitingApproval && approvalId ? () => onToolApproval?.(approvalId, false) : undefined} /> ); } diff --git a/web/app/cortex/components/CortexApprovalToggle.tsx b/web/app/cortex/components/CortexApprovalToggle.tsx new file mode 100644 index 00000000..29aa5c00 --- /dev/null +++ b/web/app/cortex/components/CortexApprovalToggle.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React, { useState } from 'react'; +import { ShieldCheck, ShieldOff } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import ConfirmDialog from '@/components/ConfirmDialog'; +import { useCortexApprovalSetting } from '@/hooks/useCortexApprovalSetting'; + +interface CortexApprovalToggleProps { + siteId: string; +} + +/** + * Site-wide admin toggle for the tier-3 approval gate. When ON (default), + * privileged tool calls pause for in-chat approval and admin single-machine + * chats route server-side so the gate can fire. Turning it OFF restores local + * Cortex's lower latency at the cost of the safety gate. + */ +export function CortexApprovalToggle({ siteId }: CortexApprovalToggleProps) { + const { requireApproval } = useCortexApprovalSetting(siteId); + const [busy, setBusy] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + + const setValue = async (next: boolean) => { + if (busy) return; + setBusy(true); + try { + const res = await fetch( + `/api/sites/${encodeURIComponent(siteId)}/cortex-settings`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requireTier3Approval: next }), + }, + ); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail || body?.title || 'Failed to update approval setting'); + } + } catch (err) { + console.error('Failed to update cortex approval setting:', err); + } finally { + setBusy(false); + } + }; + + const handleClick = () => { + // Disabling the gate weakens safety — confirm. Enabling is one click. + if (requireApproval) setConfirmOpen(true); + else void setValue(true); + }; + + const label = requireApproval ? 'approval required' : 'approval off'; + const tooltip = requireApproval + ? 'privileged (tier-3) actions require in-chat approval — click to disable site-wide' + : 'privileged (tier-3) actions run without approval — click to require approval site-wide'; + + return ( + <> + + + + + +

{tooltip}

+
+
+ + setValue(false)} + variant="destructive" + /> + + ); +} diff --git a/web/app/cortex/components/CortexChatView.tsx b/web/app/cortex/components/CortexChatView.tsx index 31fadfee..3d49d43f 100644 --- a/web/app/cortex/components/CortexChatView.tsx +++ b/web/app/cortex/components/CortexChatView.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { useSites, useMachines } from '@/hooks/useFirestore'; import { useOwletteChat, type ChatConversation } from '@/hooks/useCortex'; +import { useCortexSidebarPrefs } from '@/hooks/useCortexSidebarPrefs'; import { PageHeader } from '@/components/PageHeader'; import { AccountSettingsDialog } from '@/components/AccountSettingsDialog'; import { Button } from '@/components/ui/button'; @@ -17,6 +18,7 @@ import { ChatWindow } from './ChatWindow'; import { ChatInput } from './ChatInput'; import { MachineSelector, SITE_TARGET_ID } from './MachineSelector'; import { CortexPowerToggle } from './CortexPowerToggle'; +import { CortexApprovalToggle } from './CortexApprovalToggle'; import { LoadingWord } from '@/components/LoadingWord'; function timeAgo(date: Date): string { @@ -65,7 +67,7 @@ interface CortexChatViewProps { export function CortexChatView({ initialChatId }: CortexChatViewProps) { const router = useRouter(); - const { user, userSites, isSuperadmin, loading: authLoading, lastSiteId, lastMachineIds, updateLastSite, updateLastMachine } = useAuth(); + const { user, userSites, isSuperadmin, isSiteAdmin, loading: authLoading, lastSiteId, lastMachineIds, updateLastSite, updateLastMachine } = useAuth(); const { sites, loading: sitesLoading } = useSites(user?.uid, userSites, isSuperadmin); const [currentSiteId, setCurrentSiteId] = useState(''); @@ -76,8 +78,8 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const [errorDismissed, setErrorDismissed] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [categorizingAll, setCategorizingAll] = useState(false); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - const [sidebarOpen, setSidebarOpen] = useState(true); + // Sidebar expand/collapse state persists per-device to Firestore. + const { sidebarOpen, setSidebarOpen, collapsedGroups, setCollapsedGroups } = useCortexSidebarPrefs(); const { machines } = useMachines(currentSiteId); @@ -112,7 +114,11 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const selectedMachine = !isSiteMode ? machines.find((m) => m.machineId === selectedMachineId) : null; const suppressNextChatRouteRef = useRef(false); const skipNextLandingResetRef = useRef(false); - const pendingNewChatFromRouteIdRef = useRef(null); + // Set when we intentionally start a new chat (or delete the routed chat) while + // the URL still points at the old chat: the persistent component would briefly + // see initialChatId(old) !== activeChatId(new) and wrongly reload the old chat, + // stealing selection from the just-created one. One-shot skip of that load. + const suppressNextLoadRef = useRef(false); const previousChatIdRef = useRef(null); const previousInitialChatIdRef = useRef(initialChatId); @@ -132,6 +138,12 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const loadChat = chat.loadChat; useEffect(() => { + // Skip the load that a just-started new chat (or a deletion) would otherwise + // trigger from the stale URL before navigation commits. + if (suppressNextLoadRef.current) { + suppressNextLoadRef.current = false; + return; + } if (!initialChatId || initialChatId === activeChatId) return; void loadChat(initialChatId); }, [initialChatId, activeChatId, loadChat]); @@ -151,19 +163,15 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { } }, [activeChatId, initialChatId, router]); + // Landing transition: when the URL goes from a routed chat back to /cortex + // (browser back, or a deletion), start a fresh chat. Skipped when an explicit + // handler (handleNewChat / handleDeleteChat) already started one. With the + // persistent layout the component is not remounted, so this fires on the + // initialChatId prop change rather than on mount. useEffect(() => { const previousInitialChatId = previousInitialChatIdRef.current; previousInitialChatIdRef.current = initialChatId; - const pendingNewChatFromRouteId = pendingNewChatFromRouteIdRef.current; - if (!initialChatId && pendingNewChatFromRouteId) { - if (activeChatId !== pendingNewChatFromRouteId) { - pendingNewChatFromRouteIdRef.current = null; - router.replace(`/cortex/${encodeURIComponent(activeChatId)}`); - } - return; - } - if (initialChatId || !previousInitialChatId) return; if (skipNextLandingResetRef.current) { skipNextLandingResetRef.current = false; @@ -172,7 +180,7 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { suppressNextChatRouteRef.current = true; chat.startNewChat(); - }, [activeChatId, chat, initialChatId, router]); + }, [chat, initialChatId]); // Reset error dismissed state when a new error arrives useEffect(() => { @@ -184,8 +192,13 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const handleNewChat = useCallback((overrides?: { machineId?: string; machineName?: string }) => { if (initialChatId) { - pendingNewChatFromRouteIdRef.current = initialChatId; + // Navigate back to the landing URL but keep it there until the chat is + // persisted (handleChatPersisted replaces to /cortex/{id}). suppress stops + // the URL-sync effect from pushing the unsaved id; skipNextLandingReset + // stops the landing effect from starting a *second* new chat. suppressNextChatRouteRef.current = true; + skipNextLandingResetRef.current = true; + suppressNextLoadRef.current = true; router.push('/cortex'); } @@ -194,14 +207,27 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { }, [chat, initialChatId, router]); const handleConversationClick = useCallback((conversationId: string) => { + // Expand the selected conversation's category group if the user had it + // collapsed, so the row it lives in is actually visible after selecting. + const convo = conversationsRef.current.find((c) => c.id === conversationId); + if (convo && convo.title !== 'new conversation') { + const label = convo.category || 'General'; + setCollapsedGroups((prev) => { + if (!prev.has(label)) return prev; + const next = new Set(prev); + next.delete(label); + return next; + }); + } router.push(`/cortex/${encodeURIComponent(conversationId)}`); - }, [router]); + }, [router, setCollapsedGroups]); const handleDeleteChat = useCallback((conversationId: string) => { const deletedRouteChat = conversationId === initialChatId; if (deletedRouteChat) { suppressNextChatRouteRef.current = true; skipNextLandingResetRef.current = true; + suppressNextLoadRef.current = true; } void chat.deleteChat(conversationId); @@ -228,11 +254,44 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { return () => observer.disconnect(); }, [hasMoreConversations, loadingMore, loadMoreConversations]); + // Latest conversations, readable from event handlers without re-subscribing. + const conversationsRef = useRef(chat.conversations); + conversationsRef.current = chat.conversations; + + // Scroll the active conversation row into view whenever the active chat changes + // (selecting a conversation, starting a new one), so the highlighted row is + // never left scrolled out of sight. No state writes here — purely a DOM nudge. + useEffect(() => { + if (!chat.chatId) return; + const raf = requestAnimationFrame(() => { + sidebarScrollRef.current + ?.querySelector('[data-active-conversation="true"]') + ?.scrollIntoView({ block: 'nearest' }); + }); + return () => cancelAnimationFrame(raf); + }, [chat.chatId]); + // Skip "new conversation" entries — the API requires a title or first message to categorize const uncategorizedIds = chat.conversations .filter((c) => !c.category && c.title !== 'new conversation') .map((c) => c.id); + // Drive the collapse-all/expand-all toggle off the *actual* set of visible + // group labels so the icon/label and the action never disagree (e.g. one + // section expanded while the rest are collapsed). + const visibleGroupLabels = groupConversationsByCategory( + chat.conversations.filter((c) => c.title !== 'new conversation'), + ).map((g) => g.label); + const allGroupsCollapsed = + visibleGroupLabels.length > 0 && visibleGroupLabels.every((l) => collapsedGroups.has(l)); + + // Which category the active conversation lives in — used to flag a collapsed + // section that contains the current chat, so the user knows where it is. + const activeConvo = chat.conversations.find((c) => c.id === chat.chatId); + const activeCategoryLabel = activeConvo && activeConvo.title !== 'new conversation' + ? (activeConvo.category || 'General') + : null; + const categorizeAll = async () => { if (categorizingAll || uncategorizedIds.length === 0) return; setCategorizingAll(true); @@ -397,17 +456,14 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { -

{collapsedGroups.size > 0 ? 'expand all' : 'collapse all'}

+

{allGroupsCollapsed ? 'expand all' : 'collapse all'}

)} @@ -478,6 +534,9 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { chat.conversations.filter((c) => c.title !== 'new conversation') ).map((group) => { const isCollapsed = collapsedGroups.has(group.label); + // Highlight the header of whichever group holds the active + // conversation — collapsed (where the row is hidden) or expanded. + const containsActive = group.label === activeCategoryLabel; return (
{!isCollapsed && group.conversations.map((convo) => ( @@ -593,11 +658,14 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { )} - {!isSiteMode && selectedMachine && ( -
+
+ {currentSiteId && isSiteAdmin(currentSiteId) && ( + + )} + {!isSiteMode && selectedMachine && ( -
- )} + )} +
{/* Messages */} @@ -609,6 +677,8 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { isLoading={chat.isLoading} hasApiKey={hasApiKey} onOpenSettings={() => setAccountSettingsOpen(true)} + onToolApproval={(id, approved) => chat.addToolApprovalResponse({ id, approved })} + approvalTargetLabel={isSiteMode ? 'all machines' : selectedMachineId} /> )} @@ -734,7 +804,7 @@ function ConversationItem({ setConfirming(false); }} aria-label={`cancel delete ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > @@ -768,7 +838,7 @@ function ConversationItem({ setEditing(false); }} aria-label={`save rename ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > @@ -783,7 +853,7 @@ function ConversationItem({ setEditing(false); }} aria-label={`cancel rename ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > @@ -793,10 +863,20 @@ function ConversationItem({ return (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} > {conversation.source === 'autonomous' ? ( @@ -825,7 +905,7 @@ function ConversationItem({ setEditing(true); }} aria-label={`rename ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > diff --git a/web/app/cortex/components/SynapticIndicator.tsx b/web/app/cortex/components/SynapticIndicator.tsx index a15513ce..dbdc7191 100644 --- a/web/app/cortex/components/SynapticIndicator.tsx +++ b/web/app/cortex/components/SynapticIndicator.tsx @@ -1,104 +1,94 @@ 'use client'; /** - * Synaptic firing indicator — a triad of neurons (big, medium, small) - * exchanging signals bidirectionally along shared axons. Each neuron - * bobs gently; the whole cluster drifts. + * Synaptic firing indicator — a hexagonal neuron lattice. Six nodes sit at the + * vertices of a pointy-top hexagon; a faint web of perimeter + diagonal axons + * connects them. Cyan signal dots flow around the ring in sequence (a travelling + * wave) while the nodes fire in a staggered pulse — lively, but composed. */ export function SynapticIndicator() { - const A = { x: 5, y: 18, r: 3.2 }; // big - const B = { x: 19, y: 15, r: 2.3 }; // medium - const C = { x: 13, y: 4, r: 1.6 }; // small + const R = 8; + const C = 12; - const cycle = 380; - const d = `${cycle}ms`; + // Pointy-top hexagon vertices, starting at the top and going clockwise. + const verts = [-90, -30, 30, 90, 150, 210].map((deg) => { + const a = (deg * Math.PI) / 180; + return { x: +(C + R * Math.cos(a)).toFixed(2), y: +(C + R * Math.sin(a)).toFixed(2) }; + }); - // 6 pulses — every edge fires in both directions, staggered - const pulses = [ - { from: A, to: B, delay: 0 }, - { from: B, to: C, delay: 60 }, - { from: C, to: A, delay: 120 }, - { from: B, to: A, delay: 190 }, - { from: C, to: B, delay: 250 }, - { from: A, to: C, delay: 310 }, - ]; + // Closed perimeter path the signal dots travel along. + const perimeter = `M ${verts.map((v) => `${v.x} ${v.y}`).join(' L ')} Z`; + // Long axons across the centre, for the neural-web texture. + const diagonals = [[0, 3], [1, 4], [2, 5]].map( + ([a, b]) => `M ${verts[a].x} ${verts[a].y} L ${verts[b].x} ${verts[b].y}`, + ); - // Per-neuron gentle bob (subtle Y oscillation, varied phase) - const bobs = [ - { values: '0 0; 0 -0.5; 0 0.3; 0 0', dur: '1.6s' }, - { values: '0 0; 0 0.4; 0 -0.4; 0 0', dur: '1.3s' }, - { values: '0 0; 0 -0.3; 0 0.5; 0 0', dur: '1.9s' }, - ]; - const nodes = [A, B, C]; + const loop = 2100; // ms for one full lap around the hexagon + const nodeCycle = 1260; // ms for the node firing wave + const signals = [0, 1, 2].map((i) => (i * loop) / 3); // three dots, evenly chasing return ( - - - {/* Whole-cluster drift */} - - - {/* Axons */} - {[ - [A, B], - [B, C], - [C, A], - ].map(([p, q], i) => ( - + + {/* Axon lattice */} + + + {diagonals.map((d, i) => ( + ))} + - {/* Neurons — each bobs independently */} - {nodes.map((n, i) => ( - - - - + {/* Static fallback for reduced motion — a calm, dimly-lit hexagon. */} + + {verts.map((v, i) => ( + ))} + - {/* Bidirectional pulses */} - {pulses.map((p, i) => ( - - - - - ))} + {/* Animated layer — suppressed entirely when the user prefers reduced motion. */} + + {/* Neurons — fire in a staggered wave around the ring */} + + {verts.map((v, i) => ( + + + + + ))} + + + {/* Signals — cyan dots flowing between neurons, sequenced */} + + {signals.map((delay, i) => ( + + + + + ))} + ); diff --git a/web/app/cortex/components/ToolCallCard.tsx b/web/app/cortex/components/ToolCallCard.tsx index f95189cc..73de3237 100644 --- a/web/app/cortex/components/ToolCallCard.tsx +++ b/web/app/cortex/components/ToolCallCard.tsx @@ -1,8 +1,9 @@ 'use client'; import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, Wrench, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Wrench, CheckCircle2, AlertCircle, Loader2, ShieldAlert, Ban, Check } from 'lucide-react'; import { getToolByName } from '@/lib/mcp-tools'; +import { Button } from '@/components/ui/button'; import { CopyButton } from './CopyButton'; interface ToolCallCardProps { @@ -10,14 +11,36 @@ interface ToolCallCardProps { args: Record; result?: unknown; isLoading?: boolean; + /** + * Tier-3 approval (human-in-the-loop). `requested` shows approve/deny + * controls; `denied` shows the declined state. Absent for tier-1/2 tools + * and for already-executed tier-3 calls. + */ + approvalState?: 'requested' | 'denied'; + /** Where the tool will run, e.g. a machine name or "all machines". */ + approvalTargetLabel?: string; + onApprove?: () => void; + onDeny?: () => void; } -export function ToolCallCard({ toolName, args, result, isLoading }: ToolCallCardProps) { +export function ToolCallCard({ + toolName, + args, + result, + isLoading, + approvalState, + approvalTargetLabel, + onApprove, + onDeny, +}: ToolCallCardProps) { const [expanded, setExpanded] = useState(false); + const [submitting, setSubmitting] = useState(false); const toolDef = getToolByName(toolName); const hasError = result != null && typeof result === 'object' && !!(result as Record).error; const tierLabel = toolDef ? `Tier ${toolDef.tier}` : ''; + const awaitingApproval = approvalState === 'requested'; + const denied = approvalState === 'denied'; // Inline preview for screenshot captures: prefer the uploaded Firebase URL, // fall back to inline base64 JPEG if the upload failed but the capture succeeded. @@ -31,20 +54,30 @@ export function ToolCallCard({ toolName, args, result, isLoading }: ToolCallCard } } + const statusIcon = awaitingApproval ? ( + + ) : isLoading ? ( + + ) : denied ? ( + + ) : hasError ? ( + + ) : ( + + ); + return ( -
+
{/* Header */} + {/* Approval banner — privileged tier-3 action needs explicit go-ahead. + The payload stays collapsed (expand the card header to inspect the + input) so it isn't duplicated here and under the expanded view. */} + {awaitingApproval && ( +
+

+ cortex wants to run the privileged {toolName} tool + {approvalTargetLabel ? <> on {approvalTargetLabel} : null}. approve to continue, or expand to inspect the input. +

+
+ + +
+
+ )} + {/* Inline screenshot preview (always visible when available) */} {screenshotSrc && (
- arguments + input
@@ -119,7 +190,7 @@ export function ToolCallCard({ toolName, args, result, isLoading }: ToolCallCard
- result + output
diff --git a/web/app/cortex/layout.tsx b/web/app/cortex/layout.tsx new file mode 100644 index 00000000..244b1275 --- /dev/null +++ b/web/app/cortex/layout.tsx @@ -0,0 +1,29 @@ +'use client'; + +/** + * Persistent Cortex shell. + * + * `/cortex` and `/cortex/[chatId]` are separate route segments. Without a shared + * layout, navigating between them remounts the page subtree and wipes all of + * CortexChatView's local UI state (sidebar collapse, category collapse, the + * optimistic "new conversation" row). Hoisting the view into this layout keeps a + * single instance alive across those navigations; the active chat id is derived + * from the pathname and handed down as `initialChatId`. The page files render + * nothing — they exist only so the routes resolve. + */ + +import { usePathname } from 'next/navigation'; +import { CortexChatView } from './components/CortexChatView'; + +export default function CortexLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const match = pathname?.match(/^\/cortex\/(.+)$/); + const initialChatId = match ? decodeURIComponent(match[1]) : undefined; + + return ( + <> + {children} + + + ); +} diff --git a/web/app/cortex/page.tsx b/web/app/cortex/page.tsx index 590c58a8..04c7ac0a 100644 --- a/web/app/cortex/page.tsx +++ b/web/app/cortex/page.tsx @@ -1,7 +1,6 @@ -'use client'; - -import { CortexChatView } from './components/CortexChatView'; - +// The Cortex view is rendered by the persistent layout (app/cortex/layout.tsx), +// which derives the active chat id from the pathname. This page exists only so +// the /cortex route resolves. export default function CortexPage() { - return ; + return null; } diff --git a/web/components/ConfirmDialog.tsx b/web/components/ConfirmDialog.tsx index c94b7861..5eb56952 100644 --- a/web/components/ConfirmDialog.tsx +++ b/web/components/ConfirmDialog.tsx @@ -51,7 +51,7 @@ export default function ConfirmDialog({ {children} -
+
diff --git a/web/components/AccountSettingsDialog.tsx b/web/components/AccountSettingsDialog.tsx index 58cfa48e..5b4f38d3 100644 --- a/web/components/AccountSettingsDialog.tsx +++ b/web/components/AccountSettingsDialog.tsx @@ -17,6 +17,12 @@ import Link from 'next/link'; import { toast } from 'sonner'; import { PasskeyManager } from '@/components/PasskeyManager'; import { getBrowserTimezone } from '@/lib/timeUtils'; +import { + SCOPE_PRESETS, + SCOPE_PRESET_KEYS, + SCOPE_PRESET_DESCRIPTIONS, + type ApiKeyScopePreset, +} from '@/lib/apiKeyTypes'; import { TimezoneSelect } from '@/components/TimezoneSelect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -112,6 +118,7 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac const [apiKeys, setApiKeys] = useState([]); const [apiKeysLoading] = useState(false); const [newKeyName, setNewKeyName] = useState(''); + const [keyScopePreset, setKeyScopePreset] = useState('publisher'); const [createdKey, setCreatedKey] = useState(null); const [creatingKey, setCreatingKey] = useState(false); const [revokingKeyId, setRevokingKeyId] = useState(null); @@ -184,7 +191,7 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac .catch(() => {}); // Load API keys - fetch('/api/account/api-keys') + fetch('/api/keys') .then((res) => res.json()) .then((data) => { if (data.success) setApiKeys(data.keys || []); @@ -212,6 +219,7 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac setShowLlmKey(false); setApiKeys([]); setNewKeyName(''); + setKeyScopePreset('publisher'); setCreatedKey(null); setCreatingKey(false); setActiveSection('profile'); @@ -1133,41 +1141,74 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac {/* Create new key */}
-
-
- - setNewKeyName(e.target.value)} - className="border-border bg-background text-white" - disabled={creatingKey} - /> -
+
+ + setNewKeyName(e.target.value)} + className="border-border bg-background text-white" + disabled={creatingKey} + /> +
+
+ + +

{SCOPE_PRESET_DESCRIPTIONS[keyScopePreset]}

+
+
+

+ need custom scopes?{' '} + onOpenChange(false)} + className="text-accent-cyan hover:underline" + > + manage api keys + +

@@ -1209,14 +1250,15 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac onClick={async () => { setRevokingKeyId(k.id); try { - const res = await fetch(`/api/account/api-keys/${encodeURIComponent(k.id)}`, { + const res = await fetch(`/api/keys/${encodeURIComponent(k.id)}`, { method: 'DELETE', }); if (res.ok) { setApiKeys((prev) => prev.filter((key) => key.id !== k.id)); toast.success('API key revoked'); } else { - toast.error('Failed to revoke key'); + const data = await res.json().catch(() => ({})); + toast.error(data.detail || data.error || 'Failed to revoke key'); } } catch { toast.error('Failed to revoke key'); diff --git a/web/lib/apiKeyTypes.ts b/web/lib/apiKeyTypes.ts index ded8beed..f1a42238 100644 --- a/web/lib/apiKeyTypes.ts +++ b/web/lib/apiKeyTypes.ts @@ -98,6 +98,22 @@ export const SCOPE_PRESETS: Record = { admin: wildcardScopes(['read', 'write', 'deploy', 'rollback', 'admin']), }; +/** Ordered preset keys for scope pickers (excludes the synthetic "custom" option). */ +export const SCOPE_PRESET_KEYS: readonly ApiKeyScopePreset[] = [ + 'readonly', + 'publisher', + 'operator', + 'admin', +]; + +/** Human-readable descriptions of each preset, shared across every scope picker. */ +export const SCOPE_PRESET_DESCRIPTIONS: Record = { + readonly: 'read access to roosts, sites, machines, and cortex chats — no mutations', + publisher: 'read + write — can upload chunks, publish versions, and use cortex chats', + operator: 'read, write, deploy, rollback — full day-to-day operations', + admin: 'full access including admin permissions', +}; + export const DEFAULT_TTL_DAYS = 90; export const MAX_TTL_DAYS = 365; export const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000; From 36e6422c288844c36faa80450b6fb4299e071a98 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 12:05:21 -0700 Subject: [PATCH 37/43] fix(web): right-align disk r/w throughput in machine list view The read/write throughput stack in the dashboard list view sat immediately after the usage/capacity text, so its horizontal position drifted with the capacity-string width and never lined up across machine rows. Anchor it to the cell's right edge with ml-auto; the r/w values stay left-aligned within their own container. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/app/dashboard/components/MachineListView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/dashboard/components/MachineListView.tsx b/web/app/dashboard/components/MachineListView.tsx index 136413a6..4cdd9b2e 100644 --- a/web/app/dashboard/components/MachineListView.tsx +++ b/web/app/dashboard/components/MachineListView.tsx @@ -534,7 +534,7 @@ export function MachineRow({ const io = machine.metrics?.diskio?.[diskDevice.id]; if (!io || (io.readBps === 0 && io.writeBps === 0)) return null; return ( -
+
r w From c1efdbcfec1866640e87b75f52c3a37480a29e29 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 12:07:53 -0700 Subject: [PATCH 38/43] fix(cortex): honor approval toggle on all paths + project site-wide screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review-found gaps in the tier-3 approval / chat work: - The per-site approval flag only gated local-Cortex *routing*; needsApproval was unconditional, so "approval off" still prompted on the server-side fallback and never consulted the flag at all in site-wide mode. Thread requireTier3Approval into buildExecutableTools (needsApproval = tier>=3 && flag, default-true) and read it on every server-side/site-wide build site. - /api/cortex converted UIMessages to ModelMessages before tools were built, dropping capture_screenshot's toModelOutput image projection on follow-up turns; build tools first, then convertToModelMessages(messages, { tools }). - capture_screenshot's toModelOutput only read a top-level url, so site-wide aggregated output ({ machines: [...] }) never projected per-machine images — now each machine's screenshot is projected. Autonomous Cortex (separate buildAutonomousTools) and tier-1/2 callers are unaffected. Adds unit tests for the toggle-off gate and the screenshot projection (single + site-wide + no-url fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- web/__tests__/lib/cortex-utils.test.ts | 62 ++++++++++++++++++++++++++ web/app/api/cortex/route.ts | 29 +++++++----- web/lib/cortex-utils.server.ts | 39 +++++++++++++++- web/lib/cortexStream.server.ts | 12 +++-- 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/web/__tests__/lib/cortex-utils.test.ts b/web/__tests__/lib/cortex-utils.test.ts index 0e9f8646..2e80a890 100644 --- a/web/__tests__/lib/cortex-utils.test.ts +++ b/web/__tests__/lib/cortex-utils.test.ts @@ -300,6 +300,19 @@ describe('buildExecutableTools', () => { expect(allTools.some((t) => t.tier < 3)).toBe(true); }); + it('disables tier-3 needsApproval when requireTier3Approval is false', () => { + // The per-site approval toggle, when off, must drop the gate on this path + // too (not just local-Cortex routing) — otherwise the toggle lies. + const tools = buildExecutableTools( + {} as unknown as FirebaseFirestore.Firestore, + 's1', 'm1', 'c1', allTools, false, [], + { requireTier3Approval: false }, + ); + for (const def of allTools) { + expect(tools[def.name].needsApproval).toBe(false); + } + }); + it('executes update_process server-side and resolves process_name to processId', async () => { mockUpdateProcess.mockResolvedValue({ processId: 'proc-1' }); const { db } = createProcessConfigDb([ @@ -540,3 +553,52 @@ describe('getCortexRequireTier3Approval', () => { expect(await getCortexRequireTier3Approval(db, 's1')).toBe(true); }); }); + +// ─── capture_screenshot toModelOutput (image projection) ───────────────────── + +describe('capture_screenshot toModelOutput', () => { + function screenshotToModelOutput(siteMode: boolean) { + const def = allTools.find((t) => t.name === 'capture_screenshot')!; + const tools = buildExecutableTools( + {} as unknown as FirebaseFirestore.Firestore, + 's1', siteMode ? '' : 'm1', 'c1', [def], siteMode, siteMode ? ['m1', 'm2'] : [], + ); + return tools.capture_screenshot.toModelOutput as (a: { output: unknown }) => { + type: string; + value: unknown; + }; + } + + it('projects a single-machine screenshot url as an image-url block', () => { + const out = screenshotToModelOutput(false)({ output: { url: 'https://x/s.jpg', message: 'shot' } }); + expect(out).toEqual({ + type: 'content', + value: [ + { type: 'text', text: 'shot' }, + { type: 'image-url', url: 'https://x/s.jpg' }, + ], + }); + }); + + it('projects each machine url in site-wide aggregated output', () => { + const out = screenshotToModelOutput(true)({ + output: { machines: [ + { machine: 'm1', url: 'https://x/m1.jpg' }, + { machine: 'm2', error: 'offline' }, + ] }, + }); + expect(out).toEqual({ + type: 'content', + value: [ + { type: 'text', text: 'm1:' }, + { type: 'image-url', url: 'https://x/m1.jpg' }, + { type: 'text', text: 'm2: offline' }, + ], + }); + }); + + it('falls back to text when there is no url', () => { + const out = screenshotToModelOutput(false)({ output: { error: 'capture failed' } }); + expect(out).toEqual({ type: 'text', value: 'capture failed' }); + }); +}); diff --git a/web/app/api/cortex/route.ts b/web/app/api/cortex/route.ts index 8270bfb6..135b9304 100644 --- a/web/app/api/cortex/route.ts +++ b/web/app/api/cortex/route.ts @@ -11,7 +11,7 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { streamText, stepCountIs, convertToModelMessages, type ModelMessage, type UIMessage } from 'ai'; +import { streamText, stepCountIs, convertToModelMessages, type UIMessage } from 'ai'; import { resolveAuth, requireScope } from '@/lib/apiAuth.server'; import { getAdminDb } from '@/lib/firebase-admin'; import { FieldValue } from 'firebase-admin/firestore'; @@ -158,7 +158,7 @@ export async function POST(request: NextRequest) { // ─── Site-Wide Mode (unchanged — web-side LLM) ───────────────────── if (isSiteMode) { - return handleSiteWideMode(db, userId, siteId, await convertToModelMessages(messages), chatId, effectiveAccess); + return handleSiteWideMode(db, userId, siteId, messages, chatId, effectiveAccess); } // ─── Single Machine Mode ─────────────────────────────────────────── @@ -205,7 +205,7 @@ export async function POST(request: NextRequest) { return handleLocalCortex(db, siteId, machineId, machineName, messages, chatId); } else { // ─── Fallback: Server-side LLM (existing approach) ──────────── - return handleServerSideLLM(db, userId, siteId, machineId, machineName, await convertToModelMessages(messages), chatId, effectiveAccess); + return handleServerSideLLM(db, userId, siteId, machineId, machineName, messages, chatId, effectiveAccess); } } catch (error: unknown) { return apiError(error, 'cortex'); @@ -371,19 +371,20 @@ async function handleServerSideLLM( siteId: string, machineId: string, machineName: string, - messages: ModelMessage[], + messages: UIMessage[], chatId: string, access: SiteAccessLevel, ): Promise { - const [llmConfig, processes] = await Promise.all([ + const [llmConfig, processes, requireTier3Approval] = await Promise.all([ resolveLlmConfig(db, userId, siteId), fetchProcessSummaries(db, siteId, machineId), + getCortexRequireTier3Approval(db, siteId), ]); const toolDefs = getToolsByTier(resolveCortexMaxTier(access)); const executableTools = buildExecutableTools( db, siteId, machineId, chatId, toolDefs, - false, [], { userId, userRole: access.role }, + false, [], { userId, userRole: access.role, requireTier3Approval }, ); const model = createModel(llmConfig); @@ -391,7 +392,10 @@ async function handleServerSideLLM( const result = streamText({ model, system: buildSystemPrompt(machineName || machineId, false, processes), - messages, + // Convert with the built tools so per-tool toModelOutput hooks (e.g. + // capture_screenshot → image-url) project prior-turn tool outputs into + // model content, not just on the turn they were produced. + messages: await convertToModelMessages(messages, { tools: executableTools }), tools: executableTools, stopWhen: stepCountIs(10), }); @@ -407,7 +411,7 @@ async function handleSiteWideMode( db: FirebaseFirestore.Firestore, userId: string, siteId: string, - messages: ModelMessage[], + messages: UIMessage[], chatId: string, access: SiteAccessLevel, ): Promise { @@ -419,12 +423,15 @@ async function handleSiteWideMode( ); } - const llmConfig = await resolveLlmConfig(db, userId, siteId); + const [llmConfig, requireTier3Approval] = await Promise.all([ + resolveLlmConfig(db, userId, siteId), + getCortexRequireTier3Approval(db, siteId), + ]); const toolDefs = getToolsByTier(resolveCortexMaxTier(access)); const executableTools = buildExecutableTools( db, siteId, SITE_TARGET_ID, chatId, toolDefs, - true, onlineMachines, { userId, userRole: access.role }, + true, onlineMachines, { userId, userRole: access.role, requireTier3Approval }, ); const model = createModel(llmConfig); @@ -432,7 +439,7 @@ async function handleSiteWideMode( const result = streamText({ model, system: buildSystemPrompt('', true), - messages, + messages: await convertToModelMessages(messages, { tools: executableTools }), tools: executableTools, stopWhen: stepCountIs(10), }); diff --git a/web/lib/cortex-utils.server.ts b/web/lib/cortex-utils.server.ts index 9729323f..49df0c90 100644 --- a/web/lib/cortex-utils.server.ts +++ b/web/lib/cortex-utils.server.ts @@ -52,6 +52,13 @@ function stripReservedExistingCommandKeys(params: Record): Reco export interface BuildExecutableToolsOptions { userId?: string; userRole?: string | null; + /** + * Whether tier-3 tools require in-chat approval. Defaults to true. When the + * per-site flag (`getCortexRequireTier3Approval`) is off, tier-3 tools + * auto-run on the server-side / site-wide paths too — not just local Cortex — + * so the approval toggle is honored consistently everywhere. + */ + requireTier3Approval?: boolean; } type ProcessToolResult = Record; @@ -1117,8 +1124,9 @@ export function buildExecutableTools( // client surfaces approve/deny and resumes the stream once answered. // Tier 1/2 keep auto-running. This is a chat-only guardrail — autonomous // Cortex uses a separate `buildAutonomousTools` (no human to approve), so - // it is intentionally unaffected. - needsApproval: def.tier >= 3, + // it is intentionally unaffected. Gated by the per-site approval flag so + // turning approval off disables the gate on every path, not just local. + needsApproval: def.tier >= 3 && options.requireTier3Approval !== false, execute: async (params: unknown) => { // Server-side tools run directly on the web server (no agent relay) if (SERVER_SIDE_TOOLS.has(toolName)) { @@ -1170,8 +1178,35 @@ export function buildExecutableTools( // For capture_screenshot: inject the image as a vision content block // so the LLM can see and analyze the screenshot, not just get a URL string if (toolName === 'capture_screenshot') { + type ScreenshotBlock = { type: 'text'; text: string } | { type: 'image-url'; url: string }; toolConfig.toModelOutput = ({ output }: { output: unknown }) => { const result = output as Record | null; + + // Site-wide mode aggregates per-machine results as { machines: [...] }. + // Project each machine's screenshot URL as its own image block so the + // model sees all of them — a single top-level `url` only exists in + // single-machine mode. + const machines = Array.isArray(result?.machines) + ? (result!.machines as Array>) + : null; + if (machines) { + const blocks: ScreenshotBlock[] = []; + for (const m of machines) { + const mid = (m.machine as string) || 'machine'; + const murl = m.url as string | undefined; + if (murl) { + blocks.push({ type: 'text' as const, text: `${mid}:` }); + blocks.push({ type: 'image-url' as const, url: murl }); + } else { + const note = (m.message as string) || (m.error as string) || 'no screenshot'; + blocks.push({ type: 'text' as const, text: `${mid}: ${note}` }); + } + } + if (blocks.length > 0) { + return { type: 'content' as const, value: blocks }; + } + } + const url = result?.url as string | undefined; const message = (result?.message as string) || (result?.error as string) || 'Screenshot captured'; diff --git a/web/lib/cortexStream.server.ts b/web/lib/cortexStream.server.ts index 48b34d99..5fc42b8e 100644 --- a/web/lib/cortexStream.server.ts +++ b/web/lib/cortexStream.server.ts @@ -407,9 +407,10 @@ async function runServerSideLLM( maxToolTier: ToolTier, userRole: string | null, ): Promise { - const [llmConfig, processes] = await Promise.all([ + const [llmConfig, processes, requireTier3Approval] = await Promise.all([ resolveLlmConfig(db, userId, siteId), fetchProcessSummaries(db, siteId, machineId), + getCortexRequireTier3Approval(db, siteId), ]); const toolDefs = getToolsByTier(maxToolTier); @@ -421,7 +422,7 @@ async function runServerSideLLM( toolDefs, false, [], - { userId, userRole }, + { userId, userRole, requireTier3Approval }, ); const model = createModel(llmConfig); @@ -464,7 +465,10 @@ async function runSiteWideMode( onlineMachines: string[], userRole: string | null, ): Promise { - const llmConfig = await resolveLlmConfig(db, userId, siteId); + const [llmConfig, requireTier3Approval] = await Promise.all([ + resolveLlmConfig(db, userId, siteId), + getCortexRequireTier3Approval(db, siteId), + ]); const toolDefs = getToolsByTier(maxToolTier); const executableTools = buildExecutableTools( db, @@ -474,7 +478,7 @@ async function runSiteWideMode( toolDefs, true, onlineMachines, - { userId, userRole }, + { userId, userRole, requireTier3Approval }, ); const model = createModel(llmConfig); From afd85a4191a5e33e5e73e8148eb510c701b51578 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 12:08:01 -0700 Subject: [PATCH 39/43] fix(roost): audit config-only republish + honor CAS on the no-op branch A same-version-at-head republish that restates name/targets/extractPath applies them but returned outcome 'noop' and emitted no audit. Return configApplied and emit roost_mutated (verb config_update) when config was actually written; a true no-op still emits nothing. Review-found follow-up: that config write skipped the expectedCurrentVersionId CAS check (it returned before the optimistic-concurrency guard the promote/ create paths enforce). Now the config-only write also rejects a stale expected head with 412. Adds tests for the config_update emit and the CAS rejection. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/__tests__/api/versions.test.ts | 26 +++++++++++++++++++ .../api/roosts/[roostId]/versions/route.ts | 19 +++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/web/__tests__/api/versions.test.ts b/web/__tests__/api/versions.test.ts index 6831755a..36486523 100644 --- a/web/__tests__/api/versions.test.ts +++ b/web/__tests__/api/versions.test.ts @@ -435,6 +435,7 @@ describe('POST /versions — version-number monotonicity', () => { txState.currentVersionId = String(first.body.versionId); const roostWritesBefore = txState.roostWrites.length; const versionWritesBefore = txState.versionWrites.length; + mockEmitMutation.mockClear(); const second = await publish(undefined, { targets: ['machine-7'], name: 'lobby v2' }); @@ -451,6 +452,14 @@ describe('POST /versions — version-number monotonicity', () => { expect(w.name).toBe('lobby v2'); expect(w).not.toHaveProperty('versionCounter'); expect(w).not.toHaveProperty('versionId'); + + // ...and the config update is audited (verb=config_update), not silent. + expect(mockEmitMutation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'roost_mutated', + attributes: expect.objectContaining({ verb: 'config_update' }), + }), + ); }); async function publishTwoVersionHistory(): Promise<{ @@ -592,6 +601,23 @@ describe('POST /versions - expectedCurrentVersionId CAS', () => { expect(txState.roostWrites[0]!.previousVersionId).toBe('vrs_existing'); }); + it('rejects a config-only republish-at-head when expectedCurrentVersionId is stale', async () => { + // Republishing the head bytes hits the no-op branch; a config write there + // must still honor CAS, not slip past it with a stale expected head. + const first = await publish(); + expect(first.status).toBe(201); + txState.versionCounter = 1; + txState.currentVersionId = String(first.body.versionId); + const roostWritesBefore = txState.roostWrites.length; + + const res = await publish({ expectedCurrentVersionId: 'vrs_stale', targets: ['machine-9'] }); + + expect(res.status).toBe(412); + expect(res.body.code).toBe('version_stale'); + // No config write slipped through the CAS guard. + expect(txState.roostWrites).toHaveLength(roostWritesBefore); + }); + it('rejects non-string non-null expectedCurrentVersionId', async () => { const res = await publish({ expectedCurrentVersionId: 123 }); diff --git a/web/app/api/roosts/[roostId]/versions/route.ts b/web/app/api/roosts/[roostId]/versions/route.ts index 5b961dc6..fb78a227 100644 --- a/web/app/api/roosts/[roostId]/versions/route.ts +++ b/web/app/api/roosts/[roostId]/versions/route.ts @@ -370,7 +370,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) { ...(deployTargets !== undefined ? { targets: deployTargets } : {}), ...(deployExtractPath !== undefined ? { extractPath: deployExtractPath } : {}), }; - if (Object.keys(providedRoostPatch).length > 0) { + const configApplied = Object.keys(providedRoostPatch).length > 0; + if (configApplied) { + // The config write must honor optimistic concurrency too — otherwise a + // stale expectedHead could ride a config change in via the no-op branch, + // bypassing the CAS guard the promote/create paths enforce below. + if (expectedHead !== undefined && currentId !== expectedHead) { + return { conflict: true as const, currentId }; + } tx.set( roostRef, { updatedAt: FieldValue.serverTimestamp(), ...providedRoostPatch }, @@ -384,6 +391,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { versionNumber: existingNumber, currentVersionId: versionId, previousVersionId, + configApplied, }; } @@ -578,7 +586,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) { auth.scopeCheck, ); if (idem.mode === 'proceed') await saveIdempotency(idem.token, response); - if (result.outcome !== 'noop') { + // Emit for real versioning changes (create/promote) and for a same-head + // republish that actually restated deploy config — but not for a pure no-op. + const configOnly = result.outcome === 'noop' && 'configApplied' in result && result.configApplied; + if (result.outcome !== 'noop' || configOnly) { emitMutation({ kind: 'roost_mutated', siteId: site.siteId, @@ -588,7 +599,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { verb: result.outcome === 'promote' ? 'version_promote' - : 'version_publish', + : result.outcome === 'noop' + ? 'config_update' + : 'version_publish', endpoint: request.nextUrl.pathname, method: request.method, roostId, From de78222ea558fc2d92feffee3913c13e3574c565 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 12:08:08 -0700 Subject: [PATCH 40/43] ci(py-sdk): run pytest before building/publishing The py-sdk publish workflow built, twine-checked, and published on tag without ever running the pytest suite (sdks/python defines pytest dev deps + testpaths). Add a Run tests step (pip install -e ".[dev]" + python -m pytest) after Setup Python, before Build, so a failing suite blocks publish on every path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/py-sdk-publish.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/py-sdk-publish.yml b/.github/workflows/py-sdk-publish.yml index 81dc33c0..75430316 100644 --- a/.github/workflows/py-sdk-publish.yml +++ b/.github/workflows/py-sdk-publish.yml @@ -61,6 +61,13 @@ jobs: with: python-version: '3.12' + - name: Run tests + working-directory: sdks/python + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m pytest + - name: Build sdist and wheel working-directory: sdks/python run: | From 02ef4053620c0eef37965ba982b91bfd497541b6 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 13:12:48 -0700 Subject: [PATCH 41/43] fix(web): gate users-collection listener to superadmins (OWLETTE-WEB-3R) useUserManagement opened an onSnapshot over the entire users collection unconditionally. firestore.rules correctly restricts that collection to superadmins, so every non-superadmin tripped permission-denied on page load. The hook is mounted via ManageSitesDialog, which renders on the dashboard, roosts, logs, and deployments pages, so the denial fired across all four (Sentry OWLETTE-WEB-3R captured it on /dashboard). Gate the listener behind a required `enabled` param and pass Boolean(isSuperadmin) from the callers (ManageSitesDialog, admin/users). Rules are unchanged - loosening them would leak every user's email, role, and site assignments. Drop the e2e console-noise whitelist that had been masking this exact error so it cannot silently regress. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/__tests__/hooks/useUserManagement.test.ts | 19 +++++++++-- web/app/admin/users/page.tsx | 4 +-- web/components/ManageSitesDialog.tsx | 6 ++-- .../specs/roosts/empty-roost-state.spec.ts | 5 +-- web/hooks/useUserManagement.ts | 32 ++++++++++++------- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/web/__tests__/hooks/useUserManagement.test.ts b/web/__tests__/hooks/useUserManagement.test.ts index 91b1be9a..1baa8474 100644 --- a/web/__tests__/hooks/useUserManagement.test.ts +++ b/web/__tests__/hooks/useUserManagement.test.ts @@ -30,6 +30,7 @@ jest.mock('firebase/firestore', () => ({ })); import { useUserManagement } from '@/hooks/useUserManagement'; +import { onSnapshot } from 'firebase/firestore'; const activeDoc = (uid: string, role: string) => ({ id: uid, @@ -44,6 +45,7 @@ const deletedDoc = (uid: string, role: string, deletedAt: number) => ({ beforeEach(() => { snapshotDocs = []; unsubscribe.mockClear(); + jest.mocked(onSnapshot).mockClear(); }); afterEach(() => { @@ -57,7 +59,7 @@ describe('useUserManagement — soft delete', () => { deletedDoc('u-deleted', 'admin', 1700000000000), ]; - const { result } = renderHook(() => useUserManagement()); + const { result } = renderHook(() => useUserManagement(true)); await waitFor(() => expect(result.current.users).toHaveLength(2)); const active = result.current.users.find((u) => u.uid === 'u-active'); @@ -78,7 +80,7 @@ describe('useUserManagement — getUserCounts', () => { deletedDoc('u-del-2', 'admin', 1700000000001), ]; - const { result } = renderHook(() => useUserManagement()); + const { result } = renderHook(() => useUserManagement(true)); await waitFor(() => expect(result.current.users).toHaveLength(5)); const counts = result.current.getUserCounts(); @@ -89,3 +91,16 @@ describe('useUserManagement — getUserCounts', () => { expect(counts.deleted).toBe(2); }); }); + +describe('useUserManagement — disabled', () => { + it('does not subscribe and returns inert state when disabled', () => { + snapshotDocs = [activeDoc('u-active', 'member')]; + + const { result } = renderHook(() => useUserManagement(false)); + + expect(onSnapshot).not.toHaveBeenCalled(); + expect(result.current.users).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/web/app/admin/users/page.tsx b/web/app/admin/users/page.tsx index d965da3c..30e8dc9c 100644 --- a/web/app/admin/users/page.tsx +++ b/web/app/admin/users/page.tsx @@ -78,8 +78,8 @@ interface UserActivity { * - Demote admins to user */ export default function UserManagementPage() { - const { users, loading, error, updateUserRole, getUserCounts, assignSiteToUser, removeSiteFromUser, deleteUser } = useUserManagement(); - const { user: currentUser } = useAuth(); + const { user: currentUser, isSuperadmin } = useAuth(); + const { users, loading, error, updateUserRole, getUserCounts, assignSiteToUser, removeSiteFromUser, deleteUser } = useUserManagement(isSuperadmin); const [updatingUser, setUpdatingUser] = useState(null); const [deletingUser, setDeletingUser] = useState(null); const [manageSitesDialogOpen, setManageSitesDialogOpen] = useState(false); diff --git a/web/components/ManageSitesDialog.tsx b/web/components/ManageSitesDialog.tsx index e90441cd..121e46e0 100644 --- a/web/components/ManageSitesDialog.tsx +++ b/web/components/ManageSitesDialog.tsx @@ -43,9 +43,9 @@ export function ManageSitesDialog({ onDeleteSite, onCreateSite, }: ManageSitesDialogProps) { - // When admin, fetch all users so we can display the owner of foreign sites. - // Lazily resolve owner UIDs → emails for sites not owned by the current admin. - const { users: allUsers } = useUserManagement(); + // When superadmin, fetch all users so we can display the owner of foreign sites. + // Lazily resolve owner UIDs to emails for sites not owned by the current admin. + const { users: allUsers } = useUserManagement(Boolean(isSuperadmin)); const ownerEmailByUid = React.useMemo(() => { if (!isSuperadmin) return new Map(); const map = new Map(); diff --git a/web/e2e/specs/roosts/empty-roost-state.spec.ts b/web/e2e/specs/roosts/empty-roost-state.spec.ts index ef38f134..daae60ac 100644 --- a/web/e2e/specs/roosts/empty-roost-state.spec.ts +++ b/web/e2e/specs/roosts/empty-roost-state.spec.ts @@ -22,10 +22,7 @@ const ROOST_ID = 'rst_test_empty_001'; const ROOST_NAME = 'empty-roost'; function isKnownPageChromeNoise(message: string): boolean { - return ( - message.includes('Error fetching users: FirebaseError') || - message === '[Error] An error occurred' - ); + return message === '[Error] An error occurred'; } async function cleanup() { diff --git a/web/hooks/useUserManagement.ts b/web/hooks/useUserManagement.ts index d42a7c75..da135aaa 100644 --- a/web/hooks/useUserManagement.ts +++ b/web/hooks/useUserManagement.ts @@ -35,16 +35,24 @@ export interface UserData { * - Sort and filter users * * Usage: - * const { users, loading, error, updateUserRole } = useUserManagement(); + * const { users, loading, error, updateUserRole } = useUserManagement(isSuperadmin); */ -export function useUserManagement() { +const EMPTY_USERS: UserData[] = []; + +export function useUserManagement(enabled: boolean) { const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(!!db); - const [error, setError] = useState(db ? null : 'Firebase is not configured'); + const [loading, setLoading] = useState(enabled && !!db); + const [error, setError] = useState( + enabled && !db ? 'Firebase is not configured' : null + ); + + const exposedUsers = enabled ? users : EMPTY_USERS; + const exposedLoading = enabled ? loading : false; + const exposedError = enabled && !db ? 'Firebase is not configured' : enabled ? error : null; // Fetch all users with real-time updates useEffect(() => { - if (!db) return; + if (!enabled || !db) return; // No try/catch: `collection()`/`query()` only throw for invalid path or // query shape (both literals here), and onSnapshot surfaces runtime @@ -78,7 +86,7 @@ export function useUserManagement() { ); return () => unsubscribe(); - }, []); + }, [enabled]); /** * Update a user's role @@ -118,7 +126,7 @@ export function useUserManagement() { * - members: standard users with site-level access */ const getUserCounts = useCallback(() => { - const active = users.filter((u) => u.deletedAt == null); + const active = exposedUsers.filter((u) => u.deletedAt == null); const superadmins = active.filter((u) => u.role === 'superadmin').length; const admins = active.filter((u) => u.role === 'admin').length; const members = active.filter((u) => u.role === 'member').length; @@ -128,9 +136,9 @@ export function useUserManagement() { superadmins, admins, members, - deleted: users.length - active.length, + deleted: exposedUsers.length - active.length, }; - }, [users]); + }, [exposedUsers]); /** * Assign a site to a user @@ -209,9 +217,9 @@ export function useUserManagement() { ); return { - users, - loading, - error, + users: exposedUsers, + loading: exposedLoading, + error: exposedError, updateUserRole, getUserCounts, assignSiteToUser, From a8ca546bc159c3786d91be2a58a34e4ca0fc8200 Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 13:27:32 -0700 Subject: [PATCH 42/43] fix(web): quiet expected permission-denied noise + block agent tokens from minting api keys Three calibrated follow-ups from the OWLETTE-WEB-3R review (none are page-load auto-fire; all are client-interaction or defense-in-depth): - CreateSiteDialog: a client read of sites/{id} is denied both when the site does not exist (available) and when it is owned by another user (taken), so the dialog cannot distinguish them. Keep the optimistic-available behavior (POST /api/sites is authoritative and returns 409 on a real collision) but stop console.error-ing the expected permission-denied, which was Sentry noise on every check of a not-yet-existing id. No global existence-check endpoint is added on purpose: it would enable site-id enumeration the rules prevent. - useCortex: permission-denied when opening a /cortex/{id} URL for a chat the user does not own is expected; surface as not_found without logging noise. - POST /api/keys: reject role='agent' ID tokens via an opt-in rejectAgentTokens flag on requireSessionOrIdToken (default off, so other routes are unaffected). Agents authenticate for site/machine ops and must never mint user-scoped keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/__tests__/lib/apiAuth.test.ts | 31 +++++++++++++++++++++++++++++ web/app/api/keys/route.ts | 2 +- web/components/CreateSiteDialog.tsx | 14 ++++++++++--- web/hooks/useCortex.ts | 9 ++++++++- web/lib/apiAuth.server.ts | 13 ++++++++++-- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/web/__tests__/lib/apiAuth.test.ts b/web/__tests__/lib/apiAuth.test.ts index 8a9f1945..34daa148 100644 --- a/web/__tests__/lib/apiAuth.test.ts +++ b/web/__tests__/lib/apiAuth.test.ts @@ -206,6 +206,37 @@ describe('requireSessionOrIdToken', () => { ); expect(mockVerifyIdToken).not.toHaveBeenCalled(); }); + + it('rejects agent-role bearer tokens with 403 when rejectAgentTokens is set', async () => { + mockGetSession.mockResolvedValue(validSession({ userId: null })); + mockVerifyIdToken.mockResolvedValue({ uid: 'agent-uid', role: 'agent' }); + const req = makeRequest('http://localhost/test', { + headers: { authorization: 'Bearer agent-token' }, + }); + await expect( + requireSessionOrIdToken(req, { rejectAgentTokens: true }), + ).rejects.toThrow(expect.objectContaining({ status: 403 })); + }); + + it('allows agent-role bearer tokens by default (no behavior change for existing callers)', async () => { + mockGetSession.mockResolvedValue(validSession({ userId: null })); + mockVerifyIdToken.mockResolvedValue({ uid: 'agent-uid', role: 'agent' }); + const req = makeRequest('http://localhost/test', { + headers: { authorization: 'Bearer agent-token' }, + }); + await expect(requireSessionOrIdToken(req)).resolves.toBe('agent-uid'); + }); + + it('allows non-agent bearer tokens when rejectAgentTokens is set', async () => { + mockGetSession.mockResolvedValue(validSession({ userId: null })); + mockVerifyIdToken.mockResolvedValue({ uid: 'human-uid', role: 'member' }); + const req = makeRequest('http://localhost/test', { + headers: { authorization: 'Bearer human-token' }, + }); + await expect( + requireSessionOrIdToken(req, { rejectAgentTokens: true }), + ).resolves.toBe('human-uid'); + }); }); // ─── requireAdminOrIdToken ───────────────────────────────────────────────── diff --git a/web/app/api/keys/route.ts b/web/app/api/keys/route.ts index 93bf9cb1..76480c56 100644 --- a/web/app/api/keys/route.ts +++ b/web/app/api/keys/route.ts @@ -108,7 +108,7 @@ function validateScopes(raw: unknown): ApiKeyScope[] | string { export const POST = withRateLimit( async (request: NextRequest) => { try { - const userId = await requireSessionOrIdToken(request); + const userId = await requireSessionOrIdToken(request, { rejectAgentTokens: true }); let body: CreateKeyBody; try { diff --git a/web/components/CreateSiteDialog.tsx b/web/components/CreateSiteDialog.tsx index 47914a83..23c7e73e 100644 --- a/web/components/CreateSiteDialog.tsx +++ b/web/components/CreateSiteDialog.tsx @@ -84,13 +84,21 @@ export function CreateSiteDialog({ setValidationError(''); } } catch (error: unknown) { - console.error('Error checking site availability:', error); - const e = error as { code?: string }; - if (e?.code === 'permission-denied') { + const code = (error as { code?: string } | null)?.code; + // A client read of sites/{id} is denied BOTH when the site doesn't exist + // (truly available) AND when it exists but is owned by another user + // (firestore.rules hides foreign sites). The client can't distinguish the + // two, so treat permission-denied as optimistically available and let the + // server be authoritative: POST /api/sites returns 409 on a real collision, + // which createSite() surfaces as "already taken". This is deliberate — we do + // NOT expose a global existence-check endpoint that would let anyone + // enumerate site IDs. permission-denied is therefore expected, not an error. + if (code === 'permission-denied') { setAvailabilityStatus('available'); setValidationError(''); return; } + console.error('Error checking site availability:', error); setAvailabilityStatus('invalid'); setValidationError('Failed to check availability'); } diff --git a/web/hooks/useCortex.ts b/web/hooks/useCortex.ts index 1e233f1e..1627aa6f 100644 --- a/web/hooks/useCortex.ts +++ b/web/hooks/useCortex.ts @@ -474,7 +474,14 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted } } catch (error) { if (!isMountedRef.current || requestId !== loadChatRequestRef.current) return; - console.error('Failed to load chat messages:', error); + // permission-denied is expected when the URL points at a chat the user + // doesn't own (or an autonomous chat for a site they can't access) — + // firestore.rules correctly denies it. Surface as not_found without + // logging noise; only log genuinely unexpected failures. + const code = (error as { code?: string } | null)?.code; + if (code !== 'permission-denied') { + console.error('Failed to load chat messages:', error); + } setChatLoadError('not_found'); chat.setMessages([]); } diff --git a/web/lib/apiAuth.server.ts b/web/lib/apiAuth.server.ts index aa990e87..33ce2f02 100644 --- a/web/lib/apiAuth.server.ts +++ b/web/lib/apiAuth.server.ts @@ -110,7 +110,8 @@ export async function requireSession(request: NextRequest): Promise { } export async function requireSessionOrIdToken( - request: NextRequest + request: NextRequest, + options: { rejectAgentTokens?: boolean } = {} ): Promise { try { return await requireSession(request); @@ -126,8 +127,16 @@ export async function requireSessionOrIdToken( try { const adminAuth = getAdminAuth(); const decoded = await adminAuth.verifyIdToken(bearer); + // Agents authenticate via custom tokens (role='agent') for site/machine + // operations and must never mint user-scoped API keys. Opt-in per call so + // existing callers are unaffected; session callers are always human, so + // this only applies to the ID-token branch. + if (options.rejectAgentTokens && decoded.role === 'agent') { + throw new ApiAuthError(403, 'Forbidden: agent credentials cannot create api keys'); + } return decoded.uid; - } catch { + } catch (e) { + if (e instanceof ApiAuthError) throw e; // preserve the 403 above throw new ApiAuthError(401, 'Unauthorized: Invalid ID token'); } } From 3f35be39e45868bdbdb677d0ec065f31d8bf520a Mon Sep 17 00:00:00 2001 From: Dylan Roscover Date: Mon, 25 May 2026 15:37:17 -0700 Subject: [PATCH 43/43] docs(api,changelog): document /api/users/deletions + capture unreleased web changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openapi.yaml: add GET /api/users/deletions (superadmin audit feed: limit query param, deletions[] response, user:read / session-superadmin security), closing the route-coverage validator warning. - changelog: add an [Unreleased] section to both docs/changelog.md and the fumadocs-rendered web/content/docs/changelog.mdx capturing the web/dashboard batch shipped to prod since 2.12.3. No VERSION bump — the version number tracks the agent and this batch has no agent changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/changelog.md | 40 +++++++++++++++++++++++++++++ web/content/docs/changelog.mdx | 40 +++++++++++++++++++++++++++++ web/openapi.yaml | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index e1987a78..6d1a2f81 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,46 @@ All notable changes to owlette are documented here. The format is based on [Keep --- +## [Unreleased] + +> Web/dashboard changes shipped to prod since 2.12.3. No version bump — the +> version number tracks the agent, and this batch contains no agent changes. + +### added + +- **Cortex tier-3 tool-approval gate.** Tier-3 tool calls now require explicit in-chat approval. `/api/cortex` migrated to the UIMessage protocol (`convertToModelMessages`); the per-site approval flag is default-on and forces the server-side path. Includes Cortex sidebar/UX fixes and a persistent chat layout. +- **Scoped full-text search on the logs page**, plus an animated filters panel. +- **Date-scoped log clearing.** Clear-logs deletion accepts a `since`/`until` range, with the date window computed in the display timezone. +- **Themed date picker** (shadcn calendar + popover input) and dark-mode theming of native form controls via `color-scheme`. +- **Site admins can remove machines on their assigned sites** (previously superadmin-only). +- **Admin user management: last-seen column and deleted-user visibility.** +- **Any authenticated user can create API keys from account settings** — the account dialog was wrongly pointed at the superadmin-only `/api/account/api-keys`; it now uses the user-scoped `/api/keys` with an explicit scope-preset selector. +- **`GET /api/users/deletions`** is now documented in the OpenAPI spec; new Firestore composite indexes back the log filter combinations and the deletions audit feed. +- **CLI / SDK OIDC trusted-publishing workflows** for `@owlette/cli` and the SDKs. + +### fixed + +- **Dashboard "Missing or insufficient permissions" error (Sentry OWLETTE-WEB-3R).** `useUserManagement` opened a realtime listener over the whole `users` collection (superadmin-only per `firestore.rules`) for *every* user, via `ManageSitesDialog` mounted on the dashboard/roosts/logs/deployments pages — so every non-superadmin tripped a permission-denied on load. The listener is now gated to superadmins (client-side; rules unchanged). +- **Quieted expected `permission-denied` noise** on the site-availability check (`CreateSiteDialog`) and Cortex chat-URL load, and **blocked agent ID tokens from minting user API keys** (`POST /api/keys`). +- **Logs are ordered by timestamp across all filter combinations.** +- **roost:** config-only republish honors content-addressed CAS on the no-op branch; a restated deploy config is applied on same-version republish. +- **Cortex conversation rows are a11y-accessible** (no nested buttons). +- **Dark-mode button hover repaired** and standardized on the secondary rollover. +- **Metrics panel** persistence restored (gated on a non-empty selection; no auto-restore on load); empty metrics-slide gap removed; inline sparklines read hourly `metrics_history` buckets; disk r/w throughput right-aligned in the machine list. + +### changed + +- **Logs page refactor** — themed date pickers, date-scoped clear, aligned-column table. +- **Sunken machine card/list surfaces** with section enclosures; metrics/displays panels darkened with brighter content. +- **CLI pre-publish hardening** (6-wave review): request timeouts, idempotency-key surfacing on unconfirmed failures; `owlette key` removed (key management is dashboard-only). +- Removed the dead `MachineListView` wrapper and redundant per-instance button hover overrides. + +### infrastructure / docs + +- `/preflight` pre-push gate + `post-push-e2e` watch hook; build-system skill expanded with the installer-release + version-bump flow. +- CI: bumped checkout/setup-node/setup-java/cache to Node 24 action majors; py-sdk publish now runs pytest first. +- Layperson video-tutorial series + Playwright video-capture harness. + ## [2.12.3] - 2026-05-19 ### fixed diff --git a/web/content/docs/changelog.mdx b/web/content/docs/changelog.mdx index a0ccead2..c5e74dd9 100644 --- a/web/content/docs/changelog.mdx +++ b/web/content/docs/changelog.mdx @@ -5,6 +5,46 @@ description: "All notable changes to owlette are documented here. The format is --- +## [Unreleased] + +> Web/dashboard changes shipped to prod since 2.12.3. No version bump — the +> version number tracks the agent, and this batch contains no agent changes. + +### added + +- **Cortex tier-3 tool-approval gate.** Tier-3 tool calls now require explicit in-chat approval. `/api/cortex` migrated to the UIMessage protocol (`convertToModelMessages`); the per-site approval flag is default-on and forces the server-side path. Includes Cortex sidebar/UX fixes and a persistent chat layout. +- **Scoped full-text search on the logs page**, plus an animated filters panel. +- **Date-scoped log clearing.** Clear-logs deletion accepts a `since`/`until` range, with the date window computed in the display timezone. +- **Themed date picker** (shadcn calendar + popover input) and dark-mode theming of native form controls via `color-scheme`. +- **Site admins can remove machines on their assigned sites** (previously superadmin-only). +- **Admin user management: last-seen column and deleted-user visibility.** +- **Any authenticated user can create API keys from account settings** — the account dialog was wrongly pointed at the superadmin-only `/api/account/api-keys`; it now uses the user-scoped `/api/keys` with an explicit scope-preset selector. +- **`GET /api/users/deletions`** is now documented in the OpenAPI spec; new Firestore composite indexes back the log filter combinations and the deletions audit feed. +- **CLI / SDK OIDC trusted-publishing workflows** for `@owlette/cli` and the SDKs. + +### fixed + +- **Dashboard "Missing or insufficient permissions" error (Sentry OWLETTE-WEB-3R).** `useUserManagement` opened a realtime listener over the whole `users` collection (superadmin-only per `firestore.rules`) for *every* user, via `ManageSitesDialog` mounted on the dashboard/roosts/logs/deployments pages — so every non-superadmin tripped a permission-denied on load. The listener is now gated to superadmins (client-side; rules unchanged). +- **Quieted expected `permission-denied` noise** on the site-availability check (`CreateSiteDialog`) and Cortex chat-URL load, and **blocked agent ID tokens from minting user API keys** (`POST /api/keys`). +- **Logs are ordered by timestamp across all filter combinations.** +- **roost:** config-only republish honors content-addressed CAS on the no-op branch; a restated deploy config is applied on same-version republish. +- **Cortex conversation rows are a11y-accessible** (no nested buttons). +- **Dark-mode button hover repaired** and standardized on the secondary rollover. +- **Metrics panel** persistence restored (gated on a non-empty selection; no auto-restore on load); empty metrics-slide gap removed; inline sparklines read hourly `metrics_history` buckets; disk r/w throughput right-aligned in the machine list. + +### changed + +- **Logs page refactor** — themed date pickers, date-scoped clear, aligned-column table. +- **Sunken machine card/list surfaces** with section enclosures; metrics/displays panels darkened with brighter content. +- **CLI pre-publish hardening** (6-wave review): request timeouts, idempotency-key surfacing on unconfirmed failures; `owlette key` removed (key management is dashboard-only). +- Removed the dead `MachineListView` wrapper and redundant per-instance button hover overrides. + +### infrastructure / docs + +- `/preflight` pre-push gate + `post-push-e2e` watch hook; build-system skill expanded with the installer-release + version-bump flow. +- CI: bumped checkout/setup-node/setup-java/cache to Node 24 action majors; py-sdk publish now runs pytest first. +- Layperson video-tutorial series + Playwright video-capture harness. + ## [2.12.3] - 2026-05-19 ### fixed diff --git a/web/openapi.yaml b/web/openapi.yaml index fe00625d..7c2eeed8 100644 --- a/web/openapi.yaml +++ b/web/openapi.yaml @@ -5753,6 +5753,53 @@ paths: '401': { description: Unauthorized } '403': { description: Forbidden (not superadmin) } + /api/users/deletions: + get: + tags: [Users] + summary: list user-deletion audit events (superadmin only) + description: | + Lists user-deletion events from the platform audit log, newest first. + Covers both self-service deletions (`USER_SELF_DELETE`) and + superadmin-initiated deletions (`USER_DELETE`); both land in + `global/audit_log/entries`. Requires `user=*:read` scope + (superadmin-only at minting) or a session/id-token from a superadmin + user. + security: + - apiKey: [] + - bearerApiKey: [] + - firebaseIdToken: [] + parameters: + - name: limit + in: query + required: false + description: max events to return (clamped 1..200) + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + responses: + '200': + description: Deletion events (newest first) + content: + application/json: + schema: + type: object + required: [deletions] + properties: + deletions: + type: array + items: + type: object + required: [id] + properties: + id: { type: string } + uid: { type: string, nullable: true } + actorUid: { type: string, nullable: true } + capability: { type: string } + outcome: { type: string } + timestamp: { type: string, format: date-time, nullable: true } + denyReason: { type: string, nullable: true } + counts: { type: object, nullable: true, additionalProperties: true } + '401': { description: Unauthorized } + '403': { description: Forbidden (not superadmin) } + /api/users/bootstrap: post: tags: [Users]