Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions assistant/src/__tests__/device-id.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, {
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>", () => {
// 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);
});
});
64 changes: 64 additions & 0 deletions assistant/src/__tests__/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
46 changes: 39 additions & 7 deletions assistant/src/util/device-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<env>/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.
Expand All @@ -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");

Expand All @@ -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-<env>` 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") };
Comment on lines +74 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy device ID fallback for non-prod upgrades

This path switch sends all non-production runs to $XDG_CONFIG_HOME/vellum-<env>/device.json, but getDeviceId() no longer reads the legacy ~/.vellum/device.json first. After upgrading, existing dev/staging/test installs that already have a persisted ID in the old location (including IDs seeded by 003-seed-device-id) will silently mint a new UUID, which breaks device/installation continuity for telemetry and Sentry correlation. Add a legacy read fallback (or one-time copy) before generating a new ID.

Useful? React with 👍 / 👎.

}
Comment on lines +60 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Migration 003-seed-device-id writes device.json to wrong path for non-production environments

The PR changes getDeviceId() to resolve device.json via resolveDeviceIdPaths() which, for non-production non-containerized environments, writes to $XDG_CONFIG_HOME/vellum-<env>/device.json. However, the existing migration 003-seed-device-id (assistant/src/workspace/migrations/003-seed-device-id.ts:19-21) still uses getDeviceIdBaseDir() which always constructs join(homedir(), ".vellum", "device.json") for local environments. This creates a path mismatch: the migration seeds the device ID at ~/.vellum/device.json, but getDeviceId() reads from the XDG path, so the seeded ID is never found. The startup sequence in assistant/src/instrument.ts:69-72 explicitly defers Sentry device ID setup until after this migration runs, relying on the migration to seed the ID before getDeviceId() generates a fresh UUID — that contract is now broken for non-production environments. Additionally, per AGENTS.md, a migration should be included to copy existing ~/.vellum/device.json to the new XDG location for non-production environments to preserve device ID continuity.

Prompt for agents
The new resolveDeviceIdPaths() function routes non-production, non-containerized environments to $XDG_CONFIG_HOME/vellum-<env>/device.json, but the existing workspace migration 003-seed-device-id (assistant/src/workspace/migrations/003-seed-device-id.ts) still uses getDeviceIdBaseDir() which always resolves to ~/.vellum/device.json for local environments. This creates a mismatch where the migration seeds the wrong file.

Two things need to happen:

1. Update the migration 003-seed-device-id to use the same path resolution as getDeviceId() (i.e., call resolveDeviceIdPaths or equivalent logic) so it seeds device.json at the correct location for the current environment. Since migrations are append-only and must not be reordered, the safest approach is to add a NEW migration (e.g., 036-migrate-device-id-to-xdg) that copies the deviceId from ~/.vellum/device.json to the env-scoped XDG path when VELLUM_ENVIRONMENT is a known non-production value. This preserves device ID continuity for existing non-production installations.

2. Consider whether getDeviceIdBaseDir() should be updated or deprecated, since it no longer matches getDeviceId()'s actual resolution logic. The migration's down() method also uses getDeviceIdBaseDir() and would only clean up the production path, not the XDG path.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


/**
* Get the stable device ID for this machine.
*
Expand All @@ -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 {
Expand Down
39 changes: 34 additions & 5 deletions assistant/src/util/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
"production",
"staging",
"test",
"dev",
"local",
]);

/**
* Returns the env-scoped XDG config subdirectory name for Vellum
* (`vellum` in production, `vellum-<env>` 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-<env>/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");
}

/**
Expand Down
Loading