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:
parent
44f4fe10b6
commit
9eafc6a9f0
13396
package-lock.json
generated
Normal file
13396
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
53
src/infra/random-codes.test.ts
Normal file
53
src/infra/random-codes.test.ts
Normal 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
29
src/infra/random-codes.ts
Normal 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")}`;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user