privateer-agent 0.3.6 → 0.4.0
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/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +389 -30
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Install the resident daemon as a per-user OS service so it auto-starts at login
|
|
2
|
+
// and survives the terminal closing — the difference between "the CLI is running"
|
|
3
|
+
// and "the daemon is reachable from the app even when no CLI is". macOS → launchd
|
|
4
|
+
// user agent; Linux → systemd --user unit. No root: everything lives under the
|
|
5
|
+
// user's own home and login session.
|
|
6
|
+
//
|
|
7
|
+
// ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
|
|
8
|
+
// so the daemon CLI can load it without going through boot.ts.
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, dirname, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { globalDir } from "../config/paths.ts";
|
|
15
|
+
import { daemonIsRunning } from "./ipc.ts";
|
|
16
|
+
|
|
17
|
+
const LABEL = "pro.privateer.daemon"; // launchd label / reverse-dns id
|
|
18
|
+
const UNIT = "privateer-daemon.service"; // systemd --user unit name
|
|
19
|
+
|
|
20
|
+
// Absolute path to the node launcher that boots + runs the daemon (bin/privateer-daemon.mjs).
|
|
21
|
+
// Resolved from THIS module so it's correct for both a dev checkout and a global npm
|
|
22
|
+
// install (…/node_modules/privateer-agent/bin/privateer-daemon.mjs).
|
|
23
|
+
function daemonLauncherPath(): string {
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url)); // …/src/daemon
|
|
25
|
+
return resolve(here, "../../bin/privateer-daemon.mjs");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
|
|
29
|
+
// bash launcher already picked a compatible one) by absolute path, so the service
|
|
30
|
+
// never depends on launchd/systemd having a usable PATH.
|
|
31
|
+
function nodeBinaryPath(): string {
|
|
32
|
+
return process.execPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function daemonLogPath(): string {
|
|
36
|
+
return join(globalDir(), "daemon.log");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Env we forward into the service so a non-default home / server URL survives. Kept
|
|
40
|
+
// tiny and explicit — the daemon reads the rest from ~/.privateer.
|
|
41
|
+
function forwardedEnv(): Record<string, string> {
|
|
42
|
+
const env: Record<string, string> = {};
|
|
43
|
+
if (process.env.PRIVATEER_HOME) env.PRIVATEER_HOME = process.env.PRIVATEER_HOME;
|
|
44
|
+
if (process.env.PRIVATEER_SERVER_URL) env.PRIVATEER_SERVER_URL = process.env.PRIVATEER_SERVER_URL;
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── macOS (launchd) ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function launchAgentPath(): string {
|
|
51
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function xmlEscape(s: string): string {
|
|
55
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function launchdPlist(): string {
|
|
59
|
+
const args = [nodeBinaryPath(), daemonLauncherPath(), "run"];
|
|
60
|
+
const envVars = forwardedEnv();
|
|
61
|
+
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
62
|
+
const envXml = Object.entries(envVars)
|
|
63
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
64
|
+
.join("\n");
|
|
65
|
+
const log = xmlEscape(daemonLogPath());
|
|
66
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
67
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
|
+
<plist version="1.0">
|
|
69
|
+
<dict>
|
|
70
|
+
<key>Label</key>
|
|
71
|
+
<string>${LABEL}</string>
|
|
72
|
+
<key>ProgramArguments</key>
|
|
73
|
+
<array>
|
|
74
|
+
${argXml}
|
|
75
|
+
</array>
|
|
76
|
+
${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
|
|
77
|
+
<true/>
|
|
78
|
+
<key>KeepAlive</key>
|
|
79
|
+
<true/>
|
|
80
|
+
<key>StandardOutPath</key>
|
|
81
|
+
<string>${log}</string>
|
|
82
|
+
<key>StandardErrorPath</key>
|
|
83
|
+
<string>${log}</string>
|
|
84
|
+
</dict>
|
|
85
|
+
</plist>
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function installLaunchd(): void {
|
|
90
|
+
const plist = launchAgentPath();
|
|
91
|
+
mkdirSync(dirname(plist), { recursive: true });
|
|
92
|
+
writeFileSync(plist, launchdPlist());
|
|
93
|
+
// Unload a prior copy (ignore failure — it may not be loaded), then load with -w so
|
|
94
|
+
// it's enabled across reboots.
|
|
95
|
+
spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
|
|
96
|
+
const r = spawnSync("launchctl", ["load", "-w", plist], { encoding: "utf8" });
|
|
97
|
+
if (r.status !== 0) {
|
|
98
|
+
throw new Error(`launchctl load failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function uninstallLaunchd(): void {
|
|
103
|
+
const plist = launchAgentPath();
|
|
104
|
+
if (existsSync(plist)) {
|
|
105
|
+
spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
|
|
106
|
+
rmSync(plist, { force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Linux (systemd --user) ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function systemdUnitPath(): string {
|
|
113
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
114
|
+
return join(base, "systemd", "user", UNIT);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function systemdUnit(): string {
|
|
118
|
+
const exec = [nodeBinaryPath(), daemonLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
|
119
|
+
const envLines = Object.entries(forwardedEnv())
|
|
120
|
+
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
121
|
+
.join("\n");
|
|
122
|
+
return `[Unit]
|
|
123
|
+
Description=Privateer resident agent daemon (routines + app-driven task spawns)
|
|
124
|
+
After=network-online.target
|
|
125
|
+
Wants=network-online.target
|
|
126
|
+
|
|
127
|
+
[Service]
|
|
128
|
+
Type=simple
|
|
129
|
+
ExecStart=${exec}
|
|
130
|
+
Restart=on-failure
|
|
131
|
+
RestartSec=5
|
|
132
|
+
${envLines}
|
|
133
|
+
|
|
134
|
+
[Install]
|
|
135
|
+
WantedBy=default.target
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function installSystemd(): void {
|
|
140
|
+
const unit = systemdUnitPath();
|
|
141
|
+
mkdirSync(dirname(unit), { recursive: true });
|
|
142
|
+
writeFileSync(unit, systemdUnit());
|
|
143
|
+
spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
144
|
+
// enable-linger so the user service keeps running with no active login session —
|
|
145
|
+
// the whole point of "reachable even when no shell is open". Best-effort: it needs
|
|
146
|
+
// no root on most distros, but don't fail the install if it's disallowed.
|
|
147
|
+
spawnSync("loginctl", ["enable-linger", process.env.USER || ""], { stdio: "ignore" });
|
|
148
|
+
const r = spawnSync("systemctl", ["--user", "enable", "--now", UNIT], { encoding: "utf8" });
|
|
149
|
+
if (r.status !== 0) {
|
|
150
|
+
throw new Error(`systemctl enable failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function uninstallSystemd(): void {
|
|
155
|
+
const unit = systemdUnitPath();
|
|
156
|
+
spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
|
|
157
|
+
if (existsSync(unit)) rmSync(unit, { force: true });
|
|
158
|
+
spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Public API ───────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export interface ServiceInfo {
|
|
164
|
+
platform: NodeJS.Platform;
|
|
165
|
+
supported: boolean;
|
|
166
|
+
installed: boolean;
|
|
167
|
+
unitPath: string;
|
|
168
|
+
logPath: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function unitPathFor(platform: NodeJS.Platform): string {
|
|
172
|
+
if (platform === "darwin") return launchAgentPath();
|
|
173
|
+
if (platform === "linux") return systemdUnitPath();
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function serviceInfo(): ServiceInfo {
|
|
178
|
+
const platform = process.platform;
|
|
179
|
+
const unitPath = unitPathFor(platform);
|
|
180
|
+
return {
|
|
181
|
+
platform,
|
|
182
|
+
supported: platform === "darwin" || platform === "linux",
|
|
183
|
+
installed: !!unitPath && existsSync(unitPath),
|
|
184
|
+
unitPath,
|
|
185
|
+
logPath: daemonLogPath(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Install the service for the current platform. Idempotent (rewrites + reloads).
|
|
190
|
+
export function installService(): ServiceInfo {
|
|
191
|
+
const platform = process.platform;
|
|
192
|
+
if (platform === "darwin") installLaunchd();
|
|
193
|
+
else if (platform === "linux") installSystemd();
|
|
194
|
+
else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer daemon\` yourself, or keep a terminal open.`);
|
|
195
|
+
return serviceInfo();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function uninstallService(): ServiceInfo {
|
|
199
|
+
const platform = process.platform;
|
|
200
|
+
if (platform === "darwin") uninstallLaunchd();
|
|
201
|
+
else if (platform === "linux") uninstallSystemd();
|
|
202
|
+
else throw new Error(`No service to remove on ${platform}.`);
|
|
203
|
+
return serviceInfo();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Human-readable status line for `privateer daemon status`: whether the service is
|
|
207
|
+
// installed AND whether a daemon is actually answering on the IPC socket right now.
|
|
208
|
+
export async function statusReport(): Promise<string> {
|
|
209
|
+
const info = serviceInfo();
|
|
210
|
+
const live = await daemonIsRunning();
|
|
211
|
+
const lines = [
|
|
212
|
+
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer daemon` manually)"}`,
|
|
213
|
+
`service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
|
|
214
|
+
`daemon: ${live ? "running (answering IPC)" : "not reachable"}`,
|
|
215
|
+
`logs: ${info.logPath}`,
|
|
216
|
+
];
|
|
217
|
+
// Surface a stale-unit hint: file present but nothing answering usually means it
|
|
218
|
+
// failed to boot — the log path above is where to look.
|
|
219
|
+
if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Best-effort read of the tail of the daemon log (for a `status --log` affordance or
|
|
224
|
+
// error surfacing). Returns "" if absent.
|
|
225
|
+
export function tailDaemonLog(maxBytes = 4_000): string {
|
|
226
|
+
try {
|
|
227
|
+
const buf = readFileSync(daemonLogPath(), "utf8");
|
|
228
|
+
return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
|
|
229
|
+
} catch {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -44,6 +44,14 @@ export interface GateController {
|
|
|
44
44
|
confineToCwd?: boolean;
|
|
45
45
|
getRemote?(): boolean;
|
|
46
46
|
getNoQuarter?(): boolean;
|
|
47
|
+
// Block a tool outright while the turn is remote-driven (only consulted when
|
|
48
|
+
// getRemote() is true). For tools whose own prompts render on the host terminal
|
|
49
|
+
// rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
|
|
50
|
+
// invisible local prompt. Returns true to block. See isRemoteUnsafeTool.
|
|
51
|
+
blockedWhenRemote?(toolName: string): boolean;
|
|
52
|
+
// Notified when blockedWhenRemote blocked a tool, so the controller can surface a
|
|
53
|
+
// one-line reason in the app feed (a `notice` frame) explaining why nothing ran.
|
|
54
|
+
onRemoteBlocked?(toolName: string): void;
|
|
47
55
|
// Local interactive approval via the per-call ctx. Default provided below.
|
|
48
56
|
localAsk(req: PermissionRequest, ctx: ToolCallCtx): Promise<AskOutcome>;
|
|
49
57
|
// Remote (relay) approval. Optional until Phase 4; when absent, a remote turn
|
|
@@ -60,6 +68,23 @@ export interface GateBlock {
|
|
|
60
68
|
reason: string;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
// Tools that CANNOT be driven from the app and so are blocked outright on a
|
|
72
|
+
// remote-driven turn (see chat.ts wiring). pi-subagents runs each subagent as a
|
|
73
|
+
// child session/subprocess whose own permission gate + UI aren't wired to the
|
|
74
|
+
// relay — its approvals and "TUI clarification" prompts surface on the HOST
|
|
75
|
+
// terminal, never in the app. A driven turn that spawned one would wedge on a
|
|
76
|
+
// prompt the phone can't answer. `contact_supervisor`/`intercom` are the child→
|
|
77
|
+
// parent tools; they only exist in child sessions but are listed for safety.
|
|
78
|
+
// Blocking is fail-closed and matches the "remote turns never auto-approve"
|
|
79
|
+
// posture: the feature is simply disabled while driving until its prompts relay.
|
|
80
|
+
export const REMOTE_UNSAFE_TOOLS: ReadonlySet<string> = new Set([
|
|
81
|
+
"subagent",
|
|
82
|
+
"contact_supervisor",
|
|
83
|
+
"intercom",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
export const isRemoteUnsafeTool = (toolName: string): boolean => REMOTE_UNSAFE_TOOLS.has(toolName);
|
|
87
|
+
|
|
63
88
|
// THE decision path. Classify → policy → decision, fail-closed on any error.
|
|
64
89
|
// Returns a block directive to deny, or undefined to let the tool run.
|
|
65
90
|
export async function decideToolCall(
|
|
@@ -68,6 +93,19 @@ export async function decideToolCall(
|
|
|
68
93
|
input: unknown,
|
|
69
94
|
ctx: ToolCallCtx,
|
|
70
95
|
): Promise<GateBlock | undefined> {
|
|
96
|
+
// Remote-driven turn: a few tools can't be driven from the app because their own
|
|
97
|
+
// interactive prompts render on the host terminal, not the relay (pi-subagents
|
|
98
|
+
// spawns child sessions outside the bridge). Block them fail-closed BEFORE any
|
|
99
|
+
// classification so a driven turn never wedges on a prompt the phone can't answer,
|
|
100
|
+
// and tell the controller so it can post a notice to the app feed.
|
|
101
|
+
if (ctrl.getRemote?.() && ctrl.blockedWhenRemote?.(toolName)) {
|
|
102
|
+
ctrl.onRemoteBlocked?.(toolName);
|
|
103
|
+
return {
|
|
104
|
+
block: true,
|
|
105
|
+
reason: `${toolName} is unavailable while this terminal is driven remotely — its prompts can't reach the app. Complete the task without it, or ask the operator to run it from the terminal directly.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
const req = classifyToolCall(toolName, input, {
|
|
72
110
|
cwd: ctrl.cwd,
|
|
73
111
|
confineToCwd: ctrl.confineToCwd,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { resolve, isAbsolute, relative } from "node:path";
|
|
1
|
+
import { resolve, isAbsolute, relative, dirname, join, basename } from "node:path";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import { isProtectedPath } from "./protected.ts";
|
|
3
4
|
import type { PermissionRequest } from "./gate.ts";
|
|
4
5
|
|
|
@@ -18,8 +19,31 @@ export interface ScopeOptions {
|
|
|
18
19
|
allowedOutsideRoots?: string[];
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
|
|
23
|
+
// lexical `resolve()` check (P5-1: `resolve` normalizes `..` but does NOT follow
|
|
24
|
+
// symlinks, so `cwd/link/secret` where `link -> /etc` looks in-cwd lexically). We
|
|
25
|
+
// realpath the DEEPEST EXISTING ancestor — a write target's leaf may not exist yet —
|
|
26
|
+
// and re-append the missing tail; the escape lives in the existing prefix, so
|
|
27
|
+
// canonicalizing that is what matters. Falls back to the lexical path when nothing
|
|
28
|
+
// resolves (e.g. a fully-nonexistent tree, as in unit tests).
|
|
29
|
+
function realBase(abs: string): string {
|
|
30
|
+
let dir = abs;
|
|
31
|
+
const tail: string[] = [];
|
|
32
|
+
for (;;) {
|
|
33
|
+
try {
|
|
34
|
+
const real = realpathSync(dir);
|
|
35
|
+
return tail.length ? join(real, ...tail) : real;
|
|
36
|
+
} catch {
|
|
37
|
+
const parent = dirname(dir);
|
|
38
|
+
if (parent === dir) return abs; // reached the FS root without resolving → lexical
|
|
39
|
+
tail.unshift(basename(dir));
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
function resolveInCwd(cwd: string, p: string): string {
|
|
22
|
-
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
46
|
+
return realBase(isAbsolute(p) ? p : resolve(cwd, p));
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
function isInsideDir(root: string, abs: string): boolean {
|
|
@@ -29,11 +53,14 @@ function isInsideDir(root: string, abs: string): boolean {
|
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
// Outside the agent's working-directory scope? Only when confinement is on and the
|
|
32
|
-
// path is neither inside cwd nor inside a session-approved outside root.
|
|
56
|
+
// path is neither inside cwd nor inside a session-approved outside root. Both sides are
|
|
57
|
+
// symlink-canonicalized (realBase) so a symlinked cwd — or a symlink inside cwd — can't
|
|
58
|
+
// fake containment (P5-1).
|
|
33
59
|
export function isOutsideScope(scope: ScopeOptions, abs: string): boolean {
|
|
34
60
|
if (scope.confineToCwd === false) return false;
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
const target = realBase(abs);
|
|
62
|
+
if (isInsideDir(realBase(scope.cwd), target)) return false;
|
|
63
|
+
return !(scope.allowedOutsideRoots ?? []).some((root) => isInsideDir(realBase(root), target));
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
function str(v: unknown): string {
|
|
@@ -44,6 +71,21 @@ function firstPath(input: Record<string, unknown>): string {
|
|
|
44
71
|
return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
|
|
45
72
|
}
|
|
46
73
|
|
|
74
|
+
// A write/edit tool call whose target path we can't statically extract — an aliased
|
|
75
|
+
// param name, or a patch tool whose target paths live in the DIFF BODY rather than a
|
|
76
|
+
// param (P5-4). Fail safe: mark it outside-scope so it prompts (in default/acceptEdits)
|
|
77
|
+
// instead of defaulting to a silent in-cwd auto-write. Precise patch-body path parsing
|
|
78
|
+
// needs Pi's apply_patch schema — TODO(verify) against the full builtin tool catalog.
|
|
79
|
+
function unknownTarget(toolName: string, kind: "write" | "edit"): PermissionRequest {
|
|
80
|
+
return {
|
|
81
|
+
tool: toolName,
|
|
82
|
+
kind,
|
|
83
|
+
title: kind === "write" ? "Write to an unverified path" : "Edit an unverified path",
|
|
84
|
+
detail: "(target path not statically known — approve to allow)",
|
|
85
|
+
outside: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
47
89
|
// Known-safe read-only / meta builtins that never mutate and never leave the
|
|
48
90
|
// machine: no gate regardless of arguments. Tunable — the conservative default for
|
|
49
91
|
// anything NOT listed here is to ask (see below). TODO(verify) against Pi's full
|
|
@@ -96,6 +138,7 @@ export function classifyToolCall(
|
|
|
96
138
|
// Write — create/overwrite a file.
|
|
97
139
|
if (WRITE_TOOLS.has(name)) {
|
|
98
140
|
const p = firstPath(obj);
|
|
141
|
+
if (!p) return unknownTarget(toolName, "write"); // P5-4: no extractable path → fail safe
|
|
99
142
|
const abs = resolveInCwd(scope.cwd, p);
|
|
100
143
|
const outside = isOutsideScope(scope, abs);
|
|
101
144
|
return {
|
|
@@ -112,6 +155,7 @@ export function classifyToolCall(
|
|
|
112
155
|
// Edit — modify an existing file.
|
|
113
156
|
if (EDIT_TOOLS.has(name)) {
|
|
114
157
|
const p = firstPath(obj);
|
|
158
|
+
if (!p) return unknownTarget(toolName, "edit"); // P5-4: no extractable path → fail safe
|
|
115
159
|
const abs = resolveInCwd(scope.cwd, p);
|
|
116
160
|
const outside = isOutsideScope(scope, abs);
|
|
117
161
|
return {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel management for the app — the sibling of routinesControl.ts, but for the
|
|
3
|
+
* messaging-channel config (Telegram/Slack/Discord/WhatsApp) rather than scheduled
|
|
4
|
+
* routines.
|
|
5
|
+
*
|
|
6
|
+
* Like routines, this is owned by the ALWAYS-ON daemon relay (daemon/index.ts),
|
|
7
|
+
* NOT the channels daemon (channels/run.ts) — which may be down, and which the app
|
|
8
|
+
* must be able to configure before it has ever run. So this control edits the
|
|
9
|
+
* `channels` block of ~/.privateer/config.json; the channels daemon adopts changes
|
|
10
|
+
* on its next RESTART, matching run.ts's deliberate "no in-chat toggle, restart is
|
|
11
|
+
* the fail-safe reset" posture. `running` is a best-effort read of the channels
|
|
12
|
+
* daemon's heartbeat (channels/status.ts), never a dependency.
|
|
13
|
+
*
|
|
14
|
+
* SECRETS: bot tokens are WRITE-ONLY from the app's perspective. list() NEVER
|
|
15
|
+
* returns a token value — it reports `configured`, `running`, and `secretsSet`
|
|
16
|
+
* (which secret fields are present, by name only). save() persists whatever secret
|
|
17
|
+
* VALUES it is handed in `draft.secrets`; the seal/open of those values in transit
|
|
18
|
+
* is the caller's job (Phase 3), so this module only ever deals in the plaintext
|
|
19
|
+
* config.json it already owns.
|
|
20
|
+
*
|
|
21
|
+
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
22
|
+
* frame plumbing and the running-presence read.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { configPath } from "../config/paths.ts";
|
|
26
|
+
|
|
27
|
+
// The platforms channels/run.ts knows how to start. Order is the app's display
|
|
28
|
+
// order. Keep in sync with the `startChannel` calls in run.ts.
|
|
29
|
+
export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
|
|
30
|
+
export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
|
|
31
|
+
|
|
32
|
+
const POSTURES = ["readonly", "approve", "auto"] as const;
|
|
33
|
+
export type ChannelPosture = (typeof POSTURES)[number];
|
|
34
|
+
|
|
35
|
+
// The secret (never-echoed) fields per platform — the union of the token blocks
|
|
36
|
+
// run.ts requires to START each platform. `secretsSet` reports presence of these.
|
|
37
|
+
const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
|
|
38
|
+
telegram: ["botToken"],
|
|
39
|
+
slack: ["appToken", "botToken"],
|
|
40
|
+
discord: ["botToken"],
|
|
41
|
+
whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Non-secret projection of one platform's config, sent to the app. No token
|
|
45
|
+
// values, ever — only which secret fields are already present (`secretsSet`).
|
|
46
|
+
export interface RemoteChannel {
|
|
47
|
+
platform: ChannelPlatform;
|
|
48
|
+
configured: boolean; // a config block exists for this platform
|
|
49
|
+
running: boolean; // the channels daemon is currently serving it
|
|
50
|
+
adminCount: number;
|
|
51
|
+
memberCount: number;
|
|
52
|
+
posture: ChannelPosture;
|
|
53
|
+
tools: string[];
|
|
54
|
+
model?: string;
|
|
55
|
+
secretsSet: string[]; // e.g. ["botToken"] — names only, never values
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// An app-submitted edit. Non-secret fields REPLACE when present; `secrets` maps a
|
|
59
|
+
// secret field name → its (already-opened) value, and only present, non-empty
|
|
60
|
+
// values overwrite — omitted means "keep the existing value".
|
|
61
|
+
export interface ChannelDraft {
|
|
62
|
+
platform: ChannelPlatform;
|
|
63
|
+
admins?: string[];
|
|
64
|
+
members?: string[];
|
|
65
|
+
posture?: ChannelPosture;
|
|
66
|
+
tools?: string[];
|
|
67
|
+
model?: string;
|
|
68
|
+
secrets?: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ChannelsControl {
|
|
72
|
+
// All four platforms, always — configured or not — so the app can show empty
|
|
73
|
+
// slots to set up. Order follows CHANNEL_PLATFORMS.
|
|
74
|
+
list(): RemoteChannel[];
|
|
75
|
+
// Create or edit a platform's config. Validates posture + fail-closes on a block
|
|
76
|
+
// with no admins/members (mirrors run.ts). Returns a one-line result.
|
|
77
|
+
save(draft: ChannelDraft): { ok: boolean; message?: string };
|
|
78
|
+
// Delete a platform's config entirely. ok:false when nothing was configured.
|
|
79
|
+
remove(platform: ChannelPlatform): { ok: boolean; message?: string };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPlatform(v: unknown): v is ChannelPlatform {
|
|
83
|
+
return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizePosture(v: unknown): ChannelPosture | undefined {
|
|
87
|
+
return typeof v === "string" && (POSTURES as readonly string[]).includes(v) ? (v as ChannelPosture) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanStrList(v: unknown): string[] | undefined {
|
|
91
|
+
if (!Array.isArray(v)) return undefined;
|
|
92
|
+
const out = v.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
93
|
+
return out.length > 0 ? out : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function makeChannelsControl(opts: {
|
|
97
|
+
// Which platforms the channels daemon is serving right now (heartbeat read).
|
|
98
|
+
// Absent → everything reports not-running.
|
|
99
|
+
runningPlatforms?: () => Set<string>;
|
|
100
|
+
}): ChannelsControl {
|
|
101
|
+
const running = opts.runningPlatforms ?? (() => new Set<string>());
|
|
102
|
+
|
|
103
|
+
function readCfg(): any {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(configPath(), "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toRemote(platform: ChannelPlatform, block: any, live: Set<string>): RemoteChannel {
|
|
112
|
+
const admins = block?.admins ?? block?.allowFrom ?? []; // legacy allowFrom == admins
|
|
113
|
+
return {
|
|
114
|
+
platform,
|
|
115
|
+
configured: !!block,
|
|
116
|
+
running: live.has(platform),
|
|
117
|
+
adminCount: Array.isArray(admins) ? admins.length : 0,
|
|
118
|
+
memberCount: Array.isArray(block?.members) ? block.members.length : 0,
|
|
119
|
+
posture: normalizePosture(block?.posture) ?? "approve",
|
|
120
|
+
tools: Array.isArray(block?.tools) ? block.tools.map(String) : [],
|
|
121
|
+
model: typeof block?.model === "string" ? block.model : undefined,
|
|
122
|
+
secretsSet: SECRET_FIELDS[platform].filter((f) => block?.[f]),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
list(): RemoteChannel[] {
|
|
128
|
+
const ch = readCfg().channels ?? {};
|
|
129
|
+
const live = running();
|
|
130
|
+
return CHANNEL_PLATFORMS.map((p) => toRemote(p, ch[p], live));
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
save(draft: ChannelDraft): { ok: boolean; message?: string } {
|
|
134
|
+
if (!isPlatform(draft?.platform)) return { ok: false, message: "Unknown platform." };
|
|
135
|
+
if (draft.posture !== undefined && !normalizePosture(draft.posture))
|
|
136
|
+
return { ok: false, message: "Invalid posture." };
|
|
137
|
+
|
|
138
|
+
const cfg = readCfg();
|
|
139
|
+
cfg.channels ??= {};
|
|
140
|
+
const block: any = { ...(cfg.channels[draft.platform] ?? {}) };
|
|
141
|
+
|
|
142
|
+
// Non-secret fields replace when provided. An explicit empty array clears.
|
|
143
|
+
const admins = cleanStrList(draft.admins);
|
|
144
|
+
if (admins !== undefined) block.admins = admins;
|
|
145
|
+
const members = cleanStrList(draft.members);
|
|
146
|
+
if (members !== undefined) block.members = members;
|
|
147
|
+
if (draft.posture !== undefined) block.posture = draft.posture;
|
|
148
|
+
const tools = cleanStrList(draft.tools);
|
|
149
|
+
if (tools !== undefined) block.tools = tools;
|
|
150
|
+
if (draft.model !== undefined) {
|
|
151
|
+
const m = String(draft.model).trim();
|
|
152
|
+
if (m) block.model = m;
|
|
153
|
+
else delete block.model;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Secrets: a present, non-empty value overwrites; omitted keeps the existing.
|
|
157
|
+
for (const [k, v] of Object.entries(draft.secrets ?? {})) {
|
|
158
|
+
if (!SECRET_FIELDS[draft.platform].includes(k)) continue;
|
|
159
|
+
const val = String(v ?? "").trim();
|
|
160
|
+
if (val) block[k] = val;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Fail-closed: never persist a block that can't authorize anyone (mirrors
|
|
164
|
+
// run.ts:261 — a channel with no admins/members is skipped anyway).
|
|
165
|
+
const hasAdmins = Array.isArray(block.admins) && block.admins.length > 0;
|
|
166
|
+
const hasMembers = Array.isArray(block.members) && block.members.length > 0;
|
|
167
|
+
if (!hasAdmins && !hasMembers)
|
|
168
|
+
return { ok: false, message: "Add at least one admin before saving." };
|
|
169
|
+
|
|
170
|
+
cfg.channels[draft.platform] = block;
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, message: `Saved ${draft.platform}. Restart the channels daemon to apply.` };
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
remove(platform: ChannelPlatform): { ok: boolean; message?: string } {
|
|
180
|
+
if (!isPlatform(platform)) return { ok: false, message: "Unknown platform." };
|
|
181
|
+
const cfg = readCfg();
|
|
182
|
+
if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
|
|
183
|
+
delete cfg.channels[platform];
|
|
184
|
+
try {
|
|
185
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, message: `Removed ${platform}. Restart the channels daemon to apply.` };
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Shared fail-closed gate for signed app->terminal control frames (H2).
|
|
2
|
+
//
|
|
3
|
+
// Every mutating control frame the app sends over the (untrusted) relay --
|
|
4
|
+
// routines_save/delete/set_enabled/run, extensions_add/remove,
|
|
5
|
+
// skills_create/delete/set_enabled, channels_remove -- is signed by the account and
|
|
6
|
+
// MUST be verified before it takes effect, or a malicious server could forge it (a
|
|
7
|
+
// forged routine yields a headless-bypass session = RCE; a forged extensions_add
|
|
8
|
+
// installs an npm package = RCE; a forged skills_create injects an auto-invoked
|
|
9
|
+
// skill). channels_save has its own bespoke verify (it also carries sealed secrets);
|
|
10
|
+
// everything else routes through here.
|
|
11
|
+
//
|
|
12
|
+
// Both loci call this: the daemon (routines_*/channels_remove, termId = routineRelayId)
|
|
13
|
+
// and each interactive terminal (extensions_*/skills_*, termId = the relay's id).
|
|
14
|
+
//
|
|
15
|
+
// Fail-closed: no pinned account key, a missing signature, a bad signature, or a stale
|
|
16
|
+
// ts all reject the mutation. On success the per-terminal replay watermark advances.
|
|
17
|
+
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
18
|
+
import { verifyControl } from "../crypto/accountVerify.ts";
|
|
19
|
+
|
|
20
|
+
export interface ControlAuthResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
message?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Authorize a mutating control frame against the pinned account key + replay watermark.
|
|
27
|
+
* `termId` is THIS terminal's id (verification binds it, so a signature for another
|
|
28
|
+
* terminal won't match). Returns { ok:true } to proceed, or { ok:false, message } to
|
|
29
|
+
* refuse — the caller surfaces the message and does NOT perform the mutation.
|
|
30
|
+
*
|
|
31
|
+
* `opts.strict` (default false) governs the watermark comparison:
|
|
32
|
+
* - non-strict: reject ts BELOW the watermark; ACCEPT at-or-above. Correct for
|
|
33
|
+
* idempotent config mutations (routines/skills/extensions/channels save|delete),
|
|
34
|
+
* where replaying the latest signed frame just re-applies the same state — harmless.
|
|
35
|
+
* - strict: reject ts AT-or-below the watermark. Required for NON-idempotent, effectful
|
|
36
|
+
* actions (task_submit/task_spawn — each RUNS a headless session), where a malicious
|
|
37
|
+
* relay replaying the latest signed frame (same ts) would re-run the task / spawn
|
|
38
|
+
* another session (inference-cost + resource abuse). Strict forces every accepted
|
|
39
|
+
* effectful frame to carry a strictly-fresh ts, which the server cannot fabricate (it
|
|
40
|
+
* can't sign) — so it can only replay old frames, all of which are now refused.
|
|
41
|
+
*/
|
|
42
|
+
export function authorizeControl(
|
|
43
|
+
termId: string,
|
|
44
|
+
action: string,
|
|
45
|
+
args: Record<string, unknown>,
|
|
46
|
+
sig?: string,
|
|
47
|
+
ts?: number,
|
|
48
|
+
opts?: { strict?: boolean },
|
|
49
|
+
): ControlAuthResult {
|
|
50
|
+
const accountPub = loadAccountSignKey();
|
|
51
|
+
if (!accountPub) {
|
|
52
|
+
return { ok: false, message: "This terminal can't accept changes from the app yet — re-link it to establish trust." };
|
|
53
|
+
}
|
|
54
|
+
if (!sig || typeof ts !== "number") {
|
|
55
|
+
return { ok: false, message: "Refused an unsigned change from the app." };
|
|
56
|
+
}
|
|
57
|
+
if (!verifyControl(accountPub, { termId, ts, action, args }, sig)) {
|
|
58
|
+
return { ok: false, message: "Couldn't verify this change came from your account." };
|
|
59
|
+
}
|
|
60
|
+
const last = loadLastControlTs(termId);
|
|
61
|
+
const tooOld = opts?.strict ? ts <= last : ts < last;
|
|
62
|
+
if (tooOld) {
|
|
63
|
+
return { ok: false, message: "Ignored an out-of-date change from the app." };
|
|
64
|
+
}
|
|
65
|
+
saveLastControlTs(termId, ts);
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|