Skip to content

Commit 45f9616

Browse files
committed
fix(cli): stabilize machine identity
1 parent 452fed4 commit 45f9616

7 files changed

Lines changed: 115 additions & 3 deletions

File tree

packages/cli/src/daemon/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { execSync } from "node:child_process";
22
import { mkdirSync, unlinkSync } from "node:fs";
3-
import { arch, hostname, platform, release } from "node:os";
3+
import { arch, platform, release } from "node:os";
44
import type { MachineRuntime } from "@agent-kanban/shared";
55
import { MachineClient } from "../client/index.js";
66
import { getCredentials } from "../config.js";
77
import { generateDeviceId } from "../device.js";
88
import { createLogger } from "../logger.js";
9+
import { resolveMachineName } from "../machineName.js";
910
import { PID_FILE, STATE_DIR } from "../paths.js";
1011
import { getAvailableProviders, getProvider } from "../providers/registry.js";
1112
import type { AgentProvider, HistoryEvent } from "../providers/types.js";
@@ -168,7 +169,7 @@ function removePidFile(): void {
168169
async function getMachineInfo(providers: AgentProvider[], rateLimiter: RateLimiter) {
169170
const os = `${platform()} ${arch()} ${release()}`;
170171
const runtimes = await buildRuntimeStates(providers, rateLimiter);
171-
return { name: hostname(), os, version: getVersion(), runtimes };
172+
return { name: resolveMachineName(), os, version: getVersion(), runtimes };
172173
}
173174

174175
async function buildRuntimeStates(providers: AgentProvider[], rateLimiter: RateLimiter): Promise<MachineRuntime[]> {

packages/cli/src/device.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
import { createHash } from "node:crypto";
2+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
import { hostname, networkInterfaces } from "node:os";
4+
import { dirname } from "node:path";
5+
import { MACHINE_ID_FILE } from "./paths.js";
36

4-
export function generateDeviceId(): string {
7+
export function generateDeviceId(machineIdFile = MACHINE_ID_FILE): string {
8+
const stored = readMachineId(machineIdFile);
9+
if (stored) return stored;
10+
11+
const id = legacyDeviceId();
12+
mkdirSync(dirname(machineIdFile), { recursive: true });
13+
writeFileSync(machineIdFile, `${id}\n`);
14+
return id;
15+
}
16+
17+
function readMachineId(machineIdFile: string): string | null {
18+
try {
19+
const id = readFileSync(machineIdFile, "utf-8").trim();
20+
return id || null;
21+
} catch (err: any) {
22+
if (err?.code === "ENOENT") return null;
23+
throw err;
24+
}
25+
}
26+
27+
function legacyDeviceId(): string {
528
const host = hostname();
629
const nets = networkInterfaces();
730
let mac = "";

packages/cli/src/machineName.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { execFileSync } from "node:child_process";
2+
import { hostname, platform } from "node:os";
3+
4+
export function resolveMachineName(): string {
5+
if (platform() !== "darwin") return hostname();
6+
7+
const localHostName = execFileSync("scutil", ["--get", "LocalHostName"], { encoding: "utf-8" }).trim();
8+
return `${localHostName}.local`;
9+
}

packages/cli/src/paths.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const LOGS_DIR = join(STATE_DIR, "logs");
1616
export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
1717
export const PID_FILE = join(STATE_DIR, "daemon.pid");
1818
export const DAEMON_STATE_FILE = join(STATE_DIR, "daemon-state.json");
19+
export const MACHINE_ID_FILE = join(STATE_DIR, "machine-id");
1920
export const REPOS_DIR = join(DATA_DIR, "repos");
2021
export const WORKTREES_DIR = join(DATA_DIR, "worktrees");
2122
export const SESSIONS_DIR = join(STATE_DIR, "sessions");

packages/cli/tests/daemon-heartbeat.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
1313
}));
1414

1515
vi.mock("node:child_process", () => ({
16+
execFileSync: vi.fn().mockReturnValue("test-machine\n"),
1617
execSync: vi.fn(),
1718
}));
1819

tests/device-id.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { generateDeviceId } from "../packages/cli/src/device";
6+
7+
const dirs: string[] = [];
8+
9+
function tempMachineIdFile(): string {
10+
const dir = mkdtempSync(join(tmpdir(), "ak-device-id-"));
11+
dirs.push(dir);
12+
return join(dir, "machine-id");
13+
}
14+
15+
afterEach(() => {
16+
for (const dir of dirs.splice(0)) {
17+
rmSync(dir, { recursive: true, force: true });
18+
}
19+
});
20+
21+
describe("generateDeviceId", () => {
22+
it("reuses the persisted machine id", () => {
23+
const file = tempMachineIdFile();
24+
writeFileSync(file, "stable-machine-id\n");
25+
26+
expect(generateDeviceId(file)).toBe("stable-machine-id");
27+
});
28+
29+
it("persists the initial machine id", () => {
30+
const file = tempMachineIdFile();
31+
32+
const id = generateDeviceId(file);
33+
34+
expect(readFileSync(file, "utf-8").trim()).toBe(id);
35+
expect(generateDeviceId(file)).toBe(id);
36+
});
37+
});

tests/machine-name.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
execFileSync: vi.fn(),
5+
hostname: vi.fn(),
6+
platform: vi.fn(),
7+
}));
8+
9+
vi.mock("node:child_process", () => ({
10+
default: { execFileSync: mocks.execFileSync },
11+
execFileSync: mocks.execFileSync,
12+
}));
13+
14+
vi.mock("node:os", () => ({
15+
default: { hostname: mocks.hostname, platform: mocks.platform },
16+
hostname: mocks.hostname,
17+
platform: mocks.platform,
18+
}));
19+
20+
describe("resolveMachineName", () => {
21+
it("uses macOS LocalHostName as the display name", async () => {
22+
mocks.platform.mockReturnValue("darwin");
23+
mocks.execFileSync.mockReturnValue("Jaspers-MacBook-Air\n");
24+
25+
const { resolveMachineName } = await import("../packages/cli/src/machineName");
26+
27+
expect(resolveMachineName()).toBe("Jaspers-MacBook-Air.local");
28+
expect(mocks.execFileSync).toHaveBeenCalledWith("scutil", ["--get", "LocalHostName"], { encoding: "utf-8" });
29+
});
30+
31+
it("uses os hostname outside macOS", async () => {
32+
vi.resetModules();
33+
mocks.platform.mockReturnValue("linux");
34+
mocks.hostname.mockReturnValue("runner");
35+
36+
const { resolveMachineName } = await import("../packages/cli/src/machineName");
37+
38+
expect(resolveMachineName()).toBe("runner");
39+
});
40+
});

0 commit comments

Comments
 (0)