privateer-agent 0.12.12 → 0.12.14

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.
Files changed (37) hide show
  1. package/README.md +11 -3
  2. package/bin/privateer-subagent.mjs +10 -2
  3. package/extensions/privateer-gate.ts +7 -0
  4. package/extensions/privateer-media.ts +17 -4
  5. package/extensions/privateer-privacy.ts +13 -33
  6. package/extensions/privateer-tools.ts +8 -3
  7. package/extensions/privateer-web.ts +100 -0
  8. package/package.json +1 -1
  9. package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +102 -5
  10. package/src/acp/run.ts +29 -2
  11. package/src/channels/run.ts +9 -0
  12. package/src/cli/chat.ts +47 -0
  13. package/src/config/moat.ts +6 -26
  14. package/src/config/moatManifest.json +2 -2
  15. package/src/config/moatManifest.ts +5 -0
  16. package/src/config/privacyPolicy.ts +97 -0
  17. package/src/engine/errors.ts +97 -1
  18. package/src/ext/permissionGate.ts +6 -0
  19. package/src/harbor/index.ts +65 -11
  20. package/src/outbox/cloudOutbox.ts +72 -4
  21. package/src/permissions/childSpend.ts +105 -0
  22. package/src/permissions/classify.ts +61 -3
  23. package/src/permissions/modeGate.ts +39 -0
  24. package/src/providers/account.ts +8 -1
  25. package/src/providers/phala/measurements.ts +170 -0
  26. package/src/providers/phala/pin.ts +98 -0
  27. package/src/providers/phalaSeal.ts +131 -9
  28. package/src/providers/sealedShim.ts +6 -1
  29. package/src/remote/liveTaskSession.ts +8 -1
  30. package/src/remote/relayClient.ts +21 -0
  31. package/src/remote/remoteBridge.ts +9 -0
  32. package/src/routines/store.ts +11 -0
  33. package/src/tools/media.ts +516 -11
  34. package/src/tools/routineResult.ts +146 -0
  35. package/src/tools/videoCompose.ts +825 -7
  36. package/src/tools/web.ts +52 -13
  37. package/src/tools/webMode.ts +198 -0
package/README.md CHANGED
@@ -72,7 +72,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
72
72
  | `privateer-privacy` | `pi-privacy` — TEE attestation, ZDR routing, on-device PII gate — bound to the account tier resolver |
73
73
  | `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
74
74
  | `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
75
- | `rpiv-web-tools` | private-by-default web search (self-hosted SearXNG, no WebView) |
75
+ | `privateer-web` | `web_search`/`web_fetch` your account's search once signed in, or your own provider (`rpiv-web-tools`: self-hosted SearXNG, Brave, Tavily…) |
76
76
  | `rpiv-ask-user-question` | `ask_user_question` — a structured questionnaire the agent puts to you instead of guessing |
77
77
  | `pi-mcp-adapter`, `pi-subagents` | MCP servers · bounded parallel sub-agents |
78
78
 
@@ -612,8 +612,15 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
612
612
  approve it once.
613
613
  - **Workflows** — declarative multi-step pipelines the harbor executes; see
614
614
  [Workflows](#workflows).
615
- - **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
616
- (self-hosted SearXNG for fully private search).
615
+ - **Web tools** (`privateer-web`) — `web_search` and `web_fetch`, by one of two routes. Sign in
616
+ and they run on your Privateer account: no API key to obtain or keep on the machine, metered
617
+ against the account's daily web allowance, with the search itself made server-side (the derived
618
+ query is visible to Privateer — the page you fetch and the conversation around it are not).
619
+ Configure a provider of your own with `/web-tools` and that wins instead — `rpiv-web-tools` with
620
+ pluggable backends, including self-hosted SearXNG for fully private search, which is why a
621
+ provider you chose is never overridden. `PRIVATEER_WEB_SEARCH=privateer` picks the account route
622
+ even when you hold a key. Unattended runs (harbor, channels, ACP) always take the account route:
623
+ a routine must not hold a provider key it could be prompt-injected into leaking.
617
624
  - **Ask user question** (`rpiv-ask-user-question`) — when a request is underspecified the agent
618
625
  raises a structured questionnaire (typed options, multi-select, markdown previews, or type your
619
626
  own answer) instead of guessing. Ungated by design — it only asks you something.
@@ -631,6 +638,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
631
638
  | `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
632
639
  | `/speak` · `/talk` | read answers aloud / voice input (**alt+t** is push-to-talk) |
633
640
  | `/extensions` | list loaded Pi extensions |
641
+ | `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
634
642
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
635
643
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
636
644
 
@@ -35,8 +35,15 @@ const REPO = resolve(HERE, ".."); // repo root
35
35
  // which build their moat from factories and have no `-e` list to hand down. Those get the
36
36
  // floor and nothing else: gate = the permission moat (fail-closed, forwards the child's
37
37
  // approvals to the parent); privacy = ZDR/TEE posture + attestation dispatcher; account =
38
- // the privateer/* provider, so a child can run account models. Deliberately narrower than
39
- // a terminal's — an unattended run's children have no human to approve a tool that spends.
38
+ // the privateer/* provider, so a child can run account models.
39
+ //
40
+ // media is here for the work an unattended run most wants to delegate — a film is a
41
+ // per-shot job, and a shot per subagent is the shape that fits. It is SAFE to list
42
+ // unconditionally because the extension shapes itself rather than trusting its position
43
+ // on this list: video_compose (local ffmpeg, no spend) always registers, and the billing
44
+ // generate_* tools register only when the parent handed this child an explicit spend
45
+ // grant. See extensions/privateer-media.ts and src/permissions/childSpend.ts. Still
46
+ // narrower than a terminal's list — no web, no MCP, no brand/hints/update surface.
40
47
  export function moatExtensionPaths(repoRoot = REPO, env = process.env) {
41
48
  const inherited = (env.PRIVATEER_CHILD_EXTENSIONS ?? "")
42
49
  .split(delimiter)
@@ -47,6 +54,7 @@ export function moatExtensionPaths(repoRoot = REPO, env = process.env) {
47
54
  join(repoRoot, "extensions", "privateer-gate.ts"),
48
55
  join(repoRoot, "extensions", "privateer-privacy.ts"),
49
56
  join(repoRoot, "extensions", "privateer-account.ts"),
57
+ join(repoRoot, "extensions", "privateer-media.ts"),
50
58
  ];
51
59
  }
52
60
 
@@ -32,6 +32,7 @@ import { matchesKey } from "@earendil-works/pi-tui";
32
32
  import * as priv from "../src/auth/privateer.ts";
33
33
  import { paletteFor } from "../src/ui/palette.ts";
34
34
  import { noQuarterActive, setNoQuarter } from "../src/permissions/noQuarter.ts";
35
+ import { childSpendAllows } from "../src/permissions/childSpend.ts";
35
36
  import type { PermissionMode } from "../src/config/permissionMode.ts";
36
37
 
37
38
  const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
@@ -429,6 +430,12 @@ const gate = makePermissionGate({
429
430
  // which subagent children inherit through the env. See src/permissions/noQuarter.ts.
430
431
  getSkipAllPermissions: noQuarterActive,
431
432
  remoteAsk: bridge.remoteAsk,
433
+ // Billing tools this process was authorized for BEFORE it started. Only ever non-empty
434
+ // inside a subagent child whose parent handed one down (childSpend.ts reads the env only
435
+ // when pi-subagents has marked us a child), so a terminal keeps asking its human. This
436
+ // is what lets an unattended run delegate a shot to a subagent: without it the child's
437
+ // gate denies every generate_* call, having no one to ask.
438
+ isSpendPreauthorized: (req) => childSpendAllows(req.tool),
432
439
  });
433
440
 
434
441
  export default function privateerControl(pi: any): void {
@@ -1,5 +1,6 @@
1
- // Media tools for Pi's TUI: generate images, video, speech and music through the
2
- // signed-in Privateer account, and stitch the results together locally with ffmpeg.
1
+ // Media tools for Pi's TUI: generate images, video, 3D meshes, speech, music and
2
+ // sound effects through the signed-in Privateer account, and stitch the results
3
+ // together locally with ffmpeg.
3
4
  //
4
5
  // Generation is registered only when the account channel can actually serve it
5
6
  // (mediaEnabled → signed in, and HARBOR_MEDIA not explicitly off). Omitting the
@@ -7,14 +8,26 @@
7
8
  // 401s on every call teaches the model to keep retrying, whereas a tool that isn't
8
9
  // there makes it say "you'd need to sign in" and move on.
9
10
  //
11
+ // A SUBAGENT CHILD is held to the same rule for the same reason, one step further out.
12
+ // A child is a headless process with nobody to approve a billing call, so unless its
13
+ // parent handed down a spend grant (src/permissions/childSpend.ts — an unattended run
14
+ // passing on the media tools its own allow-list names), every generate_* call it made
15
+ // would be denied by the gate. Registering them anyway would spend the child's whole
16
+ // context discovering that one refusal at a time. So an ungranted child gets no
17
+ // generation tools and reports the truth: it can compose, not generate.
18
+ //
10
19
  // video_compose is registered UNCONDITIONALLY. It is local ffmpeg work on files
11
20
  // already on disk — no account, no network, no spend — so it stays useful to a
12
- // signed-out terminal editing media that came from anywhere.
21
+ // signed-out terminal, and to a child whose job is to cut together what its parent
22
+ // generated.
13
23
  import { makeMediaTools } from "../src/tools/media.ts";
14
24
  import { makeComposeTools } from "../src/tools/videoCompose.ts";
15
25
  import { mediaEnabled } from "../src/config/hosted.ts";
26
+ import { childHoldsSpendGrant } from "../src/permissions/childSpend.ts";
27
+ import { isSubagentChild } from "../src/remote/subagentRelay.ts";
16
28
 
17
29
  export default function privateerMedia(pi: any): void {
18
- if (mediaEnabled()) makeMediaTools()(pi);
30
+ const canSpend = !isSubagentChild() || childHoldsSpendGrant();
31
+ if (mediaEnabled() && canSpend) makeMediaTools()(pi);
19
32
  makeComposeTools()(pi);
20
33
  }
@@ -1,9 +1,10 @@
1
1
  // pi-privacy for privateer-agent: the standard pi-privacy extension (providers +
2
- // attestation + posture badge feed + PII gate) PLUS a tier resolver that teaches it
3
- // about the private ACCOUNT channel it doesn't ship so a privateer/near… model
4
- // (actually confidential-compute TEE) is treated as verified-private (no PII
5
- // over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
6
- // default entry directly.
2
+ // attestation + posture badge feed + PII gate) configured the way every Privateer session
3
+ // configures it — src/config/privacyPolicy.ts, which src/config/moat.ts hands to the
4
+ // factory-built sessions verbatim. Chiefly that means a tier resolver teaching pi-privacy
5
+ // about the private ACCOUNT channel it doesn't ship, so a privateer/near… model (actually
6
+ // confidential-compute TEE) is treated as verified-private (no PII over-warning) and a zdr
7
+ // account model as zdr-policy. Replaces loading pi-privacy's default entry directly.
7
8
  //
8
9
  // It also REPAIRS two provider registrations pi-privacy makes from its own catalog, each
9
10
  // of which replaces (not merges) whatever model list that provider already had:
@@ -23,7 +24,8 @@
23
24
  // display/resolution + routing list — posture and attestation are dispatcher-bound and
24
25
  // unaffected by the model set.
25
26
  import { makePiPrivacyExtension } from "pi-privacy";
26
- import { accountPosture, registerAccountModels } from "../src/providers/account.ts";
27
+ import { registerAccountModels } from "../src/providers/account.ts";
28
+ import { sharedPrivacyOptions } from "../src/config/privacyPolicy.ts";
27
29
 
28
30
  // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), kimi-k2-6 first — the
29
31
  // launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
@@ -51,33 +53,11 @@ function tinfoilModel(id: string) {
51
53
  };
52
54
  }
53
55
 
54
- const privacy = makePiPrivacyExtension({
55
- resolveTier: async (provider, modelId) => {
56
- if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
57
- return (await accountPosture(modelId)).tier;
58
- },
59
- // pi-privacy 0.8 added an INGEST gate: credentials arriving in a tool result are
60
- // redacted before they enter context (they'd otherwise be re-sent every turn and
61
- // written to the session file on disk). We already redact tool output in
62
- // src/ext/permissionGate.ts, so its default "warn" would put an interactive prompt
63
- // in front of something this app has always handled silently — "redact" keeps our
64
- // UX and still takes the added coverage.
65
- //
66
- // The two redactors are COMPLEMENTARY, not duplicative, which is why we run both:
67
- // ours masks the configured provider keys by exact value (from env/config) plus the
68
- // provider-specific shapes (sk-/AIza/xai-/gsk_/csk-/vapi_/fw_/Z.ai, auth headers);
69
- // pi-privacy's catches what shows up in USER code and shell output — AWS AKIA/ASIA,
70
- // GitHub gh[pousr]_, JWTs, PEM private-key blocks, Slack, Stripe — none of which
71
- // our patterns match.
72
- //
73
- // Order between the two is NOT guaranteed: pi discovers extensions with a bare
74
- // readdirSync and never sorts, so it's filesystem-dependent (alphabetical on this
75
- // box today, not by contract). "redact" makes that moot — both handlers run
76
- // unconditionally and each masks its own patterns, so the surviving content is the
77
- // same either way. Under "warn" the order WOULD matter, since it decides whether
78
- // the prompt is raised on a raw key or one we already masked.
79
- toolResultPolicy: "redact",
80
- });
56
+ // One configuration, shared with the factory-built copy in src/config/moat.ts — the tier
57
+ // resolver for the private ACCOUNT channel, the unattended/no-quarter handling, the ingest
58
+ // policy. Adding an option HERE rather than there is how this file and the moat drifted
59
+ // twice; src/config/privacyPolicy.ts records what that cost.
60
+ const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
81
61
 
82
62
  export default function privateerPrivacy(pi: any): void {
83
63
  privacy(pi);
@@ -1,9 +1,14 @@
1
1
  // Privateer-specific custom tools for Pi's TUI (Phase 5). Today: create_routine
2
- // (schedule unattended tasks → the harbor runs them). The generic tools (read/edit/
3
- // bash/grep, web, subagents, todo) come from Pi builtins + adopted packages, so only
4
- // the privateer-only tools live here. Gated by our permission-gate extension.
2
+ // (schedule unattended tasks → the harbor runs them) and read_routine_result (read
3
+ // one back its standing instruction plus its latest output so a conversation can
4
+ // ACT on what a routine found instead of only being told it ran). The generic tools
5
+ // (read/edit/bash/grep, web, subagents, todo) come from Pi builtins + adopted
6
+ // packages, so only the privateer-only tools live here. Gated by our permission-gate
7
+ // extension.
5
8
  import { routineToolDefinition } from "../src/tools/routine.ts";
9
+ import { routineResultToolDefinition } from "../src/tools/routineResult.ts";
6
10
 
7
11
  export default function privateerTools(pi: any): void {
8
12
  pi.registerTool?.(routineToolDefinition);
13
+ pi.registerTool?.(routineResultToolDefinition);
9
14
  }
@@ -0,0 +1,100 @@
1
+ // `web_search` / `web_fetch` for the terminal — one route, chosen at load.
2
+ //
3
+ // WHAT CHANGED. The TUI used to load @juicesharp/rpiv-web-tools and nothing else, so web
4
+ // access at a prompt meant going and getting a provider key first. Being signed in to a
5
+ // Privateer account bought you nothing here, even though the same account's unattended
6
+ // runs (harbor, channels, ACP) have searched through /api/rag/* all along. Now signing in
7
+ // is enough: with no provider of your own configured, the account's search is what the
8
+ // tools call — Brave server-side, no key on the machine, the account's daily allowance
9
+ // and metering, and the query visible to Privateer's servers (never say a session that
10
+ // searches this way is fully private).
11
+ //
12
+ // PRECEDENCE, AND WHY THIS FILE EXISTS. Both packs register the SAME two tool names, and
13
+ // Pi resolves duplicates first-registration-wins — a session holding both would silently
14
+ // get whichever loaded first. So exactly one of them may register, and something has to
15
+ // decide which: this extension, once per process, via tools/webMode.ts. A configured
16
+ // provider always wins. Someone who set up a self-hosted SearXNG picked the more private
17
+ // route deliberately, and re-pointing their searches at our servers (billing their account
18
+ // for them) would quietly undo that. `PRIVATEER_WEB_SEARCH=privateer` or `"provider":
19
+ // "privateer"` in the rpiv config is the way back for someone who holds a key and wants
20
+ // the account path anyway.
21
+ //
22
+ // WHAT IS STILL LIVE, AND WHAT NEEDS A RELAUNCH. `/signin` mid-session works: the account
23
+ // tools are registered up front and check for credentials per call, so a signed-out
24
+ // terminal says how to fix it rather than silently lacking a web tool. Configuring a
25
+ // provider mid-session does NOT switch routes — the registration already happened — so it
26
+ // takes effect at the next launch. `/web-tools` is registered either way, so that
27
+ // configuration is always reachable.
28
+ //
29
+ // THE PACK IS OPTIONAL. rpiv-web-tools is a dependency the launcher drops if it didn't
30
+ // resolve (a partial or hoisted install). Every import of it here is dynamic and
31
+ // failure-tolerant: without it there is no user provider to choose, so the account route
32
+ // is the only one left — which is the honest answer, not a fallback.
33
+ //
34
+ // The specifiers are built rather than written as literals on purpose: it keeps tsc from
35
+ // pulling that package's own .ts sources into our typecheck, the same trick config/moat.ts
36
+ // uses for pi-mcp-adapter.
37
+ import { guardedWebToolDefinitions } from "../src/tools/web.ts";
38
+ import { readWebToolsConfig, resolveWebMode, type ProviderMetaLike } from "../src/tools/webMode.ts";
39
+
40
+ const RPIV_WEB_TOOLS = "@juicesharp/rpiv-web-tools";
41
+
42
+ // What a signed-out terminal hears when the model reaches for the web. Both exits are
43
+ // named because both are real: sign in and it works on the account's allowance, or bring
44
+ // a provider of your own and the query never touches our servers.
45
+ const SIGNED_OUT_HINT =
46
+ "Web access needs a Privateer account: run /signin to search on your account's " +
47
+ "allowance, or /web-tools to use your own search provider (a Brave/Tavily key, or a " +
48
+ "self-hosted SearXNG) instead.";
49
+
50
+ interface RpivWebTools {
51
+ registerWebSearchTool(pi: unknown): void;
52
+ registerWebFetchTool(pi: unknown): void;
53
+ registerWebSearchConfigCommand(pi: unknown): void;
54
+ providers: readonly ProviderMetaLike[];
55
+ }
56
+
57
+ async function loadRpivWebTools(): Promise<RpivWebTools | null> {
58
+ try {
59
+ // Two entry points: the package root registers the tools and the `/web-tools`
60
+ // command, and providers/ carries the metadata (which env var holds which provider's
61
+ // key, which providers are self-hosted) that the precedence rules read. Taking that
62
+ // metadata from the package rather than restating it here is what keeps our notion of
63
+ // "configured" from drifting from theirs when it gains a provider.
64
+ const [tools, providers] = await Promise.all([
65
+ import(`${RPIV_WEB_TOOLS}/index.ts`),
66
+ import(`${RPIV_WEB_TOOLS}/providers/index.ts`),
67
+ ]);
68
+ if (typeof tools?.registerWebSearchTool !== "function") return null;
69
+ return {
70
+ registerWebSearchTool: tools.registerWebSearchTool,
71
+ registerWebFetchTool: tools.registerWebFetchTool,
72
+ registerWebSearchConfigCommand: tools.registerWebSearchConfigCommand,
73
+ providers: Array.isArray(providers?.PROVIDERS) ? providers.PROVIDERS : [],
74
+ };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export default async function privateerWeb(pi: any): Promise<void> {
81
+ const rpiv = await loadRpivWebTools();
82
+ const decision = resolveWebMode({ providers: rpiv?.providers ?? [], config: readWebToolsConfig() });
83
+
84
+ if (rpiv && decision.mode === "own") {
85
+ // The user's own provider, run by the package that owns it — its interceptors, its
86
+ // guidance overrides, its truncation. We are choosing between packs, not wrapping one.
87
+ rpiv.registerWebSearchTool(pi);
88
+ rpiv.registerWebFetchTool(pi);
89
+ } else {
90
+ for (const def of guardedWebToolDefinitions(SIGNED_OUT_HINT)) pi.registerTool?.(def);
91
+ }
92
+
93
+ // Always available, whichever pack won: it is how a user on the account route configures
94
+ // a provider of their own, and the only place to see which keys are resolving.
95
+ try {
96
+ rpiv?.registerWebSearchConfigCommand(pi);
97
+ } catch {
98
+ /* the command is a convenience — never let it cost the session its web tools */
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,10 +68,81 @@ index 4600b23..075ecae 100644
68
68
  export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
69
69
  export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
70
70
  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
71
- index ce8a9a2..1ae7b25 100644
71
+ index ce8a9a2..c52ea80 100644
72
72
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
73
73
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
74
- @@ -186,6 +186,14 @@ export class AgentSession {
74
+ @@ -38,6 +38,70 @@ import { createLocalBashOperations } from "./tools/bash.js";
75
+ import { createAllToolDefinitions } from "./tools/index.js";
76
+ import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
77
+ import { addUsageToTotals, createUsageTotals } from "./usage-totals.js";
78
+ +// ============================================================================
79
+ +// Privateer patch: provider errors that aren't API responses
80
+ +//
81
+ +// An inference endpoint does not always answer as an API. Put a WAF, a proxy or a
82
+ +// captive portal in front of one and a rejected request comes back as an HTML page,
83
+ +// which the provider SDK folds whole into `error.message` — status first, body after.
84
+ +//
85
+ +// Measured incident: the account channel's edge WAF answered a turn with a 403 block
86
+ +// page carrying three inline base64 web fonts, so `errorMessage` was 221 KB. Pi
87
+ +// printed it into the terminal in full, appended it to the session file on every
88
+ +// attempt (a 1.8 MB session), and ran isRetryableAssistantError over it — and a
89
+ +// megabyte of base64 reliably contains "429", "500" and "502", so a permanent 403
90
+ +// looked transient and burned the whole retry budget before the user saw anything.
91
+ +//
92
+ +// compactProviderError squeezes such a page down to the line a person can act on
93
+ +// (status, title, visible text — which is where a WAF puts its request id), and
94
+ +// isHardHttpFailure lets the STATUS decide retryability instead of a substring of the
95
+ +// body. Mirrors src/engine/errors.ts, which is where these are tested.
96
+ +const PV_MAX_ERROR_CHARS = 2000;
97
+ +const PV_MAX_PAGE_TEXT_CHARS = 600;
98
+ +const PV_HTML_DOC = /<!doctype html|<html[\s>]/i;
99
+ +const PV_NON_PROSE = /<(script|style|svg|head|noscript)\b[\s\S]*?<\/\1\s*>/gi;
100
+ +const PV_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#x27": "'" };
101
+ +function pvPlainText(html) {
102
+ + return html
103
+ + .replace(/<[^>]*>/g, " ")
104
+ + .replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (m, code) => {
105
+ + const key = code.toLowerCase();
106
+ + if (PV_ENTITIES[key] !== undefined)
107
+ + return PV_ENTITIES[key];
108
+ + if (key.startsWith("#x"))
109
+ + return String.fromCodePoint(parseInt(key.slice(2), 16) || 0) || m;
110
+ + if (key.startsWith("#"))
111
+ + return String.fromCodePoint(parseInt(key.slice(1), 10) || 0) || m;
112
+ + return m;
113
+ + })
114
+ + .replace(/\s+/g, " ")
115
+ + .trim();
116
+ +}
117
+ +export function compactProviderError(raw) {
118
+ + const text = typeof raw === "string" ? raw : String(raw ?? "");
119
+ + if (!PV_HTML_DOC.test(text)) {
120
+ + if (text.length <= PV_MAX_ERROR_CHARS)
121
+ + return text;
122
+ + return `${text.slice(0, PV_MAX_ERROR_CHARS)}… [dropped ${text.length - PV_MAX_ERROR_CHARS} chars]`;
123
+ + }
124
+ + const status = /^\s*(\d{3})\b/.exec(text)?.[1];
125
+ + const title = pvPlainText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(text)?.[1] ?? "");
126
+ + const body = pvPlainText(text.replace(PV_NON_PROSE, " "));
127
+ + const visible = [title, body].filter(Boolean).join(" — ").slice(0, PV_MAX_PAGE_TEXT_CHARS);
128
+ + return (`${status ?? "HTTP error"} — an HTML page, not an API response (something in front of ` +
129
+ + `the provider answered: a WAF, a proxy, or a captive portal): ` +
130
+ + `${visible || "(no readable text)"} [dropped ${text.length} chars of HTML]`);
131
+ +}
132
+ +// Client-error statuses that CAN clear on their own: a timeout, a lock conflict, an
133
+ +// early-data replay, a throttle. Every other 4xx is the request itself being wrong.
134
+ +const PV_TRANSIENT_CLIENT_STATUS = new Set([408, 409, 425, 429]);
135
+ +export function isHardHttpFailure(text) {
136
+ + const m = /^\s*(\d{3})\b/.exec(typeof text === "string" ? text : "");
137
+ + if (!m)
138
+ + return false;
139
+ + const status = Number(m[1]);
140
+ + return status >= 400 && status < 500 && !PV_TRANSIENT_CLIENT_STATUS.has(status);
141
+ +}
142
+ /**
143
+ * Parse a skill block from message text.
144
+ * Returns null if the text doesn't contain a skill block.
145
+ @@ -186,6 +250,14 @@ export class AgentSession {
75
146
  }
76
147
  const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
77
148
  if (isOAuth) {
@@ -86,7 +157,24 @@ index ce8a9a2..1ae7b25 100644
86
157
  throw new Error(`Authentication failed for "${model.provider}". ` +
87
158
  `Credentials may have expired or network is unavailable. ` +
88
159
  `Run '/login ${model.provider}' to re-authenticate.`);
89
- @@ -772,6 +780,15 @@ export class AgentSession {
160
+ @@ -360,6 +432,16 @@ export class AgentSession {
161
+ }
162
+ }
163
+ }
164
+ + // Privateer patch: squeeze a non-API error body BEFORE anything reads it. This is
165
+ + // the one point upstream of all four consumers — extension handlers, UI listeners,
166
+ + // session persistence, and the _lastAssistantMessage the retry classifier reads —
167
+ + // so an HTML block page is shortened once, in place, rather than printed, stored
168
+ + // and pattern-matched at full size. See compactProviderError above.
169
+ + if (event.type === "message_end" &&
170
+ + event.message?.role === "assistant" &&
171
+ + typeof event.message.errorMessage === "string") {
172
+ + event.message.errorMessage = compactProviderError(event.message.errorMessage);
173
+ + }
174
+ // Emit to extensions first
175
+ await this._emitExtensionEvent(event);
176
+ // Notify all listeners
177
+ @@ -772,6 +854,15 @@ export class AgentSession {
90
178
  finalError: msg.errorMessage,
91
179
  });
92
180
  this._retryAttempt = 0;
@@ -102,7 +190,7 @@ index ce8a9a2..1ae7b25 100644
102
190
  }
103
191
  if (await this._checkCompaction(msg)) {
104
192
  return true;
105
- @@ -852,6 +869,14 @@ export class AgentSession {
193
+ @@ -852,6 +943,14 @@ export class AgentSession {
106
194
  if (!hasConfiguredAuth) {
107
195
  const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
108
196
  if (isOAuth) {
@@ -117,10 +205,19 @@ index ce8a9a2..1ae7b25 100644
117
205
  throw new Error(`Authentication failed for "${this.model.provider}". ` +
118
206
  `Credentials may have expired or network is unavailable. ` +
119
207
  `Run '/login ${this.model.provider}' to re-authenticate.`);
120
- @@ -2084,6 +2109,18 @@ export class AgentSession {
208
+ @@ -2084,6 +2183,27 @@ export class AgentSession {
121
209
  // Context overflow is handled by compaction, not retry.
122
210
  if (isContextOverflow(message, this.model?.contextWindow ?? 0))
123
211
  return false;
212
+ + // Privateer patch: an HTTP status is a fact; a substring of the body is a guess.
213
+ + // pi classifies by regex over the whole error text, so a 403 whose body merely
214
+ + // CONTAINS "500" — a WAF block page, any proxy interstitial, anything with base64
215
+ + // in it — spent the full retry budget on a failure retrying could never clear.
216
+ + // A 4xx other than 408/409/425/429 is the request being wrong, whatever its body
217
+ + // says. Provider-agnostic on purpose: an intercepting proxy is not ours to detect.
218
+ + // Mirrors isHardHttpFailure in src/engine/errors.ts.
219
+ + if (isHardHttpFailure(message.errorMessage))
220
+ + return false;
124
221
  + // Privateer patch: a Privateer ACCOUNT CAP is not a throttle. Daily/monthly
125
222
  + // message or token limits (and an exhausted balance) come back from the account
126
223
  + // channel as a 429 carrying the backend's machine `code` and a ready-to-show
package/src/acp/run.ts CHANGED
@@ -89,7 +89,7 @@ export async function runAcp(): Promise<void> {
89
89
  const { moatResourceOptions } = await import("../config/moat.ts");
90
90
  const { privateerChannel, rememberAccountCredential, persistAccountCredential, dropPersistedAccountCredential } =
91
91
  await import("../providers/account.ts");
92
- const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
92
+ const { modelRegistryOf, piAuthStore } = await import("../providers/piAuthStore.ts");
93
93
  const { hasCredentials, acquireAccountCredential, revokeAccountSession } = await import("../auth/privateer.ts");
94
94
  const { webEnabled, mediaEnabled } = await import("../config/hosted.ts");
95
95
  const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
@@ -230,7 +230,23 @@ export async function runAcp(): Promise<void> {
230
230
  // there. The moment anything revoked it, every ACP turn started failing.
231
231
  let armedAccount = false;
232
232
  async function ensureAccountArmed(providerName: string): Promise<void> {
233
- if (armedAccount || providerName !== "privateer") return;
233
+ if (providerName !== "privateer") return;
234
+ // Re-read the PERSISTED entry instead of latching on `armedAccount`. auth.json holds
235
+ // one machine-global `privateer` entry, so whichever terminal armed LAST removes it
236
+ // on exit — including one that armed over ours — and a latch would never notice. The
237
+ // session we hold stays perfectly valid while Pi sees no entry at all, so every turn
238
+ // from then on fails with the misleading "This terminal isn't signed in to
239
+ // Privateer". Re-arming reuses our own session (accountCredential's memo) rather
240
+ // than minting a second one, so the recovery costs a store write, not a Linked
241
+ // Devices row.
242
+ if (armedAccount) {
243
+ try {
244
+ if (await (await piAuthStore()).read("privateer")) return;
245
+ } catch {
246
+ return; // can't read the store — leave it to the turn's own error path
247
+ }
248
+ log("account entry was removed by another terminal — re-arming");
249
+ }
234
250
  if (!hasCredentials()) {
235
251
  log("not signed in to Privateer — run `privateer` and /login, or pick a BYO-key model");
236
252
  return;
@@ -246,6 +262,10 @@ export async function runAcp(): Promise<void> {
246
262
  }
247
263
  }
248
264
  await ensureAccountArmed(model.provider);
265
+ // The provider actually selected right now — `model` is the launch model and never
266
+ // moves, but session/set_model can switch channels mid-run, and the per-turn re-arm
267
+ // below has to follow that rather than the model this process booted on.
268
+ let currentProvider = model.provider;
249
269
 
250
270
  const modelCount = listModels()?.available.length ?? 0;
251
271
  log(
@@ -312,11 +332,18 @@ export async function runAcp(): Promise<void> {
312
332
  // with the same misleading "not signed in".
313
333
  await ensureAccountArmed(next.provider);
314
334
  await session.setModel(next);
335
+ currentProvider = next.provider;
315
336
  },
316
337
  async prompt(text, events, signal) {
317
338
  holder.events = events;
318
339
  holder.error = undefined;
319
340
  try {
341
+ // Ahead of every turn, not just at startup: the entry we armed can be removed
342
+ // by another terminal's exit at any point in a long-lived host session (see
343
+ // ensureAccountArmed). It has to happen HERE because pi's prompt() throws on
344
+ // its own `hasConfiguredAuth` precheck before it emits `before_agent_start`,
345
+ // where providers/account.ts installs the equivalent net.
346
+ await ensureAccountArmed(currentProvider);
320
347
  await session.prompt(text);
321
348
  } catch (e) {
322
349
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
@@ -129,6 +129,7 @@ async function main() {
129
129
  const { moatResourceOptions } = await import("../config/moat.ts");
130
130
  const { webEnabled, mediaEnabled } = await import("../config/hosted.ts");
131
131
  const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
132
+ const { ensureAccountArmed } = await import("../providers/account.ts");
132
133
  const { agentDir, configPath, globalDir } = await import("../config/paths.ts");
133
134
  const { redactText, collectSecrets } = await import("../util/redact.ts");
134
135
  const { MessagingBridge } = await import("./bridge.ts");
@@ -321,6 +322,14 @@ async function main() {
321
322
  // A member's turn is always read-only, whatever the channel posture.
322
323
  const effectivePosture: Posture = meta.isAdmin ? chPosture : "readonly";
323
324
  try {
325
+ // Re-arm the account channel ahead of the turn. This daemon runs for weeks, and
326
+ // Pi's auth.json holds ONE machine-global `privateer` entry: any other terminal
327
+ // on the box that arms over ours and then exits takes the entry with it, and
328
+ // from that moment every channel message answers "This terminal isn't signed in
329
+ // to Privateer" on a session that is still perfectly valid. The equivalent net
330
+ // in providers/account.ts hangs off `before_agent_start`, which pi's prompt()
331
+ // never reaches — it throws on its own `hasConfiguredAuth` precheck first.
332
+ if (model?.provider === "privateer") await ensureAccountArmed(undefined);
324
333
  await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () => session.prompt(text));
325
334
  } catch (e) {
326
335
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
package/src/cli/chat.ts CHANGED
@@ -46,6 +46,7 @@ async function main() {
46
46
  rememberAccountCredential,
47
47
  persistAccountCredential,
48
48
  dropPersistedAccountCredential,
49
+ ensureAccountArmed,
49
50
  } = await import("../providers/account.ts");
50
51
  const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
51
52
  const { agentVersion } = await import("../config/version.ts");
@@ -330,6 +331,17 @@ async function main() {
330
331
  turnActive = true;
331
332
  if (remote && echo) console.log(`\n${DIM}⟿ [app] ${text.slice(0, 80)}${RESET}`);
332
333
  try {
334
+ // Re-arm the account channel BEFORE the prompt, because nothing downstream can.
335
+ // auth.json holds ONE machine-global `privateer` entry, so the terminal that
336
+ // armed LAST deletes it on exit and strands every other terminal still running —
337
+ // this one keeps a good credential in memory while Pi sees no entry, and every
338
+ // prompt dies on "This terminal isn't signed in to Privateer".
339
+ //
340
+ // The `before_agent_start` net in providers/account.ts cannot cover this: pi's
341
+ // prompt() throws on its own `hasConfiguredAuth` precheck before that event is
342
+ // ever emitted. Here is the last point ahead of it. One store read on the healthy
343
+ // path, and a no-op when the machine isn't signed in.
344
+ if (currentSpec.startsWith("privateer/")) await ensureAccountArmed(undefined);
333
345
  // Expand any `@path` mentions into appended <file> blocks + image attachments,
334
346
  // resolved against this terminal's cwd (constrained to the cwd subtree). Both a
335
347
  // locally-typed prompt and an app-driven one land here, so both get it. A prompt
@@ -568,6 +580,41 @@ async function main() {
568
580
  const t = TIERS[res.tier];
569
581
  const color = t.posture === "green" ? GREEN : t.posture === "yellow" ? YELLOW : DIM;
570
582
  console.log(`\n${color}⛉ ${t.label}${RESET} ${DIM}(${res.tier}${res.teePosture ? "/" + res.teePosture : ""}) — ${t.blurb}${RESET}${res.error ? `\n${RED} ${res.error}${RESET}` : ""}`);
583
+ // What the verified quote says about the enclave that answered (Phala today). The
584
+ // ordering here is the point: the self-consistency checks PASSED to get this far,
585
+ // so they are stated as fact; the image identity is only ever "same as last time",
586
+ // so it is stated as memory. Never let the second read like the first.
587
+ const enclave = "enclaveIdentity" in res ? res.enclaveIdentity : undefined;
588
+ if (enclave) {
589
+ const { measurements, identity, pin } = enclave;
590
+ console.log(`${DIM} event log replays to the signed registers; app_compose matches the attested compose-hash${RESET}`);
591
+ if (enclave.skippedChecks.length) {
592
+ console.log(`${YELLOW} not checked (no material in the report): ${enclave.skippedChecks.join(", ")}${RESET}`);
593
+ }
594
+
595
+ if (pin.state === "changed") {
596
+ // The one line here that should stop a reader. Not a failure — Phala upgrades
597
+ // the gateway legitimately — but it is the only moment we can ever detect a
598
+ // swapped image, so it must not read like routine output.
599
+ console.log(`${YELLOW} ⚠ enclave image CHANGED since ${pin.firstSeenAt ?? "first use"}:${RESET}`);
600
+ for (const line of pin.changed) console.log(`${YELLOW} ${line}${RESET}`);
601
+ } else if (pin.state === "first-seen") {
602
+ console.log(`${DIM} image recorded on first use — no prior value to compare against${RESET}`);
603
+ } else {
604
+ console.log(`${DIM} image unchanged since ${pin.firstSeenAt ?? "first use"}${RESET}`);
605
+ }
606
+
607
+ for (const [name, value] of Object.entries({ ...measurements, ...identity })) {
608
+ if (value) console.log(`${DIM} ${name.padEnd(12)} ${value}${RESET}`);
609
+ }
610
+ if (enclave.repoCommit) {
611
+ console.log(`${DIM} declares source ${enclave.repoUrl ?? "?"} @ ${enclave.repoCommit}${RESET}`);
612
+ console.log(`${DIM} (self-declared — the quote does not prove this binary came from that commit)${RESET}`);
613
+ }
614
+ if (enclave.downstreamDomain) {
615
+ console.log(`${DIM} forwards to ${enclave.downstreamDomain} — a separate trust domain we do not attest${RESET}`);
616
+ }
617
+ }
571
618
  }
572
619
 
573
620
  async function remoteAccess(on: boolean) {