diff --git a/.fallowrc.json b/.fallowrc.json index 509c08b3a..4ceaf9526 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -15,7 +15,11 @@ // The elkjs layout worker: only referenced through // `new Worker(new URL('./layout.worker.ts', import.meta.url))` in // apps/agent-forge/src/canvas/layout.ts (C17). - "apps/agent-forge/src/canvas/layout.worker.ts" + "apps/agent-forge/src/canvas/layout.worker.ts", + // The widget bundle (X04b): vite.widget.config.ts's + // lib.entry is not visible to the import graph. + "apps/agent-forge/src/widget/entry.ts", + "apps/agent-forge/vite.widget.config.ts" ], "ignorePatterns": [ ".claude/**" diff --git a/apps/agent-forge/package.json b/apps/agent-forge/package.json index 93884237b..30741e24c 100644 --- a/apps/agent-forge/package.json +++ b/apps/agent-forge/package.json @@ -16,11 +16,12 @@ "server:start": "tsx server/index.ts", "server:build": "tsup --config server/tsup.config.ts", "server:start:prod": "node dist-server/index.cjs", - "build:studio": "npm run build && npm run server:build", + "build:studio": "npm run build && npm run build:widget && npm run server:build", "test:server": "vitest run -c server/vitest.config.ts", "typecheck:server": "tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p evals/tsconfig.json", "eval:author": "tsx evals/author/run.ts", - "test:e2e:studio": "npm run build:studio && playwright test" + "test:e2e:studio": "npm run build:studio && playwright test", + "build:widget": "vite build -c vite.widget.config.ts" }, "dependencies": { "@codemirror/lang-javascript": "^6.2.5", diff --git a/apps/agent-forge/server/__tests__/serveWidget.test.ts b/apps/agent-forge/server/__tests__/serveWidget.test.ts new file mode 100644 index 000000000..aa4c67759 --- /dev/null +++ b/apps/agent-forge/server/__tests__/serveWidget.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import request from 'supertest'; +import type { AgentSpecV2 } from 'lousho'; +import { createApp } from '../app'; +import { RunManager } from '../runRegistry'; +import { newTestHost } from './testHost'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; +import { AgentRepo } from '../storage/agentRepo'; +import { SecretsStore } from '../secretsStore'; +import { SettingsStore } from '../settingsStore'; +import { SpendLog } from '../passes/spendLog'; +import { studioPathsFor } from '../workspace/studioPaths'; +import type { LegacyAgentStore } from '../../src/persistence/AgentStore'; + +const SPEC: AgentSpecV2 = { version: 2, name: 'ops', model: 'mock/mock-1', instructions: 'Check the queues.' }; + +describe('GET /serve/:id/widget.js (X04b)', () => { + let baseDir: string; + let studioHome: string; + let widgetDir: string; + let agentStore: LegacyAgentStore; + let runManager: RunManager; + let repo: AgentRepo; + + beforeEach(async () => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-x04b-widget-')); + studioHome = path.join(baseDir, '.lousho'); + widgetDir = path.join(baseDir, 'widget'); + fs.mkdirSync(widgetDir, { recursive: true }); + agentStore = createFsAgentStore(baseDir); + repo = new AgentRepo(baseDir); + runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + host: newTestHost(baseDir), + loadSpec: (id) => repo.readSpec(id), + saveSpec: (id, spec) => repo.writeSpec(id, spec), + }); + await repo.writeSpec('ops', SPEC); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + const paths = () => studioPathsFor(studioHome); + const app = () => + createApp({ + agentStore, + runManager, + baseDir, + studioHome, + widgetDir, + secretsStore: new SecretsStore(paths()), + settingsStore: new SettingsStore(paths()), + spendLog: new SpendLog(paths().spend), + }); + + async function enableServing(): Promise { + await request(app()).post('/agents/ops/publish').send({}); + await request(app()).put('/agents/ops/serve').send({ enabled: true }); + } + + it('serves the built bundle with JS content type and a public cache header - no token needed', async () => { + fs.writeFileSync(path.join(widgetDir, 'forge-chat.js'), '/* forge-chat */ customElements.define("forge-chat", class {})'); + await enableServing(); + const res = await request(app()).get('/serve/ops/widget.js'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('application/javascript'); + expect(res.headers['cache-control']).toBe('public, max-age=300'); + expect(res.text).toContain('forge-chat'); + }); + + it('404s LOUSHO_WIDGET_MISSING when the bundle was never built', async () => { + await enableServing(); + const res = await request(app()).get('/serve/ops/widget.js'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Widget not built. Run npm run build:studio.', code: 'LOUSHO_WIDGET_MISSING' }); + }); + + it('404s LOUSHO_NOT_SERVED when serving is disabled, and 400 on a bad id', async () => { + fs.writeFileSync(path.join(widgetDir, 'forge-chat.js'), 'x'); + await request(app()).post('/agents/ops/publish').send({}); + const disabled = await request(app()).get('/serve/ops/widget.js'); + expect(disabled.status).toBe(404); + expect(disabled.body.code).toBe('LOUSHO_NOT_SERVED'); + const bad = await request(app()).get('/serve/NOPE!/widget.js'); + expect(bad.status).toBe(400); + expect(bad.body.code).toBe('LOUSHO_AGENT_ID_INVALID'); + }); +}); diff --git a/apps/agent-forge/server/__tests__/serveWidgetKey.test.ts b/apps/agent-forge/server/__tests__/serveWidgetKey.test.ts new file mode 100644 index 000000000..c94bfef65 --- /dev/null +++ b/apps/agent-forge/server/__tests__/serveWidgetKey.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import request from 'supertest'; +import type { AgentSpecV2 } from 'lousho'; +import { createApp } from '../app'; +import { RunManager } from '../runRegistry'; +import { newTestHost } from './testHost'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; +import { AgentRepo } from '../storage/agentRepo'; +import { SecretsStore } from '../secretsStore'; +import { SettingsStore } from '../settingsStore'; +import { SpendLog } from '../passes/spendLog'; +import { studioPathsFor } from '../workspace/studioPaths'; +import type { LegacyAgentStore } from '../../src/persistence/AgentStore'; +import type { ServeStatus } from '../../shared/wireTypes'; + +const SPEC: AgentSpecV2 = { version: 2, name: 'ops', model: 'mock/mock-1', instructions: 'Check the queues.' }; +const ORIGIN = 'http://localhost:3000'; + +describe('serve widget key (X04b)', () => { + let baseDir: string; + let studioHome: string; + let agentStore: LegacyAgentStore; + let runManager: RunManager; + let repo: AgentRepo; + + beforeEach(async () => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-x04b-key-')); + studioHome = path.join(baseDir, '.lousho'); + agentStore = createFsAgentStore(baseDir); + repo = new AgentRepo(baseDir); + runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + host: newTestHost(baseDir), + loadSpec: (id) => repo.readSpec(id), + saveSpec: (id, spec) => repo.writeSpec(id, spec), + }); + await repo.writeSpec('ops', SPEC); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + const paths = () => studioPathsFor(studioHome); + const app = () => + createApp({ + agentStore, + runManager, + baseDir, + studioHome, + secretsStore: new SecretsStore(paths()), + settingsStore: new SettingsStore(paths()), + spendLog: new SpendLog(paths().spend), + }); + + /** Publishes, enables serving with one allowed origin, and returns the raw serve token. */ + async function enableServing(): Promise { + await request(app()).post('/agents/ops/publish').send({}); + await request(app()).put('/agents/ops/serve').send({ enabled: true, allowedOrigins: [ORIGIN] }); + const token = await request(app()).get('/agents/ops/serve/token'); + return token.body.token as string; + } + + async function createKey(): Promise { + const res = await request(app()).post('/agents/ops/serve/widget-key').send({}); + expect(res.status).toBe(200); + return res.body.widgetKey as string; + } + + const chat = (key: string, origin?: string) => { + const req = request(app()) + .post('/serve/ops/chat') + .set('authorization', `Bearer ${key}`) + .send({ sessionId: 's1', input: 'hi' }); + return origin === undefined ? req : req.set('origin', origin); + }; + + it('creates an fwk_ key, masks it in status, and rotates on a second POST', async () => { + await enableServing(); + const status0 = (await request(app()).get('/agents/ops/serve')).body as ServeStatus; + expect(status0.widgetKeyMasked).toBeNull(); + + const first = await createKey(); + expect(first).toMatch(/^fwk_/); + const status1 = (await request(app()).get('/agents/ops/serve')).body as ServeStatus; + expect(status1.widgetKeyMasked).not.toBeNull(); + expect(status1.widgetKeyMasked).not.toContain(first); + + const second = await createKey(); + expect(second).not.toBe(first); + // The rotated-out key no longer works. + const stale = await chat(first, ORIGIN); + expect(stale.status).toBe(401); + }); + + it('DELETE revokes the key', async () => { + await enableServing(); + const key = await createKey(); + expect((await chat(key, ORIGIN)).status).toBe(200); + const del = await request(app()).delete('/agents/ops/serve/widget-key'); + expect(del.status).toBe(204); + const status = (await request(app()).get('/agents/ops/serve')).body as ServeStatus; + expect(status.widgetKeyMasked).toBeNull(); + expect((await chat(key, ORIGIN)).status).toBe(401); + }); + + it('accepts the key on POST /serve/:id/chat from an allowed origin', async () => { + await enableServing(); + const key = await createKey(); + const res = await chat(key, ORIGIN); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/event-stream'); + expect(res.headers['access-control-allow-origin']).toBe(ORIGIN); + expect(res.text).toContain('event: done'); + }); + + it('401s LOUSHO_WIDGET_KEY_SCOPE on every non-chat serve route', async () => { + await enableServing(); + const key = await createKey(); + for (const attempt of [ + request(app()).get('/serve/ops/chat').set('authorization', `Bearer ${key}`).set('origin', ORIGIN), + request(app()).post('/serve/ops/sessions').set('authorization', `Bearer ${key}`).set('origin', ORIGIN), + request(app()).get('/serve/ops/health').set('authorization', `Bearer ${key}`).set('origin', ORIGIN), + ]) { + const res = await attempt; + expect(res.status).toBe(401); + expect(res.body.code).toBe('LOUSHO_WIDGET_KEY_SCOPE'); + } + }); + + it('403s LOUSHO_WIDGET_ORIGIN on a missing or unlisted origin', async () => { + await enableServing(); + const key = await createKey(); + const missing = await chat(key); + expect(missing.status).toBe(403); + expect(missing.body.code).toBe('LOUSHO_WIDGET_ORIGIN'); + const foreign = await chat(key, 'https://evil.example'); + expect(foreign.status).toBe(403); + expect(foreign.body.code).toBe('LOUSHO_WIDGET_ORIGIN'); + }); + + it('the serve token still works everywhere, with or without an origin', async () => { + const token = await enableServing(); + const noOrigin = await request(app()) + .post('/serve/ops/chat') + .set('authorization', `Bearer ${token}`) + .send({ sessionId: 's1', input: 'hi' }); + expect(noOrigin.status).toBe(200); + const other = await request(app()) + .get('/serve/ops/health') + .set('authorization', `Bearer ${token}`) + .set('origin', ORIGIN); + expect(other.status).toBe(200); + }); +}); diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index 285024c94..c04777feb 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -239,6 +239,14 @@ interface CreateAppBaseOptions { * `src/cli/studio.ts`. */ staticDir?: string; + /** + * Directory holding the built `` widget bundle + * (`forge-chat.js`, the output of `vite build -c vite.widget.config.ts`, + * normally `/widget`). `GET /serve/:id/widget.js` (X04b) + * serves it; defaults to `path.join(staticDir, 'widget')` when + * `staticDir` is set. + */ + widgetDir?: string; /** * The per-launch token (and extra allowed hosts) every API * request must present - see auth.ts. `startStudioServer()` always passes @@ -1369,6 +1377,7 @@ export function createApp(options: CreateAppOptions): Express { spendLog, buildAgent: serveAgentBuilder, mcpServers, + widgetDir: options.widgetDir ?? (staticDir !== undefined ? path.join(staticDir, 'widget') : undefined), }); const app = express(); diff --git a/apps/agent-forge/server/serve/serveConfigStore.ts b/apps/agent-forge/server/serve/serveConfigStore.ts index dc5c0fc4b..e8c7340f8 100644 --- a/apps/agent-forge/server/serve/serveConfigStore.ts +++ b/apps/agent-forge/server/serve/serveConfigStore.ts @@ -20,6 +20,12 @@ import type { ServeAgentConfig } from '../../shared/wireTypes'; export interface ServeAgentEntry extends ServeAgentConfig { /** The bearer `/serve//*` callers present; `auth: token` on the SDK route handler. */ token: string; + /** + * The widget key (X04b): a narrower credential the `` embed + * carries - `fwk_`-prefixed, accepted only on `POST /serve//chat` + * from an allowed origin. `null` until the user creates one. + */ + widgetKey: string | null; } /** The on-disk shape: schema-versioned so a later migration has a marker. */ @@ -42,6 +48,16 @@ function mintServeToken(): string { return randomBytes(24).toString('base64url'); } +/** A fresh widget key (X04b): `fwk_`-prefixed so logs and users can tell it from the serve token. */ +function mintWidgetKey(): string { + return `fwk_${randomBytes(24).toString('base64url')}`; +} + +/** The entry a never-configured agent starts from. */ +function freshEntry(): ServeAgentEntry { + return { enabled: false, token: mintServeToken(), allowedOrigins: [], widgetKey: null }; +} + /** * `raw` as a storable origin (`scheme://host[:port]`, lower-case) or * `null` when it isn't an http(s) URL with no path - `https://app.example` @@ -83,7 +99,12 @@ export class ServeConfigStore { private async readAll(): Promise { const file = await readJsonFile(this.file); - return file ?? { schemaVersion: 1, agents: {} }; + const data = file ?? { schemaVersion: 1, agents: {} }; + // Entries written before X04b have no `widgetKey`; normalize on read. + for (const entry of Object.values(data.agents)) { + entry.widgetKey ??= null; + } + return data; } /** @@ -95,7 +116,7 @@ export class ServeConfigStore { const file = await this.readAll(); const existing = file.agents[agentId]; if (existing !== undefined) return existing; - const entry: ServeAgentEntry = { enabled: false, token: mintServeToken(), allowedOrigins: [] }; + const entry = freshEntry(); file.agents[agentId] = entry; await writeJsonAtomic(this.file, file); return entry; @@ -106,11 +127,12 @@ export class ServeConfigStore { async put(agentId: string, patch: Partial): Promise { return withAgentLock(SERVE_LOCK, async () => { const file = await this.readAll(); - const current = file.agents[agentId] ?? { enabled: false, token: mintServeToken(), allowedOrigins: [] }; + const current = file.agents[agentId] ?? freshEntry(); const next: ServeAgentEntry = { enabled: typeof patch.enabled === 'boolean' ? patch.enabled : current.enabled, token: current.token, allowedOrigins: patch.allowedOrigins !== undefined ? checkedOrigins(patch.allowedOrigins) : current.allowedOrigins, + widgetKey: current.widgetKey ?? null, }; file.agents[agentId] = next; await writeJsonAtomic(this.file, file); @@ -118,15 +140,49 @@ export class ServeConfigStore { }); } - /** Replaces the agent's bearer and returns the new raw token. */ - async rotateToken(agentId: string): Promise { + /** + * The locked read-modify-write `rotateToken`/`createWidgetKey` share: + * `change` sees the stored (or fresh) entry and returns the entry to + * persist plus the raw secret to hand back. + */ + private async rotateSecret( + agentId: string, + change: (current: ServeAgentEntry) => { entry: ServeAgentEntry; secret: string } + ): Promise { return withAgentLock(SERVE_LOCK, async () => { const file = await this.readAll(); - const current = file.agents[agentId] ?? { enabled: false, token: mintServeToken(), allowedOrigins: [] }; + const current = file.agents[agentId] ?? freshEntry(); + const { entry, secret } = change(current); + file.agents[agentId] = entry; + await writeJsonAtomic(this.file, file); + return secret; + }); + } + + /** Replaces the agent's bearer and returns the new raw token. */ + async rotateToken(agentId: string): Promise { + return this.rotateSecret(agentId, (current) => { const token = mintServeToken(); - file.agents[agentId] = { ...current, token }; + return { entry: { ...current, token }, secret: token }; + }); + } + + /** Creates or rotates the agent's widget key and returns the raw `fwk_` key (X04b). */ + async createWidgetKey(agentId: string): Promise { + return this.rotateSecret(agentId, (current) => { + const widgetKey = mintWidgetKey(); + return { entry: { ...current, widgetKey }, secret: widgetKey }; + }); + } + + /** Revokes the agent's widget key; the entry stays otherwise untouched. */ + async revokeWidgetKey(agentId: string): Promise { + await withAgentLock(SERVE_LOCK, async () => { + const file = await this.readAll(); + const current = file.agents[agentId]; + if (current === undefined || current.widgetKey === null) return; + file.agents[agentId] = { ...current, widgetKey: null }; await writeJsonAtomic(this.file, file); - return token; }); } } diff --git a/apps/agent-forge/server/serve/serveRoutes.ts b/apps/agent-forge/server/serve/serveRoutes.ts index 94eef0d4f..82b3b1ce7 100644 --- a/apps/agent-forge/server/serve/serveRoutes.ts +++ b/apps/agent-forge/server/serve/serveRoutes.ts @@ -21,6 +21,7 @@ * version and rebuilt when the publish pointer moves; rotating the token * drops the cached handler so the old bearer dies immediately. */ +import fs from 'node:fs'; import path from 'node:path'; import { Router, type Express, type Request as ExpressRequest, type Response as ExpressResponse } from 'express'; import { @@ -69,6 +70,8 @@ export interface ServeDeps extends ServeBuildServices { buildAgent?: ServeAgentBuilder; /** The MCP server manager behind `/agents/:id/serve/mcp*` (X03); absent in tests that don't exercise it. */ mcpServers?: McpServeManager; + /** Directory holding the built `forge-chat.js` widget bundle (X04b); `GET /serve/:id/widget.js` 404s `LOUSHO_WIDGET_MISSING` without it. */ + widgetDir?: string; } interface CachedHandler { @@ -106,6 +109,34 @@ function maskToken(token: string): string { return token.length <= 8 ? '…' : `${token.slice(0, 4)}…${token.slice(-4)}`; } +/** The bearer an `Authorization: Bearer …` header carries, or `undefined`. */ +function bearerOf(req: ExpressRequest): string | undefined { + const header = req.headers.authorization; + return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined; +} + +/** + * The widget key (X04b) is narrower than the serve token: chat only, and + * only from an origin on the agent's allowedOrigins list. Answers the + * request itself on a scope/origin denial (`true`); on acceptance it + * rewrites the bearer to the full-access token - the bridge then runs as + * that caller - and returns `false` so routing continues. + */ +function applyWidgetKey(req: ExpressRequest, res: ExpressResponse, entry: ServeAgentEntry): boolean { + if (entry.widgetKey === null || bearerOf(req) !== entry.widgetKey) return false; + if (req.method !== 'POST' || req.path !== '/chat') { + sendError(res, 401, 'This key only works for the chat widget.', 'LOUSHO_WIDGET_KEY_SCOPE'); + return true; + } + const origin = req.headers.origin; + if (origin === undefined || !entry.allowedOrigins.includes(origin.toLowerCase())) { + sendError(res, 403, 'This page is not allowed to use the widget. Add its origin in Serve settings.', 'LOUSHO_WIDGET_ORIGIN'); + return true; + } + req.headers.authorization = `Bearer ${entry.token}`; + return false; +} + /** `GET /agents/:id/serve`'s body from the stored entry + the publish pointer. */ async function statusOf(agentId: string, entry: ServeAgentEntry, workspace: WorkspaceContext): Promise { const published = await readPublishedSpec(agentId, { repo: workspace.repo, versions: workspace.versions }); @@ -115,6 +146,7 @@ async function statusOf(agentId: string, entry: ServeAgentEntry, workspace: Work allowedOrigins: entry.allowedOrigins, publishedVersion: published?.version ?? null, tokenMasked: maskToken(entry.token), + widgetKeyMasked: entry.widgetKey === null ? null : maskToken(entry.widgetKey), baseUrl: `/serve/${agentId}`, }; } @@ -193,6 +225,32 @@ export function createServeSurface(deps: ServeDeps): ServeSurface { } const router = Router(); + // GET /serve/:id/widget.js (X04b) - the public widget bundle. No bearer: + // a plain `, + ``, + ].join('\n'); +} + +interface ServeToggleRowProps { + published: boolean; + publishedVersion: number | null; + enabled: boolean; + toggling: boolean; + onToggle(enabled: boolean): void; +} + +/** The enable switch + publish state (X02b): disabled with a title until a version is published. */ +function ServeToggleRow({ published, publishedVersion, enabled, toggling, onToggle }: ServeToggleRowProps): JSX.Element { + return ( +
+ onToggle(next)} + /> + + {published ? `v${publishedVersion}` : 'Not published'} +
+ ); +} + +interface WidgetEmbedProps { + agentId: string; + status: ServeStatus; + onCopy(text: string): void; + onChanged(): Promise; +} + +interface EmbedCreateProps { + canCreate: boolean; + busy: boolean; + onCreate(): void; +} + +/** The no-key state: one button, disabled with the reason until an origin exists. */ +function EmbedCreate({ canCreate, busy, onCreate }: EmbedCreateProps): JSX.Element { + return ( +
+ +
+ ); +} + +interface EmbedWithKeyProps { + placeholder: string; + hasRawKey: boolean; + busy: 'create' | 'revoke' | null; + onCopyWithKey(): void; + onMint(): void; + onRevoke(): void; +} + +/** The keyed state: placeholder snippet + Copy with key / Rotate / Revoke. */ +function EmbedWithKey({ placeholder, hasRawKey, busy, onCopyWithKey, onMint, onRevoke }: EmbedWithKeyProps): JSX.Element { + return ( + <> +
{placeholder}
+
+ + + +
+ + ); +} + +/** + * The "Embed on a page" details (X04b): the widget key is the only + * credential the snippet ever carries - the serve token stays + * server-to-server. The raw key lives in component state only, returned + * once by create/rotate; the masked form comes from `ServeStatus`. + */ +function WidgetEmbed({ agentId, status, onCopy, onChanged }: WidgetEmbedProps): JSX.Element { + const toast = useToast(); + const [rawKey, setRawKey] = useState(null); + const [busy, setBusy] = useState<'create' | 'revoke' | null>(null); + const hasKey = status.widgetKeyMasked !== null; + const canCreate = status.allowedOrigins.length > 0; + const placeholder = embedSnippet(location.origin, status.baseUrl, agentId, 'YOUR_WIDGET_KEY'); + + const act = async (kind: 'create' | 'revoke', call: () => Promise) => { + setBusy(kind); + try { + await call(); + await onChanged(); + } catch (err) { + toast.show({ message: errorMessage(err), tone: 'error' }); + } finally { + setBusy(null); + } + }; + + /** Create-or-rotate: the endpoint mints a fresh `fwk_` key either way. */ + const mint = () => + act('create', async () => { + const { widgetKey } = await runtimeClient.createWidgetKey(agentId); + setRawKey(widgetKey); + }); + const revoke = () => + act('revoke', async () => { + await runtimeClient.revokeWidgetKey(agentId); + setRawKey(null); + }); + const copyWithKey = () => { + if (rawKey !== null) onCopy(embedSnippet(location.origin, status.baseUrl, agentId, rawKey)); + }; + + return ( +
+ Embed on a page + {hasKey ? ( + void mint()} + onRevoke={() => void revoke()} + /> + ) : ( + void mint()} /> + )} +

+ The widget key can only chat with this agent, and only from the allowed origins. Rotate or revoke it here at any time. +

+
+ ); +} + interface ServeActionsDeps { agentId: string; refresh(): Promise; @@ -421,20 +585,13 @@ export function ServeCard({ agentId }: { agentId: string }): JSX.Element { return ( -
- void toggle(enabled)} - /> - - {published ? `v${status.publishedVersion}` : 'Not published'} -
+ void toggle(enabled)} + /> {published ? ( <> {status.enabled ? void copy(text)} /> : null} + {status.enabled ? ( + void copy(text)} onChanged={refresh} /> + ) : null} ) : null} {/* The MCP section stays visible unpublished too: its Start button is diff --git a/apps/agent-forge/src/screens/agentDetail/__tests__/ServeCard.test.tsx b/apps/agent-forge/src/screens/agentDetail/__tests__/ServeCard.test.tsx index fdf315c51..fa126e6ec 100644 --- a/apps/agent-forge/src/screens/agentDetail/__tests__/ServeCard.test.tsx +++ b/apps/agent-forge/src/screens/agentDetail/__tests__/ServeCard.test.tsx @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ mcpStatus: vi.fn(), startMcp: vi.fn(), stopMcp: vi.fn(), + createWidgetKey: vi.fn(), + revokeWidgetKey: vi.fn(), eventHandlers: [] as Array<(event: StudioEvent) => void>, writeText: vi.fn(), })); @@ -29,6 +31,8 @@ vi.mock('../../../runtime/runtimeClient', async (importOriginal) => { mcpStatus: mocks.mcpStatus, startMcp: mocks.startMcp, stopMcp: mocks.stopMcp, + createWidgetKey: mocks.createWidgetKey, + revokeWidgetKey: mocks.revokeWidgetKey, }, }; }); @@ -48,6 +52,7 @@ function status(overrides: Partial = {}): ServeStatus { allowedOrigins: ['http://localhost:3000'], publishedVersion: 3, tokenMasked: 'abcd…wxyz', + widgetKeyMasked: null, baseUrl: '/serve/support-bot', ...overrides, }; @@ -98,6 +103,8 @@ describe('ServeCard', () => { toolName: 'support_bot', }); mocks.stopMcp.mockResolvedValue({ running: false, url: null, port: null, version: null, toolName: null }); + mocks.createWidgetKey.mockResolvedValue({ widgetKey: 'fwk_test-key-1' }); + mocks.revokeWidgetKey.mockResolvedValue(undefined); mocks.writeText.mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { value: { writeText: mocks.writeText }, configurable: true }); }); @@ -336,4 +343,77 @@ describe('ServeCard', () => { expect(start.title).toBe('Publish a version first'); }); }); + + describe('Embed on a page (X04b)', () => { + it('is hidden while serving is disabled', async () => { + mocks.getServe.mockResolvedValue(status({ enabled: false })); + const host = renderCard(); + await flush(); + expect(host.textContent).not.toContain('Embed on a page'); + }); + + it('creates a widget key and shows the placeholder snippet + note', async () => { + mocks.getServe + .mockResolvedValueOnce(status()) + .mockResolvedValue(status({ widgetKeyMasked: 'fwk_…-key' })); + const host = renderCard(); + await flush(); + expect(host.textContent).toContain('Embed on a page'); + await act(async () => { + button(host, 'Create widget key').click(); + }); + await flush(); + expect(mocks.createWidgetKey).toHaveBeenCalledWith('support-bot'); + const pre = host.querySelector('.serve-card-embed .serve-card-pre')!.textContent!; + expect(pre).toContain('/serve/support-bot/widget.js'); + expect(pre).toContain('token="YOUR_WIDGET_KEY"'); + expect(pre).toContain('endpoint="'); + expect(pre).not.toContain('raw-token-1234'); + expect(host.textContent).toContain('The widget key can only chat with this agent'); + }); + + it('disables Create widget key with the reason while no origin is allowed', async () => { + mocks.getServe.mockResolvedValue(status({ allowedOrigins: [] })); + const host = renderCard(); + await flush(); + const create = button(host, 'Create widget key'); + expect(create.disabled).toBe(true); + expect(create.title).toBe('Add an allowed origin first'); + }); + + it('Copy with key stays disabled until a key exists, then copies the real key', async () => { + mocks.getServe + .mockResolvedValueOnce(status({ widgetKeyMasked: 'fwk_…-key' })) + .mockResolvedValue(status({ widgetKeyMasked: 'fwk_…-key' })); + const host = renderCard(); + await flush(); + const copyWithKey = button(host, 'Copy with key'); + expect(copyWithKey.disabled).toBe(true); + await act(async () => { + button(host, 'Rotate key').click(); + }); + await flush(); + expect(mocks.createWidgetKey).toHaveBeenCalledWith('support-bot'); + expect(button(host, 'Copy with key').disabled).toBe(false); + await act(async () => { + button(host, 'Copy with key').click(); + }); + await flush(); + expect(mocks.writeText).toHaveBeenCalledWith(expect.stringContaining('token="fwk_test-key-1"')); + }); + + it('revokes the key and returns to the create state', async () => { + mocks.getServe + .mockResolvedValueOnce(status({ widgetKeyMasked: 'fwk_…-key' })) + .mockResolvedValue(status({ widgetKeyMasked: null })); + const host = renderCard(); + await flush(); + await act(async () => { + button(host, 'Revoke').click(); + }); + await flush(); + expect(mocks.revokeWidgetKey).toHaveBeenCalledWith('support-bot'); + expect(button(host, 'Create widget key')).toBeDefined(); + }); + }); }); diff --git a/apps/agent-forge/src/screens/agentDetail/serveCard.css b/apps/agent-forge/src/screens/agentDetail/serveCard.css index 717c4cf3e..f0aed06a1 100644 --- a/apps/agent-forge/src/screens/agentDetail/serveCard.css +++ b/apps/agent-forge/src/screens/agentDetail/serveCard.css @@ -79,6 +79,11 @@ font-size: var(--fs-meta); color: var(--text-faint); } +.serve-card-note { + margin: var(--space-2) 0 0; + font-size: var(--fs-meta); + color: var(--text-muted); +} .serve-card-snippet { border: 1px solid var(--border); border-radius: var(--radius); diff --git a/apps/agent-forge/src/widget/__tests__/entry.test.ts b/apps/agent-forge/src/widget/__tests__/entry.test.ts new file mode 100644 index 000000000..dd96d0893 --- /dev/null +++ b/apps/agent-forge/src/widget/__tests__/entry.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; + +import { FORGE_CHAT_TAG } from '../forgeChat'; +import '../entry'; + +describe('widget bundle entry (X04b)', () => { + it('defines the forge-chat custom element on import', () => { + expect(customElements.get(FORGE_CHAT_TAG)).toBeDefined(); + }); +}); diff --git a/apps/agent-forge/src/widget/entry.ts b/apps/agent-forge/src/widget/entry.ts new file mode 100644 index 000000000..49f580ad8 --- /dev/null +++ b/apps/agent-forge/src/widget/entry.ts @@ -0,0 +1,9 @@ +/** + * The `` widget bundle entry (X04b): importing this file + * registers the custom element. `vite.widget.config.ts` builds it into + * `dist/widget/forge-chat.js`, which Studio serves at + * `GET /serve/:id/widget.js`. + */ +import { defineForgeChat } from './forgeChat'; + +defineForgeChat(); diff --git a/apps/agent-forge/vite.widget.config.ts b/apps/agent-forge/vite.widget.config.ts new file mode 100644 index 000000000..af86be6b3 --- /dev/null +++ b/apps/agent-forge/vite.widget.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; + +/** + * The `` widget bundle (X04b): one self-contained IIFE that + * registers the custom element, served by Studio at + * `GET /serve/:id/widget.js`. `emptyOutDir` only clears `dist/widget`, + * never the SPA's `dist/` - `build:studio` runs the SPA build first. + */ +export default defineConfig({ + build: { + outDir: 'dist/widget', + emptyOutDir: true, + lib: { + entry: 'src/widget/entry.ts', + name: 'ForgeChat', + formats: ['iife'], + fileName: () => 'forge-chat.js', + }, + minify: 'esbuild', + sourcemap: false, + target: 'es2020', + }, +});