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
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <id>` 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
Expand All @@ -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
<name>` 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
Expand Down
74 changes: 60 additions & 14 deletions src/cli/commands/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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));
Expand All @@ -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)) {
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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();
});
Expand Down
121 changes: 117 additions & 4 deletions src/cli/identity-persistence.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}): Record<string, string> {
const env: Record<string, string> = { ...process.env, ...{} } as Record<string, string>;

// 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];
}
}
Expand All @@ -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<string, string> = {}) {
const env = cliEnv(overrides);

const result = Bun.spawnSync({
cmd: [...CLI, ...args],
cwd: process.cwd(),
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading