feat(cli+broker): structured argument validation, msg-status prefixes (v1.9.3)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds apps/cli/src/cli/validators.ts — a small module of shape
validators (pubkey, pubkey prefix, message id, mesh slug) that return
discriminated results so callers can distinguish "shape is wrong"
(INVALID_ARGS exit) from "value is well-shaped, lookup failed"
(NOT_FOUND exit). Includes renderValidationError() for a consistent
three-tier error contract: what's wrong, what would be valid, closest
valid alternative.

First adopter is `claudemesh msg-status`:
- Validates id locally before opening WS — typos return immediately.
- Accepts 8-32 char prefixes (full ids are 32). Pastes that get
  copy-truncated by the terminal still work.
- Distinct error messages for malformed input vs not-in-queue vs
  ambiguous prefix; --json emits the structured shape.

Broker side: WS message_status handler validates idStr is 8-32
base62 before querying. Prefix lookups use LIKE 'prefix%' scoped to
the caller's mesh (no cross-mesh leak). Returns ambiguous_prefix
when more than one match.

Establishes the canonical pattern; rolling out to send / grant /
revoke / topic post --reply-to in subsequent patches.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 22:40:45 +01:00
parent 82ee89d0dc
commit 80755dbf9b
4 changed files with 300 additions and 17 deletions

View File

@@ -2906,17 +2906,66 @@ function handleConnection(ws: WebSocket): void {
} }
case "message_status": { case "message_status": {
const ms = msg as Extract<WSClientMessage, { type: "message_status" }>; const ms = msg as Extract<WSClientMessage, { type: "message_status" }>;
// Look up the message in the queue. // Validate id shape — guards against the broker running a wide
const [mqRow] = await db // LIKE scan for empty/malformed input. ≥8 base62 chars, ≤32.
.select({ const idStr = String(ms.messageId ?? "");
id: messageQueue.id, if (idStr.length < 8 || idStr.length > 32 || !/^[A-Za-z0-9]+$/.test(idStr)) {
targetSpec: messageQueue.targetSpec, sendError(
deliveredAt: messageQueue.deliveredAt, conn.ws,
meshId: messageQueue.meshId, "invalid_argument",
}) `messageId must be 8-32 base62 chars (got ${idStr.length})`,
.from(messageQueue) undefined,
.where(eq(messageQueue.id, ms.messageId)); _reqId,
if (!mqRow || mqRow.meshId !== conn.meshId) { );
break;
}
// Look up the message in the queue. Accept a prefix when the
// caller passed <32 chars (full ids are 32) — convenient for
// pasting from a copy-truncated terminal. Mesh scope is
// enforced via the meshId WHERE clause so a prefix can't
// leak across meshes.
const isPrefix = idStr.length < 32;
const mqRows = isPrefix
? await db
.select({
id: messageQueue.id,
targetSpec: messageQueue.targetSpec,
deliveredAt: messageQueue.deliveredAt,
meshId: messageQueue.meshId,
})
.from(messageQueue)
.where(
and(
eq(messageQueue.meshId, conn.meshId),
sql`${messageQueue.id} LIKE ${idStr + "%"}`,
),
)
.limit(2)
: await db
.select({
id: messageQueue.id,
targetSpec: messageQueue.targetSpec,
deliveredAt: messageQueue.deliveredAt,
meshId: messageQueue.meshId,
})
.from(messageQueue)
.where(eq(messageQueue.id, idStr));
if (mqRows.length === 0) {
sendError(conn.ws, "not_found", "message not found", undefined, _reqId);
break;
}
if (mqRows.length > 1) {
sendError(
conn.ws,
"ambiguous_prefix",
`prefix matched ${mqRows.length} messages — use a longer id`,
undefined,
_reqId,
);
break;
}
const mqRow = mqRows[0]!;
if (mqRow.meshId !== conn.meshId) {
sendError(conn.ws, "not_found", "message not found", undefined, _reqId); sendError(conn.ws, "not_found", "message not found", undefined, _reqId);
break; break;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "1.9.2", "version": "1.9.3",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -0,0 +1,198 @@
/**
* Argument validators — fail loud at the boundary, with specific reasons.
*
* Each validator returns a discriminated `ValidationResult` so callers can
* branch cleanly between "shape is wrong" (INVALID_ARGS exit) vs "value
* is well-shaped, do the lookup" (proceed). Hints (`reason`, `expected`,
* `nearest`) drive the three-tier error message contract:
*
* 1. WHAT'S WRONG — the failed assertion.
* 2. WHAT WOULD BE VALID — the canonical shape.
* 3. CLOSEST VALID ALTERNATIVE — best-effort suggestion.
*
* Use these instead of throwing strings or returning `null` for malformed
* input. They make argument errors structurally distinct from "thing
* doesn't exist" errors, which today's CLI conflates.
*/
export type ValidationResult<T = string> =
| { ok: true; value: T }
| { ok: false; code: string; reason: string; expected?: string };
const HEX_RE = /^[0-9a-f]+$/i;
const BASE62_RE = /^[A-Za-z0-9]+$/;
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* 64-char lowercase hex peer pubkey (member or session).
* Accepts UPPERCASE hex and normalizes to lowercase.
*/
export function validatePubkey(input: string | undefined): ValidationResult {
if (!input) {
return {
ok: false,
code: "missing",
reason: "pubkey is required",
expected: "64 lowercase hex chars",
};
}
if (input.length !== 64) {
return {
ok: false,
code: "wrong_length",
reason: `pubkey is ${input.length} chars, expected 64`,
expected: "64 lowercase hex chars (try `claudemesh peer list --json`)",
};
}
if (!HEX_RE.test(input)) {
return {
ok: false,
code: "non_hex",
reason: "pubkey contains non-hex characters",
expected: "characters [0-9a-f] only",
};
}
return { ok: true, value: input.toLowerCase() };
}
/**
* Hex pubkey *prefix* — used for short-form references. Min 8 chars
* to keep collisions vanishingly rare on a per-mesh roster, max 64.
*/
export function validatePubkeyPrefix(
input: string | undefined,
{ min = 8 }: { min?: number } = {},
): ValidationResult {
if (!input) {
return {
ok: false,
code: "missing",
reason: "pubkey prefix is required",
expected: `${min}-64 lowercase hex chars`,
};
}
if (input.length < min) {
return {
ok: false,
code: "too_short",
reason: `prefix is ${input.length} chars, needs ≥${min}`,
expected: `${min}+ hex chars (full pubkey is 64)`,
};
}
if (input.length > 64) {
return {
ok: false,
code: "too_long",
reason: `prefix is ${input.length} chars, max 64`,
expected: "drop trailing characters",
};
}
if (!HEX_RE.test(input)) {
return {
ok: false,
code: "non_hex",
reason: "prefix contains non-hex characters",
expected: "characters [0-9a-f] only",
};
}
return { ok: true, value: input.toLowerCase() };
}
/**
* Message id — base62, 32 chars exact, OR a prefix of ≥8 chars.
* Returns `{ value, isPrefix }` so callers can decide whether to
* resolve via lookup or treat as full id.
*/
export function validateMessageId(
input: string | undefined,
): ValidationResult<{ value: string; isPrefix: boolean }> {
if (!input) {
return {
ok: false,
code: "missing",
reason: "message id is required",
expected: "32-char base62 id, or ≥8-char prefix",
};
}
if (input.length < 8) {
return {
ok: false,
code: "too_short",
reason: `id is ${input.length} chars, needs ≥8`,
expected: "8+ chars (paste from a previous send/post output)",
};
}
if (input.length > 32) {
return {
ok: false,
code: "too_long",
reason: `id is ${input.length} chars, max 32`,
expected: "trim trailing characters",
};
}
if (!BASE62_RE.test(input)) {
return {
ok: false,
code: "bad_charset",
reason: "id contains characters outside [A-Za-z0-9]",
expected: "base62 only",
};
}
return { ok: true, value: { value: input, isPrefix: input.length < 32 } };
}
/**
* Mesh slug — kebab-case, lowercase, 2-64 chars.
*/
export function validateMeshSlug(input: string | undefined): ValidationResult {
if (!input) {
return {
ok: false,
code: "missing",
reason: "mesh slug is required",
expected: "kebab-case slug (e.g. `openclaw`)",
};
}
if (input.length < 2 || input.length > 64) {
return {
ok: false,
code: "wrong_length",
reason: `slug is ${input.length} chars, expected 2-64`,
expected: "lowercase kebab-case",
};
}
if (!SLUG_RE.test(input)) {
return {
ok: false,
code: "bad_format",
reason: "slug must be lowercase letters, digits, and hyphens (no leading/trailing hyphen)",
expected: "e.g. `team-alpha`, `flexicar-2`",
};
}
return { ok: true, value: input };
}
/**
* Render a structured validation error to stderr in the canonical
* three-line shape: `✘ <verb> <input>` / ` <reason>` / ` <expected>`.
*
* Optional fourth line for `nearest` when a fuzzy suggestion is available.
*/
export function renderValidationError(
args: {
verb: string;
input: string;
result: Extract<ValidationResult, { ok: false }>;
nearest?: string;
},
write: (s: string) => void = (s) => process.stderr.write(s),
): void {
write(` \x1b[31m✘\x1b[0m ${args.verb} ${args.input}\n`);
write(` ${args.result.reason}.\n`);
if (args.result.expected) {
write(` expected: ${args.result.expected}\n`);
}
if (args.nearest) {
write(` did you mean: \x1b[36m${args.nearest}\x1b[0m\n`);
}
}

View File

@@ -20,6 +20,7 @@ import { tryBridge } from "~/services/bridge/client.js";
import { render } from "~/ui/render.js"; import { render } from "~/ui/render.js";
import { bold, clay, dim } from "~/ui/styles.js"; import { bold, clay, dim } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js"; import { EXIT } from "~/constants/exit-codes.js";
import { validateMessageId, renderValidationError } from "~/cli/validators.js";
type StateFlags = { mesh?: string; json?: boolean }; type StateFlags = { mesh?: string; json?: boolean };
type PeerStatus = "idle" | "working" | "dnd"; type PeerStatus = "idle" | "working" | "dnd";
@@ -186,15 +187,50 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi
// --- msg-status --- // --- msg-status ---
export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> { export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise<number> {
if (!id) { // Validate input shape *before* we open a WS connection, so a typo
render.err("Usage: claudemesh msg-status <message-id>"); // returns a structured error instead of "not found or timed out".
const v = validateMessageId(id);
if (!v.ok) {
if (opts.json) {
console.log(
JSON.stringify({
ok: false,
error: "invalid_argument",
field: "messageId",
code: v.code,
reason: v.reason,
expected: v.expected,
}),
);
} else {
renderValidationError({
verb: "msg-status",
input: id ?? "(missing)",
result: v,
});
}
return EXIT.INVALID_ARGS; return EXIT.INVALID_ARGS;
} }
const lookupId = v.value.value;
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const result = await client.messageStatus(id); const result = await client.messageStatus(lookupId);
if (!result) { if (!result) {
if (opts.json) console.log(JSON.stringify({ id, found: false })); if (opts.json) {
else render.err(`Message ${id} not found or timed out.`); console.log(
JSON.stringify({
ok: false,
error: "not_found",
id: lookupId,
isPrefix: v.value.isPrefix,
}),
);
} else {
const hint = v.value.isPrefix
? ` no message id starts with ${dim("\"" + lookupId + "\"")} in this mesh.\n try: claudemesh msg-status <full-32-char-id>`
: ` message ${dim(lookupId.slice(0, 12) + "…")} not in queue (already drained, expired, or never sent in this mesh).`;
render.err(`message not found`);
process.stderr.write(hint + "\n");
}
return EXIT.NOT_FOUND; return EXIT.NOT_FOUND;
} }
if (opts.json) { if (opts.json) {