privateer-agent 0.12.5 → 0.12.7
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/LICENSE +21 -0
- package/README.md +44 -2
- package/bin/privateer-acp.mjs +2 -2
- package/bin/privateer-harbor.mjs +2 -2
- package/bin/privateer-launch.mjs +2 -2
- package/bin/privateer.mjs +9 -2
- package/extensions/privateer-brand.ts +79 -6
- package/extensions/privateer-connect.ts +22 -2
- package/extensions/privateer-hints.ts +20 -0
- package/extensions/privateer-spawn-skills.ts +39 -0
- package/extensions/privateer-speak.ts +15 -0
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +68 -0
- package/src/acp/run.ts +1 -1
- package/src/channels/run.ts +1 -1
- package/src/cli/chat.ts +23 -8
- package/src/config/spawns.ts +187 -0
- package/src/ext/permissionGate.ts +8 -1
- package/src/harbor/ipc.ts +27 -1
- package/src/mcp/catalog.ts +67 -8
- package/src/permissions/modeGate.ts +26 -7
- package/src/providers/account.ts +5 -4
- package/src/providers/defaultModel.ts +36 -15
- package/src/providers/modelCatalog.ts +115 -0
- package/src/remote/skillsControl.ts +17 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Spawn records — the per-folder defaults an agent starts with on this machine.
|
|
2
|
+
//
|
|
3
|
+
// A "spawn" is one agent pointed at one folder: the desktop's window sessions are
|
|
4
|
+
// the visible case, but the record is deliberately machine-level rather than
|
|
5
|
+
// desktop-level, because ~/.privateer is shared with the CLI and a `privateer` run
|
|
6
|
+
// in the same folder should be able to honour the same defaults later.
|
|
7
|
+
//
|
|
8
|
+
// WHY NOT THE PROJECT FOLDER. Pi already has a project scope — `.privateer/` and
|
|
9
|
+
// `.pi/` under cwd (see PROJECT_CONFIG_DIR_NAMES) — and this is NOT that. Project
|
|
10
|
+
// config lives in the tree, travels with a clone, and lands in the user's commits.
|
|
11
|
+
// These records are the opposite by choice: which model YOU run in a folder on THIS
|
|
12
|
+
// computer is a local preference, not a property of the project, and writing it into
|
|
13
|
+
// someone's repo would be a surprise the first time they `git status`. So they live
|
|
14
|
+
// under the global dir, keyed by the folder's real path, and the folder itself is
|
|
15
|
+
// left untouched. (PRIVATEER.md is the deliberate exception — that one IS about the
|
|
16
|
+
// project and belongs in the tree.)
|
|
17
|
+
//
|
|
18
|
+
// Keying is by REAL path: a symlinked checkout (/var → /private/var on macOS) must
|
|
19
|
+
// not read as a second folder, or a spawn would silently lose its defaults depending
|
|
20
|
+
// on which route the user opened it by.
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
import { globalDir } from "./paths.ts";
|
|
26
|
+
|
|
27
|
+
export interface SpawnRecord {
|
|
28
|
+
/** The folder, as resolved when the record was written. */
|
|
29
|
+
path: string;
|
|
30
|
+
/** Preferred model as "provider/id", or null to take the account default. */
|
|
31
|
+
model: string | null;
|
|
32
|
+
/** MCP connector names this folder's agent starts with. */
|
|
33
|
+
connectors: string[];
|
|
34
|
+
/** Epoch ms. */
|
|
35
|
+
createdAt: number;
|
|
36
|
+
/** Epoch ms of the last spawn opened on this folder, or null if never. */
|
|
37
|
+
lastOpenedAt: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** All spawn records: <globalDir>/spawns/<key>/spawn.json (+ a per-spawn skills/ dir). */
|
|
41
|
+
export function spawnsDir(): string {
|
|
42
|
+
return join(globalDir(), "spawns");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The identity of a folder for record purposes. realpathSync resolves symlinks so
|
|
46
|
+
// two routes to one checkout share a record; a path that doesn't exist yet (a folder
|
|
47
|
+
// the user is about to create) falls back to the lexical resolution rather than
|
|
48
|
+
// throwing. Case-folded on Windows, where the same folder is reachable as C:\Foo and
|
|
49
|
+
// c:\foo and neither spelling is more correct than the other.
|
|
50
|
+
function identity(path: string): string {
|
|
51
|
+
let real: string;
|
|
52
|
+
try {
|
|
53
|
+
real = realpathSync(resolve(path));
|
|
54
|
+
} catch {
|
|
55
|
+
real = resolve(path);
|
|
56
|
+
}
|
|
57
|
+
return process.platform === "win32" ? real.toLowerCase() : real;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stable per-folder key. 64 bits of sha256 — collisions are checked, not assumed. */
|
|
61
|
+
export function spawnKey(path: string): string {
|
|
62
|
+
return createHash("sha256").update(identity(path)).digest("hex").slice(0, 16);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** This spawn's own directory (records + per-folder skills). */
|
|
66
|
+
export function spawnDir(path: string): string {
|
|
67
|
+
return join(spawnsDir(), spawnKey(path));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-spawn skills, injected into the session as an extra skill path so a folder can
|
|
72
|
+
* carry its own without a `.privateer/skills` appearing in the user's tree.
|
|
73
|
+
*/
|
|
74
|
+
export function spawnSkillsDir(path: string): string {
|
|
75
|
+
return join(spawnDir(path), "skills");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function recordPath(path: string): string {
|
|
79
|
+
return join(spawnDir(path), "spawn.json");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Tolerant on read: these files sit in a directory users are invited to inspect, and
|
|
83
|
+
// a hand-edited or half-written one must degrade to "no record" rather than take the
|
|
84
|
+
// app down on launch. Unknown keys are dropped, not preserved — the shape is ours.
|
|
85
|
+
function parse(raw: string): SpawnRecord | null {
|
|
86
|
+
let obj: any;
|
|
87
|
+
try {
|
|
88
|
+
obj = JSON.parse(raw);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!obj || typeof obj !== "object" || typeof obj.path !== "string" || !obj.path) return null;
|
|
93
|
+
return {
|
|
94
|
+
path: obj.path,
|
|
95
|
+
model: typeof obj.model === "string" && obj.model.includes("/") ? obj.model : null,
|
|
96
|
+
connectors: Array.isArray(obj.connectors) ? obj.connectors.filter((c: unknown) => typeof c === "string") : [],
|
|
97
|
+
createdAt: Number.isFinite(obj.createdAt) ? obj.createdAt : 0,
|
|
98
|
+
lastOpenedAt: Number.isFinite(obj.lastOpenedAt) ? obj.lastOpenedAt : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The record for `path`, or null if there is none.
|
|
104
|
+
*
|
|
105
|
+
* A record whose stored path disagrees with the folder we asked about is treated as a
|
|
106
|
+
* miss: that is the 64-bit collision case, and answering with another folder's model
|
|
107
|
+
* and connectors would be worse than answering with nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function readSpawn(path: string): SpawnRecord | null {
|
|
110
|
+
const file = recordPath(path);
|
|
111
|
+
if (!existsSync(file)) return null;
|
|
112
|
+
let rec: SpawnRecord | null;
|
|
113
|
+
try {
|
|
114
|
+
rec = parse(readFileSync(file, "utf8"));
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (!rec) return null;
|
|
119
|
+
return identity(rec.path) === identity(path) ? rec : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create or update the record for `path` and return the result. Absent fields keep
|
|
124
|
+
* their stored value, so a caller that only knows the model doesn't have to read
|
|
125
|
+
* first and risk clobbering connectors written by another window.
|
|
126
|
+
*/
|
|
127
|
+
export function writeSpawn(path: string, patch: Partial<Omit<SpawnRecord, "path" | "createdAt">>, now = Date.now()): SpawnRecord {
|
|
128
|
+
const existing = readSpawn(path);
|
|
129
|
+
const next: SpawnRecord = {
|
|
130
|
+
path: resolve(path),
|
|
131
|
+
model: patch.model !== undefined ? patch.model : existing?.model ?? null,
|
|
132
|
+
connectors: patch.connectors !== undefined ? patch.connectors : existing?.connectors ?? [],
|
|
133
|
+
createdAt: existing?.createdAt || now,
|
|
134
|
+
lastOpenedAt: patch.lastOpenedAt !== undefined ? patch.lastOpenedAt : existing?.lastOpenedAt ?? null,
|
|
135
|
+
};
|
|
136
|
+
const dir = spawnDir(path);
|
|
137
|
+
mkdirSync(dir, { recursive: true });
|
|
138
|
+
// 0600 like the rest of the global dir: a record names a folder on this machine
|
|
139
|
+
// and the connectors it runs, which is nobody else's business on a shared box.
|
|
140
|
+
writeFileSync(recordPath(path), JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Stamp a spawn as opened now — the roster's "most recent first" ordering. */
|
|
145
|
+
export function touchSpawn(path: string, now = Date.now()): SpawnRecord {
|
|
146
|
+
return writeSpawn(path, { lastOpenedAt: now }, now);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Every record, most recently opened first (never-opened ones last, then by path so
|
|
151
|
+
* the order is stable). Unreadable entries are skipped rather than surfaced as blanks.
|
|
152
|
+
*/
|
|
153
|
+
export function listSpawns(): SpawnRecord[] {
|
|
154
|
+
const dir = spawnsDir();
|
|
155
|
+
if (!existsSync(dir)) return [];
|
|
156
|
+
let keys: string[];
|
|
157
|
+
try {
|
|
158
|
+
keys = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
const out: SpawnRecord[] = [];
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
try {
|
|
165
|
+
const rec = parse(readFileSync(join(dir, key, "spawn.json"), "utf8"));
|
|
166
|
+
if (rec) out.push(rec);
|
|
167
|
+
} catch {
|
|
168
|
+
// No record file, or an unreadable one — not a spawn we can offer.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0) || a.path.localeCompare(b.path));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Drop a folder's record (and its per-spawn skills). True if there was one. */
|
|
175
|
+
export function forgetSpawn(path: string): boolean {
|
|
176
|
+
const dir = spawnDir(path);
|
|
177
|
+
if (!existsSync(dir)) return false;
|
|
178
|
+
// Only remove a directory that actually holds THIS folder's record, so a collision
|
|
179
|
+
// (or a stale key) can't delete another spawn's skills.
|
|
180
|
+
if (!readSpawn(path)) return false;
|
|
181
|
+
try {
|
|
182
|
+
rmSync(dir, { recursive: true, force: true });
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -43,7 +43,12 @@ export interface GateController {
|
|
|
43
43
|
cwd: string;
|
|
44
44
|
confineToCwd?: boolean;
|
|
45
45
|
getRemote?(): boolean;
|
|
46
|
+
// The controller raised the flag: total bypass for remote turns — see
|
|
47
|
+
// ModeGate.getNoQuarter.
|
|
46
48
|
getNoQuarter?(): boolean;
|
|
49
|
+
// The weaker "auto" posture used by the non-interactive runtimes (ACP, channels):
|
|
50
|
+
// bypass-equivalent, dangerous/destructive still relayed. See ModeGate.getAutoApprove.
|
|
51
|
+
getAutoApprove?(): boolean;
|
|
47
52
|
// Total bypass — see ModeGate.getSkipAllPermissions. Set by the `--no-quarter`
|
|
48
53
|
// launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
|
|
49
54
|
// action with no prompt.
|
|
@@ -129,13 +134,15 @@ export async function decideToolCall(
|
|
|
129
134
|
setMode: ctrl.setMode,
|
|
130
135
|
allowlist: ctrl.allowlist,
|
|
131
136
|
allowedOutsideRoots: ctrl.allowedOutsideRoots,
|
|
132
|
-
// Default to the built-in dangerous-command patterns so bypass /
|
|
137
|
+
// Default to the built-in dangerous-command patterns so bypass / "auto"-posture /
|
|
133
138
|
// headless-subagent runs still force dangerous shell + secret-exfil to "ask"
|
|
134
139
|
// (→ headless deny). A controller can extend, but never silently disable, this.
|
|
140
|
+
// The two no-quarter switches sit above the denylist by design and clear it.
|
|
135
141
|
denylist: ctrl.denylist ?? DEFAULT_DENYLIST,
|
|
136
142
|
ask,
|
|
137
143
|
getRemote: ctrl.getRemote,
|
|
138
144
|
getNoQuarter: ctrl.getNoQuarter,
|
|
145
|
+
getAutoApprove: ctrl.getAutoApprove,
|
|
139
146
|
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
140
147
|
});
|
|
141
148
|
|
package/src/harbor/ipc.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer, createConnection, type Socket, type Server } from "node:net";
|
|
2
2
|
import { existsSync, unlinkSync, chmodSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { globalDir } from "../config/paths.ts";
|
|
5
6
|
import type { Routine } from "../routines/schema.ts";
|
|
@@ -8,7 +9,26 @@ import type { Routine } from "../routines/schema.ts";
|
|
|
8
9
|
// is one JSON request per connection, answered with one JSON response, both
|
|
9
10
|
// newline-terminated. Kept tiny and local — nothing crosses the machine boundary.
|
|
10
11
|
|
|
12
|
+
const isWindows = process.platform === "win32";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the harbor listens.
|
|
16
|
+
*
|
|
17
|
+
* POSIX: a unix socket inside PRIVATEER_HOME, so it inherits that directory's
|
|
18
|
+
* ownership and lives beside the log it writes.
|
|
19
|
+
*
|
|
20
|
+
* Windows has no unix sockets: `listen()` there accepts ONLY a name under
|
|
21
|
+
* \\.\pipe\, and handing it a file path fails — which is why `privateer harbor`
|
|
22
|
+
* could never start on Windows at all. The pipe name is derived from
|
|
23
|
+
* globalDir() so a non-default PRIVATEER_HOME still gets its own harbor (the
|
|
24
|
+
* pipe namespace is machine-global and has no directories to separate them),
|
|
25
|
+
* and hashed because that namespace takes no backslashes.
|
|
26
|
+
*/
|
|
11
27
|
export function harborSocketPath(): string {
|
|
28
|
+
if (isWindows) {
|
|
29
|
+
const id = createHash("sha256").update(globalDir().toLowerCase()).digest("hex").slice(0, 16);
|
|
30
|
+
return `\\\\.\\pipe\\privateer-harbor-${id}`;
|
|
31
|
+
}
|
|
12
32
|
return join(globalDir(), "harbor.sock");
|
|
13
33
|
}
|
|
14
34
|
|
|
@@ -115,6 +135,9 @@ export function startIpcServer(handler: IpcHandler): Promise<Server> {
|
|
|
115
135
|
const server = build();
|
|
116
136
|
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
117
137
|
if (err.code !== "EADDRINUSE") { reject(err); return; }
|
|
138
|
+
// Windows has nothing to reclaim: a named pipe exists only while a
|
|
139
|
+
// process holds it, so EADDRINUSE there always means a live harbor.
|
|
140
|
+
if (isWindows) { reject(new HarborAlreadyRunningError()); return; }
|
|
118
141
|
void probeExistingListener(path).then((live) => {
|
|
119
142
|
if (live) { reject(new HarborAlreadyRunningError()); return; }
|
|
120
143
|
if (reclaimed) { reject(err); return; } // already reclaimed once — give up
|
|
@@ -140,7 +163,10 @@ export function startIpcServer(handler: IpcHandler): Promise<Server> {
|
|
|
140
163
|
export function sendToHarbor(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
|
|
141
164
|
const path = harborSocketPath();
|
|
142
165
|
return new Promise<IpcResponse>((resolve, reject) => {
|
|
143
|
-
|
|
166
|
+
// The fast "nothing is there" path. Skipped on Windows: a named pipe isn't a
|
|
167
|
+
// filesystem entry, so existsSync() is false even for a live harbor and this
|
|
168
|
+
// check would report every one of them as not running.
|
|
169
|
+
if (!isWindows && !existsSync(path)) {
|
|
144
170
|
reject(new HarborNotRunningError());
|
|
145
171
|
return;
|
|
146
172
|
}
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* token — one prompt per env key (masked); `credUrl` is shown as "get one at …"
|
|
9
9
|
* path — one prompt replacing the `fill` placeholder ARG (a folder, a DSN)
|
|
10
10
|
* oauth — nothing to type here; you authorize in a browser on THIS machine
|
|
11
|
+
* url — one prompt to confirm the endpoint of a server already running HERE
|
|
11
12
|
* none — runs locally with no credentials, save it as-is
|
|
12
13
|
*
|
|
13
14
|
* Keep this list conservative and correct: a broken command in the catalog is worse
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
*/
|
|
17
18
|
import type { McpDraft, McpTransport } from "../remote/mcpControl.ts";
|
|
18
19
|
|
|
19
|
-
export type CatalogNeeds = "token" | "path" | "oauth" | "none";
|
|
20
|
+
export type CatalogNeeds = "token" | "path" | "oauth" | "url" | "none";
|
|
20
21
|
|
|
21
22
|
export interface CatalogEntry {
|
|
22
23
|
// Stable key for the picker; also the default server name written to config.
|
|
@@ -36,6 +37,25 @@ export interface CatalogEntry {
|
|
|
36
37
|
fill?: string;
|
|
37
38
|
// Where to get the credential, shown as a hint in the form.
|
|
38
39
|
credUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* An HTTP server running on THIS MACHINE that needs no credential at all.
|
|
42
|
+
*
|
|
43
|
+
* A third shape alongside `oauth` and a stored bearer token, and it needs its own
|
|
44
|
+
* flag rather than falling out of the URL: every other http entry here is a remote
|
|
45
|
+
* service the user authorizes, so "must authenticate" is derived from
|
|
46
|
+
* `transport === "http"` alone. That is false here — there is nothing to authorize
|
|
47
|
+
* — so the flag is what makes draftFromCatalog emit `auth: "none"`. Without it the
|
|
48
|
+
* adapter goes hunting for an authorization server that does not exist.
|
|
49
|
+
*
|
|
50
|
+
* Always pair with `hosted: false`: a hosted enclave has no route to the user's
|
|
51
|
+
* loopback, and hostedCapable()'s derived rule keys on `oauth`, not on this.
|
|
52
|
+
*/
|
|
53
|
+
localHttp?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Where to learn how to TURN THE SERVER ON — deliberately not `credUrl`, which
|
|
56
|
+
* means "get a credential here" and would be a lie for an entry that has none.
|
|
57
|
+
*/
|
|
58
|
+
docsUrl?: string;
|
|
39
59
|
// Can this connector run on a HOSTED (Harbor) agent? Leave unset to take the derived
|
|
40
60
|
// answer from hostedCapable() below; set it explicitly only to say "no" to something
|
|
41
61
|
// that would otherwise qualify.
|
|
@@ -110,7 +130,10 @@ export const MCP_CATALOG: CatalogEntry[] = [
|
|
|
110
130
|
label: "Linear",
|
|
111
131
|
blurb: "Issues and projects. Sign in via browser.",
|
|
112
132
|
transport: "http",
|
|
113
|
-
|
|
133
|
+
// /sse is GONE — it 404s on both GET and POST (checked 2026-07-31). Linear moved
|
|
134
|
+
// to the Streamable HTTP endpoint; the old URL silently failed for anyone who
|
|
135
|
+
// added Linear from this picker. Mirrored from the client copy on 2026-08-06.
|
|
136
|
+
url: "https://mcp.linear.app/mcp",
|
|
114
137
|
oauth: true,
|
|
115
138
|
needs: "oauth",
|
|
116
139
|
},
|
|
@@ -320,6 +343,38 @@ export const MCP_CATALOG: CatalogEntry[] = [
|
|
|
320
343
|
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
|
321
344
|
needs: "none",
|
|
322
345
|
},
|
|
346
|
+
|
|
347
|
+
// ── Local apps that host their own MCP server ───────────────────────────────
|
|
348
|
+
// http, but on 127.0.0.1: nothing to install, nothing to authorize, nothing
|
|
349
|
+
// leaving the machine. See `localHttp` on CatalogEntry for why that needs a flag.
|
|
350
|
+
{
|
|
351
|
+
// Unreal Engine 5.8 embeds an MCP server in the EDITOR PROCESS (plugin
|
|
352
|
+
// `ModelContextProtocol`, surfaced as "Unreal MCP"; the tools come from the
|
|
353
|
+
// "All Toolsets" plugin, which has to be enabled too). Three facts shape this:
|
|
354
|
+
//
|
|
355
|
+
// 1. NO AUTHENTICATION, of any kind. Hence localHttp + auth:"none".
|
|
356
|
+
// 2. LOOPBACK ONLY. It binds per [HTTPServer.Listeners] DefaultBindAddress
|
|
357
|
+
// (default `localhost`) AND rejects non-loopback `Origin` headers — so the
|
|
358
|
+
// agent has to be on the same machine as the editor. True for this CLI and
|
|
359
|
+
// for the desktop app; not true for a hosted agent, hence hosted: false.
|
|
360
|
+
// 3. IT IS ONLY UP WHILE THE EDITOR IS. A connector that fails here usually
|
|
361
|
+
// means "Unreal isn't running", not "this is misconfigured".
|
|
362
|
+
//
|
|
363
|
+
// The port and path are editable in Editor Preferences → Model Context
|
|
364
|
+
// Protocol, so `needs: "url"`: the one setup step is confirming the endpoint
|
|
365
|
+
// rather than pasting a secret. `ModelContextProtocol.GenerateClientConfig` in
|
|
366
|
+
// the UE console prints the URL the editor is actually serving.
|
|
367
|
+
id: "unreal",
|
|
368
|
+
name: "unreal",
|
|
369
|
+
label: "Unreal Engine",
|
|
370
|
+
blurb: "Drive the Unreal Editor — actors, lighting, materials, tests.",
|
|
371
|
+
transport: "http",
|
|
372
|
+
url: "http://127.0.0.1:8000/mcp",
|
|
373
|
+
localHttp: true,
|
|
374
|
+
needs: "url",
|
|
375
|
+
hosted: false,
|
|
376
|
+
docsUrl: "https://dev.epicgames.com/documentation/unreal-engine/unreal-mcp-in-unreal-editor",
|
|
377
|
+
},
|
|
323
378
|
];
|
|
324
379
|
|
|
325
380
|
export function catalogEntry(id: string): CatalogEntry | undefined {
|
|
@@ -341,9 +396,11 @@ export function promptOrder(e: CatalogEntry): string[] {
|
|
|
341
396
|
// and mcpControl treats that as "clear this key" — so a skipped
|
|
342
397
|
// optional credential is simply absent, never a bogus empty one.
|
|
343
398
|
// input.fill — the real path/DSN replacing the placeholder ARG (needs:"path").
|
|
399
|
+
// input.url — the endpoint the user confirmed (needs:"url"); blank keeps the
|
|
400
|
+
// catalog default, so a straight <enter> is the documented port.
|
|
344
401
|
export function draftFromCatalog(
|
|
345
402
|
e: CatalogEntry,
|
|
346
|
-
input: { env?: Record<string, string>; fill?: string } = {},
|
|
403
|
+
input: { env?: Record<string, string>; fill?: string; url?: string } = {},
|
|
347
404
|
): McpDraft {
|
|
348
405
|
const draft: McpDraft = { name: e.name, transport: e.transport };
|
|
349
406
|
|
|
@@ -354,11 +411,13 @@ export function draftFromCatalog(
|
|
|
354
411
|
const filled = input.fill?.trim();
|
|
355
412
|
draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
|
|
356
413
|
} else {
|
|
357
|
-
draft.url = e.url;
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
414
|
+
draft.url = input.url?.trim() || e.url;
|
|
415
|
+
// Emit the adapter's own vocabulary (`auth`) rather than the legacy boolean, so
|
|
416
|
+
// the projection carries a string and not a bogus boolean in the adapter's
|
|
417
|
+
// OAuthConfig slot. A localHttp entry authenticates to nothing — saying "oauth"
|
|
418
|
+
// there would send the adapter looking for an authorization server that does not
|
|
419
|
+
// exist, which fails at connect time rather than at save time.
|
|
420
|
+
draft.auth = e.localHttp ? "none" : (e.oauth ?? true) ? "oauth" : "none";
|
|
362
421
|
}
|
|
363
422
|
|
|
364
423
|
const keys = Object.keys(e.env ?? {});
|
|
@@ -30,10 +30,23 @@ export interface ModeGateDeps {
|
|
|
30
30
|
// Hard denies (e.g. plan mode) are still honored without bothering the phone.
|
|
31
31
|
getRemote?: () => boolean;
|
|
32
32
|
// True while the controller has toggled no-quarter (unattended) mode: remote
|
|
33
|
-
// turns auto-approve
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// turns auto-approve so the agent runs to completion without pinging the phone.
|
|
34
|
+
// This is the SAME total bypass as the `--no-quarter` launch flag below, only
|
|
35
|
+
// scoped to remote turns — dangerous shell, secret-exfil shapes and alwaysAsk-
|
|
36
|
+
// destructive tools included. It has to be: "no quarter" is a step-away-from-
|
|
37
|
+
// the-keyboard switch, and a mode that still stops on the one command the user
|
|
38
|
+
// walked away from isn't unattended, it's a turn that wedges until it times out
|
|
39
|
+
// (a relayed prompt with nobody to answer it fails closed). A hard "deny" — plan
|
|
40
|
+
// mode — is still honored, so a read-only stance can't be talked around remotely.
|
|
36
41
|
getNoQuarter?: () => boolean;
|
|
42
|
+
// True while a non-interactive runtime is running under its "auto" posture (the
|
|
43
|
+
// ACP host's `posture: "auto"`, a channel whose role resolves to it). Weaker than
|
|
44
|
+
// no-quarter on purpose: re-decide as if in bypass mode, so ordinary writes and
|
|
45
|
+
// bash run unattended but dangerous shell / alwaysAsk-destructive actions still
|
|
46
|
+
// relay for an explicit Allow/Deny. The party choosing it there is a host config
|
|
47
|
+
// or a chat-app role, not someone who tapped through a confirm on their own
|
|
48
|
+
// terminal, so it does not get to clear the denylist.
|
|
49
|
+
getAutoApprove?: () => boolean;
|
|
37
50
|
// True when the operator launched with `--no-quarter` (env PRIVATEER_NO_QUARTER):
|
|
38
51
|
// a session-wide TOTAL bypass of the gate. Every request auto-approves — including
|
|
39
52
|
// dangerous shell, destructive tools, out-of-cwd and protected-file access — with
|
|
@@ -66,10 +79,16 @@ export class ModeGate implements PermissionGate {
|
|
|
66
79
|
// remembered — we don't let a remote operator mutate local allowlist/mode.
|
|
67
80
|
if (this.deps.getRemote?.()) {
|
|
68
81
|
if (auto === "deny") return "deny";
|
|
69
|
-
// No-quarter:
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
82
|
+
// No-quarter: the controller has lowered the moat for this session, so
|
|
83
|
+
// auto-allow everything the plan-mode deny above didn't already stop —
|
|
84
|
+
// dangerous shell and alwaysAsk-destructive tools included. Stronger than
|
|
85
|
+
// `/mode bypass` (which keeps those two above it) and deliberately so: it
|
|
86
|
+
// is the remote-scoped twin of the `--no-quarter` flag, and the app's
|
|
87
|
+
// confirm says as much before the flag goes up.
|
|
88
|
+
if (this.deps.getNoQuarter?.()) return "allow";
|
|
89
|
+
// "auto" posture: the weaker cousin — bypass-equivalent, with dangerous and
|
|
90
|
+
// alwaysAsk-destructive actions still falling through to the relayed prompt.
|
|
91
|
+
if (this.deps.getAutoApprove?.() && decideAuto(req, "bypass", this.deps.allowlist, denylist) === "allow") {
|
|
73
92
|
return "allow";
|
|
74
93
|
}
|
|
75
94
|
return (await this.deps.ask(req)) === "deny" ? "deny" : "allow";
|
package/src/providers/account.ts
CHANGED
|
@@ -47,10 +47,11 @@ import {
|
|
|
47
47
|
const DEFAULT_MODELS = [
|
|
48
48
|
ACCOUNT_DEFAULT_MODEL_ID,
|
|
49
49
|
ACCOUNT_NEAR_MODEL_ID,
|
|
50
|
-
//
|
|
51
|
-
// user who saved
|
|
52
|
-
// rather than falling through to "first model with configured auth" — the BYO
|
|
53
|
-
// end this seed list exists to prevent.
|
|
50
|
+
// Both former defaults (see TINFOIL_MODEL_ID for the dates). They stay in the floor
|
|
51
|
+
// so a user who saved either as their own default still resolves it synchronously at
|
|
52
|
+
// launch, rather than falling through to "first model with configured auth" — the BYO
|
|
53
|
+
// dead end this seed list exists to prevent.
|
|
54
|
+
"tinfoil/kimi-k2-6",
|
|
54
55
|
"tinfoil/glm-5-2",
|
|
55
56
|
"anthropic/claude-opus-5",
|
|
56
57
|
"anthropic/claude-sonnet-5",
|
|
@@ -22,22 +22,43 @@ import { agentDir } from "../config/paths.ts";
|
|
|
22
22
|
// One definition, three consumers: this resolver, providers/account.ts's seed catalog,
|
|
23
23
|
// and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// glm-5-2 stalled before its first token on 9 of them — 33s to 98s each, with the
|
|
28
|
-
// model demonstrably warm 20 seconds earlier, so it is contention in that deployment
|
|
29
|
-
// rather than a cold start anything here can warm up. kimi-k2-6 and gpt-oss-120b, same
|
|
30
|
-
// enclave provider, same tier, same transport, stalled 0 times in 20 (medians 1.2s and
|
|
31
|
-
// 1.0s). The same run reproduced glm-5-2's stalls on BOTH the sealed and the cleartext
|
|
32
|
-
// path, which is what rules out the shim, the relay and the proxy as the cause.
|
|
25
|
+
// This has moved twice. The history matters, because both moves were about the same
|
|
26
|
+
// two axes — first-token latency and reasoning control — pulling in opposite directions:
|
|
33
27
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
28
|
+
// • until 2026-08-01 — glm-5-2.
|
|
29
|
+
// • 2026-08-01 → 2026-08-06 — kimi-k2-6, a LATENCY swap, not a capability one. Over
|
|
30
|
+
// 22 requests spaced 20s apart on the account channel, glm-5-2 stalled before its
|
|
31
|
+
// first token on 9 of them — 33s to 98s each, with the model demonstrably warm 20
|
|
32
|
+
// seconds earlier, so it was contention in that deployment rather than a cold start
|
|
33
|
+
// anything here can warm up. kimi-k2-6 and gpt-oss-120b, same enclave provider,
|
|
34
|
+
// same tier, same transport, stalled 0 times in 20 (medians 1.2s and 1.0s). That
|
|
35
|
+
// run reproduced the stalls on BOTH the sealed and the cleartext path, which is
|
|
36
|
+
// what ruled out the shim, the relay and the proxy as the cause.
|
|
37
|
+
// • 2026-08-06 — gpt-oss-120b, on REASONING CONTROL, having ruled out a return to
|
|
38
|
+
// glm-5-2 by re-measuring. kimi-k2-6 reasons on every turn with no working off
|
|
39
|
+
// switch: thinkingProfile (providers/account.ts) omits it deliberately because both
|
|
40
|
+
// levers were probed and neither moved the reasoning volume. On an agent that makes
|
|
41
|
+
// many small tool calls, a toggle that works is worth real latency — but not glm's
|
|
42
|
+
// latency. Re-run of the probe above (14 rounds, 20s apart, TTFT to the first token
|
|
43
|
+
// of any kind, all three models per round):
|
|
44
|
+
//
|
|
45
|
+
// glm-5-2 median 4.6s max 55.0s stalls(>10s) 5/14
|
|
46
|
+
// kimi-k2-6 median 0.9s max 1.1s stalls 0/14
|
|
47
|
+
// gpt-oss-120b median 0.4s max 0.4s stalls 0/14
|
|
48
|
+
//
|
|
49
|
+
// glm-5-2's stalls (55.0 / 46.3 / 46.3 / 55.0 / 29.9s) interleave with 1.0s
|
|
50
|
+
// responses on the same key in the same minute while the other two never waver —
|
|
51
|
+
// the 2026-08-01 signature, unchanged. At ~36% per request a ten-tool-call task
|
|
52
|
+
// stalls with ~99% probability, so it is not defaultable however good the model is.
|
|
53
|
+
// gpt-oss-120b takes the latency crown outright AND honours reasoning_effort
|
|
54
|
+
// (verified: low → 9 reasoning deltas, high → 61), so it is the only one of the
|
|
55
|
+
// three that gives the user a working dial. It is a smaller model than GLM 5.2 and
|
|
56
|
+
// Kimi K2.6; that capability trade was made knowingly. It also serves from NEAR as
|
|
57
|
+
// well as Tinfoil — the only capable model here with two attested homes.
|
|
58
|
+
//
|
|
59
|
+
// Re-measure before moving this again. The stall behaviour is a property of a
|
|
60
|
+
// provider's deployment, not of a model, and it has already changed under us twice.
|
|
61
|
+
export const TINFOIL_MODEL_ID = "tinfoil/gpt-oss-120b";
|
|
41
62
|
|
|
42
63
|
// Same model, reached two ways:
|
|
43
64
|
// - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// What the `/model` picker is allowed to offer, and why it might be short.
|
|
2
|
+
//
|
|
3
|
+
// Three surfaces build that list — the REPL (`cli/chat.ts`), the desktop session
|
|
4
|
+
// (`desktop/src/main/agentSession.ts`) and, through the relay, the app's picker
|
|
5
|
+
// sheet — and all three used the same two lines: `modelRegistry.getAvailable()`,
|
|
6
|
+
// mapped to `provider/id` and sorted. Two things about that list are surprising
|
|
7
|
+
// enough that they belong here rather than being rediscovered at each call site:
|
|
8
|
+
//
|
|
9
|
+
// 1. **Sealed-only models are registered late.** `phala/*` is registered only once
|
|
10
|
+
// the sealed loopback shim is bound (isServableAccountModel — the cleartext
|
|
11
|
+
// `/api/agent/v1` has no Phala route and rejects those ids outright), and the
|
|
12
|
+
// shim binds a beat AFTER the session's first synchronous registration. The
|
|
13
|
+
// account provider re-registers when it comes up, so the catalog heals itself
|
|
14
|
+
// within ~half a second — but a picker opened inside that window listed a
|
|
15
|
+
// catalog quietly missing the whole Phala tier. Waiting for the shim first
|
|
16
|
+
// costs milliseconds (`startShim` is a loopback `listen(0)` — no network and no
|
|
17
|
+
// attestation) and removes the race.
|
|
18
|
+
//
|
|
19
|
+
// 2. **The account catalog can be registered and yet entirely unavailable.** Pi's
|
|
20
|
+
// `getAvailable()` is `models.filter(hasConfiguredAuth)`, and hasConfiguredAuth
|
|
21
|
+
// is "the provider has an auth entry, or its config's apiKey resolves". The
|
|
22
|
+
// account provider has no apiKey — it authenticates with an OAuth child token —
|
|
23
|
+
// so every `privateer/*` model (the NEAR and Tinfoil confidential tiers, and the
|
|
24
|
+
// bulk of the 240-odd catalog) is filtered out until the credential is armed
|
|
25
|
+
// into Pi's auth store, which needs a machine login. On a signed-out box the
|
|
26
|
+
// picker therefore drops the entire account catalog and says nothing: it looks
|
|
27
|
+
// like we don't carry those models, rather than like you're signed out.
|
|
28
|
+
//
|
|
29
|
+
// So: one place computes the list, and the same place reports what it had to hide.
|
|
30
|
+
|
|
31
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
32
|
+
import { ensureSealedShim, sealedEnabled, sealedShimBase } from "./sealedShim.ts";
|
|
33
|
+
|
|
34
|
+
/** The routing provider for the account channel — `privateer/<catalog id>`. */
|
|
35
|
+
export const ACCOUNT_PROVIDER = "privateer";
|
|
36
|
+
|
|
37
|
+
/** The shape we need from Pi's model registry (kept structural — it's untyped here). */
|
|
38
|
+
export interface CatalogRegistry {
|
|
39
|
+
getAll?: () => unknown[];
|
|
40
|
+
getAvailable?: () => unknown[] | Promise<unknown[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RegistryModel {
|
|
44
|
+
provider?: string;
|
|
45
|
+
id?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const specOf = (m: RegistryModel): string => `${m.provider}/${m.id}`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wait for the sealed shim if it's coming, so a catalog read can't miss the
|
|
52
|
+
* sealed-only models purely because it happened early (see note 1 above).
|
|
53
|
+
*
|
|
54
|
+
* The account provider attached its own post-shim re-registration at extension
|
|
55
|
+
* init, i.e. BEFORE this one — promise callbacks run in attachment order, so by the
|
|
56
|
+
* time this resolves, `phala/*` is already in the registry. Failure is not fatal:
|
|
57
|
+
* the catalog is then honestly the one we can serve.
|
|
58
|
+
*/
|
|
59
|
+
export async function readySealedCatalog(): Promise<void> {
|
|
60
|
+
if (!sealedEnabled() || sealedShimBase()) return;
|
|
61
|
+
try {
|
|
62
|
+
await ensureSealedShim();
|
|
63
|
+
} catch {
|
|
64
|
+
/* no shim → no sealed models, which is exactly what the list should show */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PickerCatalog {
|
|
69
|
+
/** Sorted `provider/id` specs the session can actually reach — what to offer. */
|
|
70
|
+
specs: string[];
|
|
71
|
+
/** Account models registered but unreachable right now (0 when all is well). */
|
|
72
|
+
hiddenAccountModels: number;
|
|
73
|
+
/** Whether this machine holds a Privateer login at all. */
|
|
74
|
+
signedIn: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The picker's list, plus what it had to leave out. */
|
|
78
|
+
export async function pickerCatalog(registry: CatalogRegistry | null | undefined): Promise<PickerCatalog> {
|
|
79
|
+
await readySealedCatalog();
|
|
80
|
+
const available = ((await registry?.getAvailable?.()) ?? []) as RegistryModel[];
|
|
81
|
+
const all = (registry?.getAll?.() ?? []) as RegistryModel[];
|
|
82
|
+
const offeredAccount = available.filter((m) => m.provider === ACCOUNT_PROVIDER).length;
|
|
83
|
+
const registeredAccount = all.filter((m) => m.provider === ACCOUNT_PROVIDER).length;
|
|
84
|
+
return {
|
|
85
|
+
specs: available.map(specOf).sort(),
|
|
86
|
+
hiddenAccountModels: Math.max(0, registeredAccount - offeredAccount),
|
|
87
|
+
signedIn: hasCredentials(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One line explaining an account catalog we registered but can't offer, or null
|
|
93
|
+
* when there's nothing to explain.
|
|
94
|
+
*
|
|
95
|
+
* `signInHint` is the caller's own instruction for getting signed in — the REPL
|
|
96
|
+
* says `/login`, the desktop points at its Account menu — because "sign in" without
|
|
97
|
+
* saying where is the half of this message that was already implicit.
|
|
98
|
+
*/
|
|
99
|
+
export function hiddenAccountNotice(cat: PickerCatalog, signInHint: string): string | null {
|
|
100
|
+
if (cat.hiddenAccountModels === 0) return null;
|
|
101
|
+
const n = cat.hiddenAccountModels;
|
|
102
|
+
const models = `${n} Privateer account model${n === 1 ? "" : "s"} (including the confidential TEE ones)`;
|
|
103
|
+
return cat.signedIn
|
|
104
|
+
// Signed in but still unavailable: the credential never reached Pi's auth store
|
|
105
|
+
// — a revoked machine login, the terminal cap, or a network blip while arming.
|
|
106
|
+
? `${models} are hidden — the account channel isn't armed. ${signInHint}`
|
|
107
|
+
: `Not signed in — ${models} are hidden. ${signInHint}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A short suffix for the picker's own title, so the shortfall shows where you're looking. */
|
|
111
|
+
export function hiddenAccountTitleSuffix(cat: PickerCatalog): string {
|
|
112
|
+
return cat.hiddenAccountModels === 0
|
|
113
|
+
? ""
|
|
114
|
+
: ` · sign in for ${cat.hiddenAccountModels} more`;
|
|
115
|
+
}
|