diff --git a/assistant/src/__tests__/device-id.test.ts b/assistant/src/__tests__/device-id.test.ts new file mode 100644 index 00000000000..166aa14fd17 --- /dev/null +++ b/assistant/src/__tests__/device-id.test.ts @@ -0,0 +1,112 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +// Suppress logger output before importing the module under test. +mock.module("../util/logger.js", () => ({ + getLogger: () => + new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +import { getDeviceId, resetDeviceIdCache } from "../util/device-id.js"; + +const originalVellumEnvironment = process.env.VELLUM_ENVIRONMENT; +const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; +const originalIsContainerized = process.env.IS_CONTAINERIZED; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "vellum-device-id-test-")); + resetDeviceIdCache(); +}); + +afterEach(() => { + resetDeviceIdCache(); + + if (originalVellumEnvironment == null) { + delete process.env.VELLUM_ENVIRONMENT; + } else { + process.env.VELLUM_ENVIRONMENT = originalVellumEnvironment; + } + if (originalXdgConfigHome == null) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalIsContainerized == null) { + delete process.env.IS_CONTAINERIZED; + } else { + process.env.IS_CONTAINERIZED = originalIsContainerized; + } + + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +describe("getDeviceId env-awareness", () => { + test("non-prod (dev) writes device.json under $XDG_CONFIG_HOME/vellum-dev", () => { + // Guarantee we're not containerized — the test-preload deletes this, + // but be defensive. + delete process.env.IS_CONTAINERIZED; + process.env.VELLUM_ENVIRONMENT = "dev"; + process.env.XDG_CONFIG_HOME = tempDir; + + const id = getDeviceId(); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + + const expectedPath = join(tempDir, "vellum-dev", "device.json"); + expect(existsSync(expectedPath)).toBe(true); + + const parsed = JSON.parse(readFileSync(expectedPath, "utf-8")); + expect(parsed.deviceId).toBe(id); + }); + + test("staging environment writes under $XDG_CONFIG_HOME/vellum-staging", () => { + delete process.env.IS_CONTAINERIZED; + process.env.VELLUM_ENVIRONMENT = "staging"; + process.env.XDG_CONFIG_HOME = tempDir; + + getDeviceId(); + + const expectedPath = join(tempDir, "vellum-staging", "device.json"); + expect(existsSync(expectedPath)).toBe(true); + }); + + test("unknown environment does NOT write under $XDG_CONFIG_HOME/vellum-", () => { + // Unknown env names fall back to the legacy production behavior. + // We can't assert the exact legacy path without mocking homedir(), + // but we can assert that the XDG env-scoped dir is NOT created. + delete process.env.IS_CONTAINERIZED; + process.env.VELLUM_ENVIRONMENT = "no-such-env"; + process.env.XDG_CONFIG_HOME = tempDir; + + getDeviceId(); + + // No `vellum-no-such-env` directory created under our XDG tempdir. + const envScopedPath = join(tempDir, "vellum-no-such-env", "device.json"); + expect(existsSync(envScopedPath)).toBe(false); + // Legacy fallback would write under `${homedir()}/.vellum` — not touched. + const productionXdgPath = join(tempDir, "vellum", "device.json"); + expect(existsSync(productionXdgPath)).toBe(false); + }); + + test("production does NOT write under $XDG_CONFIG_HOME/vellum", () => { + // Production path is ~/.vellum/device.json, never XDG_CONFIG_HOME. + delete process.env.IS_CONTAINERIZED; + delete process.env.VELLUM_ENVIRONMENT; + process.env.XDG_CONFIG_HOME = tempDir; + + getDeviceId(); + + const xdgPath = join(tempDir, "vellum", "device.json"); + expect(existsSync(xdgPath)).toBe(false); + }); +}); diff --git a/assistant/src/__tests__/platform.test.ts b/assistant/src/__tests__/platform.test.ts index 939518e5a3a..d88ed41f877 100644 --- a/assistant/src/__tests__/platform.test.ts +++ b/assistant/src/__tests__/platform.test.ts @@ -21,10 +21,14 @@ import { getWorkspaceHooksDir, getWorkspacePromptPath, getWorkspaceSkillsDir, + getXdgPlatformTokenPath, + getXdgVellumConfigDirName, } from "../util/platform.js"; const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR; const originalBaseDataDir = process.env.BASE_DATA_DIR; +const originalVellumEnvironment = process.env.VELLUM_ENVIRONMENT; +const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; afterEach(() => { if (originalWorkspaceDir == null) { @@ -37,6 +41,16 @@ afterEach(() => { } else { process.env.BASE_DATA_DIR = originalBaseDataDir; } + if (originalVellumEnvironment == null) { + delete process.env.VELLUM_ENVIRONMENT; + } else { + process.env.VELLUM_ENVIRONMENT = originalVellumEnvironment; + } + if (originalXdgConfigHome == null) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } }); // Path characterization: documents the current path layout. @@ -137,6 +151,56 @@ describe("path characterization", () => { }); }); +describe("XDG platform-token path env-awareness", () => { + test("production returns ~/.config/vellum/platform-token", () => { + delete process.env.VELLUM_ENVIRONMENT; + delete process.env.XDG_CONFIG_HOME; + expect(getXdgVellumConfigDirName()).toBe("vellum"); + expect(getXdgPlatformTokenPath()).toBe( + join(homedir(), ".config", "vellum", "platform-token"), + ); + }); + + test("production (explicit) returns ~/.config/vellum/platform-token", () => { + process.env.VELLUM_ENVIRONMENT = "production"; + delete process.env.XDG_CONFIG_HOME; + expect(getXdgVellumConfigDirName()).toBe("vellum"); + expect(getXdgPlatformTokenPath()).toBe( + join(homedir(), ".config", "vellum", "platform-token"), + ); + }); + + test("dev environment returns $XDG_CONFIG_HOME/vellum-dev/platform-token", () => { + process.env.VELLUM_ENVIRONMENT = "dev"; + process.env.XDG_CONFIG_HOME = "/tmp/fake-xdg"; + expect(getXdgVellumConfigDirName()).toBe("vellum-dev"); + expect(getXdgPlatformTokenPath()).toBe( + "/tmp/fake-xdg/vellum-dev/platform-token", + ); + }); + + test.each(["staging", "test", "local"])( + "%s environment returns env-scoped path", + (env) => { + process.env.VELLUM_ENVIRONMENT = env; + process.env.XDG_CONFIG_HOME = "/tmp/fake-xdg"; + expect(getXdgVellumConfigDirName()).toBe(`vellum-${env}`); + expect(getXdgPlatformTokenPath()).toBe( + `/tmp/fake-xdg/vellum-${env}/platform-token`, + ); + }, + ); + + test("unknown environment falls back to production path", () => { + process.env.VELLUM_ENVIRONMENT = "no-such-env"; + process.env.XDG_CONFIG_HOME = "/tmp/fake-xdg"; + expect(getXdgVellumConfigDirName()).toBe("vellum"); + expect(getXdgPlatformTokenPath()).toBe( + "/tmp/fake-xdg/vellum/platform-token", + ); + }); +}); + describe("workspace path primitives", () => { test("workspace helpers resolve under workspace dir", () => { delete process.env.VELLUM_WORKSPACE_DIR; diff --git a/assistant/src/util/device-id.ts b/assistant/src/util/device-id.ts index 03a49ca27ef..b798272a635 100644 --- a/assistant/src/util/device-id.ts +++ b/assistant/src/util/device-id.ts @@ -6,11 +6,14 @@ * extensible for future per-device metadata. * * Path resolution: - * - Containerized (IS_CONTAINERIZED=true): uses /home/assistant (the assistant - * user's persistent home dir) so device.json lives on the assistant's own - * filesystem rather than the shared data volume. - * - Local (single or multi-instance): uses homedir() so all instances on the - * same machine share a single device ID. + * - Containerized (IS_CONTAINERIZED=true): `/home/assistant/.vellum/device.json` + * — the assistant user's persistent home dir, kept off the shared data + * volume. Not affected by VELLUM_ENVIRONMENT because the container fs + * has no cross-process contract with the Swift client. + * - Non-containerized production: `~/.vellum/device.json` (legacy, shared + * across all local instances on the same machine). + * - Non-containerized non-production: `$XDG_CONFIG_HOME/vellum-/device.json`, + * matching Swift's `VellumPaths.deviceIdFile`. * * The value is cached in memory after the first successful read/write. * Falls back to a generated UUID if the file cannot be read or written. @@ -23,6 +26,7 @@ import { join } from "node:path"; import { getIsContainerized } from "../config/env-registry.js"; import { getLogger } from "./logger.js"; +import { getXdgVellumConfigDirName } from "./platform.js"; const log = getLogger("device-id"); @@ -44,6 +48,35 @@ export function getDeviceIdBaseDir(): string { return homedir(); } +/** + * Resolve the directory and file path for `device.json` based on the + * runtime environment. See the module docblock for the resolution table. + * + * Production and containerized modes preserve the legacy `~/.vellum` / + * `/home/assistant/.vellum` paths. Non-production, non-containerized + * deployments route through `$XDG_CONFIG_HOME/vellum-` to match + * the Swift client's `VellumPaths.deviceIdFile`. + */ +function resolveDeviceIdPaths(): { dir: string; file: string } { + if (getIsContainerized()) { + const dir = join("/home/assistant", ".vellum"); + return { dir, file: join(dir, "device.json") }; + } + + const configDirName = getXdgVellumConfigDirName(); + if (configDirName === "vellum") { + // Production: device.json lives at ~/.vellum/device.json, shared + // across all local instances on the same machine. + const dir = join(homedir(), ".vellum"); + return { dir, file: join(dir, "device.json") }; + } + + const configHome = + process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"); + const dir = join(configHome, configDirName); + return { dir, file: join(dir, "device.json") }; +} + /** * Get the stable device ID for this machine. * @@ -60,8 +93,7 @@ export function getDeviceId(): string { return cached; } - const vellumDir = join(getDeviceIdBaseDir(), ".vellum"); - const filePath = join(vellumDir, "device.json"); + const { dir: vellumDir, file: filePath } = resolveDeviceIdPaths(); const generated = randomUUID(); try { diff --git a/assistant/src/util/platform.ts b/assistant/src/util/platform.ts index 9329dbf428a..c6be5f0fc96 100644 --- a/assistant/src/util/platform.ts +++ b/assistant/src/util/platform.ts @@ -154,15 +154,44 @@ export function getTCPHost(): string { return "127.0.0.1"; } +// Kept in sync with `cli/src/lib/environments/seeds.ts`. The daemon does not +// import from the CLI package, so the list is duplicated here. If a new +// environment is added to the seed table, add it here too. +const KNOWN_ENVIRONMENTS: ReadonlySet = new Set([ + "production", + "staging", + "test", + "dev", + "local", +]); + +/** + * Returns the env-scoped XDG config subdirectory name for Vellum + * (`vellum` in production, `vellum-` otherwise). Mirrors the Swift + * side's `VellumPaths.configDir` and the CLI's + * `environments/paths.ts:getConfigDir` so the daemon resolves to the + * same on-disk location as every other writer of these files. + * + * Unknown environment names fall back to production to preserve the + * legacy path for any unrecognized value. + */ +export function getXdgVellumConfigDirName(): string { + const raw = process.env.VELLUM_ENVIRONMENT?.trim(); + if (!raw || raw === "production") return "vellum"; + if (!KNOWN_ENVIRONMENTS.has(raw)) return "vellum"; + return `vellum-${raw}`; +} + /** - * Returns the XDG-compliant path for the platform API token - * (~/.config/vellum/platform-token). This is the canonical location - * shared by the CLI and desktop app. + * Returns the XDG-compliant path for the platform API token. Resolves to + * `$XDG_CONFIG_HOME/vellum/platform-token` in production and + * `$XDG_CONFIG_HOME/vellum-/platform-token` otherwise, matching the + * Swift client and CLI. */ -function getXdgPlatformTokenPath(): string { +export function getXdgPlatformTokenPath(): string { const configHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"); - return join(configHome, "vellum", "platform-token"); + return join(configHome, getXdgVellumConfigDirName(), "platform-token"); } /**