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
212 changes: 106 additions & 106 deletions plugins/agentbridge/server/bridge-server.js

Large diffs are not rendered by default.

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("fdc78c6", "source"),
commit: defineString("2d64afa", "source"),
bundle: defineBundle("plugin"),
contractVersion: defineNumber(1, CONTRACT_VERSION),
codeHash: defineString("ace71209327c", "source")
codeHash: defineString("e9f689afd6b6", "source")
});
function daemonStatusBuildInfo() {
return { ...BUILD_INFO };
Expand Down
14 changes: 12 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,21 @@ 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 issue --id <email|github> --name <displayName>
On the broker: issue a PSK token for someone else and PRINT it (carry it
out-of-band; does not touch <state>/auth-token)
auth login --token <PSK>
On the edge: install a broker-issued token to <state>/auth-token (0600)
auth login --id <email|github> --name <displayName>
Issue a collaboration PSK token and write it to <state>/auth-token (0600)
Self-sign a token locally (single-machine case) and write it (0600)
room create <name> | room list
Create a collaboration room (id = slugified name) or list rooms
join <roomId> Join a room and auto-join this directory next time (§2.4)
room invite <roomId> <identityId> [--name <displayName>] [--broker-url <ws://…>]
On the broker: issue a token + grant membership + print the invitee's
full join commands (one-shot cross-network onboarding). Pass the routable
--broker-url from "abg broker start"'s card so the invitee can actually reach you
join <roomId> Join a room and auto-join this directory next time (§2.4). For a remote
room (no local record) it maps the cwd; the broker enforces membership
broker start [--host <ip>] [--port <n>] [--db <path>] [--web-port <n>] [--no-web] [--no-open]
Run the always-on control-plane broker (§11.1) + a loopback-only
admin dashboard (view rooms/members/whiteboards + create a room)
Expand Down
157 changes: 123 additions & 34 deletions src/cli/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
/**
* `abg auth login` — issue a PSK token bound to a collaboration identity (§2.2, §6).
* `abg auth issue / login` — the cross-network onboarding primitives (§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.
* The broker verifies a presented token against ITS OWN Store (StorePskIdentityProvider),
* so a token only works if it was issued by the broker's Store. Two roles:
*
* - `auth issue --id --name` (run ON the broker): register + issueToken in the broker's
* collab.db and PRINT the token for the operator to carry out-of-band. Does NOT write a
* local auth-token (the token is for someone else).
* - `auth login --token <PSK>` (run on the EDGE): install the broker-issued token into
* `<state>/auth-token` (0600). No register / no issue — the binding already lives in the
* broker's Store.
* - `auth login --id --name` (legacy self-sign): register + issue + install locally, for the
* single-machine case where the same Store is both issuer and verifier.
*/

import { chmodSync, mkdirSync } from "node:fs";
Expand All @@ -26,6 +33,11 @@ export interface AuthLoginResult {
tokenFile: string;
}

export interface AuthIssueResult {
token: string;
identity: { id: string; displayName: string };
}

/** Resolve the collab DB path: explicit > env override > `<state>/collab.db`. */
function resolveDbPath(dbPath?: string): string {
if (dbPath) return dbPath;
Expand All @@ -35,56 +47,113 @@ function resolveDbPath(dbPath?: string): string {
}

/**
* 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.
* Lock the collab dir to 0700. 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 instead (matches codex-transport.ts), blocking any other local user from
* traversing in to read the secrets (CWE-732). chmodSync covers a pre-existing looser dir.
*/
export async function authLogin(opts: AuthLoginOptions): Promise<AuthLoginResult> {
const dbPath = resolveDbPath(opts.dbPath);
function lockCollabDir(dbPath: string): string {
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);
return dir;
}

/** Lock the dir, register the identity, and issue a token in this Store. Shared by login + issue. */
async function registerAndIssue(
dbPath: string,
id: string,
name: string,
): Promise<{ token: string; identity: { id: string; displayName: string }; dir: string }> {
const dir = lockCollabDir(dbPath);
const store = new SqliteStore(dbPath);
try {
const svc = new IdentityService(store);
const identity = await svc.registerIdentity(opts.id, opts.name);
const identity = await svc.registerIdentity(id, 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 };
return { token, identity, dir };
} finally {
await store.close();
}
}

const LOGIN_USAGE = "用法:abg auth login --id <email|github> --name <displayName>";
/**
* Self-sign: register the identity, issue a token, and persist it next to the collab DB.
* Single-machine path — the same Store both issues and (as the broker) verifies. 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 { token, identity, dir } = await registerAndIssue(dbPath, opts.id, opts.name);
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 };
}

/**
* Issue a token FROM the broker's Store for someone else to install (`abg auth issue`).
* Register + issue into this machine's collab.db, then return the token to PRINT. Deliberately
* does NOT write a local auth-token — this token belongs to the invitee, not the operator.
*/
export async function authIssue(opts: AuthLoginOptions): Promise<AuthIssueResult> {
const dbPath = resolveDbPath(opts.dbPath);
const { token, identity } = await registerAndIssue(dbPath, opts.id, opts.name);
return { token, identity };
}

/** Parse `--id`/`--name` (space- or `=`-separated) and run the login. */
export async function runAuthLoginCli(argv: string[]): Promise<void> {
/**
* Install a broker-issued token on the edge (`abg auth login --token <PSK>`). Writes the
* out-of-band token to `<state>/auth-token` (0600) and locks the dir — NO register / NO issue,
* because the (token → identity) binding already lives in the broker's Store. An empty token is
* rejected (it would silently disable auth).
*/
export async function installToken(opts: { token: string; dbPath?: string }): Promise<{ tokenFile: string }> {
const token = opts.token.trim();
if (token === "") throw new Error("令牌为空:abg auth login --token <PSK> 需要 broker 签发的非空令牌");
const dbPath = resolveDbPath(opts.dbPath);
const dir = lockCollabDir(dbPath);
const tokenFile = join(dir, "auth-token");
atomicWriteText(tokenFile, token, { mode: 0o600 });
return { tokenFile };
}

const LOGIN_USAGE =
"用法:abg auth login --token <PSK>(边机安装 broker 签发的令牌)|abg auth login --id <email|github> --name <displayName>(本机自签)";
const ISSUE_USAGE = "用法:abg auth issue --id <email|github> --name <displayName>(在 broker 机上签发,把令牌带外发给对方)";

/** Parse `--id`/`--name`/`--token` (space- or `=`-separated). Empty values become "". */
function parseAuthArgs(argv: string[]): { id?: string; name?: string; token?: string } {
let id: string | undefined;
let name: string | undefined;
let token: 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 (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);
else if (a === "--token") token = argv[++i] ?? "";
else if (a.startsWith("--token=")) token = a.slice("--token=".length);
}
return { id, name, token };
}

/** Run `abg auth login`: `--token` installs a broker token; `--id --name` self-signs. */
export async function runAuthLoginCli(argv: string[]): Promise<void> {
const { id, name, token } = parseAuthArgs(argv);

// --token mode: install the broker-issued token (an empty value is caught by installToken).
if (token !== undefined) {
const { tokenFile } = await installToken({ token });
console.log(`已安装协作令牌(broker 签发):${tokenFile}`);
console.log("现在可以:abg join <roomId>");
return;
}

if (!id || !name) {
console.error("缺少必填参数 --id --name。");
console.error("缺少必填参数:要么 --token <PSK>,要么 --id <…> --name <…>。");
console.error(LOGIN_USAGE);
process.exit(1);
return;
Expand All @@ -97,16 +166,36 @@ export async function runAuthLoginCli(argv: string[]): Promise<void> {
console.log(`令牌文件:${result.tokenFile}`);
}

/** Dispatch `abg auth <subcommand>`. Only `login` is supported today. */
/** Run `abg auth issue`: sign a token on the broker for an invitee to install out-of-band. */
export async function runAuthIssueCli(argv: string[]): Promise<void> {
const { id, name } = parseAuthArgs(argv);
if (!id || !name) {
console.error("缺少必填参数 --id 或 --name。");
console.error(ISSUE_USAGE);
process.exit(1);
return;
}
const result = await authIssue({ id, name });
console.log(`已在本机(broker)store 为 ${result.identity.id}(${result.identity.displayName})签发令牌。`);
console.log("把下面这行通过安全渠道带外发给对方,让它在自己机器上运行:");
console.log(` abg auth login --token ${result.token}`);
console.log("(注:对同一 --id 重复 issue 会另签新 token、旧 token 不会自动失效——令牌吊销 CLI 仍在 backlog。)");
}

/** Dispatch `abg auth <subcommand>`: `login` (install/self-sign) or `issue` (broker-side sign). */
export async function runAuth(args: string[]): Promise<void> {
const sub = args[0];
switch (sub) {
case "login":
await runAuthLoginCli(args.slice(1));
break;
case "issue":
await runAuthIssueCli(args.slice(1));
break;
default:
console.error(`未知的 auth 子命令:${sub ?? "(空)"}`);
console.error(LOGIN_USAGE);
console.error(ISSUE_USAGE);
process.exit(1);
}
}
Loading