privateer-agent 0.6.9 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -20
- package/SECURITY.md +1 -1
- package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
- package/bin/privateer-launch.mjs +7 -6
- package/bin/privateer-subagent.mjs +1 -1
- package/extensions/privateer-brand.ts +1 -1
- package/extensions/privateer-connect.ts +14 -3
- package/extensions/privateer-tools.ts +1 -1
- package/package.json +1 -1
- package/src/auth/privateer.ts +2 -2
- package/src/channels/run.ts +5 -5
- package/src/channels/status.ts +7 -7
- package/src/cli/chat.ts +29 -3
- package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
- package/src/config/hosted.ts +5 -5
- package/src/crypto/accountTrust.ts +2 -2
- package/src/crypto/accountVerify.ts +1 -1
- package/src/{daemon → harbor}/index.ts +37 -37
- package/src/{daemon → harbor}/ipc.ts +19 -19
- package/src/{daemon → harbor}/service.ts +61 -28
- package/src/main.ts +1 -1
- package/src/providers/account.ts +1 -1
- package/src/providers/defaultModel.ts +1 -1
- package/src/remote/channelsControl.ts +8 -8
- package/src/remote/controlAuth.ts +1 -1
- package/src/remote/liveTaskSession.ts +4 -4
- package/src/remote/mcpControl.ts +2 -2
- package/src/remote/relayClient.ts +41 -21
- package/src/remote/remoteBridge.ts +17 -7
- package/src/remote/routinesControl.ts +7 -7
- package/src/remote/workflowsControl.ts +6 -6
- package/src/routines/delivery.ts +6 -6
- package/src/routines/schema.ts +3 -3
- package/src/routines/store.ts +3 -3
- package/src/routines/trigger.ts +1 -1
- package/src/tools/routine.ts +8 -8
- package/src/util/fileMentions.ts +232 -0
- package/src/workflows/expr.ts +1 -1
- package/src/workflows/runner.ts +3 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1 -1
package/README.md
CHANGED
|
@@ -38,8 +38,8 @@ servers, sub-agents, scheduled routines, multi-step workflows, chat-app bridges,
|
|
|
38
38
|
one-tap approval from your phone are included — and every one of the agent's actions runs
|
|
39
39
|
through a **safe-by-default permission gate**.
|
|
40
40
|
|
|
41
|
-
Privateer runs in three places, over one account and one config: the **terminal**,
|
|
42
|
-
**background
|
|
41
|
+
Privateer runs in three places, over one account and one config: the **terminal**,
|
|
42
|
+
**Harbor** (a background service) for unattended work, and the **Privateer app** on
|
|
43
43
|
[phone, web](https://privateer.pro), and [desktop](#the-privateer-app).
|
|
44
44
|
|
|
45
45
|
## Why Privateer?
|
|
@@ -108,7 +108,7 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
108
108
|
terminal. See [The Privateer app](#the-privateer-app).
|
|
109
109
|
- **A desktop app.** The same agent hosted inside a local Electron shell — no relay hop, works
|
|
110
110
|
offline, multi-window with per-window MCP connectors. Shares your CLI login and config.
|
|
111
|
-
- **Scheduled routines.**
|
|
111
|
+
- **Scheduled routines.** Harbor, a background service, runs approved tasks unattended — cron or
|
|
112
112
|
one-off — and the agent can schedule its own follow-up work. Results deliver to a file,
|
|
113
113
|
the next session, your phone, email, or a webhook.
|
|
114
114
|
- **Declarative workflows.** Multi-step agent pipelines as YAML — typed steps, conditional
|
|
@@ -298,7 +298,7 @@ and a management surface for it.
|
|
|
298
298
|
| | |
|
|
299
299
|
|---|---|
|
|
300
300
|
| **Drive a session** | Send prompts, watch streamed output, and **Allow/Deny** each proposed action — including actions from sub-agents the session spawned |
|
|
301
|
-
| **Spawn an agent** | Start a one-shot task on the
|
|
301
|
+
| **Spawn an agent** | Start a one-shot task on the harbor: *background* (headless, read-only toolset, result sealed to your outbox) or *live* (a fresh drivable session) |
|
|
302
302
|
| **Routines** | Create, edit, pause, run, and delete scheduled unattended tasks |
|
|
303
303
|
| **Workflows** | List, run, and monitor multi-step workflows; answer `human_gate` steps to resume a paused run |
|
|
304
304
|
| **MCP connectors** | Add, edit, and enable MCP servers; credentials are sealed to the terminal and write-only |
|
|
@@ -325,24 +325,24 @@ Download for [macOS](https://privateer.pro/download/mac) (Apple silicon),
|
|
|
325
325
|
|
|
326
326
|
It's an early release and **not yet code-signed or notarized** — macOS will warn on first
|
|
327
327
|
open. Routines and channels deliberately aren't hosted here: those belong to the always-on
|
|
328
|
-
|
|
328
|
+
harbor, so background work still wants `privateer harbor install`.
|
|
329
329
|
|
|
330
|
-
## Run it unattended —
|
|
330
|
+
## Run it unattended — Harbor
|
|
331
331
|
|
|
332
|
-
|
|
333
|
-
spawns from the app — with no terminal open.
|
|
332
|
+
**Harbor** is a resident background service that runs scheduled routines, executes workflows,
|
|
333
|
+
and accepts task spawns from the app — with no terminal open.
|
|
334
334
|
|
|
335
335
|
```bash
|
|
336
|
-
privateer
|
|
337
|
-
privateer
|
|
338
|
-
privateer
|
|
339
|
-
privateer
|
|
336
|
+
privateer harbor install # install as a login service (auto-starts, reachable from the app)
|
|
337
|
+
privateer harbor status # service installed? harbor answering?
|
|
338
|
+
privateer harbor run # or just run it in the foreground
|
|
339
|
+
privateer harbor uninstall
|
|
340
340
|
```
|
|
341
341
|
|
|
342
342
|
Installs as a **launchd user agent** on macOS or a **`systemd --user` unit** on Linux — no
|
|
343
|
-
root, no sudo. (There's no Windows service path yet; use `privateer
|
|
343
|
+
root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.)
|
|
344
344
|
|
|
345
|
-
Everything the
|
|
345
|
+
Everything the harbor does still runs through the permission gate. Actions needing approval
|
|
346
346
|
surface in the app; routines you approved once run on their own schedule.
|
|
347
347
|
|
|
348
348
|
## Workflows
|
|
@@ -354,12 +354,12 @@ resumes when you answer it, including from your phone.
|
|
|
354
354
|
|
|
355
355
|
The engine ships in the standalone
|
|
356
356
|
[`privateer-workflow`](https://www.npmjs.com/package/privateer-workflow) package. Today the
|
|
357
|
-
user-facing surface is the **app** (save, run, monitor, share) and the **
|
|
357
|
+
user-facing surface is the **app** (save, run, monitor, share) and the **harbor** that
|
|
358
358
|
executes them — there's no `/workflow` command in the terminal yet. Schedule one by pointing
|
|
359
359
|
a routine at it.
|
|
360
360
|
|
|
361
361
|
Because a workflow can carry `script` steps, saving one from the app requires your **account
|
|
362
|
-
signature** — the server can't inject a workflow onto your
|
|
362
|
+
signature** — the server can't inject a workflow onto your harbor.
|
|
363
363
|
|
|
364
364
|
## Chat-app channels
|
|
365
365
|
|
|
@@ -410,14 +410,14 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
410
410
|
|
|
411
411
|
- **MCP servers** (`pi-mcp-adapter`) — declare them and their tools become first-class, gated
|
|
412
412
|
like the rest (local stdio, or remote HTTP with interactive OAuth). One catalog at
|
|
413
|
-
`~/.privateer/agent/mcp-desktop.json` is shared by the CLI, the
|
|
413
|
+
`~/.privateer/agent/mcp-desktop.json` is shared by the CLI, the harbor, and the desktop
|
|
414
414
|
app, so a machine has one coherent connector config.
|
|
415
415
|
- **Sub-agents** (`pi-subagents`) — delegate investigations to bounded parallel agents. Children
|
|
416
416
|
run as headless child processes that **inherit the moat**, so their actions hit the same
|
|
417
417
|
permission gate and their approvals surface on your phone.
|
|
418
|
-
- **Routines** — saved tasks the
|
|
418
|
+
- **Routines** — saved tasks the harbor runs unattended; ask the agent to schedule work and
|
|
419
419
|
approve it once.
|
|
420
|
-
- **Workflows** — declarative multi-step pipelines the
|
|
420
|
+
- **Workflows** — declarative multi-step pipelines the harbor executes; see
|
|
421
421
|
[Workflows](#workflows).
|
|
422
422
|
- **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
|
|
423
423
|
(self-hosted SearXNG for fully private search).
|
|
@@ -435,7 +435,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
435
435
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
436
436
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
|
437
437
|
|
|
438
|
-
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer
|
|
438
|
+
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
|
|
439
439
|
`privateer --no-quarter`, `privateer --version`.
|
|
440
440
|
|
|
441
441
|
## Develop
|
package/SECURITY.md
CHANGED
|
@@ -55,4 +55,4 @@ are sent only to the provider you selected. Bot tokens for messaging channels ar
|
|
|
55
55
|
to a terminal keypair before they reach our relay, and channel configuration is verified
|
|
56
56
|
against a link-pinned account key — the relay can neither read those tokens nor forge
|
|
57
57
|
configuration. Architecture and residual risks are documented in
|
|
58
|
-
`docs/
|
|
58
|
+
`docs/harbor-channels-and-app.md`.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Launcher for the resident Privateer
|
|
2
|
+
// Launcher for the resident Privateer harbor (routines + app-driven headless task
|
|
3
3
|
// spawns). Mirrors bin/privateer.mjs: load dev keys from the repo .env WITHOUT
|
|
4
4
|
// changing cwd, register tsx so TS resolves regardless of the invocation cwd, then
|
|
5
|
-
// hand off to the
|
|
5
|
+
// hand off to the harbor CLI dispatcher (which imports ./boot.ts before any Pi code).
|
|
6
6
|
//
|
|
7
|
-
// Invoked two ways: interactively via the bash launcher (`privateer
|
|
8
|
-
// by the installed launchd/systemd service (`node privateer-
|
|
7
|
+
// Invoked two ways: interactively via the bash launcher (`privateer harbor …`), and
|
|
8
|
+
// by the installed launchd/systemd service (`node privateer-harbor.mjs run`).
|
|
9
9
|
import { register } from "tsx/esm/api";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { dirname, resolve } from "node:path";
|
|
@@ -19,12 +19,12 @@ try {
|
|
|
19
19
|
/* no .env — rely on the ambient environment / ~/.privateer */
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// The
|
|
22
|
+
// The harbor loads the moat as in-code factories, so its subagent children (routines /
|
|
23
23
|
// task sessions) can't inherit it and `pi` isn't on PATH. Point pi-subagents at our
|
|
24
24
|
// moat-injecting wrapper so those children spawn gated + private with no double-load.
|
|
25
25
|
// Process-global is safe here: the wrapper is stateless (unlike a per-parent channel).
|
|
26
26
|
process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
|
|
27
27
|
|
|
28
28
|
register();
|
|
29
|
-
const {
|
|
30
|
-
await
|
|
29
|
+
const { runHarborCli } = await import(resolve(repo, "src/cli/harborCli.ts"));
|
|
30
|
+
await runHarborCli(process.argv.slice(2));
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -111,13 +111,14 @@ if (sub === "update") {
|
|
|
111
111
|
// runToCompletion exits via the child's exit handler.
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// --- `privateer
|
|
115
|
-
// The resident background
|
|
116
|
-
// straight into src/
|
|
117
|
-
//
|
|
118
|
-
|
|
114
|
+
// --- `privateer harbor [run|install|uninstall|status]` ---------------------
|
|
115
|
+
// The resident background harbor (routines + app-driven headless task spawns). Boots
|
|
116
|
+
// straight into src/harbor via bin/privateer-harbor.mjs — no moat-shim install (the
|
|
117
|
+
// harbor loads the moat as in-code factories, not interactive extensions).
|
|
118
|
+
// `daemon` is a hidden back-compat alias for the pre-rename command name.
|
|
119
|
+
else if (sub === "harbor" || sub === "daemon") {
|
|
119
120
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
120
|
-
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-
|
|
121
|
+
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
// --- normal launch: install the moat, then exec Pi's TUI -------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The binary pi-subagents spawns for each subagent child (via PI_SUBAGENT_PI_BINARY)
|
|
3
3
|
// when the PARENT loaded privateer's moat as IN-CODE extension factories (the lean
|
|
4
|
-
// REPL, the
|
|
4
|
+
// REPL, the harbor, live task sessions) rather than agent-dir discovery.
|
|
5
5
|
//
|
|
6
6
|
// Why a wrapper here (vs the plain cli.js the TUI uses): a subagent child is a fresh
|
|
7
7
|
// `pi` subprocess that can't inherit the parent's in-code factories. It CAN auto-
|
|
@@ -593,7 +593,7 @@ export default function privateerBrand(pi: any): void {
|
|
|
593
593
|
// in src/auth/privateer.ts): revokeLocalSessions kills the account session
|
|
594
594
|
// server-side, so leaving the persisted copy behind would make the NEXT launch
|
|
595
595
|
// reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
|
|
596
|
-
// refresh on a 401). Mirrors the
|
|
596
|
+
// refresh on a 401). Mirrors the harbor's shutdown (harbor/index.ts).
|
|
597
597
|
dropPersistedAccount(ctxRef);
|
|
598
598
|
});
|
|
599
599
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* makeMcpControl() the relay uses (src/remote/mcpControl.ts) — which is why that
|
|
13
13
|
* module was written framework-agnostic. A connector added here lands in
|
|
14
14
|
* `agent/mcp-desktop.json` and is projected into `agent/mcp.json`, so it shows up in
|
|
15
|
-
* the app's MCP screen, survives a toggle from the phone, and is read by the
|
|
15
|
+
* the app's MCP screen, survives a toggle from the phone, and is read by the harbor's
|
|
16
16
|
* routine runs. Nothing about this panel is terminal-specific except the pixels.
|
|
17
17
|
*
|
|
18
18
|
* SECRETS: env values are typed into a masked field and written in PLAINTEXT to the
|
|
@@ -74,6 +74,17 @@ class SecretField {
|
|
|
74
74
|
// are swallowed rather than inserted — the naive "strip control chars" approach
|
|
75
75
|
// would leave "[A" behind from an up-arrow and silently corrupt the token.
|
|
76
76
|
handleInput(data: string): boolean {
|
|
77
|
+
// A PASTE MUST BE UNWRAPPED FIRST. pi-tui's Terminal re-wraps pasted text in
|
|
78
|
+
// bracketed-paste markers and delivers it as ONE chunk (`\x1b[200~…\x1b[201~`,
|
|
79
|
+
// see pi-tui terminal.ts) — which starts with ESC, so the arrow-key guard below
|
|
80
|
+
// would otherwise swallow the entire token and leave the field empty.
|
|
81
|
+
const paste = /^\x1b\[200~([\s\S]*?)\x1b\[201~$/.exec(data);
|
|
82
|
+
if (paste) {
|
|
83
|
+
// Control chars (a trailing newline from the clipboard, most often) are stripped
|
|
84
|
+
// the same way typed input is — a token never legitimately contains one.
|
|
85
|
+
this.value += paste[1].replace(/[\x00-\x1f\x7f]/g, "");
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
77
88
|
if (data.startsWith("\x1b")) return true;
|
|
78
89
|
if (data === "\x7f" || data === "\b") {
|
|
79
90
|
this.value = this.value.slice(0, -1);
|
|
@@ -84,7 +95,7 @@ class SecretField {
|
|
|
84
95
|
this.value = "";
|
|
85
96
|
return true;
|
|
86
97
|
}
|
|
87
|
-
//
|
|
98
|
+
// Ordinary typed input (pastes took the branch above).
|
|
88
99
|
const printable = data.replace(/[\x00-\x1f\x7f]/g, "");
|
|
89
100
|
if (!printable) return false;
|
|
90
101
|
this.value += printable;
|
|
@@ -383,7 +394,7 @@ class ConnectPanel extends Container {
|
|
|
383
394
|
const t = this.theme;
|
|
384
395
|
this.body.addChild(new Text(t.fg("accent", t.bold("Connect a service")), 1, 0));
|
|
385
396
|
this.body.addChild(
|
|
386
|
-
new Text(t.fg("muted", "MCP connectors on this machine — shared with the app and the
|
|
397
|
+
new Text(t.fg("muted", "MCP connectors on this machine — shared with the app and the harbor."), 1, 0),
|
|
387
398
|
);
|
|
388
399
|
this.body.addChild(new Spacer(1));
|
|
389
400
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Privateer-specific custom tools for Pi's TUI (Phase 5). Today: create_routine
|
|
2
|
-
// (schedule unattended tasks → the
|
|
2
|
+
// (schedule unattended tasks → the harbor runs them). The generic tools (read/edit/
|
|
3
3
|
// bash/grep, web, subagents, todo) come from Pi builtins + adopted packages, so only
|
|
4
4
|
// the privateer-only tools live here. Gated by our permission-gate extension.
|
|
5
5
|
import { routineToolDefinition } from "../src/tools/routine.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
package/src/auth/privateer.ts
CHANGED
|
@@ -152,7 +152,7 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
|
152
152
|
// remove the "privateer" entry from Pi's authStorage (authStorage.remove("privateer"))
|
|
153
153
|
// right after revokeLocalSessions() so the next launch spawns a fresh session instead
|
|
154
154
|
// of reusing the revoked one. Doing both is safe; doing only one is not. See
|
|
155
|
-
// revokeLocalSessions and its callers (cli/chat.ts,
|
|
155
|
+
// revokeLocalSessions and its callers (cli/chat.ts, harbor/index.ts).
|
|
156
156
|
//
|
|
157
157
|
// That pairing only covers a CLEAN exit, though. A terminal killed without running its
|
|
158
158
|
// shutdown hook leaves its row alive server-side for the full TTL, and the next launch
|
|
@@ -625,7 +625,7 @@ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
|
625
625
|
* launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
|
|
626
626
|
* copy right after this resolves — `authStorage.remove("privateer")` — or the next run
|
|
627
627
|
* will reuse the token we just revoked and dead-end on a 401 (see the _account note).
|
|
628
|
-
* Callers: cli/chat.ts cleanup() and
|
|
628
|
+
* Callers: cli/chat.ts cleanup() and harbor/index.ts shutdown().
|
|
629
629
|
*/
|
|
630
630
|
export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
|
|
631
631
|
await Promise.all([revokeChildSession(timeoutMs), revokeAccountSession(timeoutMs)]);
|
package/src/channels/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Headless entry for the messaging channels — `npm run channels` (or
|
|
2
2
|
// `node --env-file=.env --import tsx src/channels/run.ts`). Boots the Pi stack the
|
|
3
|
-
// same way the routines
|
|
3
|
+
// same way the routines harbor does, then bridges any configured chat platform
|
|
4
4
|
// (Telegram, Slack) to per-conversation agent sessions.
|
|
5
5
|
//
|
|
6
6
|
// AUTHORIZATION MODEL (per channel):
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// posture). Every prompt/approval is appended to ~/.privateer/channels-audit.log.
|
|
24
24
|
// Tokens live in config.json in plaintext — protect that file's permissions.
|
|
25
25
|
//
|
|
26
|
-
// Config lives in ~/.privateer/config.json (the same file the
|
|
26
|
+
// Config lives in ~/.privateer/config.json (the same file the harbor reads):
|
|
27
27
|
// {
|
|
28
28
|
// "defaultModel": "openrouter/openai/gpt-4o-mini",
|
|
29
29
|
// "channels": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
48
48
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
49
49
|
|
|
50
|
-
// Read-only default toolset — same rationale as the routines
|
|
50
|
+
// Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
|
|
51
51
|
// a turn nobody is watching can't mutate the filesystem or shell out. Now that the
|
|
52
52
|
// gate routes approvals into the chat (see below), a user can safely widen this per
|
|
53
53
|
// channel via `channels.tools` — e.g. add "edit","write","bash" and each risky call
|
|
@@ -64,7 +64,7 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
|
64
64
|
type Posture = "readonly" | "approve" | "auto";
|
|
65
65
|
const POSTURES: Posture[] = ["readonly", "approve", "auto"];
|
|
66
66
|
|
|
67
|
-
// Bound the live session map so a long-running
|
|
67
|
+
// Bound the live session map so a long-running harbor can't grow without limit or
|
|
68
68
|
// hold stale context forever.
|
|
69
69
|
const MAX_SESSIONS = 500;
|
|
70
70
|
const SESSION_IDLE_MS = 30 * 60 * 1000; // evict a conversation unused for 30 min
|
|
@@ -255,7 +255,7 @@ async function main() {
|
|
|
255
255
|
// ── build a bridge per configured platform ───────────────────────────────────
|
|
256
256
|
const bridges: { stop(): void }[] = [];
|
|
257
257
|
// Platforms with a live bridge — written to the heartbeat file so the app's
|
|
258
|
-
// channels manager (running on the
|
|
258
|
+
// channels manager (running on the harbor's relay, a separate process) can show
|
|
259
259
|
// a live/offline badge without talking to this process.
|
|
260
260
|
const startedPlatforms: string[] = [];
|
|
261
261
|
|
package/src/channels/status.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Channels
|
|
2
|
-
//
|
|
3
|
-
// (
|
|
1
|
+
// Channels harbor heartbeat — the one bit of shared state between the channels
|
|
2
|
+
// harbor (channels/run.ts, the WRITER) and the always-on management relay
|
|
3
|
+
// (harbor/index.ts → channelsControl, the READER).
|
|
4
4
|
//
|
|
5
5
|
// The two run as SEPARATE processes, so the manager can't ask the channels
|
|
6
|
-
//
|
|
6
|
+
// harbor directly whether it's live. Instead the channels harbor writes a small
|
|
7
7
|
// heartbeat file with the platforms it's currently serving; the reader treats a
|
|
8
8
|
// FRESH heartbeat as "running" and a stale/absent one as "not running". This is
|
|
9
9
|
// best-effort presence, never a dependency: a missing file just means the app
|
|
@@ -13,7 +13,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { globalDir } from "../config/paths.ts";
|
|
15
15
|
|
|
16
|
-
// Heartbeat cadence + freshness window. The channels
|
|
16
|
+
// Heartbeat cadence + freshness window. The channels harbor rewrites the file
|
|
17
17
|
// every HEARTBEAT_MS; a heartbeat older than STALE_MS is treated as dead (the
|
|
18
18
|
// process exited without clearing it, or wedged).
|
|
19
19
|
export const HEARTBEAT_MS = 30_000;
|
|
@@ -40,8 +40,8 @@ export function writeChannelsStatus(platforms: string[]): void {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// READER: the set of platforms the channels
|
|
44
|
-
// empty set when the
|
|
43
|
+
// READER: the set of platforms the channels harbor is currently serving, or an
|
|
44
|
+
// empty set when the harbor is down / the heartbeat is stale. Never throws.
|
|
45
45
|
export function readRunningPlatforms(): Set<string> {
|
|
46
46
|
try {
|
|
47
47
|
const status = JSON.parse(readFileSync(statusPath(), "utf8")) as ChannelsStatus;
|
package/src/cli/chat.ts
CHANGED
|
@@ -36,6 +36,7 @@ async function main() {
|
|
|
36
36
|
const { makeExtensionsControl } = await import("../remote/extensionsControl.ts");
|
|
37
37
|
const { makeSkillsControl } = await import("../remote/skillsControl.ts");
|
|
38
38
|
const { authorizeControl } = await import("../remote/controlAuth.ts");
|
|
39
|
+
const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
|
|
39
40
|
const priv = await import("../auth/privateer.ts");
|
|
40
41
|
const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
|
|
41
42
|
const { agentVersion } = await import("../config/version.ts");
|
|
@@ -59,7 +60,14 @@ async function main() {
|
|
|
59
60
|
// /model switches, so the app banner + picker reflect what's actually selected.
|
|
60
61
|
let currentSpec = spec;
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
// Tab-completes an `@path` mention against the cwd tree (files the user can
|
|
64
|
+
// reference in a prompt). readline calls this with the line up to the cursor; a
|
|
65
|
+
// non-mention line returns no hits, leaving normal input untouched. Async form
|
|
66
|
+
// (callback) so the filesystem read doesn't block the event loop.
|
|
67
|
+
const completer = (line: string, cb: (err: null, result: [string[], string]) => void): void => {
|
|
68
|
+
void completeMention(line, cwd).then((r) => cb(null, r)).catch(() => cb(null, [[], line]));
|
|
69
|
+
};
|
|
70
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, completer });
|
|
63
71
|
let closed = false;
|
|
64
72
|
rl.on("close", () => (closed = true));
|
|
65
73
|
// Local terminal output is coalesced and prompt-aware. Two stutter sources it kills:
|
|
@@ -152,6 +160,13 @@ async function main() {
|
|
|
152
160
|
relay?.sendCommands(availableCommands());
|
|
153
161
|
},
|
|
154
162
|
onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
|
|
163
|
+
// The app composer is autocompleting an `@file` mention — list the cwd entries
|
|
164
|
+
// matching the query and reply. Read-only + cwd-constrained (searchFiles never
|
|
165
|
+
// escapes the subtree); resolution of the picked path happens on the prompt turn.
|
|
166
|
+
onFilesSearch: (id, query) => void (async () => {
|
|
167
|
+
try { bridge.sendFileMatches(id, await searchFiles(query, cwd)); }
|
|
168
|
+
catch { bridge.sendFileMatches(id, []); }
|
|
169
|
+
})(),
|
|
155
170
|
// The app's extensions manager: list the user's installed Pi extensions, or
|
|
156
171
|
// add/remove one. add/remove persist immediately but only load on the next
|
|
157
172
|
// terminal launch — so the final frame flags needsRestart. See runExtMutation.
|
|
@@ -258,7 +273,18 @@ async function main() {
|
|
|
258
273
|
turnActive = true;
|
|
259
274
|
if (remote && echo) console.log(`\n${DIM}⟿ [app] ${text.slice(0, 80)}${RESET}`);
|
|
260
275
|
try {
|
|
261
|
-
|
|
276
|
+
// Expand any `@path` mentions into appended <file> blocks + image attachments,
|
|
277
|
+
// resolved against this terminal's cwd (constrained to the cwd subtree). Both a
|
|
278
|
+
// locally-typed prompt and an app-driven one land here, so both get it. A prompt
|
|
279
|
+
// with no resolvable mention passes through unchanged. Unresolved tokens stay
|
|
280
|
+
// inline; note them so a typo'd path isn't silently ignored.
|
|
281
|
+
const mentions = await resolveMentions(text, cwd);
|
|
282
|
+
if (mentions.skipped.length) {
|
|
283
|
+
const m = `Couldn't attach: ${mentions.skipped.join(", ")} (must be a file inside ${cwd})`;
|
|
284
|
+
console.log(`${DIM}${m}${RESET}`);
|
|
285
|
+
if (remote) relay?.sendNotice(m);
|
|
286
|
+
}
|
|
287
|
+
await session.prompt(mentions.text, mentions.images.length ? { images: mentions.images } : undefined);
|
|
262
288
|
} catch (e) {
|
|
263
289
|
console.log(`\n${RED}${(e as Error).message}${RESET}`);
|
|
264
290
|
} finally {
|
|
@@ -500,7 +526,7 @@ async function main() {
|
|
|
500
526
|
);
|
|
501
527
|
|
|
502
528
|
const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /quit";
|
|
503
|
-
console.log(`${DIM}Ready. Type a prompt. ${HELP}${RESET}`);
|
|
529
|
+
console.log(`${DIM}Ready. Type a prompt — reference a file with @path (Tab completes). ${HELP}${RESET}`);
|
|
504
530
|
await showPosture();
|
|
505
531
|
|
|
506
532
|
// The available model catalog as sorted "provider/id" specs. Same source the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// `privateer
|
|
1
|
+
// `privateer harbor [run|install|uninstall|status]` dispatcher.
|
|
2
2
|
//
|
|
3
3
|
// ORDERING CONTRACT: import ./boot.ts FIRST (env pin + attestation dispatcher), then
|
|
4
|
-
// DYNAMICALLY import the
|
|
4
|
+
// DYNAMICALLY import the harbor (which pulls the Pi session stack) only when we
|
|
5
5
|
// actually run it — so boot's side effects are guaranteed to precede any Pi import.
|
|
6
6
|
// The service/status subcommands touch no Pi code, so they can run without paying the
|
|
7
7
|
// session-stack import cost.
|
|
@@ -9,30 +9,30 @@ import "../boot.ts";
|
|
|
9
9
|
|
|
10
10
|
function usage(): string {
|
|
11
11
|
return [
|
|
12
|
-
"Usage: privateer
|
|
12
|
+
"Usage: privateer harbor [command]",
|
|
13
13
|
"",
|
|
14
|
-
" run Run
|
|
14
|
+
" run Run Harbor in the foreground (default).",
|
|
15
15
|
" install Install it as a login service so it auto-starts and stays",
|
|
16
16
|
" reachable from the app even with no terminal open.",
|
|
17
17
|
" uninstall Remove the login service.",
|
|
18
|
-
" status Show whether the service is installed and
|
|
18
|
+
" status Show whether the service is installed and Harbor is live.",
|
|
19
19
|
].join("\n");
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function
|
|
22
|
+
export async function runHarborCli(argv: string[]): Promise<void> {
|
|
23
23
|
const sub = argv[0] ?? "run";
|
|
24
24
|
switch (sub) {
|
|
25
25
|
case "run": {
|
|
26
26
|
// Pi-touching — dynamic import AFTER boot.
|
|
27
|
-
const {
|
|
28
|
-
|
|
27
|
+
const { runHarbor } = await import("../harbor/index.ts");
|
|
28
|
+
runHarbor(); // installs its own SIGINT/SIGTERM handlers and blocks
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
case "install": {
|
|
32
|
-
const { installService } = await import("../
|
|
32
|
+
const { installService } = await import("../harbor/service.ts");
|
|
33
33
|
try {
|
|
34
34
|
const info = installService();
|
|
35
|
-
process.stdout.write(`
|
|
35
|
+
process.stdout.write(`Harbor installed as a login service.\n unit: ${info.unitPath}\n logs: ${info.logPath}\nIt will start now and on every login. Manage with \`privateer harbor status|uninstall\`.\n`);
|
|
36
36
|
} catch (err) {
|
|
37
37
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
38
38
|
process.exit(1);
|
|
@@ -40,10 +40,10 @@ export async function runDaemonCli(argv: string[]): Promise<void> {
|
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
case "uninstall": {
|
|
43
|
-
const { uninstallService } = await import("../
|
|
43
|
+
const { uninstallService } = await import("../harbor/service.ts");
|
|
44
44
|
try {
|
|
45
45
|
uninstallService();
|
|
46
|
-
process.stdout.write("
|
|
46
|
+
process.stdout.write("Harbor login service removed.\n");
|
|
47
47
|
} catch (err) {
|
|
48
48
|
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
49
49
|
process.exit(1);
|
|
@@ -51,7 +51,7 @@ export async function runDaemonCli(argv: string[]): Promise<void> {
|
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
case "status": {
|
|
54
|
-
const { statusReport } = await import("../
|
|
54
|
+
const { statusReport } = await import("../harbor/service.ts");
|
|
55
55
|
process.stdout.write((await statusReport()) + "\n");
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
@@ -61,7 +61,7 @@ export async function runDaemonCli(argv: string[]): Promise<void> {
|
|
|
61
61
|
process.stdout.write(usage() + "\n");
|
|
62
62
|
return;
|
|
63
63
|
default:
|
|
64
|
-
process.stderr.write(`Unknown
|
|
64
|
+
process.stderr.write(`Unknown harbor command: ${sub}\n\n${usage()}\n`);
|
|
65
65
|
process.exit(1);
|
|
66
66
|
}
|
|
67
67
|
}
|
package/src/config/hosted.ts
CHANGED
|
@@ -5,11 +5,11 @@ import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
|
5
5
|
|
|
6
6
|
// Harbor hosted mode.
|
|
7
7
|
//
|
|
8
|
-
// When true, this
|
|
8
|
+
// When true, this harbor is running inside Privateer's confidential-VM fleet
|
|
9
9
|
// (the host orchestrator sets HARBOR_HOSTED=1), not on a user's own machine.
|
|
10
|
-
// Hosted
|
|
10
|
+
// Hosted harbors run on-demand: they report their next routine fire time to the
|
|
11
11
|
// server and idle-suspend when there's no work, so the server can wake them
|
|
12
|
-
// again in time. A
|
|
12
|
+
// again in time. A harbor on a user's laptop leaves this off and keeps running
|
|
13
13
|
// its own cron continuously.
|
|
14
14
|
//
|
|
15
15
|
// Read via process.env at call time, mirroring PRIVATEER_HOME / PRIVATEER_SERVER_URL.
|
|
@@ -18,7 +18,7 @@ export function isHosted(): boolean {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Publish this
|
|
21
|
+
* Publish this harbor's relay identity key so the Harbor host can attest it.
|
|
22
22
|
*
|
|
23
23
|
* ATTESTATION CONTRACT (host side: treeview `server/services/harborOrchestrator/`):
|
|
24
24
|
* the orchestrator mints the SEV-SNP report on the CVM host — configfs-tsm is a
|
|
@@ -34,7 +34,7 @@ export function isHosted(): boolean {
|
|
|
34
34
|
* before the relay registers.
|
|
35
35
|
*
|
|
36
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
|
|
37
|
+
* failure must never take the harbor down — attestation simply fail-closes host-side
|
|
38
38
|
* with HARBOR_ATTEST_NO_KEY rather than reporting a false "attested".
|
|
39
39
|
*/
|
|
40
40
|
export function publishRelayPub(): void {
|
|
@@ -72,11 +72,11 @@ export function clearAccountSignKey(): void {
|
|
|
72
72
|
// watermark for its terminal is a replay/rollback of an older signed envelope and is
|
|
73
73
|
// refused; at-or-above is accepted (an idempotent replay of the latest is harmless).
|
|
74
74
|
//
|
|
75
|
-
// Keyed by termId — NOT global — so the always-on
|
|
75
|
+
// Keyed by termId — NOT global — so the always-on harbor (stable routines-… id) and
|
|
76
76
|
// each interactive terminal (its own id) don't cross-reject each other's frames when
|
|
77
77
|
// the app drives them near-simultaneously with independent ts streams. Each signed
|
|
78
78
|
// frame binds its termId (see accountVerify.controlMessage), so a per-terminal
|
|
79
|
-
// watermark is the matching granularity. Persisted so it survives a
|
|
79
|
+
// watermark is the matching granularity. Persisted so it survives a harbor restart;
|
|
80
80
|
// deliberately NOT cleared on logout (see clearAccountSignKey — M1).
|
|
81
81
|
function tsPath(): string {
|
|
82
82
|
return join(globalDir(), "control-sig.json");
|
|
@@ -32,7 +32,7 @@ function canonicalize(value: unknown): string {
|
|
|
32
32
|
|
|
33
33
|
// The signed envelope. `termId` binds the intended recipient terminal (so a signature
|
|
34
34
|
// for terminal X can't be replayed against Y — verification uses OUR termId). `ts`
|
|
35
|
-
// binds freshness (the
|
|
35
|
+
// binds freshness (the harbor rejects a ts it has already applied → no replay/rollback).
|
|
36
36
|
export interface ChannelSaveEnvelope {
|
|
37
37
|
termId: string;
|
|
38
38
|
ts: number;
|