privateer-agent 0.7.0 → 0.8.2
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 +27 -11
- package/bin/privateer-launch.mjs +1 -0
- package/extensions/privateer-gate.ts +97 -5
- package/extensions/privateer-privacy.ts +21 -0
- package/package.json +4 -2
- package/src/channels/run.ts +11 -2
- package/src/cli/chat.ts +65 -5
- package/src/harbor/index.ts +50 -9
- package/src/harbor/ipc.ts +82 -34
- package/src/mcp/catalog.ts +162 -0
- package/src/permissions/noQuarter.ts +42 -0
- package/src/providers/account.ts +60 -12
- package/src/providers/catalog.ts +8 -6
- package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
- package/src/providers/phala/aci-verifier/crypto.ts +95 -0
- package/src/providers/phala/aci-verifier/digest.ts +116 -0
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
- package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
- package/src/providers/phala/aci-verifier/errors.ts +41 -0
- package/src/providers/phala/aci-verifier/index.ts +87 -0
- package/src/providers/phala/aci-verifier/jcs.ts +69 -0
- package/src/providers/phala/aci-verifier/receipt.ts +139 -0
- package/src/providers/phala/aci-verifier/report.ts +126 -0
- package/src/providers/phala/aci-verifier/types.ts +139 -0
- package/src/providers/phala/sse.ts +43 -0
- package/src/providers/phala/webcrypto-globals.d.ts +17 -0
- package/src/providers/phalaSeal.ts +200 -0
- package/src/providers/sealedShim.ts +295 -0
- package/src/remote/liveTaskSession.ts +20 -3
- package/src/remote/relayClient.ts +63 -1
- package/src/ui/palette.ts +3 -0
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ export PRIVATEER_API_KEY=sk-priv-... # Privateer developer API (privateer.p
|
|
|
210
210
|
```
|
|
211
211
|
|
|
212
212
|
Pick a model with **`/model`** (browse each configured provider's live catalog) or pass one
|
|
213
|
-
directly as `provider/model` — e.g. `openrouter/anthropic/claude-opus-
|
|
213
|
+
directly as `provider/model` — e.g. `openrouter/anthropic/claude-opus-5`,
|
|
214
214
|
`ollama/qwen3-coder`, `nearai/zai-org/GLM-5.1-FP8`. Any OpenAI-compatible server (LM Studio,
|
|
215
215
|
vLLM, llama.cpp) works as a custom provider — just give it a base URL.
|
|
216
216
|
|
|
@@ -388,20 +388,35 @@ live in plaintext in `config.json` on your machine, and every channel action is
|
|
|
388
388
|
Switch with **`/mode`**. Even in `bypass`, a danger filter blocks destructive shell commands,
|
|
389
389
|
and protected files (`.env`, shell rc files…) are guarded — the gate is never fully off.
|
|
390
390
|
|
|
391
|
-
###
|
|
392
|
-
|
|
393
|
-
For an unattended run in a directory and on a task you fully trust, launch with:
|
|
394
|
-
|
|
395
|
-
```bash
|
|
396
|
-
privateer --no-quarter
|
|
397
|
-
```
|
|
391
|
+
### No quarter — lower the moat entirely
|
|
398
392
|
|
|
399
393
|
This is the one exception to "the gate is never fully off." It disables the permission
|
|
400
394
|
gate for the **whole session** — every action auto-approves with no prompt, including
|
|
401
395
|
destructive shell commands, out-of-cwd access, and protected files. Subagents spawned
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
396
|
+
after it goes on inherit it (env `PRIVATEER_NO_QUARTER=1`). Only for a directory and a
|
|
397
|
+
task you fully trust.
|
|
398
|
+
|
|
399
|
+
Three ways in, all the same switch:
|
|
400
|
+
|
|
401
|
+
| | |
|
|
402
|
+
|---|---|
|
|
403
|
+
| **shift+tab** | toggle it mid-session — hit it and walk away, and the agent runs the task to the end instead of stopping at the next approval |
|
|
404
|
+
| `/no-quarter [on\|off]` | the typed equivalent |
|
|
405
|
+
| `privateer --no-quarter` | start a session with the moat already down |
|
|
406
|
+
|
|
407
|
+
It's never quietly in effect: the launch flag prints a red warning banner, the toggle
|
|
408
|
+
posts a warning to the transcript, and while it's on the footer carries a permanent red
|
|
409
|
+
`⚑ no quarter — permission gate OFF` indicator. shift+tab again raises the moat.
|
|
410
|
+
|
|
411
|
+
Toggling takes effect from the next gated action — an approval already on screen still
|
|
412
|
+
needs an answer. It's a physical-terminal switch: a phone driving this terminal over
|
|
413
|
+
`/remote-access` can't reach it. The app has its own no-quarter toggle for driven turns,
|
|
414
|
+
which is `bypass` exactly — never less — so dangerous shell and destructive actions
|
|
415
|
+
still surface an Allow there, precisely as they do under `/mode bypass` locally.
|
|
416
|
+
|
|
417
|
+
> shift+tab is Pi's default "cycle thinking level" chord; Privateer takes it for this.
|
|
418
|
+
> Thinking level is still under `/settings`, or bind `app.thinking.cycle` to another key
|
|
419
|
+
> in `~/.privateer/agent/keybindings.json`.
|
|
405
420
|
|
|
406
421
|
## Extend it
|
|
407
422
|
|
|
@@ -428,6 +443,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
428
443
|
|---|---|
|
|
429
444
|
| `/model` · `/models` | switch model; `/models` is a searchable picker with TEE/ZDR privacy shields |
|
|
430
445
|
| `/mode` | switch permission mode |
|
|
446
|
+
| `/no-quarter` | lower the moat for this session and run unattended (**shift+tab**) |
|
|
431
447
|
| `/verify` | fetch and check the TEE attestation for the current model |
|
|
432
448
|
| `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
|
|
433
449
|
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -60,6 +60,7 @@ if (NO_QUARTER) {
|
|
|
60
60
|
" ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
|
|
61
61
|
" Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt.",
|
|
62
62
|
" Only use this in a directory and with a task you fully trust.",
|
|
63
|
+
" shift+tab (or /no-quarter off) raises the moat again.",
|
|
63
64
|
"",
|
|
64
65
|
].join("\n") + "\n",
|
|
65
66
|
);
|
|
@@ -28,8 +28,10 @@ import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
|
28
28
|
import { agentDir } from "../src/config/paths.ts";
|
|
29
29
|
import { agentVersion } from "../src/config/version.ts";
|
|
30
30
|
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
31
32
|
import * as priv from "../src/auth/privateer.ts";
|
|
32
33
|
import { paletteFor } from "../src/ui/palette.ts";
|
|
34
|
+
import { noQuarterActive, setNoQuarter } from "../src/permissions/noQuarter.ts";
|
|
33
35
|
import type { PermissionMode } from "../src/config/permissionMode.ts";
|
|
34
36
|
|
|
35
37
|
const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
|
|
@@ -169,6 +171,17 @@ async function pickModelRemote(filter: string): Promise<void> {
|
|
|
169
171
|
// the REPL's runCommand fall-through.
|
|
170
172
|
async function runRemoteCommand(text: string): Promise<void> {
|
|
171
173
|
const line = text.trim();
|
|
174
|
+
// No quarter is a PHYSICAL-terminal action, like /remote-access. It's stronger than
|
|
175
|
+
// any mode — it also switches off the dangerous-command denylist and the protected-
|
|
176
|
+
// file guard — so a remote controller must not be able to reach it. (The app's own
|
|
177
|
+
// no_quarter toggle covers driven turns: ModeGate re-decides those through
|
|
178
|
+
// decideAuto(req, "bypass", …), so it IS /mode bypass — never weaker. Dangerous and
|
|
179
|
+
// destructive actions sit above bypass, so they come back to the phone for an
|
|
180
|
+
// explicit Allow there, exactly as bypass surfaces them locally.)
|
|
181
|
+
if (line === "/no-quarter" || line.startsWith("/no-quarter ")) {
|
|
182
|
+
relay?.sendNotice("/no-quarter is terminal-only — run it at the machine, or use the app's own no-quarter toggle.");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
172
185
|
if (line.startsWith("/model ")) { await switchModelRemote(line.slice(7)); return; }
|
|
173
186
|
if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
|
|
174
187
|
const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
|
|
@@ -213,7 +226,9 @@ function advertiseCommands(): { name: string; description?: string }[] {
|
|
|
213
226
|
})
|
|
214
227
|
.filter(Boolean);
|
|
215
228
|
} catch { /* no commands registered yet */ }
|
|
216
|
-
|
|
229
|
+
// Terminal-only commands are withheld rather than advertised-then-refused, so the
|
|
230
|
+
// app's composer never offers something it can't run. See runRemoteCommand.
|
|
231
|
+
const seen = new Set([...builtins.map((c) => c.name), "/no-quarter"]);
|
|
217
232
|
return [...builtins, ...ext.filter((c: any) => !seen.has(c.name))];
|
|
218
233
|
}
|
|
219
234
|
|
|
@@ -250,6 +265,61 @@ function setRemoteState(s: typeof remoteState): void {
|
|
|
250
265
|
refreshRemoteStatus();
|
|
251
266
|
}
|
|
252
267
|
|
|
268
|
+
// ── no quarter (shift+tab) ────────────────────────────────────────────────────
|
|
269
|
+
// The "step away from the keyboard" switch. On, the gate is fully lowered for the
|
|
270
|
+
// rest of the session: every action auto-approves with no prompt, so a long task
|
|
271
|
+
// runs to completion instead of stalling on the next approval. Off by default and
|
|
272
|
+
// reversible with the same key; the state itself lives in src/permissions/noQuarter.ts
|
|
273
|
+
// (shared with the launch flag and inherited by subagent children).
|
|
274
|
+
//
|
|
275
|
+
// The footer carries a permanent RED indicator while it's on — this is the one
|
|
276
|
+
// setting that turns the whole moat off, so it must never be quietly in effect.
|
|
277
|
+
const NO_QUARTER_STATUS_KEY = "privateer:no-quarter";
|
|
278
|
+
|
|
279
|
+
function refreshNoQuarterStatus(): void {
|
|
280
|
+
const ui = uiRef;
|
|
281
|
+
if (!ui?.setStatus) return;
|
|
282
|
+
if (!noQuarterActive()) {
|
|
283
|
+
ui.setStatus(NO_QUARTER_STATUS_KEY, undefined);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const p = paletteFor(ui.theme);
|
|
287
|
+
ui.setStatus(
|
|
288
|
+
NO_QUARTER_STATUS_KEY,
|
|
289
|
+
`${p.RED}${p.BOLD}⚑ no quarter — permission gate OFF${p.RESET} ${p.DIM}· shift+tab to raise the moat${p.RESET}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Flip the state and tell the user, loudly on the way down. Takes effect from the
|
|
294
|
+
// next gated action — an approval already on screen still needs an answer.
|
|
295
|
+
function applyNoQuarter(on: boolean, ui: any): void {
|
|
296
|
+
setNoQuarter(on);
|
|
297
|
+
refreshNoQuarterStatus();
|
|
298
|
+
ui?.notify?.(
|
|
299
|
+
on
|
|
300
|
+
? "⚑ No quarter — the permission gate is OFF for this session. Every action (shell, edits, destructive tools, out-of-cwd, protected files) now runs without asking. shift+tab to raise the moat again."
|
|
301
|
+
: "⚓ Moat raised — the permission gate is back on.",
|
|
302
|
+
on ? "warning" : "info",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// shift+tab, intercepted at the raw-input layer. Pi reserves that chord for
|
|
307
|
+
// `app.thinking.cycle`, so pi.registerShortcut("shift+tab") would be dropped as a
|
|
308
|
+
// conflict — a TUI input listener runs ahead of every component instead, and
|
|
309
|
+
// consuming the key stops it reaching the thinking-level cycler. (Thinking level
|
|
310
|
+
// stays reachable from /settings, or by binding app.thinking.cycle to another key.)
|
|
311
|
+
let unsubscribeKeys: (() => void) | undefined;
|
|
312
|
+
|
|
313
|
+
function bindNoQuarterKey(ui: any): void {
|
|
314
|
+
if (typeof ui?.onTerminalInput !== "function") return; // older Pi / non-TUI mode
|
|
315
|
+
unsubscribeKeys?.(); // a session replacement clears listeners — rebind, never double-bind
|
|
316
|
+
unsubscribeKeys = ui.onTerminalInput((data: string) => {
|
|
317
|
+
if (!matchesKey(data, "shift+tab")) return undefined;
|
|
318
|
+
applyNoQuarter(!noQuarterActive(), ui);
|
|
319
|
+
return { consume: true };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
253
323
|
// Tear down the relay and clear the indicator. Used by `/remote-access off` AND by
|
|
254
324
|
// the app's own "End remote access" action (onTerminate), so both paths converge.
|
|
255
325
|
function disableRemote(): void {
|
|
@@ -353,10 +423,11 @@ const gate = makePermissionGate({
|
|
|
353
423
|
localAsk,
|
|
354
424
|
getRemote: bridge.getRemote,
|
|
355
425
|
getNoQuarter: bridge.getNoQuarter,
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
426
|
+
// Session-wide TOTAL bypass: every action auto-approves, no prompt. Set either by
|
|
427
|
+
// `--no-quarter` at launch (see bin/privateer-launch.mjs, which exports
|
|
428
|
+
// PRIVATEER_NO_QUARTER) or by shift+tab mid-session — both land in the same state,
|
|
429
|
+
// which subagent children inherit through the env. See src/permissions/noQuarter.ts.
|
|
430
|
+
getSkipAllPermissions: noQuarterActive,
|
|
360
431
|
remoteAsk: bridge.remoteAsk,
|
|
361
432
|
});
|
|
362
433
|
|
|
@@ -401,6 +472,13 @@ export default function privateerControl(pi: any): void {
|
|
|
401
472
|
if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
|
|
402
473
|
if (!currentSpec && ctx?.model) currentSpec = modelSpec(ctx.model);
|
|
403
474
|
refreshRemoteStatus();
|
|
475
|
+
// shift+tab → no quarter, and the red footer indicator when it's already on (a
|
|
476
|
+
// `--no-quarter` launch, or a session replacement mid-run). Interactive TUI only:
|
|
477
|
+
// headless modes and subagent children have no terminal to listen to.
|
|
478
|
+
if (ctx?.mode === "tui" && ctx?.ui) {
|
|
479
|
+
bindNoQuarterKey(ctx.ui);
|
|
480
|
+
refreshNoQuarterStatus();
|
|
481
|
+
}
|
|
404
482
|
if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
|
|
405
483
|
mode = "bypass";
|
|
406
484
|
}
|
|
@@ -443,6 +521,20 @@ export default function privateerControl(pi: any): void {
|
|
|
443
521
|
},
|
|
444
522
|
});
|
|
445
523
|
|
|
524
|
+
// The typed equivalent of shift+tab, for anyone who'd rather not trust a chord with
|
|
525
|
+
// the whole moat. Deliberately NOT reachable from the app — see runRemoteCommand.
|
|
526
|
+
pi.registerCommand?.("no-quarter", {
|
|
527
|
+
description: "Lower the moat for this session — run unattended with no approval prompts: /no-quarter [on|off]",
|
|
528
|
+
handler: (args: string, ctx: any) => {
|
|
529
|
+
if (ctx?.ui) uiRef = ctx.ui;
|
|
530
|
+
const arg = String(args ?? "").trim().toLowerCase();
|
|
531
|
+
if (arg && arg !== "on" && arg !== "off") {
|
|
532
|
+
return ctx.ui?.notify?.(`usage: /no-quarter [on|off] (currently ${noQuarterActive() ? "on" : "off"})`, "warning");
|
|
533
|
+
}
|
|
534
|
+
applyNoQuarter(arg ? arg === "on" : !noQuarterActive(), ctx.ui);
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
446
538
|
// Local extension management. Mirrors what the app's extensions screen does over
|
|
447
539
|
// the relay, but here we CAN hot-activate: ctx.reload() rebuilds the live runner,
|
|
448
540
|
// so a just-added/removed extension takes effect without relaunching (a luxury the
|
|
@@ -46,6 +46,27 @@ const privacy = makePiPrivacyExtension({
|
|
|
46
46
|
if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
|
|
47
47
|
return (await accountPosture(modelId)).tier;
|
|
48
48
|
},
|
|
49
|
+
// pi-privacy 0.8 added an INGEST gate: credentials arriving in a tool result are
|
|
50
|
+
// redacted before they enter context (they'd otherwise be re-sent every turn and
|
|
51
|
+
// written to the session file on disk). We already redact tool output in
|
|
52
|
+
// src/ext/permissionGate.ts, so its default "warn" would put an interactive prompt
|
|
53
|
+
// in front of something this app has always handled silently — "redact" keeps our
|
|
54
|
+
// UX and still takes the added coverage.
|
|
55
|
+
//
|
|
56
|
+
// The two redactors are COMPLEMENTARY, not duplicative, which is why we run both:
|
|
57
|
+
// ours masks the configured provider keys by exact value (from env/config) plus the
|
|
58
|
+
// provider-specific shapes (sk-/AIza/xai-/gsk_/csk-/vapi_/fw_/Z.ai, auth headers);
|
|
59
|
+
// pi-privacy's catches what shows up in USER code and shell output — AWS AKIA/ASIA,
|
|
60
|
+
// GitHub gh[pousr]_, JWTs, PEM private-key blocks, Slack, Stripe — none of which
|
|
61
|
+
// our patterns match.
|
|
62
|
+
//
|
|
63
|
+
// Order between the two is NOT guaranteed: pi discovers extensions with a bare
|
|
64
|
+
// readdirSync and never sorts, so it's filesystem-dependent (alphabetical on this
|
|
65
|
+
// box today, not by contract). "redact" makes that moot — both handlers run
|
|
66
|
+
// unconditionally and each masks its own patterns, so the surviving content is the
|
|
67
|
+
// same either way. Under "warn" the order WOULD matter, since it decides whether
|
|
68
|
+
// the prompt is raised on a raw key or one we already masked.
|
|
69
|
+
toolResultPolicy: "redact",
|
|
49
70
|
});
|
|
50
71
|
|
|
51
72
|
export default function privateerPrivacy(pi: any): void {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,12 +67,14 @@
|
|
|
67
67
|
"@noble/ciphers": "^2.1.1",
|
|
68
68
|
"@noble/curves": "^1.9.7",
|
|
69
69
|
"@noble/hashes": "^1.7.1",
|
|
70
|
+
"@phala/dcap-qvl": "^0.5.2",
|
|
70
71
|
"patch-package": "^8.0.1",
|
|
71
72
|
"pi-mcp-adapter": "^2.11.0",
|
|
72
|
-
"pi-privacy": "^0.
|
|
73
|
+
"pi-privacy": "^0.8.0",
|
|
73
74
|
"pi-subagents": "^0.34.0",
|
|
74
75
|
"picomatch": "^4.0.4",
|
|
75
76
|
"privateer-workflow": "^0.1.0",
|
|
77
|
+
"tinfoil": "^1.1.11",
|
|
76
78
|
"tsx": "^4.16.0",
|
|
77
79
|
"typebox": "^1.3.4",
|
|
78
80
|
"undici": "^7.28.0",
|
package/src/channels/run.ts
CHANGED
|
@@ -109,7 +109,8 @@ async function main() {
|
|
|
109
109
|
const { makePermissionGate } = await import("../ext/permissionGate.ts");
|
|
110
110
|
type GateController = import("../ext/permissionGate.ts").GateController;
|
|
111
111
|
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
112
|
-
const { makeAccountProvider } = await import("../providers/account.ts");
|
|
112
|
+
const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
|
|
113
|
+
const { hasCredentials } = await import("../auth/privateer.ts");
|
|
113
114
|
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
114
115
|
const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
|
|
115
116
|
const { redactText, collectSecrets } = await import("../util/redact.ts");
|
|
@@ -181,7 +182,15 @@ async function main() {
|
|
|
181
182
|
cwd,
|
|
182
183
|
agentDir: agentDir(),
|
|
183
184
|
resourceLoaderOptions: {
|
|
184
|
-
extensionFactories: [
|
|
185
|
+
extensionFactories: [
|
|
186
|
+
makePermissionGate(gate),
|
|
187
|
+
// Per-model verified-TEE label for the /models picker (see harbor/index.ts):
|
|
188
|
+
// TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
|
|
189
|
+
makePiPrivacyExtension({
|
|
190
|
+
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
191
|
+
}),
|
|
192
|
+
makeAccountProvider(),
|
|
193
|
+
] as any,
|
|
185
194
|
},
|
|
186
195
|
});
|
|
187
196
|
|
package/src/cli/chat.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
12
12
|
import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
|
|
13
13
|
import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
14
|
+
import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
|
|
14
15
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
15
16
|
|
|
16
17
|
// This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
|
|
@@ -38,7 +39,7 @@ async function main() {
|
|
|
38
39
|
const { authorizeControl } = await import("../remote/controlAuth.ts");
|
|
39
40
|
const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
|
|
40
41
|
const priv = await import("../auth/privateer.ts");
|
|
41
|
-
const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
|
|
42
|
+
const { makeAccountProvider, accountPosture, privateerChannel } = await import("../providers/account.ts");
|
|
42
43
|
const { agentVersion } = await import("../config/version.ts");
|
|
43
44
|
const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
|
|
44
45
|
|
|
@@ -307,8 +308,9 @@ async function main() {
|
|
|
307
308
|
},
|
|
308
309
|
getRemote: bridge.getRemote,
|
|
309
310
|
getNoQuarter: bridge.getNoQuarter,
|
|
310
|
-
// `--no-quarter` at launch
|
|
311
|
-
|
|
311
|
+
// Total gate bypass, no prompts — from `--no-quarter` at launch or shift+tab /
|
|
312
|
+
// `/no-quarter` mid-session. One shared state (../permissions/noQuarter.ts).
|
|
313
|
+
getSkipAllPermissions: noQuarterActive,
|
|
312
314
|
remoteAsk: bridge.remoteAsk,
|
|
313
315
|
// Subagents (and their child-only intercom tools) can't be driven from the app
|
|
314
316
|
// yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
|
|
@@ -322,14 +324,52 @@ async function main() {
|
|
|
322
324
|
},
|
|
323
325
|
};
|
|
324
326
|
|
|
327
|
+
// ── no quarter (shift+tab) ──────────────────────────────────────────────────
|
|
328
|
+
// The "step away from the keyboard" switch: lower the moat for the rest of the
|
|
329
|
+
// session so a long task runs to completion instead of stalling on the next
|
|
330
|
+
// approval prompt. Same state the `--no-quarter` launch flag sets, and the same
|
|
331
|
+
// chord the full TUI uses (extensions/privateer-gate.ts). Reversible with the
|
|
332
|
+
// same key; takes effect from the next gated action, so an approval already on
|
|
333
|
+
// screen still needs an answer.
|
|
334
|
+
function applyNoQuarter(on: boolean): void {
|
|
335
|
+
setNoQuarter(on);
|
|
336
|
+
flushOut(); // land streamed output above the notice
|
|
337
|
+
console.log(
|
|
338
|
+
on
|
|
339
|
+
? `\n${RED}⚑ No quarter — the permission gate is OFF for this session.${RESET}\n` +
|
|
340
|
+
`${DIM} Every action (shell, edits, destructive tools, out-of-cwd, protected files) runs without asking.\n` +
|
|
341
|
+
` shift+tab (or /no-quarter off) raises the moat again.${RESET}`
|
|
342
|
+
: `\n${GREEN}⚓ Moat raised — the permission gate is back on.${RESET}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// readline already emits keypress events for a TTY input, so we just listen. Node
|
|
347
|
+
// decodes shift+tab (CSI Z) as {name: "tab", shift: true}. We don't swallow it —
|
|
348
|
+
// readline still runs its own tab handler, which for a non-`@mention` line
|
|
349
|
+
// completes to nothing.
|
|
350
|
+
if (process.stdin.isTTY) {
|
|
351
|
+
process.stdin.on("keypress", (_s: string, key: any) => {
|
|
352
|
+
if (key?.name === "tab" && key.shift && !key.ctrl && !key.meta) applyNoQuarter(!noQuarterActive());
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
325
356
|
console.log(`${DIM}privateer-agent — lean REPL. Loading ${provider}/${modelId}…${RESET}`);
|
|
357
|
+
if (noQuarterActive()) applyNoQuarter(true); // launched with --no-quarter: say so up front
|
|
326
358
|
|
|
327
359
|
const services = await createAgentSessionServices({
|
|
328
360
|
cwd,
|
|
329
361
|
agentDir: agentDir(),
|
|
330
362
|
resourceLoaderOptions: {
|
|
331
363
|
// Structural ext types are intentionally narrow; cast to Pi's ExtensionFactory.
|
|
332
|
-
extensionFactories: [
|
|
364
|
+
extensionFactories: [
|
|
365
|
+
makePermissionGate(gate),
|
|
366
|
+
// Per-model verified-TEE label for the /models picker (see harbor/index.ts):
|
|
367
|
+
// TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
|
|
368
|
+
makePiPrivacyExtension({
|
|
369
|
+
privateerVerifiedTee: (m) => priv.hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
370
|
+
}),
|
|
371
|
+
makeAccountProvider(),
|
|
372
|
+
] as any,
|
|
333
373
|
},
|
|
334
374
|
});
|
|
335
375
|
for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
|
|
@@ -525,8 +565,9 @@ async function main() {
|
|
|
525
565
|
: `${DIM}Not signed in. /login to enable remote access & the account provider.${RESET}`,
|
|
526
566
|
);
|
|
527
567
|
|
|
528
|
-
const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /quit";
|
|
568
|
+
const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /no-quarter [on|off] /quit";
|
|
529
569
|
console.log(`${DIM}Ready. Type a prompt — reference a file with @path (Tab completes). ${HELP}${RESET}`);
|
|
570
|
+
console.log(`${DIM}shift+tab toggles no quarter — unattended mode, no approval prompts.${RESET}`);
|
|
530
571
|
await showPosture();
|
|
531
572
|
|
|
532
573
|
// The available model catalog as sorted "provider/id" specs. Same source the
|
|
@@ -612,6 +653,24 @@ async function main() {
|
|
|
612
653
|
console.log(items.length ? items.map((s) => ` ${s.name}${s.disabled ? ` ${DIM}(disabled)${RESET}` : ""}${s.editable ? "" : ` ${DIM}(read-only)${RESET}`} — ${s.description}`).join("\n") : `${DIM}No skills yet. Create them from the Privateer app.${RESET}`);
|
|
613
654
|
return true;
|
|
614
655
|
}
|
|
656
|
+
// The typed equivalent of shift+tab. A PHYSICAL-terminal action, like
|
|
657
|
+
// /remote-access: it's stronger than any mode (it also switches off the
|
|
658
|
+
// dangerous-command denylist and the protected-file guard), so a remote
|
|
659
|
+
// controller must not be able to reach it. The app's own no-quarter toggle
|
|
660
|
+
// covers driven turns and is `bypass` exactly — never weaker than /mode bypass.
|
|
661
|
+
if (line === "/no-quarter" || line.startsWith("/no-quarter ")) {
|
|
662
|
+
if (remote) {
|
|
663
|
+
relay?.sendNotice("/no-quarter is terminal-only — run it at the machine, or use the app's own no-quarter toggle.");
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
const arg = line.slice(11).trim().toLowerCase();
|
|
667
|
+
if (arg && arg !== "on" && arg !== "off") {
|
|
668
|
+
console.log(`${YELLOW}usage: /no-quarter [on|off] (currently ${noQuarterActive() ? "on" : "off"})${RESET}`);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
applyNoQuarter(arg ? arg === "on" : !noQuarterActive());
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
615
674
|
if (line.startsWith("/mode ")) { mode = line.slice(6).trim() as typeof mode; const m = `mode → ${mode}`; console.log(`${DIM}${m}${RESET}`); if (remote) relay?.sendNotice(m); return true; }
|
|
616
675
|
// Bare /mode → the picker (remote) or a hint (local).
|
|
617
676
|
if (line === "/mode") {
|
|
@@ -640,6 +699,7 @@ async function main() {
|
|
|
640
699
|
const builtins = [
|
|
641
700
|
{ name: "/model", description: "Switch the model" },
|
|
642
701
|
{ name: "/mode", description: "Change the approval mode (default/acceptEdits/plan/bypass)" },
|
|
702
|
+
// /no-quarter is deliberately absent — terminal-only, see runCommand.
|
|
643
703
|
{ name: "/models", description: "List available models" },
|
|
644
704
|
{ name: "/extensions", description: "Manage installed Pi extensions" },
|
|
645
705
|
{ name: "/skills", description: "Manage the terminal's skills" },
|
package/src/harbor/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { agentVersion } from "../config/version.ts";
|
|
|
14
14
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
15
15
|
import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
|
|
16
16
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
17
|
-
import { makeAccountProvider } from "../providers/account.ts";
|
|
17
|
+
import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
|
|
18
18
|
import { resolveDefaultModel } from "../providers/defaultModel.ts";
|
|
19
19
|
import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
20
20
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
@@ -30,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
|
30
30
|
import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
31
31
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
32
32
|
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
33
|
-
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
33
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke, defaultDeviceLabel } from "../auth/privateer.ts";
|
|
34
34
|
import {
|
|
35
35
|
loadRoutines,
|
|
36
36
|
upsertRoutine,
|
|
@@ -50,7 +50,7 @@ import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
|
50
50
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
51
51
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
52
52
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
53
|
-
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
+
import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
54
54
|
import { isHosted, publishRelayPub } from "../config/hosted.ts";
|
|
55
55
|
|
|
56
56
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
@@ -226,13 +226,19 @@ export class Harbor {
|
|
|
226
226
|
return "queued";
|
|
227
227
|
};
|
|
228
228
|
|
|
229
|
-
start(): void {
|
|
229
|
+
async start(): Promise<void> {
|
|
230
|
+
// Single-instance lock FIRST, before any other side effect: binding the IPC
|
|
231
|
+
// socket is the machine's mutex. If a live harbor already holds it this throws
|
|
232
|
+
// HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
|
|
233
|
+
// routineRelayId(), so a second would collide on the relay and double-fire
|
|
234
|
+
// routines. Doing this first means a rejected second instance never publishes a
|
|
235
|
+
// relay key, connects, or fires a tick.
|
|
236
|
+
this.server = await startIpcServer((req) => this.handleIpc(req));
|
|
230
237
|
// Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
|
|
231
238
|
// report. Before syncRelay() so the key exists by the time we're reachable.
|
|
232
239
|
publishRelayPub();
|
|
233
240
|
this.primeSchedule();
|
|
234
241
|
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
235
|
-
this.server = startIpcServer((req) => this.handleIpc(req));
|
|
236
242
|
this.syncRelay();
|
|
237
243
|
void this.flushPendingCloud();
|
|
238
244
|
const count = loadRoutines().filter((r) => r.enabled).length;
|
|
@@ -517,11 +523,22 @@ export class Harbor {
|
|
|
517
523
|
}
|
|
518
524
|
}
|
|
519
525
|
|
|
526
|
+
// This machine's origin tag, embedded (E2EE) in every sealed result so the app can
|
|
527
|
+
// show WHICH box/environment produced it — the outbox record itself is account-only,
|
|
528
|
+
// so attribution can only live inside the sealed blob (where hostnames are allowed;
|
|
529
|
+
// the server never sees it). `id` is this install's stable relay id; `label` is the
|
|
530
|
+
// hostname-based device name. Cached — it never changes for the process lifetime.
|
|
531
|
+
private originCache?: { id: string; label: string };
|
|
532
|
+
private machineOrigin(): { id: string; label: string } {
|
|
533
|
+
if (!this.originCache) this.originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
|
|
534
|
+
return this.originCache;
|
|
535
|
+
}
|
|
536
|
+
|
|
520
537
|
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: "routine" | "task" = "routine"): Promise<boolean> {
|
|
521
538
|
const pub = await this.ensureOutboxPub();
|
|
522
539
|
if (!pub) return false;
|
|
523
540
|
const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
524
|
-
const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body });
|
|
541
|
+
const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: this.machineOrigin() });
|
|
525
542
|
try {
|
|
526
543
|
const res = await apiRequest("/api/outbox", {
|
|
527
544
|
method: "POST",
|
|
@@ -727,7 +744,18 @@ export class Harbor {
|
|
|
727
744
|
cwd: spec.cwd,
|
|
728
745
|
agentDir: agentDir(),
|
|
729
746
|
resourceLoaderOptions: {
|
|
730
|
-
extensionFactories: [
|
|
747
|
+
extensionFactories: [
|
|
748
|
+
makePermissionGate(gate),
|
|
749
|
+
// Per-model verified-TEE capability for pi-privacy's /models picker: show
|
|
750
|
+
// Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
|
|
751
|
+
// when logged in; ZDR-channel models stay at their honest floor. The live
|
|
752
|
+
// verdict still comes from accountPosture on select — this only lifts the label.
|
|
753
|
+
makePiPrivacyExtension({
|
|
754
|
+
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
755
|
+
}),
|
|
756
|
+
makeAccountProvider(),
|
|
757
|
+
mcpAdapter,
|
|
758
|
+
] as any,
|
|
731
759
|
},
|
|
732
760
|
});
|
|
733
761
|
servicesRef = services as any;
|
|
@@ -834,8 +862,12 @@ export class Harbor {
|
|
|
834
862
|
this.relay?.sendTaskSpawned(handle.termId, handle.label);
|
|
835
863
|
log(`live task spawned: ${handle.termId} (${handle.label})`);
|
|
836
864
|
} catch (e) {
|
|
837
|
-
const
|
|
865
|
+
const reason = (e as Error).message;
|
|
866
|
+
const msg = `Couldn't spawn a live session: ${reason}`;
|
|
838
867
|
log(msg);
|
|
868
|
+
// Typed failure reply so the app's spawn screen stops waiting and shows why. Also send
|
|
869
|
+
// a notice as a back-compat fallback for older app builds that don't handle the frame.
|
|
870
|
+
this.relay?.sendTaskSpawnError(reason);
|
|
839
871
|
this.relay?.sendNotice(msg);
|
|
840
872
|
}
|
|
841
873
|
})();
|
|
@@ -1034,7 +1066,6 @@ export class Harbor {
|
|
|
1034
1066
|
// Entry point for `privateer harbor`. Caller must have imported ./boot.ts first.
|
|
1035
1067
|
export function runHarbor(): void {
|
|
1036
1068
|
const harbor = new Harbor();
|
|
1037
|
-
harbor.start();
|
|
1038
1069
|
const shutdown = () => {
|
|
1039
1070
|
log("shutting down");
|
|
1040
1071
|
harbor.stop();
|
|
@@ -1042,4 +1073,14 @@ export function runHarbor(): void {
|
|
|
1042
1073
|
};
|
|
1043
1074
|
process.on("SIGINT", shutdown);
|
|
1044
1075
|
process.on("SIGTERM", shutdown);
|
|
1076
|
+
harbor.start().catch((err) => {
|
|
1077
|
+
if (err instanceof HarborAlreadyRunningError) {
|
|
1078
|
+
// A resident harbor already owns this machine — leave it in charge. Exit 0 so a
|
|
1079
|
+
// manual `privateer harbor run` beside the installed login service isn't an error.
|
|
1080
|
+
process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
|
|
1081
|
+
process.exit(0);
|
|
1082
|
+
}
|
|
1083
|
+
process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1084
|
+
process.exit(1);
|
|
1085
|
+
});
|
|
1045
1086
|
}
|