Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0699c8f
chore: remove demo seed data
rogerchappel May 28, 2026
29e5da4
test: create explicit core API e2e fixtures
rogerchappel May 28, 2026
f2e507e
test: isolate inbox and chat e2e fixtures
rogerchappel May 28, 2026
c283da2
fix: require auth for core API lists
rogerchappel May 28, 2026
25c9ea6
fix: require auth for inbox and docs APIs
rogerchappel May 28, 2026
d2cda74
fix: require auth for task detail resources
rogerchappel May 28, 2026
07f2701
fix: require auth for time tracking APIs
rogerchappel May 28, 2026
baf023b
fix: require auth for project and blueprint APIs
rogerchappel May 28, 2026
ac93e7b
fix: require auth for agent runtime views
rogerchappel May 28, 2026
3375026
fix: require auth for agent skill sharing APIs
rogerchappel May 28, 2026
4ec6b93
fix: require auth for runtime management APIs
rogerchappel May 28, 2026
5bf77ca
fix: require auth for schedules and triage cron
rogerchappel May 28, 2026
3210a19
fix: require auth for runtime status and axiom cron
rogerchappel May 28, 2026
92fe73c
fix: require auth for skill management APIs
rogerchappel May 28, 2026
0d5ec63
fix: require auth for skill browse and import APIs
rogerchappel May 28, 2026
78bee0f
fix: require auth for openclaw status APIs
rogerchappel May 28, 2026
14cc119
fix: require auth for openclaw node status
rogerchappel May 28, 2026
5125c21
test: expand API auth coverage
rogerchappel May 28, 2026
4015fca
test: mock auth in API route tests
rogerchappel May 28, 2026
012e0a9
docs: update API auth guidance
rogerchappel May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ chore: update dependencies

## API Changes

- All GET endpoints are public (no auth required)
- Mutations (POST/PATCH/DELETE) require `Authorization: Bearer <HEARTBEAT_SECRET>` or a valid NextAuth session
- Most endpoints require a valid NextAuth session; runtime-scoped routes may also accept `Authorization: Bearer <HEARTBEAT_SECRET>` when explicitly documented
- Public endpoints must be explicitly documented as public
- Don't break existing API contracts. Existing consumers (crons, agent integrations) depend on them.

## Testing
Expand Down
46 changes: 35 additions & 11 deletions e2e/agents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,25 @@ test.describe("Agent management", () => {
await loginViaApi(request);
});

test("GET /api/agents returns seeded agents", async ({ request }) => {
test("GET /api/agents returns created agents", async ({ request }) => {
const callsign = uniqueName("LISTAG").toUpperCase().replace(/-/g, "");
await apiPost(
request,
"/api/agents",
{
name: "Listed Agent",
callsign,
title: "E2E Listed Agent",
},
{ expectStatus: 201 },
);

const data = await apiGet(request, "/api/agents");
expect(data.agents).toBeDefined();
expect(Array.isArray(data.agents)).toBe(true);
// Seed creates 7 agents
expect(data.agents.length).toBeGreaterThanOrEqual(7);

const callsigns = data.agents.map((a: { callsign: string }) => a.callsign);
expect(callsigns).toContain("Neo");
expect(callsigns).toContain("Cipher");
expect(callsigns).toContain("Havoc");
expect(callsigns).toContain(callsign);
});

test("POST /api/agents creates a new agent", async ({ request }) => {
Expand Down Expand Up @@ -59,10 +67,18 @@ test.describe("Agent management", () => {
});

test("GET /api/agents/:callsign returns a single agent", async ({ request }) => {
const data = await apiGet(request, "/api/agents/Neo");
expect(data.callsign).toBe("Neo");
expect(data.name).toBe("Neo");
expect(data.title).toBe("Chief Revenue Officer");
const callsign = uniqueName("GETAG").toUpperCase().replace(/-/g, "");
await apiPost(
request,
"/api/agents",
{ name: "Get Agent", callsign, title: "Lookup Engineer" },
{ expectStatus: 201 },
);

const data = await apiGet(request, `/api/agents/${callsign}`);
expect(data.callsign).toBe(callsign);
expect(data.name).toBe("Get Agent");
expect(data.title).toBe("Lookup Engineer");
});

test("PATCH /api/agents/:callsign updates an agent", async ({ request }) => {
Expand Down Expand Up @@ -103,7 +119,15 @@ test.describe("Agent management", () => {
});

test("GET /api/agents/:callsign/status returns status info", async ({ request }) => {
const data = await apiGet(request, "/api/agents/Neo/status");
const callsign = uniqueName("STATAG").toUpperCase().replace(/-/g, "");
await apiPost(
request,
"/api/agents",
{ name: "Status Agent", callsign, title: "Status Engineer" },
{ expectStatus: 201 },
);

const data = await apiGet(request, `/api/agents/${callsign}/status`);
// Status endpoint returns running state info
expect(data).toHaveProperty("status");
});
Expand Down
124 changes: 94 additions & 30 deletions e2e/api-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,52 @@ import { test, expect } from "@playwright/test";
import { uniqueName, loginViaApi } from "./helpers";

test.describe("API authentication", () => {
test.describe("GETs are publicly accessible", () => {
test("GET /api/agents is public", async ({ request }) => {
const res = await request.get("/api/agents");
expect(res.status()).toBe(200);
});

test("GET /api/tasks is public", async ({ request }) => {
const res = await request.get("/api/tasks");
expect(res.status()).toBe(200);
});

test("GET /api/projects is public", async ({ request }) => {
const res = await request.get("/api/projects");
expect(res.status()).toBe(200);
});
test.describe("business data requires authentication", () => {
const protectedGets = [
"/api/agents",
"/api/agents/NOPE/status",
"/api/agents/NOPE/output",
"/api/agents/NOPE/output/stream",
"/api/tasks",
"/api/tasks/00000000-0000-4000-8000-000000000000",
"/api/tasks/00000000-0000-4000-8000-000000000000/comments",
"/api/tasks/00000000-0000-4000-8000-000000000000/images",
"/api/tasks/00000000-0000-4000-8000-000000000000/time-entries",
"/api/projects",
"/api/projects/00000000-0000-4000-8000-000000000000",
"/api/inbox",
"/api/inbox/stats",
"/api/docs/00000000-0000-4000-8000-000000000000",
"/api/time-entries",
"/api/blueprints",
"/api/blueprints/builtin-startup-founding-team",
"/api/skills",
"/api/skills/browse",
"/api/skills/sync-status",
"/api/runtimes",
"/api/schedules",
"/api/automations/runs?job_id=e2e",
"/api/openclaw/agents",
"/api/openclaw/bridge/status",
"/api/openclaw/health",
"/api/openclaw/nodes",
"/api/runtime/check",
"/api/runtime/status",
"/api/cron/triage",
"/api/cron/axiom-research",
];

for (const path of protectedGets) {
test(`GET ${path} without auth returns 401`, async ({ request }) => {
const res = await request.get(path);
expect(res.status()).toBe(401);
});
}
});

test("GET /api/inbox is public", async ({ request }) => {
const res = await request.get("/api/inbox");
test.describe("public auth bootstrap endpoints", () => {
test("GET /api/health is public", async ({ request }) => {
const res = await request.get("/api/health");
expect(res.status()).toBe(200);
});

Expand Down Expand Up @@ -50,22 +78,58 @@ test.describe("API authentication", () => {
});

test("PATCH /api/tasks/:id without auth returns 401", async ({ request }) => {
// First get a real task ID
const tasks = await (await request.get("/api/tasks")).json();
if (tasks.length > 0) {
const res = await request.patch(`/api/tasks/${tasks[0].id}`, {
data: { status: "done" },
});
expect(res.status()).toBe(401);
}
const res = await request.patch("/api/tasks/00000000-0000-4000-8000-000000000000", {
data: { status: "done" },
});
expect(res.status()).toBe(401);
});

test("DELETE /api/projects/:id without auth returns 401", async ({ request }) => {
const projects = await (await request.get("/api/projects")).json();
if (projects.length > 0) {
const res = await request.delete(`/api/projects/${projects[0].id}`);
expect(res.status()).toBe(401);
}
const res = await request.delete("/api/projects/00000000-0000-4000-8000-000000000000");
expect(res.status()).toBe(401);
});

test("POST /api/runtimes/probe without auth returns 401", async ({ request }) => {
const res = await request.post("/api/runtimes/probe", {
data: { mode: "paste", config: "{}" },
});
expect(res.status()).toBe(401);
});

test("PATCH /api/schedules/:id without auth returns 401", async ({ request }) => {
const res = await request.patch("/api/schedules/e2e-schedule", {
data: { enabled: false },
});
expect(res.status()).toBe(401);
});

test("PATCH /api/agents/:callsign/skills/:id without auth returns 401", async ({ request }) => {
const res = await request.patch("/api/agents/NOPE/skills/00000000-0000-4000-8000-000000000000", {
data: { enabled: false },
});
expect(res.status()).toBe(401);
});

test("DELETE /api/agents/:callsign/skills/:id without auth returns 401", async ({ request }) => {
const res = await request.delete("/api/agents/NOPE/skills/00000000-0000-4000-8000-000000000000");
expect(res.status()).toBe(401);
});

test("POST /api/agents/access without auth returns 401", async ({ request }) => {
const res = await request.post("/api/agents/access", {
data: { agentId: "agent", userId: "user" },
});
expect(res.status()).toBe(401);
});

test("DELETE /api/agents/access/:id without auth returns 401", async ({ request }) => {
const res = await request.delete("/api/agents/access/00000000-0000-4000-8000-000000000000");
expect(res.status()).toBe(401);
});

test("POST /api/runtime/check without auth returns 401", async ({ request }) => {
const res = await request.post("/api/runtime/check");
expect(res.status()).toBe(401);
});
});

Expand Down
32 changes: 27 additions & 5 deletions e2e/chat-inbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ test.describe("Inbox", () => {

test.describe("Chat sessions", () => {
let companyId: string;
let sessionAgentCallsign: string;
let listAgentCallsign: string;

test.beforeAll(async ({ request }) => {
await loginViaApi(request);
Expand All @@ -180,6 +182,20 @@ test.describe("Chat sessions", () => {
});
companyId = company.id;
}

sessionAgentCallsign = uniqueName("CHATAG").toUpperCase().replace(/-/g, "");
listAgentCallsign = uniqueName("CHATLIST").toUpperCase().replace(/-/g, "");

await apiPost(request, "/api/agents", {
name: "Chat Agent",
callsign: sessionAgentCallsign,
title: "Chat Tester",
});
await apiPost(request, "/api/agents", {
name: "Chat List Agent",
callsign: listAgentCallsign,
title: "Chat List Tester",
});
});

test.beforeEach(async ({ request }) => {
Expand All @@ -188,20 +204,20 @@ test.describe("Chat sessions", () => {

test("POST /api/chat/sessions creates a session", async ({ request }) => {
const res = await apiPost(request, "/api/chat/sessions", {
agentId: "neo",
agentId: sessionAgentCallsign,
companyId,
title: uniqueName("E2E-Chat"),
});

expect(res.session).toBeDefined();
expect(res.session.agentId).toBe("neo");
expect(res.session.agentId).toBe(sessionAgentCallsign.toLowerCase());
expect(res.session.companyId).toBe(companyId);
});

test("GET /api/chat/sessions lists sessions for company", async ({ request }) => {
// Create a session first
await apiPost(request, "/api/chat/sessions", {
agentId: "cipher",
agentId: listAgentCallsign,
companyId,
});

Expand All @@ -215,7 +231,13 @@ test.describe("Chat sessions", () => {
});

test("GET /api/chat/sessions filters by agentId", async ({ request }) => {
const agentId = `e2echat${Date.now()}`;
const agentId = uniqueName("FILTERCHAT").toUpperCase().replace(/-/g, "");
await apiPost(request, "/api/agents", {
name: "Filter Chat Agent",
callsign: agentId,
title: "Filter Chat Tester",
});

await apiPost(request, "/api/chat/sessions", {
agentId,
companyId,
Expand All @@ -227,7 +249,7 @@ test.describe("Chat sessions", () => {
);
expect(res.sessions).toBeDefined();
for (const s of res.sessions) {
expect(s.agentId).toBe(agentId);
expect(s.agentId).toBe(agentId.toLowerCase());
}
});
});
6 changes: 2 additions & 4 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ export const TEST_USER = {
};

/**
* Bearer token for agent/system auth. Falls back to a test-only value when
* HEARTBEAT_SECRET is not set (PGlite dev mode still accepts it if the env
* var is unset, because `requireAuth` skips the bearer check when
* `expectedToken` is falsy — so session auth is the fallback).
* Bearer token for agent/system auth. Tests that exercise valid bearer auth
* should skip when HEARTBEAT_SECRET is not configured.
*/
export const BEARER_TOKEN = process.env.HEARTBEAT_SECRET ?? "e2e-test-secret";

Expand Down
14 changes: 8 additions & 6 deletions e2e/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ test.describe("Projects CRUD", () => {
await loginViaApi(request);
});

test("GET /api/projects returns seeded projects", async ({ request }) => {
test("GET /api/projects returns created projects", async ({ request }) => {
const firstName = uniqueName("E2E-ListProject-A");
const secondName = uniqueName("E2E-ListProject-B");
await apiPost(request, "/api/projects", { name: firstName });
await apiPost(request, "/api/projects", { name: secondName });

const projects = await apiGet(request, "/api/projects");
expect(Array.isArray(projects)).toBe(true);
// Seed creates 3 projects: CrewCmd, Product Launch, Content Pipeline
expect(projects.length).toBeGreaterThanOrEqual(3);
const names = projects.map((p: { name: string }) => p.name);
expect(names).toContain("CrewCmd");
expect(names).toContain("Product Launch");
expect(names).toContain("Content Pipeline");
expect(names).toContain(firstName);
expect(names).toContain(secondName);
});

test("POST /api/projects creates a new project", async ({ request }) => {
Expand Down
11 changes: 9 additions & 2 deletions e2e/tasks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ test.describe("Tasks lifecycle", () => {
await loginViaApi(request);
});

test("GET /api/tasks returns seeded tasks", async ({ request }) => {
test("GET /api/tasks returns created tasks", async ({ request }) => {
const firstTitle = uniqueName("E2E-ListTask-A");
const secondTitle = uniqueName("E2E-ListTask-B");
await apiPost(request, "/api/tasks", { title: firstTitle, status: "inbox" });
await apiPost(request, "/api/tasks", { title: secondTitle, status: "queued" });

const tasks = await apiGet(request, "/api/tasks");
expect(Array.isArray(tasks)).toBe(true);
expect(tasks.length).toBeGreaterThanOrEqual(8);
const titles = tasks.map((task: { title: string }) => task.title);
expect(titles).toContain(firstTitle);
expect(titles).toContain(secondTitle);
});

test("POST /api/tasks creates a task in inbox", async ({ request }) => {
Expand Down
2 changes: 1 addition & 1 deletion skills/crewcmd/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ CrewCmd is the task board, inbox, and agent management backend. All task operati
## Connection

- **Base URL:** Provided via `CREWCMD_URL` env var, or defaults to `https://localhost:3000`
- **Auth:** `Authorization: Bearer <HEARTBEAT_SECRET>` for mutations. GETs are public.
- **Auth:** Send `Authorization: Bearer <HEARTBEAT_SECRET>` for runtime-enabled API calls. Include `X-CrewCMD-Runtime-Id` and `workspaceId` or `companyId` when listing workspace data.
- Use `-k` flag with curl (self-signed TLS in local dev).

## Task Lifecycle
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/agents/[callsign]/output/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { runtime } from "@/lib/agent-runtime";
import { resolveReadableAgentByCallsign } from "@/lib/agent-route-auth";
import { requireUserOrRuntimeAuth } from "@/lib/require-auth";

export const dynamic = "force-dynamic";

Expand All @@ -10,6 +11,9 @@ interface RouteParams {

/** GET /api/agents/[callsign]/output — Get the output buffer for an agent */
export async function GET(request: NextRequest, { params }: RouteParams) {
const authError = await requireUserOrRuntimeAuth(request);
if (authError) return authError;

const { callsign } = await params;
const agent = await resolveReadableAgentByCallsign(callsign, request);

Expand Down
Loading
Loading