privateer-agent 0.3.6 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +65 -24
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +384 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +395 -32
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +516 -48
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/providers/account.ts +7 -1
- package/src/providers/defaultModel.ts +119 -0
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel management for the app — the sibling of routinesControl.ts, but for the
|
|
3
|
+
* messaging-channel config (Telegram/Slack/Discord/WhatsApp) rather than scheduled
|
|
4
|
+
* routines.
|
|
5
|
+
*
|
|
6
|
+
* Like routines, this is owned by the ALWAYS-ON daemon relay (daemon/index.ts),
|
|
7
|
+
* NOT the channels daemon (channels/run.ts) — which may be down, and which the app
|
|
8
|
+
* must be able to configure before it has ever run. So this control edits the
|
|
9
|
+
* `channels` block of ~/.privateer/config.json; the channels daemon adopts changes
|
|
10
|
+
* on its next RESTART, matching run.ts's deliberate "no in-chat toggle, restart is
|
|
11
|
+
* the fail-safe reset" posture. `running` is a best-effort read of the channels
|
|
12
|
+
* daemon's heartbeat (channels/status.ts), never a dependency.
|
|
13
|
+
*
|
|
14
|
+
* SECRETS: bot tokens are WRITE-ONLY from the app's perspective. list() NEVER
|
|
15
|
+
* returns a token value — it reports `configured`, `running`, and `secretsSet`
|
|
16
|
+
* (which secret fields are present, by name only). save() persists whatever secret
|
|
17
|
+
* VALUES it is handed in `draft.secrets`; the seal/open of those values in transit
|
|
18
|
+
* is the caller's job (Phase 3), so this module only ever deals in the plaintext
|
|
19
|
+
* config.json it already owns.
|
|
20
|
+
*
|
|
21
|
+
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
22
|
+
* frame plumbing and the running-presence read.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { configPath } from "../config/paths.ts";
|
|
26
|
+
|
|
27
|
+
// The platforms channels/run.ts knows how to start. Order is the app's display
|
|
28
|
+
// order. Keep in sync with the `startChannel` calls in run.ts.
|
|
29
|
+
export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
|
|
30
|
+
export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
|
|
31
|
+
|
|
32
|
+
const POSTURES = ["readonly", "approve", "auto"] as const;
|
|
33
|
+
export type ChannelPosture = (typeof POSTURES)[number];
|
|
34
|
+
|
|
35
|
+
// The secret (never-echoed) fields per platform — the union of the token blocks
|
|
36
|
+
// run.ts requires to START each platform. `secretsSet` reports presence of these.
|
|
37
|
+
const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
|
|
38
|
+
telegram: ["botToken"],
|
|
39
|
+
slack: ["appToken", "botToken"],
|
|
40
|
+
discord: ["botToken"],
|
|
41
|
+
whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Non-secret projection of one platform's config, sent to the app. No token
|
|
45
|
+
// values, ever — only which secret fields are already present (`secretsSet`).
|
|
46
|
+
export interface RemoteChannel {
|
|
47
|
+
platform: ChannelPlatform;
|
|
48
|
+
configured: boolean; // a config block exists for this platform
|
|
49
|
+
running: boolean; // the channels daemon is currently serving it
|
|
50
|
+
adminCount: number;
|
|
51
|
+
memberCount: number;
|
|
52
|
+
posture: ChannelPosture;
|
|
53
|
+
tools: string[];
|
|
54
|
+
model?: string;
|
|
55
|
+
secretsSet: string[]; // e.g. ["botToken"] — names only, never values
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// An app-submitted edit. Non-secret fields REPLACE when present; `secrets` maps a
|
|
59
|
+
// secret field name → its (already-opened) value, and only present, non-empty
|
|
60
|
+
// values overwrite — omitted means "keep the existing value".
|
|
61
|
+
export interface ChannelDraft {
|
|
62
|
+
platform: ChannelPlatform;
|
|
63
|
+
admins?: string[];
|
|
64
|
+
members?: string[];
|
|
65
|
+
posture?: ChannelPosture;
|
|
66
|
+
tools?: string[];
|
|
67
|
+
model?: string;
|
|
68
|
+
secrets?: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ChannelsControl {
|
|
72
|
+
// All four platforms, always — configured or not — so the app can show empty
|
|
73
|
+
// slots to set up. Order follows CHANNEL_PLATFORMS.
|
|
74
|
+
list(): RemoteChannel[];
|
|
75
|
+
// Create or edit a platform's config. Validates posture + fail-closes on a block
|
|
76
|
+
// with no admins/members (mirrors run.ts). Returns a one-line result.
|
|
77
|
+
save(draft: ChannelDraft): { ok: boolean; message?: string };
|
|
78
|
+
// Delete a platform's config entirely. ok:false when nothing was configured.
|
|
79
|
+
remove(platform: ChannelPlatform): { ok: boolean; message?: string };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isPlatform(v: unknown): v is ChannelPlatform {
|
|
83
|
+
return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizePosture(v: unknown): ChannelPosture | undefined {
|
|
87
|
+
return typeof v === "string" && (POSTURES as readonly string[]).includes(v) ? (v as ChannelPosture) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cleanStrList(v: unknown): string[] | undefined {
|
|
91
|
+
if (!Array.isArray(v)) return undefined;
|
|
92
|
+
const out = v.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
93
|
+
return out.length > 0 ? out : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function makeChannelsControl(opts: {
|
|
97
|
+
// Which platforms the channels daemon is serving right now (heartbeat read).
|
|
98
|
+
// Absent → everything reports not-running.
|
|
99
|
+
runningPlatforms?: () => Set<string>;
|
|
100
|
+
}): ChannelsControl {
|
|
101
|
+
const running = opts.runningPlatforms ?? (() => new Set<string>());
|
|
102
|
+
|
|
103
|
+
function readCfg(): any {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(configPath(), "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toRemote(platform: ChannelPlatform, block: any, live: Set<string>): RemoteChannel {
|
|
112
|
+
const admins = block?.admins ?? block?.allowFrom ?? []; // legacy allowFrom == admins
|
|
113
|
+
return {
|
|
114
|
+
platform,
|
|
115
|
+
configured: !!block,
|
|
116
|
+
running: live.has(platform),
|
|
117
|
+
adminCount: Array.isArray(admins) ? admins.length : 0,
|
|
118
|
+
memberCount: Array.isArray(block?.members) ? block.members.length : 0,
|
|
119
|
+
posture: normalizePosture(block?.posture) ?? "approve",
|
|
120
|
+
tools: Array.isArray(block?.tools) ? block.tools.map(String) : [],
|
|
121
|
+
model: typeof block?.model === "string" ? block.model : undefined,
|
|
122
|
+
secretsSet: SECRET_FIELDS[platform].filter((f) => block?.[f]),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
list(): RemoteChannel[] {
|
|
128
|
+
const ch = readCfg().channels ?? {};
|
|
129
|
+
const live = running();
|
|
130
|
+
return CHANNEL_PLATFORMS.map((p) => toRemote(p, ch[p], live));
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
save(draft: ChannelDraft): { ok: boolean; message?: string } {
|
|
134
|
+
if (!isPlatform(draft?.platform)) return { ok: false, message: "Unknown platform." };
|
|
135
|
+
if (draft.posture !== undefined && !normalizePosture(draft.posture))
|
|
136
|
+
return { ok: false, message: "Invalid posture." };
|
|
137
|
+
|
|
138
|
+
const cfg = readCfg();
|
|
139
|
+
cfg.channels ??= {};
|
|
140
|
+
const block: any = { ...(cfg.channels[draft.platform] ?? {}) };
|
|
141
|
+
|
|
142
|
+
// Non-secret fields replace when provided. An explicit empty array clears.
|
|
143
|
+
const admins = cleanStrList(draft.admins);
|
|
144
|
+
if (admins !== undefined) block.admins = admins;
|
|
145
|
+
const members = cleanStrList(draft.members);
|
|
146
|
+
if (members !== undefined) block.members = members;
|
|
147
|
+
if (draft.posture !== undefined) block.posture = draft.posture;
|
|
148
|
+
const tools = cleanStrList(draft.tools);
|
|
149
|
+
if (tools !== undefined) block.tools = tools;
|
|
150
|
+
if (draft.model !== undefined) {
|
|
151
|
+
const m = String(draft.model).trim();
|
|
152
|
+
if (m) block.model = m;
|
|
153
|
+
else delete block.model;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Secrets: a present, non-empty value overwrites; omitted keeps the existing.
|
|
157
|
+
for (const [k, v] of Object.entries(draft.secrets ?? {})) {
|
|
158
|
+
if (!SECRET_FIELDS[draft.platform].includes(k)) continue;
|
|
159
|
+
const val = String(v ?? "").trim();
|
|
160
|
+
if (val) block[k] = val;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Fail-closed: never persist a block that can't authorize anyone (mirrors
|
|
164
|
+
// run.ts:261 — a channel with no admins/members is skipped anyway).
|
|
165
|
+
const hasAdmins = Array.isArray(block.admins) && block.admins.length > 0;
|
|
166
|
+
const hasMembers = Array.isArray(block.members) && block.members.length > 0;
|
|
167
|
+
if (!hasAdmins && !hasMembers)
|
|
168
|
+
return { ok: false, message: "Add at least one admin before saving." };
|
|
169
|
+
|
|
170
|
+
cfg.channels[draft.platform] = block;
|
|
171
|
+
try {
|
|
172
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, message: `Saved ${draft.platform}. Restart the channels daemon to apply.` };
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
remove(platform: ChannelPlatform): { ok: boolean; message?: string } {
|
|
180
|
+
if (!isPlatform(platform)) return { ok: false, message: "Unknown platform." };
|
|
181
|
+
const cfg = readCfg();
|
|
182
|
+
if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
|
|
183
|
+
delete cfg.channels[platform];
|
|
184
|
+
try {
|
|
185
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, message: `Removed ${platform}. Restart the channels daemon to apply.` };
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Shared fail-closed gate for signed app->terminal control frames (H2).
|
|
2
|
+
//
|
|
3
|
+
// Every mutating control frame the app sends over the (untrusted) relay --
|
|
4
|
+
// routines_save/delete/set_enabled/run, extensions_add/remove,
|
|
5
|
+
// skills_create/delete/set_enabled, channels_remove -- is signed by the account and
|
|
6
|
+
// MUST be verified before it takes effect, or a malicious server could forge it (a
|
|
7
|
+
// forged routine yields a headless-bypass session = RCE; a forged extensions_add
|
|
8
|
+
// installs an npm package = RCE; a forged skills_create injects an auto-invoked
|
|
9
|
+
// skill). channels_save has its own bespoke verify (it also carries sealed secrets);
|
|
10
|
+
// everything else routes through here.
|
|
11
|
+
//
|
|
12
|
+
// Both loci call this: the daemon (routines_*/channels_remove, termId = routineRelayId)
|
|
13
|
+
// and each interactive terminal (extensions_*/skills_*, termId = the relay's id).
|
|
14
|
+
//
|
|
15
|
+
// Fail-closed: no pinned account key, a missing signature, a bad signature, or a stale
|
|
16
|
+
// ts all reject the mutation. On success the per-terminal replay watermark advances.
|
|
17
|
+
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
18
|
+
import { verifyControl } from "../crypto/accountVerify.ts";
|
|
19
|
+
|
|
20
|
+
export interface ControlAuthResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
message?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Authorize a mutating control frame against the pinned account key + replay watermark.
|
|
27
|
+
* `termId` is THIS terminal's id (verification binds it, so a signature for another
|
|
28
|
+
* terminal won't match). Returns { ok:true } to proceed, or { ok:false, message } to
|
|
29
|
+
* refuse — the caller surfaces the message and does NOT perform the mutation.
|
|
30
|
+
*
|
|
31
|
+
* `opts.strict` (default false) governs the watermark comparison:
|
|
32
|
+
* - non-strict: reject ts BELOW the watermark; ACCEPT at-or-above. Correct for
|
|
33
|
+
* idempotent config mutations (routines/skills/extensions/channels save|delete),
|
|
34
|
+
* where replaying the latest signed frame just re-applies the same state — harmless.
|
|
35
|
+
* - strict: reject ts AT-or-below the watermark. Required for NON-idempotent, effectful
|
|
36
|
+
* actions (task_submit/task_spawn — each RUNS a headless session), where a malicious
|
|
37
|
+
* relay replaying the latest signed frame (same ts) would re-run the task / spawn
|
|
38
|
+
* another session (inference-cost + resource abuse). Strict forces every accepted
|
|
39
|
+
* effectful frame to carry a strictly-fresh ts, which the server cannot fabricate (it
|
|
40
|
+
* can't sign) — so it can only replay old frames, all of which are now refused.
|
|
41
|
+
*/
|
|
42
|
+
export function authorizeControl(
|
|
43
|
+
termId: string,
|
|
44
|
+
action: string,
|
|
45
|
+
args: Record<string, unknown>,
|
|
46
|
+
sig?: string,
|
|
47
|
+
ts?: number,
|
|
48
|
+
opts?: { strict?: boolean },
|
|
49
|
+
): ControlAuthResult {
|
|
50
|
+
const accountPub = loadAccountSignKey();
|
|
51
|
+
if (!accountPub) {
|
|
52
|
+
return { ok: false, message: "This terminal can't accept changes from the app yet — re-link it to establish trust." };
|
|
53
|
+
}
|
|
54
|
+
if (!sig || typeof ts !== "number") {
|
|
55
|
+
return { ok: false, message: "Refused an unsigned change from the app." };
|
|
56
|
+
}
|
|
57
|
+
if (!verifyControl(accountPub, { termId, ts, action, args }, sig)) {
|
|
58
|
+
return { ok: false, message: "Couldn't verify this change came from your account." };
|
|
59
|
+
}
|
|
60
|
+
const last = loadLastControlTs(termId);
|
|
61
|
+
const tooOld = opts?.strict ? ts <= last : ts < last;
|
|
62
|
+
if (tooOld) {
|
|
63
|
+
return { ok: false, message: "Ignored an out-of-date change from the app." };
|
|
64
|
+
}
|
|
65
|
+
saveLastControlTs(termId, ts);
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension management for linked terminals.
|
|
3
|
+
*
|
|
4
|
+
* A UI-agnostic wrapper over Pi's PackageManager so the app (over the relay) can
|
|
5
|
+
* see which Pi extensions THIS terminal has installed and add/remove them — the
|
|
6
|
+
* same way the model picker lets the app switch models. Both the dev REPL
|
|
7
|
+
* (src/cli/chat.ts) and the shipped TUI (extensions/privateer-gate.ts) build one
|
|
8
|
+
* of these and route the extensions_* relay frames through it.
|
|
9
|
+
*
|
|
10
|
+
* Only the user's OWN packages surface here. The Privateer moat (privateer-*,
|
|
11
|
+
* pi-privacy, rpiv-web-tools, …) is installed by bin/privateer-tui as shim .ts
|
|
12
|
+
* files in the agent dir's extensions/ folder — auto-discovered, NOT recorded in
|
|
13
|
+
* settings.json "packages". listConfiguredPackages() reads only "packages", so the
|
|
14
|
+
* moat is naturally excluded. We keep a RESERVED name guard as defence in depth in
|
|
15
|
+
* case a user ever hand-adds one of our package names.
|
|
16
|
+
*
|
|
17
|
+
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
18
|
+
* frame plumbing and hands us a SettingsManager (the REPL reuses the session's;
|
|
19
|
+
* the TUI creates a fresh one — both read the same ~/.privateer/agent/settings.json).
|
|
20
|
+
*/
|
|
21
|
+
import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { ProgressEvent, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
// One installed extension as surfaced to the app. NON-PII: a package source
|
|
25
|
+
// (npm:/git: spec) plus its scope — no cwd, no absolute paths beyond what Pi
|
|
26
|
+
// already resolved. `installed` reflects whether the package is downloaded on disk
|
|
27
|
+
// yet (a freshly-added one persists to settings before its install completes).
|
|
28
|
+
export interface InstalledExtension {
|
|
29
|
+
source: string;
|
|
30
|
+
scope: "user" | "project";
|
|
31
|
+
filtered: boolean;
|
|
32
|
+
installed: boolean;
|
|
33
|
+
installedPath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExtensionsControl {
|
|
37
|
+
// The user's own configured packages (moat excluded). Cheap — reads settings.
|
|
38
|
+
listInstalled(): InstalledExtension[];
|
|
39
|
+
// Persist + download an npm:/git:/path package to USER settings. Returns ok:false
|
|
40
|
+
// with Pi's own message on a bad spec or a failed npm/git fetch.
|
|
41
|
+
add(source: string): Promise<{ ok: boolean; message?: string }>;
|
|
42
|
+
// Remove from USER settings + prune the cache. ok:false when nothing matched.
|
|
43
|
+
remove(source: string): Promise<{ ok: boolean; message?: string }>;
|
|
44
|
+
// Progress callback for the current add/remove (install/clone/pull steps), so the
|
|
45
|
+
// caller can relay a busy indicator. Pass undefined to clear.
|
|
46
|
+
setProgress(cb: ((ev: ProgressEvent) => void) | undefined): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Package names we never manage from the app: the Privateer moat + adopted packs
|
|
50
|
+
// installed as shims by the launcher. A guard only — listConfiguredPackages()
|
|
51
|
+
// already omits them since they aren't settings "packages".
|
|
52
|
+
const RESERVED = new Set([
|
|
53
|
+
"privateer-brand",
|
|
54
|
+
"privateer-context",
|
|
55
|
+
"privateer-gate",
|
|
56
|
+
"privateer-account",
|
|
57
|
+
"privateer-posture",
|
|
58
|
+
"privateer-tools",
|
|
59
|
+
"privateer-privacy",
|
|
60
|
+
"pi-privacy",
|
|
61
|
+
"pi-web-access",
|
|
62
|
+
"rpiv-web-tools",
|
|
63
|
+
"@juicesharp/rpiv-web-tools",
|
|
64
|
+
"pi-mcp-adapter",
|
|
65
|
+
"pi-hypa",
|
|
66
|
+
"@hypabolic/pi-hypa",
|
|
67
|
+
"pi-subagents",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// The bare package name inside a source spec, for the RESERVED check. Strips the
|
|
71
|
+
// npm:/git: scheme and any @version / #ref suffix; leaves scoped names intact.
|
|
72
|
+
function packageName(source: string): string {
|
|
73
|
+
let s = source.trim();
|
|
74
|
+
const scheme = s.indexOf(":");
|
|
75
|
+
if (scheme > 0 && /^(npm|git)$/i.test(s.slice(0, scheme))) s = s.slice(scheme + 1);
|
|
76
|
+
// Drop a trailing @version (but not the leading @ of a scope) and a git #ref.
|
|
77
|
+
s = s.replace(/#.*$/, "");
|
|
78
|
+
const at = s.lastIndexOf("@");
|
|
79
|
+
if (at > 0) s = s.slice(0, at);
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makeExtensionsControl(opts: {
|
|
84
|
+
cwd: string;
|
|
85
|
+
agentDir: string;
|
|
86
|
+
settingsManager: SettingsManager;
|
|
87
|
+
}): ExtensionsControl {
|
|
88
|
+
const pm = new DefaultPackageManager({
|
|
89
|
+
cwd: opts.cwd,
|
|
90
|
+
agentDir: opts.agentDir,
|
|
91
|
+
settingsManager: opts.settingsManager,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
listInstalled(): InstalledExtension[] {
|
|
96
|
+
let configured: Array<{ source: string; scope: string; filtered: boolean; installedPath?: string }> = [];
|
|
97
|
+
try {
|
|
98
|
+
configured = pm.listConfiguredPackages() as typeof configured;
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return configured
|
|
103
|
+
.filter((p) => !RESERVED.has(packageName(p.source)))
|
|
104
|
+
.map((p) => ({
|
|
105
|
+
source: p.source,
|
|
106
|
+
scope: p.scope === "project" ? "project" : "user",
|
|
107
|
+
filtered: !!p.filtered,
|
|
108
|
+
installed: !!p.installedPath,
|
|
109
|
+
installedPath: p.installedPath,
|
|
110
|
+
}));
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async add(source: string): Promise<{ ok: boolean; message?: string }> {
|
|
114
|
+
const src = source.trim();
|
|
115
|
+
if (!src) return { ok: false, message: "No package specified." };
|
|
116
|
+
if (RESERVED.has(packageName(src))) return { ok: false, message: "That extension is managed by Privateer." };
|
|
117
|
+
try {
|
|
118
|
+
await pm.installAndPersist(src, { local: false });
|
|
119
|
+
return { ok: true };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { ok: false, message: (e as Error).message };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async remove(source: string): Promise<{ ok: boolean; message?: string }> {
|
|
126
|
+
const src = source.trim();
|
|
127
|
+
if (!src) return { ok: false, message: "No package specified." };
|
|
128
|
+
try {
|
|
129
|
+
const removed = await pm.removeAndPersist(src, { local: false });
|
|
130
|
+
return removed ? { ok: true } : { ok: false, message: "Not installed." };
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ok: false, message: (e as Error).message };
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
setProgress(cb: ((ev: ProgressEvent) => void) | undefined): void {
|
|
137
|
+
pm.setProgressCallback(cb);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// A live, app-drivable agent session spawned on demand by the daemon (task_spawn,
|
|
2
|
+
// mode:"live"). Unlike a headless task (daemon/index.ts runTask → restricted tools +
|
|
3
|
+
// bypass gate + outbox), this stands up a FULL interactive session behind its own relay
|
|
4
|
+
// terminal so the app can attach and drive it in real time: stream tokens, approve each
|
|
5
|
+
// tool, interrupt. It is the same wiring cli/chat.ts uses for `/remote-access`, factored
|
|
6
|
+
// so the daemon can create one without a TTY.
|
|
7
|
+
//
|
|
8
|
+
// SAFETY: the gate runs in "default" mode, so on a driven turn every gated tool relays to
|
|
9
|
+
// the app for allow/deny (bridge.remoteAsk) and fail-closes if the controller is gone —
|
|
10
|
+
// full tools are safe precisely because a human is watching and approving. localAsk denies
|
|
11
|
+
// (there is no terminal to prompt), and remote-unsafe tools (subagents) are blocked.
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import {
|
|
14
|
+
createAgentSessionServices,
|
|
15
|
+
createAgentSessionFromServices,
|
|
16
|
+
SessionManager,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { agentDir } from "../config/paths.ts";
|
|
19
|
+
import { agentVersion } from "../config/version.ts";
|
|
20
|
+
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
21
|
+
import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
|
|
22
|
+
import { makePiPrivacyExtension } from "pi-privacy";
|
|
23
|
+
import { makeAccountProvider } from "../providers/account.ts";
|
|
24
|
+
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
25
|
+
import { RemoteBridge } from "./remoteBridge.ts";
|
|
26
|
+
import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
|
|
27
|
+
|
|
28
|
+
export interface LiveTaskHandle {
|
|
29
|
+
termId: string;
|
|
30
|
+
label: string;
|
|
31
|
+
stop: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LiveTaskDeps {
|
|
35
|
+
defaultModel: string;
|
|
36
|
+
parseSpec: (spec: string) => { provider: string; modelId: string };
|
|
37
|
+
log: (msg: string) => void;
|
|
38
|
+
onClosed: (termId: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// How long to keep a spawned session alive with NO controller ever attaching, and the
|
|
42
|
+
// hard ceiling on any one session's lifetime (a driven session left open is reaped so an
|
|
43
|
+
// abandoned spawn can't run the account meter or hold resources forever).
|
|
44
|
+
const ATTACH_GRACE_MS = 180_000; // 3 min to attach after spawn
|
|
45
|
+
const MAX_LIFETIME_MS = 30 * 60_000; // 30 min absolute cap
|
|
46
|
+
|
|
47
|
+
export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps): Promise<LiveTaskHandle> {
|
|
48
|
+
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
49
|
+
const modelSpec = spec.model && spec.model.trim() ? spec.model : deps.defaultModel;
|
|
50
|
+
const title = spec.title && spec.title.trim() ? spec.title.trim() : "";
|
|
51
|
+
const termId = `task-${randomUUID()}`;
|
|
52
|
+
const label = title ? `Task: ${title}`.slice(0, 60) : "Privateer Task";
|
|
53
|
+
|
|
54
|
+
let relay: RelayClient | undefined;
|
|
55
|
+
let session: any;
|
|
56
|
+
let turnActive = false;
|
|
57
|
+
let attached = false;
|
|
58
|
+
let initialPromptSent = false;
|
|
59
|
+
let stopped = false;
|
|
60
|
+
let spawnedAccount = false;
|
|
61
|
+
let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
|
|
62
|
+
|
|
63
|
+
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
|
|
66
|
+
const stop = async (): Promise<void> => {
|
|
67
|
+
if (stopped) return;
|
|
68
|
+
stopped = true;
|
|
69
|
+
if (attachTimer) clearTimeout(attachTimer);
|
|
70
|
+
if (lifeTimer) clearTimeout(lifeTimer);
|
|
71
|
+
try { relay?.stop(); } catch { /* already stopped */ }
|
|
72
|
+
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
73
|
+
// app's Linked Devices; the daemon's own child session stays alive. Best-effort.
|
|
74
|
+
if (spawnedAccount) {
|
|
75
|
+
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
76
|
+
try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
|
|
77
|
+
}
|
|
78
|
+
deps.onClosed(termId);
|
|
79
|
+
deps.log(`live task ${termId} closed`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const runTurn = async (text: string): Promise<void> => {
|
|
83
|
+
if (turnActive || stopped) return;
|
|
84
|
+
turnActive = true;
|
|
85
|
+
try {
|
|
86
|
+
await session.prompt(text);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
deps.log(`live task ${termId} turn error: ${(e as Error).message}`);
|
|
89
|
+
} finally {
|
|
90
|
+
turnActive = false;
|
|
91
|
+
bridge.settleTurn();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const bridge = new RemoteBridge({
|
|
96
|
+
onPrompt: (text) => void runTurn(text),
|
|
97
|
+
onInterrupt: () => void session?.abort?.(),
|
|
98
|
+
// Slash commands from the app composer fall through to the turn loop (Pi executes
|
|
99
|
+
// extension/skill commands via prompt). No local dispatcher here — this is headless.
|
|
100
|
+
onCommand: (text) => void runTurn(text),
|
|
101
|
+
onControllerAttached: () => {
|
|
102
|
+
attached = true;
|
|
103
|
+
if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
|
|
104
|
+
relay?.sendSnapshot([]);
|
|
105
|
+
relay?.sendContext({ model: modelSpec, version: agentVersion() });
|
|
106
|
+
relay?.sendCommands([]);
|
|
107
|
+
// Deliver the spawn's initial prompt exactly once, THROUGH the bridge's own prompt
|
|
108
|
+
// path so it counts as a driven turn (remote=true → tools relay to the app).
|
|
109
|
+
if (!initialPromptSent && spec.prompt && spec.prompt.trim()) {
|
|
110
|
+
initialPromptSent = true;
|
|
111
|
+
bridge.callbacks.onPrompt(spec.prompt);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
onTerminate: () => void stop(),
|
|
115
|
+
onStatus: (t) => deps.log(`live task ${termId}: ${t}`),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const gate: GateController = {
|
|
119
|
+
getMode: () => "default",
|
|
120
|
+
setMode: () => {},
|
|
121
|
+
allowlist: [],
|
|
122
|
+
allowedOutsideRoots: [],
|
|
123
|
+
cwd,
|
|
124
|
+
confineToCwd: true,
|
|
125
|
+
// No terminal to ask — a LOCAL turn can't happen here, but fail closed if one ever
|
|
126
|
+
// reaches this path.
|
|
127
|
+
async localAsk() {
|
|
128
|
+
return "deny";
|
|
129
|
+
},
|
|
130
|
+
getRemote: bridge.getRemote,
|
|
131
|
+
getNoQuarter: bridge.getNoQuarter,
|
|
132
|
+
remoteAsk: bridge.remoteAsk,
|
|
133
|
+
blockedWhenRemote: isRemoteUnsafeTool,
|
|
134
|
+
onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const services = await createAgentSessionServices({
|
|
138
|
+
cwd,
|
|
139
|
+
agentDir: agentDir(),
|
|
140
|
+
resourceLoaderOptions: {
|
|
141
|
+
extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
servicesRef = services as any;
|
|
145
|
+
|
|
146
|
+
const { provider, modelId } = deps.parseSpec(modelSpec);
|
|
147
|
+
if (provider === "privateer") {
|
|
148
|
+
try {
|
|
149
|
+
const creds = await spawnAccountCredentials();
|
|
150
|
+
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
151
|
+
spawnedAccount = true;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
deps.log(`live task ${termId} account channel unavailable: ${(e as Error).message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// From here on any failure MUST tear down (stop() revokes the account child session we
|
|
158
|
+
// just spawned + closes the relay), or a throw would leak an orphaned account "device"
|
|
159
|
+
// until its token TTL. Everything post-account-spawn runs under one guard.
|
|
160
|
+
try {
|
|
161
|
+
const model = (services.modelRegistry as any).find(provider, modelId);
|
|
162
|
+
if (!model) {
|
|
163
|
+
throw new Error(`model ${provider}/${modelId} not found`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
({ session } = await createAgentSessionFromServices({
|
|
167
|
+
services,
|
|
168
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
169
|
+
model,
|
|
170
|
+
// No `tools` restriction: a live session gets Pi's full toolset, made safe by the
|
|
171
|
+
// per-tool relay-to-app approval above.
|
|
172
|
+
} as any));
|
|
173
|
+
|
|
174
|
+
// Relay the extension mid-turn UI (select/confirm/input) to the app when driven, so an
|
|
175
|
+
// extension asking a question doesn't silently cancel. Mirrors cli/chat.ts's uiContext.
|
|
176
|
+
const driven = (): boolean => bridge.getRemote() && bridge.isConnected();
|
|
177
|
+
const uiContext = {
|
|
178
|
+
async select(t: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
|
179
|
+
if (!options.length) return undefined;
|
|
180
|
+
if (!driven()) return undefined;
|
|
181
|
+
const choice = await bridge.selectRemote({ title: t, options: options.map((o) => ({ value: o, label: o })) }, opts?.signal);
|
|
182
|
+
return choice ?? undefined;
|
|
183
|
+
},
|
|
184
|
+
async confirm(t: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
|
|
185
|
+
if (!driven()) return false;
|
|
186
|
+
const choice = await bridge.selectRemote({ title: t || message, options: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }] }, opts?.signal);
|
|
187
|
+
return choice === "yes";
|
|
188
|
+
},
|
|
189
|
+
async input(t: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
|
190
|
+
if (!driven()) return undefined;
|
|
191
|
+
const value = await bridge.inputRemote({ title: t, placeholder }, opts?.signal);
|
|
192
|
+
return value ?? undefined;
|
|
193
|
+
},
|
|
194
|
+
notify(message: string): void {
|
|
195
|
+
if (driven()) bridge.sendNotice(message);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
await (session as any).bindExtensions({ uiContext });
|
|
199
|
+
|
|
200
|
+
const adapter = createEngineEventAdapter();
|
|
201
|
+
session.subscribe((ev: any) => {
|
|
202
|
+
for (const ee of adapter.toEngineEvents(ev)) bridge.forwardEvent(ee);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
relay = new RelayClient(bridge.callbacks, { termId, label });
|
|
206
|
+
bridge.attachRelay(relay);
|
|
207
|
+
await relay.start();
|
|
208
|
+
|
|
209
|
+
// Reap if nobody ever attaches, and cap the absolute lifetime regardless.
|
|
210
|
+
attachTimer = setTimeout(() => { if (!attached) void stop(); }, ATTACH_GRACE_MS);
|
|
211
|
+
lifeTimer = setTimeout(() => void stop(), MAX_LIFETIME_MS);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
await stop(); // revoke the account child session + close the relay before propagating
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { termId, label, stop };
|
|
218
|
+
}
|