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
2 changes: 2 additions & 0 deletions packages/core/src/cli/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function emptySnapshot(overrides: Partial<StatusSnapshot> = {}): StatusSnapshot
sessions: [],
},
registry: { blocked: false, count: 0, integrity: "missing" },
clients: { blocked: false, count: 0, integrity: "missing" },
paths: {
baseDir: "/tmp/mba",
tcbPath: "/tmp/mba/tcb.json",
Expand Down Expand Up @@ -79,6 +80,7 @@ describe("statusView", () => {
sessions: [],
},
registry: { blocked: false, count: 0, integrity: "missing" },
clients: { blocked: false, count: 0, integrity: "missing" },
loaded: ["qwen3.8-27b"],
watches: {
modelId: "qwen3.8-27b",
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function statusView(url: string, snapshot: StatusSnapshot) {
machine: snapshot.machineOverlay.mode,
pairing: snapshot.pairing,
registry: snapshot.registry,
clients: snapshot.clients,
loaded: loaded.map((m) => m.id),
watches: {
modelId: watches.modelId,
Expand Down Expand Up @@ -74,6 +75,7 @@ export async function cmdStatus(json: boolean): Promise<void> {
const sessions = snapshot.pairing.sessions;
const pairing = snapshot.pairing;
const registry = snapshot.registry;
const clients = snapshot.clients;
const machine = snapshot.machineOverlay.mode;
const watches = snapshot.watches;

Expand All @@ -100,6 +102,13 @@ export async function cmdStatus(json: boolean): Promise<void> {
if (registry.blocked && registry.error) {
process.stdout.write(` ${dim(registry.error)}\n`);
}
const clientsLabel = clients.blocked
? `${paint("blocked", RED)} ${dim("corrupt state")}`
: dim("ok");
process.stdout.write(`${kv("clients.json", clientsLabel)}\n`);
if (clients.blocked && clients.error) {
process.stdout.write(` ${dim(clients.error)}\n`);
}
const bits = watches.watches
.map((w) => `${w.id} ${w.effective ? paint("on", GRN) : paint("off", RED)}`)
.join(" ");
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/service/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { SERVICE_CONTRACT_VERSION } from "./contracts.js";

describe("service contract", () => {
it("pins the HTTP JSON version CLI and MCP lock to", () => {
expect(SERVICE_CONTRACT_VERSION).toBe(1);
expect(SERVICE_CONTRACT_VERSION).toBe(2);
});
});
2 changes: 1 addition & 1 deletion packages/core/src/service/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Bump `SERVICE_CONTRACT_VERSION` when a field is added, renamed, or dropped.
*/

export const SERVICE_CONTRACT_VERSION = 1 as const;
export const SERVICE_CONTRACT_VERSION = 2 as const;

export type {
StatusCatalogModel,
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/service/operator-clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtempSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -35,7 +35,7 @@ describe("operator-clients", () => {
envelope: ".windsurf/mba.md",
ide: "vscode",
});
expect(readOperatorClients(path)).toHaveLength(1);
expect(readOperatorClients(path).clients).toHaveLength(1);

const again = addOperatorClient(path, {
name: "windsurf",
Expand All @@ -54,7 +54,7 @@ describe("operator-clients", () => {

const gone = removeOperatorClient(path, "windsurf");
expect(gone).toEqual({ ok: true, removed: true });
expect(readOperatorClients(path)).toEqual([]);
expect(readOperatorClients(path).clients).toEqual([]);
});

it("lists built-ins and added rows for GET /clients", () => {
Expand All @@ -71,4 +71,16 @@ describe("operator-clients", () => {
const path = join(mkdtempSync(join(tmpdir(), "mba-clients-")), "clients.json");
expect(removeOperatorClient(path, "cursor")).toMatchObject({ ok: false });
});

it("fails closed on corrupt JSON and does not overwrite it", () => {
const dir = mkdtempSync(join(tmpdir(), "mba-clients-"));
const path = join(dir, "clients.json");
writeFileSync(path, "{ not json", "utf8");
const state = readOperatorClients(path);
expect(state.kind).toBe("corrupt");
expect(state.clients).toEqual([]);
const added = addOperatorClient(path, { name: "windsurf", envelope: ".windsurf/mba.md" });
expect(added).toMatchObject({ ok: false, code: "clients-corrupt" });
expect(readFileSync(path, "utf8")).toBe("{ not json");
});
});
92 changes: 75 additions & 17 deletions packages/core/src/service/operator-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* the family → model ladder, never in `environments/`.
*/

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, posix } from "node:path";
import {
builtInEnvelopeBindings,
Expand Down Expand Up @@ -63,26 +63,67 @@ function parseClient(value: unknown): OperatorClient | undefined {
return ide ? { name, envelope, ide } : { name, envelope };
}

export function readOperatorClients(path: string): OperatorClient[] {
if (!existsSync(path)) return [];
export type ClientsReadResult =
| { readonly kind: "missing"; readonly clients: readonly OperatorClient[] }
| { readonly kind: "valid"; readonly clients: readonly OperatorClient[] }
| {
readonly kind: "corrupt";
readonly clients: readonly OperatorClient[];
readonly error: string;
};

function corruptClients(error: string): ClientsReadResult {
return { kind: "corrupt", clients: [], error };
}

/**
* Read operator clients. Missing and valid-empty are open (built-ins only).
* Corrupt JSON, wrong shape, or an invalid row fail closed — callers must
* not treat that as `[]` and pretend extras were never added.
*/
export function readOperatorClients(path: string): ClientsReadResult {
let text: string;
try {
text = readFileSync(path, "utf8");
} catch (err) {
if (
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT"
) {
return { kind: "missing", clients: [] };
}
return corruptClients("operator clients file could not be read");
}
let raw: unknown;
try {
raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
raw = JSON.parse(text) as unknown;
} catch {
return [];
return corruptClients("operator clients file is not valid JSON");
}
const doc = raw as Partial<ClientsFile> | null;
if (!doc || !Array.isArray(doc.clients)) return [];
if (
!doc ||
typeof doc !== "object" ||
doc.version !== CLIENTS_VERSION ||
!Array.isArray(doc.clients)
) {
return corruptClients("operator clients file has an unsupported shape or version");
}
const rows: OperatorClient[] = [];
const seen = new Set<string>();
for (const item of doc.clients) {
for (const [index, item] of doc.clients.entries()) {
const parsed = parseClient(item);
if (!parsed) continue;
if (seen.has(parsed.name)) continue;
if (!parsed) {
return corruptClients(`operator clients file contains an invalid client at index ${index}`);
}
if (seen.has(parsed.name)) {
return corruptClients(`operator clients file contains a duplicate name at index ${index}`);
}
seen.add(parsed.name);
rows.push(parsed);
}
return rows;
return { kind: "valid", clients: rows };
}

export function writeOperatorClients(path: string, clients: readonly OperatorClient[]): void {
Expand All @@ -95,7 +136,7 @@ export function writeOperatorClients(path: string, clients: readonly OperatorCli

export type AddClientResult =
| { readonly ok: true; readonly client: OperatorClient; readonly updated: boolean }
| { readonly ok: false; readonly error: string };
| { readonly ok: false; readonly error: string; readonly code?: "clients-corrupt" };

export function addOperatorClient(
path: string,
Expand Down Expand Up @@ -127,7 +168,11 @@ export function addOperatorClient(
const taken = new Set(
builtInEnvelopeBindings().map((b) => posix.normalize(b.envelope)),
);
const rows = readOperatorClients(path);
const state = readOperatorClients(path);
if (state.kind === "corrupt") {
return { ok: false, code: "clients-corrupt", error: state.error };
}
const rows = [...state.clients];
for (const row of rows) {
if (row.name === name) continue;
taken.add(row.envelope);
Expand All @@ -150,20 +195,26 @@ export function addOperatorClient(
export function removeOperatorClient(
path: string,
nameRaw: string,
): { readonly ok: true; readonly removed: boolean } | { readonly ok: false; readonly error: string } {
):
| { readonly ok: true; readonly removed: boolean }
| { readonly ok: false; readonly error: string; readonly code?: "clients-corrupt" } {
const name = nameRaw.trim().toLowerCase();
if (isReservedHarnessName(name)) {
return { ok: false, error: `'${name}' is a built-in client` };
}
const rows = readOperatorClients(path);
const state = readOperatorClients(path);
if (state.kind === "corrupt") {
return { ok: false, code: "clients-corrupt", error: state.error };
}
const rows = state.clients;
const next = rows.filter((r) => r.name !== name);
if (next.length === rows.length) return { ok: true, removed: false };
writeOperatorClients(path, next);
return { ok: true, removed: true };
}

export function operatorEnvelopeBindings(path: string): EnvelopeBinding[] {
return readOperatorClients(path).map(({ name, envelope }) => ({ name, envelope }));
export function operatorEnvelopeBindings(clients: readonly OperatorClient[]): EnvelopeBinding[] {
return clients.map(({ name, envelope }) => ({ name, envelope }));
}

export interface RegisteredClient {
Expand All @@ -174,13 +225,20 @@ export interface RegisteredClient {
}

/** Built-in harnesses plus operator rows. The GET /clients catalog. */
export function clientsCorruptJson(error: string): { code: "clients-corrupt"; error: string } {
return {
code: "clients-corrupt",
error: `operator clients are corrupt — ${error}`,
};
}

export function listRegisteredClients(path: string): RegisteredClient[] {
const builtIn = builtInEnvelopeBindings().map((b) => ({
name: b.name,
envelope: b.envelope,
source: "built-in" as const,
}));
const added = readOperatorClients(path).map((c) => ({
const added = readOperatorClients(path).clients.map((c) => ({
name: c.name,
envelope: c.envelope,
source: "added" as const,
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/service/routes-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import type { Hono } from "hono";
import {
addOperatorClient,
clientsCorruptJson,
listRegisteredClients,
readOperatorClients,
removeOperatorClient,
} from "./operator-clients.js";
import type { ServiceRouteContext } from "./route-context.js";
Expand All @@ -14,7 +16,12 @@ export function registerClientRoutes(app: Hono, ctx: ServiceRouteContext): void
const { paths } = ctx;

app.get("/clients", (c) => {
return c.json({ clients: listRegisteredClients(paths.clientsPath) });
const state = readOperatorClients(paths.clientsPath);
return c.json({
clients: listRegisteredClients(paths.clientsPath),
integrity: state.kind,
...(state.kind === "corrupt" ? { error: state.error } : {}),
});
});

app.post("/clients", async (c) => {
Expand Down Expand Up @@ -42,6 +49,9 @@ export function registerClientRoutes(app: Hono, ctx: ServiceRouteContext): void
ide: typeof input.ide === "string" ? input.ide : undefined,
});
if (!result.ok) {
if (result.code === "clients-corrupt") {
return c.json(clientsCorruptJson(result.error), 503);
}
return c.json({ error: result.error }, 400);
}
return c.json({
Expand All @@ -65,6 +75,9 @@ export function registerClientRoutes(app: Hono, ctx: ServiceRouteContext): void
}
const result = removeOperatorClient(paths.clientsPath, input.name);
if (!result.ok) {
if (result.code === "clients-corrupt") {
return c.json(clientsCorruptJson(result.error), 503);
}
return c.json({ error: result.error }, 400);
}
return c.json({ name: input.name.trim().toLowerCase(), removed: result.removed });
Expand Down
17 changes: 14 additions & 3 deletions packages/core/src/service/routes-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,24 @@ export function registerConnectRoutes(app: Hono, ctx: ServiceRouteContext): void
body: { error: `unknown model: ${modelId}`, code: "unknown-model" },
};
}
const clientState = readOperatorClients(paths.clientsPath);
if (clientState.kind === "corrupt") {
return {
status: 503 as const,
body: {
code: "clients-corrupt",
error: `operator clients are corrupt — ${clientState.error}`,
},
};
}
const harness = harnessName;
const ide =
(typeof ideOpt === "string" && ideOpt.length > 0 ? ideOpt : undefined) ??
readOperatorClients(paths.clientsPath).find(
clientState.clients.find(
(c) => compactHarnessKey(c.name) === compactHarnessKey(harness),
)?.ide ??
defaultIdeForHarness(harness);
const envelopes = operatorEnvelopeBindings(paths.clientsPath);
const envelopes = operatorEnvelopeBindings(clientState.clients);
const previousOwner = readEnvelopeOwner(projectRoot, harness, envelopes, ide);
const slotPeers = sessionsSharingSlot(
sessionState.sessions,
Expand Down Expand Up @@ -195,9 +205,10 @@ export function registerConnectRoutes(app: Hono, ctx: ServiceRouteContext): void
if (outcome.status === 503) {
return c.json(outcome.body, 503);
}
const clientState = readOperatorClients(paths.clientsPath);
restageSlotsAfterRevoke({
adapterDir: opts.adapterDir ?? "",
envelopes: operatorEnvelopeBindings(paths.clientsPath),
envelopes: operatorEnvelopeBindings(clientState.clients),
remaining: outcome.next,
revoked: outcome.revoked,
});
Expand Down
Loading
Loading