Skip to content
Open
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions src/core/bridge-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -146,7 +145,7 @@ export function createBridgeMeshSyncFromIdentity(
}),
userIdentityOptions,
machineIdentity: await toIdentityPort(machineIdentity),
machineIdentityOptions,
machineIdentityOptions: userIdentityOptions,
});
},
};
Expand Down
24 changes: 24 additions & 0 deletions src/core/data-directory.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>;
}> = {}): 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");
}
5 changes: 3 additions & 2 deletions src/core/gateway-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,13 @@ export class GatewayTrust {
string,
VerifiedMember & { expiresAt: number }
>();
private readonly location: Readonly<Pick<IdentitySlot, "dir">> | undefined;
private readonly location:
Readonly<Pick<IdentitySlot, "dir" | "dataDir">> | 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<Pick<IdentitySlot, "dir">>) {
constructor(location?: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>) {
this.location = location;
this.load();
}
Expand Down
23 changes: 14 additions & 9 deletions src/core/identity-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -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<Pick<IdentitySlot, "dir">>): string {
return location.dir ?? path.join(os.homedir(), ".agent-comms");
/** The directory every file this module owns lives in. */
function identityDir(
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
): string {
return resolveDataDir({ dir: location.dataDir ?? location.dir });
}

function slotPaths(slot: Readonly<IdentitySlot>): {
Expand Down Expand Up @@ -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<Pick<IdentitySlot, "dir">>,
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
): string {
return path.join(identityDir(location), "gateway-trust.json");
}
Expand All @@ -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<Pick<IdentitySlot, "dir">>,
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
): LoadedGatewayTrust {
let parsed: unknown;
try {
Expand Down Expand Up @@ -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<Pick<IdentitySlot, "dir">>,
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
trust: Readonly<LoadedGatewayTrust>,
): void {
const dir = identityDir(location);
Expand All @@ -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<Pick<IdentitySlot, "dir">>,
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
): string {
try {
const stat = fs.statSync(gatewayTrustFilePath(location));
Expand Down
4 changes: 2 additions & 2 deletions src/core/machine-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,7 +24,7 @@ export interface MachineIdentityOptions {
export function machineIdentityFile(
options?: Readonly<MachineIdentityOptions>,
): string {
const dir = options?.dir ?? path.join(os.homedir(), ".agent-comms");
const dir = resolveDataDir({ dir: options?.dir });
return path.join(dir, "machine-identity.json");
}

Expand Down
9 changes: 6 additions & 3 deletions src/core/petnames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
Expand Down Expand Up @@ -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",
);
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/user-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,7 +62,7 @@ function isStoredUserIdentity(value: unknown): value is StoredUserIdentity {
export function userIdentityDir(
options?: Readonly<UserIdentityOptions>,
): string {
return options?.dir ?? path.join(os.homedir(), ".agent-comms");
return resolveDataDir({ dir: options?.dir });
}

function userIdentityFile(options?: Readonly<UserIdentityOptions>): string {
Expand Down
Loading
Loading