privateer-agent 0.3.6 → 0.4.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/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +65 -24
- 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 +384 -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 +395 -32
- 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 +516 -48
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/providers/account.ts +7 -1
- package/src/providers/defaultModel.ts +119 -0
- 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 {
|
package/src/providers/account.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
notifySignedIn,
|
|
21
21
|
} from "../auth/privateer.ts";
|
|
22
22
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
23
|
+
import { ACCOUNT_DEFAULT_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
23
24
|
|
|
24
25
|
// Seed/fallback catalog: registered synchronously so the account provider has real
|
|
25
26
|
// models the instant it loads (before the live /api/models fetch resolves) — in
|
|
@@ -28,7 +29,7 @@ import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } fro
|
|
|
28
29
|
// confidential-compute (TEE, attestable) model — the strongest privacy tier. Also the
|
|
29
30
|
// fallback list if the live listing can't be reached.
|
|
30
31
|
const DEFAULT_MODELS = [
|
|
31
|
-
|
|
32
|
+
ACCOUNT_DEFAULT_MODEL_ID,
|
|
32
33
|
"anthropic/claude-sonnet-4.6",
|
|
33
34
|
"openai/gpt-5.5",
|
|
34
35
|
"deepseek/deepseek-v4-flash",
|
|
@@ -101,6 +102,11 @@ export const privateerOAuthProvider = {
|
|
|
101
102
|
}
|
|
102
103
|
if (cb.signal?.aborted) throw new Error("Login cancelled");
|
|
103
104
|
const creds = await spawnAccountCredentials();
|
|
105
|
+
// Seed Pi's saved model default to the account channel, so the next launch resolves
|
|
106
|
+
// to a billable subscription model instead of falling through to a keyless built-in
|
|
107
|
+
// (the "No API key found for openrouter" trap). No-op if the user already has a
|
|
108
|
+
// chosen default. See providers/defaultModel.ts.
|
|
109
|
+
ensurePiDefaultModel();
|
|
104
110
|
// The fresh path already fired notifySignedIn (pollForToken); fire here for the
|
|
105
111
|
// already-linked path so the header re-renders to "connected" on this terminal too.
|
|
106
112
|
if (wasLinked) notifySignedIn();
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The single source of truth for "which model do we default to?" — shared by every
|
|
2
|
+
// entry point that has to pick a model when the user hasn't named one: the REPL
|
|
3
|
+
// (cli/chat.ts), the daemon (routines), the channels runner, and the login-time hook
|
|
4
|
+
// that seeds Pi's TUI default (ensurePiDefaultModel).
|
|
5
|
+
//
|
|
6
|
+
// The bug this fixes: each of those sites used to hardcode `openrouter/openai/gpt-4o-
|
|
7
|
+
// mini`, which assumes a BYO OpenRouter key. A user who is ONLY signed into their
|
|
8
|
+
// Privateer subscription has no such key, so the runtime resolved to OpenRouter and
|
|
9
|
+
// then failed at request time with "No API key found for openrouter". Being signed in
|
|
10
|
+
// never nominated a model. resolveDefaultModel() makes the account channel the default
|
|
11
|
+
// the moment credentials exist, and keeps the legacy BYO behaviour otherwise.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
16
|
+
import { agentDir } from "../config/paths.ts";
|
|
17
|
+
|
|
18
|
+
// The signed-in default: a NEAR confidential-compute (TEE, attestable) model — the
|
|
19
|
+
// strongest privacy tier, and the same id the app shows first. Kept here as the one
|
|
20
|
+
// definition; providers/account.ts imports it so its seed catalog can't drift.
|
|
21
|
+
export const ACCOUNT_DEFAULT_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
|
|
22
|
+
export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
|
|
23
|
+
|
|
24
|
+
// Last-resort BYO default, preserved from the pre-resolver code so a user who set an
|
|
25
|
+
// OpenRouter key (and isn't signed in) keeps the old behaviour. If they have no key
|
|
26
|
+
// either, this still surfaces the familiar "No API key found for openrouter" — a clear
|
|
27
|
+
// signal to run /login or set a key, which is better than an empty/undefined model.
|
|
28
|
+
export const LEGACY_BYO_FALLBACK = "openrouter/openai/gpt-4o-mini";
|
|
29
|
+
|
|
30
|
+
// BYO providers we can positively detect from the environment, in preference order.
|
|
31
|
+
// Each model id matches Pi's own defaultModelPerProvider so it actually resolves once
|
|
32
|
+
// the key is present. OpenRouter stays on the legacy cheap default for continuity.
|
|
33
|
+
const BYO_BY_KEY: Array<{ env: string; spec: string }> = [
|
|
34
|
+
{ env: "ANTHROPIC_API_KEY", spec: "anthropic/claude-opus-4-8" },
|
|
35
|
+
{ env: "OPENAI_API_KEY", spec: "openai/gpt-5.5" },
|
|
36
|
+
{ env: "OPENROUTER_API_KEY", spec: LEGACY_BYO_FALLBACK },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export interface ResolveDefaultModelOptions {
|
|
40
|
+
// An explicit, user-chosen spec (e.g. config.defaultModel, a channel's `model`).
|
|
41
|
+
// Wins over everything when non-empty — it's a deliberate choice, not a fallback.
|
|
42
|
+
explicit?: string | null;
|
|
43
|
+
// Override for testing / non-process callers. Defaults to process.env.
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
// Override the signed-in check (testing). Defaults to hasCredentials().
|
|
46
|
+
signedIn?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve the model spec ("provider/id") to use when no model is named. Pure and
|
|
50
|
+
// synchronous (only reads env + the credentials file), so it's safe to call from any
|
|
51
|
+
// entry point at startup. Precedence:
|
|
52
|
+
// 1. explicit user choice (config/channel) — deliberate, always wins
|
|
53
|
+
// 2. PRIVATEER_MODEL env — dev/global override
|
|
54
|
+
// 3. signed into Privateer → the account default — the fix: subscription users
|
|
55
|
+
// 4. a BYO provider whose key is present — anthropic, openai, openrouter
|
|
56
|
+
// 5. LEGACY_BYO_FALLBACK — familiar "add a key" signal
|
|
57
|
+
export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
|
|
58
|
+
const env = opts.env ?? process.env;
|
|
59
|
+
|
|
60
|
+
const explicit = opts.explicit?.trim();
|
|
61
|
+
if (explicit) return explicit;
|
|
62
|
+
|
|
63
|
+
const fromEnv = env.PRIVATEER_MODEL?.trim();
|
|
64
|
+
if (fromEnv) return fromEnv;
|
|
65
|
+
|
|
66
|
+
const signedIn = opts.signedIn ?? hasCredentials();
|
|
67
|
+
if (signedIn) return ACCOUNT_DEFAULT_SPEC;
|
|
68
|
+
|
|
69
|
+
for (const { env: keyName, spec } of BYO_BY_KEY) {
|
|
70
|
+
if (env[keyName]?.trim()) return spec;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return LEGACY_BYO_FALLBACK;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
|
|
77
|
+
// only the first delimiter separates provider from model). Returns null for a spec
|
|
78
|
+
// with no provider prefix.
|
|
79
|
+
function splitSpec(spec: string): { provider: string; modelId: string } | null {
|
|
80
|
+
const slash = spec.indexOf("/");
|
|
81
|
+
if (slash <= 0 || slash === spec.length - 1) return null;
|
|
82
|
+
return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The TUI consumer. Pi's own model resolution (findInitialModel) checks its saved
|
|
86
|
+
// settings default BEFORE it falls through to a keyless built-in, but nothing ever
|
|
87
|
+
// pointed that default at the account channel — Pi's provider-default table has no
|
|
88
|
+
// `privateer` entry, so a signed-in-only user landed on OpenRouter and errored. On a
|
|
89
|
+
// successful login we seed Pi's global settings.json (agentDir/settings.json — the
|
|
90
|
+
// same file its SettingsManager reads) with the account default, so the NEXT launch
|
|
91
|
+
// resolves cleanly.
|
|
92
|
+
//
|
|
93
|
+
// Guarded: we only write when the user has NOT already chosen a default (no
|
|
94
|
+
// `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
|
|
95
|
+
// any read/parse/write failure is swallowed; a missing seed just means the user picks
|
|
96
|
+
// a model once via /model. Returns the spec written, or null if we left it alone.
|
|
97
|
+
export function ensurePiDefaultModel(spec: string = ACCOUNT_DEFAULT_SPEC): string | null {
|
|
98
|
+
const parts = splitSpec(spec);
|
|
99
|
+
if (!parts) return null;
|
|
100
|
+
const settingsPath = join(agentDir(), "settings.json");
|
|
101
|
+
try {
|
|
102
|
+
let settings: Record<string, unknown> = {};
|
|
103
|
+
if (existsSync(settingsPath)) {
|
|
104
|
+
const raw = readFileSync(settingsPath, "utf8").trim();
|
|
105
|
+
if (raw) settings = JSON.parse(raw) as Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
// Respect an existing choice — presence of the key means the user (or Pi) already
|
|
108
|
+
// has a default; don't override it.
|
|
109
|
+
if (typeof settings.defaultModel === "string" && settings.defaultModel.trim()) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
settings.defaultProvider = parts.provider;
|
|
113
|
+
settings.defaultModel = parts.modelId;
|
|
114
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
115
|
+
return spec;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|