From 982a2afb15f0665b2b1d0b3ce3c4d9101daded75 Mon Sep 17 00:00:00 2001 From: Antonio Pantano Date: Fri, 9 Oct 2026 13:04:26 +0200 Subject: [PATCH] feat(core): support isolated mesh data directories --- README.md | 13 + src/core/bridge-mesh.ts | 11 +- src/core/data-directory.ts | 24 ++ src/core/gateway-trust.ts | 5 +- src/core/identity-store.ts | 23 +- src/core/machine-identity.ts | 4 +- src/core/petnames.ts | 9 +- src/core/user-identity.ts | 4 +- ...ge-mesh-data-directory.integration.test.ts | 325 ++++++++++++++++++ src/test/data-directory.unit.test.ts | 29 ++ 10 files changed, 423 insertions(+), 24 deletions(-) create mode 100644 src/core/data-directory.ts create mode 100644 src/test/bridge-mesh-data-directory.integration.test.ts create mode 100644 src/test/data-directory.unit.test.ts diff --git a/README.md b/README.md index cb62f9fc..43d9292f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,19 @@ Two more keys sit beside the slot files, each generated locally on first use and **Breaking change (v2):** earlier versions derived the peer ID from the SHA-256 fingerprint of the peer's self-signed X.509 certificate rather than its raw public key. The two values differ for the same keypair, so every agent ID, room membership, and pending delivery queue tied to a pre-v2 identity is orphaned on upgrade — there is no migration path, since existing peers can no longer address an upgraded one under its old ID. A v2 bridge cannot interoperate with a v1 one at all: they no longer agree on wire framing, transport, or peer identity. +### Data directory + +Set `AGENT_COMMS_DATA_DIR` before starting a bridge or an `account` command to keep its persistent mesh data in a separate directory. Unset or blank keeps `~/.agent-comms`. The directory holds device, account and machine keys, identity locks, room and group tokens, gateway trust, connection codes, names, room-notice logs and the account grant ledger. + +Library callers can set `dataDir` on `IdentitySlot`, which is also used by the identity loaders and lock helpers: + +```ts +const slot = { harness: "my-bridge", cwd: process.cwd(), dataDir: config.dataDir }; +const { store, tool } = await createBridgeMesh(slot); +``` + +An explicit `dataDir` wins over the environment and the existing `slot.dir` override. Without `dataDir`, `slot.dir` keeps its existing scope: device-slot files, trust and petnames; account and machine keys use the environment directory or the home-directory default. Bridges using the same data directory share the account and machine keys, while each harness/cwd keeps its own device key. Separate directories create separate identities; existing data is not copied or migrated. This is storage isolation: mesh ports, discovery and the hub are configured separately. + ## Install ### pi diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index a358573b..2d006c64 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -32,6 +32,7 @@ import { VersionDriftChecker } from "./version-check.js"; import { getOwnPackageVersion } from "./package-version.js"; import { loadOrCreateUserIdentity } from "./user-identity.js"; import { loadOrCreateMachineIdentity } from "./machine-identity.js"; +import { resolveDataDir } from "./data-directory.js"; export interface BridgeMesh { store: MeshStore; @@ -85,12 +86,10 @@ export function createBridgeMeshSyncFromIdentity( fetchLatestVersion, firstContactPort = FIRST_CONTACT_PORT, } = options ?? {}; - // The user-principal identity (agent-comms#160) is shared by every bridge on this machine account -- deliberately not scoped to slot, unlike identity above. userIdentityOptions is empty (the default ~/.agent-comms location); every real bridge shares it, and only tests need an override. - const userIdentityOptions = {}; + // Account and machine keys are shared within the configured data directory, not scoped to the device's harness/cwd. Legacy slot.dir still overrides only the device's own files. + const userIdentityOptions = { dir: resolveDataDir({ dir: slot.dataDir }) }; const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); - // The machine identity (agent-comms#343) is shared by every bridge this OS account runs on the host, from the default ~/.agent-comms location. - const machineIdentityOptions = {}; - const machineIdentity = loadOrCreateMachineIdentity(machineIdentityOptions); + const machineIdentity = loadOrCreateMachineIdentity(userIdentityOptions); const store = new MeshStore({ coordinatorPort, hubUrl, @@ -146,7 +145,7 @@ export function createBridgeMeshSyncFromIdentity( }), userIdentityOptions, machineIdentity: await toIdentityPort(machineIdentity), - machineIdentityOptions, + machineIdentityOptions: userIdentityOptions, }); }, }; diff --git a/src/core/data-directory.ts b/src/core/data-directory.ts new file mode 100644 index 00000000..5870b51d --- /dev/null +++ b/src/core/data-directory.ts @@ -0,0 +1,24 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +/** The environment default for the mesh's persistent keys, trust and ledgers. */ +export const DATA_DIR_ENV_VAR = "AGENT_COMMS_DATA_DIR"; + +export interface DataDirectoryLocation { + /** Base directory for all persistent mesh data, including the shared account and machine keys. Overrides the environment default and any legacy slot-only dir. */ + dataDir?: string; +} + +/** Explicit directory first, then the environment setting, then the existing home-directory default. Blank environment values are treated as unset. */ +export function resolveDataDir({ + dir, + env = process.env, +}: Readonly<{ + dir?: string | undefined; + env?: Readonly>; +}> = {}): string { + const configured = env[DATA_DIR_ENV_VAR]?.trim(); + if (dir !== undefined) return dir; + if (configured !== undefined && configured !== "") return configured; + return path.join(os.homedir(), ".agent-comms"); +} diff --git a/src/core/gateway-trust.ts b/src/core/gateway-trust.ts index 35727b99..2ed0a832 100644 --- a/src/core/gateway-trust.ts +++ b/src/core/gateway-trust.ts @@ -55,12 +55,13 @@ export class GatewayTrust { string, VerifiedMember & { expiresAt: number } >(); - private readonly location: Readonly> | undefined; + private readonly location: + Readonly> | undefined; /** gatewayTrustStamp as of the last load or write this instance made, so refresh() can tell when another process has replaced the file since. */ private stamp: string | undefined; /** Constructs the trust boundary, optionally bound to an identity location for persistence (agent-comms#186); any IdentitySlot serves as one, since only its directory matters. See this class's own doc comment for what a location does and doesn't change. Given a location, immediately loads whatever devices and principals are already trusted into the in-memory sets. */ - constructor(location?: Readonly>) { + constructor(location?: Readonly>) { this.location = location; this.load(); } diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index 6e6b9a8f..ca31f646 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -7,8 +7,11 @@ */ import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; +import { + resolveDataDir, + type DataDirectoryLocation, +} from "./data-directory.js"; import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; import { generateIdentity, @@ -27,7 +30,7 @@ import { } from "./pid-lock-file.js"; /** A bridge's identity slot: one persisted identity per harness and cwd. */ -export interface IdentitySlot { +export interface IdentitySlot extends DataDirectoryLocation { harness: string; cwd: string; /** Directory override for tests. */ @@ -166,9 +169,11 @@ function slugifyCwd(cwd: string): string { return cwd.replace(/[\\/:*?"<>|]/g, "_"); } -/** The directory every file this module owns lives in: the slot's own override when it has one, else the per-user `.agent-comms` directory shared by every bridge on the machine. State that belongs to the machine or user rather than to one bridge slot is keyed by this alone. */ -function identityDir(location: Readonly>): string { - return location.dir ?? path.join(os.homedir(), ".agent-comms"); +/** The directory every file this module owns lives in. */ +function identityDir( + location: Readonly>, +): string { + return resolveDataDir({ dir: location.dataDir ?? location.dir }); } function slotPaths(slot: Readonly): { @@ -539,7 +544,7 @@ export function deleteIssuedRoomGrant( /** The trusted-gateway allowlist lives in one JSON file per identity directory, not per bridge slot: who may see and reach this user's agents from another machine is an operator decision about the machine, and every store on it, including each session the default cc-peer front handles, advertises itself under it. It sits beside the identity files rather than inside one, because the trusted set has no dependency on any slot's key material and GatewayTrust can be constructed before an identity is ever loaded. */ function gatewayTrustFilePath( - location: Readonly>, + location: Readonly>, ): string { return path.join(identityDir(location), "gateway-trust.json"); } @@ -564,7 +569,7 @@ function isStringArray(value: unknown): value is string[] { * The trusted devices, principals (agent-comms#187) and machines (agent-comms#343) persisted for this identity directory before this call, each empty if nothing has been saved or the file is missing or unparseable. A file written before machines were trusted has no machines list and loads with an empty one. */ export function loadGatewayTrust( - location: Readonly>, + location: Readonly>, ): LoadedGatewayTrust { let parsed: unknown; try { @@ -594,7 +599,7 @@ export function loadGatewayTrust( * Persists the complete trusted device, principal and machine sets for this identity directory, surviving a restart. Overwrites whatever was saved before in full: GatewayTrust calls this with its own current sets after every change, having first reloaded the file if another process replaced it, so there is no per-entry partial update to preserve here. */ export function saveGatewayTrust( - location: Readonly>, + location: Readonly>, trust: Readonly, ): void { const dir = identityDir(location); @@ -615,7 +620,7 @@ export function saveGatewayTrust( * A token that changes whenever the gateway trust file is replaced, or is created or removed, by any process: the file's inode, modification time and size, or a fixed marker while it does not exist. Every save goes through writeFileAtomic, a rename, so each write yields a new inode and the token differs even when two writes land within one timestamp tick. GatewayTrust compares it to notice that another bridge on the machine changed the shared allowlist. */ export function gatewayTrustStamp( - location: Readonly>, + location: Readonly>, ): string { try { const stat = fs.statSync(gatewayTrustFilePath(location)); diff --git a/src/core/machine-identity.ts b/src/core/machine-identity.ts index ff82fabd..af14712d 100644 --- a/src/core/machine-identity.ts +++ b/src/core/machine-identity.ts @@ -4,8 +4,8 @@ * Stored at ~/.agent-comms/machine-identity.json (mode 0600), so it belongs to the OS account whose home directory holds it: every bridge that account runs on the host reads the same file and is vouched for by the same machine key, while another account on the same host mints a machine key of its own and appears as a separate machine. Until the principal becomes portable across hosts (agent-comms#344) this is the same scope as the user principal; a key naming the host across every account needs a signer outside any one account and is agent-comms#367. The key is generated locally on first use and never derived from hardware (issuer-identity-file.ts says why); re-homing a rebuilt host is a deliberate copy of this file. */ -import * as os from "node:os"; import * as path from "node:path"; +import { resolveDataDir } from "./data-directory.js"; import type { PeerIdentity } from "./identity.js"; import { isStoredIssuerKey, @@ -24,7 +24,7 @@ export interface MachineIdentityOptions { export function machineIdentityFile( options?: Readonly, ): string { - const dir = options?.dir ?? path.join(os.homedir(), ".agent-comms"); + const dir = resolveDataDir({ dir: options?.dir }); return path.join(dir, "machine-identity.json"); } diff --git a/src/core/petnames.ts b/src/core/petnames.ts index 65275bc3..e909a596 100644 --- a/src/core/petnames.ts +++ b/src/core/petnames.ts @@ -5,8 +5,11 @@ */ import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; +import { + resolveDataDir, + type DataDirectoryLocation, +} from "./data-directory.js"; import { writeFileAtomic } from "./atomic-file.js"; import { parseDisplayName } from "./display-name.js"; import { isDeviceIdHex } from "./room-path.js"; @@ -17,7 +20,7 @@ const PETNAMES_FILE_MODE = 0o600; /** Owner-only directory, matching identity-store.ts's own base directory. */ const PETNAMES_DIR_MODE = 0o700; -export interface PetnamesLocation { +export interface PetnamesLocation extends DataDirectoryLocation { /** Directory override; defaults to ~/.agent-comms. */ dir?: string | undefined; } @@ -50,7 +53,7 @@ export class Petnames { location === undefined ? undefined : path.join( - location.dir ?? path.join(os.homedir(), ".agent-comms"), + resolveDataDir({ dir: location.dataDir ?? location.dir }), "petnames.json", ); } diff --git a/src/core/user-identity.ts b/src/core/user-identity.ts index 4ae882b7..82aec8c2 100644 --- a/src/core/user-identity.ts +++ b/src/core/user-identity.ts @@ -5,8 +5,8 @@ */ import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; +import { resolveDataDir } from "./data-directory.js"; import type { AccountContents } from "./account-bundle.js"; import { certifyKeyPair } from "./identity.js"; import type { PeerIdentity } from "./identity.js"; @@ -62,7 +62,7 @@ function isStoredUserIdentity(value: unknown): value is StoredUserIdentity { export function userIdentityDir( options?: Readonly, ): string { - return options?.dir ?? path.join(os.homedir(), ".agent-comms"); + return resolveDataDir({ dir: options?.dir }); } function userIdentityFile(options?: Readonly): string { diff --git a/src/test/bridge-mesh-data-directory.integration.test.ts b/src/test/bridge-mesh-data-directory.integration.test.ts new file mode 100644 index 00000000..94e9f856 --- /dev/null +++ b/src/test/bridge-mesh-data-directory.integration.test.ts @@ -0,0 +1,325 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { + createBridgeMesh, + createBridgeMeshFromIdentity, + createBridgeMeshSync, + createBridgeMeshSyncFromIdentity, + type BridgeMesh, + type BridgeMeshOptions, + type BridgeMeshSync, +} from "../core/bridge-mesh.js"; +import { + loadIdentityForFront, + loadRoomTokens, + probeSlotOwner, + releaseIdentityLock, + type IdentitySlot, +} from "../core/identity-store.js"; +import { DATA_DIR_ENV_VAR } from "../core/data-directory.js"; +import { + loadOrCreateUserIdentity, + loadUserDisplayName, +} from "../core/user-identity.js"; +import { + loadMachineDisplayName, + loadOrCreateMachineIdentity, +} from "../core/machine-identity.js"; +import { openAccountLedger } from "../core/account-ledger-store.js"; +import { runAccountCommand, type AccountCliIo } from "../account-cli.js"; +import { + freeLocalPort, + TeardownStack, + unreachableHubUrl, +} from "./hub-helpers.js"; +import { waitFor } from "./test-transport.js"; + +let root: string; +let defaultDir: string; +const cleanups = new TeardownStack(); + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "agent-comms-data-dir-")); + defaultDir = path.join(root, "environment-default"); + vi.stubEnv(DATA_DIR_ENV_VAR, defaultDir); +}); + +afterEach(async () => { + try { + await cleanups.run(); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +async function meshOptions(): Promise { + return { + coordinatorPort: await freeLocalPort(), + firstContactPort: await freeLocalPort(), + hubUrl: await unreachableHubUrl(), + fetchLatestVersion: async () => undefined, + }; +} + +function track(mesh: BridgeMesh, slot: Readonly): BridgeMesh { + cleanups.push(async () => { + await mesh.store.shutdown(); + releaseIdentityLock(slot); + }); + return mesh; +} + +async function attach(mesh: BridgeMeshSync): Promise { + await mesh.attachIdentity(); + return mesh; +} + +const factories = [ + { name: "createBridgeMesh", create: createBridgeMesh, ownsLock: true }, + { + name: "createBridgeMeshSync", + create: async ( + slot: Readonly, + options: Readonly, + ) => attach(createBridgeMeshSync(slot, options)), + ownsLock: true, + }, + { + name: "createBridgeMeshFromIdentity", + create: async ( + slot: Readonly, + options: Readonly, + ) => + createBridgeMeshFromIdentity(loadIdentityForFront(slot), slot, options), + ownsLock: false, + }, + { + name: "createBridgeMeshSyncFromIdentity", + create: async ( + slot: Readonly, + options: Readonly, + ) => + attach( + createBridgeMeshSyncFromIdentity( + loadIdentityForFront(slot), + slot, + options, + ), + ), + ownsLock: false, + }, +]; + +/** File contents, not just names, so a write into an existing installation is detected. */ +function snapshot(dir: string): [string, string][] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry): [string, string] => { + const file = path.join(entry.parentPath, entry.name); + return [ + path.relative(dir, file), + fs.readFileSync(file).toString("base64"), + ]; + }) + .sort(([a], [b]) => a.localeCompare(b)); +} + +it.each(factories)( + "$name keeps keys, names, trust and grants in dataDir", + async ({ create, ownsLock }) => { + loadOrCreateUserIdentity(); + loadOrCreateMachineIdentity(); + const before = snapshot(defaultDir); + const dataDir = path.join(root, "application"); + const dir = path.join(root, "legacy-slot"); + const slot: IdentitySlot = { + harness: "data-dir-test", + cwd: root, + dataDir, + dir, + }; + const { store } = track(await create(slot, await meshOptions()), slot); + + expect(probeSlotOwner(slot)).toBe(ownsLock ? process.pid : undefined); + expect(fs.existsSync(dir)).toBe(false); + expect(store.getUserPrincipalId()).toBeDefined(); + expect(store.getMachineId()).toBeDefined(); + await store.naming.setPrincipalName("application account"); + await store.naming.setMachineName("application machine"); + store.naming.setPetname(store.peerId, "application device"); + store.addTrustedGateway(store.peerId); + await store.admitAgentForDm(store.peerId); + await store.generateConnectionCode(); + + expect(loadUserDisplayName({ dir: dataDir })).toBe("application account"); + expect(loadMachineDisplayName({ dir: dataDir })).toBe( + "application machine", + ); + const files = snapshot(dataDir).map(([file]) => file); + expect(files).toContain("user-identity.json"); + expect(files).toContain("machine-identity.json"); + expect(files).toContain("gateway-trust.json"); + expect(files).toContain("petnames.json"); + expect(files.some((file) => file.startsWith("identity-"))).toBe(true); + expect(files.some((file) => file.startsWith("account-ledger-"))).toBe(true); + expect(files.some((file) => file.startsWith("connection-codes-"))).toBe( + true, + ); + expect(snapshot(defaultDir)).toEqual(before); + }, +); + +it("separate data directories give the same slot separate device, account and machine keys", async () => { + const aSlot = { + harness: "same-slot", + cwd: root, + dataDir: path.join(root, "a"), + }; + const bSlot = { ...aSlot, dataDir: path.join(root, "b") }; + const a = track( + await createBridgeMesh(aSlot, await meshOptions()), + aSlot, + ).store; + const b = track( + await createBridgeMesh(bSlot, await meshOptions()), + bSlot, + ).store; + expect(a.peerId).not.toBe(b.peerId); + expect(a.getUserPrincipalId()).not.toBe(b.getUserPrincipalId()); + expect(a.getMachineId()).not.toBe(b.getMachineId()); + expect(fs.existsSync(defaultDir)).toBe(false); +}); + +it("the environment selects the shared folder, while legacy slot.dir keeps its existing scope", async () => { + const defaultSlot = { harness: "environment", cwd: root }; + const a = track( + await createBridgeMesh(defaultSlot, await meshOptions()), + defaultSlot, + ).store; + const dir = path.join(root, "legacy-slot"); + const legacySlot = { harness: "legacy", cwd: root, dir }; + const b = track( + await createBridgeMesh(legacySlot, await meshOptions()), + legacySlot, + ).store; + expect(b.getUserPrincipalId()).toBe(a.getUserPrincipalId()); + expect(b.getMachineId()).toBe(a.getMachineId()); + expect(fs.existsSync(path.join(dir, "user-identity.json"))).toBe(false); + expect(snapshot(dir).some(([file]) => file.startsWith("identity-"))).toBe( + true, + ); + expect( + snapshot(defaultDir).some(([file]) => + file.startsWith("identity-environment"), + ), + ).toBe(true); +}); + +it("account export and import use the environment's directory", async () => { + const bundle = path.join(root, "account.bundle"); + const io: AccountCliIo = { + readSecret: async () => "A private test account passphrase", + log: () => undefined, + env: {}, + openStore: async () => { + throw new Error("export/import must not start a mesh"); + }, + }; + await runAccountCommand(["export", bundle], io); + const original = fs.readFileSync( + path.join(defaultDir, "user-identity.json"), + "utf-8", + ); + const importedDir = path.join(root, "imported"); + vi.stubEnv(DATA_DIR_ENV_VAR, importedDir); + await runAccountCommand(["import", bundle], io); + expect(loadOrCreateUserIdentity({ dir: importedDir }).deviceId).toEqual( + loadOrCreateUserIdentity({ dir: defaultDir }).deviceId, + ); + expect( + fs.readFileSync(path.join(defaultDir, "user-identity.json"), "utf-8"), + ).toBe(original); +}); + +it("two real peers share an account and exchange a room message; restart keeps keys, room tokens, trust and grants", async () => { + const dataDir = path.join(root, "shared"); + const slotA = { harness: "peer-a", cwd: root, dataDir }; + const slotB = { ...slotA, harness: "peer-b" }; + const options = await meshOptions(); + const a = track(await createBridgeMesh(slotA, options), slotA).store; + const b = track(await createBridgeMesh(slotB, options), slotB).store; + expect(a.peerId).not.toBe(b.peerId); + expect(a.getUserPrincipalId()).toBe(b.getUserPrincipalId()); + expect(a.getMachineId()).toBe(b.getMachineId()); + await a.init(); + await b.init(); + for (const [store, slot] of [ + [a, slotA], + [b, slotB], + ] as const) { + await store.registerAgent({ + name: slot.harness, + harness: slot.harness, + cwd: root, + pid: process.pid, + visibility: "visible", + tags: [], + }); + } + const room = await a.createRoom({ + name: "private-folder-room", + type: "public", + owner: a.peerId, + description: "Directory smoke test", + }); + const joined = b.joinRoom(room.id, b.peerId); + await waitFor( + () => + a + .listPendingRoomJoins() + .some((request) => request.requesterId === b.peerId), + "the room owner's pending join", + ); + a.acceptRoomJoin(room.id, b.peerId); + await joined; + const received: string[] = []; + b.addDeliveryListener((_id, event) => { + if (event.type === "room_message") received.push(event.message.content); + }); + await a.sendRoomMessage(room.id, a.peerId, "isolated folder message"); + await waitFor( + () => received.includes("isolated folder message"), + "the room message over the real mesh", + ); + const tokens = loadRoomTokens(slotB); + expect(tokens[room.id]).toBeDefined(); + await b.admitAgentForDm(a.peerId); + b.addTrustedGateway(a.peerId); + b.naming.setPetname(a.peerId, "first peer"); + const oldId = b.peerId; + const oldPrincipal = b.getUserPrincipalId(); + const oldMachine = b.getMachineId(); + await b.shutdown(); + releaseIdentityLock(slotB); + const restarted = track(await createBridgeMesh(slotB, options), slotB).store; + expect(restarted.peerId).toBe(oldId); + expect(restarted.getUserPrincipalId()).toBe(oldPrincipal); + expect(restarted.getMachineId()).toBe(oldMachine); + expect(loadRoomTokens(slotB)).toEqual(tokens); + expect(restarted.listTrustedGateways()).toContain(a.peerId); + expect(restarted.naming.listPetnames().get(a.peerId)).toBe("first peer"); + const ledger = await openAccountLedger({ + userIdentityOptions: { dir: dataDir }, + userIdentity: loadOrCreateUserIdentity({ dir: dataDir }), + clock: createSystemClock(), + }); + expect(await ledger.outstandingGrants("dm", a.peerId)).toHaveLength(1); + await restarted.revokeAgentDmAccess(a.peerId); + expect(await ledger.outstandingGrants("dm", a.peerId)).toEqual([]); + expect(fs.existsSync(defaultDir)).toBe(false); +}); diff --git a/src/test/data-directory.unit.test.ts b/src/test/data-directory.unit.test.ts new file mode 100644 index 00000000..7954f3a5 --- /dev/null +++ b/src/test/data-directory.unit.test.ts @@ -0,0 +1,29 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { DATA_DIR_ENV_VAR, resolveDataDir } from "../core/data-directory.js"; + +describe("resolveDataDir", () => { + it.each([undefined, "", " "])( + "keeps the home-directory default for %j", + (value) => { + expect(resolveDataDir({ env: { [DATA_DIR_ENV_VAR]: value } })).toBe( + path.join(os.homedir(), ".agent-comms"), + ); + }, + ); + + it("uses the environment directory, trimming surrounding whitespace", () => { + const dir = path.join(os.tmpdir(), "private mesh"); + expect(resolveDataDir({ env: { [DATA_DIR_ENV_VAR]: ` ${dir} ` } })).toBe( + dir, + ); + }); + + it("keeps an explicit directory unchanged and ahead of the environment", () => { + const dir = "relative folder "; + expect( + resolveDataDir({ dir, env: { [DATA_DIR_ENV_VAR]: "another folder" } }), + ).toBe(dir); + }); +});