Skip to content
Merged
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
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ conformance tests on BOTH sides. Never hand-edit one side of a contract.
| connection pull v1 | CP producer `core/connections/pull-wire.ts`, routes in `core/connections/pull-routes.ts` (`GET /workspaces/self/connections`, `POST /workspaces/self/connections/:name/token`) ↔ Go consumer `broker/internal/workspace/connections.go`, printed by `blitz-cred list\|get\|env`. Carries BOTH credential planes: the member's own connection grant and the workspace credential store (plans/MEMBER-MACHINES.md §4) | `fixtures/connection-pull/` | `test/connection-pull-conformance.test.ts` + `test/pull-credentials.test.ts` + `test/member-machines.test.ts` (CP), `broker/internal/workspace/connections_test.go` + `broker/cmd/blitz-cred/main_test.go` (box) |
| entitlements | CP `core/entitlements.ts` (`PUT /orgs/:id/entitlements` writer, `GET /orgs/:id/usage`, the 402 seat-limit refusal and its HS256 handoff token) ↔ the PRIVATE billing service, which owns plans, writes the integers, and verifies the token — core never learns a plan name | `fixtures/entitlements/` | `test/entitlements-fixtures.test.ts` (CP); the billing service copies the corpus and pins it on its side |
| recipe invocation files | `core/bootstrap.ts` writer (recipe launches emit `/var/lib/blitz/recipe/prompt.txt` + `invocation.env`) ↔ guest readers: `blitz-term` through the shared parser `box/rootfs/usr/local/libexec/blitz-recipe-invocation`, plus the bootstrap-emitted chat sender's raw `prompt.txt` read (the sender never parses `invocation.env` — model/effort/permission are interpolated into its source at render time) | `fixtures/recipe-invocation/` | `test/recipe-invocation-fixtures.test.ts` (CP), `box/actor/test/recipe-invocation-guest.test.ts` (guest: shared parser vs corpus + blitz-term delivery semantics) |
| machine-stats | guest producer `box/rootfs/usr/local/bin/blitz-machine-stats` (s6 longrun `machine-stats`, one report every 10 min) ↔ CP consumer `core/machine-stats.ts` (`POST /workspaces/self/machine-stats`), which fills `machines.disk_used_percent` and surfaces as `MachineView.volumeUsedPercent` | `fixtures/machine-stats/` | `test/machine-stats-conformance.test.ts` (CP), `box/actor/test/machine-stats-conformance.test.ts` (guest: the real script against a local origin) |
| box config v1 | CP `core/box-config.ts` producer (`GET /workspaces/self/box-config`) and consumer (`POST /workspaces/self/box-update-result`) ↔ host updater bash/python emitted by `core/bootstrap.ts` (`blitz-box-update`; cloud-VM path only — the microVM provider has its own guest lifecycle and no update path yet) | `fixtures/box-config/` | `test/box-config-conformance.test.ts` (CP), `test/box-update-conformance.test.mjs` (runs real `python3` over the emitted parser/producer, `bash -n` over the emitted scripts), `test/box-update-host.test.mjs` (runs the emitted updater in real bash against a live CP over real curl) |

Retired: the `workspace environment` contract (`GET /workspaces/self/environment`
Expand Down Expand Up @@ -129,6 +130,29 @@ The page components (`TemplatesHome`, `RecipesHome`, `CreateTemplateScreen`,
`CreateRecipeScreen`), the client methods and the recipe rows are untouched
and unreachable. Restoring a surface means restoring both branches.

## Settings surface style (webapp)

`packages/webapp/src/settings-surface.css` is canon for every
settings-shaped screen: the workspace-details dialog and its three tabs,
"My machine", the account settings page and its panels, and the section
headings of the create-workspace dialog. One class prefix, `cfg-`; the rules
and the whole vocabulary are documented at the top of that file. Read it
before restyling a settings surface, and extend it rather than starting a
seventh heading treatment somewhere else.

The four rules a change must not break:

- The tabbed dialog has ONE fixed height. `.workspace-details-dialog` sets
`height`, not `max-height`; the body scrolls; switching tabs never moves
the frame.
- Section titles are sentence case, never all-caps, always `--cfg-title-*`
(the ink white and size the "Agent rules" heading had). Descriptions are
`--cfg-desc-*`. Field micro-labels are sentence case too.
- Exactly one thin divider between two adjacent sections, drawn by
`.cfg-section ~ .cfg-section` and by nothing else. Card outlines and
list-row separators are structure, not dividers.
- No new colours: everything resolves to a token in `tokens.css`.

## VM provider architecture (do not regress)

- The plugin contract is `VmProvider` in `core/compute/types.ts`:
Expand Down
3 changes: 2 additions & 1 deletion packages/box/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ COPY packages/box/rootfs/ /
RUN set -eux; \
chmod 0755 /usr/local/bin/blitz /usr/local/bin/claude /usr/local/bin/codex \
/usr/local/bin/blitz-cred-claude /usr/local/bin/blitz-cred-codex \
/usr/local/bin/blitz-rules /usr/local/bin/blitz-cgroup; \
/usr/local/bin/blitz-rules /usr/local/bin/blitz-cgroup \
/usr/local/bin/blitz-machine-stats; \
chmod 0755 /usr/local/libexec/blitz-* /etc/s6-overlay/s6-rc.d/*/run; \
install -d -m 0755 /workspace /var/lib/blitz /srv/blitz-files; \
ln -s /workspace /srv/blitz-files/workspace; \
Expand Down
212 changes: 212 additions & 0 deletions packages/box/actor/test/machine-stats-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";

/** Producer side of the `machine-stats` cross-runtime contract. The
* control-plane consumer is pinned against the same fixtures in
* packages/control-plane/test/machine-stats-conformance.test.ts; here the real
* `blitz-machine-stats report` script runs against a local origin and what it
* posts is checked against the corpus accept rule. */

interface StatsFixture {
request: Record<string, unknown>;
accepts: boolean;
}

const scriptPath = fileURLToPath(
new URL("../../rootfs/usr/local/bin/blitz-machine-stats", import.meta.url),
);
const fixturesDirectory = fileURLToPath(
new URL("../../../schema/fixtures/machine-stats/", import.meta.url),
);

function fixtureNames(): string[] {
return readdirSync(fixturesDirectory).filter((name) => name.endsWith(".json")).sort();
}

function readFixture(name: string): StatsFixture {
// SAFETY: The machine-stats fixtures are trusted local test data authored to
// the { request, accepts } shape; the control-plane consumer test pins the
// same corpus.
return JSON.parse(readFileSync(join(fixturesDirectory, name), "utf8")) as StatsFixture;
}

/** The corpus accept rule, restated here so the producer is checked against
* the same sentence the consumer implements: an object whose `diskUsedPercent`
* is an integer 0-100. The fixture list is the proof that this restatement and
* the control plane's agree. */
function accepts(body: unknown): boolean {
if (typeof body !== "object" || body === null || Array.isArray(body)) return false;
const percent = (body as { diskUsedPercent?: unknown }).diskUsedPercent;
return typeof percent === "number" && Number.isInteger(percent)
&& percent >= 0 && percent <= 100;
}

interface BoxState {
stateDir: string;
}

function makeState(origin: string | null, token: string | null): BoxState {
const stateDir = mkdtempSync(join(tmpdir(), "stats-state-"));
if (origin !== null) writeFileSync(join(stateDir, "origin"), `${origin}\n`);
if (token !== null) {
writeFileSync(
join(stateDir, "box-credential.json"),
`${JSON.stringify({ box_id: "box", access_token: token, refresh_token: "refresh" })}\n`,
);
}
return { stateDir };
}

interface RunResult {
status: number | null;
stderr: string;
}

function runReport(state: BoxState): Promise<RunResult> {
return new Promise((resolve) => {
const child = spawn("sh", [scriptPath, "report"], {
env: { ...process.env, BLITZ_STATE_DIR: state.stateDir },
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("close", (status) => resolve({ status, stderr: stderr.trim() }));
});
}

interface Posted {
authorization: string | undefined;
contentType: string | undefined;
body: unknown;
}

const servers: Server[] = [];

function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise<string> {
const server = createServer(handler);
servers.push(server);
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
// SAFETY: The callback runs after binding an explicit TCP host/port, so
// address() is an AddressInfo, never a pipe string or null.
const address = server.address() as AddressInfo;
resolve(`http://127.0.0.1:${address.port}`);
});
});
}

/** A stand-in control plane that records the one report it is sent. */
function collector(status: number, posted: Posted[]): Promise<string> {
return startServer((req, res) => {
if (req.url !== "/workspaces/self/machine-stats" || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
if (req.headers.authorization !== "Bearer good-token") {
res.writeHead(401);
res.end();
return;
}
let raw = "";
req.on("data", (chunk) => {
raw += String(chunk);
});
req.on("end", () => {
let body: unknown = null;
try {
body = JSON.parse(raw);
} catch {
body = raw;
}
posted.push({
authorization: req.headers.authorization,
contentType: req.headers["content-type"],
body,
});
res.writeHead(status);
res.end();
});
});
}

afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))),
);
});

describe("blitz-machine-stats producer contract", () => {
it("pins the shared machine-stats fixture corpus", () => {
expect(fixtureNames()).toEqual([
"invalid-fractional-percent.json",
"invalid-missing-percent.json",
"invalid-negative-percent.json",
"invalid-null-percent.json",
"invalid-over-hundred.json",
"invalid-string-percent.json",
"valid-extra-key.json",
"valid-full.json",
"valid-mid.json",
"valid-zero.json",
]);
});

it("agrees with the corpus about which bodies the control plane takes", () => {
for (const name of fixtureNames()) {
const fixture = readFixture(name);
expect(accepts(fixture.request), name).toBe(fixture.accepts);
}
});

it("posts a body the control plane accepts, with the box credential", async () => {
const posted: Posted[] = [];
const origin = await collector(204, posted);
const state = makeState(origin, "good-token");

const result = await runReport(state);

expect(result.status, result.stderr).toBe(0);
expect(posted).toHaveLength(1);
const report = posted[0];
expect(report?.authorization).toBe("Bearer good-token");
expect(report?.contentType).toBe("application/json");
expect(accepts(report?.body)).toBe(true);
// The one key the contract names, and no other: an integer percentage
// measured off a real filesystem, this test's own temporary directory.
expect(Object.keys(report?.body as Record<string, unknown>)).toEqual(["diskUsedPercent"]);
});

it("stays quiet and succeeds when there is nothing to report with", async () => {
const posted: Posted[] = [];
const origin = await collector(204, posted);

for (const state of [makeState(null, "good-token"), makeState(origin, null)]) {
const result = await runReport(state);
expect(result.status, result.stderr).toBe(0);
}
expect(posted).toHaveLength(0);
});

it("fails open on a refused or broken control plane", async () => {
const posted: Posted[] = [];
const refusing = await collector(204, posted);
// Wrong token: the collector answers 401 before it records anything.
expect((await runReport(makeState(refusing, "wrong-token"))).status).toBe(0);
expect(posted).toHaveLength(0);

const failing = await collector(500, posted);
expect((await runReport(makeState(failing, "good-token"))).status).toBe(0);

// Nothing is listening on this port, so the fetch itself fails.
const dead = makeState("http://127.0.0.1:1", "good-token");
expect((await runReport(dead)).status).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

15 changes: 15 additions & 0 deletions packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/command/with-contenv bash
# Report the state directory's disk usage to the control plane, every ten
# minutes, forever.
#
# The loop lives here rather than in the script so the script stays one shot
# and one thing: `blitz-machine-stats report` measures once, posts once, and
# exits 0 whatever happened. A disk figure is worth nothing next to the box it
# describes, so nothing in this service may take the box down with it — hence
# the `|| true` and the sleep that follows every outcome alike.
set -uo pipefail

while true; do
/usr/local/bin/blitz-machine-stats report || true
sleep 600
done
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
longrun
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

109 changes: 109 additions & 0 deletions packages/box/rootfs/usr/local/bin/blitz-machine-stats
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/bin/sh
set -eu

usage() {
echo 'usage: blitz-machine-stats report' >&2
exit 2
}

action=${1:-}
[ "$action" = report ] || usage
shift
[ "$#" -eq 0 ] || usage

# The state directory is the mount point of the member's persistent volume when
# there is one, so measuring it measures the disk the member keeps files on.
# The conformance test overrides it to point at a scratch directory.
state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz}

exec node - "$state_dir" <<'NODE'
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');

const [stateDir] = process.argv.slice(2);
const TIMEOUT_MS = 10_000;

// Every failure below is expected: a box with no credential yet, an origin
// that is unreachable, a df that will not answer. Say why on stderr and exit 0.
// A disk figure is worth exactly nothing next to the service it reports on, so
// nothing here may ever fail a boot or restart a supervised process.
function giveUp(reason) {
console.error(`blitz-machine-stats: no report (${reason})`);
process.exit(0);
}

function read(name) {
try {
return fs.readFileSync(path.join(stateDir, name), 'utf8');
} catch (error) {
giveUp(`no ${name} (${error && error.code ? error.code : 'unreadable'})`);
}
}

function origin() {
const value = read('origin').split('\n', 1)[0].trim();
if (value === '') giveUp('empty origin');
return value.replace(/\/+$/, '');
}

function accessToken() {
let parsed;
try {
parsed = JSON.parse(read('box-credential.json'));
} catch {
giveUp('unparseable credential');
}
const token = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed.access_token
: undefined;
if (typeof token !== 'string' || token.length === 0) giveUp('credential has no access_token');
return token;
}

// `df --output=pcent DIR` prints a header line and then one percentage, e.g.
// " 62%". The control plane takes an integer 0-100 and refuses anything else
// (packages/schema/fixtures/machine-stats/), so a line that does not parse is
// not sent at all.
function diskUsedPercent() {
let output;
try {
output = execFileSync('df', ['--output=pcent', stateDir], {
encoding: 'utf8',
timeout: TIMEOUT_MS,
});
} catch (error) {
giveUp(`df failed (${error && error.message ? error.message : 'unknown'})`);
}
const figure = String(output).trim().split('\n').pop().trim();
// Anchored, so an empty line cannot become 0%: Number('') is 0, and 0% is
// the one wrong answer that looks like a right one.
const matched = /^(\d{1,3})%$/.exec(figure);
const percent = matched === null ? -1 : Number(matched[1]);
if (percent < 0 || percent > 100) giveUp(`df said ${JSON.stringify(figure)}`);
return percent;
}

async function main() {
const target = `${origin()}/workspaces/self/machine-stats`;
const token = accessToken();
const diskUsed = diskUsedPercent();
let response;
try {
response = await fetch(target, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ diskUsedPercent: diskUsed }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (error) {
giveUp(`fetch failed (${error && error.message ? error.message : 'network'})`);
}
if (!response.ok) giveUp(`http ${response.status}`);
console.error(`blitz-machine-stats: reported ${diskUsed}%`);
process.exit(0);
}

void main();
NODE
Loading
Loading