privateer-agent 0.12.47 → 0.12.49
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/README.md +28 -1
- package/bin/headless-flags.d.mts +25 -0
- package/bin/headless-flags.mjs +166 -0
- package/bin/privateer-launch.mjs +93 -3
- package/extensions/privateer-gate.ts +192 -5
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.86.0.patch +24 -11
- package/src/acp/run.ts +3 -1
- package/src/bridge/engineAdapter.ts +1 -1
- package/src/cli/chat.ts +6 -2
- package/src/config/privacyDisabled.ts +6 -1
- package/src/config/privacyPolicy.ts +3 -1
- package/src/engine/errors.ts +57 -3
- package/src/ext/permissionGate.ts +23 -4
- package/src/permissions/cliSpend.ts +163 -0
- package/src/permissions/modeGate.ts +9 -3
- package/src/permissions/noQuarter.ts +21 -2
- package/src/providers/account.ts +89 -1
- package/src/remote/headlessApproval.ts +146 -0
- package/src/tools/media.ts +151 -12
|
@@ -1824,7 +1824,7 @@ index 6d0356c..ca0aa98 100644
|
|
|
1824
1824
|
if (content) {
|
|
1825
1825
|
text += `\n\n${content}`;
|
|
1826
1826
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1827
|
-
index d82182c..
|
|
1827
|
+
index d82182c..08bec2d 100644
|
|
1828
1828
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1829
1829
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1830
1830
|
@@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui";
|
|
@@ -1850,7 +1850,7 @@ index d82182c..2764952 100644
|
|
|
1850
1850
|
if (!sessionManager.usesDefaultSessionDir()) {
|
|
1851
1851
|
args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
|
|
1852
1852
|
}
|
|
1853
|
-
@@ -192,6 +197,
|
|
1853
|
+
@@ -192,6 +197,77 @@ function formatLoginProviderCompletionDescription(provider) {
|
|
1854
1854
|
const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");
|
|
1855
1855
|
return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
|
|
1856
1856
|
}
|
|
@@ -1893,6 +1893,13 @@ index d82182c..2764952 100644
|
|
|
1893
1893
|
+ message: "The provider stopped responding — the connection went idle.",
|
|
1894
1894
|
+ hint: "No data arrived for the whole idle-timeout window, so the turn was cut off. Send it again, or run /model to switch providers (a slow model can be given longer in /settings → HTTP idle timeout).",
|
|
1895
1895
|
+ };
|
|
1896
|
+
+ // Privateer patch: the OpenAI SDK's whole message for a request that never got
|
|
1897
|
+
+ // a response. Mirrors isConnectionFailureText in src/engine/errors.ts.
|
|
1898
|
+
+ if (/^\s*(?:connection error\.?|fetch failed|other side closed|socket hang up|(?:read |connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b)/i.test(s))
|
|
1899
|
+
+ return {
|
|
1900
|
+
+ message: `Couldn't reach the model provider — the request got no response ("${s.trim().slice(0, 80)}").`,
|
|
1901
|
+
+ hint: "Check your connection and send it again. If every message fails while a fresh `privateer` works, start a new session with /new.",
|
|
1902
|
+
+ };
|
|
1896
1903
|
+ return null;
|
|
1897
1904
|
+ }
|
|
1898
1905
|
+ if (status === 429) {
|
|
@@ -1905,7 +1912,13 @@ index d82182c..2764952 100644
|
|
|
1905
1912
|
+ };
|
|
1906
1913
|
+ }
|
|
1907
1914
|
+ if (status === 401 || status === 403)
|
|
1908
|
-
+ return {
|
|
1915
|
+
+ return {
|
|
1916
|
+
+ // "401 status code (no body)" names neither what was refused nor what to do.
|
|
1917
|
+
+ message: /\(no body\)\s*$/i.test(s)
|
|
1918
|
+
+ ? `The model provider ${status === 401 ? "rejected the credential" : "refused access"} this request was sent with (${status}).`
|
|
1919
|
+
+ : s,
|
|
1920
|
+
+ hint: "Check your credentials — run /login to re-authenticate.",
|
|
1921
|
+
+ };
|
|
1909
1922
|
+ if (status === 404)
|
|
1910
1923
|
+ return { message: s, hint: "Check the model id — run /model to switch." };
|
|
1911
1924
|
+ if (status >= 500)
|
|
@@ -1915,7 +1928,7 @@ index d82182c..2764952 100644
|
|
|
1915
1928
|
export class InteractiveMode {
|
|
1916
1929
|
runtimeHost;
|
|
1917
1930
|
renderer;
|
|
1918
|
-
@@ -404,9 +
|
|
1931
|
+
@@ -404,9 +480,15 @@ export class InteractiveMode {
|
|
1919
1932
|
}
|
|
1920
1933
|
getBuiltInCommandConflictDiagnostics(extensionRunner) {
|
|
1921
1934
|
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
|
|
@@ -1932,7 +1945,7 @@ index d82182c..2764952 100644
|
|
|
1932
1945
|
.map((command) => ({
|
|
1933
1946
|
type: "warning",
|
|
1934
1947
|
message: command.invocationName === command.name
|
|
1935
|
-
@@ -775,20 +
|
|
1948
|
+
@@ -775,20 +857,18 @@ export class InteractiveMode {
|
|
1936
1949
|
this.showNewVersionNotification(newRelease);
|
|
1937
1950
|
}
|
|
1938
1951
|
});
|
|
@@ -1965,7 +1978,7 @@ index d82182c..2764952 100644
|
|
|
1965
1978
|
// Check tmux keyboard setup asynchronously
|
|
1966
1979
|
this.checkTmuxKeyboardSetup().then((warning) => {
|
|
1967
1980
|
if (warning) {
|
|
1968
|
-
@@ -1568,7 +
|
|
1981
|
+
@@ -1568,7 +1648,7 @@ export class InteractiveMode {
|
|
1969
1982
|
if (this.bugReportHintShown)
|
|
1970
1983
|
return;
|
|
1971
1984
|
this.bugReportHintShown = true;
|
|
@@ -1974,7 +1987,7 @@ index d82182c..2764952 100644
|
|
|
1974
1987
|
this.ui.requestRender();
|
|
1975
1988
|
}
|
|
1976
1989
|
renderCurrentSessionState() {
|
|
1977
|
-
@@ -2419,7 +
|
|
1990
|
+
@@ -2419,7 +2499,17 @@ export class InteractiveMode {
|
|
1978
1991
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1979
1992
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
1980
1993
|
this.editor.setText("");
|
|
@@ -1993,7 +2006,7 @@ index d82182c..2764952 100644
|
|
|
1993
2006
|
return;
|
|
1994
2007
|
}
|
|
1995
2008
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
1996
|
-
@@ -2497,12 +
|
|
2009
|
+
@@ -2497,12 +2587,42 @@ export class InteractiveMode {
|
|
1997
2010
|
if (text === "/login" || text.startsWith("/login ")) {
|
|
1998
2011
|
const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined;
|
|
1999
2012
|
this.editor.setText("");
|
|
@@ -2038,7 +2051,7 @@ index d82182c..2764952 100644
|
|
|
2038
2051
|
return;
|
|
2039
2052
|
}
|
|
2040
2053
|
if (text === "/new") {
|
|
2041
|
-
@@ -2827,9 +
|
|
2054
|
+
@@ -2827,9 +2947,19 @@ export class InteractiveMode {
|
|
2042
2055
|
if (entries[0]?.type !== "compaction") {
|
|
2043
2056
|
throw new Error("Completed compaction is missing from the session context");
|
|
2044
2057
|
}
|
|
@@ -2061,7 +2074,7 @@ index d82182c..2764952 100644
|
|
|
2061
2074
|
this.addMessageToChat(createCompactionSummaryMessage(event.result.summary, event.result.tokensBefore, new Date().toISOString()));
|
|
2062
2075
|
if (event.result.usage) {
|
|
2063
2076
|
this.addCompactionCostNotice({
|
|
2064
|
-
@@ -3253,7 +
|
|
2077
|
+
@@ -3253,7 +3383,7 @@ export class InteractiveMode {
|
|
2065
2078
|
if (this.chatContainer.children.length > 0) {
|
|
2066
2079
|
this.chatContainer.addChild(new Spacer(1));
|
|
2067
2080
|
}
|
|
@@ -2070,7 +2083,7 @@ index d82182c..2764952 100644
|
|
|
2070
2083
|
}
|
|
2071
2084
|
async getUserInput() {
|
|
2072
2085
|
const queuedInput = this.pendingUserInputs.shift();
|
|
2073
|
-
@@ -3597,7 +
|
|
2086
|
+
@@ -3597,7 +3727,17 @@ export class InteractiveMode {
|
|
2074
2087
|
}
|
|
2075
2088
|
showError(errorMessage) {
|
|
2076
2089
|
this.chatContainer.addChild(new Spacer(1));
|
package/src/acp/run.ts
CHANGED
|
@@ -304,7 +304,9 @@ export async function runAcp(): Promise<void> {
|
|
|
304
304
|
session.subscribe((ev: any) => {
|
|
305
305
|
for (const ee of adapter.toEngineEvents(ev)) {
|
|
306
306
|
if (ee.type === "text") holder.events.onText(ee.text);
|
|
307
|
-
|
|
307
|
+
// With the hint: the host shows this string and nothing else, and "Connection
|
|
308
|
+
// error." alone says neither what failed nor what to do (engine/errors.ts).
|
|
309
|
+
else if (ee.type === "error") holder.error = (ee as { hint?: string }).hint ? `${ee.error} ${(ee as { hint?: string }).hint}` : ee.error;
|
|
308
310
|
// Tool activity goes BOTH to the host (rendered as live progress — without
|
|
309
311
|
// it a minute of tool work looks like a hang) and to stderr, where it is
|
|
310
312
|
// the only way to tell a gate denial apart from the model simply choosing
|
|
@@ -145,7 +145,7 @@ export function createEngineEventAdapter() {
|
|
|
145
145
|
// until retries/overflow recovery are exhausted, but never hide it.
|
|
146
146
|
if (msg?.stopReason === "error") {
|
|
147
147
|
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage : "";
|
|
148
|
-
const described = describeErrorText(raw);
|
|
148
|
+
const described = describeErrorText(raw, { provider: typeof msg.provider === "string" ? msg.provider : undefined });
|
|
149
149
|
return [{
|
|
150
150
|
type: "error",
|
|
151
151
|
error: described?.message ?? redactText(raw || "The model call failed."),
|
package/src/cli/chat.ts
CHANGED
|
@@ -12,6 +12,7 @@ import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
|
12
12
|
import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
|
|
13
13
|
import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
14
14
|
import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
|
|
15
|
+
import { privacyDisabled } from "../config/privacyDisabled.ts"; // no Pi deps → safe pre-boot
|
|
15
16
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
16
17
|
import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
|
|
17
18
|
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts"; // node:child_process only → safe pre-boot
|
|
@@ -419,14 +420,17 @@ async function main() {
|
|
|
419
420
|
// same key; takes effect from the next gated action, so an approval already on
|
|
420
421
|
// screen still needs an answer.
|
|
421
422
|
function applyNoQuarter(on: boolean): void {
|
|
422
|
-
|
|
423
|
+
const privacyWasOff = privacyDisabled();
|
|
424
|
+
setNoQuarter(on); // also `/privacy off` on the way down (src/permissions/noQuarter.ts)
|
|
425
|
+
const privacyMoved = privacyDisabled() !== privacyWasOff;
|
|
423
426
|
flushOut(); // land streamed output above the notice
|
|
424
427
|
console.log(
|
|
425
428
|
on
|
|
426
429
|
? `\n${RED}⚑ No quarter — the permission gate is OFF for this session.${RESET}\n` +
|
|
427
430
|
`${DIM} Every action (shell, edits, destructive tools, out-of-cwd, protected files) runs without asking.\n` +
|
|
431
|
+
(privacyMoved ? ` The privacy filter is off too: outbound requests are not scanned for PII.\n` : "") +
|
|
428
432
|
` shift+tab (or /no-quarter off) raises the moat again.${RESET}`
|
|
429
|
-
: `\n${GREEN}⚓ Moat raised — the permission gate is back on.${RESET}`,
|
|
433
|
+
: `\n${GREEN}⚓ Moat raised — the permission gate is back on${privacyMoved ? ", and so is the privacy filter" : ""}.${RESET}`,
|
|
430
434
|
);
|
|
431
435
|
}
|
|
432
436
|
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
const ENV = "PRIVATEER_PRIVACY_OFF";
|
|
20
20
|
const ALT_ENV = "PI_PRIVACY_OFF";
|
|
21
21
|
|
|
22
|
+
/** Set while the filter is off BECAUSE no quarter took it down (src/permissions/noQuarter.ts). */
|
|
23
|
+
export const NO_QUARTER_PRIVACY_MARK = "PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER";
|
|
24
|
+
|
|
22
25
|
/** True while pi-privacy is completely disabled for this session. Read live, never cached. */
|
|
23
26
|
export function privacyDisabled(): boolean {
|
|
24
27
|
return process.env[ENV] === "1" || process.env[ALT_ENV] === "1";
|
|
@@ -41,10 +44,12 @@ export function onPrivacyDisabledChange(fn: (disabled: boolean) => void): () =>
|
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Set the disabled state. Modifies process.env so every module copy and child process agrees.
|
|
44
|
-
* Returns the new state.
|
|
47
|
+
* Returns the new state. Clears the no-quarter mark: an explicit set is the operator's call,
|
|
48
|
+
* so raising the moat later won't flip it back (noQuarter.ts re-sets the mark after its own call).
|
|
45
49
|
*/
|
|
46
50
|
export function setPrivacyDisabled(disabled: boolean): boolean {
|
|
47
51
|
const before = privacyDisabled();
|
|
52
|
+
delete process.env[NO_QUARTER_PRIVACY_MARK];
|
|
48
53
|
if (disabled) {
|
|
49
54
|
process.env[ENV] = "1";
|
|
50
55
|
process.env[ALT_ENV] = "1";
|
|
@@ -89,7 +89,9 @@ export function sharedPrivacyOptions() {
|
|
|
89
89
|
// ZDR-channel models stay at their honest floor. The live verdict still comes from
|
|
90
90
|
// resolveTier above on select — this only lifts the label.
|
|
91
91
|
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
92
|
-
// No quarter = unattended.
|
|
92
|
+
// No quarter = unattended. No quarter also takes the whole filter down (noQuarter.ts),
|
|
93
|
+
// so this only answers when the operator has run `/privacy on` with the moat down.
|
|
94
|
+
// The PII send-or-redact question would stall a session the
|
|
93
95
|
// operator explicitly stepped away from, so pi-privacy swallows it the SAFE way —
|
|
94
96
|
// redact, then send — and reports what it masked as output instead of asking. A live
|
|
95
97
|
// function, not a boolean: shift+tab / `/no-quarter` flips this mid-session and the
|
package/src/engine/errors.ts
CHANGED
|
@@ -505,6 +505,36 @@ export function retryDelayMs(
|
|
|
505
505
|
return Math.round(backoff * (1 - rand() * 0.25));
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
+
/** What the caller of describeErrorText knows about the failing request, if anything. */
|
|
509
|
+
export interface ErrorTextContext {
|
|
510
|
+
/** Pi's provider id for the model that failed (e.g. "privateer", "openrouter"). */
|
|
511
|
+
provider?: string;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const PROVIDER_LABELS: Record<string, string> = {
|
|
515
|
+
privateer: "Privateer",
|
|
516
|
+
anthropic: "Anthropic",
|
|
517
|
+
openai: "OpenAI",
|
|
518
|
+
openrouter: "OpenRouter",
|
|
519
|
+
google: "Google",
|
|
520
|
+
tinfoil: "Tinfoil",
|
|
521
|
+
ollama: "Ollama",
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
function providerLabel(provider: string | undefined): string {
|
|
525
|
+
if (!provider) return "the model provider";
|
|
526
|
+
return PROVIDER_LABELS[provider] ?? provider;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A request that never got an HTTP response, in the words the SDKs actually use:
|
|
530
|
+
// OpenAI's APIConnectionError is the bare "Connection error.", undici's is "fetch
|
|
531
|
+
// failed" / "other side closed", Node's are the errno names.
|
|
532
|
+
const CONNECTION_FAILURE = /^\s*(?:connection error\.?|fetch failed|other side closed|socket hang up|(?:read |connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b)/i;
|
|
533
|
+
|
|
534
|
+
export function isConnectionFailureText(text: string | null | undefined): boolean {
|
|
535
|
+
return CONNECTION_FAILURE.test(typeof text === "string" ? text : "");
|
|
536
|
+
}
|
|
537
|
+
|
|
508
538
|
/**
|
|
509
539
|
* Describe an error we only have the TEXT of, for the one place that has nothing else.
|
|
510
540
|
*
|
|
@@ -515,9 +545,14 @@ export function retryDelayMs(
|
|
|
515
545
|
* and say the same thing `describeError` would. Returns null when there is no leading
|
|
516
546
|
* status to read, so ordinary messages print unchanged.
|
|
517
547
|
*/
|
|
518
|
-
export function describeErrorText(
|
|
548
|
+
export function describeErrorText(
|
|
549
|
+
text: string | null | undefined,
|
|
550
|
+
context: ErrorTextContext = {},
|
|
551
|
+
): DescribedError | null {
|
|
519
552
|
const s = typeof text === "string" ? text : "";
|
|
520
553
|
const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
|
|
554
|
+
const account = context.provider === "privateer";
|
|
555
|
+
const who = providerLabel(context.provider);
|
|
521
556
|
if (!Number.isFinite(status)) {
|
|
522
557
|
// No leading status: the one message we can still describe with certainty is a
|
|
523
558
|
// transport idle timeout, which undici names exactly. Everything else is printed
|
|
@@ -528,6 +563,17 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
|
|
|
528
563
|
hint: IDLE_TIMEOUT_DESCRIPTION.hint,
|
|
529
564
|
};
|
|
530
565
|
}
|
|
566
|
+
// The OpenAI SDK's whole message for a request that never got a response. It
|
|
567
|
+
// says nothing about WHERE, so name the endpoint and the next step.
|
|
568
|
+
if (isConnectionFailureText(s)) {
|
|
569
|
+
return {
|
|
570
|
+
message: redactText(`Couldn't reach ${who} — the request got no response ("${s.trim().slice(0, 80)}").`),
|
|
571
|
+
hint: account
|
|
572
|
+
? "Check this machine's connection. If it started around a sign-in, the session reconnects on your next message; if every message fails while a fresh `privateer` works, start a new session with /new."
|
|
573
|
+
: "Check your connection and the provider's base URL, then send it again — or run /model to switch providers.",
|
|
574
|
+
retryable: true,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
531
577
|
return null;
|
|
532
578
|
}
|
|
533
579
|
|
|
@@ -542,9 +588,17 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
|
|
|
542
588
|
};
|
|
543
589
|
}
|
|
544
590
|
if (status === 401 || status === 403) {
|
|
591
|
+
// "401 status code (no body)" names neither what was refused nor what to do. The
|
|
592
|
+
// status is enough to say both.
|
|
593
|
+
const bare = /\(no body\)\s*$/i.test(s) || s.trim() === String(status);
|
|
594
|
+
const refused = status === 401 ? "rejected the credential" : "refused access";
|
|
545
595
|
return {
|
|
546
|
-
message: redactText(
|
|
547
|
-
|
|
596
|
+
message: redactText(
|
|
597
|
+
bare ? `${who} ${refused} this request was sent with (${status}).` : compactProviderError(s),
|
|
598
|
+
),
|
|
599
|
+
hint: account
|
|
600
|
+
? "This terminal's Privateer session was refused. It is renewed automatically on your next message; if it keeps failing, run /login (or `privateer auth status` to see whether this machine is signed in)."
|
|
601
|
+
: `Check the API key for ${who} — run /login, or set the provider's API key env var.`,
|
|
548
602
|
};
|
|
549
603
|
}
|
|
550
604
|
if (status === 404) {
|
|
@@ -57,7 +57,13 @@ export interface GateController {
|
|
|
57
57
|
// session can spend what it was told it may spend instead of denying every media
|
|
58
58
|
// call for want of a human. Lifts `alwaysAsk` and nothing else — see
|
|
59
59
|
// ModeGate.isSpendPreauthorized for the guards.
|
|
60
|
-
|
|
60
|
+
// `input` and `signal` are the call's own, for a grant that prices the call before
|
|
61
|
+
// answering (the `--max-spend` ledger, permissions/cliSpend.ts).
|
|
62
|
+
isSpendPreauthorized?(req: PermissionRequest, input?: unknown, signal?: AbortSignal): boolean | Promise<boolean>;
|
|
63
|
+
// Extra words for the model when the gate denies `req` — WHY, and what would allow it
|
|
64
|
+
// (e.g. a headless run explaining --allow-spend). Appended to the denial; absent ⇒ the
|
|
65
|
+
// generic denial text alone.
|
|
66
|
+
explainDenial?(req: PermissionRequest): string | undefined;
|
|
61
67
|
// Block a tool outright while the turn is remote-driven (only consulted when
|
|
62
68
|
// getRemote() is true). For tools whose own prompts render on the host terminal
|
|
63
69
|
// rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
|
|
@@ -174,9 +180,22 @@ export async function decideToolCall(
|
|
|
174
180
|
getNoQuarter: ctrl.getNoQuarter,
|
|
175
181
|
getAutoApprove: ctrl.getAutoApprove,
|
|
176
182
|
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
177
|
-
isSpendPreauthorized: ctrl.isSpendPreauthorized
|
|
183
|
+
isSpendPreauthorized: ctrl.isSpendPreauthorized
|
|
184
|
+
? (r: PermissionRequest) => ctrl.isSpendPreauthorized!(r, input, ctx.signal)
|
|
185
|
+
: undefined,
|
|
178
186
|
});
|
|
179
187
|
|
|
188
|
+
// Why, when there is more to say than "denied" — read at denial time, so it reflects
|
|
189
|
+
// what the ask learned (a spend ledger's refusal reason, say).
|
|
190
|
+
const why = (): string => {
|
|
191
|
+
try {
|
|
192
|
+
const extra = ctrl.explainDenial?.(req);
|
|
193
|
+
return extra ? ` ${extra}` : "";
|
|
194
|
+
} catch {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
180
199
|
let decision: "allow" | "deny";
|
|
181
200
|
try {
|
|
182
201
|
decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
|
|
@@ -186,13 +205,13 @@ export async function decideToolCall(
|
|
|
186
205
|
// tell the model to stop retrying and take a different path (or ask the user).
|
|
187
206
|
return {
|
|
188
207
|
block: true,
|
|
189
|
-
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it
|
|
208
|
+
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it.${why()}`,
|
|
190
209
|
};
|
|
191
210
|
}
|
|
192
211
|
if (decision === "deny") {
|
|
193
212
|
return {
|
|
194
213
|
block: true,
|
|
195
|
-
reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves
|
|
214
|
+
reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves.${why()}`,
|
|
196
215
|
};
|
|
197
216
|
}
|
|
198
217
|
return undefined;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPEND PRE-APPROVAL FOR ONE HEADLESS RUN, typed on the command line.
|
|
2
|
+
//
|
|
3
|
+
// privateer -p --allow-spend generate_video --max-calls 1 --max-spend 1.00 "…"
|
|
4
|
+
//
|
|
5
|
+
// THE PROBLEM. A `-p` run has no screen, so the gate's local asker has nobody to ask
|
|
6
|
+
// and every billing tool — each one `alwaysAsk` — is denied. An agent driving Privateer
|
|
7
|
+
// from a script could plan a whole film and only discover at the last step that the
|
|
8
|
+
// one call that mattered could never be approved. The routine grant (childSpend.ts)
|
|
9
|
+
// solves this for scheduled runs; this is the same idea for a run a person starts.
|
|
10
|
+
//
|
|
11
|
+
// WHY A FLAG, NOT AN ENV VAR. The grant is typed per invocation, capped, and gone when
|
|
12
|
+
// the process exits. The launcher carries it to the gate in PRIVATEER_CLI_SPEND — the
|
|
13
|
+
// only channel into Pi's process — but deletes any inherited value before parsing argv
|
|
14
|
+
// (bin/privateer-launch.mjs), so an export lingering in someone's shell never counts.
|
|
15
|
+
// And it is honoured only where the flag can apply: a TOP-LEVEL headless session. Not
|
|
16
|
+
// the TUI (a person approves each call there), and not a subagent child (a child's
|
|
17
|
+
// grant comes only from childSpend.ts, whose caps this ledger does not share).
|
|
18
|
+
//
|
|
19
|
+
// WHAT IT LIFTS. Exactly what the routine grant lifts, through the same
|
|
20
|
+
// ModeGate.isSpendPreauthorized hook and under the same guards: `alwaysAsk` must be the
|
|
21
|
+
// only reason to ask, and a call that leaves the working directory or touches a
|
|
22
|
+
// protected file is never covered. On top of that, two caps:
|
|
23
|
+
//
|
|
24
|
+
// • --max-calls counts calls this ledger ALLOWED, not calls that succeeded — a call
|
|
25
|
+
// that then fails server-side still used its slot. Over-counting is the safe error.
|
|
26
|
+
// • --max-spend is checked BEFORE each call against the server's own reservation
|
|
27
|
+
// figure for that exact call (tools/media.ts quoteMediaCallUsd), which is
|
|
28
|
+
// worst-case by design. A call that can't be priced is REFUSED under a dollar cap
|
|
29
|
+
// rather than waved through — a cap that skips what it can't measure isn't one.
|
|
30
|
+
|
|
31
|
+
import type { PermissionRequest } from "./gate.ts";
|
|
32
|
+
import { BILLED_MEDIA_TOOLS } from "./classify.ts";
|
|
33
|
+
|
|
34
|
+
/** The env var the launcher writes from `--allow-spend` (mirrors bin/headless-flags.mjs). */
|
|
35
|
+
export const CLI_SPEND_ENV = "PRIVATEER_CLI_SPEND";
|
|
36
|
+
|
|
37
|
+
export interface CliSpendGrant {
|
|
38
|
+
tools: string[];
|
|
39
|
+
maxCalls?: number;
|
|
40
|
+
maxSpendUsd?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The grant this run was launched with, or null. Defensive: anything malformed, any
|
|
45
|
+
* tool that isn't a billing tool, or a grant with no cap at all reads as NO grant —
|
|
46
|
+
* the launcher never writes one of those, so seeing one means it didn't come from it.
|
|
47
|
+
*/
|
|
48
|
+
export function readCliSpendGrant(env: NodeJS.ProcessEnv = process.env): CliSpendGrant | null {
|
|
49
|
+
const raw = env[CLI_SPEND_ENV];
|
|
50
|
+
if (!raw) return null;
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const g = parsed as Partial<CliSpendGrant>;
|
|
58
|
+
if (!g || !Array.isArray(g.tools)) return null;
|
|
59
|
+
const tools = g.tools.filter((t): t is string => typeof t === "string" && BILLED_MEDIA_TOOLS.has(t));
|
|
60
|
+
if (tools.length === 0) return null;
|
|
61
|
+
const maxCalls = Number.isInteger(g.maxCalls) && (g.maxCalls as number) > 0 ? (g.maxCalls as number) : undefined;
|
|
62
|
+
const maxSpendUsd =
|
|
63
|
+
typeof g.maxSpendUsd === "number" && Number.isFinite(g.maxSpendUsd) && g.maxSpendUsd > 0 ? g.maxSpendUsd : undefined;
|
|
64
|
+
if (maxCalls === undefined && maxSpendUsd === undefined) return null;
|
|
65
|
+
return { tools, ...(maxCalls !== undefined ? { maxCalls } : {}), ...(maxSpendUsd !== undefined ? { maxSpendUsd } : {}) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Price one call of `tool` with these arguments, in USD; null when it can't be priced. */
|
|
69
|
+
export type SpendQuote = (tool: string, input: unknown, signal?: AbortSignal) => Promise<number | null>;
|
|
70
|
+
|
|
71
|
+
export type SpendDecision = { ok: true; usd: number | null } | { ok: false; reason: string };
|
|
72
|
+
|
|
73
|
+
const money = (n: number): string => `$${n.toFixed(2)}`;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The running tally for one grant. One per process: the caps are for the whole run.
|
|
77
|
+
*/
|
|
78
|
+
export class CliSpendLedger {
|
|
79
|
+
private calls = 0;
|
|
80
|
+
private spentUsd = 0;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
readonly grant: CliSpendGrant,
|
|
84
|
+
private readonly quote: SpendQuote,
|
|
85
|
+
) {}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* May this call spend? Records it when it may. The check and the record happen in
|
|
89
|
+
* one synchronous step after the (async) quote, so two calls racing in parallel can't
|
|
90
|
+
* both squeeze under the same remaining budget.
|
|
91
|
+
*/
|
|
92
|
+
async authorize(tool: string, input: unknown, signal?: AbortSignal): Promise<SpendDecision> {
|
|
93
|
+
const { tools, maxCalls, maxSpendUsd } = this.grant;
|
|
94
|
+
if (!tools.includes(tool)) {
|
|
95
|
+
return { ok: false, reason: `this run's --allow-spend covers ${tools.join(", ")}, not ${tool}` };
|
|
96
|
+
}
|
|
97
|
+
if (maxCalls !== undefined && this.calls >= maxCalls) {
|
|
98
|
+
return { ok: false, reason: `this run's --max-calls ${maxCalls} is used up` };
|
|
99
|
+
}
|
|
100
|
+
let usd: number | null = null;
|
|
101
|
+
if (maxSpendUsd !== undefined) {
|
|
102
|
+
try {
|
|
103
|
+
usd = await this.quote(tool, input, signal);
|
|
104
|
+
} catch {
|
|
105
|
+
usd = null;
|
|
106
|
+
}
|
|
107
|
+
if (usd === null) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason:
|
|
111
|
+
`${tool} can't be priced before it runs, so it can't be checked against --max-spend ${money(maxSpendUsd)}` +
|
|
112
|
+
(tool === "generate_image" || tool === "generate_sprite"
|
|
113
|
+
? " (a call that overrides the image model is one of those — leave `model`/`image_model` unset)"
|
|
114
|
+
: "") +
|
|
115
|
+
". Cap this run with --max-calls instead",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// Recheck after the await: a parallel call may have spent while we were quoting.
|
|
119
|
+
if (maxCalls !== undefined && this.calls >= maxCalls) {
|
|
120
|
+
return { ok: false, reason: `this run's --max-calls ${maxCalls} is used up` };
|
|
121
|
+
}
|
|
122
|
+
if (this.spentUsd + usd > maxSpendUsd + 1e-9) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason:
|
|
126
|
+
`this call is estimated at ${money(usd)} and only ${money(Math.max(0, maxSpendUsd - this.spentUsd))} ` +
|
|
127
|
+
`of this run's --max-spend ${money(maxSpendUsd)} is left`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
this.spentUsd += usd;
|
|
131
|
+
}
|
|
132
|
+
this.calls++;
|
|
133
|
+
return { ok: true, usd };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One line for the end of the run / a denial: what the grant has used so far. */
|
|
137
|
+
summary(): string {
|
|
138
|
+
const { maxCalls, maxSpendUsd } = this.grant;
|
|
139
|
+
const parts = [`${this.calls}${maxCalls !== undefined ? `/${maxCalls}` : ""} billed call(s)`];
|
|
140
|
+
if (maxSpendUsd !== undefined) parts.push(`~${money(this.spentUsd)} of ${money(maxSpendUsd)} estimated`);
|
|
141
|
+
return parts.join(", ");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The guidance a headless run gives when a billed call hits the gate with nothing to
|
|
147
|
+
* approve it — written for the MODEL as much as the person, since the model is the one
|
|
148
|
+
* that reads a tool denial and decides what to do next. It names every way out.
|
|
149
|
+
*/
|
|
150
|
+
export function headlessSpendGuidance(tool: string, cmd = process.env.PRIVATEER_CMD || "privateer"): string {
|
|
151
|
+
return (
|
|
152
|
+
`This is a headless run (-p) with no one at a screen to approve ${tool}, which bills the account. ` +
|
|
153
|
+
`It can't be approved from inside this run. To allow it, the person running Privateer can re-run with ` +
|
|
154
|
+
`\`${cmd} -p --allow-spend ${tool} --max-calls 1\` (optionally --max-spend <usd>), add --approve-in-app to ` +
|
|
155
|
+
`approve it from the Privateer app, or drive Privateer over ACP (\`${cmd} acp\`), where the controlling ` +
|
|
156
|
+
`program is asked. Stop and report this rather than retrying.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Is this the request a CLI grant could ever cover? (Billing tool, and billing is the only ask.) */
|
|
161
|
+
export function isSpendRequest(req: PermissionRequest): boolean {
|
|
162
|
+
return req.alwaysAsk === true && BILLED_MEDIA_TOOLS.has(req.tool);
|
|
163
|
+
}
|
|
@@ -63,7 +63,12 @@ export interface ModeGateDeps {
|
|
|
63
63
|
// Consulted ONLY to lift `alwaysAsk`, and only under the guards in ModeGate.request.
|
|
64
64
|
// Absent ⇒ nothing is pre-authorized, which is the posture every interactive session
|
|
65
65
|
// keeps: a terminal always asks its human, however cheap the call.
|
|
66
|
-
|
|
66
|
+
//
|
|
67
|
+
// May be async: a `--max-spend` grant prices the call before answering (cliSpend.ts).
|
|
68
|
+
// It is consulted LAST, after every other guard has passed, because a grant with a
|
|
69
|
+
// budget RECORDS what it allows — asking it about a call the mode would refuse anyway
|
|
70
|
+
// would spend budget on nothing.
|
|
71
|
+
isSpendPreauthorized?: (req: PermissionRequest) => boolean | Promise<boolean>;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
@@ -129,8 +134,9 @@ export class ModeGate implements PermissionGate {
|
|
|
129
134
|
req.alwaysAsk &&
|
|
130
135
|
!req.outside &&
|
|
131
136
|
!req.protected &&
|
|
132
|
-
this.deps.isSpendPreauthorized
|
|
133
|
-
decideAuto({ ...req, alwaysAsk: false }, this.deps.getMode(), this.deps.allowlist, denylist) === "allow"
|
|
137
|
+
this.deps.isSpendPreauthorized &&
|
|
138
|
+
decideAuto({ ...req, alwaysAsk: false }, this.deps.getMode(), this.deps.allowlist, denylist) === "allow" &&
|
|
139
|
+
(await this.deps.isSpendPreauthorized(req)) === true
|
|
134
140
|
) {
|
|
135
141
|
return "allow";
|
|
136
142
|
}
|
|
@@ -27,9 +27,20 @@
|
|
|
27
27
|
// process.env is the one thing all those copies share, so the state lives there and
|
|
28
28
|
// nowhere else: every reader, in every extension, sees every toggle.
|
|
29
29
|
//
|
|
30
|
+
// NO QUARTER ALSO TAKES THE PRIVACY FILTER DOWN. Lowering the moat is the "step away"
|
|
31
|
+
// switch, and a turn that runs to completion unattended must not stall on pi-privacy's
|
|
32
|
+
// PII prompt either — so going to no quarter is also `/privacy off`. Raising the moat
|
|
33
|
+
// puts the filter back, but ONLY if no quarter is what took it down: a session already
|
|
34
|
+
// running with `/privacy off` (or `--no-privacy`) stays off. The marker that records
|
|
35
|
+
// "no quarter did this" lives in the env for the same cross-copy reasons as the flag,
|
|
36
|
+
// and setPrivacyDisabled clears it — so an explicit `/privacy on|off` mid-no-quarter
|
|
37
|
+
// is the operator's word and a later shift+tab doesn't overrule it.
|
|
38
|
+
//
|
|
30
39
|
// IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere,
|
|
31
40
|
// including boot-ordered entrypoints (see boot.ts's ORDERING CONTRACT).
|
|
32
41
|
|
|
42
|
+
import { NO_QUARTER_PRIVACY_MARK, privacyDisabled, setPrivacyDisabled } from "../config/privacyDisabled.ts";
|
|
43
|
+
|
|
33
44
|
const ENV = "PRIVATEER_NO_QUARTER";
|
|
34
45
|
|
|
35
46
|
/** True while the gate is fully lowered for this session. Read live, never cached. */
|
|
@@ -39,8 +50,16 @@ export function noQuarterActive(): boolean {
|
|
|
39
50
|
|
|
40
51
|
/** Set the state — in the env, so every copy of this module and every child agrees. Returns the new state. */
|
|
41
52
|
export function setNoQuarter(on: boolean): boolean {
|
|
42
|
-
if (on)
|
|
43
|
-
|
|
53
|
+
if (on) {
|
|
54
|
+
process.env[ENV] = "1";
|
|
55
|
+
if (!privacyDisabled()) {
|
|
56
|
+
setPrivacyDisabled(true);
|
|
57
|
+
process.env[NO_QUARTER_PRIVACY_MARK] = "1";
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
delete process.env[ENV];
|
|
61
|
+
if (process.env[NO_QUARTER_PRIVACY_MARK] === "1") setPrivacyDisabled(false); // clears the mark
|
|
62
|
+
}
|
|
44
63
|
return on;
|
|
45
64
|
}
|
|
46
65
|
|