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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <forge-chat> 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/**"
Expand Down
5 changes: 3 additions & 2 deletions apps/agent-forge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions apps/agent-forge/server/__tests__/serveWidget.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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');
});
});
162 changes: 162 additions & 0 deletions apps/agent-forge/server/__tests__/serveWidgetKey.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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);
});
});
9 changes: 9 additions & 0 deletions apps/agent-forge/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ interface CreateAppBaseOptions {
* `src/cli/studio.ts`.
*/
staticDir?: string;
/**
* Directory holding the built `<forge-chat>` widget bundle
* (`forge-chat.js`, the output of `vite build -c vite.widget.config.ts`,
* normally `<staticDir>/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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading