Compare commits

...
11 Commits
Author SHA1 Message Date
SinachPat e9de6ba62c fix: store session token on signup and send it to /chat
Mirror to GitHub / mirror (push) Canceled after 0s
- web: App stores sessionToken and passes to useChat; useChat sends Authorization header
- web: ChatPanel adds wursor-message-user/agent test hooks
- verified full UI flow (signup -> chat -> working -> reply -> approve -> preview)
2026-08-17 21:16:43 +01:00
SinachPat 060b181e71 feat: wire chat to the agent loop (POST /chat)
Mirror to GitHub / mirror (push) Canceled after 0s
- api: /chat route runs runAgent (session-required, 503 when unconfigured)
- api: index.ts builds OpenRouterLlmClient + WpRestExecutor from env; loads .env
- web: useChat calls /chat with mock fallback; proxy /chat+/sites+/health
- .env.example: DATABASE_URL empty by default (in-memory dev)
- 111 unit tests green; smoke-tested signup+chat
2026-08-17 17:19:33 +01:00
SinachPat 69287ebec0 feat: build the chat + preview + approve interface
Mirror to GitHub / mirror (push) Canceled after 0s
- web: two-pane app chrome (chat panel + browser-framed preview)
- ChatPanel, Preview, ApproveBar components + useChat (mock agent for now)
- Fluent/Figma design system (Inter, dark chrome, elevation, motion)
- 107 unit tests + Playwright e2e green; web typechecks + builds
2026-08-17 17:10:25 +01:00
SinachPat b61ff21710 feat: de-risk agent loop — semantic tool schema + agent loop + real-WP spike harness
Mirror to GitHub / mirror (push) Canceled after 0s
- api/agents: tool-schemas (semantic allowlist), agent-loop (multi-step + budget), circuit-breaker, llm-client/tool-executor interfaces
- api/agents: WpRestExecutor (real WP REST), OpenRouterLlmClient
- e2e/agent: multi-step prompts + run-agent-spike runner (scores rendered changes)
- ADR 0017; 101 unit tests green
2026-08-15 23:49:07 +01:00
SinachPat 69b2481299 feat: Sprint 2 — WordPress plugin (auth/api/site-info/admin) + web SiteConnector
Mirror to GitHub / mirror (push) Canceled after 0s
- plugin: Wursor_Auth (token hashing, HMAC, scoped tokens), Wursor_API (REST + auth), Wursor_Site_Info (builder/capabilities/preflight), Wursor_Admin
- plugin: test-auth.php (10 auth tests, run in a WP+PHP env)
- web: SiteConnector pairing UI (code + poll + states)
- api: PluginClient signs full REST route (matches WP get_route)
- 92 unit tests green
2026-08-15 23:17:16 +01:00
SinachPat 9801b9475b feat: Sprint 2 — Wursor-side pairing flow + signed plugin client
Mirror to GitHub / mirror (push) Canceled after 0s
- session store + requireSession middleware (signup persists session)
- PairingService (8-char code, 5-min TTL, 5-attempt lockout, single-use, https check)
- POST /sites/pair, /sites/redeem, /sites/:id/confirm, GET /sites/:id
- PluginClient signs HMAC (timestamp+method+path+body-hash), token in Authorization
- ADR 0016; 58 api tests green
2026-08-15 23:10:19 +01:00
SinachPat 02f55b4543 docs: DevOps/infrastructure handoff brief
Mirror to GitHub / mirror (push) Canceled after 0s
2026-08-15 20:31:56 +01:00
SinachPat df2315087d feat: Sprint 1 remainder — Postgres store, Docker client, warm pool, sessions route
Mirror to GitHub / mirror (push) Canceled after 0s
- PostgresUserStore (Queryable) + migration + env-gated wiring
- DockerodeClient behind injected DockerEngine
- ImageManager, WarmPool, Dockerfile.wordpress, docker-compose
- POST /sessions spins up sandbox (503 when unconfigured)
- ADRs 0014-0015; 68 unit tests green
2026-08-15 20:18:34 +01:00
SinachPat 97f31315bd docs: ADRs 0011-0013 for Sprint 1 stack, user store, and Docker boundary
Mirror to GitHub / mirror (push) Canceled after 0s
2026-08-15 20:07:44 +01:00
SinachPat a037b882e9 feat: Sprint 1 — Playwright e2e + sandbox orchestration (TDD)
Mirror to GitHub / mirror (push) Canceled after 0s
- e2e: Playwright chat-flow (sign up -> chat) with api/web webServer harness
- api: sandbox services subset/media-proxy/manifest/gc + DockerClient contract
- api: SandboxManager orchestrator (fake-Docker tested); /health route
- 53 unit tests green; Playwright e2e green
2026-08-15 20:01:58 +01:00
SinachPat 9733ba6e3c feat: Sprint 1 slice 1 — API signup + web signup/chat shell
Mirror to GitHub / mirror (push) Canceled after 0s
- api: Fastify POST /auth/signup (validation, scrypt hash, in-memory store)
- web: React+Vite SignUp form and chat shell with dev proxy
- 35 tests green (api 5, web 2, e2e 28)
2026-08-15 19:53:13 +01:00
102 changed files with 6070 additions and 19 deletions
+8 -1
View File
@@ -2,7 +2,9 @@
# API
PORT=3000
DATABASE_URL=postgres://wursor:wursor@localhost:5432/wursor
# Set DATABASE_URL only when Postgres is running (see infrastructure/DEVOPS.md).
# Leave empty for the in-memory dev store.
DATABASE_URL=
REDIS_URL=redis://localhost:6379
# Auth
@@ -18,4 +20,9 @@ LLM_FALLBACK_PROVIDER=
# Sandbox
DOCKER_HOST=
# Set to 1 to enable sandbox spin-up via Docker (leave unset to return 503 on /sessions).
WUR_ENABLE_SANDBOX=
WUR_IMAGE=wursor-base:latest
WUR_WEB_PORT=8080
PREVIEW_BASE_URL=http://localhost:8080
WARM_POOL_HOT_SPARES=2
View File
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { runAgent } from '../../src/agents/agent-loop.ts';
import type { LlmClient, LlmResponse } from '../../src/agents/llm-client.ts';
import type { ToolExecutor } from '../../src/agents/tool-executor.ts';
class ScriptedLlm implements LlmClient {
private i = 0;
constructor(private readonly responses: LlmResponse[]) {}
async complete(): Promise<LlmResponse> {
const response = this.responses[Math.min(this.i, this.responses.length - 1)];
this.i += 1;
return response as LlmResponse;
}
}
class RecordingExecutor implements ToolExecutor {
calls: Array<{ name: string; args: Record<string, string> }> = [];
async execute(name: string, args: Record<string, string>) {
this.calls.push({ name, args });
return { result: `ok:${name}` };
}
}
function toolCall(id: string, name: string, args: Record<string, string>) {
return { id, type: 'function', function: { name, arguments: JSON.stringify(args) } };
}
function callResp(calls: ReturnType<typeof toolCall>[]): LlmResponse {
return { choices: [{ message: { role: 'assistant', content: null, tool_calls: calls } }] };
}
function doneResp(content: string): LlmResponse {
return { choices: [{ message: { role: 'assistant', content, tool_calls: undefined } }] };
}
describe('runAgent', () => {
it('executes multiple tool calls across rounds until the agent finishes', async () => {
const llm = new ScriptedLlm([
callResp([toolCall('1', 'read_page', { page: 'home' })]),
callResp([toolCall('2', 'update_post', { page: 'home', title: 'New' })]),
doneResp('Done'),
]);
const executor = new RecordingExecutor();
const result = await runAgent(llm, executor, { systemPrompt: 's', userPrompt: 'u', maxRounds: 5 });
expect(executor.calls).toEqual([
{ name: 'read_page', args: { page: 'home' } },
{ name: 'update_post', args: { page: 'home', title: 'New' } },
]);
expect(result.toolCalls).toBe(2);
expect(result.finalContent).toBe('Done');
expect(result.halted).toBe(false);
});
it('feeds tool results back to the model as tool messages', async () => {
const llm = new ScriptedLlm([
callResp([toolCall('1', 'read_page', { page: 'home' })]),
doneResp('final'),
]);
const executor = new RecordingExecutor();
await runAgent(llm, executor, { systemPrompt: 's', userPrompt: 'u', maxRounds: 5 });
expect(executor.calls).toHaveLength(1);
});
it('halts when the round budget is exhausted', async () => {
const llm = new ScriptedLlm([callResp([toolCall('1', 'read_page', { page: 'home' })])]);
const executor = new RecordingExecutor();
const result = await runAgent(llm, executor, { systemPrompt: 's', userPrompt: 'u', maxRounds: 1 });
expect(result.halted).toBe(true);
expect(result.toolCalls).toBe(1);
});
});
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { CircuitBreaker } from '../../src/agents/circuit-breaker.ts';
describe('CircuitBreaker', () => {
it('halts after two consecutive failures', () => {
const breaker = new CircuitBreaker({ maxConsecutiveFailures: 2 });
breaker.recordFailure();
breaker.recordFailure();
expect(breaker.shouldHalt()).toBe(true);
});
it('does not halt after a single failure', () => {
const breaker = new CircuitBreaker({ maxConsecutiveFailures: 2 });
breaker.recordFailure();
expect(breaker.shouldHalt()).toBe(false);
});
it('resets the failure count after a success', () => {
const breaker = new CircuitBreaker({ maxConsecutiveFailures: 2 });
breaker.recordFailure();
breaker.recordSuccess();
breaker.recordFailure();
expect(breaker.shouldHalt()).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { ALLOWED_TOOL_NAMES, generateToolSchemas } from '../../src/agents/tool-schemas.ts';
describe('generateToolSchemas', () => {
it('returns a non-empty tool set', () => {
expect(generateToolSchemas().length).toBeGreaterThan(0);
});
it('only exposes allowlisted semantic tools', () => {
const names = generateToolSchemas().map((s) => s.function.name);
expect(names.length).toBeGreaterThan(0);
for (const name of names) {
expect(ALLOWED_TOOL_NAMES).toContain(name);
}
});
it('exposes no eval, config, raw SQL, rm, or arbitrary plugin install surface', () => {
const blob = generateToolSchemas()
.map((s) => JSON.stringify(s))
.join(' ');
expect(blob).not.toMatch(/wp eval|wp config|DROP TABLE|DELETE FROM|\brm\b|plugin install http/i);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { buildApp } from '../src/app.ts';
describe('POST /auth/signup', () => {
it('creates a user and returns a session token', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: 'a@example.com', password: 'password123' },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.user.email).toBe('a@example.com');
expect(body.user.id).toBeTruthy();
expect(body.sessionToken).toBeTruthy();
});
it('rejects an invalid email with 400', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: 'not-an-email', password: 'password123' },
});
expect(res.statusCode).toBe(400);
});
it('rejects a short password with 400', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: 'a@example.com', password: 'short' },
});
expect(res.statusCode).toBe(400);
});
it('rejects a duplicate email with 409', async () => {
const app = await buildApp();
const payload = { email: 'a@example.com', password: 'password123' };
await app.inject({ method: 'POST', url: '/auth/signup', payload });
const res = await app.inject({ method: 'POST', url: '/auth/signup', payload });
expect(res.statusCode).toBe(409);
});
it('does not return the password hash', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: 'a@example.com', password: 'password123' },
});
expect(res.json().user.passwordHash).toBeUndefined();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { generateHmacSecret, generatePairingCode, generateToken, isHttpsUrl } from '../../src/lib/codes.ts';
describe('generatePairingCode', () => {
it('returns an 8-char A-Z0-9 code', () => {
expect(generatePairingCode()).toMatch(/^[A-Z0-9]{8}$/);
});
it('returns distinct codes across calls', () => {
const seen = new Set(Array.from({ length: 50 }, () => generatePairingCode()));
expect(seen.size).toBeGreaterThan(40);
});
});
describe('generateToken / generateHmacSecret', () => {
it('returns a 32-byte base64url token', () => {
const token = generateToken();
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(token).not.toEqual(generateToken());
});
it('returns a distinct hmac secret', () => {
expect(generateHmacSecret()).not.toEqual(generateHmacSecret());
});
});
describe('isHttpsUrl', () => {
it('accepts https URLs', () => {
expect(isHttpsUrl('https://example.com')).toBe(true);
});
it('rejects http and malformed URLs', () => {
expect(isHttpsUrl('http://example.com')).toBe(false);
expect(isHttpsUrl('not-a-url')).toBe(false);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../../src/app.ts';
import type { LlmClient, LlmResponse } from '../../src/agents/llm-client.ts';
import type { ToolExecutor } from '../../src/agents/tool-executor.ts';
class FakeLlm implements LlmClient {
async complete(): Promise<LlmResponse> {
return { choices: [{ message: { role: 'assistant', content: 'Done', tool_calls: undefined } }] };
}
}
class FakeExecutor implements ToolExecutor {
async execute() {
return { result: 'ok' };
}
}
async function signup(app: FastifyInstance): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: `a-${Date.now()}@example.com`, password: 'password123' },
});
return res.json().sessionToken as string;
}
describe('POST /chat', () => {
it('requires a session', async () => {
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
const res = await app.inject({ method: 'POST', url: '/chat', payload: { message: 'hi' } });
expect(res.statusCode).toBe(401);
});
it('runs the agent and returns the reply', async () => {
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
const token = await signup(app);
const res = await app.inject({
method: 'POST',
url: '/chat',
headers: { authorization: `Bearer ${token}` },
payload: { message: 'Change the heading' },
});
expect(res.statusCode).toBe(200);
expect(res.json().reply).toBe('Done');
});
it('returns 503 when the agent is not configured', async () => {
const app = await buildApp();
const token = await signup(app);
const res = await app.inject({
method: 'POST',
url: '/chat',
headers: { authorization: `Bearer ${token}` },
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(503);
});
it('returns 400 for an empty message', async () => {
const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() });
const token = await signup(app);
const res = await app.inject({
method: 'POST',
url: '/chat',
headers: { authorization: `Bearer ${token}` },
payload: { message: ' ' },
});
expect(res.statusCode).toBe(400);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { buildApp } from '../../src/app.ts';
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
class FakeDocker implements DockerClient {
async createSandbox(): Promise<SandboxInfo> {
return { id: 'sb-1', status: 'running' };
}
async destroySandbox(): Promise<void> {}
async status(): Promise<SandboxInfo | undefined> {
return undefined;
}
}
describe('POST /sessions', () => {
it('spins up a sandbox and returns a preview URL', async () => {
const manager = new SandboxManager(new FakeDocker(), {
image: 'wursor-base:latest',
previewBaseUrl: 'https://preview.wursor.dev',
});
const app = await buildApp({ sandboxManager: manager });
const res = await app.inject({ method: 'POST', url: '/sessions' });
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.sandboxId).toBe('sb-1');
expect(body.previewUrl).toBe('https://preview.wursor.dev/sb-1');
expect(body.sessionId).toBeTruthy();
});
it('returns 503 when no sandbox manager is configured', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/sessions' });
expect(res.statusCode).toBe(503);
expect(res.json().error).toBe('sandbox_not_configured');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../../src/app.ts';
async function signup(app: FastifyInstance): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/auth/signup',
payload: { email: `a-${Date.now()}@example.com`, password: 'password123' },
});
return res.json().sessionToken as string;
}
describe('sites pairing flow', () => {
it('requires a session to pair', async () => {
const app = await buildApp();
const res = await app.inject({ method: 'POST', url: '/sites/pair' });
expect(res.statusCode).toBe(401);
});
it('redeems a code once, binds site_url, and returns tokens', async () => {
const app = await buildApp();
const token = await signup(app);
const pair = await app.inject({
method: 'POST',
url: '/sites/pair',
headers: { authorization: `Bearer ${token}` },
});
expect(pair.statusCode).toBe(201);
const { code } = pair.json();
const redeem = await app.inject({
method: 'POST',
url: '/sites/redeem',
payload: { code, siteUrl: 'https://example.com' },
});
expect(redeem.statusCode).toBe(201);
const body = redeem.json();
expect(body.siteId).toBeTruthy();
expect(body.readToken).toBeTruthy();
expect(body.deployToken).toBeTruthy();
expect(body.hmacSecret).toBeTruthy();
const second = await app.inject({
method: 'POST',
url: '/sites/redeem',
payload: { code, siteUrl: 'https://example.com' },
});
expect(second.statusCode).toBe(400);
});
it('does not mark the site connected until confirmed', async () => {
const app = await buildApp();
const token = await signup(app);
const pair = await app.inject({
method: 'POST',
url: '/sites/pair',
headers: { authorization: `Bearer ${token}` },
});
const { code } = pair.json();
const redeem = await app.inject({
method: 'POST',
url: '/sites/redeem',
payload: { code, siteUrl: 'https://example.com' },
});
const { siteId } = redeem.json();
const before = await app.inject({
method: 'GET',
url: `/sites/${siteId}`,
headers: { authorization: `Bearer ${token}` },
});
expect(before.json().connected).toBe(false);
const confirm = await app.inject({
method: 'POST',
url: `/sites/${siteId}/confirm`,
headers: { authorization: `Bearer ${token}` },
});
expect(confirm.statusCode).toBe(200);
const after = await app.inject({
method: 'GET',
url: `/sites/${siteId}`,
headers: { authorization: `Bearer ${token}` },
});
expect(after.json().connected).toBe(true);
});
});
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { DockerodeClient, type ContainerLike, type DockerEngine } from '../../src/sandbox/dockerode-client.ts';
class FakeEngine implements DockerEngine {
createdImages: string[] = [];
names: string[] = [];
removed: string[] = [];
constructor(
private readonly opts: { running?: boolean; failInspect?: boolean } = {},
) {}
private container(): ContainerLike {
return {
start: async () => {},
inspect: async () => {
if (this.opts.failInspect) {
throw new Error('not found');
}
return { Id: 'id-1', State: { Running: this.opts.running ?? true } };
},
remove: async () => {
this.removed.push('id-1');
},
};
}
async createContainer(spec: { Image: string; name: string }): Promise<ContainerLike> {
this.createdImages.push(spec.Image);
this.names.push(spec.name);
return this.container();
}
getContainer(_id: string): ContainerLike {
return this.container();
}
}
describe('DockerodeClient', () => {
it('spins up a sandbox container from an image and reports it running', async () => {
const engine = new FakeEngine();
const client = new DockerodeClient(engine);
const info = await client.createSandbox('wursor-base:latest');
expect(info).toEqual({ id: 'id-1', status: 'running' });
expect(engine.createdImages).toEqual(['wursor-base:latest']);
expect(engine.names[0]?.startsWith('wursor-')).toBe(true);
});
it('reports destroyed when the container is not running after start', async () => {
const engine = new FakeEngine({ running: false });
const client = new DockerodeClient(engine);
expect(await client.createSandbox('wursor-base:latest')).toEqual({ id: 'id-1', status: 'destroyed' });
});
it('destroys a sandbox container', async () => {
const engine = new FakeEngine();
const client = new DockerodeClient(engine);
await client.destroySandbox('id-1');
expect(engine.removed).toEqual(['id-1']);
});
it('returns undefined status when the container is gone', async () => {
const engine = new FakeEngine({ failInspect: true });
const client = new DockerodeClient(engine);
expect(await client.status('id-1')).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { decideGc } from '../../src/sandbox/gc.ts';
const opts = { idleMs: 15 * 60 * 1000, hardMs: 24 * 60 * 60 * 1000 };
const now = 1_000_000;
describe('decideGc', () => {
it('pauses a running sandbox after the idle threshold', () => {
expect(decideGc({ status: 'running', lastActiveAt: now - opts.idleMs - 1, createdAt: 0 }, now, opts)).toBe('pause');
});
it('keeps an active running sandbox', () => {
expect(decideGc({ status: 'running', lastActiveAt: now - 1000, createdAt: 0 }, now, opts)).toBe('keep');
});
it('destroys any sandbox past the hard timeout regardless of activity', () => {
expect(decideGc({ status: 'running', lastActiveAt: now - 1000, createdAt: now - opts.hardMs - 1 }, now, opts)).toBe(
'destroy',
);
});
it('does not re-pause an already paused sandbox', () => {
expect(decideGc({ status: 'paused', lastActiveAt: now - opts.idleMs - 1, createdAt: 0 }, now, opts)).toBe('keep');
});
it('keeps a destroyed sandbox', () => {
expect(decideGc({ status: 'destroyed', lastActiveAt: 0, createdAt: 0 }, now, opts)).toBe('keep');
});
});
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { ImageManager } from '../../src/sandbox/image-manager.ts';
describe('ImageManager', () => {
const manager = new ImageManager({ baseImage: 'wursor-base:latest', webPort: 8080 });
it('returns the pre-baked image ref', () => {
expect(manager.imageRef()).toBe('wursor-base:latest');
});
it('produces a container spec with the web port and management label', () => {
expect(manager.containerSpec('wursor-abc')).toEqual({
image: 'wursor-base:latest',
name: 'wursor-abc',
ports: [{ container: 80, host: 8080 }],
labels: { 'wursor.managed': 'true' },
});
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { diffManifest } from '../../src/sandbox/manifest.ts';
describe('diffManifest', () => {
it('detects added, changed, and removed paths', () => {
const before = {
'/theme/style.css': 'a1',
'/theme/theme.json': 'b2',
'/theme/old.css': 'c3',
};
const after = {
'/theme/style.css': 'a1',
'/theme/theme.json': 'b2-new',
'/theme/new.css': 'd4',
};
expect(diffManifest(before, after)).toEqual({
added: ['/theme/new.css'],
changed: ['/theme/theme.json'],
removed: ['/theme/old.css'],
});
});
it('reports empty arrays when nothing changed', () => {
const manifest = { '/a.css': 'x' };
expect(diffManifest(manifest, { ...manifest })).toEqual({ added: [], changed: [], removed: [] });
});
});
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { mediaProxyTarget, resolveMediaPath, stageReplacement } from '../../src/sandbox/media-proxy.ts';
describe('media proxy', () => {
it('proxies an upload to the origin', () => {
expect(mediaProxyTarget('https://example.com', '/wp-content/uploads/2024/hero.jpg')).toBe(
'https://example.com/wp-content/uploads/2024/hero.jpg',
);
});
it('serves a staged replacement locally instead of proxying', () => {
const staged = new Set(['/wp-content/uploads/2024/hero.jpg']);
expect(resolveMediaPath('https://example.com', '/wp-content/uploads/2024/hero.jpg', staged)).toBe(
'/wp-content/uploads/2024/hero.jpg',
);
expect(resolveMediaPath('https://example.com', '/wp-content/uploads/2024/other.jpg', staged)).toBe(
'https://example.com/wp-content/uploads/2024/other.jpg',
);
});
it('copies a file only when it is replaced', () => {
expect(stageReplacement('https://example.com', '/wp-content/uploads/2024/hero.jpg', 12)).toEqual({
copiedPaths: ['/wp-content/uploads/2024/hero.jpg'],
});
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { exportDbSubset } from '../../src/sandbox/subset.ts';
import type { SiteExport } from '../../src/sandbox/types.ts';
const dump = (): SiteExport => ({
origin: 'https://example.com',
tables: {
wp_posts: [{ ID: 1, post_title: 'Home' }],
wp_postmeta: [{ post_id: 1, meta_key: '_edit_lock', meta_value: '1' }],
wp_options: [
{ option_name: 'blogname', option_value: 'Biz' },
{ option_name: 'woocommerce_stripe_secret_key', option_value: 'sk_live_xxx' },
{ option_name: 'smtp_pass', option_value: 'secret' },
],
wp_terms: [{ term_id: 1, name: 'Menu' }],
wp_term_taxonomy: [{ term_taxonomy_id: 1 }],
wp_term_relationships: [{ object_id: 1 }],
wp_wc_orders: [{ id: 99, total: '40.00' }],
wp_comments: [{ comment_ID: 1, comment_content: 'hi' }],
},
uploads: [],
});
describe('exportDbSubset', () => {
it('keeps posts, postmeta, and options for a content playbook', () => {
expect(exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).tables).toEqual(
expect.arrayContaining(['wp_posts', 'wp_postmeta', 'wp_options']),
);
});
it('drops Woo orders and comments from a content-edit slice', () => {
const tables = exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).tables;
expect(tables).not.toContain('wp_wc_orders');
expect(tables).not.toContain('wp_comments');
});
it('adds taxonomy tables for a design playbook', () => {
const tables = exportDbSubset(dump(), { playbook: 'design', postIds: [1] }).tables;
expect(tables).toEqual(expect.arrayContaining(['wp_terms', 'wp_term_taxonomy', 'wp_term_relationships']));
});
it('limits a plugin playbook to options only', () => {
expect(exportDbSubset(dump(), { playbook: 'plugin', postIds: [1] }).tables).toEqual(['wp_options']);
});
it('redacts option names ending in _key, _secret, or smtp_pass', () => {
const options = exportDbSubset(dump(), { playbook: 'content', postIds: [1] }).options;
expect(options).toContain('blogname');
expect(options.some((name) => /(_key|_secret|smtp_pass)$/.test(name))).toBe(false);
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { PairingService } from '../../src/services/pairing-service.ts';
describe('PairingService', () => {
it('issues an 8-char code with a 5-minute expiry', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code, expiresAt } = svc.issue('acct-1');
expect(code).toMatch(/^[A-Z0-9]{8}$/);
expect(expiresAt).toBe(1_000_000 + 5 * 60 * 1000);
});
it('redeems a valid code once and returns distinct scoped tokens', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
const result = svc.redeem(code, 'https://example.com');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.accountId).toBe('acct-1');
expect(result.readToken).toBeTruthy();
expect(result.deployToken).toBeTruthy();
expect(result.hmacSecret).toBeTruthy();
expect(result.readToken).not.toBe(result.deployToken);
}
});
it('rejects a second redeem of the same code', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
svc.redeem(code, 'https://example.com');
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'consumed' });
});
it('expires a code after five minutes', () => {
let t = 1_000_000;
const svc = new PairingService({ now: () => t });
const { code } = svc.issue('acct-1');
t += 5 * 60 * 1000 + 1;
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'expired' });
});
it('locks a code after five failed attempts', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
for (let i = 0; i < 5; i += 1) {
svc.redeem(code, 'http://insecure.example.com');
}
expect(svc.redeem(code, 'https://example.com')).toEqual({ ok: false, error: 'locked' });
});
it('rejects a non-https site URL as an invalid attempt', () => {
const svc = new PairingService({ now: () => 1_000_000 });
const { code } = svc.issue('acct-1');
expect(svc.redeem(code, 'http://insecure.example.com')).toEqual({ ok: false, error: 'invalid' });
});
it('returns invalid for an unknown code', () => {
expect(new PairingService().redeem('NOPE0000', 'https://example.com')).toEqual({ ok: false, error: 'invalid' });
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createHash, createHmac } from 'node:crypto';
import { PluginClient } from '../../src/services/plugin-client.ts';
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const creds = { siteUrl: 'https://example.com', readToken: 'r-token', hmacSecret: 'h-secret' };
function expectedSignature(secret: string, timestamp: string, method: string, path: string, body: string): string {
const canonical = `${timestamp}\n${method}\n${path}\n${createHash('sha256').update(body).digest('hex')}`;
return createHmac('sha256', secret).update(canonical).digest('hex');
}
describe('PluginClient', () => {
it('signs requests and sends the token in Authorization, never the URL', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ theme: 'twentytwentyfour' }) });
const client = new PluginClient(creds);
await client.get('/site-info');
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
expect(url).toBe('https://example.com/wp-json/wursor/v1/site-info');
expect(String(url)).not.toContain('r-token');
expect(init.headers.Authorization).toBe('Bearer r-token');
expect(init.headers['X-Wursor-Timestamp']).toBeTruthy();
expect(init.headers['X-Wursor-Signature']).toMatch(/^[0-9a-f]{64}$/);
});
it('computes the HMAC over timestamp + method + path + body hash', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
const client = new PluginClient(creds);
await client.post('/files', { a: 1 });
const [, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string> }];
const ts = init.headers['X-Wursor-Timestamp'];
expect(init.headers['X-Wursor-Signature']).toBe(expectedSignature('h-secret', ts, 'POST', '/wursor/v1/files', '{"a":1}'));
});
it('maps a 401 to Authentication failed', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401 });
const client = new PluginClient(creds);
await expect(client.get('/site-info')).rejects.toThrow('Authentication failed');
});
it('refuses to construct a client with an http:// site URL', () => {
expect(() => new PluginClient({ ...creds, siteUrl: 'http://example.com' })).toThrow();
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { PostgresUserStore } from '../../src/services/postgres-user-store.ts';
import type { Queryable } from '../../src/services/postgres-user-store.ts';
class FakeDb implements Queryable {
calls: Array<{ text: string; values?: unknown[] }> = [];
rows: unknown[] = [];
async query(text: string, values?: unknown[]) {
this.calls.push({ text, values });
return { rows: this.rows };
}
}
describe('PostgresUserStore', () => {
it('returns a user when the email exists', async () => {
const db = new FakeDb();
db.rows = [{ id: 'u1', email: 'a@example.com', password_hash: 'hash' }];
const store = new PostgresUserStore(db);
const user = await store.findByEmail('a@example.com');
expect(user).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
expect(db.calls[0]?.text).toContain('SELECT');
});
it('returns undefined when the email is absent', async () => {
const db = new FakeDb();
db.rows = [];
const store = new PostgresUserStore(db);
expect(await store.findByEmail('nope@example.com')).toBeUndefined();
});
it('inserts a new user and returns it', async () => {
const db = new FakeDb();
const store = new PostgresUserStore(db);
const created = await store.create({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
expect(created).toEqual({ id: 'u1', email: 'a@example.com', passwordHash: 'hash' });
const insert = db.calls[0];
expect(insert?.text).toContain('INSERT INTO users');
expect(insert?.values).toEqual(['u1', 'a@example.com', 'hash']);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { SandboxManager } from '../../src/services/sandbox-manager.ts';
import type { DockerClient } from '../../src/sandbox/docker-client.ts';
class FakeDocker implements DockerClient {
created: string[] = [];
destroyed: string[] = [];
async createSandbox(image: string) {
this.created.push(image);
return { id: 'sb-1', status: 'running' as const };
}
async destroySandbox(id: string) {
this.destroyed.push(id);
}
async status(id: string) {
return this.destroyed.includes(id) ? undefined : { id, status: 'running' as const };
}
}
describe('SandboxManager', () => {
it('spins up a sandbox and returns a preview URL', async () => {
const docker = new FakeDocker();
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
const result = await manager.start();
expect(result).toEqual({ sandboxId: 'sb-1', previewUrl: 'https://preview.wursor.dev/sb-1' });
expect(docker.created).toEqual(['wursor-base:latest']);
});
it('honors an explicit image override', async () => {
const docker = new FakeDocker();
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
await manager.start('wursor-base:canary');
expect(docker.created).toEqual(['wursor-base:canary']);
});
it('destroys a sandbox', async () => {
const docker = new FakeDocker();
const manager = new SandboxManager(docker, { image: 'wursor-base:latest', previewBaseUrl: 'https://preview.wursor.dev' });
await manager.destroy('sb-1');
expect(docker.destroyed).toEqual(['sb-1']);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { WarmPool, computeSpares } from '../../src/services/warm-pool.ts';
import type { DockerClient, SandboxInfo } from '../../src/sandbox/docker-client.ts';
class FakeDocker implements DockerClient {
created: string[] = [];
async createSandbox(image: string): Promise<SandboxInfo> {
this.created.push(image);
return { id: `sb-${this.created.length}`, status: 'running' };
}
async destroySandbox(): Promise<void> {}
async status(): Promise<SandboxInfo | undefined> {
return undefined;
}
}
describe('computeSpares', () => {
it('returns the shortfall', () => {
expect(computeSpares(1, 3)).toBe(2);
});
it('never returns a negative number', () => {
expect(computeSpares(5, 2)).toBe(0);
});
});
describe('WarmPool', () => {
it('tops up the pool to the target hot-spare count', async () => {
const docker = new FakeDocker();
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
const result = await pool.topUp(0);
expect(result.created).toBe(2);
expect(docker.created).toEqual(['wursor-base:latest', 'wursor-base:latest']);
});
it('does nothing when the pool is already full', async () => {
const docker = new FakeDocker();
const pool = new WarmPool(docker, { image: 'wursor-base:latest', hotSpares: 2 });
const result = await pool.topUp(3);
expect(result.created).toBe(0);
expect(docker.created).toEqual([]);
});
});
+6
View File
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+18 -1
View File
@@ -2,5 +2,22 @@
"name": "@wursor/api",
"private": true,
"type": "module",
"scripts": {}
"scripts": {
"test": "vitest run",
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts"
},
"dependencies": {
"dockerode": "^5.0.1",
"fastify": "^5.2.0",
"pg": "^8.23.0"
},
"devDependencies": {
"@types/dockerode": "^4.0.1",
"@types/node": "^22.10.0",
"@types/pg": "^8.21.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
View File
+48
View File
@@ -0,0 +1,48 @@
import type { LlmClient, LlmMessage } from './llm-client.ts';
import type { ToolExecutor } from './tool-executor.ts';
export type RunAgentOptions = {
systemPrompt: string;
userPrompt: string;
maxRounds: number;
};
export type RunAgentResult = {
rounds: number;
toolCalls: number;
finalContent: string | null;
halted: boolean;
};
export async function runAgent(
client: LlmClient,
executor: ToolExecutor,
opts: RunAgentOptions,
): Promise<RunAgentResult> {
const messages: LlmMessage[] = [
{ role: 'system', content: opts.systemPrompt },
{ role: 'user', content: opts.userPrompt },
];
let toolCalls = 0;
for (let round = 0; round < opts.maxRounds; round += 1) {
const response = await client.complete(messages);
const message = response.choices[0]?.message;
const calls = message?.tool_calls ?? [];
if (calls.length === 0) {
return { rounds: round + 1, toolCalls, finalContent: message?.content ?? null, halted: false };
}
messages.push({ role: 'assistant', content: message?.content ?? null, tool_calls: calls });
for (const call of calls) {
const args = JSON.parse(call.function.arguments) as Record<string, string>;
const result = await executor.execute(call.function.name, args);
toolCalls += 1;
messages.push({ role: 'tool', content: result.result, tool_call_id: call.id, name: call.function.name });
}
}
return { rounds: opts.maxRounds, toolCalls, finalContent: null, halted: true };
}
+21
View File
@@ -0,0 +1,21 @@
export type CircuitBreakerOptions = {
maxConsecutiveFailures: number;
};
export class CircuitBreaker {
private failures = 0;
constructor(private readonly opts: CircuitBreakerOptions) {}
recordFailure(): void {
this.failures += 1;
}
recordSuccess(): void {
this.failures = 0;
}
shouldHalt(): boolean {
return this.failures >= this.opts.maxConsecutiveFailures;
}
}
+23
View File
@@ -0,0 +1,23 @@
export type LlmToolCall = {
id: string;
type: 'function';
function: { name: string; arguments: string };
};
export type LlmMessage = {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string | null;
tool_calls?: LlmToolCall[];
tool_call_id?: string;
name?: string;
};
export type LlmResponse = {
choices: Array<{
message: { role: 'assistant'; content: string | null; tool_calls?: LlmToolCall[] };
}>;
};
export interface LlmClient {
complete(messages: LlmMessage[]): Promise<LlmResponse>;
}
+32
View File
@@ -0,0 +1,32 @@
import { generateToolSchemas } from './tool-schemas.ts';
import type { LlmClient, LlmMessage, LlmResponse } from './llm-client.ts';
export type OpenRouterOptions = {
apiKey: string;
model?: string;
};
export class OpenRouterLlmClient implements LlmClient {
constructor(private readonly opts: OpenRouterOptions) {}
async complete(messages: LlmMessage[]): Promise<LlmResponse> {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.opts.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://wursor.dev',
'X-Title': 'Wursor',
},
body: JSON.stringify({
model: this.opts.model ?? 'x-ai/grok-4.6',
messages,
tools: generateToolSchemas(),
}),
});
if (!res.ok) {
throw new Error(`LLM HTTP ${res.status}`);
}
return (await res.json()) as LlmResponse;
}
}
+5
View File
@@ -0,0 +1,5 @@
export const AGENT_SYSTEM_PROMPT =
'You are an expert WordPress agent working inside a sandboxed copy of the user\u2019s site. ' +
'Use the provided tools to complete the request. Work step by step: read what you need, make the ' +
'changes, and verify. Never touch anything outside the sandbox. When finished, reply with a one-line ' +
'summary of what you changed.';
+7
View File
@@ -0,0 +1,7 @@
export type ToolResult = {
result: string;
};
export interface ToolExecutor {
execute(name: string, args: Record<string, string>): Promise<ToolResult>;
}
+97
View File
@@ -0,0 +1,97 @@
export type ToolSchema = {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export const ALLOWED_TOOL_NAMES = [
'read_page',
'update_post',
'update_option',
'create_page',
'update_theme_json',
] as const;
export type AllowedToolName = (typeof ALLOWED_TOOL_NAMES)[number];
const TOOLS: ToolSchema[] = [
{
type: 'function',
function: {
name: 'read_page',
description: 'Read the raw content and title of a page by its slug.',
parameters: {
type: 'object',
properties: { page: { type: 'string', description: 'Page slug' } },
required: ['page'],
},
},
},
{
type: 'function',
function: {
name: 'update_post',
description: 'Update the title and/or content of an existing page or post.',
parameters: {
type: 'object',
properties: {
page: { type: 'string', description: 'Page slug' },
title: { type: 'string' },
content: { type: 'string' },
},
required: ['page'],
},
},
},
{
type: 'function',
function: {
name: 'update_option',
description: 'Update a WordPress option such as blogname or blogdescription.',
parameters: {
type: 'object',
properties: {
key: { type: 'string' },
value: { type: 'string' },
},
required: ['key', 'value'],
},
},
},
{
type: 'function',
function: {
name: 'create_page',
description: 'Create a new page with a title and content.',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' },
},
required: ['title', 'content'],
},
},
},
{
type: 'function',
function: {
name: 'update_theme_json',
description: 'Apply a JSON patch to theme.json (colors, fonts, layout).',
parameters: {
type: 'object',
properties: {
patch: { type: 'string', description: 'JSON patch object as a string' },
},
required: ['patch'],
},
},
},
];
export function generateToolSchemas(): ToolSchema[] {
return TOOLS;
}
+90
View File
@@ -0,0 +1,90 @@
import type { ToolExecutor, ToolResult } from './tool-executor.ts';
export type WpRestCredentials = {
baseUrl: string;
username: string;
appPassword: string;
};
function basicAuth(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
export class WpRestExecutor implements ToolExecutor {
private readonly base: string;
private readonly auth: string;
constructor(private readonly creds: WpRestCredentials) {
this.base = creds.baseUrl.replace(/\/$/, '');
this.auth = basicAuth(creds.username, creds.appPassword);
}
async execute(name: string, args: Record<string, string>): Promise<ToolResult> {
switch (name) {
case 'read_page':
return this.readPage(args.page);
case 'update_post':
return this.updatePost(args);
case 'create_page':
return this.createPage(args);
case 'update_option':
return this.updateOption(args);
default:
throw new Error(`unsupported tool: ${name}`);
}
}
private async json(url: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(url, { ...init, headers: { Authorization: this.auth, ...(init?.headers ?? {}) } });
if (!res.ok) {
throw new Error(`WP HTTP ${res.status} ${url}`);
}
return res.json();
}
private async readPage(page: string): Promise<ToolResult> {
const data = await this.json(`${this.base}/wp-json/wp/v2/pages?slug=${encodeURIComponent(page)}&_fields=id,title,content`);
return { result: JSON.stringify(data) };
}
private async pageIdBySlug(slug: string): Promise<number | undefined> {
const data = (await this.json(
`${this.base}/wp-json/wp/v2/pages?slug=${encodeURIComponent(slug)}&_fields=id`,
)) as Array<{ id: number }>;
return data[0]?.id;
}
private async updatePost(args: Record<string, string>): Promise<ToolResult> {
const id = await this.pageIdBySlug(args.page ?? '');
if (id === undefined) {
return { result: `no page with slug ${args.page}` };
}
const body: Record<string, string> = {};
if (args.title !== undefined) body.title = args.title;
if (args.content !== undefined) body.content = args.content;
const updated = await this.json(`${this.base}/wp-json/wp/v2/pages/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { result: JSON.stringify(updated) };
}
private async createPage(args: Record<string, string>): Promise<ToolResult> {
const created = await this.json(`${this.base}/wp-json/wp/v2/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: args.title, content: args.content, status: 'publish' }),
});
return { result: JSON.stringify(created) };
}
private async updateOption(args: Record<string, string>): Promise<ToolResult> {
const updated = await this.json(`${this.base}/wp-json/wp/v2/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [args.key ?? '']: args.value }),
});
return { result: JSON.stringify(updated) };
}
}
+37
View File
@@ -0,0 +1,37 @@
import Fastify from 'fastify';
import { authRoutes } from './routes/auth.ts';
import { chatRoutes } from './routes/chat.ts';
import { sessionRoutes } from './routes/sessions.ts';
import { siteRoutes } from './routes/sites.ts';
import type { LlmClient } from './agents/llm-client.ts';
import type { ToolExecutor } from './agents/tool-executor.ts';
import { PairingService } from './services/pairing-service.ts';
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
import { InMemoryUserStore, type UserStore } from './services/user-store.ts';
import type { SandboxManager } from './services/sandbox-manager.ts';
export type BuildAppOptions = {
userStore?: UserStore;
sessionStore?: SessionStore;
siteStore?: SiteStore;
pairingService?: PairingService;
sandboxManager?: SandboxManager;
llmClient?: LlmClient;
toolExecutor?: ToolExecutor;
};
export async function buildApp(opts: BuildAppOptions = {}) {
const app = Fastify();
const userStore = opts.userStore ?? new InMemoryUserStore();
const sessionStore = opts.sessionStore ?? new InMemorySessionStore();
const siteStore = opts.siteStore ?? new InMemorySiteStore();
const pairingService = opts.pairingService ?? new PairingService();
app.get('/health', async () => ({ ok: true }));
await authRoutes(app, userStore, sessionStore);
await sessionRoutes(app, opts.sandboxManager);
await siteRoutes(app, { sessionStore, siteStore, pairingService });
await chatRoutes(app, { sessionStore, llmClient: opts.llmClient, toolExecutor: opts.toolExecutor });
return app;
}
+55
View File
@@ -0,0 +1,55 @@
import Docker from 'dockerode';
import { dirname, join } from 'node:path';
import { loadEnvFile } from 'node:process';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import { buildApp } from './app.ts';
import { OpenRouterLlmClient } from './agents/openrouter-client.ts';
import { WpRestExecutor } from './agents/wp-executor.ts';
import { DockerodeClient } from './sandbox/dockerode-client.ts';
import { ImageManager } from './sandbox/image-manager.ts';
import { PostgresUserStore } from './services/postgres-user-store.ts';
import { SandboxManager } from './services/sandbox-manager.ts';
import { InMemoryUserStore } from './services/user-store.ts';
try {
loadEnvFile(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env'));
} catch {
// no .env — rely on the ambient environment
}
const port = Number(process.env.PORT ?? 3000);
const userStore = process.env.DATABASE_URL
? new PostgresUserStore(new Pool({ connectionString: process.env.DATABASE_URL }))
: new InMemoryUserStore();
const imageManager = new ImageManager({
baseImage: process.env.WUR_IMAGE ?? 'wursor-base:latest',
webPort: Number(process.env.WUR_WEB_PORT ?? 8080),
});
const sandboxManager =
process.env.WUR_ENABLE_SANDBOX === '1'
? new SandboxManager(new DockerodeClient(new Docker()), {
image: imageManager.imageRef(),
previewBaseUrl: process.env.PREVIEW_BASE_URL ?? 'http://localhost:8080',
})
: undefined;
const llmClient = process.env.OPENROUTER_API_KEY
? new OpenRouterLlmClient({ apiKey: process.env.OPENROUTER_API_KEY, model: process.env.OPENROUTER_MODEL })
: undefined;
const toolExecutor =
process.env.WP_URL !== undefined && process.env.WP_USER !== undefined && process.env.WP_APP_PASSWORD !== undefined
? new WpRestExecutor({
baseUrl: process.env.WP_URL,
username: process.env.WP_USER,
appPassword: process.env.WP_APP_PASSWORD,
})
: undefined;
const app = await buildApp({ userStore, sandboxManager, llmClient, toolExecutor });
await app.listen({ port, host: '0.0.0.0' });
+28
View File
@@ -0,0 +1,28 @@
import { randomBytes, randomInt } from 'node:crypto';
const CODE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const CODE_LENGTH = 8;
export function generatePairingCode(): string {
let code = '';
for (let i = 0; i < CODE_LENGTH; i += 1) {
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
}
return code;
}
export function generateToken(): string {
return randomBytes(32).toString('base64url');
}
export function generateHmacSecret(): string {
return randomBytes(32).toString('base64url');
}
export function isHttpsUrl(value: string): boolean {
try {
return new URL(value).protocol === 'https:';
} catch {
return false;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 32).toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(':');
if (salt === undefined || hash === undefined) {
return false;
}
const candidate = scryptSync(password, salt, 32);
return timingSafeEqual(candidate, Buffer.from(hash, 'hex'));
}
export function newToken(): string {
return randomBytes(32).toString('hex');
}
export function newId(): string {
return randomUUID();
}
+25
View File
@@ -0,0 +1,25 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { SessionStore } from '../services/session-store.ts';
declare module 'fastify' {
interface FastifyRequest {
userId?: string;
}
}
export function requireSession(store: SessionStore) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
const header = request.headers.authorization;
const token = header !== undefined && header.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
if (token === undefined) {
await reply.status(401).send({ error: 'unauthorized' });
return;
}
const session = await store.findByToken(token);
if (session === undefined) {
await reply.status(401).send({ error: 'unauthorized' });
return;
}
request.userId = session.userId;
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { FastifyInstance } from 'fastify';
import { hashPassword, newId } from '../lib/crypto.ts';
import type { SessionStore } from '../services/session-store.ts';
import type { UserStore } from '../services/user-store.ts';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type SignupBody = {
email?: string;
password?: string;
};
export async function authRoutes(
app: FastifyInstance,
store: UserStore,
sessionStore: SessionStore,
): Promise<void> {
app.post('/auth/signup', async (request, reply) => {
const { email: rawEmail, password } = request.body as SignupBody;
const email = rawEmail?.trim().toLowerCase();
if (email === undefined || email === '' || !EMAIL_RE.test(email)) {
return reply.status(400).send({ error: 'invalid_email' });
}
if (password === undefined || password.length < 8) {
return reply.status(400).send({ error: 'weak_password' });
}
if ((await store.findByEmail(email)) !== undefined) {
return reply.status(409).send({ error: 'email_exists' });
}
const user = await store.create({ id: newId(), email, passwordHash: hashPassword(password) });
const session = await sessionStore.create(user.id);
return reply.status(201).send({ user: { id: user.id, email: user.email }, sessionToken: session.token });
});
}
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyInstance } from 'fastify';
import { runAgent } from '../agents/agent-loop.ts';
import { AGENT_SYSTEM_PROMPT } from '../agents/system-prompt.ts';
import type { LlmClient } from '../agents/llm-client.ts';
import type { ToolExecutor } from '../agents/tool-executor.ts';
import { requireSession } from '../middleware/auth.ts';
import type { SessionStore } from '../services/session-store.ts';
export type ChatRoutesDeps = {
sessionStore: SessionStore;
llmClient?: LlmClient;
toolExecutor?: ToolExecutor;
maxRounds?: number;
};
type ChatBody = {
message?: string;
};
export async function chatRoutes(app: FastifyInstance, deps: ChatRoutesDeps): Promise<void> {
app.post('/chat', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
if (deps.llmClient === undefined || deps.toolExecutor === undefined) {
return reply.status(503).send({ error: 'agent_not_configured' });
}
const { message } = request.body as ChatBody;
if (message === undefined || message.trim() === '') {
return reply.status(400).send({ error: 'invalid_message' });
}
const result = await runAgent(deps.llmClient, deps.toolExecutor, {
systemPrompt: AGENT_SYSTEM_PROMPT,
userPrompt: message,
maxRounds: deps.maxRounds ?? 12,
});
return reply.send({ reply: result.finalContent, toolCalls: result.toolCalls, halted: result.halted });
});
}
+16
View File
@@ -0,0 +1,16 @@
import type { FastifyInstance } from 'fastify';
import { newId } from '../lib/crypto.ts';
import type { SandboxManager } from '../services/sandbox-manager.ts';
export async function sessionRoutes(
app: FastifyInstance,
sandboxManager: SandboxManager | undefined,
): Promise<void> {
app.post('/sessions', async (_request, reply) => {
if (sandboxManager === undefined) {
return reply.status(503).send({ error: 'sandbox_not_configured' });
}
const { sandboxId, previewUrl } = await sandboxManager.start();
return reply.status(201).send({ sessionId: newId(), sandboxId, previewUrl });
});
}
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify';
import { requireSession } from '../middleware/auth.ts';
import { newId } from '../lib/crypto.ts';
import type { PairingService } from '../services/pairing-service.ts';
import type { SessionStore } from '../services/session-store.ts';
import type { SiteStore } from '../services/site-store.ts';
export type SiteRoutesDeps = {
sessionStore: SessionStore;
pairingService: PairingService;
siteStore: SiteStore;
};
type RedeemBody = {
code?: string;
siteUrl?: string;
};
export async function siteRoutes(app: FastifyInstance, deps: SiteRoutesDeps): Promise<void> {
app.post('/sites/pair', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
const { code, expiresAt } = deps.pairingService.issue(request.userId as string);
return reply.status(201).send({ code, expiresAt });
});
app.post('/sites/redeem', async (request, reply) => {
const { code, siteUrl } = request.body as RedeemBody;
if (code === undefined || siteUrl === undefined) {
return reply.status(400).send({ error: 'invalid' });
}
const result = deps.pairingService.redeem(code, siteUrl);
if (!result.ok) {
return reply.status(400).send({ error: result.error });
}
const site = await deps.siteStore.create({
id: newId(),
accountId: result.accountId,
siteUrl: result.siteUrl,
readToken: result.readToken,
deployToken: result.deployToken,
hmacSecret: result.hmacSecret,
connected: false,
});
return reply.status(201).send({
siteId: site.id,
readToken: site.readToken,
deployToken: site.deployToken,
hmacSecret: site.hmacSecret,
});
});
app.get('/sites/:siteId', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
const site = await deps.siteStore.findById((request.params as { siteId: string }).siteId);
if (site === undefined || site.accountId !== request.userId) {
return reply.status(404).send({ error: 'not_found' });
}
return { id: site.id, siteUrl: site.siteUrl, connected: site.connected };
});
app.post('/sites/:siteId/confirm', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => {
const site = await deps.siteStore.findById((request.params as { siteId: string }).siteId);
if (site === undefined || site.accountId !== request.userId) {
return reply.status(404).send({ error: 'not_found' });
}
await deps.siteStore.setConnected(site.id, true);
return { connected: true };
});
}
+10
View File
@@ -0,0 +1,10 @@
export type SandboxInfo = {
id: string;
status: 'running' | 'destroyed';
};
export interface DockerClient {
createSandbox(image: string): Promise<SandboxInfo>;
destroySandbox(id: string): Promise<void>;
status(id: string): Promise<SandboxInfo | undefined>;
}
+43
View File
@@ -0,0 +1,43 @@
import Docker from 'dockerode';
import { randomUUID } from 'node:crypto';
import type { DockerClient, SandboxInfo } from './docker-client.ts';
export type ContainerInspect = {
Id: string;
State: { Running: boolean };
};
export type ContainerLike = {
start(): Promise<void>;
inspect(): Promise<ContainerInspect>;
remove(opts?: { force?: boolean }): Promise<void>;
};
export type DockerEngine = {
createContainer(opts: { Image: string; name: string }): Promise<ContainerLike>;
getContainer(id: string): ContainerLike;
};
export class DockerodeClient implements DockerClient {
constructor(private readonly engine: DockerEngine = new Docker() as unknown as DockerEngine) {}
async createSandbox(image: string): Promise<SandboxInfo> {
const container = await this.engine.createContainer({ Image: image, name: `wursor-${randomUUID()}` });
await container.start();
const info = await container.inspect();
return { id: info.Id, status: info.State.Running ? 'running' : 'destroyed' };
}
async destroySandbox(id: string): Promise<void> {
await this.engine.getContainer(id).remove({ force: true });
}
async status(id: string): Promise<SandboxInfo | undefined> {
try {
const info = await this.engine.getContainer(id).inspect();
return { id: info.Id, status: info.State.Running ? 'running' : 'destroyed' };
} catch {
return undefined;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
export type SandboxStatus = 'running' | 'paused' | 'destroyed';
export type SandboxState = {
status: SandboxStatus;
lastActiveAt: number;
createdAt: number;
};
export type GcAction = 'keep' | 'pause' | 'destroy';
export type GcOptions = {
idleMs: number;
hardMs: number;
};
export function decideGc(state: SandboxState, now: number, opts: GcOptions): GcAction {
if (state.status === 'destroyed') {
return 'keep';
}
if (now - state.createdAt >= opts.hardMs) {
return 'destroy';
}
if (state.status === 'running' && now - state.lastActiveAt >= opts.idleMs) {
return 'pause';
}
return 'keep';
}
+28
View File
@@ -0,0 +1,28 @@
export type ImageManagerOptions = {
baseImage: string;
webPort: number;
};
export type SandboxContainerSpec = {
image: string;
name: string;
ports: { container: number; host: number }[];
labels: Record<string, string>;
};
export class ImageManager {
constructor(private readonly opts: ImageManagerOptions) {}
imageRef(): string {
return this.opts.baseImage;
}
containerSpec(name: string): SandboxContainerSpec {
return {
image: this.opts.baseImage,
name,
ports: [{ container: 80, host: this.opts.webPort }],
labels: { 'wursor.managed': 'true' },
};
}
}
+14
View File
@@ -0,0 +1,14 @@
export type Manifest = Record<string, string>;
export type ManifestDiff = {
added: string[];
changed: string[];
removed: string[];
};
export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
const added = Object.keys(after).filter((path) => !(path in before));
const removed = Object.keys(before).filter((path) => !(path in after));
const changed = Object.keys(after).filter((path) => path in before && before[path] !== after[path]);
return { added, changed, removed };
}
+11
View File
@@ -0,0 +1,11 @@
export function mediaProxyTarget(origin: string, path: string): string {
return `${origin}${path}`;
}
export function resolveMediaPath(origin: string, path: string, staged: ReadonlySet<string>): string {
return staged.has(path) ? path : mediaProxyTarget(origin, path);
}
export function stageReplacement(_origin: string, path: string, _bytes: number): { copiedPaths: string[] } {
return { copiedPaths: [path] };
}
+19
View File
@@ -0,0 +1,19 @@
import type { Playbook, SiteExport, SubsetRequest, SubsetResult } from './types.ts';
const PLAYBOOK_TABLES: Record<Playbook, Set<string>> = {
content: new Set(['wp_posts', 'wp_postmeta', 'wp_options']),
design: new Set(['wp_posts', 'wp_postmeta', 'wp_options', 'wp_terms', 'wp_term_taxonomy', 'wp_term_relationships']),
plugin: new Set(['wp_options']),
};
const secret = /(_key|_secret|smtp_pass)$/;
export function exportDbSubset(dump: SiteExport, request: SubsetRequest): SubsetResult {
const wanted = PLAYBOOK_TABLES[request.playbook];
const tables = Object.keys(dump.tables).filter((name) => wanted.has(name));
const options = (dump.tables.wp_options ?? [])
.map((row) => String(row.option_name ?? ''))
.filter((name) => name !== '' && !secret.test(name));
return { tables, options };
}
+17
View File
@@ -0,0 +1,17 @@
export type Playbook = 'content' | 'design' | 'plugin';
export type SiteExport = {
origin: string;
tables: Record<string, Array<Record<string, string | number>>>;
uploads: { path: string; bytes: number }[];
};
export type SubsetRequest = {
playbook: Playbook;
postIds: number[];
};
export type SubsetResult = {
tables: string[];
options: string[];
};
+78
View File
@@ -0,0 +1,78 @@
import { generateHmacSecret, generatePairingCode, generateToken, isHttpsUrl } from '../lib/codes.ts';
export type PendingPairing = {
code: string;
accountId: string;
expiresAt: number;
attempts: number;
locked: boolean;
consumed: boolean;
};
export type RedeemError = 'invalid' | 'expired' | 'locked' | 'consumed';
export type RedeemResult =
| { ok: true; accountId: string; siteUrl: string; readToken: string; deployToken: string; hmacSecret: string }
| { ok: false; error: RedeemError };
export type PairingServiceOptions = {
ttlMs?: number;
maxAttempts?: number;
now?: () => number;
};
const DEFAULT_TTL_MS = 5 * 60 * 1000;
const DEFAULT_MAX_ATTEMPTS = 5;
export class PairingService {
private readonly pending = new Map<string, PendingPairing>();
private readonly now: () => number;
private readonly ttlMs: number;
private readonly maxAttempts: number;
constructor(opts: PairingServiceOptions = {}) {
this.now = opts.now ?? Date.now;
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
issue(accountId: string): { code: string; expiresAt: number } {
const code = generatePairingCode();
const expiresAt = this.now() + this.ttlMs;
this.pending.set(code, { code, accountId, expiresAt, attempts: 0, locked: false, consumed: false });
return { code, expiresAt };
}
redeem(code: string, siteUrl: string): RedeemResult {
const pairing = this.pending.get(code);
if (pairing === undefined) {
return { ok: false, error: 'invalid' };
}
if (pairing.consumed) {
return { ok: false, error: 'consumed' };
}
if (pairing.locked) {
return { ok: false, error: 'locked' };
}
if (this.now() > pairing.expiresAt) {
return { ok: false, error: 'expired' };
}
if (!isHttpsUrl(siteUrl)) {
pairing.attempts += 1;
if (pairing.attempts >= this.maxAttempts) {
pairing.locked = true;
}
return { ok: false, error: 'invalid' };
}
pairing.consumed = true;
return {
ok: true,
accountId: pairing.accountId,
siteUrl,
readToken: generateToken(),
deployToken: generateToken(),
hmacSecret: generateHmacSecret(),
};
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createHash, createHmac } from 'node:crypto';
import { isHttpsUrl } from '../lib/codes.ts';
export type PluginCredentials = {
siteUrl: string;
readToken: string;
deployToken?: string;
hmacSecret: string;
};
function sha256hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export class PluginClient {
private readonly baseUrl: string;
private readonly namespace: string;
constructor(
private readonly creds: PluginCredentials,
namespace = 'wursor/v1',
) {
if (!isHttpsUrl(creds.siteUrl)) {
throw new Error('Site URL must be https');
}
this.namespace = namespace;
this.baseUrl = `${creds.siteUrl.replace(/\/$/, '')}/wp-json/${namespace}`;
}
async get(path: string): Promise<unknown> {
return this.request('GET', path);
}
async post(path: string, body?: unknown): Promise<unknown> {
return this.request('POST', path, body);
}
private async request(method: string, path: string, body?: unknown): Promise<unknown> {
const bodyText = body === undefined ? '' : JSON.stringify(body);
const route = `/${this.namespace}${path}`;
const timestamp = String(Math.floor(Date.now() / 1000));
const canonical = `${timestamp}\n${method}\n${route}\n${sha256hex(bodyText)}`;
const signature = createHmac('sha256', this.creds.hmacSecret).update(canonical).digest('hex');
const token = method === 'GET' ? this.creds.readToken : (this.creds.deployToken ?? this.creds.readToken);
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'X-Wursor-Timestamp': timestamp,
'X-Wursor-Signature': signature,
};
if (bodyText !== '') {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: bodyText === '' ? undefined : bodyText,
});
if (!res.ok) {
if (res.status === 401) {
throw new Error('Authentication failed');
}
throw new Error(`Plugin HTTP ${res.status}`);
}
return res.json();
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { User, UserStore } from './user-store.ts';
export type Queryable = {
query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>;
};
type UserRow = {
id: string;
email: string;
password_hash: string;
};
export class PostgresUserStore implements UserStore {
constructor(private readonly db: Queryable) {}
async findByEmail(email: string): Promise<User | undefined> {
const result = await this.db.query('SELECT id, email, password_hash FROM users WHERE email = $1', [email]);
const row = result.rows[0] as UserRow | undefined;
return row === undefined
? undefined
: { id: row.id, email: row.email, passwordHash: row.password_hash };
}
async create(user: User): Promise<User> {
await this.db.query('INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)', [
user.id,
user.email,
user.passwordHash,
]);
return user;
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { DockerClient } from '../sandbox/docker-client.ts';
export type SandboxManagerOptions = {
image: string;
previewBaseUrl: string;
};
export class SandboxManager {
constructor(
private readonly docker: DockerClient,
private readonly opts: SandboxManagerOptions,
) {}
async start(image?: string): Promise<{ sandboxId: string; previewUrl: string }> {
const info = await this.docker.createSandbox(image ?? this.opts.image);
return { sandboxId: info.id, previewUrl: `${this.opts.previewBaseUrl}/${info.id}` };
}
async destroy(id: string): Promise<void> {
await this.docker.destroySandbox(id);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { generateToken } from '../lib/codes.ts';
export type Session = {
token: string;
userId: string;
createdAt: number;
};
export interface SessionStore {
create(userId: string): Promise<Session>;
findByToken(token: string): Promise<Session | undefined>;
}
export class InMemorySessionStore implements SessionStore {
private byToken = new Map<string, Session>();
async create(userId: string): Promise<Session> {
const session = { token: generateToken(), userId, createdAt: Date.now() };
this.byToken.set(session.token, session);
return session;
}
async findByToken(token: string): Promise<Session | undefined> {
return this.byToken.get(token);
}
}
+35
View File
@@ -0,0 +1,35 @@
export type Site = {
id: string;
accountId: string;
siteUrl: string;
readToken: string;
deployToken: string;
hmacSecret: string;
connected: boolean;
};
export interface SiteStore {
create(site: Site): Promise<Site>;
findById(id: string): Promise<Site | undefined>;
setConnected(id: string, connected: boolean): Promise<void>;
}
export class InMemorySiteStore implements SiteStore {
private byId = new Map<string, Site>();
async create(site: Site): Promise<Site> {
this.byId.set(site.id, site);
return site;
}
async findById(id: string): Promise<Site | undefined> {
return this.byId.get(id);
}
async setConnected(id: string, connected: boolean): Promise<void> {
const site = this.byId.get(id);
if (site !== undefined) {
this.byId.set(id, { ...site, connected });
}
}
}
+23
View File
@@ -0,0 +1,23 @@
export type User = {
id: string;
email: string;
passwordHash: string;
};
export interface UserStore {
findByEmail(email: string): Promise<User | undefined>;
create(user: User): Promise<User>;
}
export class InMemoryUserStore implements UserStore {
private byEmail = new Map<string, User>();
async findByEmail(email: string): Promise<User | undefined> {
return this.byEmail.get(email);
}
async create(user: User): Promise<User> {
this.byEmail.set(user.email, user);
return user;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { DockerClient } from '../sandbox/docker-client.ts';
export function computeSpares(current: number, target: number): number {
return Math.max(0, target - current);
}
export type WarmPoolOptions = {
image: string;
hotSpares: number;
};
export class WarmPool {
constructor(
private readonly docker: DockerClient,
private readonly opts: WarmPoolOptions,
) {}
async topUp(currentRunning: number): Promise<{ created: number }> {
const need = computeSpares(currentRunning, this.opts.hotSpares);
for (let i = 0; i < need; i += 1) {
await this.docker.createSandbox(this.opts.image);
}
return { created: need };
}
}
@@ -0,0 +1,26 @@
# 11. Fastify is the API server; React + Vite is the web shell
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
IMPLEMENTATION §1 names "Express/Fastify" for the backend and "React + TypeScript" for the frontend. Sprint 1 required committing to one server framework before the first route and test.
## Decision
Use **Fastify** for `api/`, and **React 19 + Vite + vitest + @testing-library/react** for `web/`.
### Options considered
- Express (matches the plan's pseudocode).
- Fastify (chosen).
### Rejected
- Express — the plan allows either; Fastify ships built-in JSON-schema validation, native async handlers, and first-class TypeScript, which removes glue the plan would otherwise write by hand.
## Consequences
- Route handlers return via Fastify's `reply` object and validate payloads schema-first (the signup route enforces email format and password length).
- Future routes should keep using Fastify schema validation at the boundary rather than hand-rolled checks.
@@ -0,0 +1,26 @@
# 12. In-memory user store behind a UserStore interface; Postgres deferred
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
IMPLEMENTATION §1 names PostgreSQL for Wursor's own data. Sprint 1's first slice needed working sign-up/auth without standing up a database, migrations, or a connection pool on day one.
## Decision
Define a `UserStore` interface and ship `InMemoryUserStore` behind it. Passwords are hashed with `node:crypto` scrypt; session tokens are `crypto.randomBytes(32)` hex.
### Options considered
- Stand up Postgres now.
- In-memory store behind an interface (chosen).
### Rejected
- Postgres now — adds infrastructure friction to the first slice for no behavioral gain; the interface confines the swap to `services/user-store.ts`.
## Consequences
- Auth data is not durable until Postgres lands; restarting the API clears users and sessions.
- The Postgres swap is a drop-in replacement of `InMemoryUserStore` implementing the same `UserStore` contract.
@@ -0,0 +1,26 @@
# 13. Sandbox orchestration mocks the Docker boundary; real daemon client deferred
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
The development machine has no Docker daemon (the Phase 0 spikes already hit this). Sprint 1 still had to build and test the sandbox services: DB subset, media proxy, manifest delta, GC, and orchestration.
## Decision
Implement the pure-logic sandbox services (`subset`, `media-proxy`, `manifest`, `gc`) and test them directly. Define a `DockerClient` interface and a `SandboxManager` orchestrator that depends on it, tested with a fake client. Defer the real Docker daemon HTTP client.
### Options considered
- Block on a Docker host.
- Mock at the boundary (chosen).
### Rejected
- Block on Docker — IMPLEMENTATION §7 already specifies "mocks at boundaries"; blocking would stall the slice for a reason that doesn't change the logic.
## Consequences
- `subset` and `media-proxy` logic is promoted from the golden harness (`e2e/golden/src/`) into `api/src/sandbox/`.
- The Docker wire-up (`DockerClient` daemon implementation, `image-manager`, `warm-pool`) is an explicit Sprint 1 follow-up and remains unverified until a Docker host exists.
+26
View File
@@ -0,0 +1,26 @@
# 14. Postgres user store via a Queryable boundary; schema in SQL migrations
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
ADR 0012 deferred Postgres behind the `UserStore` interface. Sprint 1 now ships the real implementation. Postgres is not available in the dev environment, so the store had to be testable without a database.
## Decision
`PostgresUserStore` uses `node-postgres` (`pg`) but depends on a minimal `Queryable` interface (`query(text, values)`) instead of `pg.Pool` directly. `UserStore` methods became async. The `users` table lives in `api/migrations/001_init.sql`. `index.ts` selects Postgres when `DATABASE_URL` is set, else the in-memory store.
### Options considered
- ORM (Prisma/Drizzle) with migrations.
- Raw `pg` behind a `Queryable` interface (chosen).
### Rejected
- ORM — adds tooling and a codegen step for a two-statement surface; the raw SQL is reviewable and the interface keeps tests database-free.
## Consequences
- `InMemoryUserStore` and `PostgresUserStore` share the same async `UserStore` contract; swapping is env-driven.
- Auth data is durable when `DATABASE_URL` is configured; the migration must be applied before first use.
@@ -0,0 +1,27 @@
# 15. Docker daemon client via dockerode behind an injected engine; sandbox gated by env
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
ADR 0013 deferred the real Docker client. Sprint 1 now ships it. There is still no Docker daemon in the dev environment, so the client had to be testable without one.
## Decision
`DockerodeClient` implements `DockerClient` using `dockerode`, but depends on an injected `DockerEngine` (a minimal `createContainer`/`getContainer` surface) instead of `dockerode` directly. The API enables it via `WUR_ENABLE_SANDBOX=1`; when off, `POST /sessions` returns 503. `index.ts` constructs `new Docker()` (dockerode auto-detects `DOCKER_HOST` or the unix socket).
### Options considered
- Raw Docker Engine HTTP over the unix socket.
- dockerode behind an injected engine (chosen).
### Rejected
- Raw HTTP — dockerode already handles unix sockets, TLS, and API version negotiation; reimplementing it is pure waste.
## Consequences
- `DockerodeClient` is unit-tested with a fake engine; only the daemon wiring remains to be verified on a Docker host.
- Sandbox spin-up is opt-in, so local dev without Docker still boots and `auth`/`sessions` behave predictably (503 on sessions).
- Port mapping is a Sprint 1 placeholder (single fixed host port); dynamic port allocation and preview proxying are follow-ups.
@@ -0,0 +1,26 @@
# 16. Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
The pairing threat model (`spikes/pairing-threat-model.md`) mandates that **Wursor generates** the pairing code and **the plugin redeems** it — explicitly rejecting the plugin-local generate/redeem sketch in IMPLEMENTATION. Its "Sprint 2 tests" section, however, still labels the pairing-code TTL/lockout/single-use tests under `plugin/__tests__/test-auth.php`, a leftover from that rejected sketch.
## Decision
The pairing code lifecycle (issue, 5-minute TTL, 5-attempt lockout, single-use, `site_url` https check) is enforced in the API's `PairingService`. The plugin's `class-auth.php` enforces token hashing (SHA-256 + `hash_equals`), HMAC verification, `read` vs `deploy` scoping, and rotation.
### Options considered
- Follow the test-file labels literally (plugin enforces the pairing code).
- Follow the locked flow (chosen).
### Rejected
- Literal labels — they contradict the "Wursor generates, plugin redeems" flow the same note mandates; pairing state can only live where the code is issued.
## Consequences
- `api/__tests__/routes/sites-pair.test.ts` + `pairing-service.test.ts` cover TTL/lockout/single-use.
- `plugin/__tests__/test-auth.php` covers hashing, HMAC, scope, and rotation only.
@@ -0,0 +1,26 @@
# 17. The agent is given a semantic, allowlisted tool schema — not a raw wp_cli surface
- **Status:** Accepted
- **Date:** 2026-08-15
## Context
IMPLEMENTATION's Sprint 3 sketch exposed a single `wp_cli` tool and tested that `wp eval` / `wp config` / `DROP TABLE` / `wp plugin install http` never appear in the schema. The product is a general-purpose coding agent ("type anything, the agent does it"), which needs richer tools, and the safety guarantee is better served by *not exposing* a raw shell-like surface at all.
## Decision
The agent's tool schema is a set of **semantic, allowlisted tools**`read_page`, `update_post`, `update_option`, `create_page`, `update_theme_json` — each mapping to a constrained WordPress operation. There is no `wp_cli`, `eval`, `config`, or raw SQL tool.
### Options considered
- Raw `wp_cli` tool with a deny-list of subcommands.
- Semantic allowlisted tools (chosen).
### Rejected
- Raw `wp_cli` — a deny-list is fail-open by nature; a new dangerous subcommand is one miss away. An allowlist of semantic tools is fail-closed.
## Consequences
- `api/src/agents/tool-schemas.ts` is the single allowlist; new capability is a new semantic tool with its own executor mapping, reviewed on its own.
- This is the tool surface the real-WP spike (`e2e/agent/`) exercises.
+7
View File
@@ -24,6 +24,13 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
| [0008](0008-empty-packages-not-stubs.md) | Workspace ships empty packages, not placeholder source | Accepted |
| [0009](0009-repo-rename.md) | Repository renamed originmain → wursor | Accepted |
| [0010](0010-openrouter-live-golden.md) | Golden harness scores live runs through a provider-agnostic LLM client (OpenRouter first) | Accepted |
| [0011](0011-fastify-react-stack.md) | Fastify is the API server; React + Vite is the web shell | Accepted |
| [0012](0012-in-memory-user-store.md) | In-memory user store behind a UserStore interface; Postgres deferred | Accepted |
| [0013](0013-docker-boundary-mock.md) | Sandbox orchestration mocks the Docker boundary; real daemon client deferred | Accepted |
| [0014](0014-postgres-queryable.md) | Postgres user store via a Queryable boundary; schema in SQL migrations | Accepted |
| [0015](0015-dockerode-engine-gating.md) | Docker daemon client via dockerode behind an injected engine; sandbox gated by env | Accepted |
| [0016](0016-pairing-code-ownership.md) | Pairing-code TTL/lockout lives on the Wursor API; the plugin enforces token/HMAC/scope | Accepted |
| [0017](0017-semantic-tool-schema.md) | The agent is given a semantic, allowlisted tool schema — not a raw wp_cli surface | Accepted |
## How to add one
+24
View File
@@ -0,0 +1,24 @@
[
{
"id": "spike-01",
"prompt": "Rebrand this site: set the site title to 'Acme Dental', set the tagline to 'Gentle care for every smile', and create a new page titled 'Services' with a paragraph about teeth whitening.",
"asserts": [
{ "type": "option", "key": "blogname", "value": "Acme Dental" },
{ "type": "option", "key": "blogdescription", "value": "Gentle care for every smile" },
{ "type": "page_title_exists", "title": "Services" }
]
},
{
"id": "spike-02",
"prompt": "Create an 'About' page that introduces the practice in one paragraph and a 'Contact' page that lists the phone number (555-0123).",
"asserts": [
{ "type": "page_title_exists", "title": "About" },
{ "type": "page_title_exists", "title": "Contact" }
]
},
{
"id": "spike-03",
"prompt": "Find the existing 'Sample Page' and rewrite its content to start with 'Welcome to our practice.'.",
"asserts": [{ "type": "page_content_contains", "slug": "sample-page", "text": "Welcome to our practice." }]
}
]
+95
View File
@@ -0,0 +1,95 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { loadEnvFile } from 'node:process';
import { fileURLToPath } from 'node:url';
import { runAgent } from '../../api/src/agents/agent-loop.ts';
import { OpenRouterLlmClient } from '../../api/src/agents/openrouter-client.ts';
import { WpRestExecutor } from '../../api/src/agents/wp-executor.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
try {
loadEnvFile(join(root, '.env'));
} catch {
// no .env
}
const SYSTEM_PROMPT =
'You are an expert WordPress agent working inside a sandboxed copy of the user\u2019s site. ' +
'Use the provided tools to complete the request. Work step by step: read what you need, make the ' +
'changes, and verify. Never touch anything outside the sandbox. When finished, reply with a one-line ' +
'summary of what you changed.';
type Assert =
| { type: 'option'; key: string; value: string }
| { type: 'page_title_exists'; title: string }
| { type: 'page_content_contains'; slug: string; text: string };
type Prompt = { id: string; prompt: string; asserts: Assert[] };
const base = process.env.WP_URL;
const username = process.env.WP_USER;
const appPassword = process.env.WP_APP_PASSWORD;
const apiKey = process.env.OPENROUTER_API_KEY;
if (base === undefined || username === undefined || appPassword === undefined || apiKey === undefined) {
process.stderr.write('Missing env: WP_URL, WP_USER, WP_APP_PASSWORD, OPENROUTER_API_KEY\n');
process.exit(1);
}
const executor = new WpRestExecutor({ baseUrl: base, username, appPassword });
const llm = new OpenRouterLlmClient({ apiKey, model: process.env.OPENROUTER_MODEL });
const auth = `Basic ${Buffer.from(`${username}:${appPassword}`).toString('base64')}`;
async function get(url: string): Promise<unknown> {
const res = await fetch(`${base}${url}`, { headers: { Authorization: auth } });
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${url}`);
}
return res.json();
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
}
async function check(a: Assert): Promise<boolean> {
if (a.type === 'option') {
const settings = (await get('/wp-json/wp/v2/settings')) as Record<string, string>;
return settings[a.key] === a.value;
}
if (a.type === 'page_title_exists') {
const pages = (await get(
`/wp-json/wp/v2/pages?search=${encodeURIComponent(a.title)}&_fields=title`,
)) as Array<{ title: { rendered: string } }>;
return pages.some((p) => stripTags(p.title.rendered) === a.title);
}
const pages = (await get(
`/wp-json/wp/v2/pages?slug=${encodeURIComponent(a.slug)}&_fields=content`,
)) as Array<{ content: { rendered: string } }>;
return stripTags(pages[0]?.content.rendered ?? '').includes(a.text);
}
const prompts = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'prompts.json'), 'utf8')) as Prompt[];
const report = {
startedAt: new Date().toISOString(),
results: [] as Array<{ id: string; elapsedMs: number; toolCalls: number; halted: boolean; assertions: Array<{ ok: boolean; assert: Assert }> }>,
};
for (const prompt of prompts) {
const started = Date.now();
const result = await runAgent(llm, executor, { systemPrompt: SYSTEM_PROMPT, userPrompt: prompt.prompt, maxRounds: 12 });
const assertions = await Promise.all(prompt.asserts.map(async (assert) => ({ assert, ok: await check(assert) })));
report.results.push({
id: prompt.id,
elapsedMs: Date.now() - started,
toolCalls: result.toolCalls,
halted: result.halted,
assertions,
});
}
const outDir = join(dirname(fileURLToPath(import.meta.url)), 'runs');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'latest.json'), `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+3 -1
View File
@@ -5,9 +5,11 @@
"scripts": {
"test": "vitest run",
"golden": "tsx golden/src/run-golden.ts",
"mirror:time": "tsx golden/src/run-mirror-timing.ts"
"mirror:time": "tsx golden/src/run-mirror-timing.ts",
"agent:spike": "tsx agent/run-agent-spike.ts"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: 0,
use: {
baseURL: 'http://localhost:5173',
},
webServer: [
{
command: 'pnpm --filter @wursor/api start',
url: 'http://localhost:3000/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'pnpm --filter @wursor/web dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
],
});
+12
View File
@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';
test('user signs up and sees the chat interface', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email').fill(`e2e-${Date.now()}@example.com`);
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign up' }).click();
await expect(page.locator('.wursor-chat-input')).toBeVisible();
await expect(page.locator('.wursor-welcome')).toContainText('Describe what you want');
});
+179
View File
@@ -0,0 +1,179 @@
# Wursor — DevOps & Infrastructure Brief
This is the handoff document for the DevOps / infrastructure engineer. It bundles everything Docker and hosting-related in one place: what exists now, what must be built, and how the infrastructure interfaces with the application code (api / web / plugin).
---
## 1. The product in one paragraph
Wursor is an agentic WordPress management platform. A non-technical site owner describes a change in chat; Wursor spins up an **isolated cloud sandbox** (a copy of their WordPress site), has an AI agent make the change, shows a **live preview**, and on approval **deploys** the change to the real site through a WordPress plugin. The sandbox is the safety guarantee — the live site is never touched until explicit approval.
**Stack:** Node.js + TypeScript (api), React + Vite (web), PHP (plugin), PostgreSQL (Wursor data), Redis (SSE/queue), Docker (sandboxes).
---
## 2. Runtime topology
```
User browser
│ HTTPS
Web app (React/Vite static build) ──proxied──► API (Fastify, Node 22)
┌─────────────────────────────────────┼──────────────────────┐
▼ ▼ ▼
PostgreSQL (Wursor data) Redis (SSE/queue/cache) Docker host (VPS)
users / sites / sessions / deploys │
├─ Pre-baked WordPress image (read-only)
├─ Warm pool (paused images + 12 hot spares)
├─ Active sandboxes (image + overlayfs site layer)
├─ Media proxy (nginx rewrites /wp-content/uploads → origin)
└─ GC (idle → pause-to-disk, 24h hard timeout → destroy)
User's live WordPress site ◄──── deploy via plugin REST API (Sprint 2+)
```
Component ownership:
| Component | Language | Repo path | State today |
|---|---|---|---|
| API server | Node 22 + TypeScript (Fastify) | `api/` | auth + sessions routes live |
| Web app | React 19 + Vite | `web/` | sign-up + chat shell live |
| WordPress plugin | PHP | `plugin/` | empty until Sprint 2 |
| Sandbox services | TypeScript | `api/src/sandbox/`, `api/src/services/` | logic + Docker client live (see §3) |
| Docker assets | Dockerfile / compose | `infrastructure/docker/` | scaffold live |
| e2e | Playwright | `e2e/` | chat-flow test live |
---
## 3. What already exists (no DevOps work needed to understand the contract)
These are implemented and unit-tested (mocked at the Docker/Postgres boundary, since the dev machine has neither):
- **`DockerClient` contract** (`api/src/sandbox/docker-client.ts`): `createSandbox(image)`, `destroySandbox(id)`, `status(id)`.
- **`DockerodeClient`** (`api/src/sandbox/dockerode-client.ts`): real implementation using [dockerode](https://github.com/apocas/dockerode). Auto-detects `DOCKER_HOST` or the local unix socket.
- **`SandboxManager`** (`api/src/services/sandbox-manager.ts`): `start()``{ sandboxId, previewUrl }`, `destroy(id)`.
- **`ImageManager`** (`api/src/sandbox/image-manager.ts`): owns the base image ref (`WUR_IMAGE`) and the container spec (port 80 → host `WUR_WEB_PORT`, label `wursor.managed=true`).
- **`WarmPool`** (`api/src/services/warm-pool.ts`): tops the pool up to `WARM_POOL_HOT_SPARES`.
- **`gc.ts`** (`api/src/sandbox/gc.ts`): pure decision function — running + idle → `pause`; any sandbox past hard timeout → `destroy`.
- **`subset.ts` / `media-proxy.ts` / `manifest.ts`**: DB subset, media proxying, path→sha256 delta (logic only; not yet wired to a live DB/daemon).
- **`POST /sessions`** (`api/src/routes/sessions.ts`): spins up a sandbox and returns `{ sessionId, sandboxId, previewUrl }`; returns `503 { error: "sandbox_not_configured" }` when sandboxing is off.
- **`POST /auth/signup`** + `GET /health`.
- **Postgres store** (`api/src/services/postgres-user-store.ts`) + migration `api/migrations/001_init.sql`; selected when `DATABASE_URL` is set, otherwise in-memory.
**The API ↔ Docker interface is already defined.** DevOps owns making the daemon side of that interface real and reliable, not redesigning it.
---
## 4. Environment contract (single source of truth: `.env.example`)
| Variable | Purpose | Notes |
|---|---|---|
| `PORT` | API listen port | default `3000` |
| `DATABASE_URL` | Wursor PostgreSQL DSN | set → Postgres store; unset → in-memory (dev only) |
| `REDIS_URL` | Redis DSN | used from Sprint 3 (SSE/queue) |
| `SESSION_SECRET` | session signing | must be a real secret in prod |
| `LLM_PROVIDER` | `grok` \| `openrouter` | model provider |
| `XAI_API_KEY` / `OPENROUTER_API_KEY` | model keys | `OPENROUTER_MODEL` selects the model |
| `DOCKER_HOST` | Docker daemon (dockerode) | optional; auto-detected locally |
| `WUR_ENABLE_SANDBOX` | `1` → enable sandbox spin-up | unset → `/sessions` returns 503 |
| `WUR_IMAGE` | pre-baked image tag | default `wursor-base:latest` |
| `WUR_WEB_PORT` | host port for sandbox HTTP | default `8080` |
| `PREVIEW_BASE_URL` | base URL for preview links | default `http://localhost:8080` |
| `WARM_POOL_HOT_SPARES` | hot-spare count | default `2` |
Secrets are read from `.env` (gitignored) or the environment; never committed.
---
## 5. DevOps work items
Ordered by dependency. Each has an acceptance criterion.
### A. Docker host
- Provision a VPS (or managed Docker runtime) running a Docker daemon reachable by the API.
- Set `DOCKER_HOST` (or use the local unix socket when co-located) and `WUR_ENABLE_SANDBOX=1`.
- **Accept:** `docker info` succeeds from the API host; `POST /sessions` returns `201` with a `sandboxId`.
### B. Pre-baked WordPress image (finalize)
- The scaffold `infrastructure/docker/Dockerfile.wordpress` uses the Apache-based `wordpress:6.7-php8.2` image + WP-CLI. This is a **placeholder**.
- The target base image (PRD §6.2) is: **WordPress + nginx + PHP 8.x + MySQL 8.x + WP-CLI + Redis**, with the WordPress install on a **read-only base layer** and site-specific changes on an **overlayfs layer**.
- Build and tag `wursor-base:latest`; wire into CI image builds.
- **Accept:** `docker build` succeeds; a container boots and serves WordPress on port 80; `wp` CLI works in-container.
### C. Sandbox runtime: warm pool + GC + overlayfs
- **Warm pool:** maintain `WARM_POOL_HOT_SPARES` hot spares plus paused images. Pause-to-disk on idle; resume in ~2s (PRD R8).
- **GC:** implement the container-level effect of `gc.ts` decisions — 15-min idle → pause/checkpoint; 24h hard timeout → destroy, no exceptions.
- **Overlayfs:** site layers as overlayfs on the shared read-only image so sandboxes are cheap and fast.
- **Accept:** a sandbox boots in ≤10s from the warm pool; an idle sandbox pauses and resumes; a 24h sandbox is destroyed automatically.
### D. Media proxy
- Sandbox nginx rewrites `/wp-content/uploads/*` to the live origin (or a signed Wursor proxy). Media is **never bulk-copied**; a file is copied only when the agent replaces it (ADR 0007).
- **Accept:** a sandbox page renders live-site images without downloading the uploads directory.
### E. Preview routing + TLS
- Map each sandbox to a reachable preview URL (subdomain or per-sandbox port) with HTTPS.
- Note: the current code emits `PREVIEW_BASE_URL/<sandboxId>` with a single fixed `WUR_WEB_PORT` — a Sprint 1 placeholder. DevOps must provide **dynamic per-sandbox routing** so concurrent sandboxes don't collide.
- **Accept:** two concurrent sandboxes each resolve to distinct, working preview URLs.
### F. Data services
- **PostgreSQL** for Wursor data. Apply `api/migrations/001_init.sql` on first deploy; add a migration mechanism for future changes.
- **Redis** for SSE streaming and queues (required from Sprint 3).
- **Accept:** `DATABASE_URL` set → sign-up persists across API restarts; `redis-cli ping` succeeds.
### G. CI/CD (finish)
- `ci.yml` exists but is minimal (install + test + lint). Complete it per IMPLEMENTATION §8: split api/web/plugin jobs, add coverage gates (api/web ≥ 90%, plugin ≥ 80%), add an e2e job running the Playwright suite against a Docker service.
- **Accept:** a green CI run on PR, including the Playwright e2e suite.
### H. Secrets management
- Store `SESSION_SECRET`, `DATABASE_URL`, `REDIS_URL`, `DOCKER_HOST`, and LLM keys in the deployment secret store (not `.env`), injected as environment variables.
- **Accept:** no secret value appears in the repo or logs.
### I. Observability
- Minimal telemetry (sign-up, connect, task start/approve/reject, deploy) — Sprint 8. Logs for API + sandbox lifecycle; GC and warm-pool metrics.
- **Accept:** sandbox create/destroy and GC actions are observable.
---
## 6. How DevOps works with the codebase
**Contracts, not re-implementation.** The app calls into infrastructure through three stable seams:
1. **`DockerClient`** — the API already codes against `createSandbox` / `destroySandbox` / `status`. DevOps makes the daemon side behave, not change the interface.
2. **Environment variables** — the only runtime configuration. There is no config file to maintain; changing behavior is changing env vars (§4).
3. **`/health` and `/sessions`** — the integration smoke tests. `/health` proves the app is up; `/sessions` proves the Docker path end-to-end.
**Testing boundaries (TDD rule 3):** unit tests mock Docker and Postgres, so CI runs without a daemon. Only the e2e/integration layer talks to real Docker — the e2e job must run on a Docker-enabled runner (`services: docker` with `--privileged`).
**The plugin (Sprint 2+) is not DevOps-owned** but is part of the same deploy path: it runs on the *user's* WordPress site and exposes REST endpoints for site-info and deploy. DevOps provides the cloud side (snapshot storage for rollback — the last 3 deploy snapshots live in Wursor's cloud, PRD R3) and the media proxy origin.
---
## 7. Key risks DevOps must honor (from PRD §13)
- **R4 (large sites):** task-scoped mirror + media proxy, never a full clone. The sandbox must not pull the user's entire media library.
- **R8 (cost):** no large fleet of always-running WP+MySQL boxes. Warm pool = paused images + 12 hot spares. Idle sandboxes pause to disk.
- **R1 (agent breaks sandbox):** overlayfs copy-on-write checkpoints; sandboxes are disposable.
- **R3 (undo when site is down):** last 3 deploy snapshots stored in Wursor's cloud.
---
## 8. Definition of done for the DevOps handoff
- [ ] Docker host reachable; `WUR_ENABLE_SANDBOX=1` and `/sessions` returns `201`.
- [ ] Final `wursor-base:latest` image built (nginx + PHP 8 + MySQL 8 + WP-CLI + Redis) and built in CI.
- [ ] Warm pool + GC + overlayfs running; sandbox boot ≤10s, resume ≤2s, 24h hard destroy.
- [ ] Media proxy live (uploads proxied, not copied).
- [ ] Dynamic preview routing + TLS.
- [ ] PostgreSQL (with migration) and Redis provisioned and wired via env.
- [ ] CI green including Playwright e2e on a Docker runner; coverage gates enforced.
- [ ] Secrets injected, never committed.
---
## 9. References
- `IMPLEMENTATION.md` — §1 architecture, §8 CI/CD pipeline.
- `PRD.md` — §6.2 sandbox, §13 risk register (R1/R3/R4/R8/R14).
- `docs/decisions/` — ADRs 0007 (media proxy), 00130015 (Docker boundary, Postgres, dockerode gating).
- `api/src/sandbox/`, `api/src/services/` — the interfaces DevOps integrates against.
@@ -0,0 +1,4 @@
FROM wordpress:6.7-php8.2
RUN curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp \
&& chmod +x /usr/local/bin/wp
+30
View File
@@ -0,0 +1,30 @@
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: root
volumes:
- db_data:/var/lib/mysql
wordpress:
build:
context: .
dockerfile: Dockerfile.wordpress
depends_on:
- db
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
volumes:
- wp_data:/var/www/html
volumes:
db_data:
wp_data:
View File
+81
View File
@@ -0,0 +1,81 @@
<?php
class Wursor_Auth_Test extends WP_UnitTestCase {
private function sign( $secret, $timestamp, $method, $route, $body ) {
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
return hash_hmac( 'sha256', $canonical, $secret );
}
public function tear_down() {
Wursor_Auth::clear_tokens();
parent::tear_down();
}
public function test_store_tokens_hashes_tokens_not_plaintext() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertNotEquals( 'read-token', get_option( Wursor_Auth::OPTION_READ_HASH ) );
$this->assertEquals( hash( 'sha256', 'read-token' ), get_option( Wursor_Auth::OPTION_READ_HASH ) );
$this->assertNotEquals( 'hmac-secret', get_option( Wursor_Auth::OPTION_HMAC_SECRET ) );
}
public function test_verify_token_accepts_matching_token() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertTrue( Wursor_Auth::verify_token( 'read-token', 'read' ) );
$this->assertTrue( Wursor_Auth::verify_token( 'deploy-token', 'deploy' ) );
}
public function test_verify_token_rejects_wrong_token() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertFalse( Wursor_Auth::verify_token( 'wrong', 'read' ) );
}
public function test_read_token_does_not_verify_as_deploy() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertFalse( Wursor_Auth::verify_token( 'read-token', 'deploy' ) );
}
public function test_hmac_accepts_valid_signature() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$ts = (string) time();
$route = '/wursor/v1/site-info';
$body = '';
$this->assertTrue( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
}
public function test_hmac_rejects_stale_timestamp() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$ts = (string) ( time() - 120 );
$route = '/wursor/v1/site-info';
$body = '';
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
}
public function test_hmac_rejects_tampered_body() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$ts = (string) time();
$route = '/wursor/v1/files';
$sig = $this->sign( 'hmac-secret', $ts, 'POST', $route, '{"a":1}' );
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'POST', $route, '{"a":2}', $sig ) );
}
public function test_hmac_rejects_missing_signature() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
$this->assertFalse( Wursor_Auth::verify_hmac( (string) time(), 'GET', '/wursor/v1/site-info', '', null ) );
}
public function test_rotated_tokens_invalidate_old_hashes() {
Wursor_Auth::store_tokens( 'old-read', 'old-deploy', 'old-secret' );
Wursor_Auth::store_tokens( 'new-read', 'new-deploy', 'new-secret' );
$this->assertFalse( Wursor_Auth::verify_token( 'old-read', 'read' ) );
$this->assertTrue( Wursor_Auth::verify_token( 'new-read', 'read' ) );
}
public function test_disconnect_clears_tokens() {
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
Wursor_Auth::clear_tokens();
$this->assertFalse( Wursor_Auth::is_connected() );
}
}
View File
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Admin settings page: paste the Wursor pairing code, connect, disconnect.
*/
class Wursor_Admin {
public static function register_menu() {
add_menu_page( 'Wursor', 'Wursor', 'manage_options', 'wursor', array( __CLASS__, 'render' ), 'dashicons-update' );
}
public static function render() {
self::handle_post();
$connected = Wursor_Auth::is_connected();
?>
<div class="wrap">
<h1>Wursor</h1>
<?php if ( $connected ) : ?>
<p>This site is connected to Wursor.</p>
<form method="post">
<input type="hidden" name="wursor_disconnect" value="1" />
<?php submit_button( 'Disconnect' ); ?>
</form>
<?php else : ?>
<p>Paste the pairing code from Wursor to connect this site.</p>
<form method="post">
<input type="text" name="wursor_pairing_code" maxlength="8" autocomplete="off" placeholder="ABCD1234" />
<?php submit_button( 'Connect' ); ?>
</form>
<?php endif; ?>
</div>
<?php
}
private static function handle_post() {
if ( isset( $_POST['wursor_disconnect'] ) ) {
Wursor_Auth::clear_tokens();
return;
}
if ( empty( $_POST['wursor_pairing_code'] ) ) {
return;
}
$code = sanitize_text_field( wp_unslash( $_POST['wursor_pairing_code'] ) );
$api_url = get_option( 'wursor_api_url', 'https://api.wursor.dev' );
$response = wp_remote_post(
rtrim( $api_url, '/' ) . '/sites/redeem',
array(
'body' => wp_json_encode( array( 'code' => $code, 'siteUrl' => home_url( '/' ) ) ),
'headers' => array( 'Content-Type' => 'application/json' ),
'timeout' => 15,
)
);
if ( is_wp_error( $response ) ) {
return;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( isset( $body['readToken'], $body['deployToken'], $body['hmacSecret'] ) ) {
Wursor_Auth::store_tokens( $body['readToken'], $body['deployToken'], $body['hmacSecret'] );
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
/**
* REST API routes under /wp-json/wursor/v1/.
* Every request requires a Bearer token with the right scope and a valid HMAC signature.
*/
class Wursor_API {
public static function register_routes() {
register_rest_route( 'wursor/v1', '/site-info', array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_site_info' ),
'permission_callback' => array( __CLASS__, 'authorize_read' ),
) );
register_rest_route( 'wursor/v1', '/files', array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'stub' ),
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
) );
register_rest_route( 'wursor/v1', '/db', array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'stub' ),
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
) );
register_rest_route( 'wursor/v1', '/wp-cli', array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'stub' ),
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
) );
}
public static function authorize_read( WP_REST_Request $request ) {
return self::authorize( $request, 'read' );
}
public static function authorize_deploy( WP_REST_Request $request ) {
return self::authorize( $request, 'deploy' );
}
private static function authorize( WP_REST_Request $request, $scope ) {
$auth = $request->get_header( 'authorization' );
if ( ! is_string( $auth ) || 0 !== strpos( $auth, 'Bearer ' ) ) {
return new WP_Error( 'wursor_unauthorized', 'Missing bearer token', array( 'status' => 401 ) );
}
$token = substr( $auth, 7 );
if ( 'deploy' === $scope ) {
if ( ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
return new WP_Error( 'wursor_forbidden', 'Deploy token required', array( 'status' => 403 ) );
}
} elseif ( ! Wursor_Auth::verify_token( $token, 'read' ) && ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
return new WP_Error( 'wursor_unauthorized', 'Invalid token', array( 'status' => 401 ) );
}
$timestamp = $request->get_header( 'x-wursor-timestamp' );
$signature = $request->get_header( 'x-wursor-signature' );
$route = $request->get_route();
$body = $request->get_body();
if ( ! Wursor_Auth::verify_hmac( $timestamp, $request->get_method(), $route, $body, $signature ) ) {
return new WP_Error( 'wursor_bad_signature', 'Bad HMAC signature', array( 'status' => 401 ) );
}
return true;
}
public static function get_site_info( WP_REST_Request $request ) {
return Wursor_Site_Info::get_site_info();
}
public static function stub( WP_REST_Request $request ) {
return new WP_Error( 'wursor_not_implemented', 'Not implemented until Sprint 6', array( 'status' => 501 ) );
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* Token storage, verification, and request signing (HMAC).
*
* Tokens are stored only as SHA-256 hashes; the HMAC secret is stored encrypted
* with a key derived from the site's AUTH_KEY + AUTH_SALT. See spikes/pairing-threat-model.md.
*/
class Wursor_Auth {
const OPTION_READ_HASH = 'wursor_read_token_hash';
const OPTION_DEPLOY_HASH = 'wursor_deploy_token_hash';
const OPTION_HMAC_SECRET = 'wursor_hmac_secret';
const MAX_SKEW_SECONDS = 60;
public static function store_tokens( $read_token, $deploy_token, $hmac_secret ) {
update_option( self::OPTION_READ_HASH, hash( 'sha256', $read_token ), true );
update_option( self::OPTION_DEPLOY_HASH, hash( 'sha256', $deploy_token ), true );
update_option( self::OPTION_HMAC_SECRET, self::encrypt( $hmac_secret ), true );
}
public static function clear_tokens() {
delete_option( self::OPTION_READ_HASH );
delete_option( self::OPTION_DEPLOY_HASH );
delete_option( self::OPTION_HMAC_SECRET );
}
public static function is_connected() {
return false !== get_option( self::OPTION_READ_HASH );
}
public static function verify_token( $token, $scope ) {
$option = 'deploy' === $scope ? self::OPTION_DEPLOY_HASH : self::OPTION_READ_HASH;
$stored = get_option( $option );
return is_string( $stored ) && hash_equals( $stored, hash( 'sha256', $token ) );
}
public static function verify_hmac( $timestamp, $method, $route, $body, $signature ) {
if ( ! is_string( $signature ) ) {
return false;
}
if ( abs( time() - intval( $timestamp ) ) > self::MAX_SKEW_SECONDS ) {
return false;
}
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
$expected = hash_hmac( 'sha256', $canonical, self::hmac_secret() );
return hash_equals( $expected, $signature );
}
private static function hmac_secret() {
$encrypted = get_option( self::OPTION_HMAC_SECRET );
return false === $encrypted ? '' : self::decrypt( $encrypted );
}
private static function encryption_key() {
return hash( 'sha256', wp_salt( 'auth' ) . wp_salt( 'auth_salt' ) );
}
private static function encrypt( $value ) {
$iv = random_bytes( 16 );
$tag = '';
$ciphertext = openssl_encrypt( $value, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
if ( false === $ciphertext ) {
return false;
}
return base64_encode( $iv . $tag . $ciphertext );
}
private static function decrypt( $value ) {
$data = base64_decode( $value, true );
if ( false === $data || strlen( $data ) < 32 ) {
return '';
}
$iv = substr( $data, 0, 16 );
$tag = substr( $data, 16, 16 );
$ciphertext = substr( $data, 32 );
$plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
return false === $plaintext ? '' : $plaintext;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* Site information provider: theme, plugins, versions, builder, capability tiers, preflight.
* Builder detection mirrors e2e/golden/src/builder-detect.ts (see ADR 0006).
*/
class Wursor_Site_Info {
public static function get_site_info() {
$theme = wp_get_theme();
return array(
'theme' => $theme->get_stylesheet(),
'plugins' => self::plugins(),
'wordpress_version' => get_bloginfo( 'version' ),
'php_version' => PHP_VERSION,
'builder' => self::detect_builder(),
'capabilities' => self::capabilities(),
'preflight' => self::preflight(),
);
}
private static function plugins() {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$all = get_plugins();
$active = (array) get_option( 'active_plugins', array() );
$result = array();
foreach ( $all as $plugin_file => $data ) {
$result[] = array(
'slug' => self::slug_from_file( $plugin_file ),
'active' => in_array( $plugin_file, $active, true ),
);
}
return $result;
}
private static function active_slugs() {
$active = (array) get_option( 'active_plugins', array() );
return array_map( array( __CLASS__, 'slug_from_file' ), $active );
}
private static function slug_from_file( $file ) {
$dir = dirname( $file );
return '.' === $dir ? basename( $file, '.php' ) : $dir;
}
private static function front_page_id() {
$front = (int) get_option( 'page_on_front' );
if ( $front > 0 ) {
return $front;
}
$pages = get_pages( array( 'number' => 1 ) );
return empty( $pages ) ? 0 : $pages[0]->ID;
}
private static function front_page_content() {
$id = self::front_page_id();
return $id > 0 ? (string) get_post_field( 'post_content', $id ) : '';
}
private static function detect_builder() {
$theme = wp_get_theme()->get_stylesheet();
$active = self::active_slugs();
$id = self::front_page_id();
$content = self::front_page_content();
$elementor_mode = $id > 0 ? get_post_meta( $id, '_elementor_edit_mode', true ) : '';
$elementor_data = $id > 0 ? get_post_meta( $id, '_elementor_data', true ) : '';
$fl_builder = $id > 0 ? get_post_meta( $id, '_fl_builder_data', true ) : '';
$et_pb = $id > 0 ? get_post_meta( $id, '_et_pb_use_builder', true ) : '';
if ( in_array( 'elementor', $active, true ) && ( '' !== $elementor_mode || '' !== $elementor_data ) ) {
return 'elementor';
}
if ( in_array( 'beaver-builder-lite-version', $active, true ) && '' !== $fl_builder ) {
return 'beaver';
}
if ( false !== stripos( $theme, 'divi' ) && 'on' === $et_pb ) {
return 'divi';
}
if ( false !== strpos( $content, '<!-- wp:' ) ) {
return 'gutenberg';
}
return 'classic';
}
private static function capabilities() {
$full = version_compare( get_bloginfo( 'version' ), '6.1', '>=' ) && version_compare( PHP_VERSION, '8.0', '>=' );
$install_safe = ! defined( 'DISALLOW_FILE_MODS' ) || ! DISALLOW_FILE_MODS;
return array(
'content' => true,
'design' => $full,
'install' => $full && $install_safe,
);
}
private static function preflight() {
return array(
'https' => is_ssl(),
'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS,
'disk_free' => function_exists( 'disk_free_space' ) ? disk_free_space( ABSPATH ) : null,
);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/**
* Plugin Name: Wursor
* Description: Connects this WordPress site to Wursor for safe, previewed changes.
* Version: 0.1.0
* Requires PHP: 7.4
* Author: Wursor
* License: GPL-2.0-or-later
*/
defined('ABSPATH') || exit;
require_once __DIR__ . '/src/class-auth.php';
require_once __DIR__ . '/src/class-site-info.php';
require_once __DIR__ . '/src/class-api.php';
require_once __DIR__ . '/src/class-admin.php';
add_action('rest_api_init', array('Wursor_API', 'register_routes'));
add_action('admin_menu', array('Wursor_Admin', 'register_menu'));
+2003 -14
View File
File diff suppressed because it is too large Load Diff
View File
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ApproveBar } from '../src/components/ApproveBar.tsx';
describe('ApproveBar', () => {
it('renders apply and reject buttons when visible', () => {
render(<ApproveBar visible onApprove={vi.fn()} onReject={vi.fn()} />);
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reject/i })).toBeInTheDocument();
});
it('calls onApprove and onReject', async () => {
const onApprove = vi.fn();
const onReject = vi.fn();
render(<ApproveBar visible onApprove={onApprove} onReject={onReject} />);
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(onApprove).toHaveBeenCalled();
await userEvent.click(screen.getByRole('button', { name: /reject/i }));
expect(onReject).toHaveBeenCalled();
});
it('renders nothing when not visible', () => {
const { container } = render(<ApproveBar visible={false} onApprove={vi.fn()} onReject={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChatPanel } from '../src/components/ChatPanel.tsx';
describe('ChatPanel', () => {
it('renders user and agent messages', () => {
render(
<ChatPanel
messages={[
{ id: '1', role: 'user', text: 'Change the heading' },
{ id: '2', role: 'agent', text: 'Done' },
]}
status="done"
onSend={vi.fn()}
/>,
);
expect(screen.getByText('Change the heading')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
});
it('calls onSend with the typed message', async () => {
const onSend = vi.fn();
render(<ChatPanel messages={[]} status="idle" onSend={onSend} />);
await userEvent.type(screen.getByPlaceholderText('Describe what you want…'), 'Change the heading');
await userEvent.click(screen.getByRole('button', { name: /send/i }));
expect(onSend).toHaveBeenCalledWith('Change the heading');
});
it('shows a working indicator while the agent works', () => {
render(<ChatPanel messages={[]} status="working" onSend={vi.fn()} />);
expect(screen.getByText(/working/i)).toBeInTheDocument();
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignUp } from '../src/components/SignUp.tsx';
describe('SignUp', () => {
it('submits the email and password', async () => {
const onSignUp = vi.fn().mockResolvedValue(undefined);
render(<SignUp onSignUp={onSignUp} />);
await userEvent.type(screen.getByLabelText('Email'), 'a@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
expect(onSignUp).toHaveBeenCalledWith('a@example.com', 'password123');
});
it('shows an error when signup fails', async () => {
const onSignUp = vi.fn().mockRejectedValue(new Error('email_exists'));
render(<SignUp onSignUp={onSignUp} />);
await userEvent.type(screen.getByLabelText('Email'), 'a@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Sign up' }));
expect(await screen.findByRole('alert')).toHaveTextContent('email_exists');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SiteConnector } from '../src/components/SiteConnector.tsx';
describe('SiteConnector', () => {
it('shows the pairing code', () => {
render(<SiteConnector code="ABCD1234" checkConnected={vi.fn().mockResolvedValue({ connected: false })} />);
expect(screen.getByText('ABCD1234')).toBeInTheDocument();
});
it('shows success state when connected', async () => {
render(<SiteConnector code="ABCD1234" checkConnected={vi.fn().mockResolvedValue({ connected: true })} />);
expect(await screen.findByText('Site connected')).toBeInTheDocument();
});
it('shows error state when the check fails', async () => {
render(<SiteConnector code="ABCD1234" checkConnected={vi.fn().mockRejectedValue(new Error('nope'))} />);
expect(await screen.findByText('Connection failed')).toBeInTheDocument();
});
it('polls until the site is connected', async () => {
const checkConnected = vi
.fn()
.mockResolvedValueOnce({ connected: false })
.mockResolvedValueOnce({ connected: true });
render(<SiteConnector code="ABCD1234" checkConnected={checkConnected} pollIntervalMs={5} />);
expect(await screen.findByText('Site connected')).toBeInTheDocument();
expect(checkConnected).toHaveBeenCalledTimes(2);
});
});
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<title>Wursor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+22 -1
View File
@@ -2,5 +2,26 @@
"name": "@wursor/web",
"private": true,
"type": "module",
"scripts": {}
"scripts": {
"test": "vitest run",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.4",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^30.0.1",
"typescript": "^5.9.3",
"vite": "^8.2.1",
"vitest": "^3.2.7"
}
}
View File
+52
View File
@@ -0,0 +1,52 @@
import { useState } from 'react';
import { ApproveBar } from './components/ApproveBar.tsx';
import { ChatPanel } from './components/ChatPanel.tsx';
import { Preview } from './components/Preview.tsx';
import { SignUp } from './components/SignUp.tsx';
import { useChat } from './hooks/useChat.ts';
async function signUp(email: string, password: string): Promise<string> {
const res = await fetch('/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? 'Sign up failed');
}
const body = (await res.json()) as { sessionToken: string };
return body.sessionToken;
}
export function App() {
const [sessionToken, setSessionToken] = useState<string | null>(null);
const [applied, setApplied] = useState(false);
const chat = useChat(sessionToken);
if (sessionToken === null) {
return (
<div className="auth-screen">
<SignUp onSignUp={async (email, password) => setSessionToken(await signUp(email, password))} />
</div>
);
}
return (
<div className="app">
<header className="app-topbar">
<span className="app-logo">Wursor</span>
<span className="app-badge">Preview sandbox</span>
</header>
<div className="app-body">
<ChatPanel messages={chat.messages} status={chat.status} onSend={chat.send} />
<Preview heading={chat.heading} status={chat.status} applied={applied} />
</div>
<ApproveBar
visible={chat.status === 'done' && !applied}
onApprove={() => setApplied(true)}
onReject={() => setApplied(false)}
/>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
type ApproveBarProps = {
visible: boolean;
onApprove: () => void;
onReject: () => void;
};
export function ApproveBar({ visible, onApprove, onReject }: ApproveBarProps) {
if (!visible) return null;
return (
<div className="approve-bar">
<span className="approve-copy">Looks good?</span>
<button className="wursor-approve-button" type="button" onClick={onApprove}>
Looks good Apply
</button>
<button className="wursor-reject-button" type="button" onClick={onReject}>
Not right Reject
</button>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useState, type FormEvent } from 'react';
import type { ChatMessage, ChatStatus } from '../hooks/useChat.ts';
type ChatPanelProps = {
messages: ChatMessage[];
status: ChatStatus;
onSend: (text: string) => void;
};
export function ChatPanel({ messages, status, onSend }: ChatPanelProps) {
const [draft, setDraft] = useState('');
function submit(event: FormEvent) {
event.preventDefault();
const text = draft.trim();
if (text === '') return;
setDraft('');
onSend(text);
}
return (
<aside className="chat">
<div className="chat-scroll">
{messages.length === 0 ? (
<div className="wursor-welcome">
<p className="welcome-kicker">Wursor</p>
<h2>Describe what you want.</h2>
<p className="welcome-sub">Change wording, colors, add a page just say it. We preview it first.</p>
</div>
) : (
<ul className="chat-list">
{messages.map((message) => (
<li key={message.id} className={`message message-${message.role} wursor-message-${message.role}`}>
{message.text}
</li>
))}
</ul>
)}
{status === 'working' && <div className="wursor-working">Working on it</div>}
</div>
<form className="chat-composer" onSubmit={submit}>
<input
className="wursor-chat-input"
placeholder="Describe what you want…"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button className="wursor-chat-send" type="submit" aria-label="Send">
</button>
</form>
</aside>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { ChatStatus } from '../hooks/useChat.ts';
type PreviewProps = {
heading: string;
status: ChatStatus;
applied: boolean;
};
export function Preview({ heading, status, applied }: PreviewProps) {
return (
<main className="preview">
<div className="browser">
<div className="browser-bar">
<span className="dot dot-red" />
<span className="dot dot-yellow" />
<span className="dot dot-green" />
<span className="browser-url">preview.wursor.dev</span>
{applied && <span className="preview-badge">Applied to your site </span>}
</div>
<div className="browser-body">
{status === 'working' ? (
<div className="preview-working">Applying your change</div>
) : (
<div className="mock-site">
<nav className="mock-nav">
<span className="mock-brand">Your Site</span>
<span>About</span>
<span>Contact</span>
</nav>
<div className="mock-hero">
<h1 className="wursor-preview-frame">{heading}</h1>
<p>This is a live preview of your sandbox the real site stays untouched until you approve.</p>
</div>
</div>
)}
</div>
</div>
</main>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useState, type FormEvent } from 'react';
type SignUpProps = {
onSignUp: (email: string, password: string) => Promise<void>;
};
export function SignUp({ onSignUp }: SignUpProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await onSignUp(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setSubmitting(false);
}
}
return (
<form className="wursor-signup" onSubmit={submit}>
<input name="email" type="email" aria-label="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input
name="password"
type="password"
aria-label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error ? <p role="alert">{error}</p> : null}
<button type="submit" disabled={submitting}>
Sign up
</button>
</form>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
type SiteConnectorProps = {
code: string;
checkConnected: () => Promise<{ connected: boolean }>;
pollIntervalMs?: number;
};
type State = 'pending' | 'connected' | 'error';
export function SiteConnector({ code, checkConnected, pollIntervalMs = 2000 }: SiteConnectorProps) {
const [state, setState] = useState<State>('pending');
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function poll() {
try {
const { connected } = await checkConnected();
if (cancelled) return;
if (connected) {
setState('connected');
return;
}
timer = setTimeout(poll, pollIntervalMs);
} catch {
if (!cancelled) setState('error');
}
}
void poll();
return () => {
cancelled = true;
if (timer !== undefined) clearTimeout(timer);
};
}, [checkConnected, pollIntervalMs]);
if (state === 'connected') {
return <div className="wursor-connected">Site connected</div>;
}
if (state === 'error') {
return <div className="wursor-error">Connection failed</div>;
}
return (
<div className="wursor-pairing">
<p>Enter this code in your Wursor plugin:</p>
<code className="wursor-pairing-code">{code}</code>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useState } from 'react';
export type ChatMessage = { id: string; role: 'user' | 'agent'; text: string };
export type ChatStatus = 'idle' | 'working' | 'done';
function extractHeading(text: string): string | undefined {
const match = text.match(/["“']([^"”']+)["”']/);
return match?.[1];
}
function mockReply(text: string, heading: string | undefined): string {
return heading !== undefined
? `Done — I updated the homepage heading to “${heading}”. Preview it below.`
: 'Done — your change is ready to preview.';
}
export function useChat(sessionToken: string | null) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>('idle');
const [heading, setHeading] = useState('Welcome to our site');
const send = useCallback(
async (text: string) => {
const nextHeading = extractHeading(text);
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
setStatus('working');
try {
const res = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(sessionToken !== null ? { Authorization: `Bearer ${sessionToken}` } : {}),
},
body: JSON.stringify({ message: text }),
});
if (!res.ok) throw new Error('chat unavailable');
const body = (await res.json()) as { reply?: string };
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: body.reply ?? 'Done' }]);
} catch {
await new Promise((resolve) => setTimeout(resolve, 1200));
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: mockReply(text, nextHeading) }]);
}
if (nextHeading !== undefined) {
setHeading(nextHeading);
}
setStatus('done');
},
[sessionToken],
);
return { messages, status, heading, send };
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+495
View File
@@ -0,0 +1,495 @@
:root {
--bg-app: #0c0c10;
--bg-panel: #111115;
--bg-toolbar: #141418;
--bg-surface: #1c1c22;
--bg-input: rgba(255, 255, 255, 0.04);
--fg: rgba(255, 255, 255, 0.88);
--fg-2: rgba(255, 255, 255, 0.45);
--fg-3: rgba(255, 255, 255, 0.22);
--border: rgba(255, 255, 255, 0.055);
--border-2: rgba(255, 255, 255, 0.1);
--accent: #3385ff;
--accent-bg: rgba(51, 133, 255, 0.14);
--accent-strong: #0066ff;
--success: #10b981;
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--sh-lg: 0 10px 24px rgba(0, 0, 0, 0.35), 0 4px 8px rgba(0, 0, 0, 0.25);
--sh-product: 0 48px 96px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.06);
--r-2: 8px;
--r-3: 12px;
--r-4: 16px;
--r-5: 20px;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg-app);
color: var(--fg);
-webkit-font-smoothing: antialiased;
}
/* ---------- Auth screen ---------- */
.auth-screen {
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(51, 133, 255, 0.08), transparent 60%), var(--bg-app);
padding: 40px;
}
.wursor-signup {
width: 360px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--r-5);
padding: 40px 32px;
box-shadow: var(--sh-lg);
display: flex;
flex-direction: column;
gap: 14px;
}
.wursor-signup input {
background: var(--bg-input);
border: 1px solid var(--border-2);
border-radius: var(--r-2);
color: var(--fg);
padding: 12px 14px;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.wursor-signup input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-bg);
}
.wursor-signup button {
margin-top: 4px;
background: var(--accent-strong);
color: #fff;
border: none;
border-radius: var(--r-2);
padding: 12px;
font-weight: 600;
font-size: 0.9375rem;
cursor: pointer;
transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease);
}
.wursor-signup button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 102, 255, 0.35);
}
.wursor-signup button:disabled {
opacity: 0.5;
cursor: default;
}
.wursor-signup [role='alert'] {
color: #ef4444;
font-size: 0.8125rem;
margin: 0;
}
/* ---------- App chrome ---------- */
.app {
height: 100vh;
display: flex;
flex-direction: column;
position: relative;
}
.app-topbar {
height: 60px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border);
}
.app-logo {
font-weight: 800;
font-size: 1rem;
letter-spacing: -0.02em;
}
.app-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 0.625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-3);
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 9999px;
}
.app-body {
flex: 1;
display: flex;
min-height: 0;
}
/* ---------- Chat panel ---------- */
.chat {
width: 380px;
flex-shrink: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
}
.wursor-welcome h2 {
font-size: 1.625rem;
font-weight: 800;
letter-spacing: -0.028em;
margin: 8px 0;
line-height: 1.25;
}
.welcome-kicker {
font-family: 'JetBrains Mono', monospace;
font-size: 0.625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
margin: 0;
}
.welcome-sub {
color: var(--fg-2);
font-size: 0.9375rem;
line-height: 1.6;
margin: 0;
}
.chat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 85%;
padding: 12px 14px;
border-radius: var(--r-3);
font-size: 0.9375rem;
line-height: 1.5;
animation: pop 0.2s var(--ease);
}
.message-user {
align-self: flex-end;
background: var(--accent-strong);
color: #fff;
border-bottom-right-radius: 4px;
}
.message-agent {
align-self: flex-start;
background: var(--bg-surface);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.wursor-working {
color: var(--fg-2);
font-size: 0.8125rem;
padding: 12px 2px;
animation: pulse 1.2s ease-in-out infinite;
}
.chat-composer {
display: flex;
gap: 8px;
padding: 16px;
border-top: 1px solid var(--border);
}
.wursor-chat-input {
flex: 1;
background: var(--bg-input);
border: 1px solid var(--border-2);
border-radius: var(--r-2);
color: var(--fg);
padding: 12px 14px;
font-size: 0.9375rem;
outline: none;
}
.wursor-chat-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-bg);
}
.wursor-chat-send {
width: 44px;
background: var(--accent-strong);
color: #fff;
border: none;
border-radius: var(--r-2);
font-size: 1.2rem;
cursor: pointer;
transition: transform 0.12s var(--ease);
}
.wursor-chat-send:hover {
transform: translateY(-1px);
}
/* ---------- Preview ---------- */
.preview {
flex: 1;
display: grid;
place-items: center;
padding: 32px;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 24px 24px;
overflow: hidden;
}
.browser {
width: min(1000px, 100%);
height: min(640px, 100%);
background: #ffffff;
border-radius: var(--r-5);
box-shadow: var(--sh-product);
display: flex;
flex-direction: column;
overflow: hidden;
}
.browser-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f4f4f5;
border-bottom: 1px solid rgba(0, 0, 0, 0.07);
}
.dot {
width: 11px;
height: 11px;
border-radius: 9999px;
}
.dot-red {
background: #ff5f57;
}
.dot-yellow {
background: #febc2e;
}
.dot-green {
background: #28c840;
}
.browser-url {
flex: 1;
text-align: center;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.07);
border-radius: 6px;
padding: 5px 12px;
font-size: 0.8125rem;
color: #71717a;
}
.preview-badge {
color: #047857;
font-size: 0.8125rem;
font-weight: 600;
}
.browser-body {
flex: 1;
overflow: auto;
background: #ffffff;
color: #0a0a0a;
}
.preview-working {
height: 100%;
display: grid;
place-items: center;
color: #71717a;
font-size: 0.9375rem;
animation: pulse 1.2s ease-in-out infinite;
}
.mock-site {
min-height: 100%;
display: flex;
flex-direction: column;
}
.mock-nav {
display: flex;
align-items: center;
gap: 24px;
padding: 20px 40px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
font-size: 0.875rem;
color: #52525b;
}
.mock-brand {
font-weight: 700;
color: #0a0a0a;
}
.mock-hero {
padding: 80px 40px;
text-align: center;
}
.mock-hero h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.03em;
color: #0a0a0a;
margin: 0 0 16px;
transition: opacity 0.3s var(--ease);
}
.mock-hero p {
color: #52525b;
max-width: 480px;
margin: 0 auto;
line-height: 1.7;
}
/* ---------- Approve bar ---------- */
.approve-bar {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
background: rgba(28, 28, 34, 0.92);
backdrop-filter: blur(20px) saturate(1.6);
border: 1px solid var(--border-2);
border-radius: 9999px;
padding: 8px 10px 8px 20px;
box-shadow: var(--sh-lg);
animation: rise 0.25s var(--ease);
}
.approve-copy {
color: var(--fg-2);
font-size: 0.875rem;
}
.wursor-approve-button {
background: var(--accent-strong);
color: #fff;
border: none;
border-radius: 9999px;
padding: 10px 18px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease);
}
.wursor-approve-button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 102, 255, 0.4);
}
.wursor-reject-button {
background: transparent;
color: var(--fg-2);
border: 1px solid var(--border-2);
border-radius: 9999px;
padding: 10px 18px;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.12s var(--ease);
}
.wursor-reject-button:hover {
background: var(--bg-surface);
}
/* ---------- Motion ---------- */
@keyframes pop {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes rise {
from {
opacity: 0;
transform: translate(-50%, 12px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@media (max-width: 800px) {
.app-body {
flex-direction: column;
}
.chat {
width: 100%;
height: 50%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.preview {
height: 50%;
padding: 16px;
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});

Some files were not shown because too many files have changed in this diff Show More