pi-roundtable-sandbox 0.7.2 → 0.7.3
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/CHANGELOG.md +7 -0
- package/README.md +125 -1
- package/package.json +15 -3
- package/src/broker.ts +151 -85
- package/src/directory-file.ts +48 -0
- package/src/index.ts +76 -0
- package/src/model-input.ts +1 -1
- package/src/pi-attachments.ts +159 -0
- package/src/pi-broker.ts +500 -0
- package/src/pi-container-driver.ts +194 -0
- package/src/pi-model-input.ts +219 -0
- package/src/pi-protocol.ts +141 -0
- package/src/pi-runtime.ts +396 -0
- package/src/protocol.ts +8 -0
- package/src/research-worker.ts +132 -0
- package/src/safe-fetch.ts +397 -0
- package/src/scoped-delegator.ts +120 -0
- package/worker/Dockerfile.deps.pi +4 -0
- package/worker/Dockerfile.pi +12 -0
- package/worker/pi-content.ts +16 -0
- package/worker/pi-main.ts +382 -0
- package/worker/pi-memory.ts +21 -0
- package/worker/pi-skills.ts +140 -0
- package/worker/pi-tools.ts +211 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { closeSync, constants, openSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { InboundMessage, TurnAttachments } from "pi-roundtable";
|
|
4
|
+
import { openDirectoryFile, ownDirectory } from "./directory-file.ts";
|
|
5
|
+
import type { PiImage } from "./pi-protocol.ts";
|
|
6
|
+
import { safeFetch } from "./safe-fetch.ts";
|
|
7
|
+
|
|
8
|
+
export interface PiAttachmentOptions {
|
|
9
|
+
/** Trusted image-preparation hook receives bytes, never a guest-writable path. */
|
|
10
|
+
prepareImage(data: Uint8Array, mimeType: string): Promise<PiImage>;
|
|
11
|
+
/** Test-only fixture transport; production uses pinned safeFetch. */
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
/** Trusted presentation adapter for a failure; return undefined to keep the default wording. */
|
|
15
|
+
describeFailure?(error: unknown): string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** A failure whose message is already safe to show, matching the core's attachment wording. */
|
|
18
|
+
class AttachmentRefused extends Error {}
|
|
19
|
+
/** Bounded downloads and no-follow, descriptor-anchored writes into a guest-writable directory. */
|
|
20
|
+
export async function collectPiAttachments(
|
|
21
|
+
message: InboundMessage,
|
|
22
|
+
dir: string,
|
|
23
|
+
options: PiAttachmentOptions,
|
|
24
|
+
): Promise<TurnAttachments> {
|
|
25
|
+
// The guest owns this directory's contents, so a planted symlink is replaced rather than followed.
|
|
26
|
+
ownDirectory(dir);
|
|
27
|
+
const directory = openSync(
|
|
28
|
+
dir,
|
|
29
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
|
30
|
+
);
|
|
31
|
+
const files: TurnAttachments["files"][number][] = [];
|
|
32
|
+
const failures: TurnAttachments["failures"][number][] = [];
|
|
33
|
+
const images: PiImage[] = [];
|
|
34
|
+
let total = 0;
|
|
35
|
+
const refs = [
|
|
36
|
+
...message.attachments.map((ref, index) => ({
|
|
37
|
+
ref,
|
|
38
|
+
index,
|
|
39
|
+
prefix: message.messageId,
|
|
40
|
+
fromReference: false,
|
|
41
|
+
})),
|
|
42
|
+
...(message.reference?.attachments ?? []).map((ref, index) => ({
|
|
43
|
+
ref,
|
|
44
|
+
index,
|
|
45
|
+
prefix: `${message.messageId}-ref`,
|
|
46
|
+
fromReference: true,
|
|
47
|
+
})),
|
|
48
|
+
];
|
|
49
|
+
try {
|
|
50
|
+
for (const [
|
|
51
|
+
order,
|
|
52
|
+
{ ref, index, prefix, fromReference },
|
|
53
|
+
] of refs.entries()) {
|
|
54
|
+
try {
|
|
55
|
+
options.signal?.throwIfAborted();
|
|
56
|
+
if (order >= 20) throw new AttachmentRefused("too many attachments");
|
|
57
|
+
if (ref.size > 25 * 1024 * 1024)
|
|
58
|
+
throw new AttachmentRefused(`larger than 25 MB (${ref.size} bytes)`);
|
|
59
|
+
const fetched = options.fetchImpl
|
|
60
|
+
? await options.fetchImpl(ref.url, { signal: options.signal })
|
|
61
|
+
: undefined;
|
|
62
|
+
const result = fetched
|
|
63
|
+
? {
|
|
64
|
+
status: fetched.status,
|
|
65
|
+
headers: fetched.headers,
|
|
66
|
+
data: new Uint8Array(await fetched.arrayBuffer()),
|
|
67
|
+
}
|
|
68
|
+
: await safeFetch(ref.url, {
|
|
69
|
+
signal: options.signal,
|
|
70
|
+
maxBytes: 25 * 1024 * 1024,
|
|
71
|
+
});
|
|
72
|
+
if (result.status < 200 || result.status >= 300)
|
|
73
|
+
throw new AttachmentRefused(
|
|
74
|
+
`download failed with HTTP ${result.status}`,
|
|
75
|
+
);
|
|
76
|
+
total += result.data.byteLength;
|
|
77
|
+
if (result.data.byteLength > 25 * 1024 * 1024)
|
|
78
|
+
throw new AttachmentRefused(
|
|
79
|
+
`larger than 25 MB (${result.data.byteLength} bytes)`,
|
|
80
|
+
);
|
|
81
|
+
if (total > 50 * 1024 * 1024)
|
|
82
|
+
throw new AttachmentRefused("attachments exceed 50 MB in total");
|
|
83
|
+
if (!/^[a-zA-Z0-9_-]{1,160}$/.test(prefix))
|
|
84
|
+
throw new Error("Invalid message id");
|
|
85
|
+
const name =
|
|
86
|
+
ref.name
|
|
87
|
+
.normalize("NFC")
|
|
88
|
+
.replace(/[/\\\p{Cc}]/gu, "_")
|
|
89
|
+
.replace(/^\.+/, "_")
|
|
90
|
+
.slice(-120) || "file";
|
|
91
|
+
const file = `${prefix}-${index}-${name}`;
|
|
92
|
+
const path = join(dir, file);
|
|
93
|
+
const fd = await openDirectoryFile(
|
|
94
|
+
directory,
|
|
95
|
+
file,
|
|
96
|
+
constants.O_WRONLY |
|
|
97
|
+
constants.O_CREAT |
|
|
98
|
+
constants.O_EXCL |
|
|
99
|
+
constants.O_NOFOLLOW,
|
|
100
|
+
0o600,
|
|
101
|
+
);
|
|
102
|
+
try {
|
|
103
|
+
let written = 0;
|
|
104
|
+
while (written < result.data.length)
|
|
105
|
+
written += writeSync(
|
|
106
|
+
fd,
|
|
107
|
+
result.data,
|
|
108
|
+
written,
|
|
109
|
+
result.data.length - written,
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
const contentType =
|
|
115
|
+
ref.contentType ||
|
|
116
|
+
result.headers.get("content-type") ||
|
|
117
|
+
"application/octet-stream";
|
|
118
|
+
files.push({
|
|
119
|
+
name: ref.name,
|
|
120
|
+
file,
|
|
121
|
+
path,
|
|
122
|
+
contentType,
|
|
123
|
+
size: result.data.byteLength,
|
|
124
|
+
fromReference,
|
|
125
|
+
});
|
|
126
|
+
const mime = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
127
|
+
if (
|
|
128
|
+
images.length < 4 &&
|
|
129
|
+
["image/png", "image/jpeg", "image/gif", "image/webp"].includes(mime)
|
|
130
|
+
) {
|
|
131
|
+
try {
|
|
132
|
+
images.push(await options.prepareImage(result.data, mime));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
failures.push({
|
|
135
|
+
name: ref.name,
|
|
136
|
+
reason:
|
|
137
|
+
options.describeFailure?.(error) ??
|
|
138
|
+
"the image could not be decoded",
|
|
139
|
+
fromReference,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
failures.push({
|
|
145
|
+
name: ref.name,
|
|
146
|
+
reason:
|
|
147
|
+
options.describeFailure?.(error) ??
|
|
148
|
+
(error instanceof AttachmentRefused
|
|
149
|
+
? error.message
|
|
150
|
+
: `download failed: ${String(error)}`),
|
|
151
|
+
fromReference,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { files, failures, images };
|
|
156
|
+
} finally {
|
|
157
|
+
closeSync(directory);
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/pi-broker.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import type { ChannelKey } from "pi-roundtable";
|
|
2
|
+
import { type BrokerListener, listenBroker } from "./broker.ts";
|
|
3
|
+
import { piModelInput } from "./pi-model-input.ts";
|
|
4
|
+
import {
|
|
5
|
+
PI_MEDIA_LIMITS,
|
|
6
|
+
type PiThinkingLevel,
|
|
7
|
+
type PiToolResponse,
|
|
8
|
+
type PiTurnRequest,
|
|
9
|
+
type PiTurnResponse,
|
|
10
|
+
validateImages,
|
|
11
|
+
validateReplyFiles,
|
|
12
|
+
} from "./pi-protocol.ts";
|
|
13
|
+
import { boundedText, isRecord } from "./protocol.ts";
|
|
14
|
+
|
|
15
|
+
export interface PiHostContext {
|
|
16
|
+
channel: ChannelKey;
|
|
17
|
+
profile: string;
|
|
18
|
+
speaker: { id: string; name: string };
|
|
19
|
+
/** The host-judged level for this turn; the worker can request less but never more. */
|
|
20
|
+
thinking: PiThinkingLevel;
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
export interface PiMcpServer {
|
|
24
|
+
name: string;
|
|
25
|
+
url: string;
|
|
26
|
+
tools: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export interface PiBrokerOptions {
|
|
29
|
+
model: string;
|
|
30
|
+
/** Host-only, read at each call. Never sent to the worker. */
|
|
31
|
+
oauthToken: () => string | Promise<string>;
|
|
32
|
+
tools?: {
|
|
33
|
+
names: readonly string[];
|
|
34
|
+
call(
|
|
35
|
+
name: string,
|
|
36
|
+
input: Record<string, unknown>,
|
|
37
|
+
context: PiHostContext,
|
|
38
|
+
): Promise<PiToolResponse>;
|
|
39
|
+
};
|
|
40
|
+
mcp?: {
|
|
41
|
+
servers: readonly PiMcpServer[];
|
|
42
|
+
token: () => string | Promise<string>;
|
|
43
|
+
};
|
|
44
|
+
upstream?: string;
|
|
45
|
+
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
|
|
46
|
+
maxCalls?: number;
|
|
47
|
+
maxOutputTokens?: number;
|
|
48
|
+
/** Trusted endpoints can use HTTP only for explicitly configured local MCP. */
|
|
49
|
+
allowHttpMcp?: boolean;
|
|
50
|
+
}
|
|
51
|
+
const MODEL_PATHS = new Set([
|
|
52
|
+
"/anthropic/v1/messages",
|
|
53
|
+
"/anthropic/v1/messages/count_tokens",
|
|
54
|
+
]);
|
|
55
|
+
const HEADERS = [
|
|
56
|
+
"anthropic-version",
|
|
57
|
+
"anthropic-beta",
|
|
58
|
+
"content-type",
|
|
59
|
+
"accept",
|
|
60
|
+
"user-agent",
|
|
61
|
+
"x-app",
|
|
62
|
+
];
|
|
63
|
+
const RESPONSE_HEADERS = ["content-type", "request-id", "x-should-retry"];
|
|
64
|
+
/** Headers the worker's client uses to back off or recover; each value is short and checked. */
|
|
65
|
+
function passthroughHeader(name: string): boolean {
|
|
66
|
+
return (
|
|
67
|
+
name === "retry-after" ||
|
|
68
|
+
name.startsWith("anthropic-ratelimit-") ||
|
|
69
|
+
RESPONSE_HEADERS.includes(name)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function endpoint(raw: string, allowHttp = false): URL {
|
|
73
|
+
// pi-lens-ignore: unchecked-throwing-call -- invalid trusted endpoint configuration must fail startup.
|
|
74
|
+
const url = new URL(raw);
|
|
75
|
+
if (
|
|
76
|
+
!(url.protocol === "https:" || (allowHttp && url.protocol === "http:")) ||
|
|
77
|
+
url.username ||
|
|
78
|
+
url.password ||
|
|
79
|
+
url.hash ||
|
|
80
|
+
url.search
|
|
81
|
+
)
|
|
82
|
+
throw new Error("Invalid trusted endpoint");
|
|
83
|
+
return url;
|
|
84
|
+
}
|
|
85
|
+
function streamBounded(
|
|
86
|
+
response: Response,
|
|
87
|
+
signal: AbortSignal,
|
|
88
|
+
maxBytes: number,
|
|
89
|
+
secret: string,
|
|
90
|
+
): ReadableStream<Uint8Array> {
|
|
91
|
+
const reader = response.body?.getReader();
|
|
92
|
+
let total = 0;
|
|
93
|
+
let carry = Buffer.alloc(0);
|
|
94
|
+
const variants = [
|
|
95
|
+
secret,
|
|
96
|
+
encodeURIComponent(secret),
|
|
97
|
+
Buffer.from(secret).toString("base64"),
|
|
98
|
+
Buffer.from(secret).toString("base64url"),
|
|
99
|
+
Buffer.from(secret).toString("hex"),
|
|
100
|
+
]
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.map((s) => Buffer.from(s));
|
|
103
|
+
const tail = Math.max(0, ...variants.map((s) => s.length)) - 1;
|
|
104
|
+
return new ReadableStream({
|
|
105
|
+
async pull(controller) {
|
|
106
|
+
try {
|
|
107
|
+
signal.throwIfAborted();
|
|
108
|
+
const result = await reader?.read();
|
|
109
|
+
const data = Buffer.concat([carry, result?.value ?? new Uint8Array()]);
|
|
110
|
+
if (variants.some((s) => data.includes(s)))
|
|
111
|
+
throw new Error("Credential reflected");
|
|
112
|
+
total += result?.value?.byteLength ?? 0;
|
|
113
|
+
if (total > maxBytes) throw new Error("Response too large");
|
|
114
|
+
if (!result || result.done) {
|
|
115
|
+
if (data.length) controller.enqueue(data);
|
|
116
|
+
controller.close();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const cut = Math.max(0, data.length - tail);
|
|
120
|
+
if (cut) controller.enqueue(data.subarray(0, cut));
|
|
121
|
+
carry = data.subarray(cut);
|
|
122
|
+
} catch {
|
|
123
|
+
await reader?.cancel().catch(() => {});
|
|
124
|
+
controller.error(new Error("Broker response failed"));
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
cancel: () => reader?.cancel(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Explicit subscription mode. A broker is idle except while the host binds one admitted turn. */
|
|
132
|
+
export class PiSandboxBroker {
|
|
133
|
+
readonly #options: PiBrokerOptions;
|
|
134
|
+
#ready = false;
|
|
135
|
+
#receivingResult = false;
|
|
136
|
+
#next: ((turn: PiTurnRequest) => void) | undefined;
|
|
137
|
+
#pending:
|
|
138
|
+
| {
|
|
139
|
+
request: PiTurnRequest;
|
|
140
|
+
signal: AbortSignal;
|
|
141
|
+
resolve(result: PiTurnResponse): void;
|
|
142
|
+
reject(error: Error): void;
|
|
143
|
+
delivered: boolean;
|
|
144
|
+
}
|
|
145
|
+
| undefined;
|
|
146
|
+
isReady(): boolean {
|
|
147
|
+
return this.#ready;
|
|
148
|
+
}
|
|
149
|
+
execute(
|
|
150
|
+
request: PiTurnRequest,
|
|
151
|
+
signal: AbortSignal,
|
|
152
|
+
): Promise<PiTurnResponse> {
|
|
153
|
+
if (this.#pending) return Promise.reject(new Error("Worker is busy"));
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const abort = () => {
|
|
156
|
+
this.#pending = undefined;
|
|
157
|
+
reject(new Error("Turn cancelled"));
|
|
158
|
+
};
|
|
159
|
+
if (signal.aborted) {
|
|
160
|
+
abort();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
164
|
+
this.#pending = {
|
|
165
|
+
request,
|
|
166
|
+
signal,
|
|
167
|
+
delivered: false,
|
|
168
|
+
resolve: (result) => {
|
|
169
|
+
signal.removeEventListener("abort", abort);
|
|
170
|
+
this.#pending = undefined;
|
|
171
|
+
resolve(result);
|
|
172
|
+
},
|
|
173
|
+
reject,
|
|
174
|
+
};
|
|
175
|
+
this.#next?.(request);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async #worker(request: Request, path: string): Promise<Response> {
|
|
179
|
+
if (path === "/worker/ready" && request.method === "POST") {
|
|
180
|
+
this.#ready = true;
|
|
181
|
+
this.#rearmStartup();
|
|
182
|
+
return new Response("Ready");
|
|
183
|
+
}
|
|
184
|
+
if (path === "/worker/next" && request.method === "GET") {
|
|
185
|
+
if (this.#next) return new Response("Already waiting", { status: 429 });
|
|
186
|
+
if (this.#pending && !this.#pending.delivered) {
|
|
187
|
+
this.#pending.delivered = true;
|
|
188
|
+
return Response.json(this.#pending.request);
|
|
189
|
+
}
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
const finish = (response: Response) => {
|
|
192
|
+
request.signal.removeEventListener("abort", abort);
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
this.#next = undefined;
|
|
195
|
+
resolve(response);
|
|
196
|
+
};
|
|
197
|
+
const abort = () => finish(new Response("Retry", { status: 408 }));
|
|
198
|
+
const timer = setTimeout(abort, 45_000);
|
|
199
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
200
|
+
this.#next = (turn) => {
|
|
201
|
+
if (this.#pending) this.#pending.delivered = true;
|
|
202
|
+
finish(Response.json(turn));
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (path === "/worker/result" && request.method === "POST") {
|
|
207
|
+
const pending = this.#pending;
|
|
208
|
+
if (!pending?.delivered || pending.signal.aborted)
|
|
209
|
+
return new Response("No matching turn", { status: 409 });
|
|
210
|
+
if (this.#receivingResult)
|
|
211
|
+
return new Response("Already receiving", { status: 429 });
|
|
212
|
+
this.#receivingResult = true;
|
|
213
|
+
try {
|
|
214
|
+
const value: unknown = JSON.parse(
|
|
215
|
+
await boundedText(
|
|
216
|
+
request.body,
|
|
217
|
+
72 * 1024 * 1024,
|
|
218
|
+
AbortSignal.any([request.signal, pending.signal]),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
if (
|
|
222
|
+
!isRecord(value) ||
|
|
223
|
+
this.#pending !== pending ||
|
|
224
|
+
value.turnId !== pending.request.turnId ||
|
|
225
|
+
!this.#pending?.delivered ||
|
|
226
|
+
!isRecord(value.result) ||
|
|
227
|
+
typeof value.result.ok !== "boolean"
|
|
228
|
+
)
|
|
229
|
+
return new Response("No matching turn", { status: 409 });
|
|
230
|
+
const result = value.result;
|
|
231
|
+
if (
|
|
232
|
+
result.ok
|
|
233
|
+
? typeof result.text !== "string" ||
|
|
234
|
+
result.text.length > 100_000 ||
|
|
235
|
+
!Array.isArray(result.files) ||
|
|
236
|
+
result.files.length > PI_MEDIA_LIMITS.files
|
|
237
|
+
: typeof result.error !== "string" || result.error.length > 10000
|
|
238
|
+
)
|
|
239
|
+
return new Response("Invalid result", { status: 400 });
|
|
240
|
+
if (
|
|
241
|
+
result.ok === true &&
|
|
242
|
+
typeof result.text === "string" &&
|
|
243
|
+
Array.isArray(result.files)
|
|
244
|
+
) {
|
|
245
|
+
validateReplyFiles(result.files);
|
|
246
|
+
this.#pending.resolve({
|
|
247
|
+
ok: true,
|
|
248
|
+
text: result.text,
|
|
249
|
+
files: result.files,
|
|
250
|
+
});
|
|
251
|
+
} else if (typeof result.error === "string")
|
|
252
|
+
this.#pending.resolve({ ok: false, error: result.error });
|
|
253
|
+
return new Response("Accepted");
|
|
254
|
+
} catch {
|
|
255
|
+
return new Response("Invalid result", { status: 400 });
|
|
256
|
+
} finally {
|
|
257
|
+
this.#receivingResult = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return new Response("Not found", { status: 404 });
|
|
261
|
+
}
|
|
262
|
+
#turn:
|
|
263
|
+
| { context: PiHostContext; remaining: number; active: number }
|
|
264
|
+
| undefined;
|
|
265
|
+
/**
|
|
266
|
+
* A restarted worker connects its MCP servers before any turn, so it needs the metadata-only
|
|
267
|
+
* window again. It still allows no tool call, and re-arming is rate-limited.
|
|
268
|
+
*/
|
|
269
|
+
#rearmStartup(): void {
|
|
270
|
+
const startup = this.#startup;
|
|
271
|
+
if (
|
|
272
|
+
!this.#turn &&
|
|
273
|
+
(!startup || startup.context.signal.aborted || startup.remaining <= 0) &&
|
|
274
|
+
Date.now() - this.#startupArmed > 10_000
|
|
275
|
+
)
|
|
276
|
+
this.#startup = this.#newStartup();
|
|
277
|
+
}
|
|
278
|
+
#startupArmed = Date.now();
|
|
279
|
+
#startup:
|
|
280
|
+
| { context: PiHostContext; remaining: number; active: number }
|
|
281
|
+
| undefined = this.#newStartup();
|
|
282
|
+
#newStartup() {
|
|
283
|
+
this.#startupArmed = Date.now();
|
|
284
|
+
return {
|
|
285
|
+
context: {
|
|
286
|
+
channel: "sandbox:startup" as ChannelKey,
|
|
287
|
+
profile: "",
|
|
288
|
+
speaker: { id: "", name: "" },
|
|
289
|
+
thinking: "low" as const,
|
|
290
|
+
signal: AbortSignal.timeout(90_000),
|
|
291
|
+
},
|
|
292
|
+
remaining: 32,
|
|
293
|
+
active: 0,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
constructor(options: PiBrokerOptions) {
|
|
297
|
+
endpoint(options.upstream ?? "https://api.anthropic.com");
|
|
298
|
+
for (const server of options.mcp?.servers ?? []) {
|
|
299
|
+
endpoint(server.url, options.allowHttpMcp);
|
|
300
|
+
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(server.name))
|
|
301
|
+
throw new Error("Invalid MCP server name");
|
|
302
|
+
}
|
|
303
|
+
if (
|
|
304
|
+
!Number.isSafeInteger(options.maxCalls ?? 128) ||
|
|
305
|
+
(options.maxCalls ?? 128) < 1 ||
|
|
306
|
+
(options.maxCalls ?? 128) > 1000
|
|
307
|
+
)
|
|
308
|
+
throw new Error("Invalid broker budget");
|
|
309
|
+
if (
|
|
310
|
+
!Number.isSafeInteger(options.maxOutputTokens ?? 128_000) ||
|
|
311
|
+
(options.maxOutputTokens ?? 128_000) < 1025 ||
|
|
312
|
+
(options.maxOutputTokens ?? 128_000) > 200_000
|
|
313
|
+
)
|
|
314
|
+
throw new Error("Invalid output-token budget");
|
|
315
|
+
this.#options = options;
|
|
316
|
+
}
|
|
317
|
+
bind(context: PiHostContext): () => void {
|
|
318
|
+
if (this.#turn) throw new Error("Broker turn already active");
|
|
319
|
+
const bound = {
|
|
320
|
+
context: { ...context, speaker: { ...context.speaker } },
|
|
321
|
+
remaining: this.#options.maxCalls ?? 128,
|
|
322
|
+
active: 0,
|
|
323
|
+
};
|
|
324
|
+
this.#startup = undefined;
|
|
325
|
+
this.#turn = bound;
|
|
326
|
+
return () => {
|
|
327
|
+
if (this.#turn === bound) this.#turn = undefined;
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
listen(socket: string): Promise<BrokerListener> {
|
|
331
|
+
return listenBroker(socket, (request) => this.handle(request), {
|
|
332
|
+
stream: true,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
async handle(request: Request): Promise<Response> {
|
|
336
|
+
// pi-lens-ignore: unchecked-throwing-call -- Request construction already validates this absolute URL.
|
|
337
|
+
const url = new URL(request.url);
|
|
338
|
+
if (url.hash || (url.search && url.search !== "?beta=true"))
|
|
339
|
+
return new Response("Not found", { status: 404 });
|
|
340
|
+
if (url.pathname.startsWith("/worker/") && !url.search)
|
|
341
|
+
return this.#worker(request, url.pathname);
|
|
342
|
+
// Discovery is static and credential-free, needed before the worker's first admitted turn.
|
|
343
|
+
if (
|
|
344
|
+
url.pathname === "/mcp-tools" &&
|
|
345
|
+
request.method === "GET" &&
|
|
346
|
+
!url.search
|
|
347
|
+
) {
|
|
348
|
+
// The worker's first call after a restart, before it connects its MCP servers.
|
|
349
|
+
this.#rearmStartup();
|
|
350
|
+
return Response.json({
|
|
351
|
+
servers: (this.#options.mcp?.servers ?? []).map(({ name, tools }) => ({
|
|
352
|
+
name,
|
|
353
|
+
tools,
|
|
354
|
+
})),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const startup = !this.#turn;
|
|
358
|
+
const turn = this.#turn ?? this.#startup;
|
|
359
|
+
if (!turn || turn.context.signal.aborted)
|
|
360
|
+
return new Response("Turn ended", { status: 410 });
|
|
361
|
+
const toolName = url.pathname.startsWith("/tools/")
|
|
362
|
+
? url.pathname.slice(7)
|
|
363
|
+
: "";
|
|
364
|
+
const server = url.pathname.startsWith("/mcp/")
|
|
365
|
+
? this.#options.mcp?.servers.find((s) => s.name === url.pathname.slice(5))
|
|
366
|
+
: undefined;
|
|
367
|
+
const model = MODEL_PATHS.has(url.pathname);
|
|
368
|
+
if (startup && !server) return new Response("Turn ended", { status: 410 });
|
|
369
|
+
if (
|
|
370
|
+
request.method !== "POST" ||
|
|
371
|
+
(!model && !server && !this.#options.tools?.names.includes(toolName)) ||
|
|
372
|
+
(!model && url.search)
|
|
373
|
+
)
|
|
374
|
+
return new Response("Not found", { status: 404 });
|
|
375
|
+
if (turn.remaining <= 0 || turn.active >= 4)
|
|
376
|
+
return new Response("Broker budget exhausted", { status: 429 });
|
|
377
|
+
turn.remaining--;
|
|
378
|
+
turn.active++;
|
|
379
|
+
// The turn signal carries the deadline: a long generation or image call must not be cut shorter.
|
|
380
|
+
const signal = AbortSignal.any([turn.context.signal, request.signal]);
|
|
381
|
+
try {
|
|
382
|
+
const input: unknown = JSON.parse(
|
|
383
|
+
await boundedText(request.body, 96 * 1024 * 1024),
|
|
384
|
+
);
|
|
385
|
+
if (!isRecord(input))
|
|
386
|
+
return new Response("Invalid input", { status: 400 });
|
|
387
|
+
signal.throwIfAborted();
|
|
388
|
+
if (toolName) {
|
|
389
|
+
if (!isRecord(input.input))
|
|
390
|
+
return new Response("Invalid tool input", { status: 400 });
|
|
391
|
+
// Deliberately ignore every caller identity field; the host-bound speaker wins.
|
|
392
|
+
const result = await this.#options.tools?.call(toolName, input.input, {
|
|
393
|
+
...turn.context,
|
|
394
|
+
signal,
|
|
395
|
+
});
|
|
396
|
+
if (
|
|
397
|
+
!result ||
|
|
398
|
+
typeof result.ok !== "boolean" ||
|
|
399
|
+
(result.text?.length ?? 0) > 1_000_000 ||
|
|
400
|
+
(result.error?.length ?? 0) > 10_000
|
|
401
|
+
)
|
|
402
|
+
throw new Error("Invalid tool output");
|
|
403
|
+
if (result.image) validateImages([result.image]);
|
|
404
|
+
signal.throwIfAborted();
|
|
405
|
+
return Response.json(result);
|
|
406
|
+
}
|
|
407
|
+
let target: string;
|
|
408
|
+
let secret: string;
|
|
409
|
+
let body: Record<string, unknown>;
|
|
410
|
+
const headers = new Headers({
|
|
411
|
+
"content-type": "application/json",
|
|
412
|
+
accept: "application/json, text/event-stream",
|
|
413
|
+
"accept-encoding": "identity",
|
|
414
|
+
});
|
|
415
|
+
if (server) {
|
|
416
|
+
const params = input.params;
|
|
417
|
+
const allowed =
|
|
418
|
+
input.method === "initialize" ||
|
|
419
|
+
input.method === "notifications/initialized" ||
|
|
420
|
+
input.method === "ping" ||
|
|
421
|
+
input.method === "tools/list" ||
|
|
422
|
+
(input.method === "tools/call" &&
|
|
423
|
+
isRecord(params) &&
|
|
424
|
+
typeof params.name === "string" &&
|
|
425
|
+
server.tools.includes(params.name) &&
|
|
426
|
+
isRecord(params.arguments));
|
|
427
|
+
if (!allowed || (startup && input.method === "tools/call"))
|
|
428
|
+
return new Response("MCP method refused", { status: 403 });
|
|
429
|
+
target = server.url;
|
|
430
|
+
secret = (await this.#options.mcp?.token()) ?? "";
|
|
431
|
+
body = {
|
|
432
|
+
jsonrpc: "2.0",
|
|
433
|
+
...(input.id === undefined ? {} : { id: input.id }),
|
|
434
|
+
method: input.method,
|
|
435
|
+
...(params === undefined ? {} : { params }),
|
|
436
|
+
};
|
|
437
|
+
for (const name of ["mcp-session-id", "mcp-protocol-version"]) {
|
|
438
|
+
const value = request.headers.get(name);
|
|
439
|
+
if (value && value.length <= 256) headers.set(name, value);
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
body = piModelInput(
|
|
443
|
+
input,
|
|
444
|
+
this.#options.model,
|
|
445
|
+
url.pathname.endsWith("/count_tokens"),
|
|
446
|
+
this.#options.maxOutputTokens ?? 128_000,
|
|
447
|
+
turn.context.thinking,
|
|
448
|
+
);
|
|
449
|
+
target = `${this.#options.upstream ?? "https://api.anthropic.com"}${url.pathname.slice("/anthropic".length)}${url.search}`;
|
|
450
|
+
secret = await this.#options.oauthToken();
|
|
451
|
+
for (const name of HEADERS) {
|
|
452
|
+
const value = request.headers.get(name);
|
|
453
|
+
if (value && value.length <= 4096) headers.set(name, value);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (!secret) throw new Error("Credential unavailable");
|
|
457
|
+
headers.set("authorization", `Bearer ${secret}`);
|
|
458
|
+
const upstream = await (this.#options.fetchImpl ?? fetch)(target, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
redirect: "error",
|
|
461
|
+
headers,
|
|
462
|
+
body: JSON.stringify(body),
|
|
463
|
+
signal,
|
|
464
|
+
});
|
|
465
|
+
// Errors keep their status, body and back-off headers so the worker can recognize an
|
|
466
|
+
// oversized context, a rate limit or an expired MCP session; the same scans apply.
|
|
467
|
+
const outgoing = new Headers();
|
|
468
|
+
for (const [name, value] of upstream.headers) {
|
|
469
|
+
if (
|
|
470
|
+
passthroughHeader(name) &&
|
|
471
|
+
value.length <= 256 &&
|
|
472
|
+
!value.includes(secret)
|
|
473
|
+
)
|
|
474
|
+
outgoing.set(name, value);
|
|
475
|
+
}
|
|
476
|
+
if (server) {
|
|
477
|
+
const id = upstream.headers.get("mcp-session-id");
|
|
478
|
+
if (id && !id.includes(secret) && id.length <= 256)
|
|
479
|
+
outgoing.set("mcp-session-id", id);
|
|
480
|
+
}
|
|
481
|
+
if (upstream.status < 200 || upstream.status > 599)
|
|
482
|
+
return new Response("Upstream refused", { status: 502 });
|
|
483
|
+
return new Response(
|
|
484
|
+
upstream.status === 204 || upstream.status === 205
|
|
485
|
+
? null
|
|
486
|
+
: streamBounded(
|
|
487
|
+
upstream,
|
|
488
|
+
signal,
|
|
489
|
+
PI_MEDIA_LIMITS.totalFileBytes,
|
|
490
|
+
secret,
|
|
491
|
+
),
|
|
492
|
+
{ headers: outgoing, status: upstream.status },
|
|
493
|
+
);
|
|
494
|
+
} catch {
|
|
495
|
+
return new Response("Broker call failed", { status: 502 });
|
|
496
|
+
} finally {
|
|
497
|
+
turn.active--;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|