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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { mintApiKey, verifyApiKey } from "@hasna/contracts/auth";
import { describe, expect, test } from "bun:test";
import { createSqliteLoopStorage } from "../lib/storage/sqlite.js";
import type { Loop, WorkflowSpec } from "../types.js";
Expand Down Expand Up @@ -1014,4 +1015,127 @@ describe("loops-api foundation", () => {
await storage.close();
}
});
// Regression: incident 607176 / task c64e66bd. `claimRuns` pushed
// `publicLoop(claim.loop)`, which rewrites target.prompt to
// "[redacted N chars]" for agent targets. The runner executes the loop it is
// handed by the claim response, so every agent-type loop ran with a
// placeholder as its entire instruction and exited 0 having done nothing.
// Redaction belongs on operator-facing reads (GET /v1/loops), never on the
// runner's execution payload. This test FAILS against the unfixed base.
test("runner claim delivers the agent prompt unredacted while operator reads stay redacted", async () => {
const mod = await import("./index.js");
const storage = createSqliteLoopStorage(":memory:");
const now = new Date("2026-01-01T00:00:00Z");
const server = mod.createLoopsApiServer({ host: "127.0.0.1", port: 0, storage, now: () => now });
const prompt = "Using the Write tool, create /tmp/loop-probe.txt containing exactly SENTINEL-OK and nothing else.";

try {
const loop = await storage.createLoop(
{
name: "api-runner-agent-prompt",
schedule: { type: "once", at: "2026-01-01T00:00:00Z" },
target: { type: "agent", provider: "claude", prompt },
leaseMs: 60_000,
},
new Date("2025-12-31T00:00:00Z"),
);

const register = await fetch(apiUrl(server, "/v1/runners/register"), {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ runnerId: "runner-prompt", machineId: "machine-prompt" }),
});
expect(register.status).toBe(200);

const claimResponse = await fetch(apiUrl(server, "/v1/runners/claim"), {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ runnerId: "runner-prompt", maxClaims: 1 }),
});
expect(claimResponse.status).toBe(200);
const claimed = (await claimResponse.json()) as {
claims: Array<{ loop: { id: string; target: { prompt?: string } } }>;
};

// The runner must receive the REAL prompt.
expect(claimed.claims).toHaveLength(1);
expect(claimed.claims[0]!.loop.id).toBe(loop.id);
expect(claimed.claims[0]!.loop.target.prompt).toBe(prompt);
expect(claimed.claims[0]!.loop.target.prompt).not.toMatch(/^\[redacted/);

// ...while the operator-facing read stays redacted. Without this arm the
// fix could be "corrected" into a credential-leaking regression.
const read = await fetch(apiUrl(server, `/v1/loops/${loop.id}`));
expect(read.status).toBe(200);
const body = (await read.json()) as { loop: { target: { prompt?: string } } };
expect(body.loop.target.prompt).toBe(`[redacted ${prompt.length} chars]`);
} finally {
server.stop(true);
await storage.close();
}
});

test("runner claim requires execution scope before returning the raw loop payload", async () => {
const mod = await import("./index.js");
const storage = createSqliteLoopStorage(":memory:");
const now = new Date("2026-01-01T00:00:00Z");
const signingSecret = "runner-scope-test-signing-secret";
const authenticator = verifyApiKey({
app: "loops",
signingSecret,
nowMs: () => now.getTime(),
isRevoked: async () => false,
});
const readKey = mintApiKey({
app: "loops",
scopes: ["loops:read"],
signingSecret,
nowMs: now.getTime(),
ttlSeconds: 60,
});
const runnerKey = mintApiKey({
app: "loops",
scopes: ["loops:execute"],
signingSecret,
nowMs: now.getTime(),
ttlSeconds: 60,
});
const server = mod.createLoopsApiServer({ host: "127.0.0.1", port: 0, storage, now: () => now, authenticator });
const prompt = "NONSECRET_RUNNER_SCOPE_MARKER";

try {
await storage.createLoop(
{
name: "api-runner-scope",
schedule: { type: "once", at: now.toISOString() },
target: { type: "agent", provider: "claude", prompt },
leaseMs: 60_000,
},
new Date("2025-12-31T00:00:00Z"),
);

const readClaim = await fetch(apiUrl(server, "/v1/runners/claim"), {
method: "POST",
headers: { ...jsonHeaders, "x-api-key": readKey.token },
body: JSON.stringify({ runnerId: "unregistered-reader", maxClaims: 1 }),
});
expect(readClaim.status).toBe(403);
expect(await storage.listRuns({ status: "running" })).toHaveLength(0);

const runnerClaim = await fetch(apiUrl(server, "/v1/runners/claim"), {
method: "POST",
headers: { ...jsonHeaders, "x-api-key": runnerKey.token },
body: JSON.stringify({ runnerId: "scoped-runner", maxClaims: 1 }),
});
expect(runnerClaim.status).toBe(200);
const claimed = (await runnerClaim.json()) as {
claims: Array<{ loop: { target: { prompt?: string } } }>;
};
expect(claimed.claims).toHaveLength(1);
expect(claimed.claims[0]!.loop.target.prompt).toBe(prompt);
} finally {
server.stop(true);
await storage.close();
}
});
});
16 changes: 15 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const DEFAULT_EVIDENCE_LIMIT_BYTES = 256 * 1024;
// The client batches by byte budget well under this ceiling.
const DEFAULT_IMPORT_LIMIT_BYTES = 32 * 1024 * 1024;
const MIN_RUNNER_LEASE_MS = 1_000;
const RUNNER_EXECUTION_SCOPES = ["loops:execute"] as const;

program
.name("loops-api")
Expand Down Expand Up @@ -92,6 +93,17 @@ function authorizeRequest(request: Request, host: string): Response | undefined
: Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
}

function requiredScopesForRequest(method: string, pathname: string): readonly string[] | undefined {
if (method !== "POST") return undefined;
if (/^\/v1\/runners\/(?:[^/]+\/)?(?:register|heartbeat|poll|claim)$/.test(pathname)) {
return RUNNER_EXECUTION_SCOPES;
}
if (/^\/v1\/runs\/[^/]+\/(?:heartbeat|finalize|evidence)$/.test(pathname)) {
return RUNNER_EXECUTION_SCOPES;
}
return undefined;
}

function ok(payload: Record<string, unknown> = {}, init?: ResponseInit): Response {
return Response.json({ ok: true, ...payload }, init);
}
Expand Down Expand Up @@ -197,9 +209,11 @@ export function createLoopsApiServer(opts: LoopsApiServerOptions = {}) {
}
// ── Authenticated control plane (/status included) ───────────────────
if (opts.authenticator) {
const requiredScopes = requiredScopesForRequest(request.method, url.pathname);
const decision = await opts.authenticator.authenticate(request.headers, {
method: request.method,
path: url.pathname,
requiredScopes,
});
if (!decision.ok) {
return Response.json(
Expand Down Expand Up @@ -728,7 +742,7 @@ async function claimRuns(
{ claimToken: claim.claimToken },
) ?? claim.run;
claims.push({
loop: publicLoop(claim.loop),
loop: claim.loop,
run: publicRun(run, false, { redactError: true }),
claimToken: claim.claimToken,
});
Expand Down
Loading