Skip to content

Commit 4466476

Browse files
Merge pull request #15 from lodar/feat/openagent-persona-export
feat(cli): export digital employees as OpenAgent persona-cards
2 parents 1ee7a20 + e350837 commit 4466476

7 files changed

Lines changed: 566 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ The board exposes agents as managed organizational resources:
377377
- role, summary, owner, readiness, and status
378378
- assigned skills and knowledge
379379
- runtime and harness binding
380+
- export any digital employee as a signed [OpenAgent](https://github.com/5dive-ai/openagent) persona-card (`agent-space employee export-persona --name <employee> --sign`); operator instructions, skills, and owner identity are redacted by default — add `--include-sensitive` to embed them
380381
- common channels and channel availability
381382
- borrow/request flows
382383
- review queues for owners and admins

apps/cli/src/commands/employee.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { writeFileSync } from "node:fs";
12
import {
23
bindEmployeeRuntimeSync,
34
createEmployeeSync,
45
listActiveEmployeesSync,
56
listEmployeeRuntimeBindingsForWorkspaceSync,
7+
listEmployeeSkillIdsSync,
68
unbindEmployeeRuntimeSync,
79
} from "@agent-space/services";
10+
import { employeeToPersona } from "@agent-space/domain";
11+
import { signPersona } from "../lib/openagent-persona-sign.ts";
812
import { parseArgs, getStringFlag } from "../lib/args.ts";
913
import { writeData, type OutputFormat } from "../lib/format.ts";
1014

@@ -111,6 +115,58 @@ export function runEmployeeCommand(
111115
return 0;
112116
}
113117

118+
if (subcommand === "export-persona") {
119+
const { flags } = parseArgs(args);
120+
const name = getStringFlag(flags, "name");
121+
122+
if (!name) {
123+
console.error(
124+
"Usage: agent-space employee export-persona --name <employee> [--sign] [--include-sensitive] [--out <path>] [--json]",
125+
);
126+
return 1;
127+
}
128+
129+
const employee = listActiveEmployeesSync().find((candidate) => candidate.name === name);
130+
if (!employee) {
131+
console.error(`No active employee named "${name}".`);
132+
return 1;
133+
}
134+
135+
const skills = listEmployeeSkillIdsSync(name);
136+
const sign = flags.sign === true || getStringFlag(flags, "sign") !== undefined;
137+
// Private operator config (instructions, skills, owner identity) is redacted
138+
// by default; --include-sensitive opts it back into the exported card.
139+
const includeSensitive =
140+
flags["include-sensitive"] === true || getStringFlag(flags, "include-sensitive") !== undefined;
141+
142+
// Map (pure, runtime-agnostic domain) then sign (node layer) — the two compose.
143+
const persona = employeeToPersona(employee, skills, { includeSensitive });
144+
const didKey = sign ? signPersona(persona).didKey : null;
145+
const serialized = JSON.stringify(persona, null, 2);
146+
147+
const out = getStringFlag(flags, "out");
148+
if (out) {
149+
writeFileSync(out, `${serialized}\n`);
150+
}
151+
152+
if (format === "json") {
153+
writeData(format, {
154+
ok: true,
155+
employee: name,
156+
signed: sign,
157+
includeSensitive,
158+
didKey: didKey ?? null,
159+
out: out ?? null,
160+
persona,
161+
});
162+
} else if (out) {
163+
writeData(format, { ok: true, employee: name, signed: sign, includeSensitive, didKey: didKey ?? "", out });
164+
} else {
165+
console.log(serialized);
166+
}
167+
return 0;
168+
}
169+
114170
console.error("Usage: agent-space employee list [--json]");
115171
console.error(
116172
" or: agent-space employee create --name <name> --role <role> [--traits a,b] [--summary <text>] [--fit <text>] [--origin <label>] [--json]",
@@ -119,5 +175,8 @@ export function runEmployeeCommand(
119175
" or: agent-space employee bind-runtime --name <employee> --runtime-id <runtime-id> [--json]",
120176
);
121177
console.error(" or: agent-space employee unbind-runtime --name <employee> [--json]");
178+
console.error(
179+
" or: agent-space employee export-persona --name <employee> [--sign] [--include-sensitive] [--out <path>] [--json]",
180+
);
122181
return 1;
123182
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { generateKeyPairSync } from "node:crypto";
4+
import { employeeToPersona } from "@agent-space/domain";
5+
import type { ActiveEmployee } from "@agent-space/domain/workspace";
6+
import {
7+
didKeyFromPublicKey,
8+
signPersona,
9+
verifyPersonaSignature,
10+
} from "./openagent-persona-sign.ts";
11+
12+
const employee: ActiveEmployee = {
13+
name: "Atlas",
14+
role: "Research Lead",
15+
remarkName: "阿特拉斯",
16+
ownerUserId: "user-42",
17+
origin: "手动创建",
18+
summary: "Digs through papers and surfaces the load-bearing claims.",
19+
traits: ["rigorous", "curious"],
20+
fit: "Deep-dive research and synthesis.",
21+
skillIds: [],
22+
channels: ["research"],
23+
status: "active",
24+
instructions: "Cite sources. Flag uncertainty explicitly.",
25+
};
26+
27+
const skills = ["literature-review", "citation-check"];
28+
29+
test("signPersona mints a did:key and a self-verifying provenance block", () => {
30+
const keyPair = generateKeyPairSync("ed25519");
31+
const persona = employeeToPersona(employee, skills);
32+
const { persona: signed, didKey } = signPersona(persona, {
33+
now: "2026-07-03T00:00:00.000Z",
34+
keyPair,
35+
});
36+
37+
assert.ok(signed.provenance);
38+
assert.equal(signed.provenance?.signed_at, "2026-07-03T00:00:00.000Z");
39+
assert.equal(signed.provenance?.created_by.name, "Atlas");
40+
assert.ok(signed.provenance?.created_by.key.includes("BEGIN PUBLIC KEY"));
41+
assert.ok(signed.provenance?.signature);
42+
43+
assert.equal(didKey, didKeyFromPublicKey(keyPair.publicKey));
44+
assert.match(didKey, /^did:key:z6Mk/);
45+
46+
// The signature verifies against created_by.key, exactly as @5dive/openagent does.
47+
assert.equal(verifyPersonaSignature(signed), true);
48+
});
49+
50+
test("signPersona mutates and returns the same persona object", () => {
51+
const persona = employeeToPersona(employee, skills);
52+
const { persona: signed } = signPersona(persona);
53+
assert.equal(signed, persona);
54+
assert.ok(persona.provenance?.signature);
55+
});
56+
57+
test("tampering with a signed persona breaks verification", () => {
58+
const persona = employeeToPersona(employee, skills);
59+
signPersona(persona);
60+
persona.behavior = `${persona.behavior} (tampered)`;
61+
assert.equal(verifyPersonaSignature(persona), false);
62+
});
63+
64+
test("verifyPersonaSignature is false for an unsigned persona", () => {
65+
const persona = employeeToPersona(employee, skills);
66+
assert.equal(verifyPersonaSignature(persona), false);
67+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {
2+
createPublicKey,
3+
generateKeyPairSync,
4+
sign as edSign,
5+
verify as edVerify,
6+
type KeyObject,
7+
} from "node:crypto";
8+
import type { OpenAgentPersona } from "@agent-space/domain";
9+
10+
/**
11+
* Node-layer signer for OpenAgent persona-cards. The pure `employeeToPersona()`
12+
* mapper (packages/domain) produces an unsigned persona; this composes on top of
13+
* it to attach a self-verifying ed25519 provenance block and derive the agent's
14+
* did:key address. It lives here — not in packages/domain — because it depends on
15+
* node:crypto and Buffer, which the runtime-agnostic domain build cannot type.
16+
*
17+
* The canonicalisation, ed25519 signature and did:key derivation mirror
18+
* `@5dive/openagent` (lib/provenance.js) exactly, so a signed export validates and
19+
* verifies with `npx @5dive/openagent validate` / provenance verify without any
20+
* dependency on the (CommonJS) CLI package.
21+
*/
22+
23+
export interface SignPersonaOptions {
24+
/** Fixed timestamp (ISO 8601) for the provenance block; defaults to now. */
25+
now?: string;
26+
/** Deterministic keypair injection for tests / reproducible exports. */
27+
keyPair?: { publicKey: KeyObject; privateKey: KeyObject };
28+
}
29+
30+
export interface SignPersonaResult {
31+
persona: OpenAgentPersona;
32+
/** The agent's canonical public address, e.g. "did:key:z6Mk…". */
33+
didKey: string;
34+
}
35+
36+
// ---- canonicalisation (mirrors @5dive/openagent lib/provenance.js) ----------
37+
38+
// Deterministic JSON: object keys sorted recursively, primitives via JSON.stringify.
39+
function stableStringify(value: unknown): string {
40+
if (Array.isArray(value)) {
41+
return `[${value.map(stableStringify).join(",")}]`;
42+
}
43+
if (value && typeof value === "object") {
44+
const record = value as Record<string, unknown>;
45+
return `{${Object.keys(record)
46+
.sort()
47+
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
48+
.join(",")}}`;
49+
}
50+
return JSON.stringify(value);
51+
}
52+
53+
// The exact bytes a signature covers: the whole persona with
54+
// provenance.signature removed. Round-trips through JSON to drop undefined.
55+
function canonicalBytes(persona: OpenAgentPersona): Buffer {
56+
const clone = JSON.parse(JSON.stringify(persona)) as OpenAgentPersona;
57+
if (clone.provenance) {
58+
delete clone.provenance.signature;
59+
}
60+
return Buffer.from(stableStringify(clone), "utf8");
61+
}
62+
63+
// ---- did:key address (multicodec ed25519-pub + base58btc) -------------------
64+
65+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
66+
67+
function base58btcEncode(bytes: Buffer): string {
68+
let zeros = 0;
69+
while (zeros < bytes.length && bytes[zeros] === 0) {
70+
zeros += 1;
71+
}
72+
const digits: number[] = [];
73+
for (let index = zeros; index < bytes.length; index += 1) {
74+
let carry = bytes[index];
75+
for (let digitIndex = 0; digitIndex < digits.length; digitIndex += 1) {
76+
carry += digits[digitIndex] << 8;
77+
digits[digitIndex] = carry % 58;
78+
carry = Math.floor(carry / 58);
79+
}
80+
while (carry > 0) {
81+
digits.push(carry % 58);
82+
carry = Math.floor(carry / 58);
83+
}
84+
}
85+
let out = "1".repeat(zeros);
86+
for (let index = digits.length - 1; index >= 0; index -= 1) {
87+
out += BASE58_ALPHABET[digits[index]];
88+
}
89+
return out;
90+
}
91+
92+
// Raw 32-byte ed25519 public key via JWK export (no hand-parsed SPKI offsets).
93+
function rawEd25519PublicKey(publicKey: KeyObject): Buffer {
94+
const jwk = publicKey.export({ format: "jwk" }) as { crv?: string; x?: string };
95+
if (jwk.crv !== "Ed25519" || !jwk.x) {
96+
throw new Error("did:key needs an Ed25519 public key");
97+
}
98+
const raw = Buffer.from(jwk.x, "base64url");
99+
if (raw.length !== 32) {
100+
throw new Error(`unexpected ed25519 key length: ${raw.length}`);
101+
}
102+
return raw;
103+
}
104+
105+
/** Derive the did:key public address for an ed25519 public key. */
106+
export function didKeyFromPublicKey(publicKey: KeyObject): string {
107+
const raw = rawEd25519PublicKey(publicKey);
108+
const prefixed = Buffer.concat([Buffer.from([0xed, 0x01]), raw]); // 0xed01 = ed25519-pub
109+
return `did:key:z${base58btcEncode(prefixed)}`;
110+
}
111+
112+
// ---- signing ----------------------------------------------------------------
113+
114+
/**
115+
* Attach a signed ed25519 provenance block to a persona (mutating it in place)
116+
* and return it alongside the signer's did:key address. Mints a fresh keypair
117+
* unless one is injected via `opts.keyPair`.
118+
*/
119+
export function signPersona(
120+
persona: OpenAgentPersona,
121+
opts: SignPersonaOptions = {},
122+
): SignPersonaResult {
123+
const { publicKey, privateKey } = opts.keyPair ?? generateKeyPairSync("ed25519");
124+
const publicPem = publicKey.export({ type: "spki", format: "pem" }).toString().trim();
125+
126+
persona.provenance = {
127+
created_by: {
128+
name: persona.name,
129+
key: publicPem,
130+
url: "https://github.com/HKUDS/AgentSpace",
131+
},
132+
signed_at: opts.now ?? new Date().toISOString(),
133+
};
134+
135+
persona.provenance.signature = edSign(null, canonicalBytes(persona), privateKey).toString("base64");
136+
137+
return { persona, didKey: didKeyFromPublicKey(publicKey) };
138+
}
139+
140+
/**
141+
* Verify a signed persona's provenance block the same way `@5dive/openagent`
142+
* does: recompute the canonical bytes (signature removed) and check the ed25519
143+
* signature against created_by.key.
144+
*/
145+
export function verifyPersonaSignature(persona: OpenAgentPersona): boolean {
146+
const sig = persona.provenance?.signature;
147+
const key = persona.provenance?.created_by?.key;
148+
if (!sig || !key) {
149+
return false;
150+
}
151+
const publicKey = createPublicKey(key);
152+
return edVerify(null, canonicalBytes(persona), publicKey, Buffer.from(sig, "base64"));
153+
}

packages/domain/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./workspace.ts";
2+
export * from "./openagent-persona.ts";
23
export * from "./mentions.ts";
34
export * from "./mention-plan.ts";
45
export * from "./channel-document-runs.ts";

0 commit comments

Comments
 (0)