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.
- package/README.md +167 -442
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +77 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/cli/chat.ts
ADDED
|
@@ -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
|
+
});
|
package/src/config/paths.ts
CHANGED
|
@@ -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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
17
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
export function
|
|
25
|
-
return
|
|
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
|
+
}
|