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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ V1_PROGRESS.md
# Runtime E2E artifacts and python cache
artifacts/
__pycache__/

# v3 collab secrets (abg auth login) — never commit raw PSK tokens / identity DB
collab.db
collab.db-wal
collab.db-shm
auth-token
4 changes: 2 additions & 2 deletions plugins/agentbridge/server/bridge-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) {
}
var BUILD_INFO = Object.freeze({
version: defineString("0.1.24", "0.0.0-source"),
commit: defineString("c5b4172", "source"),
commit: defineString("dbefc72", "source"),
bundle: defineBundle("plugin"),
contractVersion: defineNumber(1, CONTRACT_VERSION),
codeHash: defineString("3705b611c554", "source")
codeHash: defineString("fc5f11bd22b6", "source")
});
function sameRuntimeContract(a, b) {
if (!a || !b)
Expand Down
4 changes: 2 additions & 2 deletions plugins/agentbridge/server/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ function defineNumber(value, fallback) {
}
var BUILD_INFO = Object.freeze({
version: defineString("0.1.24", "0.0.0-source"),
commit: defineString("c5b4172", "source"),
commit: defineString("dbefc72", "source"),
bundle: defineBundle("plugin"),
contractVersion: defineNumber(1, CONTRACT_VERSION),
codeHash: defineString("3705b611c554", "source")
codeHash: defineString("fc5f11bd22b6", "source")
});
function daemonStatusBuildInfo() {
return { ...BUILD_INFO };
Expand Down
63 changes: 63 additions & 0 deletions src/backbone/identity-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { randomUUID } from "node:crypto";
import type { Store } from "./store";
import type { Identity } from "./identity";

/**
* Three-layer identity service (§2.1–2.2) over a Store.
*
* Enforces the id/displayName separation: `id` (an email or GitHub name) is the
* UNIQUE routing key the core uses for signing / routing / membership; the
* displayName is display-only and may collide. Persists person identities,
* logical agents (e.g. "Alice's Claude Code") and sessions, and issues PSK
* tokens bound to an identity (`abg auth login`).
*
* The three layers map to Store rows: identities (person), agents (logical
* agent, FK personId), sessions (ephemeral). Re-registering the same id reuses
* the row (a second device → same identity), never a new one (§2.2).
*/
export class IdentityService {
constructor(private readonly store: Store) {}

/** Register a person identity (or update its display name). Returns it. */
async registerIdentity(id: string, displayName: string): Promise<Identity> {
const trimmed = id.trim();
if (trimmed === "") throw new Error("identity id must be non-empty (use an email or GitHub name)");
const rec = await this.store.upsertIdentity(trimmed, displayName);
return { id: rec.id, displayName: rec.displayName };
}

async getIdentity(id: string): Promise<Identity | null> {
const rec = await this.store.getIdentity(id);
return rec ? { id: rec.id, displayName: rec.displayName } : null;
}

/** Register a logical agent under a person (§2.1). Re-register updates type. */
async registerAgent(agentId: string, personId: string, type: string): Promise<void> {
await this.store.upsertAgent(agentId, personId, type);
}

async recordSession(sessionId: string, agentId: string, startedAt: number): Promise<void> {
await this.store.recordSession(sessionId, agentId, startedAt);
}

/** Roll a logical agent up to its owning person id (§2.1). Null if unknown. */
async resolvePerson(agentId: string): Promise<string | null> {
const agent = await this.store.getAgent(agentId);
return agent ? agent.personId : null;
}

/**
* Issue a fresh PSK token bound to an existing identity (`abg auth login`).
* The identity MUST already be registered. Returns the opaque token to hand to
* the user; the (token → identity) binding is persisted for the broker to
* verify via {@link Store.resolveToken}.
*/
async issueToken(identityId: string): Promise<string> {
if (!(await this.store.getIdentity(identityId))) {
throw new Error(`unknown identity: ${identityId} (register it first)`);
}
const token = randomUUID();
await this.store.issueToken(token, identityId);
return token;
}
}
32 changes: 32 additions & 0 deletions src/backbone/identity/store-psk-identity-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Store } from "../store";
import type { Identity, IdentityProvider } from "../identity";

/**
* Store-backed PSK IdentityProvider — the real broker auth driver (§6.2).
*
* Resolves a presented token via the Store's persisted (token → identity)
* bindings issued by `abg auth login`. Unlike the in-memory PskIdentityProvider
* (seeded at construction), this reads the live Store, so a freshly-issued token
* authenticates without restarting the broker.
*
* Note (security, MVP): tokens are stored raw in the local Store. The collab DB
* file itself is 0644 (bun:sqlite default), so its CONTAINING directory is locked
* to 0700 by the writer (`abg auth login`, src/cli/auth.ts) to block other local
* users — the durable equivalent of control-token.ts's 0600 file. The token is an
* unguessable randomUUID and the link is WireGuard-encrypted over Tailscale (§7).
* Hashing tokens at rest is a §11.3 hardening item.
*/
export class StorePskIdentityProvider implements IdentityProvider {
constructor(private readonly store: Store) {}

async authenticate(credential: string): Promise<Identity> {
const identityId = await this.store.resolveToken(credential);
if (!identityId) throw new Error("invalid PSK token");
const identity = await this.store.getIdentity(identityId);
if (!identity) {
// The token's identity row was deleted out from under it.
throw new Error(`token resolved to unknown identity: ${identityId}`);
}
return { id: identity.id, displayName: identity.displayName };
}
}
8 changes: 8 additions & 0 deletions src/backbone/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export interface Store {
/** Remove and return the target's pending envelopes (deduped by idempotencyKey). */
drainPending(targetAgentId: string): Promise<Envelope[]>;

// --- auth tokens (§6.2): `abg auth login` binds a PSK token to an identity ---
/** Persist a token → identity binding. Re-issuing the same token re-points it. */
issueToken(token: string, identityId: string): Promise<void>;
/** Resolve a presented token to its identity id, or null if unknown. */
resolveToken(token: string): Promise<string | null>;
/** All issued (token, identityId) bindings — e.g. to seed an in-memory PSK provider. */
listTokens(): Promise<Array<{ token: string; identityId: string }>>;

/** Release resources (close the DB handle). Idempotent. */
close(): Promise<void>;
}
13 changes: 13 additions & 0 deletions src/backbone/store/memory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class InMemoryStore implements Store {
private whiteboards = new Map<string, WhiteboardRecord>();
// targetAgentId → (idempotencyKey → envelope); Map preserves insertion order
private pending = new Map<string, Map<string, Envelope>>();
private tokens = new Map<string, string>(); // token → identityId

async upsertIdentity(id: string, displayName: string): Promise<IdentityRecord> {
const record = { id, displayName };
Expand Down Expand Up @@ -148,6 +149,18 @@ export class InMemoryStore implements Store {
return [...byKey.values()];
}

async issueToken(token: string, identityId: string): Promise<void> {
this.tokens.set(token, identityId); // re-issue re-points
}

async resolveToken(token: string): Promise<string | null> {
return this.tokens.get(token) ?? null;
}

async listTokens(): Promise<Array<{ token: string; identityId: string }>> {
return [...this.tokens.entries()].map(([token, identityId]) => ({ token, identityId }));
}

async close(): Promise<void> {
// No handle to release; idempotent no-op.
}
Expand Down
12 changes: 12 additions & 0 deletions src/backbone/store/postgres-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ export class PostgresStore implements Store {
throw new Error(NOT_IMPLEMENTED);
}

async issueToken(_token: string, _identityId: string): Promise<void> {
throw new Error(NOT_IMPLEMENTED);
}

async resolveToken(_token: string): Promise<string | null> {
throw new Error(NOT_IMPLEMENTED);
}

async listTokens(): Promise<Array<{ token: string; identityId: string }>> {
throw new Error(NOT_IMPLEMENTED);
}

async close(): Promise<void> {
throw new Error(NOT_IMPLEMENTED);
}
Expand Down
27 changes: 27 additions & 0 deletions src/backbone/store/sqlite-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export class SqliteStore implements Store {
envelope TEXT NOT NULL,
UNIQUE (target_agent_id, idempotency_key)
);
CREATE TABLE IF NOT EXISTS auth_tokens (
token TEXT PRIMARY KEY,
identity_id TEXT NOT NULL
);
`);
}

Expand Down Expand Up @@ -250,6 +254,29 @@ export class SqliteStore implements Store {
return rows.map((r) => JSON.parse(r.envelope) as Envelope);
}

// --- auth tokens ---
async issueToken(token: string, identityId: string): Promise<void> {
this.db
.query(
"INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?) ON CONFLICT(token) DO UPDATE SET identity_id=excluded.identity_id",
)
.run(token, identityId);
}

async resolveToken(token: string): Promise<string | null> {
const row = this.db
.query("SELECT identity_id FROM auth_tokens WHERE token=?")
.get(token) as { identity_id: string } | null;
return row ? row.identity_id : null;
}

async listTokens(): Promise<Array<{ token: string; identityId: string }>> {
const rows = this.db
.query("SELECT token, identity_id FROM auth_tokens")
.all() as { token: string; identity_id: string }[];
return rows.map((r) => ({ token: r.token, identityId: r.identity_id }));
}

async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
Expand Down
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ async function main(command: string | undefined, restArgs: string[]) {
const { runLogs } = await import("./cli/logs");
await runLogs(restArgs);
break;
case "auth":
const { runAuth } = await import("./cli/auth");
await runAuth(restArgs);
break;
case "--help":
case "-h":
case undefined:
Expand Down Expand Up @@ -163,6 +167,8 @@ Commands:
doctor [--json] Diagnose env, daemon, build drift, logs, and current thread
doctor resume-pollution [--apply] Find/fix old AgentBridge kickoff metadata
budget [--json] Show both agents' subscription quota snapshot (5h/weekly, drift, pause state)
auth login --id <email|github> --name <displayName>
Issue a collaboration PSK token and write it to <state>/auth-token (0600)
logs [--codex] [-f] [-n N]
Tail this pair's daemon log (or the codex wrapper log with
--codex). -n N: last N lines (default 100). -f: follow/stream.
Expand Down
112 changes: 112 additions & 0 deletions src/cli/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* `abg auth login` — issue a PSK token bound to a collaboration identity (§2.2, §6).
*
* Registers (or refreshes) a person identity in the local collab Store, issues a
* fresh PSK token, and writes it to `<state>/auth-token` (0600) for the broker to
* verify via StorePskIdentityProvider. The Store double as the (token → identity)
* binding source, so a freshly-issued token authenticates without a broker restart.
*/

import { chmodSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { atomicWriteText } from "../atomic-json";
import { IdentityService } from "../backbone/identity-service";
import { SqliteStore } from "../backbone/store/sqlite-store";
import { StateDirResolver } from "../state-dir";

export interface AuthLoginOptions {
id: string;
name: string;
dbPath?: string;
}

export interface AuthLoginResult {
token: string;
identity: { id: string; displayName: string };
tokenFile: string;
}

/** Resolve the collab DB path: explicit > env override > `<state>/collab.db`. */
function resolveDbPath(dbPath?: string): string {
if (dbPath) return dbPath;
const env = process.env.AGENTBRIDGE_COLLAB_DB;
if (env && env.length > 0) return env;
return join(new StateDirResolver().dir, "collab.db");
}

/**
* Register the identity, issue a token, and persist it next to the collab DB.
* Directly unit-testable: pass an explicit `dbPath` to a temp dir.
*/
export async function authLogin(opts: AuthLoginOptions): Promise<AuthLoginResult> {
const dbPath = resolveDbPath(opts.dbPath);
const dir = dirname(dbPath);
// The collab DB holds RAW PSK tokens (auth_tokens) + identity emails/PII
// (identities). bun:sqlite creates the DB file 0644, and its WAL/SHM sidecars
// are recreated 0644 on every reopen, so file-level chmod is not durable —
// lock the CONTAINING directory to 0700 instead (matches codex-transport.ts),
// blocking any other local user from traversing in to read the secrets
// (CWE-732). chmodSync covers the case where the dir already existed looser.
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);

const store = new SqliteStore(dbPath);
try {
const svc = new IdentityService(store);
const identity = await svc.registerIdentity(opts.id, opts.name);
const token = await svc.issueToken(identity.id);
const tokenFile = join(dir, "auth-token");
// 0600 from creation (CWE-732): the token is a local secret.
atomicWriteText(tokenFile, token, { mode: 0o600 });
return { token, identity, tokenFile };
} finally {
await store.close();
}
}

const LOGIN_USAGE = "用法:abg auth login --id <email|github> --name <displayName>";

/** Parse `--id`/`--name` (space- or `=`-separated) and run the login. */
export async function runAuthLoginCli(argv: string[]): Promise<void> {
let id: string | undefined;
let name: string | undefined;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--id") {
id = argv[++i];
} else if (a.startsWith("--id=")) {
id = a.slice("--id=".length);
} else if (a === "--name") {
name = argv[++i];
} else if (a.startsWith("--name=")) {
name = a.slice("--name=".length);
}
}

if (!id || !name) {
console.error("缺少必填参数 --id 或 --name。");
console.error(LOGIN_USAGE);
process.exit(1);
return;
}

const result = await authLogin({ id, name });
console.log(
`已为 ${result.identity.id}(${result.identity.displayName})签发令牌:${result.token}`,
);
console.log(`令牌文件:${result.tokenFile}`);
}

/** Dispatch `abg auth <subcommand>`. Only `login` is supported today. */
export async function runAuth(args: string[]): Promise<void> {
const sub = args[0];
switch (sub) {
case "login":
await runAuthLoginCli(args.slice(1));
break;
default:
console.error(`未知的 auth 子命令:${sub ?? "(空)"}`);
console.error(LOGIN_USAGE);
process.exit(1);
}
}
Loading