privateer-agent 0.12.13 → 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.
- package/bin/privateer-subagent.mjs +10 -2
- package/extensions/privateer-gate.ts +7 -0
- package/extensions/privateer-media.ts +17 -4
- package/extensions/privateer-privacy.ts +13 -33
- package/extensions/privateer-tools.ts +8 -3
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +102 -5
- package/src/acp/run.ts +29 -2
- package/src/channels/run.ts +9 -0
- package/src/cli/chat.ts +47 -0
- package/src/config/moat.ts +6 -26
- package/src/config/moatManifest.ts +5 -0
- package/src/config/privacyPolicy.ts +97 -0
- package/src/engine/errors.ts +97 -1
- package/src/ext/permissionGate.ts +6 -0
- package/src/harbor/index.ts +65 -11
- package/src/outbox/cloudOutbox.ts +72 -4
- package/src/permissions/childSpend.ts +105 -0
- package/src/permissions/classify.ts +61 -3
- package/src/permissions/modeGate.ts +39 -0
- package/src/providers/account.ts +8 -1
- package/src/providers/phala/measurements.ts +170 -0
- package/src/providers/phala/pin.ts +98 -0
- package/src/providers/phalaSeal.ts +131 -9
- package/src/providers/sealedShim.ts +6 -1
- package/src/remote/liveTaskSession.ts +8 -1
- package/src/remote/relayClient.ts +21 -0
- package/src/remote/remoteBridge.ts +9 -0
- package/src/routines/store.ts +11 -0
- package/src/tools/media.ts +516 -11
- package/src/tools/routineResult.ts +146 -0
- package/src/tools/videoCompose.ts +825 -7
|
@@ -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.
|
|
39
|
-
//
|
|
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
|
|
2
|
-
// signed-in Privateer account, and stitch the results
|
|
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
|
|
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
|
-
|
|
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)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.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..
|
|
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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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 +
|
|
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 +
|
|
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 (
|
|
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) };
|
package/src/channels/run.ts
CHANGED
|
@@ -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) {
|
package/src/config/moat.ts
CHANGED
|
@@ -177,36 +177,16 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
177
177
|
|
|
178
178
|
const { makePermissionGate } = await import("../ext/permissionGate.ts");
|
|
179
179
|
const { makePiPrivacyExtension } = await import("pi-privacy");
|
|
180
|
-
const { makeAccountProvider
|
|
181
|
-
const { hasCredentials } = await import("../auth/privateer.ts");
|
|
180
|
+
const { makeAccountProvider } = await import("../providers/account.ts");
|
|
182
181
|
const { webEnabled, mediaEnabled } = await import("./hosted.ts");
|
|
183
|
-
const {
|
|
184
|
-
const { cliPalette, detectScheme } = await import("../ui/palette.ts");
|
|
182
|
+
const { sharedPrivacyOptions } = await import("./privacyPolicy.ts");
|
|
185
183
|
|
|
186
184
|
const factories: ExtensionFactory[] = [makePermissionGate(opts.gate)];
|
|
187
185
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
factories.push(
|
|
193
|
-
makePiPrivacyExtension({
|
|
194
|
-
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
195
|
-
// No quarter = unattended: the PII send-or-redact question would block a session
|
|
196
|
-
// the operator explicitly stepped away from, so it's swallowed the safe way —
|
|
197
|
-
// auto-redact + send — and what was masked surfaces as output instead. A live
|
|
198
|
-
// function, not a boolean: shift+tab / /no-quarter flips this mid-session.
|
|
199
|
-
piiUnattended: noQuarterActive,
|
|
200
|
-
// Color-coat that notice as the moat acting on your behalf: the red no-quarter
|
|
201
|
-
// flag (same glyph/color as the no-quarter banner in chat.ts), body in the
|
|
202
|
-
// accent color — distinct from yellow warnings and red errors.
|
|
203
|
-
renderPiiAutoRedact: (notice: string) => {
|
|
204
|
-
const p = cliPalette(detectScheme());
|
|
205
|
-
const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
|
|
206
|
-
return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
|
|
207
|
-
},
|
|
208
|
-
}),
|
|
209
|
-
);
|
|
186
|
+
// pi-privacy is configured in exactly ONE place, shared with the DISCOVERED copy of this
|
|
187
|
+
// extension (the TUI's, and every subagent child's) — see ./privacyPolicy.ts for the two
|
|
188
|
+
// bugs that came of configuring it in two.
|
|
189
|
+
factories.push(makePiPrivacyExtension(sharedPrivacyOptions()));
|
|
210
190
|
factories.push(makeAccountProvider()); // must follow pi-privacy — see header
|
|
211
191
|
|
|
212
192
|
if (opts.relayFiles) {
|
|
@@ -27,6 +27,11 @@ import { readFileSync } from "node:fs";
|
|
|
27
27
|
* `dep` is a node_modules specifier as [packageName, ...pathSegments], resolved through
|
|
28
28
|
* the node_modules chain at launch (npm hoists, so a fixed path would miss). Exactly one
|
|
29
29
|
* of the two is set.
|
|
30
|
+
*
|
|
31
|
+
* `note` is USER-VISIBLE: relayClient.sendExtensions ships it to the app, which lists it
|
|
32
|
+
* under each built-in in the Extensions manager. Write it as a one-line description of
|
|
33
|
+
* what the extension gives the user (English only — the app renders it verbatim, as it
|
|
34
|
+
* already does for the agent's status messages), not as a code comment.
|
|
30
35
|
*/
|
|
31
36
|
export interface MoatShim {
|
|
32
37
|
name: string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The pi-privacy options that must be the SAME however a session reaches pi-privacy.
|
|
2
|
+
//
|
|
3
|
+
// There are two call sites, and they are genuinely separate routes — not one path with a
|
|
4
|
+
// fallback:
|
|
5
|
+
//
|
|
6
|
+
// - src/config/moat.ts builds the extension from FACTORIES: the harbor's sessions,
|
|
7
|
+
// live tasks, the channels runner, ACP, the lean REPL.
|
|
8
|
+
// - extensions/privateer-privacy.ts is what Pi DISCOVERS for the interactive TUI, and
|
|
9
|
+
// what bin/privateer-subagent.mjs injects (`-e`) into every subagent child.
|
|
10
|
+
//
|
|
11
|
+
// They drifted, and the drift was the bug this module exists to make impossible: the
|
|
12
|
+
// factory side wired the unattended signal and the discovered side didn't, so in the
|
|
13
|
+
// terminal `--no-quarter` (and shift+tab) lowered the permission moat while pi-privacy
|
|
14
|
+
// still stopped the turn with "PII detected — send as-is or redact?". No quarter means no
|
|
15
|
+
// prompts, from every gate in the session, not just ours.
|
|
16
|
+
//
|
|
17
|
+
// EVERYTHING pi-privacy is configured with lives here — there is no "and each side adds
|
|
18
|
+
// its own bit" left, because that arrangement is what produced both bugs. The other one:
|
|
19
|
+
// c2ee0fa added `resolveTier` to the discovered extension to stop the PII gate
|
|
20
|
+
// over-warning on the account channel (pi-privacy's own catalog only knows Privateer's
|
|
21
|
+
// PUBLIC developer key, which floors to zdr-policy, so a session on an ATTESTED account
|
|
22
|
+
// TEE model was judged unverified and asked about PII on every turn). The factory-built
|
|
23
|
+
// sessions never got it, and they never got the badge right either. Symmetrically, they
|
|
24
|
+
// had `privateerVerifiedTee` and the discovered copy didn't.
|
|
25
|
+
//
|
|
26
|
+
// IMPORT-SAFETY: this module reaches the account channel, so it pulls Pi-touching code
|
|
27
|
+
// and node builtins — it is NOT safe to import from a boot-ordered entrypoint (see
|
|
28
|
+
// boot.ts's ORDERING CONTRACT). moat.ts therefore imports it DYNAMICALLY, inside
|
|
29
|
+
// buildMoat(), exactly as it does every other Pi-touching import. Extensions load late
|
|
30
|
+
// and import it statically.
|
|
31
|
+
|
|
32
|
+
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
33
|
+
import { cliPalette, detectScheme } from "../ui/palette.ts";
|
|
34
|
+
import { accountPosture, privateerChannel } from "../providers/account.ts";
|
|
35
|
+
import { hasCredentials } from "../auth/privateer.ts";
|
|
36
|
+
|
|
37
|
+
// Color-coat pi-privacy's auto-redact notice as the moat acting on your behalf: the red
|
|
38
|
+
// no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
|
|
39
|
+
// extension's status line), body in the accent color — distinct from a yellow warning and
|
|
40
|
+
// from a red error, because this is neither: it's the answer we gave for you.
|
|
41
|
+
function renderPiiAutoRedact(notice: string): string {
|
|
42
|
+
const p = cliPalette(detectScheme());
|
|
43
|
+
const body = notice.startsWith("⚑ ") ? notice.slice(2) : notice;
|
|
44
|
+
return `${p.RED}⚑${p.RESET} ${p.CYAN}${body}${p.RESET}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pi-privacy options every Privateer session gets, whichever route built it. */
|
|
48
|
+
export function sharedPrivacyOptions() {
|
|
49
|
+
return {
|
|
50
|
+
// The account channel's real posture. pi-privacy ships a `privateer` provider, but it
|
|
51
|
+
// is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
|
|
52
|
+
// end-to-end), so from the package alone every privateer/* model floors to
|
|
53
|
+
// zdr-policy. The in-app ACCOUNT channel is a different thing — its own OAuth
|
|
54
|
+
// session, account server and sealed relay — and only the host can say what it
|
|
55
|
+
// resolves to: tee-verified for a quote WE checked over the sealed path,
|
|
56
|
+
// tee-unverified for a proxied enclave we can't bind to this connection, zdr-policy
|
|
57
|
+
// for the ZDR-channel models. Without this hook a session running an ATTESTED TEE
|
|
58
|
+
// model is judged unverified, so the badge lies and the PII gate asks about every
|
|
59
|
+
// turn — the over-warning c2ee0fa fixed for the terminal and nowhere else.
|
|
60
|
+
resolveTier: async (provider: string, modelId: string) => {
|
|
61
|
+
if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
|
|
62
|
+
return (await accountPosture(modelId)).tier;
|
|
63
|
+
},
|
|
64
|
+
// Per-model verified-TEE capability for pi-privacy's /models picker: show Privateer's
|
|
65
|
+
// TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE" when logged in, while
|
|
66
|
+
// ZDR-channel models stay at their honest floor. The live verdict still comes from
|
|
67
|
+
// resolveTier above on select — this only lifts the label.
|
|
68
|
+
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
69
|
+
// No quarter = unattended. The PII send-or-redact question would stall a session the
|
|
70
|
+
// operator explicitly stepped away from, so pi-privacy swallows it the SAFE way —
|
|
71
|
+
// redact, then send — and reports what it masked as output instead of asking. A live
|
|
72
|
+
// function, not a boolean: shift+tab / `/no-quarter` flips this mid-session and the
|
|
73
|
+
// gate re-reads it on every request.
|
|
74
|
+
piiUnattended: noQuarterActive,
|
|
75
|
+
renderPiiAutoRedact,
|
|
76
|
+
// pi-privacy's INGEST gate: credentials arriving in a tool result are masked before
|
|
77
|
+
// they enter context (otherwise they're re-sent every turn and written to the session
|
|
78
|
+
// file on disk). We already redact tool output in src/ext/permissionGate.ts, so its
|
|
79
|
+
// default "warn" would put an interactive prompt in front of something this app has
|
|
80
|
+
// always handled silently — "redact" keeps our UX and still takes the added coverage.
|
|
81
|
+
//
|
|
82
|
+
// The two redactors are COMPLEMENTARY, not duplicative, which is why we run both:
|
|
83
|
+
// ours masks the configured provider keys by exact value (from env/config) plus the
|
|
84
|
+
// provider-specific shapes (sk-/AIza/xai-/gsk_/csk-/vapi_/fw_/Z.ai, auth headers);
|
|
85
|
+
// pi-privacy's catches what shows up in USER code and shell output — AWS AKIA/ASIA,
|
|
86
|
+
// GitHub gh[pousr]_, JWTs, PEM private-key blocks, Slack, Stripe — none of which our
|
|
87
|
+
// patterns match.
|
|
88
|
+
//
|
|
89
|
+
// Order between the two is NOT guaranteed: pi discovers extensions with a bare
|
|
90
|
+
// readdirSync and never sorts, so it's filesystem-dependent (alphabetical on this box
|
|
91
|
+
// today, not by contract). "redact" makes that moot — both handlers run
|
|
92
|
+
// unconditionally and each masks its own patterns, so the surviving content is the
|
|
93
|
+
// same either way. Under "warn" the order WOULD matter, since it decides whether the
|
|
94
|
+
// prompt is raised on a raw key or one we already masked.
|
|
95
|
+
toolResultPolicy: "redact" as const,
|
|
96
|
+
};
|
|
97
|
+
}
|