diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 3d5258a..6b3ea7f 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -2906,17 +2906,66 @@ function handleConnection(ws: WebSocket): void { } case "message_status": { const ms = msg as Extract; - // Look up the message in the queue. - const [mqRow] = await db - .select({ - id: messageQueue.id, - targetSpec: messageQueue.targetSpec, - deliveredAt: messageQueue.deliveredAt, - meshId: messageQueue.meshId, - }) - .from(messageQueue) - .where(eq(messageQueue.id, ms.messageId)); - if (!mqRow || mqRow.meshId !== conn.meshId) { + // Validate id shape — guards against the broker running a wide + // LIKE scan for empty/malformed input. ≥8 base62 chars, ≤32. + const idStr = String(ms.messageId ?? ""); + if (idStr.length < 8 || idStr.length > 32 || !/^[A-Za-z0-9]+$/.test(idStr)) { + sendError( + conn.ws, + "invalid_argument", + `messageId must be 8-32 base62 chars (got ${idStr.length})`, + undefined, + _reqId, + ); + 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); break; } diff --git a/apps/cli/package.json b/apps/cli/package.json index d5138a0..e1eb5e0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.9.2", + "version": "1.9.3", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/cli/validators.ts b/apps/cli/src/cli/validators.ts new file mode 100644 index 0000000..7cb35f1 --- /dev/null +++ b/apps/cli/src/cli/validators.ts @@ -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 = + | { 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: `✘ ` / ` ` / ` `. + * + * Optional fourth line for `nearest` when a fuzzy suggestion is available. + */ +export function renderValidationError( + args: { + verb: string; + input: string; + result: Extract; + 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`); + } +} diff --git a/apps/cli/src/commands/broker-actions.ts b/apps/cli/src/commands/broker-actions.ts index 9357787..3dfb27e 100644 --- a/apps/cli/src/commands/broker-actions.ts +++ b/apps/cli/src/commands/broker-actions.ts @@ -20,6 +20,7 @@ import { tryBridge } from "~/services/bridge/client.js"; import { render } from "~/ui/render.js"; import { bold, clay, dim } from "~/ui/styles.js"; import { EXIT } from "~/constants/exit-codes.js"; +import { validateMessageId, renderValidationError } from "~/cli/validators.js"; type StateFlags = { mesh?: string; json?: boolean }; type PeerStatus = "idle" | "working" | "dnd"; @@ -186,15 +187,50 @@ export async function runForget(id: string | undefined, opts: StateFlags): Promi // --- msg-status --- export async function runMsgStatus(id: string | undefined, opts: StateFlags): Promise { - if (!id) { - render.err("Usage: claudemesh msg-status "); + // Validate input shape *before* we open a WS connection, so a typo + // 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; } + const lookupId = v.value.value; return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => { - const result = await client.messageStatus(id); + const result = await client.messageStatus(lookupId); if (!result) { - if (opts.json) console.log(JSON.stringify({ id, found: false })); - else render.err(`Message ${id} not found or timed out.`); + if (opts.json) { + 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 ` + : ` 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; } if (opts.json) {