privateer-agent 0.6.3 → 0.6.6
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 +35 -0
- package/SECURITY.md +54 -0
- package/bin/apply-patches.d.mts +18 -0
- package/bin/apply-patches.mjs +143 -0
- package/bin/privateer-launch.mjs +151 -10
- package/extensions/privateer-gate.ts +4 -0
- package/package.json +8 -3
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -0
- package/skills/resolve-dependencies/SKILL.md +103 -0
- package/src/cli/chat.ts +2 -0
- package/src/config/hosted.ts +36 -0
- package/src/daemon/index.ts +75 -7
- package/src/ext/permissionGate.ts +13 -3
- package/src/permissions/modeGate.ts +14 -0
- package/src/remote/mcpControl.ts +268 -0
- package/src/remote/relayClient.ts +78 -0
- package/src/remote/remoteBridge.ts +7 -0
- package/src/routines/schema.ts +9 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "resolve-dependencies"
|
|
3
|
+
description: "Resolve a missing system binary/CLI tool or language package (npm/pip/cargo/go/gem) in the current environment. Use when a command fails with 'command not found', a build/test fails on a missing tool, or code fails on an unresolved import/module. Detects the right package manager, prefers a user-writable install, and runs the concrete install command through the permission gate."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resolving missing dependencies
|
|
7
|
+
|
|
8
|
+
When work is blocked because a **system tool** (e.g. `ripgrep`, `jq`, `cmake`) or a
|
|
9
|
+
**language package** (e.g. `zod`, `requests`, `serde`) is not installed, resolve it
|
|
10
|
+
deliberately. You are running on the user's own machine — installs are real and
|
|
11
|
+
persist — so pick the smallest, least-privileged command that unblocks the task and
|
|
12
|
+
let the user approve it.
|
|
13
|
+
|
|
14
|
+
Never install silently to "just make it work." Confirm what's missing, choose the
|
|
15
|
+
narrowest install, and run it through the normal `bash` tool so the permission gate
|
|
16
|
+
shows the user the exact command. Do not pipe remote scripts into a shell
|
|
17
|
+
(`curl … | sh`) — the gate flags that as dangerous and it is rarely the right way to
|
|
18
|
+
get a dependency.
|
|
19
|
+
|
|
20
|
+
## 1. Confirm it's actually missing
|
|
21
|
+
|
|
22
|
+
Before installing anything, verify the gap:
|
|
23
|
+
|
|
24
|
+
- System tool: `command -v <tool>` (or `which <tool>`). Empty output → missing.
|
|
25
|
+
- Language package: check the project manifest first (`package.json`,
|
|
26
|
+
`requirements.txt` / `pyproject.toml`, `Cargo.toml`, `go.mod`, `Gemfile`). If the
|
|
27
|
+
package is already declared, the fix is usually **install project deps**
|
|
28
|
+
(`npm install`, `pip install -r requirements.txt`), not adding a new one.
|
|
29
|
+
|
|
30
|
+
If a tool is missing only for this one command, consider whether an already-present
|
|
31
|
+
alternative works (e.g. `rg` → `grep -r`, `jq` → a small `node`/`python` one-liner)
|
|
32
|
+
before installing.
|
|
33
|
+
|
|
34
|
+
## 2. Detect the right package manager
|
|
35
|
+
|
|
36
|
+
**Language packages** — pick from the manifest that exists in the project (do not
|
|
37
|
+
guess the ecosystem):
|
|
38
|
+
|
|
39
|
+
| Manifest present | Add a package | Install declared deps |
|
|
40
|
+
| --------------------------- | ------------------------------------------- | -------------------------------- |
|
|
41
|
+
| `package.json` | `npm install <pkg>` (or `pnpm add`/`yarn add` if that lockfile is present) | `npm install` |
|
|
42
|
+
| `requirements.txt` | `pip install <pkg>` | `pip install -r requirements.txt`|
|
|
43
|
+
| `pyproject.toml` (Poetry) | `poetry add <pkg>` | `poetry install` |
|
|
44
|
+
| `pyproject.toml` (uv) | `uv add <pkg>` | `uv sync` |
|
|
45
|
+
| `Cargo.toml` | `cargo add <pkg>` | `cargo build` |
|
|
46
|
+
| `go.mod` | `go get <pkg>` | `go mod download` |
|
|
47
|
+
| `Gemfile` | `bundle add <pkg>` | `bundle install` |
|
|
48
|
+
|
|
49
|
+
Match the lockfile, not just the manifest: `pnpm-lock.yaml` → use `pnpm`,
|
|
50
|
+
`yarn.lock` → `yarn`, `bun.lockb` → `bun`. Adding a package edits the manifest —
|
|
51
|
+
that's usually wanted, but say so if the user only asked to run something.
|
|
52
|
+
|
|
53
|
+
**System binaries** — detect the OS package manager by probing, in order, and use the
|
|
54
|
+
first one available:
|
|
55
|
+
|
|
56
|
+
| `command -v` hit | Install command | Notes |
|
|
57
|
+
| ---------------- | ---------------------------------- | ---------------------------------- |
|
|
58
|
+
| `brew` | `brew install <pkg>` | macOS/Linuxbrew — no sudo, preferred |
|
|
59
|
+
| `apt-get` | `sudo apt-get install -y <pkg>` | Debian/Ubuntu; run `apt-get update` first if the install 404s |
|
|
60
|
+
| `dnf` / `yum` | `sudo dnf install -y <pkg>` | Fedora/RHEL |
|
|
61
|
+
| `apk` | `apk add <pkg>` (`sudo` if needed) | Alpine |
|
|
62
|
+
| `pacman` | `sudo pacman -S --noconfirm <pkg>` | Arch |
|
|
63
|
+
| `nix-env` | `nix-env -iA nixpkgs.<pkg>` | Nix — no sudo |
|
|
64
|
+
| `zypper` | `sudo zypper install -y <pkg>` | openSUSE |
|
|
65
|
+
|
|
66
|
+
The package name is not always the command name (e.g. the `fd` command ships as
|
|
67
|
+
`fd-find` on apt, `fd` on brew). If unsure of the exact package name, use the
|
|
68
|
+
`web_search` / `web_fetch` tools to confirm it for the detected package manager
|
|
69
|
+
before running the install.
|
|
70
|
+
|
|
71
|
+
## 3. Prefer the least-privileged install
|
|
72
|
+
|
|
73
|
+
- Reach for a **no-sudo** manager first (`brew`, `nix`, language package managers)
|
|
74
|
+
before a system one that needs `sudo`.
|
|
75
|
+
- For language packages outside a project, prefer a **user or isolated install**
|
|
76
|
+
over a global one:
|
|
77
|
+
- Python: a virtualenv (`python -m venv .venv && . .venv/bin/activate`) or
|
|
78
|
+
`pip install --user <pkg>` rather than a system-wide `sudo pip`.
|
|
79
|
+
- Node CLIs you only need to run once: `npx <pkg>` instead of `npm install -g`.
|
|
80
|
+
- Only escalate to `sudo` / system-wide when there is no user-writable option and the
|
|
81
|
+
task genuinely needs it. Explain why in the same message so the user's approval is
|
|
82
|
+
informed.
|
|
83
|
+
|
|
84
|
+
## 4. Run it and verify
|
|
85
|
+
|
|
86
|
+
Run the chosen command via the `bash` tool (never fabricate success). The permission
|
|
87
|
+
gate will surface the exact command to the user — that is intended, so keep the
|
|
88
|
+
command to the single thing you need.
|
|
89
|
+
|
|
90
|
+
After it completes:
|
|
91
|
+
|
|
92
|
+
1. Re-check availability (`command -v <tool>`, or re-run the failing import/build).
|
|
93
|
+
2. Retry the original task.
|
|
94
|
+
3. If the install was denied or failed, **stop and tell the user** what's missing and
|
|
95
|
+
the command you would run — do not loop on variants of the same install.
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
- If the environment looks locked-down (read-only filesystem, no package manager
|
|
100
|
+
found, no network), say so plainly and ask how the user wants to proceed rather than
|
|
101
|
+
hunting for a workaround.
|
|
102
|
+
- One dependency at a time when debugging a broken environment — install, verify,
|
|
103
|
+
then move on — so a failure is easy to attribute.
|
package/src/cli/chat.ts
CHANGED
|
@@ -281,6 +281,8 @@ async function main() {
|
|
|
281
281
|
},
|
|
282
282
|
getRemote: bridge.getRemote,
|
|
283
283
|
getNoQuarter: bridge.getNoQuarter,
|
|
284
|
+
// `--no-quarter` at launch (env PRIVATEER_NO_QUARTER) → total gate bypass, no prompts.
|
|
285
|
+
getSkipAllPermissions: () => process.env.PRIVATEER_NO_QUARTER === "1",
|
|
284
286
|
remoteAsk: bridge.remoteAsk,
|
|
285
287
|
// Subagents (and their child-only intercom tools) can't be driven from the app
|
|
286
288
|
// yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
|
package/src/config/hosted.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { globalDir } from "./paths.ts";
|
|
4
|
+
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
5
|
+
|
|
1
6
|
// Harbor hosted mode.
|
|
2
7
|
//
|
|
3
8
|
// When true, this daemon is running inside Privateer's confidential-VM fleet
|
|
@@ -11,3 +16,34 @@
|
|
|
11
16
|
export function isHosted(): boolean {
|
|
12
17
|
return process.env.HARBOR_HOSTED === "1";
|
|
13
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Publish this daemon's relay identity key so the Harbor host can attest it.
|
|
22
|
+
*
|
|
23
|
+
* ATTESTATION CONTRACT (host side: treeview `server/services/harborOrchestrator/`):
|
|
24
|
+
* the orchestrator mints the SEV-SNP report on the CVM host — configfs-tsm is a
|
|
25
|
+
* privileged kernel interface a rootless tenant deliberately cannot reach — and binds
|
|
26
|
+
* `report_data[0:32] = sha256(DER-SPKI(terminalPub))`. To do that it needs OUR public
|
|
27
|
+
* key, so we drop it in `$PRIVATEER_HOME` (bind-mounted from host tmpfs) as the mirror
|
|
28
|
+
* of the `routines/relay-id` file the host seeds for us.
|
|
29
|
+
*
|
|
30
|
+
* It must be the key the app ACTUALLY drives over the relay — the same value we send
|
|
31
|
+
* in sendContext({ terminalPub }) — otherwise the app's fail-closed check reports a
|
|
32
|
+
* key mismatch. Base64 of the raw 32 X25519 bytes; the host wraps it in the SPKI DER
|
|
33
|
+
* prefix itself. Minting happens on first call, which is fine: this runs at boot,
|
|
34
|
+
* before the relay registers.
|
|
35
|
+
*
|
|
36
|
+
* Hosted-only and best-effort: on a user's own machine this is a no-op, and a write
|
|
37
|
+
* failure must never take the daemon down — attestation simply fail-closes host-side
|
|
38
|
+
* with HARBOR_ATTEST_NO_KEY rather than reporting a false "attested".
|
|
39
|
+
*/
|
|
40
|
+
export function publishRelayPub(): void {
|
|
41
|
+
if (!isHosted()) return;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(join(globalDir(), "relay-pub"), terminalPublicKeyBase64(), { mode: 0o600 });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(
|
|
46
|
+
`[harbor] could not publish relay-pub — enclave attestation will fail closed: ${String(err)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
|
20
20
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
21
21
|
import { makeRoutinesControl } from "../remote/routinesControl.ts";
|
|
22
22
|
import { makeChannelsControl } from "../remote/channelsControl.ts";
|
|
23
|
+
import { makeMcpControl } from "../remote/mcpControl.ts";
|
|
23
24
|
import { makeWorkflowsControl } from "../remote/workflowsControl.ts";
|
|
24
25
|
import { runWorkflow as executeWorkflow, type RunnerDeps, type AgentRunSpec, type AgentRunResult, type ScriptRunResult } from "../workflows/runner.ts";
|
|
25
26
|
import type { Workflow, Step } from "../workflows/schema.ts";
|
|
@@ -50,7 +51,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
50
51
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
51
52
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
52
53
|
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
-
import { isHosted } from "../config/hosted.ts";
|
|
54
|
+
import { isHosted, publishRelayPub } from "../config/hosted.ts";
|
|
54
55
|
|
|
55
56
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
56
57
|
// write/edit/bash, so a routine firing with nobody watching can't mutate the
|
|
@@ -190,6 +191,13 @@ export class Daemon {
|
|
|
190
191
|
runningPlatforms: () => readRunningPlatforms(),
|
|
191
192
|
});
|
|
192
193
|
|
|
194
|
+
// App-facing MCP connector management (list/save/set_enabled/remove) over the
|
|
195
|
+
// daemon's relay — the daemon is the Node HOST that actually runs the adapter (a
|
|
196
|
+
// phone/web client can't). Edits the SHARED agent/mcp-desktop.json + mcp.json, so a
|
|
197
|
+
// machine has one MCP config whether it was set from the desktop (IPC) or the phone
|
|
198
|
+
// (relay). Tokens ride in a SEALED box (applyMcpSave) — the relay never sees them.
|
|
199
|
+
private readonly mcp = makeMcpControl();
|
|
200
|
+
|
|
193
201
|
// App-facing workflow management (list/get/save/remove/run) over the daemon's relay.
|
|
194
202
|
// Run-now is injected here since only the daemon owns the runner + its seams. A
|
|
195
203
|
// workflow can carry a `script` step (RCE if forged), so every mutation is
|
|
@@ -219,6 +227,9 @@ export class Daemon {
|
|
|
219
227
|
};
|
|
220
228
|
|
|
221
229
|
start(): void {
|
|
230
|
+
// Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
|
|
231
|
+
// report. Before syncRelay() so the key exists by the time we're reachable.
|
|
232
|
+
publishRelayPub();
|
|
222
233
|
this.primeSchedule();
|
|
223
234
|
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
224
235
|
this.server = startIpcServer((req) => this.handleIpc(req));
|
|
@@ -287,6 +298,14 @@ export class Daemon {
|
|
|
287
298
|
onChannelsList: () => this.pushChannels(),
|
|
288
299
|
onChannelsSave: (draft, sealedSecrets, sig, ts) => this.pushChannels(this.applyChannelSave(draft, sealedSecrets, sig, ts)),
|
|
289
300
|
onChannelsRemove: (platform, sig, ts) => this.pushChannels(this.guardControl("channels_remove", { platform }, sig, ts, () => this.channels.remove(platform as any).message)),
|
|
301
|
+
// MCP connector management from the app. `save` has its own signed verify (it
|
|
302
|
+
// carries a sealed env box — applyMcpSave); `set_enabled`/`remove` are
|
|
303
|
+
// account-signed here (H2 — a forged toggle arms/disarms a tool surface; a
|
|
304
|
+
// forged removal is a DoS). Then mcpControl writes the shared config + re-pushes.
|
|
305
|
+
onMcpList: () => this.pushMcp(),
|
|
306
|
+
onMcpSave: (draft, sealedSecrets, sig, ts) => this.pushMcp(this.applyMcpSave(draft, sealedSecrets, sig, ts)),
|
|
307
|
+
onMcpSetEnabled: (name, enabled, sig, ts) => this.pushMcp(this.guardControl("mcp_set_enabled", { name, enabled }, sig, ts, () => this.mcp.setEnabled(name, enabled).message)),
|
|
308
|
+
onMcpRemove: (name, sig, ts) => this.pushMcp(this.guardControl("mcp_remove", { name }, sig, ts, () => this.mcp.remove(name).message)),
|
|
290
309
|
// Workflow management from the app. Each MUTATION is account-signed (H2) — a forged
|
|
291
310
|
// workflows_save plants a `script` step that bypasses the permission gate (RCE),
|
|
292
311
|
// and workflows_run executes the graph — so all three are verified (guardControl,
|
|
@@ -340,6 +359,42 @@ export class Daemon {
|
|
|
340
359
|
this.relay?.sendChannels({ items: this.channels.list(), message });
|
|
341
360
|
}
|
|
342
361
|
|
|
362
|
+
private pushMcp(message?: string): void {
|
|
363
|
+
this.relay?.sendMcp({ items: this.mcp.list(), message });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Verify an account-signed MCP save (H2) that also carries a SEALED env box, then
|
|
367
|
+
// apply it. Same shape as applyChannelSave but routed through the generic signed
|
|
368
|
+
// envelope (action "mcp_save", args {draft, sealedSecrets}) — the action tag stops a
|
|
369
|
+
// signature made for any other frame from being replayed as an MCP save. Fail-closed:
|
|
370
|
+
// an unsigned/forged/stale frame returns the refusal message and NOTHING is written.
|
|
371
|
+
// The sealed box opens to { termId, env } — a token the relay never sees in the clear.
|
|
372
|
+
private applyMcpSave(draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number): string | undefined {
|
|
373
|
+
const auth = authorizeControl(
|
|
374
|
+
routineRelayId(),
|
|
375
|
+
"mcp_save",
|
|
376
|
+
{ draft, sealedSecrets: sealedSecrets ?? null },
|
|
377
|
+
sig,
|
|
378
|
+
ts,
|
|
379
|
+
);
|
|
380
|
+
if (!auth.ok) return auth.message;
|
|
381
|
+
|
|
382
|
+
let withEnv = draft;
|
|
383
|
+
if (sealedSecrets) {
|
|
384
|
+
let opened: { termId?: string; env?: Record<string, string> };
|
|
385
|
+
try {
|
|
386
|
+
opened = openJsonFromApp(sealedSecrets);
|
|
387
|
+
} catch {
|
|
388
|
+
return "Couldn't decrypt the connector credentials — they may have been sealed to a different terminal.";
|
|
389
|
+
}
|
|
390
|
+
if (opened.termId !== routineRelayId()) {
|
|
391
|
+
return "These credentials were addressed to a different terminal.";
|
|
392
|
+
}
|
|
393
|
+
withEnv = { ...draft, env: opened.env ?? {} };
|
|
394
|
+
}
|
|
395
|
+
return this.mcp.save(withEnv as any).message;
|
|
396
|
+
}
|
|
397
|
+
|
|
343
398
|
// Push the current workflow summaries to an attached controller (its workflows
|
|
344
399
|
// manager). `message` is a one-line result from the last mutation, if any.
|
|
345
400
|
private pushWorkflows(message?: string): void {
|
|
@@ -600,9 +655,14 @@ export class Daemon {
|
|
|
600
655
|
const config = loadDaemonConfig();
|
|
601
656
|
const modelSpec = routine.model ?? config.defaultModel;
|
|
602
657
|
const split = splitRoutineTools(routine.tools);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
658
|
+
// MCP tools (server__tool) join the allow-list: the mcpAdapter loaded in runSession
|
|
659
|
+
// registers them from the shared mcp.json, and the routine's SIGNED tool list is the
|
|
660
|
+
// authorization boundary under the bypass gate (same as builtin tools). An http/OAuth
|
|
661
|
+
// connector that never completed its browser flow simply errors at call time.
|
|
662
|
+
const builtinAllow = split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
663
|
+
const allowedTools = [...builtinAllow, ...split.mcp];
|
|
664
|
+
if (routine.delivery.includes("email")) {
|
|
665
|
+
log(" note: email delivery is not wired yet (Phase 5) — skipping it");
|
|
606
666
|
}
|
|
607
667
|
|
|
608
668
|
const { out, status, error } = await this.runSession({
|
|
@@ -655,11 +715,19 @@ export class Daemon {
|
|
|
655
715
|
return "deny";
|
|
656
716
|
},
|
|
657
717
|
};
|
|
718
|
+
// MCP adapter (Phase 5): registers the tools from the shared agent/mcp.json — the
|
|
719
|
+
// same projection the app's MCP manager (mcpControl) writes over the relay. No
|
|
720
|
+
// servers configured → a no-op. Dynamically imported so it loads only when a
|
|
721
|
+
// session actually runs (Pi is already booted by here). The specifier is a
|
|
722
|
+
// variable so tsc treats it as Promise<any> and doesn't pull the third-party
|
|
723
|
+
// adapter's own .ts into our typecheck — same intent as the desktop's agentImport.
|
|
724
|
+
const mcpAdapterSpec = "pi-mcp-adapter";
|
|
725
|
+
const { default: mcpAdapter } = await import(mcpAdapterSpec);
|
|
658
726
|
const services = await createAgentSessionServices({
|
|
659
727
|
cwd: spec.cwd,
|
|
660
728
|
agentDir: agentDir(),
|
|
661
729
|
resourceLoaderOptions: {
|
|
662
|
-
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
730
|
+
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider(), mcpAdapter] as any,
|
|
663
731
|
},
|
|
664
732
|
});
|
|
665
733
|
servicesRef = services as any;
|
|
@@ -724,7 +792,7 @@ export class Daemon {
|
|
|
724
792
|
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
725
793
|
const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
726
794
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
727
|
-
const allowedTools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
795
|
+
const allowedTools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
728
796
|
const title = deriveTaskTitle(spec);
|
|
729
797
|
const key = `task:${title}`;
|
|
730
798
|
if (this.running.has(key)) {
|
|
@@ -904,7 +972,7 @@ export class Daemon {
|
|
|
904
972
|
const config = loadDaemonConfig();
|
|
905
973
|
const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
906
974
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
907
|
-
const tools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
|
|
975
|
+
const tools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
908
976
|
const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd: spec.cwd, model, tools });
|
|
909
977
|
let output: Record<string, unknown> = {};
|
|
910
978
|
try { const p = JSON.parse(out.trim()); if (p && typeof p === "object" && !Array.isArray(p)) output = p as Record<string, unknown>; } catch { /* non-JSON → raw text only */ }
|
|
@@ -44,6 +44,10 @@ export interface GateController {
|
|
|
44
44
|
confineToCwd?: boolean;
|
|
45
45
|
getRemote?(): boolean;
|
|
46
46
|
getNoQuarter?(): boolean;
|
|
47
|
+
// Total bypass — see ModeGate.getSkipAllPermissions. Set by the `--no-quarter`
|
|
48
|
+
// launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
|
|
49
|
+
// action with no prompt.
|
|
50
|
+
getSkipAllPermissions?(): boolean;
|
|
47
51
|
// Block a tool outright while the turn is remote-driven (only consulted when
|
|
48
52
|
// getRemote() is true). For tools whose own prompts render on the host terminal
|
|
49
53
|
// rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
|
|
@@ -132,20 +136,26 @@ export async function decideToolCall(
|
|
|
132
136
|
ask,
|
|
133
137
|
getRemote: ctrl.getRemote,
|
|
134
138
|
getNoQuarter: ctrl.getNoQuarter,
|
|
139
|
+
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
135
140
|
});
|
|
136
141
|
|
|
137
142
|
let decision: "allow" | "deny";
|
|
138
143
|
try {
|
|
139
144
|
decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
|
|
140
145
|
} catch (err) {
|
|
141
|
-
// Fail closed: a thrown/aborted/timed-out approval blocks the tool.
|
|
146
|
+
// Fail closed: a thrown/aborted/timed-out approval blocks the tool. Phrase it
|
|
147
|
+
// as terminal — re-issuing the identical call will hit the same closed gate, so
|
|
148
|
+
// tell the model to stop retrying and take a different path (or ask the user).
|
|
142
149
|
return {
|
|
143
150
|
block: true,
|
|
144
|
-
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default
|
|
151
|
+
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it.`,
|
|
145
152
|
};
|
|
146
153
|
}
|
|
147
154
|
if (decision === "deny") {
|
|
148
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
block: true,
|
|
157
|
+
reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves.`,
|
|
158
|
+
};
|
|
149
159
|
}
|
|
150
160
|
return undefined;
|
|
151
161
|
}
|
|
@@ -34,6 +34,13 @@ export interface ModeGateDeps {
|
|
|
34
34
|
// pinging the phone. Dangerous shell and alwaysAsk-destructive actions rank
|
|
35
35
|
// above bypass in decideAuto, so those still relay for an explicit Allow/Deny.
|
|
36
36
|
getNoQuarter?: () => boolean;
|
|
37
|
+
// True when the operator launched with `--no-quarter` (env PRIVATEER_NO_QUARTER):
|
|
38
|
+
// a session-wide TOTAL bypass of the gate. Every request auto-approves — including
|
|
39
|
+
// dangerous shell, destructive tools, out-of-cwd and protected-file access — with
|
|
40
|
+
// no prompt, local or remote. This sits ABOVE everything else (mode, allowlist,
|
|
41
|
+
// the remote branch, even the dangerous-command denylist): the operator has
|
|
42
|
+
// explicitly opted the whole session out of the moat. Off unless the flag is set.
|
|
43
|
+
getSkipAllPermissions?: () => boolean;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
@@ -43,6 +50,13 @@ export class ModeGate implements PermissionGate {
|
|
|
43
50
|
constructor(private readonly deps: ModeGateDeps) {}
|
|
44
51
|
|
|
45
52
|
async request(req: PermissionRequest): Promise<PermissionDecision> {
|
|
53
|
+
// No-quarter: the operator launched with `--no-quarter`, opting the whole
|
|
54
|
+
// session out of the gate. Total bypass — auto-allow EVERY request (dangerous
|
|
55
|
+
// shell, destructive tools, outside-cwd, protected files) with no prompt, before
|
|
56
|
+
// any mode/allowlist/remote/denylist policy is consulted. Deliberately the very
|
|
57
|
+
// first check so nothing below can force an "ask" back on.
|
|
58
|
+
if (this.deps.getSkipAllPermissions?.()) return "allow";
|
|
59
|
+
|
|
46
60
|
const denylist = this.deps.denylist ?? [];
|
|
47
61
|
const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
|
|
48
62
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP connector management for the app — the sibling of channelsControl.ts, but for
|
|
3
|
+
* MCP server config rather than messaging channels. It is what lets the phone/web
|
|
4
|
+
* client add, toggle, and remove MCP connectors on a Node HOST it drives over the
|
|
5
|
+
* relay (the daemon today; an interactive terminal by the same shape).
|
|
6
|
+
*
|
|
7
|
+
* The client itself can NEVER run MCP — a browser tab / RN runtime can't spawn a
|
|
8
|
+
* stdio child or hold the adapter. So "serving MCP to phone/web" means MANAGING the
|
|
9
|
+
* config here, on a host that executes it. This control owns that config.
|
|
10
|
+
*
|
|
11
|
+
* SAME FILE MODEL AS THE DESKTOP (treeview/desktop/src/main/mcpService.ts): the
|
|
12
|
+
* source of truth is `${agentDir}/mcp-desktop.json` — every server with an `enabled`
|
|
13
|
+
* flag — and from it we PROJECT the standard `${agentDir}/mcp.json` (enabled servers
|
|
14
|
+
* only, `{mcpServers:{}}` shape) that pi-mcp-adapter reads. Sharing those two files
|
|
15
|
+
* means a machine has ONE coherent MCP config whether it was edited from the desktop
|
|
16
|
+
* over IPC or from the phone over the relay.
|
|
17
|
+
*
|
|
18
|
+
* SECRETS: MCP env values are credentials (GITHUB_PERSONAL_ACCESS_TOKEN, …). Over the
|
|
19
|
+
* untrusted relay they are WRITE-ONLY, exactly like channel bot tokens: list() NEVER
|
|
20
|
+
* returns an env VALUE — only which env keys exist (`envKeys`) and which are non-empty
|
|
21
|
+
* (`secretsSet`), by name. save() persists whatever env VALUES it is handed in
|
|
22
|
+
* `draft.env`; the seal/open of those values in transit is the caller's job (the
|
|
23
|
+
* daemon opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
|
|
24
|
+
* this module only ever deals in the plaintext files it already owns.
|
|
25
|
+
*
|
|
26
|
+
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
27
|
+
* frame plumbing and the sealed-secret open.
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
30
|
+
import { join, dirname } from "node:path";
|
|
31
|
+
import { agentDir } from "../config/paths.ts";
|
|
32
|
+
|
|
33
|
+
export type McpTransport = "stdio" | "http";
|
|
34
|
+
|
|
35
|
+
// One server as stored in the source file (mcp-desktop.json). Mirrors the desktop's
|
|
36
|
+
// SourceEntry: the standard fields the adapter needs plus our `enabled` flag.
|
|
37
|
+
interface SourceEntry {
|
|
38
|
+
transport?: McpTransport;
|
|
39
|
+
command?: string;
|
|
40
|
+
args?: string[];
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
url?: string;
|
|
43
|
+
oauth?: boolean;
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface SourceFile {
|
|
47
|
+
servers: Record<string, SourceEntry>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Non-secret projection of one server, sent to the app. No env VALUES, ever — only
|
|
51
|
+
// which env keys exist and which are set (`secretsSet`). `host` is surfaced for the
|
|
52
|
+
// app's privacy badge ("Sends data to <host>" for http; stdio runs locally).
|
|
53
|
+
export interface RemoteMcpServer {
|
|
54
|
+
name: string;
|
|
55
|
+
transport: McpTransport;
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
command?: string; // stdio: the launch binary (not a secret — e.g. "npx")
|
|
58
|
+
argsPreview?: string; // stdio: args joined, for a one-line summary
|
|
59
|
+
url?: string; // http: the endpoint (not a secret; the vendor host)
|
|
60
|
+
host?: string; // http: parsed host for the privacy badge
|
|
61
|
+
oauth: boolean; // http servers negotiate OAuth; stdio never does
|
|
62
|
+
envKeys: string[]; // env var NAMES only (e.g. ["GITHUB_PERSONAL_ACCESS_TOKEN"])
|
|
63
|
+
secretsSet: string[]; // subset of envKeys whose value is non-empty — names only
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// An app-submitted edit. Non-secret fields REPLACE when present; `env` maps a var
|
|
67
|
+
// name → its (already-opened) value, and only present, non-empty values overwrite —
|
|
68
|
+
// an omitted key keeps the existing value (so a re-save without re-typing the token
|
|
69
|
+
// preserves it, matching the channels-manager rule).
|
|
70
|
+
export interface McpDraft {
|
|
71
|
+
name: string;
|
|
72
|
+
transport?: McpTransport;
|
|
73
|
+
command?: string;
|
|
74
|
+
args?: string[];
|
|
75
|
+
url?: string;
|
|
76
|
+
oauth?: boolean;
|
|
77
|
+
env?: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface McpControl {
|
|
81
|
+
// Every managed server, non-secret projection. Enabled or not — the app shows
|
|
82
|
+
// disabled connectors so they can be toggled back on.
|
|
83
|
+
list(): RemoteMcpServer[];
|
|
84
|
+
// Create or edit a server. Validates transport ⟷ required field (stdio→command,
|
|
85
|
+
// http→url). Returns a one-line result. Re-projects mcp.json on success.
|
|
86
|
+
save(draft: McpDraft): { ok: boolean; message?: string };
|
|
87
|
+
// Enable/disable a server (re-projects). ok:false when the name is unknown.
|
|
88
|
+
setEnabled(name: string, enabled: boolean): { ok: boolean; message?: string };
|
|
89
|
+
// Delete a server entirely (re-projects). ok:false when nothing was configured.
|
|
90
|
+
remove(name: string): { ok: boolean; message?: string };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const TRANSPORTS: readonly McpTransport[] = ["stdio", "http"];
|
|
94
|
+
function isTransport(v: unknown): v is McpTransport {
|
|
95
|
+
return typeof v === "string" && TRANSPORTS.includes(v as McpTransport);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hostOf(url: string): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
return new URL(url).host || undefined;
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cleanArgs(v: unknown): string[] | undefined {
|
|
107
|
+
if (!Array.isArray(v)) return undefined;
|
|
108
|
+
return v.map((x) => String(x ?? "")).filter((s) => s.length > 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function makeMcpControl(opts?: {
|
|
112
|
+
// Override the source/projection dir (tests). Defaults to the shared agent dir, so
|
|
113
|
+
// this control and the desktop's mcpService edit the SAME two files.
|
|
114
|
+
dir?: () => string;
|
|
115
|
+
}): McpControl {
|
|
116
|
+
const dir = opts?.dir ?? agentDir;
|
|
117
|
+
const sourcePath = () => join(dir(), "mcp-desktop.json");
|
|
118
|
+
const projectionPath = () => join(dir(), "mcp.json");
|
|
119
|
+
|
|
120
|
+
function readSource(): SourceFile {
|
|
121
|
+
// Seed from an existing standard mcp.json on first run (a machine that already
|
|
122
|
+
// had connectors before this control existed), so nothing is silently dropped.
|
|
123
|
+
try {
|
|
124
|
+
const raw = JSON.parse(readFileSync(sourcePath(), "utf8"));
|
|
125
|
+
if (raw && typeof raw === "object" && raw.servers) return { servers: raw.servers };
|
|
126
|
+
} catch {
|
|
127
|
+
/* fall through to seed */
|
|
128
|
+
}
|
|
129
|
+
const servers: Record<string, SourceEntry> = {};
|
|
130
|
+
try {
|
|
131
|
+
const proj = JSON.parse(readFileSync(projectionPath(), "utf8"));
|
|
132
|
+
for (const [name, entry] of Object.entries(proj?.mcpServers ?? {})) {
|
|
133
|
+
servers[name] = { ...(entry as SourceEntry), enabled: true };
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
/* no prior config */
|
|
137
|
+
}
|
|
138
|
+
return { servers };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeSource(src: SourceFile): void {
|
|
142
|
+
mkdirSync(dirname(sourcePath()), { recursive: true });
|
|
143
|
+
writeFileSync(sourcePath(), JSON.stringify(src, null, 2) + "\n");
|
|
144
|
+
project(src);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Project the enabled servers into the standard mcp.json the adapter reads. An
|
|
148
|
+
// entry with no explicit transport is treated as stdio if it has a command, http
|
|
149
|
+
// if it has a url — matching the adapter's own inference.
|
|
150
|
+
function project(src: SourceFile): void {
|
|
151
|
+
const mcpServers: Record<string, unknown> = {};
|
|
152
|
+
for (const [name, e] of Object.entries(src.servers)) {
|
|
153
|
+
if (e.enabled === false) continue;
|
|
154
|
+
const { enabled, ...std } = e;
|
|
155
|
+
mcpServers[name] = std;
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(dirname(projectionPath()), { recursive: true });
|
|
158
|
+
writeFileSync(projectionPath(), JSON.stringify({ mcpServers }, null, 2) + "\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toRemote(name: string, e: SourceEntry): RemoteMcpServer {
|
|
162
|
+
const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
|
|
163
|
+
const env = e.env ?? {};
|
|
164
|
+
const envKeys = Object.keys(env);
|
|
165
|
+
return {
|
|
166
|
+
name,
|
|
167
|
+
transport,
|
|
168
|
+
enabled: e.enabled !== false,
|
|
169
|
+
command: transport === "stdio" ? e.command : undefined,
|
|
170
|
+
argsPreview: transport === "stdio" && e.args?.length ? e.args.join(" ") : undefined,
|
|
171
|
+
url: transport === "http" ? e.url : undefined,
|
|
172
|
+
host: transport === "http" && e.url ? hostOf(e.url) : undefined,
|
|
173
|
+
// http servers negotiate OAuth; stdio never does (matches mcpService.list()).
|
|
174
|
+
oauth: transport === "http",
|
|
175
|
+
envKeys,
|
|
176
|
+
secretsSet: envKeys.filter((k) => String(env[k] ?? "").length > 0),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
list(): RemoteMcpServer[] {
|
|
182
|
+
const src = readSource();
|
|
183
|
+
return Object.entries(src.servers).map(([name, e]) => toRemote(name, e));
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
save(draft: McpDraft): { ok: boolean; message?: string } {
|
|
187
|
+
const name = String(draft?.name ?? "").trim();
|
|
188
|
+
if (!name) return { ok: false, message: "A connector needs a name." };
|
|
189
|
+
if (draft.transport !== undefined && !isTransport(draft.transport))
|
|
190
|
+
return { ok: false, message: "Unknown transport." };
|
|
191
|
+
|
|
192
|
+
const src = readSource();
|
|
193
|
+
const prev: SourceEntry = src.servers[name] ?? {};
|
|
194
|
+
const entry: SourceEntry = { ...prev };
|
|
195
|
+
|
|
196
|
+
const transport: McpTransport =
|
|
197
|
+
(draft.transport as McpTransport) ?? prev.transport ?? (draft.url || prev.url ? "http" : "stdio");
|
|
198
|
+
entry.transport = transport;
|
|
199
|
+
|
|
200
|
+
if (transport === "stdio") {
|
|
201
|
+
if (draft.command !== undefined) entry.command = String(draft.command).trim();
|
|
202
|
+
const args = cleanArgs(draft.args);
|
|
203
|
+
if (args !== undefined) entry.args = args;
|
|
204
|
+
// A stdio server can't reach a url and never does OAuth — clear stale fields.
|
|
205
|
+
delete entry.url;
|
|
206
|
+
delete entry.oauth;
|
|
207
|
+
if (!entry.command) return { ok: false, message: "A local (stdio) connector needs a command." };
|
|
208
|
+
} else {
|
|
209
|
+
if (draft.url !== undefined) entry.url = String(draft.url).trim();
|
|
210
|
+
if (draft.oauth !== undefined) entry.oauth = !!draft.oauth;
|
|
211
|
+
delete entry.command;
|
|
212
|
+
delete entry.args;
|
|
213
|
+
if (!entry.url) return { ok: false, message: "A remote (http) connector needs a URL." };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Env/secrets: a present, non-empty value overwrites; an omitted key keeps the
|
|
217
|
+
// existing value (re-save without re-typing the token preserves it). An explicit
|
|
218
|
+
// empty string clears that key.
|
|
219
|
+
if (draft.env !== undefined) {
|
|
220
|
+
const merged: Record<string, string> = { ...(prev.env ?? {}) };
|
|
221
|
+
for (const [k, v] of Object.entries(draft.env)) {
|
|
222
|
+
const key = String(k).trim();
|
|
223
|
+
if (!key) continue;
|
|
224
|
+
const val = String(v ?? "");
|
|
225
|
+
if (val.length > 0) merged[key] = val;
|
|
226
|
+
else delete merged[key];
|
|
227
|
+
}
|
|
228
|
+
if (Object.keys(merged).length > 0) entry.env = merged;
|
|
229
|
+
else delete entry.env;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A brand-new server comes up enabled; an edit preserves the prior flag.
|
|
233
|
+
entry.enabled = prev.enabled ?? true;
|
|
234
|
+
|
|
235
|
+
src.servers[name] = entry;
|
|
236
|
+
try {
|
|
237
|
+
writeSource(src);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, message: `Saved "${name}".` };
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
setEnabled(name: string, enabled: boolean): { ok: boolean; message?: string } {
|
|
245
|
+
const src = readSource();
|
|
246
|
+
if (!src.servers[name]) return { ok: false, message: "No such connector." };
|
|
247
|
+
src.servers[name].enabled = !!enabled;
|
|
248
|
+
try {
|
|
249
|
+
writeSource(src);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, message: `${enabled ? "Enabled" : "Disabled"} "${name}".` };
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
remove(name: string): { ok: boolean; message?: string } {
|
|
257
|
+
const src = readSource();
|
|
258
|
+
if (!src.servers[name]) return { ok: false, message: "Not configured." };
|
|
259
|
+
delete src.servers[name];
|
|
260
|
+
try {
|
|
261
|
+
writeSource(src);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return { ok: false, message: `Couldn't write MCP config: ${e instanceof Error ? e.message : String(e)}` };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, message: `Removed "${name}".` };
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|