privateer-agent 0.6.7 → 0.6.9
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/SECURITY.md +5 -1
- package/bin/privateer-launch.mjs +33 -23
- package/extensions/privateer-brand.ts +152 -60
- package/extensions/privateer-connect.ts +754 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +128 -4
- package/src/auth/accountSessions.ts +4 -1
- package/src/auth/privateer.ts +94 -22
- package/src/cli/chat.ts +2 -2
- package/src/daemon/index.ts +2 -2
- package/src/mcp/catalog.ts +178 -0
- package/src/providers/account.ts +132 -37
- package/src/providers/defaultModel.ts +42 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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",
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
2
|
-
index e223ce1..
|
|
2
|
+
index e223ce1..3615d74 100644
|
|
3
3
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
4
4
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -163,6 +163,14 @@ export class AgentSession {
|
|
6
|
+
}
|
|
7
|
+
const isOAuth = this._modelRegistry.isUsingOAuth(model);
|
|
8
|
+
if (isOAuth) {
|
|
9
|
+
+ // Privateer patch: the account channel has no API key that could expire —
|
|
10
|
+
+ // the terminal is simply not signed in yet (a fresh install now boots on
|
|
11
|
+
+ // `privateer/*`, which is the model it will run once logged in). Stock Pi's
|
|
12
|
+
+ // wording describes a state that user was never in, and its "/login
|
|
13
|
+
+ // privateer" bypasses the branded sign-in. auth-guidance owns the words.
|
|
14
|
+
+ if (model.provider === "privateer") {
|
|
15
|
+
+ throw new Error(formatNoApiKeyFoundMessage(model.provider));
|
|
16
|
+
+ }
|
|
17
|
+
throw new Error(`Authentication failed for "${model.provider}". ` +
|
|
18
|
+
`Credentials may have expired or network is unavailable. ` +
|
|
19
|
+
`Run '/login ${model.provider}' to re-authenticate.`);
|
|
20
|
+
@@ -711,6 +719,15 @@ export class AgentSession {
|
|
6
21
|
finalError: msg.errorMessage,
|
|
7
22
|
});
|
|
8
23
|
this._retryAttempt = 0;
|
|
@@ -18,11 +33,72 @@ index e223ce1..2bdab10 100644
|
|
|
18
33
|
}
|
|
19
34
|
if (await this._checkCompaction(msg)) {
|
|
20
35
|
return true;
|
|
36
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
37
|
+
index 197bccc..27f6429 100644
|
|
38
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
39
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
|
40
|
+
@@ -1,21 +1,33 @@
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { getDocsPath } from "../config.js";
|
|
43
|
+
const UNKNOWN_PROVIDER = "unknown";
|
|
44
|
+
+// Privateer patch: speak Privateer, and stop printing absolute node_modules doc paths.
|
|
45
|
+
+//
|
|
46
|
+
+// Stock Pi answered every auth failure with four lines, two of them full paths into
|
|
47
|
+
+// node_modules/@earendil-works/pi-coding-agent/docs/. On a terminal that isn't signed
|
|
48
|
+
+// in, that wall repeats on every prompt and buries the one sentence that matters. It
|
|
49
|
+
+// also told a Privateer user to go find a provider API key when their subscription
|
|
50
|
+
+// already covers the model. Same information, one actionable line.
|
|
51
|
+
export function getProviderLoginHelp() {
|
|
52
|
+
- return [
|
|
53
|
+
- "Use /login to log into a provider via OAuth or API key. See:",
|
|
54
|
+
- ` ${join(getDocsPath(), "providers.md")}`,
|
|
55
|
+
- ` ${join(getDocsPath(), "models.md")}`,
|
|
56
|
+
- ].join("\n");
|
|
57
|
+
+ return "Run /login to connect your Privateer account, or /login keys to use your own provider API key.";
|
|
58
|
+
}
|
|
59
|
+
export function formatNoModelsAvailableMessage() {
|
|
60
|
+
return `No models available. ${getProviderLoginHelp()}`;
|
|
61
|
+
}
|
|
62
|
+
export function formatNoModelSelectedMessage() {
|
|
63
|
+
- return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`;
|
|
64
|
+
+ return `No model selected. ${getProviderLoginHelp()}\n\nThen use /models to select a model.`;
|
|
65
|
+
}
|
|
66
|
+
export function formatNoApiKeyFoundMessage(provider) {
|
|
67
|
+
+ // The account channel: there is no API key to find — you're just not signed in (or
|
|
68
|
+
+ // the session didn't arm). Naming the real problem is the whole fix here.
|
|
69
|
+
+ if (provider === "privateer") {
|
|
70
|
+
+ return "This terminal isn't signed in to Privateer, so it can't run your subscription models.\n\nRun /login — it takes one approval in the Privateer app, and no API key.";
|
|
71
|
+
+ }
|
|
72
|
+
const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider;
|
|
73
|
+
return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`;
|
|
74
|
+
}
|
|
75
|
+
+// Kept so the module's imports stay meaningful for anything that still wants the docs.
|
|
76
|
+
+export function getProviderDocsPaths() {
|
|
77
|
+
+ return [join(getDocsPath(), "providers.md"), join(getDocsPath(), "models.md")];
|
|
78
|
+
+}
|
|
79
|
+
//# sourceMappingURL=auth-guidance.js.map
|
|
21
80
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
22
|
-
index 5d65200..
|
|
81
|
+
index 5d65200..0a08971 100644
|
|
23
82
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
24
83
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
25
|
-
@@ -
|
|
84
|
+
@@ -301,9 +301,15 @@ export class InteractiveMode {
|
|
85
|
+
}
|
|
86
|
+
getBuiltInCommandConflictDiagnostics(extensionRunner) {
|
|
87
|
+
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
|
|
88
|
+
+ // Privateer redirect: these built-ins are patched above to dispatch into the
|
|
89
|
+
+ // Privateer extension, so an extension command of the same name is deliberate,
|
|
90
|
+
+ // not a mistake — and it stays live in the modes that have no such redirect
|
|
91
|
+
+ // (rpc, print). Warning about it just tells the user their own shipped auth
|
|
92
|
+
+ // commands are broken when they aren't.
|
|
93
|
+
+ const redirectedBuiltins = new Set(["login", "logout"]);
|
|
94
|
+
return extensionRunner
|
|
95
|
+
.getRegisteredCommands()
|
|
96
|
+
- .filter((command) => builtinNames.has(command.name))
|
|
97
|
+
+ .filter((command) => builtinNames.has(command.name) && !redirectedBuiltins.has(command.name))
|
|
98
|
+
.map((command) => ({
|
|
99
|
+
type: "warning",
|
|
100
|
+
message: command.invocationName === command.name
|
|
101
|
+
@@ -2042,7 +2048,17 @@ export class InteractiveMode {
|
|
26
102
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
27
103
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
28
104
|
this.editor.setText("");
|
|
@@ -41,3 +117,51 @@ index 5d65200..a997ad7 100644
|
|
|
41
117
|
return;
|
|
42
118
|
}
|
|
43
119
|
if (text === "/export" || text.startsWith("/export ")) {
|
|
120
|
+
@@ -2105,14 +2121,44 @@ export class InteractiveMode {
|
|
121
|
+
this.editor.setText("");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
- if (text === "/login") {
|
|
125
|
+
- this.showOAuthSelector("login");
|
|
126
|
+
+ if (text === "/login" || text.startsWith("/login ")) {
|
|
127
|
+
this.editor.setText("");
|
|
128
|
+
+ // Privateer redirect: a bare /login IS the account sign-in. Pi's built-in
|
|
129
|
+
+ // opens a two-step menu ("Use a subscription" → a list of 20+ providers)
|
|
130
|
+
+ // that buries the one option a Privateer user wants, and — because it
|
|
131
|
+
+ // only auto-selects a model when the current one is UNKNOWN — a
|
|
132
|
+
+ // successful login through it left the terminal on its launch model and
|
|
133
|
+
+ // the next prompt died on "No API key found". The extension's own flow
|
|
134
|
+
+ // signs in, arms the account channel, and selects the model.
|
|
135
|
+
+ // `/login <anything>` (documented as `/login keys`) still opens Pi's own
|
|
136
|
+
+ // selector, so BYO provider keys stay reachable; and if the extension
|
|
137
|
+
+ // isn't loaded we fall back to Pi's selector for both.
|
|
138
|
+
+ if (text === "/login" && this.isExtensionCommand("/privateer")) {
|
|
139
|
+
+ await this.session.prompt("/privateer login");
|
|
140
|
+
+ }
|
|
141
|
+
+ else {
|
|
142
|
+
+ this.showOAuthSelector("login");
|
|
143
|
+
+ }
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (text === "/logout") {
|
|
147
|
+
- this.showOAuthSelector("logout");
|
|
148
|
+
this.editor.setText("");
|
|
149
|
+
+ // Privateer redirect: /logout must actually log you out. Pi's built-in
|
|
150
|
+
+ // only clears Pi's authStorage, which leaves the Privateer machine
|
|
151
|
+
+ // login in ~/.privateer/credentials.json untouched — so on a signed-in
|
|
152
|
+
+ // machine it reported "No stored credentials to remove" and changed
|
|
153
|
+
+ // nothing. Route to the account logout instead, which revokes this
|
|
154
|
+
+ // machine's whole token family (login + every terminal spawned from it)
|
|
155
|
+
+ // and wipes local state. Target is `/privateer logout` rather than the
|
|
156
|
+
+ // extension's own `/logout` so this can never re-enter the branch it
|
|
157
|
+
+ // was dispatched from. Falls back to Pi's selector when the extension
|
|
158
|
+
+ // isn't loaded.
|
|
159
|
+
+ if (this.isExtensionCommand("/privateer")) {
|
|
160
|
+
+ await this.session.prompt("/privateer logout");
|
|
161
|
+
+ }
|
|
162
|
+
+ else {
|
|
163
|
+
+ this.showOAuthSelector("logout");
|
|
164
|
+
+ }
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (text === "/new") {
|
|
@@ -144,7 +144,10 @@ export function dropOwnedSession(pid: number): void {
|
|
|
144
144
|
writeRegistry(reg);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
//
|
|
147
|
+
// Wipe the registry. Used by logout(), where the server has just revoked this
|
|
148
|
+
// machine's whole token family: every entry now names a dead session, and leaving
|
|
149
|
+
// them behind would offer the next login a list of orphans to "reclaim" that can
|
|
150
|
+
// only fail. Also a test seam.
|
|
148
151
|
export function clearOwnedSessions(): void {
|
|
149
152
|
try {
|
|
150
153
|
rmSync(accountSessionsPath(), { force: true });
|
package/src/auth/privateer.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
forgetOwnedSession,
|
|
22
22
|
orphanedSessions,
|
|
23
23
|
dropOwnedSession,
|
|
24
|
+
clearOwnedSessions,
|
|
24
25
|
} from "./accountSessions.ts";
|
|
25
26
|
import { isAccountCapCode } from "../engine/errors.ts";
|
|
26
27
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
@@ -94,9 +95,37 @@ export function defaultDeviceLabel(): string {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
// ──
|
|
98
|
+
// ── Cross-instance state ─────────────────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// Pi loads every extension with a FRESH jiti instance (`moduleCache: false`, see
|
|
101
|
+
// core/extensions/loader.js), so privateer-brand and privateer-account each get their
|
|
102
|
+
// OWN copy of this module — separate credential cache, separate listener sets.
|
|
103
|
+
//
|
|
104
|
+
// That silently broke /login. The account provider's OAuth login() ran inside the
|
|
105
|
+
// privateer-account copy and called notifySignedIn() there, while the UI's listener
|
|
106
|
+
// (the one that switches the live session onto a model the account can actually
|
|
107
|
+
// serve) was registered on the privateer-brand copy. The signal never crossed, so a
|
|
108
|
+
// successful sign-in left the terminal pinned to its keyless launch model and the
|
|
109
|
+
// very next prompt died with "No API key found for openrouter" — exactly the state
|
|
110
|
+
// the login was supposed to fix. The same split let a /logout in one copy leave a
|
|
111
|
+
// stale `user` memoized in another.
|
|
112
|
+
//
|
|
113
|
+
// So anything that must be observed ACROSS extensions lives on globalThis, keyed by
|
|
114
|
+
// a registered Symbol — one bus, however many module instances jiti creates.
|
|
115
|
+
const SHARED = Symbol.for("privateer.auth.shared");
|
|
116
|
+
|
|
117
|
+
interface SharedAuthState {
|
|
118
|
+
cache: Credentials | null;
|
|
119
|
+
signedIn: Set<SignedInListener>;
|
|
120
|
+
expired: Set<SessionExpiredListener>;
|
|
121
|
+
}
|
|
98
122
|
|
|
99
|
-
|
|
123
|
+
function shared(): SharedAuthState {
|
|
124
|
+
const g = globalThis as { [SHARED]?: SharedAuthState };
|
|
125
|
+
return (g[SHARED] ??= { cache: null, signedIn: new Set(), expired: new Set() });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Credential storage (0600, like saveGlobalConfig) ─────────────────────────
|
|
100
129
|
|
|
101
130
|
// Per-terminal child session (see spawnChildSession). Held in memory ONLY — it
|
|
102
131
|
// is never written to the shared credentials file, so each running terminal
|
|
@@ -144,15 +173,16 @@ export function loadCredentials(): Credentials | null {
|
|
|
144
173
|
// memoized the pre-login "absent" as null, that instance would report "not signed
|
|
145
174
|
// in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
|
|
146
175
|
// disk on each miss lets a later call see what a sign-in just wrote.
|
|
147
|
-
|
|
176
|
+
const state = shared();
|
|
177
|
+
if (state.cache) return state.cache;
|
|
148
178
|
const path = credentialsPath();
|
|
149
179
|
if (!existsSync(path)) return null;
|
|
150
180
|
try {
|
|
151
|
-
|
|
181
|
+
state.cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
|
152
182
|
} catch {
|
|
153
183
|
return null;
|
|
154
184
|
}
|
|
155
|
-
return
|
|
185
|
+
return state.cache;
|
|
156
186
|
}
|
|
157
187
|
|
|
158
188
|
export function saveCredentials(creds: Credentials): void {
|
|
@@ -162,7 +192,7 @@ export function saveCredentials(creds: Credentials): void {
|
|
|
162
192
|
const path = credentialsPath();
|
|
163
193
|
writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
164
194
|
tryChmod(path, 0o600);
|
|
165
|
-
|
|
195
|
+
shared().cache = creds;
|
|
166
196
|
}
|
|
167
197
|
|
|
168
198
|
export function clearCredentials(): void {
|
|
@@ -174,7 +204,7 @@ export function clearCredentials(): void {
|
|
|
174
204
|
// Drop the pinned account signing key too — it belongs to the account that just
|
|
175
205
|
// signed out; a different account must re-pin its own at link.
|
|
176
206
|
clearAccountSignKey();
|
|
177
|
-
|
|
207
|
+
shared().cache = null;
|
|
178
208
|
_child = null;
|
|
179
209
|
_account = null;
|
|
180
210
|
}
|
|
@@ -204,15 +234,15 @@ function tryChmod(path: string, mode: number): void {
|
|
|
204
234
|
// stops working; the UI subscribes to announce the sign-out prominently.
|
|
205
235
|
|
|
206
236
|
type SessionExpiredListener = () => void;
|
|
207
|
-
const _expiredListeners = new Set<SessionExpiredListener>();
|
|
208
237
|
|
|
209
238
|
export function onSessionExpired(listener: SessionExpiredListener): () => void {
|
|
210
|
-
|
|
211
|
-
|
|
239
|
+
const listeners = shared().expired;
|
|
240
|
+
listeners.add(listener);
|
|
241
|
+
return () => listeners.delete(listener);
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
function notifySessionExpired(): void {
|
|
215
|
-
for (const listener of
|
|
245
|
+
for (const listener of shared().expired) {
|
|
216
246
|
try {
|
|
217
247
|
listener();
|
|
218
248
|
} catch {
|
|
@@ -248,17 +278,19 @@ export function handleServerRevoke(): void {
|
|
|
248
278
|
// A listener here refreshes the UI regardless of which path the user took.
|
|
249
279
|
|
|
250
280
|
type SignedInListener = () => void;
|
|
251
|
-
const _signedInListeners = new Set<SignedInListener>();
|
|
252
281
|
|
|
253
282
|
export function onSignedIn(listener: SignedInListener): () => void {
|
|
254
|
-
|
|
255
|
-
|
|
283
|
+
const listeners = shared().signedIn;
|
|
284
|
+
listeners.add(listener);
|
|
285
|
+
return () => listeners.delete(listener);
|
|
256
286
|
}
|
|
257
287
|
|
|
258
288
|
// Emit the sign-in signal. Exported so the account OAuth provider can announce a
|
|
259
289
|
// completed subscription login on the already-linked path (see the note above).
|
|
290
|
+
// Listeners MUST be idempotent: a device-code login fires this once the credentials
|
|
291
|
+
// land and again once the account channel is armed (see privateerOAuthProvider.login).
|
|
260
292
|
export function notifySignedIn(): void {
|
|
261
|
-
for (const listener of
|
|
293
|
+
for (const listener of shared().signedIn) {
|
|
262
294
|
try {
|
|
263
295
|
listener();
|
|
264
296
|
} catch {
|
|
@@ -602,15 +634,55 @@ export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
|
|
|
602
634
|
// ── Logout ───────────────────────────────────────────────────────────────────
|
|
603
635
|
|
|
604
636
|
/**
|
|
605
|
-
* Log out this
|
|
606
|
-
* local
|
|
637
|
+
* Log out this MACHINE: revoke the machine login and every terminal session
|
|
638
|
+
* spawned from it, then wipe all local auth state. Other devices (the phone app,
|
|
639
|
+
* another laptop) keep their own logins — each has its own token family.
|
|
640
|
+
*
|
|
641
|
+
* Two things this deliberately does NOT do, both of which it used to:
|
|
642
|
+
*
|
|
643
|
+
* 1. It does not POST /auth/logout. That endpoint calls revokeAllUserSessions —
|
|
644
|
+
* the entire ACCOUNT, every device including the app — while this function's
|
|
645
|
+
* contract (and its old doc comment) promised the opposite. Signing out of one
|
|
646
|
+
* terminal must not sign you out of your phone.
|
|
647
|
+
*
|
|
648
|
+
* 2. It does not go through apiRequest/authedFetch. Those authenticate with a CHILD
|
|
649
|
+
* session and spawn one if absent — so at the per-machine child cap the spawn
|
|
650
|
+
* throws 429 and the logout never reaches the server AT ALL. That was a deadlock:
|
|
651
|
+
* the cap blocked the one call that clears the cap. We authenticate with the
|
|
652
|
+
* PARENT instead, which is never subject to the cap.
|
|
653
|
+
*
|
|
654
|
+
* The parent's stored access token is usually expired (it is minted once at /login
|
|
655
|
+
* and never rotated — the refresh token is the liveness proof), so we rotate for a
|
|
656
|
+
* fresh one first. Rotation is free here precisely because we are destroying the
|
|
657
|
+
* credential either way: nothing downstream needs the token we burn. rotateSession
|
|
658
|
+
* is the no-ownership-side-effects variant, so this cannot clobber the registry
|
|
659
|
+
* entry of a session we are about to revoke wholesale anyway.
|
|
660
|
+
*
|
|
661
|
+
* DELETE /auth/session/current then revokes the parent's family, which the server
|
|
662
|
+
* cascades to every row with `parentFamilyId === familyId` — i.e. all this machine's
|
|
663
|
+
* terminals, including the orphans left by terminals that died without their
|
|
664
|
+
* shutdown hook. That cascade is what makes an accumulated cap self-clearing:
|
|
665
|
+
* logout, log back in, and the machine starts from zero live children.
|
|
666
|
+
*
|
|
667
|
+
* Local state is wiped unconditionally at the end, whatever the network did. A
|
|
668
|
+
* logout that can't reach the server must still leave you logged out locally —
|
|
669
|
+
* the rows it failed to revoke age out on their TTL.
|
|
607
670
|
*/
|
|
608
671
|
export async function logout(): Promise<void> {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
672
|
+
const parent = loadCredentials();
|
|
673
|
+
if (parent) {
|
|
674
|
+
try {
|
|
675
|
+
const fresh = await rotateSession(parent.refreshToken);
|
|
676
|
+
await deleteSession(fresh.access, 5000);
|
|
677
|
+
} catch {
|
|
678
|
+
/* offline, or the login was already dead server-side — wipe locally anyway */
|
|
679
|
+
}
|
|
613
680
|
}
|
|
681
|
+
// In-memory sessions are gone with the family above; drop the handles so nothing
|
|
682
|
+
// tries to revoke them individually on the way out.
|
|
683
|
+
_child = null;
|
|
684
|
+
_account = null;
|
|
685
|
+
clearOwnedSessions(); // every entry named a session the cascade just killed
|
|
614
686
|
clearCredentials();
|
|
615
687
|
}
|
|
616
688
|
|
|
@@ -622,7 +694,7 @@ export async function logout(): Promise<void> {
|
|
|
622
694
|
// = the JWT's exp (so Pi refreshes just before the server would reject it). These are
|
|
623
695
|
// independent of authedFetch's in-memory _child (Pi owns this credential's lifecycle).
|
|
624
696
|
|
|
625
|
-
interface AccountCredential {
|
|
697
|
+
export interface AccountCredential {
|
|
626
698
|
access: string;
|
|
627
699
|
refresh: string;
|
|
628
700
|
expires: number; // ms epoch
|
package/src/cli/chat.ts
CHANGED
|
@@ -478,8 +478,8 @@ async function main() {
|
|
|
478
478
|
});
|
|
479
479
|
console.log(`${GREEN}Signed in as ${user.email ?? user.id}.${RESET}`);
|
|
480
480
|
// Move the live session onto a confidential model right away, so the next prompt
|
|
481
|
-
// doesn't dead-end on the
|
|
482
|
-
//
|
|
481
|
+
// doesn't dead-end on the launch model's missing key. resolveSignedInModel picks
|
|
482
|
+
// Tinfoil GLM 5.2 — direct with a Tinfoil key, over the subscription otherwise;
|
|
483
483
|
// PRIVATEER_MODEL (a deliberate override) is respected and left alone.
|
|
484
484
|
if (!process.env.PRIVATEER_MODEL?.trim()) {
|
|
485
485
|
const target = resolveSignedInModel();
|
package/src/daemon/index.ts
CHANGED
|
@@ -326,7 +326,7 @@ export class Daemon {
|
|
|
326
326
|
// The account signed this daemon out server-side (revoked from the app's Linked
|
|
327
327
|
// Devices). Beyond ending remote access (onTerminate), this wipes the machine
|
|
328
328
|
// login: drop the relay and clear credentials, so routines/tasks stop cleanly
|
|
329
|
-
// instead of dead-ending on a 401 each run. Stays idle until you /
|
|
329
|
+
// instead of dead-ending on a 401 each run. Stays idle until you /login on this
|
|
330
330
|
// machine and restart the daemon (the relayTerminated guard, as with onTerminate).
|
|
331
331
|
onRevoked: () => {
|
|
332
332
|
this.relayTerminated = true;
|
|
@@ -334,7 +334,7 @@ export class Daemon {
|
|
|
334
334
|
this.relay?.stop();
|
|
335
335
|
this.relay = undefined;
|
|
336
336
|
handleServerRevoke();
|
|
337
|
-
log("account signed out from the app (session revoked) — cleared credentials; idle until you run /
|
|
337
|
+
log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the daemon");
|
|
338
338
|
},
|
|
339
339
|
onStatus: (text) => log(`relay: ${text}`),
|
|
340
340
|
onDisconnected: () => {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated MCP connector catalog for the `/connect` TUI picker — the terminal's
|
|
3
|
+
* counterpart to the app's quick-add grid (treeview/client/components/mcpCatalog.ts).
|
|
4
|
+
* Kept in sync with that list by hand: it is small, changes rarely, and duplicating
|
|
5
|
+
* eight entries is cheaper than making the agent depend on the client package.
|
|
6
|
+
*
|
|
7
|
+
* `needs` drives what the wizard asks for after you pick an entry:
|
|
8
|
+
* token — one prompt per env key (masked); `credUrl` is shown as "get one at …"
|
|
9
|
+
* path — one prompt replacing the `fill` placeholder ARG (a folder, a DSN)
|
|
10
|
+
* oauth — nothing to type here; you authorize in a browser on THIS machine
|
|
11
|
+
* none — runs locally with no credentials, save it as-is
|
|
12
|
+
*
|
|
13
|
+
* Keep this list conservative and correct: a broken command in the catalog is worse
|
|
14
|
+
* than an omission — the user has no way to tell "this server is misconfigured" from
|
|
15
|
+
* "MCP is broken". tests/mcpCatalog.test.ts enforces the structural invariants.
|
|
16
|
+
*/
|
|
17
|
+
import type { McpDraft, McpTransport } from "../remote/mcpControl.ts";
|
|
18
|
+
|
|
19
|
+
export type CatalogNeeds = "token" | "path" | "oauth" | "none";
|
|
20
|
+
|
|
21
|
+
export interface CatalogEntry {
|
|
22
|
+
// Stable key for the picker; also the default server name written to config.
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
label: string; // display name in the picker
|
|
26
|
+
blurb: string; // one line, lowercase-ish, says what it gives the agent
|
|
27
|
+
transport: McpTransport;
|
|
28
|
+
command?: string; // stdio
|
|
29
|
+
args?: string[]; // stdio
|
|
30
|
+
env?: Record<string, string>; // env KEYS the user must fill (values are "")
|
|
31
|
+
url?: string; // http
|
|
32
|
+
oauth?: boolean; // http servers that negotiate OAuth
|
|
33
|
+
needs: CatalogNeeds;
|
|
34
|
+
// needs:"token" → the PRIMARY env key (others are still prompted for).
|
|
35
|
+
// needs:"path" → the placeholder ARG to replace with a real path/DSN.
|
|
36
|
+
fill?: string;
|
|
37
|
+
// Where to get the credential, shown as a hint in the form.
|
|
38
|
+
credUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MCP_CATALOG: CatalogEntry[] = [
|
|
42
|
+
{
|
|
43
|
+
id: "github",
|
|
44
|
+
name: "github",
|
|
45
|
+
label: "GitHub",
|
|
46
|
+
blurb: "Repos, issues, and pull requests.",
|
|
47
|
+
transport: "stdio",
|
|
48
|
+
command: "npx",
|
|
49
|
+
args: ["-y", "@modelcontextprotocol/server-github"],
|
|
50
|
+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" },
|
|
51
|
+
needs: "token",
|
|
52
|
+
fill: "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
53
|
+
credUrl: "https://github.com/settings/tokens",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "filesystem",
|
|
57
|
+
name: "filesystem",
|
|
58
|
+
label: "Filesystem",
|
|
59
|
+
blurb: "Read and write files in a folder you pick.",
|
|
60
|
+
transport: "stdio",
|
|
61
|
+
command: "npx",
|
|
62
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/folder"],
|
|
63
|
+
needs: "path",
|
|
64
|
+
fill: "/path/to/folder",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "notion",
|
|
68
|
+
name: "notion",
|
|
69
|
+
label: "Notion",
|
|
70
|
+
blurb: "Pages, databases, and blocks.",
|
|
71
|
+
transport: "stdio",
|
|
72
|
+
command: "npx",
|
|
73
|
+
args: ["-y", "@notionhq/notion-mcp-server"],
|
|
74
|
+
env: { NOTION_TOKEN: "" },
|
|
75
|
+
needs: "token",
|
|
76
|
+
fill: "NOTION_TOKEN",
|
|
77
|
+
credUrl: "https://www.notion.so/my-integrations",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "linear",
|
|
81
|
+
name: "linear",
|
|
82
|
+
label: "Linear",
|
|
83
|
+
blurb: "Issues and projects. Sign in via browser.",
|
|
84
|
+
transport: "http",
|
|
85
|
+
url: "https://mcp.linear.app/sse",
|
|
86
|
+
oauth: true,
|
|
87
|
+
needs: "oauth",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "slack",
|
|
91
|
+
name: "slack",
|
|
92
|
+
label: "Slack",
|
|
93
|
+
blurb: "Read and post to channels.",
|
|
94
|
+
transport: "stdio",
|
|
95
|
+
command: "npx",
|
|
96
|
+
args: ["-y", "@modelcontextprotocol/server-slack"],
|
|
97
|
+
env: { SLACK_BOT_TOKEN: "", SLACK_TEAM_ID: "" },
|
|
98
|
+
needs: "token",
|
|
99
|
+
fill: "SLACK_BOT_TOKEN",
|
|
100
|
+
credUrl: "https://api.slack.com/apps",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "postgres",
|
|
104
|
+
name: "postgres",
|
|
105
|
+
label: "PostgreSQL",
|
|
106
|
+
blurb: "Query a Postgres database (read-only).",
|
|
107
|
+
transport: "stdio",
|
|
108
|
+
command: "npx",
|
|
109
|
+
args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
|
|
110
|
+
needs: "path",
|
|
111
|
+
fill: "postgresql://localhost/mydb",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "playwright",
|
|
115
|
+
name: "playwright",
|
|
116
|
+
label: "Browser (Playwright)",
|
|
117
|
+
blurb: "Drive a real browser to fetch and click.",
|
|
118
|
+
transport: "stdio",
|
|
119
|
+
command: "npx",
|
|
120
|
+
args: ["-y", "@playwright/mcp@latest"],
|
|
121
|
+
needs: "none",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "memory",
|
|
125
|
+
name: "memory",
|
|
126
|
+
label: "Memory",
|
|
127
|
+
blurb: "A local knowledge-graph scratchpad.",
|
|
128
|
+
transport: "stdio",
|
|
129
|
+
command: "npx",
|
|
130
|
+
args: ["-y", "@modelcontextprotocol/server-memory"],
|
|
131
|
+
needs: "none",
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export function catalogEntry(id: string): CatalogEntry | undefined {
|
|
136
|
+
return MCP_CATALOG.find((e) => e.id === id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The env keys the wizard prompts for, primary (`fill`) first so the token you
|
|
140
|
+
// actually care about is asked for before the incidentals (Slack's TEAM_ID).
|
|
141
|
+
export function promptOrder(e: CatalogEntry): string[] {
|
|
142
|
+
const keys = Object.keys(e.env ?? {});
|
|
143
|
+
if (!e.fill || !keys.includes(e.fill)) return keys;
|
|
144
|
+
return [e.fill, ...keys.filter((k) => k !== e.fill)];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Build the draft mcpControl.save() persists, from a catalog entry plus whatever the
|
|
148
|
+
// user typed. PURE — the whole reason this lives outside the TUI component.
|
|
149
|
+
//
|
|
150
|
+
// input.env — env VALUES by key. An empty/omitted value is passed through as ""
|
|
151
|
+
// and mcpControl treats that as "clear this key" — so a skipped
|
|
152
|
+
// optional credential is simply absent, never a bogus empty one.
|
|
153
|
+
// input.fill — the real path/DSN replacing the placeholder ARG (needs:"path").
|
|
154
|
+
export function draftFromCatalog(
|
|
155
|
+
e: CatalogEntry,
|
|
156
|
+
input: { env?: Record<string, string>; fill?: string } = {},
|
|
157
|
+
): McpDraft {
|
|
158
|
+
const draft: McpDraft = { name: e.name, transport: e.transport };
|
|
159
|
+
|
|
160
|
+
if (e.transport === "stdio") {
|
|
161
|
+
draft.command = e.command;
|
|
162
|
+
// Replace the placeholder ARG in place (not by index) so reordering the catalog's
|
|
163
|
+
// args can never silently overwrite the wrong one.
|
|
164
|
+
const filled = input.fill?.trim();
|
|
165
|
+
draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
|
|
166
|
+
} else {
|
|
167
|
+
draft.url = e.url;
|
|
168
|
+
draft.oauth = e.oauth ?? true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const keys = Object.keys(e.env ?? {});
|
|
172
|
+
if (keys.length > 0) {
|
|
173
|
+
const env: Record<string, string> = {};
|
|
174
|
+
for (const k of keys) env[k] = input.env?.[k] ?? "";
|
|
175
|
+
draft.env = env;
|
|
176
|
+
}
|
|
177
|
+
return draft;
|
|
178
|
+
}
|