privateer-agent 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +167 -442
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +77 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -0,0 +1,243 @@
1
+ // A lean interactive REPL — enough to actually USE the agent and test the moat
2
+ // before the full pi-tui rewrite (Phase 6). Reuses everything built: the gate
3
+ // (with terminal y/n approval), the pi-privacy extension (providers + attestation),
4
+ // the EngineEvent adapter, and Pi's real session.
5
+ //
6
+ // Run: nvm use && node --env-file=.env --import tsx src/cli/chat.ts
7
+ // Model: PRIVATEER_MODEL=provider/id (default openrouter/openai/gpt-4o-mini)
8
+ // e.g. tinfoil/llama3-3-70b to watch TEE posture go green.
9
+
10
+ import "../boot.ts"; // env + attestation dispatcher, before any Pi import
11
+ import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
12
+
13
+ const RESET = "\x1b[0m", DIM = "\x1b[2m", CYAN = "\x1b[36m", YELLOW = "\x1b[33m", RED = "\x1b[31m", GREEN = "\x1b[32m";
14
+
15
+ async function main() {
16
+ const readline = await import("node:readline");
17
+ const {
18
+ createAgentSessionServices,
19
+ createAgentSessionFromServices,
20
+ SessionManager,
21
+ } = await import("@earendil-works/pi-coding-agent");
22
+ const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
23
+ const { makePermissionGate } = await import("../ext/permissionGate.ts");
24
+ const { makePiPrivacyExtension, verifyModelPosture, TIERS } = await import("pi-privacy");
25
+ const { agentDir } = await import("../config/paths.ts");
26
+ const { RemoteBridge } = await import("../remote/remoteBridge.ts");
27
+ const { RelayClient } = await import("../remote/relayClient.ts");
28
+ const priv = await import("../auth/privateer.ts");
29
+ const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
30
+
31
+ const spec = process.env.PRIVATEER_MODEL ?? "openrouter/openai/gpt-4o-mini";
32
+ const slash = spec.indexOf("/");
33
+ const provider = spec.slice(0, slash);
34
+ const modelId = spec.slice(slash + 1);
35
+ const cwd = process.cwd();
36
+
37
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
+ let closed = false;
39
+ rl.on("close", () => (closed = true));
40
+ // Resolve to /quit if the input stream ends (EOF / Ctrl-D / piped input), incl.
41
+ // if it closes while a question is pending, so we never throw USE_AFTER_CLOSE.
42
+ const ask = (q: string): Promise<string> =>
43
+ new Promise((res) => {
44
+ if (closed) return res("/quit");
45
+ const onClose = () => res("/quit");
46
+ rl.once("close", onClose);
47
+ rl.question(q, (a) => {
48
+ rl.off("close", onClose);
49
+ res(a);
50
+ });
51
+ });
52
+
53
+ // Session + remote state, declared up front so the gate and the relay bridge can
54
+ // reference them (they're assigned/used later, only at runtime).
55
+ let session: any = null;
56
+ let relay: any = null;
57
+ let turnActive = false;
58
+
59
+ // The relay bridge: wires the app (when /remote-access is on) to the same gate +
60
+ // turn loop. Its gate hooks (getRemote/remoteAsk) are handed to the gate below,
61
+ // so a remote-driven turn relays each tool to the phone instead of the terminal.
62
+ const bridge = new RemoteBridge({
63
+ onPrompt: (text) => void runTurn(text, true),
64
+ onInterrupt: () => void session?.abort?.(),
65
+ onControllerAttached: () => relay?.sendSnapshot([]),
66
+ onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
67
+ });
68
+
69
+ // Serialize turns so a remote prompt and a locally-typed one can't overlap.
70
+ async function runTurn(text: string, remote: boolean): Promise<void> {
71
+ if (turnActive) {
72
+ console.log(`\n${DIM}(busy — a turn is already running)${RESET}`);
73
+ return;
74
+ }
75
+ turnActive = true;
76
+ if (remote) console.log(`\n${DIM}⟿ [app] ${text.slice(0, 80)}${RESET}`);
77
+ try {
78
+ await session.prompt(text);
79
+ } catch (e) {
80
+ console.log(`\n${RED}${(e as Error).message}${RESET}`);
81
+ } finally {
82
+ turnActive = false;
83
+ if (remote) bridge.settleTurn();
84
+ }
85
+ }
86
+
87
+ // The gate: local terminal approval, plus the remote branch via the bridge.
88
+ let mode: "default" | "acceptEdits" | "bypass" | "plan" = "default";
89
+ const gate: GateController = {
90
+ getMode: () => mode,
91
+ setMode: (m) => (mode = m),
92
+ allowlist: [],
93
+ allowedOutsideRoots: [],
94
+ cwd,
95
+ async localAsk(req) {
96
+ const a = (await ask(`\n${YELLOW}⚠ Allow ${req.title}: ${req.detail}?${RESET} [y/N/a] `)).trim().toLowerCase();
97
+ return a === "y" || a === "yes" ? "allow" : a === "a" || a === "always" ? "always" : "deny";
98
+ },
99
+ getRemote: bridge.getRemote,
100
+ getNoQuarter: bridge.getNoQuarter,
101
+ remoteAsk: bridge.remoteAsk,
102
+ };
103
+
104
+ console.log(`${DIM}privateer-agent — lean REPL. Loading ${provider}/${modelId}…${RESET}`);
105
+
106
+ const services = await createAgentSessionServices({
107
+ cwd,
108
+ agentDir: agentDir(),
109
+ resourceLoaderOptions: {
110
+ // Structural ext types are intentionally narrow; cast to Pi's ExtensionFactory.
111
+ extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
112
+ },
113
+ });
114
+ for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
115
+
116
+ // Account channel: seed the OAuth credential (a fresh child session) so getApiKey
117
+ // resolves it; Pi then manages refresh on expiry via the registered oauth provider.
118
+ if (provider === "privateer") {
119
+ try {
120
+ const creds = await priv.spawnAccountCredentials();
121
+ (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
122
+ } catch (e) {
123
+ console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
124
+ }
125
+ }
126
+
127
+ const model = (services.modelRegistry as any).find(provider, modelId);
128
+ if (!model) {
129
+ console.log(`${RED}Model ${provider}/${modelId} not found.${RESET} Set PRIVATEER_MODEL=provider/id and check the key is in .env.`);
130
+ rl.close();
131
+ process.exit(1);
132
+ }
133
+
134
+ ({ session } = await createAgentSessionFromServices({
135
+ services,
136
+ sessionManager: SessionManager.inMemory(cwd),
137
+ model,
138
+ } as any));
139
+
140
+ // Stream the turn as EngineEvents — printed locally AND forwarded to the app
141
+ // (the relay only sends when a controller is attached, so this is safe always).
142
+ const adapter = createEngineEventAdapter();
143
+ session.subscribe((ev: any) => {
144
+ for (const ee of adapter.toEngineEvents(ev)) {
145
+ bridge.forwardEvent(ee);
146
+ if (ee.type === "text") process.stdout.write(ee.text);
147
+ else if (ee.type === "reasoning") process.stdout.write(`${DIM}${ee.text}${RESET}`);
148
+ else if (ee.type === "tool-call") process.stdout.write(`\n${CYAN}⏺ ${ee.name}${RESET} ${DIM}${JSON.stringify(ee.input).slice(0, 120)}${RESET}\n`);
149
+ else if (ee.type === "tool-result") process.stdout.write(`${DIM} ↳ ${String(ee.output).slice(0, 200)}${RESET}\n`);
150
+ else if (ee.type === "tool-error") process.stdout.write(`\n${RED}✗ ${ee.name}: ${ee.error}${RESET}\n`);
151
+ else if (ee.type === "error") process.stdout.write(`\n${RED}error: ${ee.error}${RESET}\n`);
152
+ else if (ee.type === "finish") process.stdout.write("\n");
153
+ }
154
+ });
155
+
156
+ async function showPosture() {
157
+ // The account channel has its own posture (server-proxy attestation for near/,
158
+ // ZDR policy otherwise); other providers go through pi-privacy.
159
+ const res =
160
+ provider === "privateer"
161
+ ? await accountPosture(modelId)
162
+ : await verifyModelPosture(provider, modelId, {
163
+ apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
164
+ });
165
+ const t = TIERS[res.tier];
166
+ const color = t.posture === "green" ? GREEN : t.posture === "yellow" ? YELLOW : DIM;
167
+ console.log(`\n${color}⛉ ${t.label}${RESET} ${DIM}(${res.tier}${res.teePosture ? "/" + res.teePosture : ""}) — ${t.blurb}${RESET}${res.error ? `\n${RED} ${res.error}${RESET}` : ""}`);
168
+ }
169
+
170
+ async function remoteAccess(on: boolean) {
171
+ if (on) {
172
+ if (relay) return console.log(`${DIM}remote access already on${RESET}`);
173
+ if (!priv.hasCredentials()) return console.log(`${RED}Not signed in.${RESET} Run /login first.`);
174
+ relay = new RelayClient(bridge.callbacks, { label: "privateer-cli" });
175
+ bridge.attachRelay(relay);
176
+ await relay.start();
177
+ console.log(`${DIM}Remote access enabling — approve this terminal in the Privateer app, then drive it from there.${RESET}`);
178
+ } else {
179
+ relay?.stop();
180
+ relay = null;
181
+ console.log(`${DIM}remote access off${RESET}`);
182
+ }
183
+ }
184
+
185
+ async function login() {
186
+ console.log(`${DIM}Requesting a device code…${RESET}`);
187
+ try {
188
+ const user = await priv.runDeviceLogin({
189
+ onCode: (code: any) => {
190
+ console.log(`\n${CYAN}Approve this terminal in the Privateer app:${RESET}`);
191
+ console.log(` code: ${YELLOW}${code.user_code}${RESET}`);
192
+ if (code.verification_uri_complete) console.log(` or open: ${DIM}${code.verification_uri_complete}${RESET}`);
193
+ console.log(`${DIM} waiting for approval…${RESET}`);
194
+ },
195
+ });
196
+ console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
197
+ } catch (e) {
198
+ console.log(`${RED}${(e as Error).message}${RESET}`);
199
+ }
200
+ }
201
+
202
+ // Surface the account state so an inherited login (shared ~/.privateer/credentials.json
203
+ // with the 0.2 CLI) isn't a surprise when /remote-access "just works".
204
+ const who = priv.currentUser();
205
+ console.log(
206
+ who
207
+ ? `${DIM}Signed in as ${who.email ?? who.id} ${DIM}(Privateer account — shared ~/.privateer login).${RESET}`
208
+ : `${DIM}Not signed in. /login to enable remote access & the account provider.${RESET}`,
209
+ );
210
+
211
+ const HELP = "Commands: /remote-access <on|off> /login /models [filter] /verify /mode <…> /quit";
212
+ console.log(`${DIM}Ready. Type a prompt. ${HELP}${RESET}`);
213
+ await showPosture();
214
+
215
+ for (;;) {
216
+ const line = (await ask(`\n${CYAN}›${RESET} `)).trim();
217
+ if (line === "/quit" || line === "/exit") break;
218
+ if (line === "/verify") { await showPosture(); continue; }
219
+ if (line === "/remote-access" || line === "/remote-access on" || line === "/remote") { await remoteAccess(true); continue; }
220
+ if (line === "/remote-access off") { await remoteAccess(false); continue; }
221
+ if (line === "/login") { await login(); continue; }
222
+ if (line === "/help" || line === "?") { console.log(`${DIM}${HELP}${RESET}`); continue; }
223
+ if (line.startsWith("/models")) {
224
+ const filter = line.slice(7).trim().toLowerCase();
225
+ const all: any[] = (services.modelRegistry as any).getAvailable ? await (services.modelRegistry as any).getAvailable() : [];
226
+ const rows = all.map((m) => `${m.provider}/${m.id}`).filter((s) => !filter || s.toLowerCase().includes(filter)).sort();
227
+ console.log(rows.slice(0, 40).join("\n") + (rows.length > 40 ? `\n${DIM}… ${rows.length - 40} more (try /models <filter>)${RESET}` : ""));
228
+ continue;
229
+ }
230
+ if (line.startsWith("/mode ")) { mode = line.slice(6).trim() as typeof mode; console.log(`${DIM}mode → ${mode}${RESET}`); continue; }
231
+ if (!line) continue;
232
+ await runTurn(line, false);
233
+ }
234
+ relay?.stop();
235
+ rl.close();
236
+ console.log(`${DIM}bye.${RESET}`);
237
+ process.exit(0);
238
+ }
239
+
240
+ main().catch((e) => {
241
+ console.error(e?.stack ?? e);
242
+ process.exit(1);
243
+ });
@@ -3,61 +3,39 @@ import { join } from "node:path";
3
3
 
4
4
  // Global config/data dir. Overridable via PRIVATEER_HOME (portability + tests).
5
5
  // Computed lazily so the env var can be set before first use.
6
+ //
7
+ // KEEP — ported verbatim from tree-cli/src/config/paths.ts. This is the anchor
8
+ // the whole config layout hangs off, including Pi's agent dir (see boot.ts).
6
9
  export function globalDir(): string {
7
10
  return process.env.PRIVATEER_HOME ?? join(homedir(), ".privateer");
8
11
  }
9
12
 
10
- // Project-scoped config dir (./.privateer).
11
- export function projectDir(cwd: string = process.cwd()): string {
12
- return join(cwd, ".privateer");
13
+ // Pi's agent dir, nested UNDER the privateer home so there is one tree to
14
+ // back up / inspect / delete, and zero collision with a user's standalone Pi at
15
+ // ~/.pi/agent (we never read or write theirs). This is the value boot.ts pins
16
+ // into PI_CODING_AGENT_DIR before any Pi import.
17
+ //
18
+ // ~/.privateer/ ← PRIVATEER_HOME
19
+ // ├── credentials.json relay/account JWT (KEEP — privateer identity)
20
+ // ├── config.json webhooks/remote/posture prefs (ours; Pi never reads it)
21
+ // ├── routines/
22
+ // └── agent/ ← PI_CODING_AGENT_DIR
23
+ // ├── auth.json models.json settings.json trust.json
24
+ // ├── sessions/
25
+ // └── extensions/
26
+ export function agentDir(): string {
27
+ return join(globalDir(), "agent");
13
28
  }
14
29
 
15
30
  // Privateer-account session credentials (JWT access/refresh + user). Kept in a
16
- // SEPARATE file from config.json: these are session tokens, not BYO provider
17
- // keys, and have a different lifecycle (rotated on refresh, cleared on logout).
31
+ // SEPARATE file from Pi's provider keys (agent/auth.json): these are session
32
+ // tokens with a different lifecycle (rotated on refresh, cleared on logout).
18
33
  export function credentialsPath(): string {
19
34
  return join(globalDir(), "credentials.json");
20
35
  }
21
36
 
22
- // Optional enterprise-managed settings file (highest precedence). Opt-in via env
23
- // so it stays out of the way for individuals and tests.
24
- export function managedSettingsPath(): string | undefined {
25
- return process.env.PRIVATEER_MANAGED_SETTINGS || undefined;
26
- }
27
-
28
- // The standard file/dir layout under a scope's .privateer directory. Later
29
- // milestones (commands, agents, mcp, output styles) resolve their roots here so
30
- // the convention lives in one place.
31
- export interface ScopePaths {
32
- dir: string;
33
- config: string; // config.json — credentials + prefs (existing single file)
34
- settings: string; // settings.json — layered settings
35
- settingsLocal: string; // settings.local.json — gitignored local overrides
36
- commands: string; // commands/ — custom slash commands (M2)
37
- agents: string; // agents/ — custom subagents (M4)
38
- skills: string; // skills/ — agent skills, one SKILL.md directory each
39
- outputStyles: string; // output-styles/ — persona prompts (M2)
40
- mcp: string; // mcp.json — MCP server declarations (M4)
41
- }
42
-
43
- function scopePaths(dir: string): ScopePaths {
44
- return {
45
- dir,
46
- config: join(dir, "config.json"),
47
- settings: join(dir, "settings.json"),
48
- settingsLocal: join(dir, "settings.local.json"),
49
- commands: join(dir, "commands"),
50
- agents: join(dir, "agents"),
51
- skills: join(dir, "skills"),
52
- outputStyles: join(dir, "output-styles"),
53
- mcp: join(dir, "mcp.json"),
54
- };
55
- }
56
-
57
- export function globalPaths(): ScopePaths {
58
- return scopePaths(globalDir());
59
- }
60
-
61
- export function projectPaths(cwd: string = process.cwd()): ScopePaths {
62
- return scopePaths(projectDir(cwd));
37
+ // Privateer-only preferences read exclusively by our code (webhooks, remote
38
+ // access, posture, redaction). Pi never reads this file.
39
+ export function configPath(): string {
40
+ return join(globalDir(), "config.json");
63
41
  }
@@ -0,0 +1,5 @@
1
+ // Permission modes, split out from the (Phase 7) zod config schema so the
2
+ // permission layer can depend on just this without pulling zod. Ported from
3
+ // tree-cli/src/config/schema.ts.
4
+ export const PERMISSION_MODES = ["default", "acceptEdits", "bypass", "plan"] as const;
5
+ export type PermissionMode = (typeof PERMISSION_MODES)[number];
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Outbox sealed-box crypto — SENDER (terminal) side.
3
+ *
4
+ * A running terminal seals summaries of unattended results (completed routines,
5
+ * finished agent tasks) to the account's outbox public key and POSTs the
6
+ * ciphertext to the server, so the app can catch up on next open without any
7
+ * push notification.
8
+ *
9
+ * This terminal holds NO account key material. By design this module can ONLY
10
+ * seal — there is no `open` and no way to derive the account private key here.
11
+ * A stolen terminal can post messages but can never read the outbox, other
12
+ * terminals' results, or any history. That "write-only terminal" property is
13
+ * the whole reason the outbox is asymmetric rather than symmetric.
14
+ *
15
+ * Construction and domain-separation constants MUST stay byte-for-byte in sync
16
+ * with treeview/client/services/outboxSeal.ts (the recipient side):
17
+ * X25519 → HKDF-SHA256 → AES-256-GCM. Wire: epk(32) ‖ iv(12) ‖ ct‖tag, base64.
18
+ */
19
+
20
+ import { x25519 } from "@noble/curves/ed25519";
21
+ import { hkdf } from "@noble/hashes/hkdf";
22
+ import { sha256 } from "@noble/hashes/sha256";
23
+ import { gcm } from "@noble/ciphers/aes.js";
24
+ import { randomBytes } from "@noble/hashes/utils";
25
+
26
+ const enc = new TextEncoder();
27
+
28
+ // MUST match treeview/client/services/outboxSeal.ts.
29
+ const KDF_SEAL = enc.encode("privateer-outbox-seal-v1");
30
+
31
+ function concat(...arrs: Uint8Array[]): Uint8Array {
32
+ const total = arrs.reduce((n, a) => n + a.length, 0);
33
+ const out = new Uint8Array(total);
34
+ let off = 0;
35
+ for (const a of arrs) { out.set(a, off); off += a.length; }
36
+ return out;
37
+ }
38
+
39
+ /** Decode the account public key the server hands us (base64 → 32 raw bytes). */
40
+ export function decodeAccountPublicKey(b64: string): Uint8Array {
41
+ const buf = Buffer.from(b64, "base64");
42
+ if (buf.length !== 32) throw new Error("outbox public key must be 32 bytes");
43
+ return new Uint8Array(buf);
44
+ }
45
+
46
+ /** Seal plaintext to the account public key. Returns the base64 wire string. */
47
+ export function seal(accountPub: Uint8Array, plaintext: Uint8Array): string {
48
+ const esk = x25519.utils.randomPrivateKey();
49
+ const epk = x25519.getPublicKey(esk);
50
+ const shared = x25519.getSharedSecret(esk, accountPub);
51
+ const salt = concat(epk, accountPub); // binds ephemeral + recipient
52
+ const key = hkdf(sha256, shared, salt, KDF_SEAL, 32);
53
+ const iv = randomBytes(12);
54
+ const ct = gcm(key, iv).encrypt(plaintext); // ct includes the 16-byte tag
55
+ return Buffer.from(concat(epk, iv, ct)).toString("base64");
56
+ }
57
+
58
+ /** Seal a JSON-serializable value. */
59
+ export function sealJson(accountPub: Uint8Array, obj: unknown): string {
60
+ return seal(accountPub, enc.encode(JSON.stringify(obj)));
61
+ }