privateer-agent 0.12.8 → 0.12.11
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 +29 -10
- package/SECURITY.md +41 -5
- package/bin/privateer-launch.mjs +10 -0
- package/bin/privateer-verify.d.mts +10 -0
- package/bin/privateer-verify.mjs +289 -0
- package/extensions/privateer-brand.ts +8 -2
- package/extensions/privateer-hints.ts +8 -2
- package/extensions/privateer-speak.ts +1 -1
- package/package.json +25 -25
- package/patches/{@earendil-works+pi-coding-agent+0.80.3.patch → @earendil-works+pi-coding-agent+0.84.1.patch} +138 -70
- package/src/acp/run.ts +5 -4
- package/src/channels/run.ts +3 -1
- package/src/cli/chat.ts +7 -5
- package/src/config/moat.ts +13 -0
- package/src/harbor/index.ts +72 -21
- package/src/main.ts +1 -1
- package/src/nostr/event.ts +1 -1
- package/src/outbox/cloudOutbox.ts +179 -3
- package/src/providers/account.ts +38 -30
- package/src/providers/defaultModel.ts +1 -1
- package/src/providers/piAuthStore.ts +129 -0
- package/src/providers/sealedShim.ts +1 -1
- package/src/remote/liveTaskSession.ts +6 -6
- package/src/routines/delivery.ts +15 -2
- package/src/routines/resultBrief.ts +60 -0
- package/src/routines/resultMedia.ts +131 -0
- package/src/routines/schema.ts +1 -1
- package/src/routines/store.ts +7 -0
- package/src/session.ts +26 -9
- package/src/tools/attachResult.ts +66 -0
- package/src/tools/media.ts +2 -2
- package/src/tools/sendFile.ts +4 -1
- package/src/tools/videoCompose.ts +1 -1
- package/src/tools/web.ts +1 -1
package/src/providers/account.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { globalDir } from "../config/paths.ts";
|
|
|
28
28
|
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
|
|
29
29
|
import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
|
|
30
30
|
import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
|
|
31
|
+
import { piAuthStore } from "./piAuthStore.ts";
|
|
31
32
|
import {
|
|
32
33
|
sealedEnabled,
|
|
33
34
|
sealedProviderFor,
|
|
@@ -484,7 +485,7 @@ export const privateerOAuthProvider = {
|
|
|
484
485
|
};
|
|
485
486
|
|
|
486
487
|
// Confidential-compute prefixes in the account catalog: every model the server serves
|
|
487
|
-
// out of a TEE. `near/` is the one we can attest
|
|
488
|
+
// out of a TEE. `near/` is the one we can attest in full from here (the server
|
|
488
489
|
// proxies a nonce'd quote); `tinfoil/` and `phala/` are equally real enclaves whose
|
|
489
490
|
// attestation we cannot bind to THIS connection through the proxy — see accountPosture.
|
|
490
491
|
const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
|
|
@@ -780,16 +781,11 @@ async function accountCredential(): Promise<AccountCredential> {
|
|
|
780
781
|
return slot.inFlight;
|
|
781
782
|
}
|
|
782
783
|
|
|
783
|
-
// `ctx` is Pi's ExtensionContext
|
|
784
|
-
//
|
|
784
|
+
// `ctx` is Pi's ExtensionContext. It used to carry the auth store too
|
|
785
|
+
// (ctx.modelRegistry.authStorage) — pi 0.84 removed it, and the surviving
|
|
786
|
+
// ModelRegistry facade has no credential surface at all, so the store is resolved
|
|
787
|
+
// from piAuthStore() instead and `ctx` is now only ever consulted for UI.
|
|
785
788
|
type SeedContext = {
|
|
786
|
-
modelRegistry?: {
|
|
787
|
-
authStorage?: {
|
|
788
|
-
set?: (provider: string, cred: unknown) => void;
|
|
789
|
-
get?: (provider: string) => unknown;
|
|
790
|
-
remove?: (provider: string) => void;
|
|
791
|
-
};
|
|
792
|
-
};
|
|
793
789
|
model?: { provider?: string; id?: string };
|
|
794
790
|
hasUI?: boolean;
|
|
795
791
|
ui?: { notify?: (message: string, level: string) => void };
|
|
@@ -809,10 +805,12 @@ export async function armAccountCredential(
|
|
|
809
805
|
opts: { notify?: boolean } = {},
|
|
810
806
|
): Promise<boolean> {
|
|
811
807
|
if (!hasCredentials()) return false;
|
|
812
|
-
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
813
|
-
if (typeof store?.set !== "function") return false;
|
|
814
808
|
try {
|
|
815
|
-
store
|
|
809
|
+
// `modify` is the store's only write path: it hands us the current entry and
|
|
810
|
+
// takes the replacement back. We ignore the current one — a freshly minted
|
|
811
|
+
// child credential always supersedes whatever is on disk.
|
|
812
|
+
const cred = await accountCredential();
|
|
813
|
+
await (await piAuthStore()).modify("privateer", async () => ({ type: "oauth", ...cred }));
|
|
816
814
|
return true;
|
|
817
815
|
} catch (e) {
|
|
818
816
|
// The account channel is NOT armed: a dead machine login (401 → credentials cleared
|
|
@@ -831,6 +829,17 @@ export async function armAccountCredential(
|
|
|
831
829
|
}
|
|
832
830
|
}
|
|
833
831
|
|
|
832
|
+
// Write an already-acquired account credential into Pi's auth store.
|
|
833
|
+
//
|
|
834
|
+
// The entrypoints that mint their own child credential (chat, acp, harbor, the live
|
|
835
|
+
// task session) used to poke `services.authStorage.set(...)` directly. pi 0.84 removed
|
|
836
|
+
// that property and made the store's only write path an async `modify`, so the poke
|
|
837
|
+
// lives here now — one place that knows the shape, next to the ownership rules that
|
|
838
|
+
// govern the same entry.
|
|
839
|
+
export async function persistAccountCredential(creds: AccountCredential): Promise<void> {
|
|
840
|
+
await (await piAuthStore()).modify("privateer", async () => ({ type: "oauth", ...creds }));
|
|
841
|
+
}
|
|
842
|
+
|
|
834
843
|
// Drop Pi's PERSISTED account credential (the `privateer` entry in auth.json) — but
|
|
835
844
|
// ONLY when it's the one this process minted. Every exit path pairs a session revoke
|
|
836
845
|
// with this drop (the contract in auth/privateer.ts): Pi reuses the persisted
|
|
@@ -849,31 +858,32 @@ export async function armAccountCredential(
|
|
|
849
858
|
// machine's whole token family — every terminal's session included. Those callers have
|
|
850
859
|
// also already wiped the local credentials, so the ownership memo is gone by then and an
|
|
851
860
|
// ownership-checked drop would refuse and strand a dead entry on disk.
|
|
852
|
-
export function dropPersistedAccountCredential(
|
|
853
|
-
|
|
861
|
+
export async function dropPersistedAccountCredential(
|
|
862
|
+
opts: { force?: boolean } = {},
|
|
863
|
+
): Promise<boolean> {
|
|
864
|
+
const store = await piAuthStore();
|
|
854
865
|
const mine = ownedAccountCredential()?.access;
|
|
855
866
|
// Every caller revokes this process's session immediately before calling us, so drop
|
|
856
867
|
// the memo whatever happens to the entry on disk — otherwise a later arm() in this
|
|
857
868
|
// process (a harbor run's session_start, say) could re-persist the revoked token.
|
|
858
869
|
armSlot().cred = undefined;
|
|
859
|
-
if (typeof store?.remove !== "function") return false;
|
|
860
870
|
try {
|
|
861
|
-
if (opts.force) {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
871
|
+
if (!opts.force) {
|
|
872
|
+
// Not force: read the entry back and only drop it if it is ours. This read is
|
|
873
|
+
// the whole safety property — it is what sees the entry a CONCURRENT terminal
|
|
874
|
+
// wrote, and auth.json is re-read from disk when its revision changes, so this
|
|
875
|
+
// observes other processes rather than a stale in-process copy.
|
|
876
|
+
const persisted = (await store.read("privateer")) as { access?: unknown } | undefined;
|
|
865
877
|
if (!persisted) return false; // nothing persisted — nothing to drop
|
|
866
878
|
// Some other terminal's session. Leave it: it's the key that terminal is using.
|
|
867
879
|
if (typeof persisted.access === "string" && persisted.access !== mine) return false;
|
|
868
|
-
} else if (!mine) {
|
|
869
|
-
// Older Pi with no readable auth store AND we never minted anything — we have no
|
|
870
|
-
// basis to claim the entry, so leave it alone rather than break another terminal.
|
|
871
|
-
return false;
|
|
872
880
|
}
|
|
873
|
-
|
|
881
|
+
// force: the whole token family is revoked, so the entry is dead for every
|
|
882
|
+
// terminal and ownership is irrelevant.
|
|
883
|
+
await store.delete("privateer");
|
|
874
884
|
return true;
|
|
875
885
|
} catch {
|
|
876
|
-
return false; //
|
|
886
|
+
return false; // unwritable store — the server TTL is the fallback
|
|
877
887
|
}
|
|
878
888
|
}
|
|
879
889
|
|
|
@@ -889,12 +899,10 @@ export function dropPersistedAccountCredential(ctx: unknown, opts: { force?: boo
|
|
|
889
899
|
// refresh path (refreshToken), and pre-empting it would mint a second session row.
|
|
890
900
|
export async function ensureAccountArmed(ctx: unknown): Promise<void> {
|
|
891
901
|
if (!hasCredentials()) return;
|
|
892
|
-
const store = (ctx as SeedContext)?.modelRegistry?.authStorage;
|
|
893
|
-
if (typeof store?.get !== "function") return; // can't tell — leave it to session_start
|
|
894
902
|
try {
|
|
895
|
-
if (
|
|
903
|
+
if (await (await piAuthStore()).read("privateer")) return;
|
|
896
904
|
} catch {
|
|
897
|
-
return;
|
|
905
|
+
return; // can't tell — leave it to the next session_start
|
|
898
906
|
}
|
|
899
907
|
await armAccountCredential(ctx, { notify: false });
|
|
900
908
|
}
|
|
@@ -71,7 +71,7 @@ export const ACCOUNT_DEFAULT_MODEL_ID = TINFOIL_MODEL_ID;
|
|
|
71
71
|
export const ACCOUNT_DEFAULT_SPEC = `privateer/${ACCOUNT_DEFAULT_MODEL_ID}`;
|
|
72
72
|
|
|
73
73
|
// The account channel's NEAR confidential-compute model — no longer the default, but
|
|
74
|
-
// still the one account model we can attest
|
|
74
|
+
// still the one account model we can attest in full through the server proxy, so
|
|
75
75
|
// it stays first in the seed catalog after the default. See providers/account.ts.
|
|
76
76
|
export const ACCOUNT_NEAR_MODEL_ID = "near/zai-org/GLM-5.1-FP8";
|
|
77
77
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Access to Pi's credential store and model registry after the 0.84 refactor.
|
|
2
|
+
//
|
|
3
|
+
// pi-coding-agent 0.84 folded credential storage and the model registry into a single
|
|
4
|
+
// `ModelRuntime` and removed both from `AgentSessionServices`. Two things that used to
|
|
5
|
+
// hang off `services` no longer do:
|
|
6
|
+
//
|
|
7
|
+
// services.authStorage -> gone; the runtime owns a private CredentialStore
|
|
8
|
+
// services.modelRegistry -> gone; superseded by services.modelRuntime
|
|
9
|
+
//
|
|
10
|
+
// This module is the single place that knows about that change, so the account channel
|
|
11
|
+
// and the entrypoints don't each grow their own workaround.
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ NOTHING HERE MAY STATICALLY IMPORT PI. This module is pulled in by
|
|
14
|
+
// providers/account.ts, which is itself statically imported by extensions and by the
|
|
15
|
+
// harbor — and whose static graph is deliberately Pi-free, because Pi must not load
|
|
16
|
+
// before boot.ts has installed the env and the dispatcher (the same contract session.ts
|
|
17
|
+
// documents). So the one place a Pi class is genuinely needed loads it dynamically, and
|
|
18
|
+
// everything else is duck-typed off objects Pi already handed us.
|
|
19
|
+
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { agentDir as defaultAgentDir } from "../config/paths.ts";
|
|
23
|
+
|
|
24
|
+
/** The agent dir Pi is actually using — boot pins PI_CODING_AGENT_DIR to ours. */
|
|
25
|
+
function resolveAgentDir(agentDir?: string): string {
|
|
26
|
+
return agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The subset of Pi's CredentialStore the account channel uses. All async in 0.84. */
|
|
30
|
+
export type PiCredentialStore = {
|
|
31
|
+
read(providerId: string): Promise<unknown>;
|
|
32
|
+
modify(providerId: string, fn: (current: unknown) => Promise<unknown>): Promise<unknown>;
|
|
33
|
+
delete(providerId: string): Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let cached: { path: string; store: Promise<PiCredentialStore> } | undefined;
|
|
37
|
+
let override: Promise<PiCredentialStore> | undefined;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pi's credential store for auth.json — the same file, format and class the ModelRuntime
|
|
41
|
+
* uses. `AuthStorage` IS that class: model-runtime.js defaults to
|
|
42
|
+
* `DefaultAuthStorage.create(authPath)`, where DefaultAuthStorage is this exact export,
|
|
43
|
+
* and it already implements the public CredentialStore interface. 0.84 merely stopped
|
|
44
|
+
* re-exporting it, so our pi patch puts the export back — which keeps us on Pi's own
|
|
45
|
+
* store and file format instead of reimplementing either.
|
|
46
|
+
*
|
|
47
|
+
* Why ModelRuntime isn't enough: it exposes login/logout/listCredentials but no way to
|
|
48
|
+
* WRITE an OAuth credential, and pi-ai exports only InMemoryCredentialStore. The account
|
|
49
|
+
* channel must write one, because Pi persists an OAuth credential only for a login IT
|
|
50
|
+
* drove — never for our device-code flow.
|
|
51
|
+
*
|
|
52
|
+
* Holding a second instance over the same path is safe and is not a shortcut: auth.json
|
|
53
|
+
* is machine-global and shared by every terminal on the box, so the store is already
|
|
54
|
+
* built for concurrent access (per-path shared read state, a file-revision check on
|
|
55
|
+
* read, serialized `modify` writes). A second instance in this process is just one more
|
|
56
|
+
* reader/writer, exactly like another terminal — which is the case the ownership check
|
|
57
|
+
* in account.ts exists to handle.
|
|
58
|
+
*/
|
|
59
|
+
export function piAuthStore(agentDir?: string): Promise<PiCredentialStore> {
|
|
60
|
+
if (override) return override;
|
|
61
|
+
const path = join(resolveAgentDir(agentDir), "auth.json");
|
|
62
|
+
if (cached?.path !== path) {
|
|
63
|
+
cached = {
|
|
64
|
+
path,
|
|
65
|
+
store: import("@earendil-works/pi-coding-agent").then((pi) => {
|
|
66
|
+
// `AuthStorage` reaches the index through OUR pi patch (0.84 stopped exporting
|
|
67
|
+
// it), and patches are best-effort — `bin/apply-patches.mjs` degrades to stock
|
|
68
|
+
// pi when it cannot write to node_modules, the `sudo npm i -g` case. This is
|
|
69
|
+
// the one place left that genuinely needs the patched export: only pi's own
|
|
70
|
+
// class knows auth.json's locking, revision checks and file format, and pi's
|
|
71
|
+
// package exports map blocks the deep path it still lives at. So say what
|
|
72
|
+
// happened. Unpatched, the alternative is `undefined.create is not a function`
|
|
73
|
+
// at whatever moment the account channel first writes a credential.
|
|
74
|
+
const ctor = (pi as { AuthStorage?: { create(p: string): PiCredentialStore } }).AuthStorage;
|
|
75
|
+
if (!ctor) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"pi's AuthStorage export is missing — the privateer patch for " +
|
|
78
|
+
"@earendil-works/pi-coding-agent has not been applied to this install. " +
|
|
79
|
+
"Run `privateer` once with write access to node_modules (a root-owned " +
|
|
80
|
+
"global install needs sudo), or reinstall with a release bundle.",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return ctor.create(path);
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return cached.store;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Substitute the store. Tests only.
|
|
92
|
+
*
|
|
93
|
+
* The account channel's ownership rules used to be testable by handing the functions a
|
|
94
|
+
* ctx with a fake store on it; the store is resolved internally now, so this is the seam
|
|
95
|
+
* that replaces it. Pass undefined to restore the real one.
|
|
96
|
+
*/
|
|
97
|
+
export function setPiAuthStoreForTests(store: PiCredentialStore | undefined): void {
|
|
98
|
+
override = store ? Promise.resolve(store) : undefined;
|
|
99
|
+
cached = undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The registry-shaped reads our callers make. Matches the old ModelRegistry facade. */
|
|
103
|
+
export type RegistryLike = {
|
|
104
|
+
find(provider: string, modelId: string): unknown;
|
|
105
|
+
getAll(): readonly unknown[];
|
|
106
|
+
getAvailable(): Promise<readonly unknown[]>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A registry-shaped view of a session's services.
|
|
111
|
+
*
|
|
112
|
+
* 0.84 kept `ModelRegistry` as a synchronous facade over `ModelRuntime` (that is how
|
|
113
|
+
* extensions still see it) but stopped putting one on `services`. We deliberately do NOT
|
|
114
|
+
* construct one: importing the class would drag Pi into this module's static graph. The
|
|
115
|
+
* runtime is a live object Pi already gave us, so three delegating methods reproduce
|
|
116
|
+
* exactly the surface our callers used — `find` is `getModel` renamed.
|
|
117
|
+
*/
|
|
118
|
+
export function modelRegistryOf(services: { modelRuntime?: unknown } | null | undefined): RegistryLike {
|
|
119
|
+
const runtime = services?.modelRuntime as {
|
|
120
|
+
getModel(p: string, id: string): unknown;
|
|
121
|
+
getModels(): readonly unknown[];
|
|
122
|
+
getAvailable(): Promise<readonly unknown[]>;
|
|
123
|
+
} | undefined;
|
|
124
|
+
return {
|
|
125
|
+
find: (provider, modelId) => runtime?.getModel(provider, modelId),
|
|
126
|
+
getAll: () => runtime?.getModels() ?? [],
|
|
127
|
+
getAvailable: async () => (await runtime?.getAvailable()) ?? [],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -41,7 +41,7 @@ import { iterateSSE } from "./phala/sse.ts";
|
|
|
41
41
|
export type SealedProvider = "tinfoil" | "phala";
|
|
42
42
|
|
|
43
43
|
// Sealed mode is ON by default as of 2026-07-31, when the live checklist in
|
|
44
|
-
// docs/tee-verified-tinfoil-ehbp.md passed
|
|
44
|
+
// docs/tee-verified-tinfoil-ehbp.md passed in full against the deployed relay:
|
|
45
45
|
// both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
|
|
46
46
|
// TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
|
|
47
47
|
// refused rather than silently greened, and the server bills the turn.
|
|
@@ -20,7 +20,9 @@ import { agentVersion } from "../config/version.ts";
|
|
|
20
20
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
21
21
|
import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
|
|
22
22
|
import { moatResourceOptions } from "../config/moat.ts";
|
|
23
|
-
import { rememberAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
|
|
23
|
+
import { rememberAccountCredential, persistAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
|
|
24
|
+
// Pi-free static graph by design — see the header of piAuthStore.ts.
|
|
25
|
+
import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
24
26
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
25
27
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
26
28
|
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
@@ -70,7 +72,6 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
70
72
|
let initialPromptSent = false;
|
|
71
73
|
let stopped = false;
|
|
72
74
|
let spawnedAccount = false;
|
|
73
|
-
let servicesRef: { authStorage?: { remove?: (p: string) => void; get?: (p: string) => unknown } } | null = null;
|
|
74
75
|
|
|
75
76
|
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
76
77
|
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -117,7 +118,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
117
118
|
try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
|
|
118
119
|
// Ownership-checked: auth.json is shared machine-wide, so a live task's teardown
|
|
119
120
|
// must not delete an interactive terminal's entry (see providers/account.ts).
|
|
120
|
-
try { dropPersistedAccountCredential(
|
|
121
|
+
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
121
122
|
}
|
|
122
123
|
deps.onClosed(termId);
|
|
123
124
|
deps.log(`live task ${termId} closed`);
|
|
@@ -218,13 +219,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
218
219
|
})) as any),
|
|
219
220
|
},
|
|
220
221
|
});
|
|
221
|
-
servicesRef = services as any;
|
|
222
222
|
|
|
223
223
|
const { provider, modelId } = deps.parseSpec(modelSpec);
|
|
224
224
|
if (provider === "privateer") {
|
|
225
225
|
try {
|
|
226
226
|
const creds = await spawnAccountCredentials();
|
|
227
|
-
|
|
227
|
+
await persistAccountCredential(creds);
|
|
228
228
|
rememberAccountCredential(creds); // claim it, so stop() drops OUR entry only
|
|
229
229
|
spawnedAccount = true;
|
|
230
230
|
} catch (e) {
|
|
@@ -236,7 +236,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
236
236
|
// just spawned + closes the relay), or a throw would leak an orphaned account "device"
|
|
237
237
|
// until its token TTL. Everything post-account-spawn runs under one guard.
|
|
238
238
|
try {
|
|
239
|
-
const model = (services
|
|
239
|
+
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
240
240
|
if (!model) {
|
|
241
241
|
throw new Error(`model ${provider}/${modelId} not found`);
|
|
242
242
|
}
|
package/src/routines/delivery.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Routine } from "./schema.ts";
|
|
2
2
|
import { webhookName } from "./schema.ts";
|
|
3
3
|
import { writeRoutineOutput, addNotice } from "./store.ts";
|
|
4
|
+
import type { StagedMedia } from "./resultMedia.ts";
|
|
4
5
|
|
|
5
6
|
// A relay pusher, injected by the harbor. Given the finished result it either
|
|
6
7
|
// forwards it to an attached controller immediately ("live") or persists it to the
|
|
@@ -14,7 +15,14 @@ export type RelayPusher = (routine: Routine, content: string) => "live" | "queue
|
|
|
14
15
|
// "queued" when it was persisted for a later flush — either way the result is
|
|
15
16
|
// durably accounted for. `status` is carried so the sealed envelope can render
|
|
16
17
|
// ok/error without re-parsing the markdown body.
|
|
17
|
-
|
|
18
|
+
// `media` is what the run staged for the Inbox (resultMedia.ts) — sealed and stored
|
|
19
|
+
// alongside the message by the pusher, or named in the body when it can't travel.
|
|
20
|
+
export type CloudPusher = (
|
|
21
|
+
routine: Routine,
|
|
22
|
+
content: string,
|
|
23
|
+
status: "ok" | "error",
|
|
24
|
+
media: StagedMedia[],
|
|
25
|
+
) => Promise<"sent" | "queued">;
|
|
18
26
|
|
|
19
27
|
// A named webhook endpoint from config `webhooks`.
|
|
20
28
|
export interface WebhookTarget {
|
|
@@ -28,6 +36,11 @@ export interface DeliveryContext {
|
|
|
28
36
|
// (e.g. not signed in) → the `cloud` channel falls back to a notice so the result
|
|
29
37
|
// isn't silently lost.
|
|
30
38
|
pushCloud?: CloudPusher;
|
|
39
|
+
// Files the run staged for the app's Inbox. Only the `cloud` channel carries them:
|
|
40
|
+
// a webhook receiver has no key to open a sealed blob, and the file channel already
|
|
41
|
+
// has the originals on disk — so for every other channel the body's own prose is
|
|
42
|
+
// what tells the reader an attachment exists.
|
|
43
|
+
media?: StagedMedia[];
|
|
31
44
|
// Named endpoints for "webhook:<name>" delivery entries; results POST here.
|
|
32
45
|
webhooks?: Record<string, WebhookTarget>;
|
|
33
46
|
// Scrubs secrets from anything leaving the machine. Webhook bodies always pass
|
|
@@ -144,7 +157,7 @@ export async function deliver(
|
|
|
144
157
|
// yet) and flushes later — both are durable. Only when no pusher is wired at all,
|
|
145
158
|
// and nothing else keeps a durable record, do we fall back to a notice.
|
|
146
159
|
if (wants.has("cloud")) {
|
|
147
|
-
const sent = ctx.pushCloud ? await ctx.pushCloud(routine, content, status) : undefined;
|
|
160
|
+
const sent = ctx.pushCloud ? await ctx.pushCloud(routine, content, status, ctx.media ?? []) : undefined;
|
|
148
161
|
if (sent === "sent") delivered.push("cloud");
|
|
149
162
|
else if (sent === "queued") delivered.push("cloud(queued)");
|
|
150
163
|
else if (!wants.has("file") && !wants.has("notice") && !wants.has("relay")) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The delivery brief — what an unattended run is told about where its answer lands.
|
|
2
|
+
//
|
|
3
|
+
// A routine's prompt is written by the user ("summarise my open PRs at 7am"). It says
|
|
4
|
+
// what to do and nothing about the surface, so the model wrote for a terminal: paths,
|
|
5
|
+
// scrollback references, "see above", a plan to run something else next. None of that
|
|
6
|
+
// survives the trip. The result is sealed to the account outbox and read hours later
|
|
7
|
+
// in the app's Inbox, by someone who was not there and cannot answer back.
|
|
8
|
+
//
|
|
9
|
+
// So we prepend this. It states the surface (markdown, in an app), what the surface can
|
|
10
|
+
// render beyond prose (ONE fenced artifact, which the app turns into a Cargo card with
|
|
11
|
+
// Preview / Download / Save), and how to hand over media (the attach_to_result tool —
|
|
12
|
+
// only mentioned when it is actually registered, because a brief that advertises a tool
|
|
13
|
+
// the run doesn't have is how a model ends up describing an attachment nobody gets).
|
|
14
|
+
//
|
|
15
|
+
// Deliberately short. It is prepended to every unattended turn, so every line costs
|
|
16
|
+
// tokens on every run; anything that isn't load-bearing for the delivery is the user's
|
|
17
|
+
// prompt's business, not ours.
|
|
18
|
+
|
|
19
|
+
export interface BriefOptions {
|
|
20
|
+
/** The result reaches the app's Inbox (cloud delivery). False → no brief at all. */
|
|
21
|
+
inbox: boolean;
|
|
22
|
+
/** attach_to_result is registered for this run. */
|
|
23
|
+
canAttach: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HEADER = "[Privateer delivery brief — how this answer will be read]";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The brief for one run, or "" when it wouldn't help. Callers prepend it with
|
|
30
|
+
* `withBrief` rather than concatenating by hand.
|
|
31
|
+
*/
|
|
32
|
+
export function deliveryBrief(opts: BriefOptions): string {
|
|
33
|
+
if (!opts.inbox) return "";
|
|
34
|
+
const lines = [
|
|
35
|
+
HEADER,
|
|
36
|
+
"Nobody is at this terminal. Your final message is delivered to the user's Privateer Inbox in the app and read later, on a phone or in a browser, with no access to this machine.",
|
|
37
|
+
"",
|
|
38
|
+
"- Write the answer so it stands alone. No scrollback, no \"see above\", no next step you meant to run.",
|
|
39
|
+
"- It renders as Markdown: headings, lists, tables, links and code all work.",
|
|
40
|
+
"- A local file path is not something the user can open. Don't offer one as the way to see something.",
|
|
41
|
+
"- If the answer is better as something they can keep, open or share, end your message with ONE fenced artifact and describe it in prose above:",
|
|
42
|
+
" ```html … ``` a complete standalone HTML document — page, slide deck or game. Inline all CSS and JS; it is opened with no network and no sibling files.",
|
|
43
|
+
" ```md kind=pdf … ``` a document (also kind=docx, kind=md).",
|
|
44
|
+
" ```csv kind=sheet … ``` a spreadsheet.",
|
|
45
|
+
" The app turns that fence into an artifact card with Preview, Download and Save — so don't also paste the same content as prose.",
|
|
46
|
+
];
|
|
47
|
+
if (opts.canAttach) {
|
|
48
|
+
lines.push(
|
|
49
|
+
"- To send an image, video, audio clip or document, call attach_to_result with its path. That is the only way bytes reach the user; base64 pasted into the answer is not rendered.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Prepend the brief to a user prompt, fenced off so the two don't read as one instruction. */
|
|
56
|
+
export function withBrief(prompt: string, opts: BriefOptions): string {
|
|
57
|
+
const brief = deliveryBrief(opts);
|
|
58
|
+
if (!brief) return prompt;
|
|
59
|
+
return `${brief}\n\n---\n\n${prompt}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Media staged for ONE unattended result — the stills, clips and files an agent
|
|
2
|
+
// wants the user to actually see in their Privateer Inbox, rather than describe.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. A routine can already generate media (src/tools/media.ts) and
|
|
5
|
+
// compose it (videoCompose.ts), but until now the run could only ever say where it
|
|
6
|
+
// put the file. Nobody is at that machine — that is the whole premise of an
|
|
7
|
+
// unattended run — so "saved to ~/.privateer/media/still-3.png" is a result the
|
|
8
|
+
// user cannot open from the surface they were handed. `send_file_to_client` doesn't
|
|
9
|
+
// close that gap either: it needs a controller attached RIGHT NOW, which is exactly
|
|
10
|
+
// what a 7am routine doesn't have.
|
|
11
|
+
//
|
|
12
|
+
// So the run stages files here (the attach_to_result tool — src/tools/attachResult.ts),
|
|
13
|
+
// and delivery seals them to the account outbox alongside the message: small ones
|
|
14
|
+
// inline in the envelope, larger ones as sealed blobs the app collects on the same
|
|
15
|
+
// sync (src/outbox/cloudOutbox.ts). Store-and-forward, E2EE, no controller needed.
|
|
16
|
+
//
|
|
17
|
+
// The ceilings below are the honest part. They are checked at STAGE time, not at
|
|
18
|
+
// delivery time, so the model learns "that clip is too big" while it can still do
|
|
19
|
+
// something about it (re-encode, trim, attach a frame instead) rather than after the
|
|
20
|
+
// turn has ended. A refusal is never traded against something already staged.
|
|
21
|
+
|
|
22
|
+
import { existsSync, statSync } from "node:fs";
|
|
23
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { mediaTypeForPath } from "../tools/sendFile.ts";
|
|
26
|
+
|
|
27
|
+
/** Most files one result may carry. */
|
|
28
|
+
export const MAX_ATTACHMENTS = 6;
|
|
29
|
+
/** Largest single file, matching the server's per-blob ceiling. */
|
|
30
|
+
export const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
/** Largest total per result — bounds one run's share of the account's mailbox. */
|
|
32
|
+
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** How the app should present an attachment. Derived from the media type. */
|
|
35
|
+
export type MediaClass = "image" | "video" | "audio" | "file";
|
|
36
|
+
|
|
37
|
+
export interface StagedMedia {
|
|
38
|
+
/** Per-result id; ties the envelope's metadata to its bytes. */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Absolute path on THIS machine. Never leaves it — see the note in delivery. */
|
|
41
|
+
path: string;
|
|
42
|
+
name: string;
|
|
43
|
+
mediaType: string;
|
|
44
|
+
cls: MediaClass;
|
|
45
|
+
size: number;
|
|
46
|
+
caption?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function classifyMedia(mediaType: string): MediaClass {
|
|
50
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
51
|
+
if (mediaType.startsWith("video/")) return "video";
|
|
52
|
+
if (mediaType.startsWith("audio/")) return "audio";
|
|
53
|
+
return "file";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type StageResult =
|
|
57
|
+
| { ok: true; item: StagedMedia }
|
|
58
|
+
| { ok: false; reason: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One run's staging area. Created per routine/task run and thrown away with it, so
|
|
62
|
+
* a file staged by yesterday's run can never ride along with today's.
|
|
63
|
+
*/
|
|
64
|
+
export class ResultMedia {
|
|
65
|
+
private items: StagedMedia[] = [];
|
|
66
|
+
|
|
67
|
+
/** Bytes staged so far. */
|
|
68
|
+
totalBytes(): number {
|
|
69
|
+
return this.items.reduce((n, m) => n + m.size, 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
list(): StagedMedia[] {
|
|
73
|
+
return [...this.items];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
clear(): void {
|
|
77
|
+
this.items = [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stage one file. `cwd` resolves a relative path the same way the run's other
|
|
82
|
+
* file tools do. Re-staging the same path REPLACES the earlier entry (a model
|
|
83
|
+
* that regenerates an image and attaches it again means the new one), which also
|
|
84
|
+
* stops a retry loop from eating the count cap.
|
|
85
|
+
*/
|
|
86
|
+
stage(path: string, cwd: string, caption?: string): StageResult {
|
|
87
|
+
const abs = isAbsolute(path) ? path : resolve(cwd, path);
|
|
88
|
+
if (!existsSync(abs)) return { ok: false, reason: `File not found: ${path}` };
|
|
89
|
+
const stat = statSync(abs);
|
|
90
|
+
if (stat.isDirectory()) return { ok: false, reason: `${path} is a directory — attach a single file.` };
|
|
91
|
+
if (stat.size === 0) return { ok: false, reason: `${path} is empty — nothing to attach.` };
|
|
92
|
+
if (stat.size > MAX_ATTACHMENT_BYTES) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: `${basename(abs)} is ${mb(stat.size)} MB; the inbox caps one attachment at ${mb(MAX_ATTACHMENT_BYTES)} MB. Re-encode it smaller, trim it, or attach a single frame instead.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const existing = this.items.findIndex((m) => m.path === abs);
|
|
100
|
+
const others = existing >= 0 ? this.items.filter((_, i) => i !== existing) : this.items;
|
|
101
|
+
if (others.length >= MAX_ATTACHMENTS) {
|
|
102
|
+
return { ok: false, reason: `Already carrying ${MAX_ATTACHMENTS} attachments — that is the limit for one result.` };
|
|
103
|
+
}
|
|
104
|
+
const otherBytes = others.reduce((n, m) => n + m.size, 0);
|
|
105
|
+
if (otherBytes + stat.size > MAX_TOTAL_ATTACHMENT_BYTES) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `That would put this result over the ${mb(MAX_TOTAL_ATTACHMENT_BYTES)} MB attachment budget (${mb(otherBytes)} MB already staged).`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const mediaType = mediaTypeForPath(abs);
|
|
113
|
+
const item: StagedMedia = {
|
|
114
|
+
id: randomUUID(),
|
|
115
|
+
path: abs,
|
|
116
|
+
name: basename(abs),
|
|
117
|
+
mediaType,
|
|
118
|
+
cls: classifyMedia(mediaType),
|
|
119
|
+
size: stat.size,
|
|
120
|
+
// A caption is shown under the attachment in the app, so it is worth having
|
|
121
|
+
// and worth bounding — this is model-authored text heading for a UI.
|
|
122
|
+
...(caption?.trim() ? { caption: caption.trim().slice(0, 200) } : {}),
|
|
123
|
+
};
|
|
124
|
+
this.items = [...others, item];
|
|
125
|
+
return { ok: true, item };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function mb(bytes: number): string {
|
|
130
|
+
return (bytes / 1048576).toFixed(1).replace(/\.0$/, "");
|
|
131
|
+
}
|
package/src/routines/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
4
|
// Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
|
|
5
|
-
// inside the user's trust boundary. `cloud` stores an
|
|
5
|
+
// inside the user's trust boundary. `cloud` stores an ENCRYPTED copy in
|
|
6
6
|
// the account's server outbox for the app to catch up on when it next opens (the
|
|
7
7
|
// server only ever sees ciphertext — it can't read results — but does learn that a
|
|
8
8
|
// result exists, plus its time and rough size). `email` and `webhook:<name>` cross
|
package/src/routines/store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { globalDir } from "../config/paths.ts";
|
|
5
5
|
import { Routine, RoutineFile } from "./schema.ts";
|
|
6
|
+
import type { StagedMedia } from "./resultMedia.ts";
|
|
6
7
|
|
|
7
8
|
// routines.json lives alongside config.json in the global dir. It can carry the
|
|
8
9
|
// prompt text and (for email delivery) recipient hints, so it is written owner-only
|
|
@@ -223,6 +224,12 @@ export interface PendingCloud {
|
|
|
223
224
|
// workflow run. Preserved so the flush re-seals with the right `kind` and the app
|
|
224
225
|
// labels it correctly in the inbox.
|
|
225
226
|
kind?: OutboxKind;
|
|
227
|
+
// Attachments the run staged for the Inbox (resultMedia.ts) — RECORDS, not bytes.
|
|
228
|
+
// The files are still on this machine, so a flush hours later re-reads them from
|
|
229
|
+
// these paths; one that has since been deleted is named in the delivered body
|
|
230
|
+
// instead. Copying megabytes into this queue would be the same content twice on
|
|
231
|
+
// the one box that already has it.
|
|
232
|
+
media?: StagedMedia[];
|
|
226
233
|
}
|
|
227
234
|
|
|
228
235
|
function pendingCloudPath(): string {
|