Compare commits
18
Commits
bb84ab5aef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9de6ba62c | ||
|
|
060b181e71 | ||
|
|
69287ebec0 | ||
|
|
b61ff21710 | ||
|
|
69b2481299 | ||
|
|
9801b9475b | ||
|
|
02f55b4543 | ||
|
|
df2315087d | ||
|
|
97f31315bd | ||
|
|
a037b882e9 | ||
|
|
9733ba6e3c | ||
|
|
2f53b1901b | ||
|
|
6eeac23719 | ||
|
|
9a7058c85b | ||
|
|
0d6afab425 | ||
|
|
f42549c451 | ||
|
|
ffe666f479 | ||
|
|
d34347b1d9 |
+12
-2
@@ -2,17 +2,27 @@
|
||||
|
||||
# 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
|
||||
SESSION_SECRET=replace-me
|
||||
|
||||
# LLM — Grok is the default adapter. Switch provider with LLM_PROVIDER.
|
||||
# LLM — Grok is the default adapter. Switch provider with LLM_PROVIDER (grok | openrouter).
|
||||
LLM_PROVIDER=grok
|
||||
XAI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
# Model sent when LLM_PROVIDER=openrouter (default x-ai/grok-4.6).
|
||||
OPENROUTER_MODEL=x-ai/grok-4.6
|
||||
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
|
||||
|
||||
@@ -8,6 +8,9 @@ concurrency:
|
||||
group: mirror
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,4 +22,4 @@ jobs:
|
||||
- name: Reconcile with GitHub
|
||||
run: bash scripts/mirror.sh
|
||||
env:
|
||||
MIRROR_URL: https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/SinachPat/wursor.git
|
||||
MIRROR_URL: https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/Wursor/wursor.git
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
- run: pnpm lint
|
||||
@@ -8,6 +8,9 @@ concurrency:
|
||||
group: mirror
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,4 +22,4 @@ jobs:
|
||||
- name: Reconcile with Gitea
|
||||
run: bash scripts/mirror.sh
|
||||
env:
|
||||
MIRROR_URL: https://x-access-token:${{ secrets.GITEA_TOKEN }}@git.weown.tools/Wursor/s004.git
|
||||
MIRROR_URL: https://x-access-token:${{ secrets.GITEA }}@git.weown.tools/pat/wursor.git
|
||||
|
||||
+30
-1
@@ -1,8 +1,37 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
# Secrets — never commit real credentials, keys, or tokens.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.envrc
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
*.jks
|
||||
*.keystore
|
||||
id_rsa
|
||||
id_rsa.*
|
||||
id_ed25519
|
||||
id_ed25519.*
|
||||
id_ecdsa
|
||||
id_ecdsa.*
|
||||
*.token
|
||||
credentials.json
|
||||
credentials*.json
|
||||
service-account*.json
|
||||
secrets.json
|
||||
secrets*.json
|
||||
.netrc
|
||||
.npmrc
|
||||
*.tfvars
|
||||
!.tfvars.example
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
.wp-env/
|
||||
vendor/
|
||||
dist/
|
||||
|
||||
@@ -12,7 +12,7 @@ The Agentic WordPress Management Platform
|
||||
| **Date** | August 13, 2026 |
|
||||
| **Author** | Patrick (Product Lead) |
|
||||
| **Status** | Draft — Internal (non-technical-first pivot; Phase 0) |
|
||||
| **Repo** | SinachPat/wursor |
|
||||
| **Repo** | Wursor/wursor |
|
||||
| **Classification** | Confidential |
|
||||
| **Supersedes** | v1.3 (engineer-first; desktop shell; Tauri + Monaco) |
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
@@ -0,0 +1,7 @@
|
||||
export type ToolResult = {
|
||||
result: string;
|
||||
};
|
||||
|
||||
export interface ToolExecutor {
|
||||
execute(name: string, args: Record<string, string>): Promise<ToolResult>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,5 @@ Rename the repository to `wursor`, set the description to the product tagline, c
|
||||
|
||||
- Remote is `https://github.com/SinachPat/wursor`; identity matches the product.
|
||||
- Redirects from the old name are handled by GitHub automatically.
|
||||
|
||||
> **Follow-up (2026-08-15):** the repo was later transferred to the `Wursor` GitHub organization — canonical path is now `https://github.com/Wursor/wursor`.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# 10. Golden harness scores live runs through a provider-agnostic LLM client (OpenRouter first)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-15
|
||||
|
||||
## Context
|
||||
|
||||
Phase 0's golden-task spike was the last gate item, stuck at "partial" because no `XAI_API_KEY` was set and the harness hard-coded `api.x.ai`. The developer holds an OpenRouter key, and OpenRouter speaks the same OpenAI-compatible `chat/completions` shape (including tool-calling), so the live run could be unblocked without a Grok-specific key.
|
||||
|
||||
## Decision
|
||||
|
||||
Replace the hard-coded `grok-client.ts` with a provider-agnostic `llm-client.ts` that supports `grok` (x.ai) and `openrouter`, selected by `LLM_PROVIDER`. The OpenRouter default model is `x-ai/grok-4.6`. The harness now passes the site's page slugs into the prompt.
|
||||
|
||||
### Options considered
|
||||
|
||||
- Wait for an `XAI_API_KEY` and keep the hard-coded x.ai client.
|
||||
- Hard-code OpenRouter, dropping the x.ai path.
|
||||
- Provider-agnostic client with `grok` + `openrouter` (chosen).
|
||||
|
||||
### Rejected
|
||||
|
||||
- Wait for x.ai — blocks the gate on a key we don't have, for no technical reason.
|
||||
- OpenRouter-only — the plan (IMPLEMENTATION §3) still names Grok the default adapter; keeping both providers matches that plan and costs one env switch.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The live gate run is scored and passing (`gb-01` passed on `x-ai/grok-4.6`); the Phase 0 golden spike flips to done.
|
||||
- `x-ai/grok-latest` is not a callable OpenRouter ID (returns 400); the pinned `x-ai/grok-4.6` works and supports `tool_choice: required`.
|
||||
- A real finding: without the page-slug list in the prompt, the model guessed `page: "home"` and failed. Tool-call prompts must always carry site/page context. Sprint 3 must inherit this when it builds `api/src/agents/llm-client.ts`.
|
||||
- `llm-client.ts` here is a spike; Sprint 3 will generalize it (streaming, circuit breaker, fallback) rather than promote this file verbatim.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -23,6 +23,14 @@ Each ADR is a single file following the [Nygard format](https://cognitect.com/bl
|
||||
| [0007](0007-media-proxy-not-copy.md) | Sandboxes proxy uploads; never copy the media library | Accepted |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -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." }]
|
||||
}
|
||||
]
|
||||
@@ -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(/ /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`);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { resolveProvider, callLlm } from '../src/llm-client.ts';
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('resolveProvider', () => {
|
||||
it('maps grok to the x.ai endpoint and grok-3 model', () => {
|
||||
expect(resolveProvider('grok')).toEqual({ baseUrl: 'https://api.x.ai/v1', model: 'grok-3' });
|
||||
});
|
||||
|
||||
it('maps openrouter to the openrouter endpoint and the grok-4.6 model', () => {
|
||||
expect(resolveProvider('openrouter')).toEqual({
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'x-ai/grok-4.6',
|
||||
});
|
||||
});
|
||||
|
||||
it('honors an explicit model override', () => {
|
||||
expect(resolveProvider('openrouter', 'openai/gpt-4o')).toEqual({
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'openai/gpt-4o',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callLlm', () => {
|
||||
it('posts an OpenAI-shaped tool-call request to the grok endpoint', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ choices: [] }) });
|
||||
|
||||
await callLlm({
|
||||
provider: 'grok',
|
||||
apiKey: 'test-key',
|
||||
prompt: 'Change the heading',
|
||||
siteId: 'gutenberg-business',
|
||||
builder: 'gutenberg',
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string>; body: string }];
|
||||
expect(url).toBe('https://api.x.ai/v1/chat/completions');
|
||||
expect(init.headers.Authorization).toBe('Bearer test-key');
|
||||
const body = JSON.parse(init.body) as { model: string; tool_choice: string; tools: Array<{ function: { name: string } }> };
|
||||
expect(body.model).toBe('grok-3');
|
||||
expect(body.tool_choice).toBe('required');
|
||||
expect(body.tools[0]?.function.name).toBe('edit_heading');
|
||||
});
|
||||
|
||||
it('adds OpenRouter identification headers and uses the openrouter endpoint', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ choices: [] }) });
|
||||
|
||||
await callLlm({
|
||||
provider: 'openrouter',
|
||||
apiKey: 'test-key',
|
||||
prompt: 'Change the heading',
|
||||
siteId: 'gutenberg-business',
|
||||
builder: 'gutenberg',
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: Record<string, string>; body: string }];
|
||||
expect(url).toBe('https://openrouter.ai/api/v1/chat/completions');
|
||||
expect(init.headers['HTTP-Referer']).toBeDefined();
|
||||
expect(init.headers['X-Title']).toBeDefined();
|
||||
expect(JSON.parse(init.body).model).toBe('x-ai/grok-4.6');
|
||||
});
|
||||
|
||||
it('includes page slugs in the user message when provided', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ choices: [] }) });
|
||||
|
||||
await callLlm({
|
||||
provider: 'grok',
|
||||
apiKey: 'test-key',
|
||||
prompt: 'Change the heading',
|
||||
siteId: 'gutenberg-business',
|
||||
builder: 'gutenberg',
|
||||
pages: ['homepage', 'about', 'contact'],
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, { body: string }];
|
||||
const body = JSON.parse(init.body) as { messages: Array<{ content: string }> };
|
||||
expect(body.messages[1]?.content).toContain('pages=homepage,about,contact');
|
||||
});
|
||||
|
||||
it('throws when the provider returns a non-ok response', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401 });
|
||||
|
||||
await expect(
|
||||
callLlm({ provider: 'grok', apiKey: 'bad', prompt: 'x', siteId: 's', builder: 'b' }),
|
||||
).rejects.toThrow('LLM HTTP 401');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@
|
||||
"fixturePassed": 20,
|
||||
"fixtureTotal": 20,
|
||||
"grokLive": {
|
||||
"skipped": true,
|
||||
"reason": "XAI_API_KEY not set"
|
||||
"id": "gb-01",
|
||||
"passed": true
|
||||
},
|
||||
"scores": [
|
||||
{
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
import type { GrokResponse } from './types.ts';
|
||||
|
||||
const url = 'https://api.x.ai/v1/chat/completions';
|
||||
export type LlmProvider = 'grok' | 'openrouter';
|
||||
|
||||
export async function callGrok(input: {
|
||||
export type LlmConfig = {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const PROVIDERS: Record<LlmProvider, LlmConfig> = {
|
||||
grok: { baseUrl: 'https://api.x.ai/v1', model: 'grok-3' },
|
||||
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'x-ai/grok-4.6' },
|
||||
};
|
||||
|
||||
export function resolveProvider(provider: LlmProvider, model?: string): LlmConfig {
|
||||
const config = PROVIDERS[provider];
|
||||
return { baseUrl: config.baseUrl, model: model ?? config.model };
|
||||
}
|
||||
|
||||
export async function callLlm(input: {
|
||||
provider: LlmProvider;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
builder: string;
|
||||
pages?: string[];
|
||||
}): Promise<GrokResponse> {
|
||||
const response = await fetch(url, {
|
||||
const { baseUrl, model } = resolveProvider(input.provider, input.model);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (input.provider === 'openrouter') {
|
||||
headers['HTTP-Referer'] = 'https://wursor.dev';
|
||||
headers['X-Title'] = 'Wursor golden harness';
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: 'grok-3',
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -24,7 +50,7 @@ export async function callGrok(input: {
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `site=${input.siteId} builder=${input.builder}\n${input.prompt}`,
|
||||
content: `site=${input.siteId} builder=${input.builder}${input.pages ? ` pages=${input.pages.join(',')}` : ''}\n${input.prompt}`,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
@@ -71,7 +97,7 @@ export async function callGrok(input: {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grok HTTP ${response.status}`);
|
||||
throw new Error(`LLM HTTP ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as GrokResponse;
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { detectBuilder } from './builder-detect.ts';
|
||||
import { asGrokResponse, expectedCalls } from './expected-calls.ts';
|
||||
import { callGrok } from './grok-client.ts';
|
||||
import { callLlm, type LlmProvider } from './llm-client.ts';
|
||||
import { loadPrompts } from './load-prompts.ts';
|
||||
import { loadSite } from './load-site.ts';
|
||||
import { scoreGrokResponse } from './score.ts';
|
||||
|
||||
const goldenRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = join(goldenRoot, '..', '..');
|
||||
|
||||
try {
|
||||
loadEnvFile(join(repoRoot, '.env'));
|
||||
} catch {
|
||||
// no .env at repo root — live run will skip
|
||||
}
|
||||
|
||||
function provider(): LlmProvider {
|
||||
return process.env.LLM_PROVIDER === 'openrouter' ? 'openrouter' : 'grok';
|
||||
}
|
||||
|
||||
function key(): string | undefined {
|
||||
const value = process.env.XAI_API_KEY;
|
||||
const value = provider() === 'openrouter' ? process.env.OPENROUTER_API_KEY : process.env.XAI_API_KEY;
|
||||
return value !== undefined && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
@@ -39,11 +51,14 @@ if (apiKey !== undefined) {
|
||||
}
|
||||
const site = loadSite(prompt.site);
|
||||
try {
|
||||
const grok = await callGrok({
|
||||
const grok = await callLlm({
|
||||
provider: provider(),
|
||||
apiKey,
|
||||
model: provider() === 'openrouter' ? process.env.OPENROUTER_MODEL : undefined,
|
||||
prompt: prompt.prompt,
|
||||
siteId: site.id,
|
||||
builder: detectBuilder(site),
|
||||
pages: site.posts.map((post) => post.slug),
|
||||
});
|
||||
grokLive = {
|
||||
id: prompt.id,
|
||||
@@ -57,7 +72,10 @@ if (apiKey !== undefined) {
|
||||
const report = {
|
||||
fixturePassed: fixtureScores.filter((row) => row.passed).length,
|
||||
fixtureTotal: fixtureScores.length,
|
||||
grokLive: grokLive ?? { skipped: true, reason: 'XAI_API_KEY not set' },
|
||||
grokLive: grokLive ?? {
|
||||
skipped: true,
|
||||
reason: provider() === 'openrouter' ? 'OPENROUTER_API_KEY not set' : 'XAI_API_KEY not set',
|
||||
},
|
||||
scores: fixtureScores,
|
||||
};
|
||||
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 + 1–2 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 + 1–2 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), 0013–0015 (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
|
||||
@@ -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:
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
@@ -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'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
Generated
+2003
-14
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ Throwaway fixtures and scripts are allowed. Product UI is not.
|
||||
|
||||
| Spike | File | Status |
|
||||
|---|---|---|
|
||||
| Golden-task harness (R7) | [golden-task.md](./golden-task.md) | partial — live Grok pending key |
|
||||
| Golden-task harness (R7) | [golden-task.md](./golden-task.md) | done — live run scored via OpenRouter |
|
||||
| Builder detect (R6 / R13) | [builder-detect.md](./builder-detect.md) | done |
|
||||
| Pairing threat model (R9) | [pairing-threat-model.md](./pairing-threat-model.md) | done |
|
||||
| Large-site mirror timing (R4) | [mirror-timing.md](./mirror-timing.md) | done — synthetic 2GB |
|
||||
|
||||
+14
-8
@@ -1,6 +1,6 @@
|
||||
# Spike: golden-task harness (R7)
|
||||
|
||||
**Status:** partial — harness exists; live Grok run not scored (`XAI_API_KEY` unset)
|
||||
**Status:** done — live run scored via OpenRouter (`x-ai/grok-4.6`), `gb-01` passed
|
||||
|
||||
## Question
|
||||
|
||||
@@ -15,7 +15,7 @@ Can we score a model on WordPress tasks without vibes?
|
||||
|
||||
## Result
|
||||
|
||||
Yes, if “score” means: apply a tool call to a fixture and assert the new heading/option. No, if it means we have a Grok quality number. This machine has no `XAI_API_KEY`, so the live call was skipped.
|
||||
Yes, if “score” means: apply a tool call to a fixture and assert the new heading/option. The harness is now live-scored through OpenRouter.
|
||||
|
||||
### What exists
|
||||
|
||||
@@ -24,22 +24,28 @@ Yes, if “score” means: apply a tool call to a fixture and assert the new hea
|
||||
| 20 prompts | `e2e/golden/prompts.json` |
|
||||
| Gutenberg dental site | `e2e/golden/sites/gutenberg-business/site.json` |
|
||||
| Elementor restaurant site | `e2e/golden/sites/elementor-restaurant/site.json` |
|
||||
| Apply + assert + Grok parser | `e2e/golden/src/` |
|
||||
| Apply + assert + LLM parser | `e2e/golden/src/` |
|
||||
| Provider client (grok + openrouter) | `e2e/golden/src/llm-client.ts` |
|
||||
| Scoreboard | `e2e/golden/runs/latest.json` |
|
||||
|
||||
Two sites. Ten prompts each. Assertions are `preview_text`, `option`, or `screenshot`. Screenshot here means “the fixture page text must contain X” — not a PNG/SSIM check.
|
||||
|
||||
`pnpm --filter @wursor/e2e golden` scored **20/20** fixture tool traces. Live Grok: skipped.
|
||||
`pnpm --filter @wursor/e2e golden` scored **20/20** fixture tool traces and **gb-01 passed live** (`x-ai/grok-4.6` via OpenRouter).
|
||||
|
||||
`pnpm test:e2e` — 21 tests, including the scorer.
|
||||
`pnpm test:e2e` — 28 tests, including the scorer and the provider client.
|
||||
|
||||
### How to score a real Grok run
|
||||
### How to score a real run
|
||||
|
||||
```bash
|
||||
XAI_API_KEY=… pnpm --filter @wursor/e2e golden
|
||||
# .env: LLM_PROVIDER=openrouter, OPENROUTER_API_KEY=…
|
||||
pnpm --filter @wursor/e2e golden
|
||||
```
|
||||
|
||||
That sends `gb-01` through `api.x.ai` and asserts the homepage heading.
|
||||
Sends `gb-01` through OpenRouter and asserts the homepage heading.
|
||||
|
||||
### Finding
|
||||
|
||||
The model needs the page slugs in context. The first live call returned `page: "home"` and failed; after passing `pages=homepage,about,…` in the prompt, `gb-01` passed. The real agent must ship page/site context with every tool-call prompt.
|
||||
|
||||
### Decision
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user