Unify random code generation system (Jadis via orchestration)

- Created src/infra/random-codes.ts with unified utilities:
  - generateHumanCode() for human-friendly codes (no ambiguous chars)
  - generateSecureToken() for tokens/secrets (base64url)
  - generateUUID() wrapper for consistency
  - generateTempSuffix() for temp file names

- Refactored existing code to use new utilities:
  - src/pairing/pairing-store.ts
  - src/infra/exec-approvals.ts
  - src/infra/node-pairing.ts
  - src/infra/device-pairing.ts
  - src/infra/voicewake.ts
  - src/infra/exec-host.ts

- Added comprehensive tests in src/infra/random-codes.test.ts
- Updated pairing-store tests to mock the new module

All tests passing.
This commit is contained in:
Nick DiMoro 2026-01-25 04:39:05 -07:00
parent 44f4fe10b6
commit 9eafc6a9f0
10 changed files with 13526 additions and 50 deletions

13396
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { generateUUID } from "./random-codes.js";
export type DevicePairingPendingRequest = {
requestId: string;
@ -89,7 +89,7 @@ async function readJSON<T>(filePath: string): Promise<T | null> {
async function writeJSONAtomic(filePath: string, value: unknown) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = `${filePath}.${randomUUID()}.tmp`;
const tmp = `${filePath}.${generateUUID()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
try {
await fs.chmod(tmp, 0o600);
@ -210,7 +210,7 @@ function scopesAllow(requested: string[], allowed: string[]): boolean {
}
function newToken() {
return randomUUID().replaceAll("-", "");
return generateUUID().replaceAll("-", "");
}
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
@ -250,7 +250,7 @@ export async function requestDevicePairing(
}
const isRepair = Boolean(state.pairedByDeviceId[deviceId]);
const request: DevicePairingPendingRequest = {
requestId: randomUUID(),
requestId: generateUUID(),
deviceId,
publicKey: req.publicKey,
displayName: req.displayName,

View File

@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { generateSecureToken, generateUUID } from "./random-codes.js";
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
@ -129,7 +130,7 @@ function ensureAllowlistIds(
const next = allowlist.map((entry) => {
if (entry.id) return entry;
changed = true;
return { ...entry, id: crypto.randomUUID() };
return { ...entry, id: generateUUID() };
});
return changed ? next : allowlist;
}
@ -168,7 +169,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
}
function generateToken(): string {
return crypto.randomBytes(24).toString("base64url");
return generateSecureToken();
}
export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
@ -1165,7 +1166,7 @@ export function recordAllowlistUse(
item.pattern === entry.pattern
? {
...item,
id: item.id ?? crypto.randomUUID(),
id: item.id ?? generateUUID(),
lastUsedAt: Date.now(),
lastUsedCommand: command,
lastResolvedPath: resolvedPath,
@ -1189,7 +1190,7 @@ export function addAllowlistEntry(
const trimmed = pattern.trim();
if (!trimmed) return;
if (allowlist.some((entry) => entry.pattern === trimmed)) return;
allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() });
allowlist.push({ id: generateUUID(), pattern: trimmed, lastUsedAt: Date.now() });
agents[target] = { ...existing, allowlist };
approvals.agents = agents;
saveExecApprovals(approvals);
@ -1235,7 +1236,7 @@ export async function requestExecApprovalViaSocket(params: {
const payload = JSON.stringify({
type: "request",
token,
id: crypto.randomUUID(),
id: generateUUID(),
request,
});

View File

@ -1,5 +1,6 @@
import crypto from "node:crypto";
import net from "node:net";
import { generateSecureToken, generateUUID } from "./random-codes.js";
export type ExecHostRequest = {
command: string[];
@ -57,7 +58,7 @@ export async function requestExecHostViaSocket(params: {
};
const requestJson = JSON.stringify(request);
const nonce = crypto.randomBytes(16).toString("hex");
const nonce = generateSecureToken(16);
const ts = Date.now();
const hmac = crypto
.createHmac("sha256", token)
@ -65,7 +66,7 @@ export async function requestExecHostViaSocket(params: {
.digest("hex");
const payload = JSON.stringify({
type: "exec",
id: crypto.randomUUID(),
id: generateUUID(),
nonce,
ts,
hmac,

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { generateUUID } from "./random-codes.js";
export type NodePairingPendingRequest = {
requestId: string;
@ -76,7 +76,7 @@ async function readJSON<T>(filePath: string): Promise<T | null> {
async function writeJSONAtomic(filePath: string, value: unknown) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = `${filePath}.${randomUUID()}.tmp`;
const tmp = `${filePath}.${generateUUID()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
try {
await fs.chmod(tmp, 0o600);
@ -144,7 +144,7 @@ function normalizeNodeId(nodeId: string) {
}
function newToken() {
return randomUUID().replaceAll("-", "");
return generateUUID().replaceAll("-", "");
}
export async function listNodePairing(baseDir?: string): Promise<NodePairingList> {
@ -186,7 +186,7 @@ export async function requestNodePairing(
const isRepair = Boolean(state.pairedByNodeId[nodeId]);
const request: NodePairingPendingRequest = {
requestId: randomUUID(),
requestId: generateUUID(),
nodeId,
displayName: req.displayName,
platform: req.platform,

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
generateHumanCode,
generateSecureToken,
generateTempSuffix,
generateUUID,
} from "./random-codes.js";
const HUMAN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
describe("random code helpers", () => {
it("generates human-friendly codes with the default length", () => {
const code = generateHumanCode();
expect(code).toHaveLength(8);
});
it("generates human-friendly codes with a custom length", () => {
const code = generateHumanCode(16);
expect(code).toHaveLength(16);
});
it("generates human-friendly codes from the expected alphabet", () => {
const code = generateHumanCode(64);
for (const char of code) {
expect(HUMAN_CODE_ALPHABET).toContain(char);
}
expect(code).not.toMatch(/[01IO]/);
});
it("returns an empty human code when length is zero", () => {
expect(generateHumanCode(0)).toBe("");
});
it("generates secure tokens as base64url", () => {
const token = generateSecureToken();
expect(token).toHaveLength(32);
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(token).not.toMatch(/[+=\/]/);
});
it("generates UUIDs", () => {
const uuid = generateUUID();
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("generates temp suffixes with timestamp and random hex", () => {
const suffix = generateTempSuffix();
const match = /^([0-9]+)\.([0-9a-f]{8})$/.exec(suffix);
expect(match).not.toBeNull();
const timestamp = match ? Number(match[1]) : 0;
expect(timestamp).toBeGreaterThan(0);
});
});

29
src/infra/random-codes.ts Normal file
View File

@ -0,0 +1,29 @@
import { randomBytes, randomInt, randomUUID } from "node:crypto";
const HUMAN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function generateHumanCode(length = 8): string {
if (length <= 0) {
return "";
}
let output = "";
for (let i = 0; i < length; i += 1) {
const index = randomInt(HUMAN_CODE_ALPHABET.length);
output += HUMAN_CODE_ALPHABET[index];
}
return output;
}
export function generateSecureToken(bytes = 24): string {
return randomBytes(bytes).toString("base64url");
}
export function generateUUID(): string {
return randomUUID();
}
export function generateTempSuffix(): string {
return `${Date.now()}.${randomBytes(4).toString("hex")}`;
}

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { generateUUID } from "./random-codes.js";
export type VoiceWakeConfig = {
triggers: string[];
@ -34,7 +34,7 @@ async function readJSON<T>(filePath: string): Promise<T | null> {
async function writeJSONAtomic(filePath: string, value: unknown) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = `${filePath}.${randomUUID()}.tmp`;
const tmp = `${filePath}.${generateUUID()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
await fs.rename(tmp, filePath);
}

View File

@ -1,4 +1,3 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@ -6,8 +5,19 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveOAuthDir } from "../config/paths.js";
import { generateHumanCode } from "../infra/random-codes.js";
import { listChannelPairingRequests, upsertChannelPairingRequest } from "./pairing-store.js";
vi.mock("../infra/random-codes.js", async () => {
const actual = await vi.importActual<typeof import("../infra/random-codes.js")>(
"../infra/random-codes.js",
);
return {
...actual,
generateHumanCode: vi.fn(actual.generateHumanCode),
};
});
async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>) {
const previous = process.env.CLAWDBOT_STATE_DIR;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-pairing-"));
@ -81,26 +91,22 @@ describe("pairing store", () => {
it("regenerates when a generated code collides", async () => {
await withTempStateDir(async () => {
const spy = vi.spyOn(crypto, "randomInt");
try {
spy.mockReturnValue(0);
const first = await upsertChannelPairingRequest({
channel: "telegram",
id: "123",
});
expect(first.code).toBe("AAAAAAAA");
const generateHumanCodeMock = vi.mocked(generateHumanCode);
generateHumanCodeMock.mockReturnValueOnce("AAAAAAAA");
generateHumanCodeMock.mockReturnValueOnce("AAAAAAAA");
generateHumanCodeMock.mockReturnValueOnce("BBBBBBBB");
const sequence = Array(8).fill(0).concat(Array(8).fill(1));
let idx = 0;
spy.mockImplementation(() => sequence[idx++] ?? 1);
const second = await upsertChannelPairingRequest({
channel: "telegram",
id: "456",
});
expect(second.code).toBe("BBBBBBBB");
} finally {
spy.mockRestore();
}
const first = await upsertChannelPairingRequest({
channel: "telegram",
id: "123",
});
expect(first.code).toBe("AAAAAAAA");
const second = await upsertChannelPairingRequest({
channel: "telegram",
id: "456",
});
expect(second.code).toBe("BBBBBBBB");
});
});

View File

@ -1,4 +1,3 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@ -7,6 +6,7 @@ import lockfile from "proper-lockfile";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import { generateHumanCode, generateUUID } from "../infra/random-codes.js";
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@ -95,7 +95,7 @@ async function readJsonFile<T>(
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
const tmp = path.join(dir, `${path.basename(filePath)}.${generateUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf-8",
});
@ -170,19 +170,9 @@ function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
return { requests: sorted.slice(-maxPending), removed: true };
}
function randomCode(): string {
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
let out = "";
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length);
out += PAIRING_CODE_ALPHABET[idx];
}
return out;
}
function generateUniqueCode(existing: Set<string>): string {
for (let attempt = 0; attempt < 500; attempt += 1) {
const code = randomCode();
const code = generateHumanCode(PAIRING_CODE_LENGTH);
if (!existing.has(code)) return code;
}
throw new Error("failed to generate unique pairing code");