refactor(cli): unify on clawdis CLI + node permissions

This commit is contained in:
Peter Steinberger
2025-12-20 02:08:04 +00:00
parent 479720c169
commit 849446ae17
49 changed files with 1205 additions and 2735 deletions
+11
View File
@@ -228,6 +228,7 @@ describe("node bridge server", () => {
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
} | null = null;
let disconnected: {
@@ -238,6 +239,7 @@ describe("node bridge server", () => {
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
} | null = null;
let resolveDisconnected: (() => void) | null = null;
@@ -268,6 +270,7 @@ describe("node bridge server", () => {
version: "1.0",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
permissions: { screenRecording: true, notifications: false },
});
// Approve the pending request from the gateway side.
@@ -304,6 +307,7 @@ describe("node bridge server", () => {
version: "2.0",
deviceFamily: "iPad",
modelIdentifier: "iPad99,1",
permissions: { screenRecording: false },
});
const line3 = JSON.parse(await readLine2()) as { type: string };
expect(line3.type).toBe("hello-ok");
@@ -320,6 +324,10 @@ describe("node bridge server", () => {
expect(lastAuthed?.version).toBe("1.0");
expect(lastAuthed?.deviceFamily).toBe("iPad");
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
expect(lastAuthed?.permissions).toEqual({
screenRecording: false,
notifications: false,
});
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
socket2.destroy();
@@ -432,6 +440,7 @@ describe("node bridge server", () => {
modelIdentifier: "iPad14,5",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
permissions: { accessibility: true },
});
// Approve the pending request from the gateway side.
@@ -464,6 +473,7 @@ describe("node bridge server", () => {
"canvas.snapshot",
"camera.snap",
]);
expect(node?.permissions).toEqual({ accessibility: true });
const after = await listNodePairing(baseDir);
const paired = after.paired.find((p) => p.nodeId === "n-caps");
@@ -473,6 +483,7 @@ describe("node bridge server", () => {
"canvas.snapshot",
"camera.snap",
]);
expect(paired?.permissions).toEqual({ accessibility: true });
socket.destroy();
await server.close();
+29
View File
@@ -22,6 +22,7 @@ type BridgeHelloFrame = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
};
type BridgePairRequestFrame = {
@@ -34,6 +35,7 @@ type BridgePairRequestFrame = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteAddress?: string;
silent?: boolean;
};
@@ -123,6 +125,7 @@ export type NodeBridgeClientInfo = {
remoteIp?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
};
export type NodeBridgeServerOpts = {
@@ -288,6 +291,18 @@ export async function startNodeBridgeServer(
return undefined;
};
const normalizePermissions = (
raw: unknown,
): Record<string, boolean> | undefined => {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return undefined;
const entries = Object.entries(raw as Record<string, unknown>)
.map(([key, value]) => [String(key).trim(), value === true] as const)
.filter(([key]) => key.length > 0);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries);
};
const caps =
(Array.isArray(hello.caps)
? hello.caps.map((c) => String(c)).filter(Boolean)
@@ -299,6 +314,10 @@ export async function startNodeBridgeServer(
Array.isArray(hello.commands) && hello.commands.length > 0
? hello.commands.map((c) => String(c)).filter(Boolean)
: verified.node.commands;
const helloPermissions = normalizePermissions(hello.permissions);
const permissions = helloPermissions
? { ...(verified.node.permissions ?? {}), ...helloPermissions }
: verified.node.permissions;
isAuthenticated = true;
const existing = connections.get(nodeId);
@@ -318,6 +337,7 @@ export async function startNodeBridgeServer(
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
caps,
commands,
permissions,
remoteIp: remoteAddress,
};
await updatePairedNodeMetadata(
@@ -331,6 +351,7 @@ export async function startNodeBridgeServer(
remoteIp: nodeInfo.remoteIp,
caps: nodeInfo.caps,
commands: nodeInfo.commands,
permissions: nodeInfo.permissions,
},
opts.pairingBaseDir,
);
@@ -396,6 +417,10 @@ export async function startNodeBridgeServer(
commands: Array.isArray(req.commands)
? req.commands.map((c) => String(c)).filter(Boolean)
: undefined,
permissions:
req.permissions && typeof req.permissions === "object"
? (req.permissions as Record<string, boolean>)
: undefined,
remoteIp: remoteAddress,
silent: req.silent === true ? true : undefined,
},
@@ -433,6 +458,10 @@ export async function startNodeBridgeServer(
commands: Array.isArray(req.commands)
? req.commands.map((c) => String(c)).filter(Boolean)
: undefined,
permissions:
req.permissions && typeof req.permissions === "object"
? (req.permissions as Record<string, boolean>)
: undefined,
remoteIp: remoteAddress,
};
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
-124
View File
@@ -1,124 +0,0 @@
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const runExecCalls = vi.hoisted(
() => [] as Array<{ cmd: string; args: string[] }>,
);
const runCommandCalls = vi.hoisted(
() => [] as Array<{ argv: string[]; timeoutMs: number }>,
);
let runExecThrows = false;
vi.mock("../process/exec.js", () => ({
runExec: vi.fn(async (cmd: string, args: string[]) => {
runExecCalls.push({ cmd, args });
if (runExecThrows) throw new Error("which failed");
return { stdout: "/usr/local/bin/clawdis-mac\n", stderr: "" };
}),
runCommandWithTimeout: vi.fn(async (argv: string[], timeoutMs: number) => {
runCommandCalls.push({ argv, timeoutMs });
return { stdout: "ok", stderr: "", code: 0 };
}),
}));
import { resolveClawdisMacBinary, runClawdisMac } from "./clawdis-mac.js";
describe("clawdis-mac binary resolver", () => {
it("uses env override on macOS and errors elsewhere", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: (code: number) => {
throw new Error(`exit ${code}`);
},
};
if (process.platform === "darwin") {
vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac");
await expect(resolveClawdisMacBinary(runtime)).resolves.toBe(
"/opt/bin/clawdis-mac",
);
return;
}
await expect(resolveClawdisMacBinary(runtime)).rejects.toThrow(/exit 1/);
});
it("runs the helper with --json when requested", async () => {
if (process.platform !== "darwin") return;
vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac");
const res = await runClawdisMac(["browser", "status"], {
json: true,
timeoutMs: 1234,
});
expect(res).toMatchObject({ stdout: "ok", code: 0 });
expect(runCommandCalls.length).toBeGreaterThan(0);
expect(runCommandCalls.at(-1)?.argv).toEqual([
"/opt/bin/clawdis-mac",
"--json",
"browser",
"status",
]);
expect(runCommandCalls.at(-1)?.timeoutMs).toBe(1234);
});
it("falls back to `which clawdis-mac` when no override is set", async () => {
if (process.platform !== "darwin") return;
vi.stubEnv("CLAWDIS_MAC_BIN", "");
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: (code: number) => {
throw new Error(`exit ${code}`);
},
};
const resolved = await resolveClawdisMacBinary(runtime);
expect(resolved).toBe("/usr/local/bin/clawdis-mac");
expect(runExecCalls.some((c) => c.cmd === "which")).toBe(true);
});
it("falls back to ./bin/clawdis-mac when which fails", async () => {
if (process.platform !== "darwin") return;
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdis-mac-test-"));
const oldCwd = process.cwd();
try {
const binDir = path.join(tmp, "bin");
await fsp.mkdir(binDir, { recursive: true });
const exePath = path.join(binDir, "clawdis-mac");
await fsp.writeFile(exePath, "#!/bin/sh\necho ok\n", "utf-8");
await fsp.chmod(exePath, 0o755);
process.chdir(tmp);
vi.stubEnv("CLAWDIS_MAC_BIN", "");
runExecThrows = true;
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: (code: number) => {
throw new Error(`exit ${code}`);
},
};
const resolved = await resolveClawdisMacBinary(runtime);
const expectedReal = await fsp.realpath(exePath);
const resolvedReal = await fsp.realpath(resolved);
expect(resolvedReal).toBe(expectedReal);
} finally {
runExecThrows = false;
process.chdir(oldCwd);
await fsp.rm(tmp, { recursive: true, force: true });
}
});
});
-65
View File
@@ -1,65 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
export type ClawdisMacExecResult = {
stdout: string;
stderr: string;
code: number | null;
};
function isFileExecutable(p: string): boolean {
try {
const stat = fs.statSync(p);
if (!stat.isFile()) return false;
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
export async function resolveClawdisMacBinary(
runtime: RuntimeEnv = defaultRuntime,
): Promise<string> {
if (process.platform !== "darwin") {
runtime.error("clawdis-mac is only available on macOS.");
runtime.exit(1);
}
const override = process.env.CLAWDIS_MAC_BIN?.trim();
if (override) return override;
try {
const { stdout } = await runExec("which", ["clawdis-mac"], 2000);
const resolved = stdout.trim();
if (resolved) return resolved;
} catch {
// fall through
}
const local = path.resolve(process.cwd(), "bin", "clawdis-mac");
if (isFileExecutable(local)) return local;
runtime.error(
"Missing required binary: clawdis-mac. Install the Clawdis mac app/CLI helper (or set CLAWDIS_MAC_BIN).",
);
runtime.exit(1);
}
export async function runClawdisMac(
args: string[],
opts?: { json?: boolean; timeoutMs?: number; runtime?: RuntimeEnv },
): Promise<ClawdisMacExecResult> {
const runtime = opts?.runtime ?? defaultRuntime;
const cmd = await resolveClawdisMacBinary(runtime);
const argv: string[] = [cmd];
if (opts?.json) argv.push("--json");
argv.push(...args);
const res = await runCommandWithTimeout(argv, opts?.timeoutMs ?? 30_000);
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
}
+5
View File
@@ -13,6 +13,7 @@ export type NodePairingPendingRequest = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
silent?: boolean;
isRepair?: boolean;
@@ -29,6 +30,7 @@ export type NodePairingPairedNode = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
createdAtMs: number;
approvedAtMs: number;
@@ -185,6 +187,7 @@ export async function requestNodePairing(
modelIdentifier: req.modelIdentifier,
caps: req.caps,
commands: req.commands,
permissions: req.permissions,
remoteIp: req.remoteIp,
silent: req.silent,
isRepair,
@@ -217,6 +220,7 @@ export async function approveNodePairing(
modelIdentifier: pending.modelIdentifier,
caps: pending.caps,
commands: pending.commands,
permissions: pending.permissions,
remoteIp: pending.remoteIp,
createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now,
@@ -281,6 +285,7 @@ export async function updatePairedNodeMetadata(
remoteIp: patch.remoteIp ?? existing.remoteIp,
caps: patch.caps ?? existing.caps,
commands: patch.commands ?? existing.commands,
permissions: patch.permissions ?? existing.permissions,
};
state.pairedByNodeId[normalized] = next;
+1 -1
View File
@@ -12,7 +12,7 @@ describe("system-presence", () => {
const instanceIdLower = instanceIdUpper.toLowerCase();
upsertPresence(instanceIdUpper, {
host: "clawdis-mac",
host: "clawdis",
mode: "app",
instanceId: instanceIdUpper,
reason: "connect",