feat(FN-1119): add agent API key management APIs

- Add AgentApiKey and AgentApiKeyCreateResult types and export them from @fusion/core
- Implement AgentStore API key create/list/revoke methods with SHA-256 token hashing and JSONL persistence
- Add comprehensive AgentStore coverage for CRUD behavior, revocation semantics, persistence, and concurrent key creation
- Add dashboard routes and API tests for POST/GET/DELETE agent key endpoints with 404/validation handling
- Add a minor @gsxdsm/fusion changeset for the new agent API key feature
This commit is contained in:
gsxdsm
2026-04-07 23:32:26 -07:00
parent 075402c11d
commit 5b6a695c53
7 changed files with 550 additions and 3 deletions

View File

@@ -14,8 +14,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentStore } from "./agent-store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
import { mkdtempSync, existsSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
import type { AgentCapability, AgentState } from "./types.js";
function makeTmpDir(): string {
@@ -859,6 +860,147 @@ describe("AgentStore", () => {
});
});
// ── API Keys ──────────────────────────────────────────────────────
describe("API Keys", () => {
it("createApiKey returns key metadata and one-time plaintext token", async () => {
const agent = await store.createAgent({ name: "KeyAgent", role: "executor" });
const result = await store.createApiKey(agent.id);
expect(result.key.id).toMatch(/^key-[a-f0-9]{8}$/);
expect(result.key.agentId).toBe(agent.id);
expect(result.key.tokenHash).toMatch(/^[a-f0-9]{64}$/);
expect(result.token).toMatch(/^[a-f0-9]{64}$/);
expect(new Date(result.key.createdAt).getTime()).not.toBeNaN();
expect(result.key.revokedAt).toBeUndefined();
const expectedHash = createHash("sha256").update(result.token).digest("hex");
expect(result.key.tokenHash).toBe(expectedHash);
const keyPath = join(rootDir, "agents", `${agent.id}-keys.jsonl`);
expect(existsSync(keyPath)).toBe(true);
const persisted = readFileSync(keyPath, "utf-8");
expect(persisted).not.toContain(result.token);
});
it("createApiKey with label persists the label", async () => {
const agent = await store.createAgent({ name: "LabeledKeyAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id, { label: "CI Key" });
const keys = await store.listApiKeys(agent.id);
expect(key.label).toBe("CI Key");
expect(keys).toHaveLength(1);
expect(keys[0].label).toBe("CI Key");
});
it("createApiKey omits empty labels", async () => {
const agent = await store.createAgent({ name: "NoLabelKeyAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id, { label: " " });
expect(key.label).toBeUndefined();
});
it("createApiKey throws when agent is not found", async () => {
await expect(store.createApiKey("agent-missing")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("listApiKeys returns keys for one agent and empty array for an agent with no keys", async () => {
const withKeys = await store.createAgent({ name: "WithKeys", role: "executor" });
const noKeys = await store.createAgent({ name: "NoKeys", role: "executor" });
const other = await store.createAgent({ name: "Other", role: "reviewer" });
const first = await store.createApiKey(withKeys.id);
const second = await store.createApiKey(withKeys.id);
await store.createApiKey(other.id);
const withKeysList = await store.listApiKeys(withKeys.id);
expect(withKeysList).toHaveLength(2);
expect(withKeysList.map((key) => key.id)).toEqual([first.key.id, second.key.id]);
const noKeysList = await store.listApiKeys(noKeys.id);
expect(noKeysList).toEqual([]);
});
it("listApiKeys throws when agent is not found", async () => {
await expect(store.listApiKeys("agent-missing")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("revokeApiKey sets revokedAt and revoked key remains in list", async () => {
const agent = await store.createAgent({ name: "RevokeKeyAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id);
const revoked = await store.revokeApiKey(agent.id, key.id);
expect(revoked.id).toBe(key.id);
expect(revoked.revokedAt).toBeDefined();
const keys = await store.listApiKeys(agent.id);
expect(keys).toHaveLength(1);
expect(keys[0].id).toBe(key.id);
expect(keys[0].revokedAt).toBe(revoked.revokedAt);
});
it("revokeApiKey already revoked is a no-op", async () => {
const agent = await store.createAgent({ name: "RevokeTwiceAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id);
const firstRevocation = await store.revokeApiKey(agent.id, key.id);
const secondRevocation = await store.revokeApiKey(agent.id, key.id);
expect(firstRevocation.revokedAt).toBeDefined();
expect(secondRevocation.revokedAt).toBe(firstRevocation.revokedAt);
});
it("revokeApiKey throws when key is not found", async () => {
const agent = await store.createAgent({ name: "MissingKeyAgent", role: "executor" });
await expect(store.revokeApiKey(agent.id, "key-missing")).rejects.toThrow(
`API key key-missing not found for agent ${agent.id}`
);
});
it("revokeApiKey throws when agent is not found", async () => {
await expect(store.revokeApiKey("agent-missing", "key-1234")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("multiple keys can be listed and revoking one does not affect others", async () => {
const agent = await store.createAgent({ name: "MultiKeyAgent", role: "executor" });
const key1 = await store.createApiKey(agent.id, { label: "key-1" });
const key2 = await store.createApiKey(agent.id, { label: "key-2" });
const key3 = await store.createApiKey(agent.id, { label: "key-3" });
const revoked = await store.revokeApiKey(agent.id, key2.key.id);
const keys = await store.listApiKeys(agent.id);
expect(keys).toHaveLength(3);
const byId = new Map(keys.map((key) => [key.id, key]));
expect(byId.get(key1.key.id)?.revokedAt).toBeUndefined();
expect(byId.get(key2.key.id)?.revokedAt).toBe(revoked.revokedAt);
expect(byId.get(key3.key.id)?.revokedAt).toBeUndefined();
});
it("API keys survive store reinitialization", async () => {
const agent = await store.createAgent({ name: "KeyPersistence", role: "executor" });
const { key } = await store.createApiKey(agent.id, { label: "persist" });
const store2 = new AgentStore({ rootDir });
await store2.init();
const keys = await store2.listApiKeys(agent.id);
expect(keys).toHaveLength(1);
expect(keys[0].id).toBe(key.id);
expect(keys[0].label).toBe("persist");
});
});
// ── concurrency (withLock) ────────────────────────────────────────
describe("concurrency", () => {
@@ -900,6 +1042,20 @@ describe("AgentStore", () => {
expect(new Date(event.timestamp).getTime()).not.toBeNaN();
}
});
it("concurrent createApiKey calls don't corrupt the JSONL file", async () => {
const agent = await store.createAgent({ name: "ConcKeys", role: "executor" });
const results = await Promise.all(
Array.from({ length: 10 }, () => store.createApiKey(agent.id))
);
const keys = await store.listApiKeys(agent.id);
expect(keys).toHaveLength(10);
const ids = new Set(results.map(({ key }) => key.id));
expect(ids.size).toBe(10);
});
});
// ── filesystem persistence ────────────────────────────────────────

View File

@@ -3,16 +3,18 @@
*
* Agents are stored at `.fusion/agents/{agentId}.json` with their metadata.
* Heartbeat events are appended to `.fusion/agents/{agentId}-heartbeats.jsonl`.
* API keys are stored in `.fusion/agents/{agentId}-keys.jsonl` (hash-only).
*
* File Structure:
* - agents/{agentId}.json: Agent metadata (id, name, role, state, taskId, timestamps, metadata)
* - agents/{agentId}-heartbeats.jsonl: Append-only heartbeat events
* - agents/{agentId}-keys.jsonl: API key records with SHA-256 token hashes
*/
import { mkdir, readFile, writeFile, readdir, unlink } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { randomUUID, randomBytes, createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import type {
Agent,
@@ -20,6 +22,8 @@ import type {
AgentCapability,
AgentCreateInput,
AgentUpdateInput,
AgentApiKey,
AgentApiKeyCreateResult,
AgentHeartbeatEvent,
AgentHeartbeatRun,
AgentDetail,
@@ -364,6 +368,83 @@ export class AgentStore extends EventEmitter {
return agents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
/**
* Create an API key for an agent.
* Persists only the SHA-256 token hash; plaintext token is returned once.
*/
async createApiKey(agentId: string, options?: { label?: string }): Promise<AgentApiKeyCreateResult> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const token = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(token).digest("hex");
const createdAt = new Date().toISOString();
const label = options?.label?.trim();
const key: AgentApiKey = {
id: `key-${randomUUID().slice(0, 8)}`,
agentId,
tokenHash,
createdAt,
...(label ? { label } : {}),
};
const keyPath = this.getApiKeysPath(agentId);
await writeFile(keyPath, `${JSON.stringify(key)}\n`, { flag: "a" });
return { key, token };
});
}
/**
* List all API keys for an agent, including revoked keys.
*/
async listApiKeys(agentId: string): Promise<AgentApiKey[]> {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
return this.readApiKeys(agentId);
}
/**
* Revoke an API key for an agent.
* Revoking an already-revoked key is a no-op.
*/
async revokeApiKey(agentId: string, keyId: string): Promise<AgentApiKey> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const keys = await this.readApiKeys(agentId);
const keyIndex = keys.findIndex((key) => key.id === keyId);
if (keyIndex === -1) {
throw new Error(`API key ${keyId} not found for agent ${agentId}`);
}
const existing = keys[keyIndex];
if (existing.revokedAt) {
return existing;
}
const revoked: AgentApiKey = {
...existing,
revokedAt: new Date().toISOString(),
};
keys[keyIndex] = revoked;
await this.writeApiKeys(agentId, keys);
return revoked;
});
}
/**
* Delete an agent and its heartbeat history.
* @param agentId - The agent ID
@@ -737,6 +818,43 @@ export class AgentStore extends EventEmitter {
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
private getApiKeysPath(agentId: string): string {
return join(this.agentsDir, `${agentId}-keys.jsonl`);
}
private async readApiKeys(agentId: string): Promise<AgentApiKey[]> {
const keyPath = this.getApiKeysPath(agentId);
if (!existsSync(keyPath)) {
return [];
}
const content = await readFile(keyPath, "utf-8");
if (!content.trim()) {
return [];
}
const keys: AgentApiKey[] = [];
const lines = content.split("\n").filter(Boolean);
for (const line of lines) {
try {
const key = JSON.parse(line) as AgentApiKey;
if (key.agentId === agentId) {
keys.push(key);
}
} catch {
// Skip malformed lines
}
}
return keys;
}
private async writeApiKeys(agentId: string, keys: AgentApiKey[]): Promise<void> {
const keyPath = this.getApiKeysPath(agentId);
const content = keys.map((key) => JSON.stringify(key)).join("\n");
await writeFile(keyPath, content ? `${content}\n` : "");
}
private async readAgentFile(agentId: string): Promise<AgentData> {
const path = join(this.agentsDir, `${agentId}.json`);
const content = await readFile(path, "utf-8");

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -1601,6 +1601,30 @@ export interface AgentUpdateInput {
instructionsText?: string;
}
/** An API key associated with an agent for bearer token authentication. */
export interface AgentApiKey {
/** Unique key identifier (e.g., "key-a1b2c3d4") */
id: string;
/** The agent this key belongs to */
agentId: string;
/** SHA-256 hash of the plaintext token (hex-encoded, 64 chars) */
tokenHash: string;
/** Optional human-readable label for the key */
label?: string;
/** ISO-8601 timestamp when the key was created */
createdAt: string;
/** ISO-8601 timestamp when the key was revoked, null if active */
revokedAt?: string;
}
/** Result returned when creating a new API key — includes the plaintext token exactly once. */
export interface AgentApiKeyCreateResult {
/** The persisted key metadata (不含 plaintext token) */
key: AgentApiKey;
/** The plaintext token — shown only at creation, never stored */
token: string;
}
/** Per-task session persistence for an agent */
export interface AgentTaskSession {
/** Agent ID */