privateer-agent 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +11 -2
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +255 -74
- package/src/mcp/catalog.ts +32 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/providers/account.ts +318 -9
- package/src/remote/liveTaskSession.ts +11 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +43 -6
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
|
@@ -28,7 +28,12 @@ import { readFileSync, appendFileSync } from "node:fs";
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import * as priv from "../src/auth/privateer.ts";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
armAccountCredential,
|
|
33
|
+
dropPersistedAccountCredential,
|
|
34
|
+
makeAccountProvider,
|
|
35
|
+
verificationLink,
|
|
36
|
+
} from "../src/providers/account.ts";
|
|
32
37
|
import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
|
|
33
38
|
import { discoverContextFiles, onContextChanged } from "../src/context.ts";
|
|
34
39
|
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
@@ -317,12 +322,15 @@ export default function privateerBrand(pi: any): void {
|
|
|
317
322
|
// and dead-ends on the first inference. Removing it makes the next /signin spawn a
|
|
318
323
|
// fresh session. Reached via the model registry (constructed with the auth
|
|
319
324
|
// storage; see session.ts). Best-effort: nothing persisted → nothing to do.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
//
|
|
326
|
+
// Two flavours, because auth.json is machine-global and shared by every Privateer
|
|
327
|
+
// terminal (see providers/account.ts dropPersistedAccountCredential):
|
|
328
|
+
// - `force` for sign-out / expiry, where the whole token family is revoked and the
|
|
329
|
+
// entry is dead for every terminal;
|
|
330
|
+
// - the default ownership-checked drop for THIS process's exit, which must not
|
|
331
|
+
// delete the credential another running terminal is using.
|
|
332
|
+
const dropPersistedAccount = (ctx: any, opts: { force?: boolean } = {}): void => {
|
|
333
|
+
dropPersistedAccountCredential(ctx, opts);
|
|
326
334
|
};
|
|
327
335
|
|
|
328
336
|
// /update — run the global npm install in a child process and report the outcome via
|
|
@@ -370,7 +378,9 @@ export default function privateerBrand(pi: any): void {
|
|
|
370
378
|
const p = paletteFor(ctx?.ui?.theme);
|
|
371
379
|
const user = await priv.runDeviceLogin({
|
|
372
380
|
onCode: (code: any) => {
|
|
373
|
-
|
|
381
|
+
// Absolute url: the server sends it scheme-less, and this line is what the
|
|
382
|
+
// user copies into a browser. See providers/account.ts verificationLink.
|
|
383
|
+
const uri = clean(verificationLink(code.verification_uri_complete ?? code.verification_uri));
|
|
374
384
|
const userCode = clean(code.user_code);
|
|
375
385
|
ctx?.ui?.setWidget?.(
|
|
376
386
|
"privateer-signin",
|
|
@@ -412,7 +422,7 @@ export default function privateerBrand(pi: any): void {
|
|
|
412
422
|
const u = priv.currentUser();
|
|
413
423
|
ctx?.ui?.notify?.("Signing out of Privateer…", "info");
|
|
414
424
|
await priv.logout(); // never throws: local state is wiped whatever the network did
|
|
415
|
-
dropPersistedAccount(ctx);
|
|
425
|
+
dropPersistedAccount(ctx, { force: true }); // the whole family is revoked, not just ours
|
|
416
426
|
refresh(ctx);
|
|
417
427
|
ctx?.ui?.notify?.(
|
|
418
428
|
`Signed out${u?.email ? ` (${u.email})` : ""} — this machine and its terminals. Drop anchor for now.`,
|
|
@@ -594,6 +604,8 @@ export default function privateerBrand(pi: any): void {
|
|
|
594
604
|
// server-side, so leaving the persisted copy behind would make the NEXT launch
|
|
595
605
|
// reuse a token that's already dead and dead-end on its first prompt (Pi doesn't
|
|
596
606
|
// refresh on a 401). Mirrors the harbor's shutdown (harbor/index.ts).
|
|
607
|
+
// Ownership-checked (no force): revokeLocalSessions killed OUR sessions only, so a
|
|
608
|
+
// persisted entry belonging to another running terminal must survive our exit.
|
|
597
609
|
dropPersistedAccount(ctxRef);
|
|
598
610
|
});
|
|
599
611
|
|
|
@@ -602,8 +614,9 @@ export default function privateerBrand(pi: any): void {
|
|
|
602
614
|
priv.onSessionExpired(() => {
|
|
603
615
|
// clearCredentials() has already wiped the local machine login; also drop Pi's
|
|
604
616
|
// persisted account credential so the next prompt/launch doesn't reuse a token
|
|
605
|
-
// that's now dead server-side (see dropPersistedAccount).
|
|
606
|
-
|
|
617
|
+
// that's now dead server-side (see dropPersistedAccount). Forced: the machine login
|
|
618
|
+
// is gone, so the entry can't be useful to any terminal on this machine.
|
|
619
|
+
dropPersistedAccount(ctxRef, { force: true });
|
|
607
620
|
refresh(ctxRef);
|
|
608
621
|
ctxRef?.ui?.notify?.("Your Privateer session expired. Run /login to sign back in.", "warning");
|
|
609
622
|
});
|
|
@@ -37,9 +37,11 @@ import {
|
|
|
37
37
|
import {
|
|
38
38
|
MCP_CATALOG,
|
|
39
39
|
draftFromCatalog,
|
|
40
|
+
hostedCapable,
|
|
40
41
|
promptOrder,
|
|
41
42
|
type CatalogEntry,
|
|
42
43
|
} from "../src/mcp/catalog.ts";
|
|
44
|
+
import { isHosted } from "../src/config/hosted.ts";
|
|
43
45
|
|
|
44
46
|
// Minimal views of Pi's theme + TUI handle, mirroring privateer-models.ts — enough to
|
|
45
47
|
// color text and request a redraw without coupling this file to Pi internals.
|
|
@@ -126,6 +128,15 @@ interface Step {
|
|
|
126
128
|
// A step that may be submitted empty. For an edit, an empty secret means "keep
|
|
127
129
|
// the value already on disk" — mcpControl's env merge rule makes that free.
|
|
128
130
|
optional?: boolean;
|
|
131
|
+
// Ask this step only when the answers so far say it's relevant — the auth questions
|
|
132
|
+
// are meaningless for a local command, and three dead optional prompts in a row is
|
|
133
|
+
// how a wizard teaches people to hammer Enter without reading. Predicates may only
|
|
134
|
+
// read answers from EARLIER steps.
|
|
135
|
+
when?: (answers: Record<string, string>) => boolean;
|
|
136
|
+
// Reject an answer before it becomes an answer, returning the reason to show. For
|
|
137
|
+
// rules mcpControl can't check because it never sees the raw form input, or that it
|
|
138
|
+
// would only catch after the user has typed three more fields for nothing.
|
|
139
|
+
validate?: (value: string) => string | undefined;
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
type View = "list" | "catalog" | "form";
|
|
@@ -147,7 +158,15 @@ interface Pending {
|
|
|
147
158
|
// from the answer rather than asked as a separate question: an https:// answer is an
|
|
148
159
|
// http server, anything else is a stdio command line. That matches mcpControl's own
|
|
149
160
|
// inference (`draft.url || prev.url ? "http" : "stdio"`), so there's one rule, not two.
|
|
161
|
+
const isHttpTarget = (answers: Record<string, string>) => /^https?:\/\//i.test((answers.target ?? "").trim());
|
|
162
|
+
|
|
150
163
|
function customSteps(existing?: RemoteMcpServer): Step[] {
|
|
164
|
+
// A hosted agent takes remote OAuth connectors and nothing else (Option B —
|
|
165
|
+
// treeview/docs/HARBOR_CONNECTORS_PLAN.md §2), so the custom form drops to that
|
|
166
|
+
// shape: no local command, and no stored token. Both are refused at the question
|
|
167
|
+
// rather than at first call, because a connector that saves cleanly and then never
|
|
168
|
+
// works is the worse of the two failures.
|
|
169
|
+
const hosted = isHosted();
|
|
151
170
|
const target = existing
|
|
152
171
|
? existing.transport === "http"
|
|
153
172
|
? existing.url
|
|
@@ -162,15 +181,48 @@ function customSteps(existing?: RemoteMcpServer): Step[] {
|
|
|
162
181
|
},
|
|
163
182
|
{
|
|
164
183
|
key: "target",
|
|
165
|
-
prompt: "Launch command, or an https:// URL",
|
|
166
|
-
hint:
|
|
184
|
+
prompt: hosted ? "Server URL" : "Launch command, or an https:// URL",
|
|
185
|
+
hint: hosted
|
|
186
|
+
? "e.g. https://mcp.example.com/sse — a hosted agent can't run a local command."
|
|
187
|
+
: "e.g. npx -y @modelcontextprotocol/server-memory · or https://mcp.example.com/sse",
|
|
167
188
|
initial: target,
|
|
189
|
+
validate: hosted
|
|
190
|
+
? (v) =>
|
|
191
|
+
/^https?:\/\//i.test(v.trim())
|
|
192
|
+
? undefined
|
|
193
|
+
: "A hosted agent can only reach remote connectors — this needs an https:// URL."
|
|
194
|
+
: undefined,
|
|
168
195
|
},
|
|
169
196
|
{
|
|
170
197
|
key: "env",
|
|
171
198
|
prompt: "Environment variables (optional)",
|
|
172
199
|
hint: "KEY=value, comma-separated. Leave blank for none.",
|
|
173
200
|
optional: true,
|
|
201
|
+
// Nothing to set them ON when there is no local process to spawn, and their
|
|
202
|
+
// values are credentials at rest — both reasons a hosted agent skips this.
|
|
203
|
+
when: () => !hosted,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
key: "bearerToken",
|
|
207
|
+
prompt: "Bearer token (optional)",
|
|
208
|
+
hint: existing?.bearerTokenSet
|
|
209
|
+
? "Leave blank to keep the token already saved."
|
|
210
|
+
: "Leave blank to sign in through the browser instead (OAuth).",
|
|
211
|
+
secret: true,
|
|
212
|
+
optional: true,
|
|
213
|
+
// Never asked on a hosted agent: the home is tmpfs and a stored token would have
|
|
214
|
+
// to rest somewhere we can read. OAuth is the only credential it may hold.
|
|
215
|
+
when: (a) => !hosted && isHttpTarget(a),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
key: "headers",
|
|
219
|
+
prompt: "Extra HTTP headers (optional)",
|
|
220
|
+
hint: "KEY=value, comma-separated — e.g. X-Api-Version=2. Leave blank for none.",
|
|
221
|
+
// Header values are credentials as often as not, so this is masked like a token
|
|
222
|
+
// — and for the same reason a hosted agent is never offered it.
|
|
223
|
+
secret: true,
|
|
224
|
+
optional: true,
|
|
225
|
+
when: (a) => !hosted && isHttpTarget(a),
|
|
174
226
|
},
|
|
175
227
|
];
|
|
176
228
|
}
|
|
@@ -196,7 +248,22 @@ function buildCustomDraft(answers: Record<string, string>): McpDraft {
|
|
|
196
248
|
if (/^https?:\/\//i.test(target)) {
|
|
197
249
|
draft.transport = "http";
|
|
198
250
|
draft.url = target;
|
|
199
|
-
|
|
251
|
+
const token = (answers.bearerToken ?? "").trim();
|
|
252
|
+
const headers = parseEnv(answers.headers ?? "");
|
|
253
|
+
const hasHeaders = Object.keys(headers).length > 0;
|
|
254
|
+
if (hasHeaders) draft.headers = headers;
|
|
255
|
+
if (token) {
|
|
256
|
+
// A static token: the adapter only sends the Authorization header when auth is
|
|
257
|
+
// explicitly "bearer", so this pairing is not optional.
|
|
258
|
+
draft.auth = "bearer";
|
|
259
|
+
draft.bearerToken = token;
|
|
260
|
+
} else if (hasHeaders) {
|
|
261
|
+
// Custom headers ARE the credential here — the adapter's supportsOAuth() refuses
|
|
262
|
+
// OAuth once headers are configured, so claiming "oauth" would be a lie.
|
|
263
|
+
draft.auth = "none";
|
|
264
|
+
} else {
|
|
265
|
+
draft.auth = "oauth";
|
|
266
|
+
}
|
|
200
267
|
} else {
|
|
201
268
|
draft.transport = "stdio";
|
|
202
269
|
const parts = target.split(/\s+/).filter(Boolean);
|
|
@@ -265,8 +332,16 @@ const NEEDS_LABEL: Record<string, string> = {
|
|
|
265
332
|
none: "no setup",
|
|
266
333
|
};
|
|
267
334
|
|
|
335
|
+
// On a hosted (Harbor) agent the picker offers only what the runtime can actually
|
|
336
|
+
// hold — see hostedCapable(). Offering the other 16 would be offering a connector that
|
|
337
|
+
// installs nothing, keeps nothing, and fails at first call: the tenant is `--read-only`
|
|
338
|
+
// with no uv/uvx/Python and a tmpfs home wiped on every suspend. Filtering here rather
|
|
339
|
+
// than letting them fail later is the difference between "not available on a hosted
|
|
340
|
+
// agent" and "I set it up and it just doesn't work."
|
|
268
341
|
function catalogRows(): CatalogRow[] {
|
|
269
|
-
const
|
|
342
|
+
const hosted = isHosted();
|
|
343
|
+
const entries = hosted ? MCP_CATALOG.filter(hostedCapable) : MCP_CATALOG;
|
|
344
|
+
const rows: CatalogRow[] = entries.map((e) => ({
|
|
270
345
|
entry: e,
|
|
271
346
|
label: e.label,
|
|
272
347
|
blurb: e.blurb,
|
|
@@ -274,7 +349,9 @@ function catalogRows(): CatalogRow[] {
|
|
|
274
349
|
}));
|
|
275
350
|
rows.push({
|
|
276
351
|
label: "Custom…",
|
|
277
|
-
|
|
352
|
+
// Custom survives the filter because a custom REMOTE OAuth connector is fine here;
|
|
353
|
+
// it's stdio and stored tokens that aren't. customSteps enforces that.
|
|
354
|
+
blurb: hosted ? "Any https:// URL with browser sign-in." : "Any stdio command or http URL.",
|
|
278
355
|
needsLabel: "",
|
|
279
356
|
});
|
|
280
357
|
return rows;
|
|
@@ -359,6 +436,36 @@ class ConnectPanel extends Container {
|
|
|
359
436
|
return this.pending?.steps[this.stepIndex];
|
|
360
437
|
}
|
|
361
438
|
|
|
439
|
+
// A step is asked only when its `when` predicate (if any) passes against the answers
|
|
440
|
+
// collected so far. Navigation walks over the hidden ones in both directions, and
|
|
441
|
+
// the position counter reports the VISIBLE steps so "2/3" doesn't count a question
|
|
442
|
+
// the user will never see.
|
|
443
|
+
private stepVisible(i: number): boolean {
|
|
444
|
+
const step = this.pending?.steps[i];
|
|
445
|
+
if (!step) return false;
|
|
446
|
+
return step.when ? step.when(this.answers) : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private seekStep(from: number, dir: 1 | -1): number | undefined {
|
|
450
|
+
const total = this.pending?.steps.length ?? 0;
|
|
451
|
+
for (let i = from; i >= 0 && i < total; i += dir) {
|
|
452
|
+
if (this.stepVisible(i)) return i;
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private visiblePosition(): { at: number; of: number } {
|
|
458
|
+
const total = this.pending?.steps.length ?? 0;
|
|
459
|
+
let at = 0;
|
|
460
|
+
let of = 0;
|
|
461
|
+
for (let i = 0; i < total; i++) {
|
|
462
|
+
if (!this.stepVisible(i)) continue;
|
|
463
|
+
of++;
|
|
464
|
+
if (i <= this.stepIndex) at = of;
|
|
465
|
+
}
|
|
466
|
+
return { at, of };
|
|
467
|
+
}
|
|
468
|
+
|
|
362
469
|
// -- rendering ------------------------------------------------------------
|
|
363
470
|
|
|
364
471
|
private refresh(): void {
|
|
@@ -429,6 +536,13 @@ class ConnectPanel extends Container {
|
|
|
429
536
|
private renderCatalog(): void {
|
|
430
537
|
const t = this.theme;
|
|
431
538
|
this.body.addChild(new Text(t.fg("accent", t.bold("Add a connector")), 1, 0));
|
|
539
|
+
// Say why the list is short before the user goes looking for GitHub. A filter with
|
|
540
|
+
// no explanation reads as a missing connector, which is a support question.
|
|
541
|
+
if (isHosted()) {
|
|
542
|
+
this.body.addChild(
|
|
543
|
+
new Text(t.fg("muted", " Hosted agent — remote connectors with browser sign-in only."), 1, 0),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
432
546
|
this.body.addChild(new Spacer(1));
|
|
433
547
|
this.body.addChild(this.search);
|
|
434
548
|
this.body.addChild(new Spacer(1));
|
|
@@ -458,7 +572,8 @@ class ConnectPanel extends Container {
|
|
|
458
572
|
const t = this.theme;
|
|
459
573
|
const p = this.pending!;
|
|
460
574
|
const step = this.currentStep()!;
|
|
461
|
-
const
|
|
575
|
+
const pos = this.visiblePosition();
|
|
576
|
+
const counter = pos.of > 1 ? ` ${pos.at}/${pos.of}` : "";
|
|
462
577
|
this.body.addChild(new Text(t.fg("accent", t.bold(p.title)) + t.fg("muted", counter), 1, 0));
|
|
463
578
|
this.body.addChild(new Spacer(1));
|
|
464
579
|
this.body.addChild(new Text(` ${t.fg("text", step.prompt)}`, 1, 0));
|
|
@@ -555,10 +670,19 @@ class ConnectPanel extends Container {
|
|
|
555
670
|
this.refresh();
|
|
556
671
|
return;
|
|
557
672
|
}
|
|
673
|
+
// Validate only what was actually typed: a blank optional answer means "skip", and
|
|
674
|
+
// a rule about the shape of a value has nothing to say about its absence.
|
|
675
|
+
const bad = value && step.validate ? step.validate(value) : undefined;
|
|
676
|
+
if (bad) {
|
|
677
|
+
this.status = bad;
|
|
678
|
+
this.refresh();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
558
681
|
this.answers[step.key] = value;
|
|
559
682
|
this.status = "";
|
|
560
|
-
|
|
561
|
-
|
|
683
|
+
const next = this.seekStep(this.stepIndex + 1, 1);
|
|
684
|
+
if (next !== undefined) {
|
|
685
|
+
this.stepIndex = next;
|
|
562
686
|
this.loadStep();
|
|
563
687
|
return;
|
|
564
688
|
}
|
|
@@ -701,8 +825,9 @@ class ConnectPanel extends Container {
|
|
|
701
825
|
const step = this.currentStep()!;
|
|
702
826
|
if (kb.matches(data, "tui.select.cancel")) {
|
|
703
827
|
// Back a step, or out of the form entirely from the first one.
|
|
704
|
-
|
|
705
|
-
|
|
828
|
+
const prev = this.stepIndex > 0 ? this.seekStep(this.stepIndex - 1, -1) : undefined;
|
|
829
|
+
if (prev !== undefined) {
|
|
830
|
+
this.stepIndex = prev;
|
|
706
831
|
this.loadStep();
|
|
707
832
|
} else {
|
|
708
833
|
this.view = this.pending?.origin ?? "list";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@phala/dcap-qvl": "^0.5.2",
|
|
71
71
|
"patch-package": "^8.0.1",
|
|
72
72
|
"pi-mcp-adapter": "^2.11.0",
|
|
73
|
-
"pi-privacy": "^0.
|
|
73
|
+
"pi-privacy": "^0.9.0",
|
|
74
74
|
"pi-subagents": "^0.34.0",
|
|
75
75
|
"picomatch": "^4.0.4",
|
|
76
76
|
"privateer-workflow": "^0.1.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
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..cc853e7 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 {
|
|
@@ -33,6 +33,25 @@ index e223ce1..3615d74 100644
|
|
|
33
33
|
}
|
|
34
34
|
if (await this._checkCompaction(msg)) {
|
|
35
35
|
return true;
|
|
36
|
+
@@ -1996,6 +2013,18 @@ export class AgentSession {
|
|
37
|
+
// Context overflow is handled by compaction, not retry.
|
|
38
|
+
if (isContextOverflow(message, this.model?.contextWindow ?? 0))
|
|
39
|
+
return false;
|
|
40
|
+
+ // Privateer patch: a Privateer ACCOUNT CAP is not a throttle. Daily/monthly
|
|
41
|
+
+ // message or token limits (and an exhausted balance) come back from the account
|
|
42
|
+
+ // channel as a 429 carrying the backend's machine `code` and a ready-to-show
|
|
43
|
+
+ // message. pi classifies anything containing "429" as transient, so the agent
|
|
44
|
+
+ // spent its whole retry budget — with exponential backoff — on a limit that
|
|
45
|
+
+ // cannot clear, and only then showed the user the one message that says what to
|
|
46
|
+
+ // do (upgrade, top up, or /login keys for a BYO key). Scoped to the `privateer`
|
|
47
|
+
+ // provider and to the backend's own cap wording so no other provider's transient
|
|
48
|
+
+ // rate limit is affected. Mirrors isAccountCapCode in src/engine/errors.ts.
|
|
49
|
+
+ if (this.model?.provider === "privateer" &&
|
|
50
|
+
+ /"code"\s*:\s*"[A-Z0-9_]*(?:CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP)[A-Z0-9_]*"|limit of [^.]*reached|upgrade or top ?up|usage limit reached/i.test(message.errorMessage ?? ""))
|
|
51
|
+
+ return false;
|
|
52
|
+
return isRetryableAssistantError(message);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
36
55
|
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
56
|
index 197bccc..27f6429 100644
|
|
38
57
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/auth-guidance.js
|
package/src/auth/privateer.ts
CHANGED
|
@@ -149,10 +149,12 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
|
149
149
|
// copy in place would let the next run send a token that still looks valid but is dead
|
|
150
150
|
// server-side → inference fails with a dead-end `401 {code: SESSION_REVOKED}`.
|
|
151
151
|
// The fix is to revoke it AND drop the persisted credential together: the caller must
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
152
|
+
// drop the "privateer" entry from Pi's authStorage — via
|
|
153
|
+
// providers/account.ts dropPersistedAccountCredential(), NOT a bare
|
|
154
|
+
// authStorage.remove(), because auth.json is machine-global and the entry may belong to
|
|
155
|
+
// another running terminal — right after revokeLocalSessions(), so the next launch
|
|
156
|
+
// spawns a fresh session instead of reusing the revoked one. Doing both is safe; doing
|
|
157
|
+
// only one is not. See revokeLocalSessions and its callers (cli/chat.ts, harbor/index.ts).
|
|
156
158
|
//
|
|
157
159
|
// That pairing only covers a CLEAN exit, though. A terminal killed without running its
|
|
158
160
|
// shutdown hook leaves its row alive server-side for the full TTL, and the next launch
|
|
@@ -195,6 +197,21 @@ export function saveCredentials(creds: Credentials): void {
|
|
|
195
197
|
shared().cache = creds;
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
// The account-provider credential this process armed is memoized on a registered
|
|
201
|
+
// symbol by providers/account.ts (one slot across jiti's per-extension module copies).
|
|
202
|
+
// Clearing it from here — rather than importing account.ts, which would make a cycle —
|
|
203
|
+
// keeps a single rule: local credentials gone ⇒ armed credential gone.
|
|
204
|
+
//
|
|
205
|
+
// Without this, /logout followed by signing in as a DIFFERENT account reused the old
|
|
206
|
+
// account's memoized session: logout() revokes that whole token family, so the new
|
|
207
|
+
// sign-in armed Pi with a token already dead server-side and the first prompt 401'd.
|
|
208
|
+
const ARMED_SLOT = Symbol.for("privateer.accountCredential");
|
|
209
|
+
|
|
210
|
+
function forgetArmedAccountCredential(): void {
|
|
211
|
+
const slot = (globalThis as { [ARMED_SLOT]?: { cred?: unknown } })[ARMED_SLOT];
|
|
212
|
+
if (slot) slot.cred = undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
198
215
|
export function clearCredentials(): void {
|
|
199
216
|
try {
|
|
200
217
|
rmSync(credentialsPath(), { force: true });
|
|
@@ -207,6 +224,7 @@ export function clearCredentials(): void {
|
|
|
207
224
|
shared().cache = null;
|
|
208
225
|
_child = null;
|
|
209
226
|
_account = null;
|
|
227
|
+
forgetArmedAccountCredential();
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
export function hasCredentials(): boolean {
|
|
@@ -623,7 +641,8 @@ export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
|
623
641
|
*
|
|
624
642
|
* IMPORTANT: the account session is persisted by Pi (auth.json) and reused on the next
|
|
625
643
|
* launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
|
|
626
|
-
* copy right after this resolves — `
|
|
644
|
+
* copy right after this resolves — `dropPersistedAccountCredential(ctx)` from
|
|
645
|
+
* providers/account.ts, which drops it only if THIS process minted it — or the next run
|
|
627
646
|
* will reuse the token we just revoked and dead-end on a 401 (see the _account note).
|
|
628
647
|
* Callers: cli/chat.ts cleanup() and harbor/index.ts shutdown().
|
|
629
648
|
*/
|
package/src/channels/run.ts
CHANGED
|
@@ -46,6 +46,9 @@
|
|
|
46
46
|
|
|
47
47
|
import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
48
48
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
49
|
+
// Names only — the factory itself is imported lazily in main() like every other
|
|
50
|
+
// module here. Safe statically: this evaluates after boot.ts and pulls in no Pi.
|
|
51
|
+
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
49
52
|
|
|
50
53
|
// Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
|
|
51
54
|
// a turn nobody is watching can't mutate the filesystem or shell out. Now that the
|
|
@@ -54,6 +57,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
54
57
|
// prompts in-chat for a yes/no.
|
|
55
58
|
const SAFE_TOOLS = ["read", "grep", "find", "ls"];
|
|
56
59
|
|
|
60
|
+
// Web tools join the default set when the agent has web access — see
|
|
61
|
+
// config/hosted.ts. Kept out of SAFE_TOOLS proper because they're the one "read-only"
|
|
62
|
+
// capability that still sends a query off the machine.
|
|
63
|
+
const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
|
|
64
|
+
|
|
57
65
|
// A channel's posture governs how an ADMIN's risky actions are handled (members are
|
|
58
66
|
// always capped to read-only — see effectivePosture). Config + restart only; there
|
|
59
67
|
// is deliberately no in-chat toggle.
|
|
@@ -111,6 +119,8 @@ async function main() {
|
|
|
111
119
|
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
112
120
|
const { makeAccountProvider, privateerChannel } = await import("../providers/account.ts");
|
|
113
121
|
const { hasCredentials } = await import("../auth/privateer.ts");
|
|
122
|
+
const { makeWebTools } = await import("../tools/web.ts");
|
|
123
|
+
const { webEnabled } = await import("../config/hosted.ts");
|
|
114
124
|
const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
|
|
115
125
|
const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
|
|
116
126
|
const { redactText, collectSecrets } = await import("../util/redact.ts");
|
|
@@ -133,7 +143,10 @@ async function main() {
|
|
|
133
143
|
}
|
|
134
144
|
const ch = cfg.channels ?? {};
|
|
135
145
|
const defaultModel: string = resolveDefaultModel({ explicit: ch.model ?? cfg.defaultModel });
|
|
136
|
-
const
|
|
146
|
+
const web = webEnabled();
|
|
147
|
+
const defaultTools: string[] = Array.isArray(ch.tools) && ch.tools.length
|
|
148
|
+
? (web ? ch.tools : ch.tools.filter((t: string) => !WEB_TOOLS.includes(t)))
|
|
149
|
+
: (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
|
|
137
150
|
const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
|
|
138
151
|
const cwd: string = ch.cwd ?? process.cwd();
|
|
139
152
|
const secrets = collectSecrets(cfg.providers);
|
|
@@ -190,6 +203,10 @@ async function main() {
|
|
|
190
203
|
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
191
204
|
}),
|
|
192
205
|
makeAccountProvider(),
|
|
206
|
+
// Web access (src/tools/web.ts) — same wiring as the harbor: registered here
|
|
207
|
+
// because this path builds its session explicitly, and omitted entirely when
|
|
208
|
+
// the agent isn't allowed the web.
|
|
209
|
+
...(webEnabled() ? [makeWebTools()] : []),
|
|
193
210
|
] as any,
|
|
194
211
|
},
|
|
195
212
|
});
|
package/src/cli/chat.ts
CHANGED
|
@@ -39,7 +39,13 @@ async function main() {
|
|
|
39
39
|
const { authorizeControl } = await import("../remote/controlAuth.ts");
|
|
40
40
|
const { resolveMentions, completeMention, searchFiles } = await import("../util/fileMentions.ts");
|
|
41
41
|
const priv = await import("../auth/privateer.ts");
|
|
42
|
-
const {
|
|
42
|
+
const {
|
|
43
|
+
makeAccountProvider,
|
|
44
|
+
accountPosture,
|
|
45
|
+
privateerChannel,
|
|
46
|
+
rememberAccountCredential,
|
|
47
|
+
dropPersistedAccountCredential,
|
|
48
|
+
} = await import("../providers/account.ts");
|
|
43
49
|
const { agentVersion } = await import("../config/version.ts");
|
|
44
50
|
const { resolveDefaultModel, resolveSignedInModel } = await import("../providers/defaultModel.ts");
|
|
45
51
|
|
|
@@ -397,7 +403,9 @@ async function main() {
|
|
|
397
403
|
cleanedUp = true;
|
|
398
404
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
399
405
|
try { await priv.revokeLocalSessions(); } catch { /* best effort — server TTL is the fallback */ }
|
|
400
|
-
|
|
406
|
+
// Ownership-checked: auth.json is machine-global, so removing an entry another
|
|
407
|
+
// running terminal minted would strand it (see providers/account.ts).
|
|
408
|
+
try { dropPersistedAccountCredential({ modelRegistry: { authStorage: services.authStorage } }); } catch { /* nothing persisted */ }
|
|
401
409
|
}
|
|
402
410
|
const onSignal = (): void => { void cleanup().finally(() => process.exit(0)); };
|
|
403
411
|
process.once("SIGINT", onSignal);
|
|
@@ -409,6 +417,7 @@ async function main() {
|
|
|
409
417
|
try {
|
|
410
418
|
const creds = await priv.acquireAccountCredential();
|
|
411
419
|
(services.authStorage as any).set("privateer", { type: "oauth", ...creds });
|
|
420
|
+
rememberAccountCredential(creds); // claim it, so cleanup drops OUR entry and only ours
|
|
412
421
|
} catch (e) {
|
|
413
422
|
console.log(`${RED}Account channel unavailable: ${(e as Error).message}${RESET}`);
|
|
414
423
|
}
|
package/src/config/hosted.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { globalDir } from "./paths.ts";
|
|
4
4
|
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
5
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
5
6
|
|
|
6
7
|
// Harbor hosted mode.
|
|
7
8
|
//
|
|
@@ -17,6 +18,26 @@ export function isHosted(): boolean {
|
|
|
17
18
|
return process.env.HARBOR_HOSTED === "1";
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Is this agent allowed to reach the live web (web_search / web_fetch)?
|
|
23
|
+
*
|
|
24
|
+
* Both tools are served by the account API, so credentials are a hard prerequisite —
|
|
25
|
+
* without them there is nothing to authenticate with and every call would 401.
|
|
26
|
+
*
|
|
27
|
+
* `HARBOR_WEB` is authoritative when set. Hosted agents always set it explicitly, from
|
|
28
|
+
* the per-agent switch in the app (harborOrchestrator/tenants.js → tenantEnv), because
|
|
29
|
+
* a search sends the derived query out of the enclave to our servers and that has to be
|
|
30
|
+
* the user's call. Unset — a daemon on someone's own laptop — defaults to on once
|
|
31
|
+
* signed in: the same account, the same billing, and nothing to disclose beyond what
|
|
32
|
+
* the tool description already says.
|
|
33
|
+
*/
|
|
34
|
+
export function webEnabled(): boolean {
|
|
35
|
+
const flag = process.env.HARBOR_WEB;
|
|
36
|
+
if (flag === "1") return hasCredentials();
|
|
37
|
+
if (flag === "0") return false;
|
|
38
|
+
return hasCredentials();
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/**
|
|
21
42
|
* Publish this harbor's relay identity key so the Harbor host can attest it.
|
|
22
43
|
*
|