diff --git a/README.md b/README.md index bf35739..89089e3 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,12 @@ way: 1. an explicit `--from` / `from` argument, 2. the `CONVERSATIONS_AGENT_ID` env var, 3. the agent that registered on this MCP connection (stdio only, see below), -4. this installation's identity, `~/.hasna/conversations/agent-id` — **only when +4. the identity registered for `CONVERSATIONS_SESSION_ID`, stored in a + session-keyed file under `~/.hasna/conversations/session-identities/`, +5. this installation's identity, `~/.hasna/conversations/agent-id` — **only when the process opts in with `CONVERSATIONS_USE_MACHINE_IDENTITY=1`**. -**There is no fifth rung. A session that declares nothing gets an error, not a +**There is no sixth rung. A session that declares nothing gets an error, not a name.** Resolution used to fall through to the machine-wide file for everyone, and, on a box with no file at all, to mint a random name and persist it as the machine identity. Both were silent, and both produced the same damage: messages @@ -175,6 +177,24 @@ filtering and creator-vs-assignee matching meaningful: export CONVERSATIONS_AGENT_ID=agent-harness # per seat, survives restarts ``` +Runtimes that already carry a stable session id can bind it once without +putting the agent name in every child process. `agents register` uses +`CONVERSATIONS_SESSION_ID` as the presence session id and writes only that +session's hashed identity record. Another session id gets another file, so the +two registrations can coexist and rebinding one cannot clobber the other: + +```bash +export CONVERSATIONS_SESSION_ID=codewith-run-123 +conversations agents register agent-harness +conversations whoami --json # agent-harness; source names CONVERSATIONS_SESSION_ID +``` + +`--session ` creates the same binding explicitly. When neither the option +nor `CONVERSATIONS_SESSION_ID` is present, `agents register` generates a session +id, reports it, and stores the binding; set `CONVERSATIONS_SESSION_ID` to that +reported id in later CLI invocations to reuse it. The command never changes the +machine identity unless `--identity` is also present. + Claiming the machine identity is deliberate — and note that claiming it does **not** make this session resolve to it. Writing the file and reading the file are separate decisions on purpose, so a seat can set the box's identity without @@ -197,6 +217,20 @@ Two things also write it, and nothing else does: claims it. Seed-if-absent, never last-writer-wins — an identity that already exists is left alone. +Session identity files are separate from that installation-wide file. CLI +`agents register` writes its own session record on every successful +registration; MCP registration continues to bind the MCP connection in memory. +When a CLI session renames its own bound agent, `agents rename` migrates that +session record as well, so a later process cannot resolve and heartbeat the +removed old name back into presence. + +**Migrating from ≤ 0.5.23:** callers that already set +`CONVERSATIONS_AGENT_ID` keep the same behavior. Callers with a stable +`CONVERSATIONS_SESSION_ID` may instead run `conversations agents register +` once per session and let later CLI processes resolve the session-keyed +binding. Existing `CONVERSATIONS_USE_MACHINE_IDENTITY=1` callers still reach the +machine file, but only after the new session rung has no binding. + **Migrating from ≤ 0.5.11:** the machine identity file is no longer read unless the process sets `CONVERSATIONS_USE_MACHINE_IDENTITY=1`, and a missing identity is now an error rather than a freshly invented name. If a box went quiet after diff --git a/src/cli/commands/agents.ts b/src/cli/commands/agents.ts index 7a56c8b..f54182e 100644 --- a/src/cli/commands/agents.ts +++ b/src/cli/commands/agents.ts @@ -5,10 +5,13 @@ import { closeDb } from "../../lib/db.js"; import { resolveIdentity, readPersistedIdentity, + readSessionIdentity, updateCachedAutoName, isSelfRename, describeIdentitySource, IdentityError, + bindSessionIdentity, + getDeclaredSessionId, } from "../../lib/identity.js"; import { emitCliError } from "../cli-error.js"; import { isAgentConflict, normalizeAgentName } from "../../lib/presence.js"; @@ -176,16 +179,24 @@ export function registerAgentCommands(program: Command): void { process.exit(1); } - // Presence lives in the store, but this installation's identity lives in - // the local agent-id file. Without this the rename succeeds remotely and - // the very next process resolves the OLD name again — the identity looks - // like it "reverts". Only follow the rename when we renamed OURSELVES, - // and decide that from the file on disk, never from the in-process cache - // (in a long-lived daemon that cache can be days stale). + // Presence lives in the store, while durable identity can live in either + // the session record or the installation-wide agent-id file. Migrate each + // record only when it names the agent that was actually renamed. Without + // this, the next process resolves the OLD name and heartbeat recreates + // presence for an agent the rename just removed. + const normalizedRenamed = normalizeAgentName(renamed); const persistedIdentity = readPersistedIdentity(); - const isSelf = isSelfRename(old, persistedIdentity); - const identityAdopted = isSelf ? updateCachedAutoName(normalizeAgentName(renamed)) : false; - const identityWriteFailed = isSelf && !identityAdopted; + const machineIsSelf = isSelfRename(old, persistedIdentity); + const identityAdopted = machineIsSelf ? updateCachedAutoName(normalizedRenamed) : false; + const identityWriteFailed = machineIsSelf && !identityAdopted; + + const declaredSessionId = getDeclaredSessionId(); + const persistedSessionIdentity = readSessionIdentity(declaredSessionId); + const sessionIsSelf = isSelfRename(old, persistedSessionIdentity); + const sessionIdentityAdopted = sessionIsSelf && declaredSessionId + ? bindSessionIdentity(normalizedRenamed, declaredSessionId) + : false; + const sessionIdentityWriteFailed = sessionIsSelf && !sessionIdentityAdopted; if (opts.json) { printJsonLine({ @@ -194,16 +205,23 @@ export function registerAgentCommands(program: Command): void { renamed: true, identity_adopted: identityAdopted, identity_write_failed: identityWriteFailed, + session_identity_adopted: sessionIdentityAdopted, + session_identity_write_failed: sessionIdentityWriteFailed, }); } else { printLine(chalk.green(`Agent "${old}" renamed to "${renamed}".`)); if (identityAdopted) { - printLine(chalk.dim(`This installation's identity is now "${normalizeAgentName(renamed)}".`)); + printLine(chalk.dim(`This installation's identity is now "${normalizedRenamed}".`)); } else if (identityWriteFailed) { // Report the file, not resolveIdentity(): the file is what survives // this process, and it still names the agent we just renamed away. printErrorLine(chalk.red(`Renamed in presence, but could not update the local agent-id file (pinned read-only?). This installation still resolves as "${persistedIdentity}" — which no longer exists in presence.`)); } + if (sessionIdentityAdopted) { + printLine(chalk.dim(`This session's identity is now "${normalizedRenamed}".`)); + } else if (sessionIdentityWriteFailed) { + printErrorLine(chalk.red(`Renamed in presence, but could not update this session's identity binding. CONVERSATIONS_SESSION_ID still resolves as "${persistedSessionIdentity}" — which no longer exists in presence.`)); + } } } catch (e: any) { printErrorLine(chalk.red(e.message)); @@ -229,7 +247,9 @@ export function registerAgentCommands(program: Command): void { process.exit(1); } - const sessionId = opts.session || crypto.randomUUID(); + const explicitSessionId = typeof opts.session === "string" ? opts.session.trim() : ""; + const environmentSessionId = getDeclaredSessionId(); + const sessionId = explicitSessionId || environmentSessionId || crypto.randomUUID(); const result = await getStore().registerAgent(agentName, sessionId, opts.role, opts.project, opts.force); if (isAgentConflict(result)) { @@ -244,6 +264,19 @@ export function registerAgentCommands(program: Command): void { const registeredName = result.agent.agent; + // Presence registration and identity resolution are separate surfaces. + // Persist the successful registration under this session id so the next + // CLI process carrying the same CONVERSATIONS_SESSION_ID resolves to the + // same agent. Each session gets its own hashed file; registering session B + // cannot rewrite session A or the installation-wide fallback. + const sessionIdentityBound = bindSessionIdentity(registeredName, sessionId); + const sessionIdentityWriteFailed = !sessionIdentityBound; + const sessionIdentitySource = explicitSessionId + ? "explicit (--session)" + : environmentSessionId + ? "env var (CONVERSATIONS_SESSION_ID)" + : "generated by agents register"; + // Adopting is OPT-IN. The agent-id file is machine-wide: every session on // this host that passes neither --from nor CONVERSATIONS_AGENT_ID resolves // through it. Registering on a shared box must not silently repoint the @@ -266,15 +299,25 @@ export function registerAgentCommands(program: Command): void { identity_adopted: identityAdopted, identity_write_failed: identityWriteFailed, identity_env_override: envOverride, + session_identity_bound: sessionIdentityBound, + session_identity_write_failed: sessionIdentityWriteFailed, + session_identity_source: sessionIdentitySource, }); } else { const action = result.took_over ? chalk.yellow("took over") : result.created ? chalk.green("registered") : chalk.cyan("updated"); printLine(` ${action} ${chalk.bold(registeredName)} session: ${chalk.dim(sessionId)}`); + if (sessionIdentityBound) { + printLine(chalk.dim(` identity session identity bound via ${sessionIdentitySource}`)); + if (!environmentSessionId) { + printLine(chalk.dim(` reuse set CONVERSATIONS_SESSION_ID=${sessionId} for later CLI invocations in this session`)); + } else if (explicitSessionId && explicitSessionId !== environmentSessionId) { + printLine(chalk.yellow(` warning --session bound "${explicitSessionId}", but this environment resolves CONVERSATIONS_SESSION_ID="${environmentSessionId}"`)); + } + } else { + printErrorLine(chalk.red(" identity session binding was NOT persisted; later CLI invocations cannot inherit this registration")); + } if (identityAdopted) { printLine(chalk.dim(` identity installation identity set to "${registeredName}"`)); - if (envOverride && normalizeAgentName(envOverride) !== normalizeAgentName(registeredName)) { - printLine(chalk.yellow(` warning CONVERSATIONS_AGENT_ID="${envOverride}" overrides the file; this environment still resolves as "${envOverride}"`)); - } } else if (identityWriteFailed) { // Read the file rather than resolveIdentity(): nothing was adopted, so // the truth is whatever the unwritable file already says. @@ -284,6 +327,9 @@ export function registerAgentCommands(program: Command): void { : "This installation still has no machine identity."; printErrorLine(chalk.red(` identity NOT changed — could not write ${chalk.bold("agent-id")} (pinned read-only?). ${stillResolves}`)); } + if (envOverride && normalizeAgentName(envOverride) !== normalizeAgentName(registeredName)) { + printLine(chalk.yellow(` warning CONVERSATIONS_AGENT_ID="${envOverride}" has higher precedence; this environment still resolves as "${envOverride}"`)); + } } closeDb(); }); diff --git a/src/cli/identity-persistence.e2e.test.ts b/src/cli/identity-persistence.e2e.test.ts index 156ac8c..01e56fb 100644 --- a/src/cli/identity-persistence.e2e.test.ts +++ b/src/cli/identity-persistence.e2e.test.ts @@ -21,14 +21,19 @@ const TEST_DB = join(tmpdir(), `conversations-identity-${Date.now()}.db`); const CLI = ["bun", "run", "./src/cli/index.tsx"]; const AGENT_ID_FILE = join(HOME_DIR, ".hasna", "conversations", "agent-id"); -function runCli(args: string[]) { +function cliEnv(overrides: Record = {}): Record { const env: Record = { ...process.env, ...{} } as Record; // Never inherit the developer's identity or transport: CONVERSATIONS_AGENT_ID - // short-circuits the file we are testing, and the HASNA_CONVERSATIONS_* keys - // would point the test at the real cloud deployment. + // and CONVERSATIONS_SESSION_ID short-circuit the sources we are testing, and + // the HASNA_CONVERSATIONS_* keys would point the test at the real cloud + // deployment. for (const key of Object.keys(env)) { - if (key === "CONVERSATIONS_AGENT_ID" || key.startsWith("HASNA_CONVERSATIONS_")) { + if ( + key === "CONVERSATIONS_AGENT_ID" + || key === "CONVERSATIONS_SESSION_ID" + || key.startsWith("HASNA_CONVERSATIONS_") + ) { delete env[key]; } } @@ -43,6 +48,12 @@ function runCli(args: string[]) { // it — the same one-line migration a cron job or loop makes. env.CONVERSATIONS_USE_MACHINE_IDENTITY = "1"; + return { ...env, ...overrides }; +} + +function runCli(args: string[], overrides: Record = {}) { + const env = cliEnv(overrides); + const result = Bun.spawnSync({ cmd: [...CLI, ...args], cwd: process.cwd(), @@ -102,6 +113,108 @@ describe("CLI identity persistence (e2e)", () => { expect(JSON.parse(runCli(["whoami", "--json"]).stdout).agent).toBe(autoName); }); + test("two concurrent sessions retain different registered identities without clobbering", () => { + const sessionA = "session-alpha"; + const sessionB = "session-beta"; + + // Both presence records remain live while the identities are resolved. The + // registrations themselves are serialized so this test covers session + // isolation, not SQLite's separate multi-writer transaction behavior. + const registerA = runCli( + ["agents", "register", "session-agent-alpha", "--json"], + { CONVERSATIONS_SESSION_ID: sessionA }, + ); + const registerB = runCli( + ["agents", "register", "session-agent-beta", "--json"], + { CONVERSATIONS_SESSION_ID: sessionB }, + ); + + expect(registerA.exitCode).toBe(0); + expect(registerB.exitCode).toBe(0); + expect(JSON.parse(registerA.stdout)).toMatchObject({ + session_identity_bound: true, + session_identity_write_failed: false, + session_identity_source: "env var (CONVERSATIONS_SESSION_ID)", + }); + expect(JSON.parse(registerB.stdout)).toMatchObject({ + session_identity_bound: true, + session_identity_write_failed: false, + session_identity_source: "env var (CONVERSATIONS_SESSION_ID)", + }); + + const whoamiA = runCli(["whoami", "--json"], { + CONVERSATIONS_SESSION_ID: sessionA, + }); + const whoamiB = runCli(["whoami", "--json"], { + CONVERSATIONS_SESSION_ID: sessionB, + }); + + expect(whoamiA.exitCode).toBe(0); + expect(whoamiB.exitCode).toBe(0); + expect(JSON.parse(whoamiA.stdout)).toMatchObject({ + agent: "session-agent-alpha", + source: expect.stringContaining("CONVERSATIONS_SESSION_ID"), + }); + expect(JSON.parse(whoamiB.stdout)).toMatchObject({ + agent: "session-agent-beta", + source: expect.stringContaining("CONVERSATIONS_SESSION_ID"), + }); + + // Rebinding one live session must affect only that session. The other + // session and the installation-wide compatibility fallback stay intact. + const rebindA = runCli( + ["agents", "register", "session-agent-alpha-next", "--json"], + { CONVERSATIONS_SESSION_ID: sessionA }, + ); + expect(rebindA.exitCode).toBe(0); + expect(JSON.parse(runCli(["whoami", "--json"], { + CONVERSATIONS_SESSION_ID: sessionA, + }).stdout).agent).toBe("session-agent-alpha-next"); + expect(JSON.parse(runCli(["whoami", "--json"], { + CONVERSATIONS_SESSION_ID: sessionB, + }).stdout).agent).toBe("session-agent-beta"); + expect(storedIdentity()).toBe("seed-agent"); + }); + + test("renaming a session-bound agent migrates the binding for later processes", () => { + const sessionId = "session-rename"; + const oldName = "session-agent-before-rename"; + const newName = "session-agent-after-rename"; + + const register = runCli( + ["agents", "register", oldName, "--json"], + { CONVERSATIONS_SESSION_ID: sessionId }, + ); + expect(register.exitCode).toBe(0); + expect(JSON.parse(register.stdout).session_identity_bound).toBe(true); + + const rename = runCli( + ["agents", "rename", oldName, newName, "--json"], + { CONVERSATIONS_SESSION_ID: sessionId }, + ); + expect(rename.exitCode).toBe(0); + expect(JSON.parse(rename.stdout)).toMatchObject({ + renamed: true, + session_identity_adopted: true, + session_identity_write_failed: false, + }); + + // Both commands run in new processes. A stale binding would resolve the + // removed old name here, and heartbeat would recreate its presence row. + expect(JSON.parse(runCli(["whoami", "--json"], { + CONVERSATIONS_SESSION_ID: sessionId, + }).stdout).agent).toBe(newName); + + const heartbeat = runCli(["agents", "heartbeat", "--json"], { + CONVERSATIONS_SESSION_ID: sessionId, + }); + expect(heartbeat.exitCode).toBe(0); + expect(JSON.parse(heartbeat.stdout)).toMatchObject({ + agent: newName, + heartbeat: true, + }); + }); + test("register --identity deliberately claims the machine identity, and it survives a new process", () => { const autoName = JSON.parse(runCli(["whoami", "--json"]).stdout).agent as string; diff --git a/src/lib/identity.test.ts b/src/lib/identity.test.ts index cc5ee4e..2039b5c 100644 --- a/src/lib/identity.test.ts +++ b/src/lib/identity.test.ts @@ -10,6 +10,8 @@ import { updateCachedAutoName, _resetAutoName, describeIdentitySource, + bindSessionIdentity, + readSessionIdentity, } from "./identity"; import { AGENT_NAMES } from "./names"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, readFileSync, writeFileSync } from "fs"; @@ -25,6 +27,7 @@ import { getDataDir } from "./db"; * fixed for, so the suite now runs against a throwaway HOME. */ const savedEnv = process.env.CONVERSATIONS_AGENT_ID; +const savedSessionEnv = process.env.CONVERSATIONS_SESSION_ID; let savedHome: string | undefined; let savedUserProfile: string | undefined; let tempHome: string; @@ -39,6 +42,10 @@ beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), "conversations-identity-test-")); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; + // No test may inherit the operator's live identity. Individual cases set the + // exact env rung they intend to exercise, and afterEach restores the caller. + delete process.env.CONVERSATIONS_AGENT_ID; + delete process.env.CONVERSATIONS_SESSION_ID; // These suites exercise the machine-identity FILE path, which is now opt-in. // The suites below that assert the refusal delete this in their own // beforeEach (inner hooks run after outer ones). @@ -54,6 +61,12 @@ afterEach(() => { delete process.env.CONVERSATIONS_AGENT_ID; } + if (savedSessionEnv !== undefined) { + process.env.CONVERSATIONS_SESSION_ID = savedSessionEnv; + } else { + delete process.env.CONVERSATIONS_SESSION_ID; + } + delete process.env.CONVERSATIONS_USE_MACHINE_IDENTITY; if (savedHome !== undefined) process.env.HOME = savedHome; else delete process.env.HOME; @@ -96,6 +109,51 @@ describe("resolveIdentity", () => { }); }); +describe("session identity bindings", () => { + beforeEach(() => { + delete process.env.CONVERSATIONS_AGENT_ID; + delete process.env.CONVERSATIONS_SESSION_ID; + }); + + test("keeps two session ids isolated in the same data directory", () => { + expect(bindSessionIdentity("session-agent-a", "session-a")).toBe(true); + expect(bindSessionIdentity("session-agent-b", "session-b")).toBe(true); + + process.env.CONVERSATIONS_SESSION_ID = "session-a"; + expect(readSessionIdentity()).toBe("session-agent-a"); + expect(resolveIdentity()).toBe("session-agent-a"); + + process.env.CONVERSATIONS_SESSION_ID = "session-b"; + expect(readSessionIdentity()).toBe("session-agent-b"); + expect(resolveIdentity()).toBe("session-agent-b"); + }); + + test("rebinding one session leaves the other session alone", () => { + expect(bindSessionIdentity("session-agent-a", "session-a")).toBe(true); + expect(bindSessionIdentity("session-agent-b", "session-b")).toBe(true); + expect(bindSessionIdentity("session-agent-a-next", "session-a")).toBe(true); + + expect(readSessionIdentity("session-a")).toBe("session-agent-a-next"); + expect(readSessionIdentity("session-b")).toBe("session-agent-b"); + }); + + test("explicit and agent env identities still outrank a session binding", () => { + expect(bindSessionIdentity("session-agent", "session-a")).toBe(true); + process.env.CONVERSATIONS_SESSION_ID = "session-a"; + process.env.CONVERSATIONS_AGENT_ID = "env-agent"; + + expect(resolveIdentity()).toBe("env-agent"); + expect(resolveIdentity("explicit-agent")).toBe("explicit-agent"); + }); + + test("whoami source names the session mechanism that answered", () => { + expect(bindSessionIdentity("session-agent", "session-a")).toBe(true); + process.env.CONVERSATIONS_SESSION_ID = "session-a"; + + expect(describeIdentitySource()).toContain("CONVERSATIONS_SESSION_ID"); + }); +}); + /** * A seat routinely answers to two names — an agent name and a seat slug — and * the queues behind them are genuinely disjoint. A watcher armed on one name @@ -156,6 +214,13 @@ describe("resolveIdentities", () => { expect(resolveIdentities()).toEqual(["env-agent", "env-seat"]); }); + test("falls back to the bound session identity when no agent env was given", () => { + delete process.env.CONVERSATIONS_AGENT_ID; + process.env.CONVERSATIONS_SESSION_ID = "session-read"; + expect(bindSessionIdentity("session-agent", "session-read")).toBe(true); + expect(resolveIdentities()).toEqual(["session-agent"]); + }); + test("throws rather than guessing when nothing declared an identity", () => { delete process.env.CONVERSATIONS_AGENT_ID; try { unlinkSync(agentIdFile()); } catch {} @@ -286,6 +351,13 @@ describe("requireIdentity", () => { expect(requireIdentity()).toBe("env-agent"); }); + test("returns a bound session identity when flag and agent env are absent", () => { + delete process.env.CONVERSATIONS_AGENT_ID; + process.env.CONVERSATIONS_SESSION_ID = "session-required"; + expect(bindSessionIdentity("session-agent", "session-required")).toBe(true); + expect(requireIdentity()).toBe("session-agent"); + }); + test("throws when no identity available", () => { delete process.env.CONVERSATIONS_AGENT_ID; expect(() => requireIdentity()).toThrow("Agent identity required"); diff --git a/src/lib/identity.ts b/src/lib/identity.ts index b4db7c6..eff7d3e 100644 --- a/src/lib/identity.ts +++ b/src/lib/identity.ts @@ -1,4 +1,5 @@ -import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from "fs"; +import { createHash, randomUUID } from "crypto"; import { join, dirname } from "path"; import { getDataDir } from "./db.js"; import { normalizeAgentName } from "./presence.js"; @@ -15,6 +16,81 @@ function agentIdFile(): string { return join(getDataDir(), "agent-id"); } +/** Return the stable session id declared by the caller, if it has one. */ +export function getDeclaredSessionId(): string | null { + const sessionId = process.env.CONVERSATIONS_SESSION_ID?.trim(); + return sessionId || null; +} + +/** + * Path for one session's identity binding. + * + * The session id is hashed rather than interpolated into the path. Besides + * avoiding path traversal, this lets callers use opaque runtime session ids + * without leaking them into directory listings. + */ +function sessionIdentityFile(sessionId: string): string { + const key = createHash("sha256").update(sessionId).digest("hex"); + return join(getDataDir(), "session-identities", `${key}.json`); +} + +type SessionIdentityRecord = { + version: 1; + session_id: string; + agent: string; +}; + +/** Read one session's persisted identity, verifying the unhashed key too. */ +export function readSessionIdentity( + sessionId: string | null = getDeclaredSessionId(), +): string | null { + const declared = sessionId?.trim(); + if (!declared) return null; + + try { + const record = JSON.parse( + readFileSync(sessionIdentityFile(declared), "utf-8"), + ) as Partial; + if (record.version !== 1 || record.session_id !== declared) return null; + const agent = typeof record.agent === "string" ? record.agent.trim() : ""; + return agent || null; + } catch { + return null; + } +} + +/** + * Bind one stable session id to an agent without touching another session or + * the installation-wide fallback. The rename makes the record replacement + * atomic for readers in other CLI processes. + */ +export function bindSessionIdentity(name: string, sessionId: string): boolean { + const declared = sessionId.trim(); + const agent = name.trim(); + if (!declared || !agent) return false; + + const target = sessionIdentityFile(declared); + const temp = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + mkdirSync(dirname(target), { recursive: true }); + const record: SessionIdentityRecord = { + version: 1, + session_id: declared, + agent, + }; + writeFileSync(temp, JSON.stringify(record) + "\n", { + encoding: "utf-8", + mode: 0o600, + }); + renameSync(temp, target); + return true; + } catch { + return false; + } finally { + try { rmSync(temp, { force: true }); } catch {} + } +} + let cachedAutoName: string | null = null; /** @@ -79,6 +155,7 @@ function identityNotSet(persisted: string | null): IdentityError { `Declare one of:\n` + ` - CONVERSATIONS_AGENT_ID= per session (what a durable seat should set)\n` + ` - --from per invocation\n` + + ` - CONVERSATIONS_SESSION_ID= then run conversations agents register \n` + ` - CONVERSATIONS_USE_MACHINE_IDENTITY=1 only where this process owns the whole machine's identity`, ); } @@ -129,11 +206,11 @@ export function getAutoName(): string { /** * Resolve agent identity. * - * Priority: explicit flag → CONVERSATIONS_AGENT_ID env → opted-in machine - * identity file. There is deliberately no fourth rung: a session that declared - * nothing gets an error, not a guess. Silent inheritance and silent invention - * were the same bug, and both were invisible precisely because resolution - * always succeeded. + * Priority: explicit flag → CONVERSATIONS_AGENT_ID env → identity bound to the + * declared CONVERSATIONS_SESSION_ID → opted-in machine identity file. There is + * deliberately no fifth rung: a session that declared nothing gets an error, + * not a guess. Silent inheritance and silent invention were the same bug, and + * both were invisible precisely because resolution always succeeded. * * @throws {IdentityError} when nothing declared an identity for this session. */ @@ -142,6 +219,8 @@ export function resolveIdentity(explicit?: string): string { if (explicitValue) return explicitValue; const envValue = process.env.CONVERSATIONS_AGENT_ID?.trim(); if (envValue) return envValue; + const sessionValue = readSessionIdentity(); + if (sessionValue) return sessionValue; return getAutoName(); } @@ -198,6 +277,9 @@ export function resolveIdentities(explicit?: string): string[] { const envList = parseIdentityList(process.env.CONVERSATIONS_AGENT_ID); if (envList.length > 0) return envList; + const sessionIdentity = readSessionIdentity(); + if (sessionIdentity) return [sessionIdentity]; + return [getAutoName()]; } @@ -214,20 +296,25 @@ export function resolveIdentities(explicit?: string): string[] { export function describeIdentitySource(explicit?: string): string { if (explicit?.trim()) return "explicit (--from flag)"; if (process.env.CONVERSATIONS_AGENT_ID?.trim()) return "env var (CONVERSATIONS_AGENT_ID)"; + if (readSessionIdentity()) { + return "session identity file keyed by CONVERSATIONS_SESSION_ID"; + } return `machine identity file, opted in via CONVERSATIONS_USE_MACHINE_IDENTITY (${agentIdFile()})`; } /** - * Require an explicit identity (for headless/MCP use). - * Throws if no identity is set via flag or env. + * Require a caller-scoped identity (for headless/MCP use). + * Throws if no identity is set via flag, agent env, or session binding. */ export function requireIdentity(explicit?: string): string { const explicitValue = explicit?.trim(); if (explicitValue) return explicitValue; const envValue = process.env.CONVERSATIONS_AGENT_ID?.trim(); if (envValue) return envValue; + const sessionValue = readSessionIdentity(); + if (sessionValue) return sessionValue; throw new Error( - "Agent identity required. Set CONVERSATIONS_AGENT_ID env var or pass --from flag." + "Agent identity required. Set CONVERSATIONS_AGENT_ID, bind CONVERSATIONS_SESSION_ID with agents register, or pass --from." ); }