Skip to content

Commit ec7df3a

Browse files
committed
fix(cli): restore leader identity from server
1 parent 5861e55 commit ec7df3a

7 files changed

Lines changed: 332 additions & 65 deletions

File tree

packages/cli/src/agent/identity.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { createHash } from "node:crypto";
12
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
import { join } from "node:path";
4+
import { getCredentials } from "../config.js";
5+
import { generateDeviceId } from "../device.js";
36
import { IDENTITIES_DIR } from "../paths.js";
47

58
export interface StoredIdentity {
@@ -8,28 +11,47 @@ export interface StoredIdentity {
811
fingerprint: string;
912
}
1013

11-
function identityPath(runtime: string): string {
14+
function legacyIdentityPath(runtime: string): string {
1215
return join(IDENTITIES_DIR, `${runtime}.json`);
1316
}
1417

18+
function scopedIdentityPath(runtime: string): string {
19+
const { apiUrl } = getCredentials();
20+
const deviceId = generateDeviceId();
21+
const scope = createHash("sha256").update(`${apiUrl}\n${deviceId}\n${runtime}`).digest("hex").slice(0, 16);
22+
return join(IDENTITIES_DIR, `${runtime}-${scope}.json`);
23+
}
24+
1525
export function loadIdentity(runtime: string): StoredIdentity | null {
1626
try {
17-
return JSON.parse(readFileSync(identityPath(runtime), "utf-8"));
18-
} catch {
19-
return null;
20-
}
27+
return JSON.parse(readFileSync(scopedIdentityPath(runtime), "utf-8"));
28+
} catch {}
29+
30+
// Migrate the old runtime-only identity on first access into the new
31+
// api-url + machine + runtime scoped location.
32+
try {
33+
const identity = JSON.parse(readFileSync(legacyIdentityPath(runtime), "utf-8")) as StoredIdentity;
34+
saveIdentity(runtime, identity);
35+
return identity;
36+
} catch {}
37+
38+
return null;
2139
}
2240

2341
export function saveIdentity(runtime: string, identity: StoredIdentity): void {
2442
mkdirSync(IDENTITIES_DIR, { recursive: true });
25-
writeFileSync(identityPath(runtime), `${JSON.stringify(identity, null, 2)}\n`);
43+
writeFileSync(scopedIdentityPath(runtime), `${JSON.stringify(identity, null, 2)}\n`);
2644
}
2745

2846
export function removeIdentity(runtime: string): boolean {
29-
try {
30-
rmSync(identityPath(runtime));
31-
return true;
32-
} catch {
33-
return false;
47+
let removed = false;
48+
for (const path of [scopedIdentityPath(runtime), legacyIdentityPath(runtime)]) {
49+
try {
50+
rmSync(path);
51+
removed = true;
52+
} catch {
53+
// ignore
54+
}
3455
}
56+
return removed;
3557
}

packages/cli/src/agent/leader.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,52 @@ function isDaemonAlive(): boolean {
2020
}
2121
}
2222

23-
async function ensureIdentity(runtime: AgentRuntime, client: MachineClient): Promise<StoredIdentity> {
24-
const existing = loadIdentity(runtime);
25-
if (existing) return existing;
23+
function missingIdentityMessage(runtime: AgentRuntime): string {
24+
return [
25+
`No identity found for runtime "${runtime}".`,
26+
"",
27+
"Create one explicitly with:",
28+
" ak identity create --username <username> [--name <name>]",
29+
"",
30+
"Choose the identity values yourself:",
31+
" --username required, user-like handle chosen by the agent",
32+
" --name optional full name shown in the UI",
33+
"",
34+
`This identity is reused for the same api-url + machine + runtime (${runtime}).`,
35+
"",
36+
"Examples:",
37+
" ak identity create --username alex",
38+
' ak identity create --username alex --name "Alex Chen"',
39+
].join("\n");
40+
}
2641

42+
async function restoreIdentity(runtime: AgentRuntime, client: MachineClient): Promise<StoredIdentity | null> {
2743
const agents = (await client.listAgents()) as Agent[];
28-
const match = agents.find((a) => a.runtime === runtime && a.kind === "leader");
29-
if (match) {
30-
const identity: StoredIdentity = { agent_id: match.id, name: match.name, fingerprint: match.fingerprint };
31-
saveIdentity(runtime, identity);
32-
return identity;
44+
const leaders = agents.filter((agent) => agent.kind === "leader" && agent.runtime === runtime);
45+
if (leaders.length !== 1) return null;
46+
const leader = leaders[0];
47+
const identity: StoredIdentity = { agent_id: leader.id, name: leader.name, fingerprint: leader.fingerprint };
48+
saveIdentity(runtime, identity);
49+
return identity;
50+
}
51+
52+
export async function createIdentity(input: { runtime: AgentRuntime; username: string; name?: string }): Promise<StoredIdentity> {
53+
const existing = loadIdentity(input.runtime);
54+
if (existing) {
55+
throw new Error(`Identity for runtime "${input.runtime}" already exists.`);
3356
}
3457

35-
const agent = (await client.createAgent({ name: runtime, runtime, kind: "leader" })) as Agent;
58+
const client = new MachineClient();
59+
const payload: { username: string; name?: string; runtime: AgentRuntime; kind: "leader" } = {
60+
username: input.username,
61+
runtime: input.runtime,
62+
kind: "leader",
63+
};
64+
if (input.name) payload.name = input.name;
65+
66+
const agent = (await client.createAgent(payload)) as Agent;
3667
const identity: StoredIdentity = { agent_id: agent.id, name: agent.name, fingerprint: agent.fingerprint };
37-
saveIdentity(runtime, identity);
68+
saveIdentity(input.runtime, identity);
3869
return identity;
3970
}
4071

@@ -72,13 +103,20 @@ export async function createClient(): Promise<ApiClient> {
72103
return cachedLeaderClient;
73104
}
74105

106+
let identity = loadIdentity(runtime);
107+
const machineClient = new MachineClient();
108+
if (!identity) {
109+
identity = await restoreIdentity(runtime, machineClient);
110+
}
111+
if (!identity) {
112+
throw new Error(missingIdentityMessage(runtime));
113+
}
114+
75115
if (!isDaemonAlive()) {
76116
throw new Error("Daemon is not running. Start it with: ak start");
77117
}
78118

79-
// First call — auto-init leader session
80-
const machineClient = new MachineClient();
81-
const identity = await ensureIdentity(runtime, machineClient);
119+
// First call — create a leader session for an existing identity
82120
const { publicKey, privateKey } = (await crypto.subtle.generateKey({ name: "Ed25519" } as any, true, ["sign", "verify"])) as CryptoKeyPair;
83121
const pubJwk = await crypto.subtle.exportKey("jwk", publicKey);
84122
const privJwk = await crypto.subtle.exportKey("jwk", privateKey);

packages/cli/src/client/base.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ export abstract class ApiClient {
148148
return this.request<any[]>("GET", `/api/agents/${agentId}/sessions`);
149149
}
150150
createAgent(input: {
151-
name: string;
152-
username?: string;
151+
name?: string;
152+
username: string;
153153
bio?: string;
154154
soul?: string;
155155
role?: string;

packages/cli/src/commands/start.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync,
33
import { join } from "node:path";
44
import type { Command } from "commander";
55
import { getCredentials, saveCredentials, setCurrent } from "../config.js";
6-
import { DAEMON_STATE_FILE, IDENTITIES_DIR, LOGS_DIR, PID_FILE, SESSIONS_DIR, STATE_DIR } from "../paths.js";
6+
import { DAEMON_STATE_FILE, LOGS_DIR, PID_FILE, SESSIONS_DIR, STATE_DIR } from "../paths.js";
77
import { getAvailableProviders } from "../providers/registry.js";
88
import { listSessions } from "../session/store.js";
99
import { getVersion } from "../version.js";
@@ -131,14 +131,11 @@ export function registerStartCommand(program: Command) {
131131
process.exit(1);
132132
}
133133

134-
// Clear identity/session cache if API URL changed. This is the ONLY
135-
// place that is allowed to bulk-delete sessions — identities from the
136-
// old backend are meaningless under a new apiUrl. Never add other
137-
// callers: in_review session files are reject-resume entry points and
138-
// must survive every other kind of restart.
134+
// Clear session cache if API URL changed. Sessions are backend-specific
135+
// and must not survive environment switches. Identities are now scoped
136+
// by api-url + machine + runtime, so they remain valid side by side.
139137
const prevState = readDaemonState();
140138
if (prevState && prevState.apiUrl !== apiUrl) {
141-
rmSync(IDENTITIES_DIR, { recursive: true, force: true });
142139
rmSync(SESSIONS_DIR, { recursive: true, force: true });
143140
}
144141
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);

packages/cli/src/index.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { AGENT_RUNTIMES, type AgentRuntime } from "@agent-kanban/shared";
12
import { Command } from "commander";
23
import { loadIdentity } from "./agent/identity.js";
3-
import { createClient } from "./agent/leader.js";
4+
import { createClient, createIdentity } from "./agent/leader.js";
45
import { detectRuntime } from "./agent/runtime.js";
56
import { registerApplyCommand } from "./commands/apply.js";
67
import { registerCreateCommand } from "./commands/create.js";
@@ -202,17 +203,56 @@ registerWaitCommand(program);
202203

203204
// ─── Identity ───
204205

206+
const identityCmd = program.command("identity").description("Manage leader identity for the current runtime");
207+
208+
identityCmd
209+
.command("create")
210+
.description("Create and save a leader identity for the current runtime")
211+
.requiredOption("--username <username>", "User-like handle chosen by the agent")
212+
.option("--name <name>", "Optional full name shown in the UI")
213+
.option("--runtime <runtime>", `Agent runtime: ${AGENT_RUNTIMES.join(", ")}`)
214+
.action(async (opts) => {
215+
const runtime = (opts.runtime || detectRuntime()) as string | null;
216+
if (!runtime) {
217+
console.error("No agent runtime found. Install claude, codex, or gemini CLI, or pass --runtime explicitly.");
218+
process.exit(1);
219+
}
220+
if (!AGENT_RUNTIMES.includes(runtime as AgentRuntime)) {
221+
console.error(`Unknown runtime "${runtime}" — must be one of: ${AGENT_RUNTIMES.join(", ")}`);
222+
process.exit(1);
223+
}
224+
225+
const identity = await createIdentity({
226+
runtime: runtime as AgentRuntime,
227+
username: opts.username,
228+
name: opts.name,
229+
});
230+
console.log(`Created identity for ${runtime}`);
231+
console.log(`Agent ID: ${identity.agent_id}`);
232+
console.log(`Name: ${identity.name}`);
233+
console.log(`Fingerprint: ${identity.fingerprint}`);
234+
});
235+
205236
program
206237
.command("whoami")
207238
.description("Show agent identity for the current runtime")
208-
.action(async () => {
209-
// Trigger auto-init so whoami works as first command
210-
await createClient();
239+
.action(() => {
211240
const runtime = detectRuntime();
212241
const runtimeKey = runtime ?? "default";
213242
const identity = loadIdentity(runtimeKey);
214243
if (!identity) {
215-
console.error("Failed to resolve identity.");
244+
console.error(
245+
[
246+
`No identity found for runtime "${runtimeKey}".`,
247+
"",
248+
"Create one explicitly with:",
249+
" ak identity create --username <username> [--name <name>]",
250+
"",
251+
"Choose the identity values yourself:",
252+
" --username required, user-like handle chosen by the agent",
253+
" --name optional full name shown in the UI",
254+
].join("\n"),
255+
);
216256
process.exit(1);
217257
}
218258
console.log(`Runtime: ${runtimeKey}`);

0 commit comments

Comments
 (0)