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
23 changes: 13 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,25 @@ npm test # control-plane, box actor, ui, guest node:test,
fall. When you remove findings, lower the baseline in the same change.
Never raise the baseline to make a change pass.

## Known debt (as of 2026-08-18)
## Known debt (as of 2026-08-23)

- 108 anti-slop findings remain, all Tier C: external-boundary code that
needs real parsers (52 no-unknown-parameters, 27 no-runtime-typeof in
- 105 anti-slop findings remain, all Tier C: external-boundary code that
needs real parsers (51 no-unknown-parameters, 25 no-runtime-typeof in
plain JS, 23 no-unsafe-dictionary-type, 6 no-unknown-returns). Fixing one
requires characterization tests FIRST — these fixes can change accepted
inputs. Plan and history: GitHub issue #1.
- 16 `TODO(deslop-tier-c):` markers flag type assertions whose invariant is
not actually enforced today (latent-bug candidates). Grep for the marker.
- `TODO(house-canon):` markers flag direct fetch/console sites awaiting
migration to the canon helpers.
- 4 files exceed the 700-line warn: `core/workspaces.ts`,
`control-plane/scripts/lib/worker-source.mjs`, `webapp/src/CloudApp.tsx`,
`webapp/src/terminal-touch-controller.ts`. Split on touch, never big-bang.
(`core/files/sync.ts` left the list 2026-08-21 when its transfer plumbing
split into `core/files/dav.ts`.)
- 6 files exceed the 700-line warn: `core/bootstrap.ts`,
`core/workspaces.ts`, `control-plane/scripts/lib/worker-source.mjs`,
`webapp/src/CloudApp.tsx`, `webapp/src/api.ts`,
`webapp/src/terminal-touch-controller.ts`. Split on touch, never
big-bang. (`core/files/sync.ts` left the list 2026-08-21 via the
`core/files/dav.ts` split; `core/bootstrap.ts` and `webapp/src/api.ts`
crossed the line before the 2026-08-23 sweep and are noted here so the
list matches what `lint:gate` prints.)

## Cross-runtime contracts (fixtures are the source of truth)

Expand Down Expand Up @@ -102,8 +105,8 @@ Do not add aliases anywhere else.
and both conformance tests present and passing. A new cross-runtime
payload without fixtures is a finding.
6. Max-lines: the warn list printed by `lint:gate` should not grow.
7. Reference counts for comparison (2026-08-19): anti-slop 108
(52/27/23/6), blitz-house 0, max-lines warnings 4. These are the numbers
7. Reference counts for comparison (2026-08-23): anti-slop 105
(51/25/23/6), blitz-house 0, max-lines warnings 6. These are the numbers
a sweep compares against, so lower them in the same change that removes
findings — a stale reference hides the next regression.

Expand Down
1 change: 0 additions & 1 deletion docs/SELF-HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ not "refuse to deploy". So you can run step 4 immediately and come back here.
| `BOX_IMAGE_TAG` | mode-dependent | Empty for registry mode; the archive's image tag for R2 modes. |
| `BOX_IMAGE_SHA256` | mode-dependent | Empty for registry mode; the archive's SHA-256 for R2 modes. |
| `SESSION_TTL_DAYS` | no | Session cookie lifetime in days, 1–3650. Default 30. |
| `MAX_CONCURRENT_WORKSPACES` | no | Per-principal cap on non-destroyed workspaces, 1–1000. Default 10. |
| `MICROVM_HOSTS` | yes | JSON array of Firecracker hosts. **Set `'[]'` if you have none** — that cleanly disables the microVM provider and removes its token secret from the required set. Each configured host names a `tokenVar`; that Worker secret must then exist and be at least 32 characters with no whitespace, or **every request to the Worker fails with 500**. |
| `HETZNER_MACHINE_TYPES` | no | Comma-separated `type@location` entries for the Hetzner machine catalog, e.g. `cpx21@hil,cx32@fsn1`. Unset or blank keeps the default catalog (`cpx21@hil`, `cpx31@hil`). Malformed entries are skipped with a logged warning. |
| `SIGNUP_MODE` | no | `open` (default) or `invite`. In `invite` mode a Google sign-in that would create a new user is refused unless it carries a valid invite (step 7) or the verified bootstrap secret (step 6). Existing users always sign in. |
Expand Down
4 changes: 2 additions & 2 deletions lint-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"anti-slop/no-unknown-parameters": 52,
"anti-slop/no-runtime-typeof": 27,
"anti-slop/no-unknown-parameters": 51,
"anti-slop/no-runtime-typeof": 25,
"anti-slop/no-unsafe-dictionary-type": 23,
"anti-slop/no-unknown-returns": 6,
"blitz-house/no-raw-fetch": 0,
Expand Down
48 changes: 15 additions & 33 deletions packages/box/actor/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,26 +65,6 @@ export function claudeEnv(
return env;
}

const PERMISSION_MODES: ReadonlySet<string> = new Set([
"default",
"acceptEdits",
"bypassPermissions",
"plan",
]);

export function claudePermissionMode(value: string): NonNullable<Options["permissionMode"]> {
// SAFETY: The set above holds exactly the PermissionMode literals the SDK accepts.
return PERMISSION_MODES.has(value) ? (value as NonNullable<Options["permissionMode"]>) : "default";
}

const EFFORT_LEVELS: ReadonlySet<string> = new Set(["low", "medium", "high", "xhigh", "max"]);

/** "default" (or anything unknown) leaves the SDK's own effort choice alone. */
export function claudeEffort(value: string): Options["effort"] | undefined {
// SAFETY: The set above holds exactly the EffortLevel literals the SDK accepts.
return EFFORT_LEVELS.has(value) ? (value as NonNullable<Options["effort"]>) : undefined;
}

export interface ClaudeStreamChunk {
messageId: string;
text?: string;
Expand All @@ -105,15 +85,6 @@ export function claudeStreamChunk(
return { messageId: currentMessageId };
}

export function claudeTurnOutput(
stopReason: TurnOutput["stopReason"],
resumeId: string | undefined,
): TurnOutput {
const output: TurnOutput = { stopReason };
if (resumeId) output.resumeId = resumeId;
return output;
}

export class ClaudeAdapter implements AgentAdapter {
public async runTurn(input: TurnInput): Promise<TurnOutput> {
const abortController = new AbortController();
Expand Down Expand Up @@ -146,7 +117,11 @@ export class ClaudeAdapter implements AgentAdapter {
env: claudeEnv(input.token, input.environment),
includePartialMessages: true,
pathToClaudeCodeExecutable: CLAUDE_BINARY,
permissionMode: claudePermissionMode(input.config.permission),
// SAFETY: a session's config is only ever written by defaultAgentConfig/
// applyAgentConfig from its provider's catalog, and the claude catalog's
// permission values are pinned to the SDK's PermissionMode literals
// (ClaudeCatalog in agent-config.ts).
permissionMode: input.config.permission as NonNullable<Options["permissionMode"]>,
// The agent's rules live in ~/.claude/CLAUDE.md (installed each boot by
// blitz-init-state), not in an appended system prompt. Load all
// filesystem setting sources so that file is read. This is the SDK's own
Expand All @@ -167,8 +142,11 @@ export class ClaudeAdapter implements AgentAdapter {
// something a reviewer has to catch.
if (input.resumeId) options.resume = input.resumeId;
if (input.config.model !== "default") options.model = input.config.model;
const effort = claudeEffort(input.config.effort);
if (effort !== undefined) options.effort = effort;
if (input.config.effort !== "default") {
// SAFETY: same catalog pin as permissionMode above — every non-"default"
// claude effort value is one of the SDK's effort literals.
options.effort = input.config.effort as NonNullable<Options["effort"]>;
}
let resumeId = input.resumeId ?? undefined;
let stopReason: TurnOutput["stopReason"] = "refusal";
let messageId = input.turnId;
Expand All @@ -186,7 +164,11 @@ export class ClaudeAdapter implements AgentAdapter {
}
if (record.type === "result") stopReason = record.subtype === "success" ? "end_turn" : "refusal";
}
return claudeTurnOutput(stopReason, resumeId);
// A fresh session's output must not carry a resumeId key at all, the same
// omission rule the optional `options` above follow.
const output: TurnOutput = { stopReason };
if (resumeId) output.resumeId = resumeId;
return output;
}
}

Expand Down
22 changes: 15 additions & 7 deletions packages/box/actor/src/agent-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { Provider } from "./types.js";

export const MODEL_CONFIG_ID = "model";
Expand All @@ -24,6 +25,16 @@ interface ProviderCatalog {
defaults: AgentConfig;
}

/** The claude lists are pinned to the agent SDK's own literals at compile
* time ("default" means: leave the SDK's own choice alone), so the claude
* adapter forwards a session's choices without revalidating them. A value
* the SDK does not accept fails `satisfies` below instead of shipping. */
type ClaudeChoice<Value extends string> = { value: Value; name: string };
interface ClaudeCatalog extends ProviderCatalog {
efforts: ClaudeChoice<"default" | NonNullable<Options["effort"]>>[];
permissions: ClaudeChoice<NonNullable<Options["permissionMode"]>>[];
}

const CATALOGS = {
claude: {
models: [
Expand Down Expand Up @@ -77,7 +88,7 @@ const CATALOGS = {
],
defaults: { model: "default", effort: "medium", permission: "never" },
},
} satisfies Record<Provider, ProviderCatalog>;
} satisfies Record<Provider, ProviderCatalog> & { claude: ClaudeCatalog };

export function defaultAgentConfig(provider: Provider): AgentConfig {
return { ...CATALOGS[provider].defaults };
Expand All @@ -102,14 +113,11 @@ function select(

export function agentConfigOptions(provider: Provider, config: AgentConfig): SessionConfigOption[] {
const catalog = CATALOGS[provider];
const options = [
return [
select(MODEL_CONFIG_ID, "Model", "model", catalog.models, config.model),
select(EFFORT_CONFIG_ID, "Effort", "thought_level", catalog.efforts, config.effort),
select(PERMISSION_CONFIG_ID, "Permissions", "mode", catalog.permissions, config.permission),
];
if (catalog.efforts.length > 0) {
options.push(select(EFFORT_CONFIG_ID, "Effort", "thought_level", catalog.efforts, config.effort));
}
options.push(select(PERMISSION_CONFIG_ID, "Permissions", "mode", catalog.permissions, config.permission));
return options;
}

/** Applies one selector change, ignoring unknown ids and values. */
Expand Down
27 changes: 10 additions & 17 deletions packages/box/actor/src/agent-rules-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,25 @@ import { spawn } from "node:child_process";
const REFRESH_TTL_MS = 5 * 60 * 1000;

function detachedSync(): void {
// SAFETY-equivalent invariant, stated because callers rely on it: spawn with
// these fixed, valid arguments never throws synchronously — every failure,
// a missing binary included, arrives through the "error" event handled
// below, so the refresh stays invisible to the session that triggered it.
const child = spawn("blitz-rules", ["sync"], { stdio: "ignore", detached: true });
// A missing binary or spawn error surfaces asynchronously; swallow it so the
// refresh stays invisible to the session.
child.on("error", () => undefined);
child.unref();
}

/** Returns the "a session started" callback the actor service calls. The two
* optional arguments are test seams; production passes neither. The returned
* function never throws, so callers need no guard of their own. */
export function createRulesRefresher(
run: () => void = detachedSync,
now: () => number = Date.now,
ttlMs: number = REFRESH_TTL_MS,
): () => void {
/** Returns the "a session started" callback the actor service calls. The
* returned function never throws, so callers need no guard of their own. */
export function createRulesRefresher(): () => void {
let lastAttempt: number | null = null;
return () => {
const attemptedAt = now();
if (lastAttempt !== null && attemptedAt - lastAttempt < ttlMs) return;
const attemptedAt = Date.now();
if (lastAttempt !== null && attemptedAt - lastAttempt < REFRESH_TTL_MS) return;
// The attempt counts against the TTL whether or not it works, so a box
// whose spawn keeps failing does not spin on every session.
lastAttempt = attemptedAt;
try {
run();
} catch {
// Never let a synchronous spawn failure disturb the caller.
}
detachedSync();
};
}
9 changes: 0 additions & 9 deletions packages/box/actor/src/chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,4 @@ export class ChatSessionStore {
.all(sessionId, limit) as JournalEvent[];
return rows;
}

public sequences(sessionId: string): number[] {
// SAFETY: The query projects the integer seq column from schema-owned event rows.
return (
this.database.prepare("SELECT seq FROM events WHERE session_id = ? ORDER BY seq").all(sessionId) as Array<{
seq: number;
}>
).map(({ seq }) => seq);
}
}
6 changes: 3 additions & 3 deletions packages/box/actor/src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ export class CredentialSource {
const reason = error instanceof Error ? brokerReason(execFailure(error)) : "";
throw new Error(reason ? `broker mint failed: ${reason}` : "broker mint failed");
}
if (stdout.length === 0 || stdout.length > 1_048_576) {
throw new Error("broker returned an invalid token");
}
// Size is already owned on both sides: execFile's maxBuffer above kills
// and rejects any child whose stdout exceeds it, and parseToken refuses
// an empty line.
return parseToken(stdout);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/box/actor/test/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,8 @@ describe("ACP actor", () => {
const terminal = await client.take((frame) => frame.id === "prompt-fixture");
expect((terminal.result as { stopReason: string }).stopReason).toBe("end_turn");
expect(observed.slice(1)).toEqual(updates);
expect(item.store.sequences(sessionId)).toEqual(observed.map((_value, index) => index + 1));
expect(item.store.replay(sessionId, observed.length).map(({ seq }) => seq))
.toEqual(observed.map((_value, index) => index + 1));
client.close();
});

Expand Down
106 changes: 87 additions & 19 deletions packages/box/actor/test/adapter-object-contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,95 @@
import { describe, expect, it } from "vitest";
import { claudeTurnOutput } from "../src/adapters/claude.js";
import { describe, expect, it, vi } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeAdapter } from "../src/adapters/claude.js";
import { codexThreadRequestParams } from "../src/adapters/codex.js";
import { defaultAgentConfig } from "../src/agent-config.js";
import type { TurnInput } from "../src/types.js";

// Claude's `resume` used to have a helper and a block of its own here. It does
// not need one: the SDK reads that option as `if (resume) push('--resume=…')`,
// so an absent key and an undefined one are the same argv, and nothing reads
// the object's key order. The conditional spread at the call site keeps the key
// absent anyway, and `Options` typing — not a runtime assertion — is what keeps
// a second token-delivery hook off the object.
// Claude's turn output and options are asserted through runTurn, the produced
// boundary, with the SDK's `query` stubbed: the SDK reads `resume` as
// `if (resume) push('--resume=…')` and nothing reads key order, but the ACP
// result frame is JSON.stringify(output), so key omission is wire-visible.
//
// Codex is the opposite and stays: its params object IS the JSON-RPC wire.
// Codex is different and keeps its direct params test: that object IS the
// JSON-RPC wire.
const queryMock = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock }));

function engineRun(...messages: Array<Record<string, unknown>>): void {
queryMock.mockImplementationOnce(() =>
(async function* () {
// SAFETY: test fixtures stand in for engine messages; runTurn reads only
// the fields supplied here.
for (const message of messages) yield message as unknown as SDKMessage;
})(),
);
}

function turnInput(overrides: Partial<TurnInput> = {}): TurnInput {
return {
sessionId: "session-fixture",
turnId: "turn-1",
cwd: "/workspace",
prompt: [{ type: "text", text: "Say hello." }],
resumeId: null,
signal: new AbortController().signal,
token: null,
environment: { HOME: "/var/lib/blitz/home" },
config: defaultAgentConfig("claude"),
emit: async () => undefined,
requestPermission: async () => {
throw new Error("no permission request belongs in these turns");
},
...overrides,
};
}

function lastQueryOptions(): Record<string, unknown> {
// SAFETY: the adapter always calls query({ prompt, options }); the fixture
// above was invoked before this reader.
const call = queryMock.mock.calls.at(-1) as [{ options: Record<string, unknown> }] | undefined;
if (!call) throw new Error("query was not called");
return call[0].options;
}

describe("adapter object omission contracts", () => {
it("preserves Claude turn-output resume omission", () => {
const absent = claudeTurnOutput("end_turn", undefined);
expect(Object.keys(absent)).toEqual(["stopReason"]);
expect("resumeId" in absent).toBe(false);
expect(JSON.stringify(absent)).toBe('{"stopReason":"end_turn"}');

const present = claudeTurnOutput("end_turn", "session-1");
expect(Object.keys(present)).toEqual(["stopReason", "resumeId"]);
expect("resumeId" in present).toBe(true);
expect(JSON.stringify(present)).toBe('{"stopReason":"end_turn","resumeId":"session-1"}');
it("keeps resumeId absent from a fresh Claude turn's output", async () => {
engineRun({ type: "result", subtype: "success", uuid: "result-1" });
const output = await new ClaudeAdapter().runTurn(turnInput());

expect(Object.keys(output)).toEqual(["stopReason"]);
expect("resumeId" in output).toBe(false);
expect(JSON.stringify(output)).toBe('{"stopReason":"end_turn"}');
});

it("carries the engine's session id out as resumeId", async () => {
engineRun({ type: "result", subtype: "success", session_id: "session-1", uuid: "result-1" });
const output = await new ClaudeAdapter().runTurn(turnInput());

expect(Object.keys(output)).toEqual(["stopReason", "resumeId"]);
expect("resumeId" in output).toBe(true);
expect(JSON.stringify(output)).toBe('{"stopReason":"end_turn","resumeId":"session-1"}');
});

it("forwards catalog config to the engine and omits every 'default'", async () => {
engineRun({ type: "result", subtype: "success", uuid: "result-1" });
await new ClaudeAdapter().runTurn(turnInput());
const defaults = lastQueryOptions();
expect(defaults.permissionMode).toBe("bypassPermissions");
expect("model" in defaults).toBe(false);
expect("effort" in defaults).toBe(false);
expect("resume" in defaults).toBe(false);

engineRun({ type: "result", subtype: "success", uuid: "result-2" });
await new ClaudeAdapter().runTurn(turnInput({
resumeId: "resume-1",
config: { model: "claude-fable-5", effort: "max", permission: "plan" },
}));
const pinned = lastQueryOptions();
expect(pinned.model).toBe("claude-fable-5");
expect(pinned.effort).toBe("max");
expect(pinned.permissionMode).toBe("plan");
expect(pinned.resume).toBe("resume-1");
});

it("preserves Codex threadId omission before later request fields", () => {
Expand Down
Loading
Loading