pi-roundtable-sandbox 0.7.9 → 0.7.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/CHANGELOG.md +12 -0
- package/README.md +84 -2
- package/package.json +2 -2
- package/src/container-driver.ts +43 -12
- package/src/index.ts +22 -0
- package/src/pi-broker.ts +353 -6
- package/src/pi-container-driver.ts +74 -3
- package/src/pi-protocol.ts +118 -0
- package/src/pi-runtime.ts +67 -10
- package/src/precheck-runner.ts +401 -0
- package/worker/Dockerfile +1 -1
- package/worker/pi-compaction.ts +225 -0
- package/worker/pi-main.ts +96 -13
- package/worker/precheck-main.ts +147 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker session's compaction: the host's tiers, and the host's compactor reached over the broker.
|
|
3
|
+
*/
|
|
4
|
+
import type {
|
|
5
|
+
AgentSession,
|
|
6
|
+
AgentSessionEvent,
|
|
7
|
+
ExtensionFactory,
|
|
8
|
+
SessionBeforeCompactEvent,
|
|
9
|
+
SessionBeforeCompactResult,
|
|
10
|
+
SettingsManager,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
type CompactionHistory,
|
|
14
|
+
CompactionTiers,
|
|
15
|
+
compactionEngine,
|
|
16
|
+
} from "pi-roundtable/kit";
|
|
17
|
+
import type {
|
|
18
|
+
PiCompactionReport,
|
|
19
|
+
PiCompactMessage,
|
|
20
|
+
PiCompactRequest,
|
|
21
|
+
PiCompactResponse,
|
|
22
|
+
PiWorkerConfig,
|
|
23
|
+
} from "../src/pi-protocol.ts";
|
|
24
|
+
|
|
25
|
+
type CompactionEnd = Extract<AgentSessionEvent, { type: "compaction_end" }>;
|
|
26
|
+
export type WorkerLog = (msg: string, fields?: Record<string, unknown>) => void;
|
|
27
|
+
|
|
28
|
+
export interface WorkerCompactionOptions {
|
|
29
|
+
/** The session's entries, for the tiers' loop guard. */
|
|
30
|
+
history: CompactionHistory;
|
|
31
|
+
contextWindow(provider: string, id: string): number | undefined;
|
|
32
|
+
/** What the host told the worker at startup; no `compaction` means no host compactor. */
|
|
33
|
+
config: PiWorkerConfig;
|
|
34
|
+
/** The broker's Unix socket. */
|
|
35
|
+
socket: string;
|
|
36
|
+
log: WorkerLog;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The request the host's compactor reads: Pi's preparation, with the messages it keeps. */
|
|
40
|
+
export function compactRequest(
|
|
41
|
+
event: SessionBeforeCompactEvent,
|
|
42
|
+
): PiCompactRequest {
|
|
43
|
+
const { preparation } = event;
|
|
44
|
+
const start = event.branchEntries.findIndex(
|
|
45
|
+
(entry) => entry.id === preparation.firstKeptEntryId,
|
|
46
|
+
);
|
|
47
|
+
const keptMessages =
|
|
48
|
+
start < 0
|
|
49
|
+
? []
|
|
50
|
+
: event.branchEntries
|
|
51
|
+
.slice(start)
|
|
52
|
+
.flatMap((entry) =>
|
|
53
|
+
entry.type === "message" ? [entry.message] : [],
|
|
54
|
+
);
|
|
55
|
+
const { read, written, edited } = preparation.fileOps;
|
|
56
|
+
const modified = new Set([...written, ...edited]);
|
|
57
|
+
return {
|
|
58
|
+
reason: event.reason,
|
|
59
|
+
tokensBefore: preparation.tokensBefore,
|
|
60
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
61
|
+
isSplitTurn: preparation.isSplitTurn,
|
|
62
|
+
messagesToSummarize: preparation.messagesToSummarize as PiCompactMessage[],
|
|
63
|
+
turnPrefixMessages: preparation.turnPrefixMessages as PiCompactMessage[],
|
|
64
|
+
keptMessages: keptMessages as PiCompactMessage[],
|
|
65
|
+
...(preparation.previousSummary === undefined
|
|
66
|
+
? {}
|
|
67
|
+
: { previousSummary: preparation.previousSummary }),
|
|
68
|
+
...(event.customInstructions === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { customInstructions: event.customInstructions }),
|
|
71
|
+
readFiles: [...read].filter((path) => !modified.has(path)).sort(),
|
|
72
|
+
modifiedFiles: [...modified].sort(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compacts as the host's own sessions do: large windows at the soft threshold through the host's
|
|
78
|
+
* compactor, past the hard ceiling through Pi's summary, which reaches the model over the broker.
|
|
79
|
+
*/
|
|
80
|
+
export class WorkerCompaction {
|
|
81
|
+
readonly #options: WorkerCompactionOptions;
|
|
82
|
+
readonly #tiers: CompactionTiers;
|
|
83
|
+
readonly #pending = new Set<Promise<void>>();
|
|
84
|
+
|
|
85
|
+
constructor(options: WorkerCompactionOptions) {
|
|
86
|
+
this.#options = options;
|
|
87
|
+
this.#tiers = new CompactionTiers(
|
|
88
|
+
options.history,
|
|
89
|
+
options.contextWindow,
|
|
90
|
+
options.config.compaction?.engine,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pi's settings, with each model's reserve set so it compacts at its tier. */
|
|
95
|
+
settings(): SettingsManager {
|
|
96
|
+
return this.#tiers.settings();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The host compactor's handler, held back by the tiers; none when the host has no compactor. */
|
|
100
|
+
extensions(): { name: string; factory: ExtensionFactory }[] {
|
|
101
|
+
if (!this.#options.config.compaction) return [];
|
|
102
|
+
const handler: ExtensionFactory = (pi) => {
|
|
103
|
+
pi.on("session_before_compact", (event) => this.#compact(event));
|
|
104
|
+
};
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
name: "host-compaction",
|
|
108
|
+
factory: this.#tiers.wrapCompactor(handler, (bypass) => {
|
|
109
|
+
this.#options.log("compaction skips the host compactor", bypass);
|
|
110
|
+
void this.#report({ type: "bypass", ...bypass });
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Logs a finished compaction and reports it to the host, which logs it for the channel. */
|
|
117
|
+
async ended(event: CompactionEnd, session: AgentSession): Promise<void> {
|
|
118
|
+
const { result } = event;
|
|
119
|
+
const { model, settingsManager } = session;
|
|
120
|
+
const report: PiCompactionReport = result
|
|
121
|
+
? {
|
|
122
|
+
type: "end",
|
|
123
|
+
reason: event.reason,
|
|
124
|
+
aborted: event.aborted,
|
|
125
|
+
willRetry: event.willRetry,
|
|
126
|
+
engine: compactionEngine(
|
|
127
|
+
result.details,
|
|
128
|
+
this.#options.config.compaction?.engine,
|
|
129
|
+
),
|
|
130
|
+
tokensBefore: result.tokensBefore,
|
|
131
|
+
...(result.estimatedTokensAfter === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { tokensAfter: result.estimatedTokensAfter }),
|
|
134
|
+
...(model
|
|
135
|
+
? {
|
|
136
|
+
nextCompactionAt:
|
|
137
|
+
model.contextWindow -
|
|
138
|
+
settingsManager.getCompactionSettings(model).reserveTokens,
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
}
|
|
142
|
+
: {
|
|
143
|
+
type: "end",
|
|
144
|
+
reason: event.reason,
|
|
145
|
+
aborted: event.aborted,
|
|
146
|
+
willRetry: event.willRetry,
|
|
147
|
+
...(event.errorMessage === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { error: event.errorMessage.slice(0, 10_000) }),
|
|
150
|
+
};
|
|
151
|
+
const { type: _type, ...fields } = report;
|
|
152
|
+
this.#options.log(
|
|
153
|
+
result ? "conversation compacted" : "compaction failed",
|
|
154
|
+
fields,
|
|
155
|
+
);
|
|
156
|
+
await this.#report(report);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #compact(
|
|
160
|
+
event: SessionBeforeCompactEvent,
|
|
161
|
+
): Promise<SessionBeforeCompactResult | undefined> {
|
|
162
|
+
const { log } = this.#options;
|
|
163
|
+
try {
|
|
164
|
+
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
165
|
+
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
|
166
|
+
const response = await fetch("http://broker/compaction/compact", {
|
|
167
|
+
unix: this.#options.socket,
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": "application/json" },
|
|
170
|
+
body: JSON.stringify(compactRequest(event)),
|
|
171
|
+
signal: event.signal,
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
log("compaction falls back to Pi's summary", {
|
|
176
|
+
fallback: `the broker answered ${response.status}`,
|
|
177
|
+
});
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const answer = (await response.json()) as PiCompactResponse;
|
|
181
|
+
if (!answer.ok) {
|
|
182
|
+
log("compaction falls back to Pi's summary", {
|
|
183
|
+
fallback: answer.fallback,
|
|
184
|
+
});
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return { compaction: answer.compaction };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log("compaction falls back to Pi's summary", {
|
|
190
|
+
fallback: `the compact request failed: ${String(error)}`,
|
|
191
|
+
});
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Waits for the reports still on their way, so they reach the host while the turn is bound. */
|
|
197
|
+
async settled(): Promise<void> {
|
|
198
|
+
await Promise.allSettled([...this.#pending]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#report(report: PiCompactionReport): Promise<void> {
|
|
202
|
+
const sending = this.#send(report).finally(() =>
|
|
203
|
+
this.#pending.delete(sending),
|
|
204
|
+
);
|
|
205
|
+
this.#pending.add(sending);
|
|
206
|
+
return sending;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #send(report: PiCompactionReport): Promise<void> {
|
|
210
|
+
try {
|
|
211
|
+
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
212
|
+
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
|
213
|
+
const response = await fetch("http://broker/compaction/report", {
|
|
214
|
+
unix: this.#options.socket,
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "content-type": "application/json" },
|
|
217
|
+
body: JSON.stringify(report),
|
|
218
|
+
signal: AbortSignal.timeout(5000),
|
|
219
|
+
});
|
|
220
|
+
await response.body?.cancel();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.#options.log("compaction report failed", { error: String(error) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
package/worker/pi-main.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
DefaultResourceLoader,
|
|
10
10
|
ModelRuntime,
|
|
11
11
|
SessionManager,
|
|
12
|
-
SettingsManager,
|
|
13
12
|
} from "@earendil-works/pi-coding-agent";
|
|
14
13
|
import {
|
|
15
14
|
activeToolsExtension,
|
|
@@ -29,6 +28,7 @@ import {
|
|
|
29
28
|
type PiTurnRequest as PartyTurnRequest,
|
|
30
29
|
type PiTurnResponse as PartyTurnResponse,
|
|
31
30
|
PI_MEDIA_LIMITS,
|
|
31
|
+
type PiWorkerConfig,
|
|
32
32
|
safeFileName,
|
|
33
33
|
type PiTurnContext as TurnContext,
|
|
34
34
|
validatePiTurn,
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
PI_OUTBOX as WORKSPACE_OUTBOX,
|
|
37
37
|
} from "../src/pi-protocol.ts";
|
|
38
38
|
import { boundedText } from "../src/protocol.ts";
|
|
39
|
+
import { WorkerCompaction } from "./pi-compaction.ts";
|
|
39
40
|
import type { PiWorkerContent } from "./pi-content.ts";
|
|
40
41
|
import { speakerMemoryExtension } from "./pi-memory.ts";
|
|
41
42
|
import {
|
|
@@ -46,6 +47,8 @@ import {
|
|
|
46
47
|
import { boundedFile, brokerToolsExtension } from "./pi-tools.ts";
|
|
47
48
|
|
|
48
49
|
let content: PiWorkerContent;
|
|
50
|
+
/** The session's compaction, whose reports must reach the host before the turn's result. */
|
|
51
|
+
let workerCompaction: WorkerCompaction | undefined;
|
|
49
52
|
let baseTools: string[] = [];
|
|
50
53
|
const THINKING = "low";
|
|
51
54
|
const MCP_CONNECT_TIMEOUT_MS = 45_000;
|
|
@@ -94,6 +97,7 @@ function startForwarder(): void {
|
|
|
94
97
|
// pi-lens-ignore: unchecked-throwing-call -- the server builds request.url, always an absolute URL
|
|
95
98
|
const url = new URL(request.url);
|
|
96
99
|
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
100
|
+
const started = Date.now();
|
|
97
101
|
const response = await fetch(
|
|
98
102
|
`http://broker${url.pathname}${url.search}`,
|
|
99
103
|
{
|
|
@@ -105,7 +109,20 @@ function startForwarder(): void {
|
|
|
105
109
|
: undefined,
|
|
106
110
|
signal: request.signal,
|
|
107
111
|
},
|
|
108
|
-
)
|
|
112
|
+
).catch((error: unknown) => {
|
|
113
|
+
log("broker call failed", {
|
|
114
|
+
path: url.pathname,
|
|
115
|
+
latencyMs: Date.now() - started,
|
|
116
|
+
error: String(error),
|
|
117
|
+
});
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
120
|
+
if (response.status >= 400)
|
|
121
|
+
log("broker call failed", {
|
|
122
|
+
path: url.pathname,
|
|
123
|
+
status: response.status,
|
|
124
|
+
latencyMs: Date.now() - started,
|
|
125
|
+
});
|
|
109
126
|
return new Response(response.body, {
|
|
110
127
|
status: response.status,
|
|
111
128
|
statusText: response.statusText,
|
|
@@ -127,6 +144,22 @@ async function mcpServers(): Promise<McpToolsResponse["servers"]> {
|
|
|
127
144
|
return ((await response.json()) as McpToolsResponse).servers;
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
/** Whether the host compacts for this worker; an older broker without the route has no compactor. */
|
|
148
|
+
async function workerConfig(): Promise<PiWorkerConfig> {
|
|
149
|
+
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
150
|
+
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
|
151
|
+
const response = await fetch("http://broker/worker/config", {
|
|
152
|
+
unix: join(CONTAINER_RUN_DIR, BROKER_SOCKET),
|
|
153
|
+
});
|
|
154
|
+
if (response.status === 404) {
|
|
155
|
+
await response.body?.cancel();
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw new Error(`the broker answered ${response.status} for worker/config`);
|
|
160
|
+
return (await response.json()) as PiWorkerConfig;
|
|
161
|
+
}
|
|
162
|
+
|
|
130
163
|
/** pi-mcp-adapter registers tools only after its eager connection completes. */
|
|
131
164
|
async function waitForTools(
|
|
132
165
|
session: AgentSession,
|
|
@@ -142,6 +175,7 @@ async function waitForTools(
|
|
|
142
175
|
|
|
143
176
|
async function createSession(
|
|
144
177
|
servers: McpToolsResponse["servers"],
|
|
178
|
+
config: PiWorkerConfig,
|
|
145
179
|
): Promise<AgentSession> {
|
|
146
180
|
const SKILLS = content.skillsDir ? loadSkillIndex(content.skillsDir) : [];
|
|
147
181
|
const agentDir = process.env.PI_CODING_AGENT_DIR ?? "/tmp/pi-agent";
|
|
@@ -151,6 +185,19 @@ async function createSession(
|
|
|
151
185
|
const modelRuntime = await ModelRuntime.create({
|
|
152
186
|
authPath: join(agentDir, "auth.json"),
|
|
153
187
|
});
|
|
188
|
+
const sessionManager = SessionManager.continueRecent(
|
|
189
|
+
CONTAINER_WORKSPACE,
|
|
190
|
+
sessionDir,
|
|
191
|
+
);
|
|
192
|
+
const compaction = new WorkerCompaction({
|
|
193
|
+
history: sessionManager,
|
|
194
|
+
contextWindow: (provider, id) =>
|
|
195
|
+
modelRuntime.getModel(provider, id)?.contextWindow,
|
|
196
|
+
config,
|
|
197
|
+
socket: join(CONTAINER_RUN_DIR, BROKER_SOCKET),
|
|
198
|
+
log,
|
|
199
|
+
});
|
|
200
|
+
workerCompaction = compaction;
|
|
154
201
|
const resourceLoader = new DefaultResourceLoader({
|
|
155
202
|
cwd: CONTAINER_WORKSPACE,
|
|
156
203
|
agentDir,
|
|
@@ -189,6 +236,7 @@ async function createSession(
|
|
|
189
236
|
factory: speakerMemoryExtension(() => turnContext),
|
|
190
237
|
},
|
|
191
238
|
...(content.extensions?.(() => turnContext) ?? []),
|
|
239
|
+
...compaction.extensions(),
|
|
192
240
|
...(servers.length > 0
|
|
193
241
|
? [
|
|
194
242
|
{
|
|
@@ -218,11 +266,10 @@ async function createSession(
|
|
|
218
266
|
thinkingLevel: THINKING,
|
|
219
267
|
modelRuntime,
|
|
220
268
|
resourceLoader,
|
|
221
|
-
sessionManager
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
),
|
|
225
|
-
settingsManager: SettingsManager.inMemory({}),
|
|
269
|
+
sessionManager,
|
|
270
|
+
// Large windows compact at the soft threshold through the host's compactor, and through Pi's
|
|
271
|
+
// summary past the hard ceiling, as the host's own sessions do.
|
|
272
|
+
settingsManager: compaction.settings(),
|
|
226
273
|
noTools: "builtin",
|
|
227
274
|
});
|
|
228
275
|
const model = modelRuntime.getModel(content.model.provider, content.model.id);
|
|
@@ -240,6 +287,17 @@ async function createSession(
|
|
|
240
287
|
);
|
|
241
288
|
if (missing.length > 0)
|
|
242
289
|
throw new Error(`tools are not registered: ${missing.join(", ")}`);
|
|
290
|
+
session.subscribe((event) => {
|
|
291
|
+
if (event.type === "compaction_end") void compaction.ended(event, session);
|
|
292
|
+
else if (event.type === "auto_retry_start")
|
|
293
|
+
log("model call retried", {
|
|
294
|
+
attempt: event.attempt,
|
|
295
|
+
error: event.errorMessage,
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
log("compaction ready", {
|
|
299
|
+
hostCompactor: config.compaction?.engine ?? null,
|
|
300
|
+
});
|
|
243
301
|
return session;
|
|
244
302
|
}
|
|
245
303
|
|
|
@@ -262,6 +320,21 @@ async function runTurn(
|
|
|
262
320
|
const toolCalls: string[] = [];
|
|
263
321
|
const unsubscribe = session.subscribe((event) => {
|
|
264
322
|
if (event.type === "tool_execution_start") toolCalls.push(event.toolName);
|
|
323
|
+
else if (event.type === "tool_execution_end" && event.isError)
|
|
324
|
+
log("tool failed", {
|
|
325
|
+
turnId: turn.turnId,
|
|
326
|
+
tool: event.toolName,
|
|
327
|
+
error: textOf(event.result?.content ?? []).slice(0, 2000),
|
|
328
|
+
});
|
|
329
|
+
else if (
|
|
330
|
+
event.type === "message_end" &&
|
|
331
|
+
event.message.role === "assistant" &&
|
|
332
|
+
event.message.stopReason === "error"
|
|
333
|
+
)
|
|
334
|
+
log("model call failed", {
|
|
335
|
+
turnId: turn.turnId,
|
|
336
|
+
error: event.message.errorMessage,
|
|
337
|
+
});
|
|
265
338
|
});
|
|
266
339
|
try {
|
|
267
340
|
await session.prompt(
|
|
@@ -276,6 +349,7 @@ async function runTurn(
|
|
|
276
349
|
: undefined,
|
|
277
350
|
);
|
|
278
351
|
} catch (error) {
|
|
352
|
+
log("prompt failed", { turnId: turn.turnId, error: String(error) });
|
|
279
353
|
return { ok: false, error: `prompt failed: ${String(error)}` };
|
|
280
354
|
} finally {
|
|
281
355
|
clearTimeout(timer);
|
|
@@ -327,10 +401,12 @@ async function main(): Promise<void> {
|
|
|
327
401
|
|
|
328
402
|
startForwarder();
|
|
329
403
|
const servers = await mcpServers();
|
|
330
|
-
const session = await createSession(servers);
|
|
404
|
+
const session = await createSession(servers, await workerConfig());
|
|
331
405
|
|
|
332
406
|
const brokerSocket = join(CONTAINER_RUN_DIR, BROKER_SOCKET);
|
|
333
407
|
log("sandbox worker ready", { profile, tools: activeTools.length });
|
|
408
|
+
// A broker that stays unreachable is logged once, not every quarter second.
|
|
409
|
+
let pollFailing = false;
|
|
334
410
|
for (;;) {
|
|
335
411
|
try {
|
|
336
412
|
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
@@ -356,10 +432,15 @@ async function main(): Promise<void> {
|
|
|
356
432
|
await boundedText(next.body, 96 * 1024 * 1024),
|
|
357
433
|
);
|
|
358
434
|
validatePiTurn(turn);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
435
|
+
pollFailing = false;
|
|
436
|
+
const result = await runTurn(session, turn).catch((error: unknown) => {
|
|
437
|
+
log("turn output failed", {
|
|
438
|
+
turnId: turn.turnId,
|
|
439
|
+
error: String(error),
|
|
440
|
+
});
|
|
441
|
+
return { ok: false as const, error: "Worker output failed" };
|
|
442
|
+
});
|
|
443
|
+
await workerCompaction?.settled();
|
|
363
444
|
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
364
445
|
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
|
365
446
|
const sent = await fetch("http://broker/worker/result", {
|
|
@@ -370,7 +451,9 @@ async function main(): Promise<void> {
|
|
|
370
451
|
signal: AbortSignal.timeout(60_000),
|
|
371
452
|
});
|
|
372
453
|
await sent.body?.cancel();
|
|
373
|
-
} catch {
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (!pollFailing) log("broker poll failed", { error: String(error) });
|
|
456
|
+
pollFailing = true;
|
|
374
457
|
await Bun.sleep(250);
|
|
375
458
|
}
|
|
376
459
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Runs one schedule's precheck script inside its sealed container and prints its answer.
|
|
2
|
+
// The script is untrusted: the container, not this file, is the boundary.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { BROKER_PATH, boundedText, isRecord } from "../src/protocol.ts";
|
|
8
|
+
import { unixBrokerRequest } from "./transport.ts";
|
|
9
|
+
|
|
10
|
+
interface Input {
|
|
11
|
+
script: string;
|
|
12
|
+
firedAt: string;
|
|
13
|
+
timeZone: string;
|
|
14
|
+
today: string;
|
|
15
|
+
schedule: { id: number; title: string };
|
|
16
|
+
servers: { name: string; tools: string[] }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isInput(value: unknown): value is Input {
|
|
20
|
+
return (
|
|
21
|
+
isRecord(value) &&
|
|
22
|
+
typeof value.script === "string" &&
|
|
23
|
+
typeof value.firedAt === "string" &&
|
|
24
|
+
typeof value.timeZone === "string" &&
|
|
25
|
+
typeof value.today === "string" &&
|
|
26
|
+
isRecord(value.schedule) &&
|
|
27
|
+
Array.isArray(value.servers)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const REFUSALS: Record<number, string> = {
|
|
32
|
+
403: "that server or tool is not granted to this script",
|
|
33
|
+
404: "that server is not granted to this script",
|
|
34
|
+
410: "the run ended",
|
|
35
|
+
409: "the script made two MCP calls at once; await each before the next",
|
|
36
|
+
429: "the script made too many MCP calls",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The MCP calls a script makes, each through the host's broker. */
|
|
40
|
+
function mcpClient(servers: Input["servers"]) {
|
|
41
|
+
const call = async (
|
|
42
|
+
server: string,
|
|
43
|
+
tool: string,
|
|
44
|
+
args: Record<string, unknown> = {},
|
|
45
|
+
): Promise<Record<string, unknown>> => {
|
|
46
|
+
const granted = servers.find((s) => s.name === server);
|
|
47
|
+
if (!granted?.tools.includes(tool))
|
|
48
|
+
throw new Error(`${server}/${tool} is not granted to this script`);
|
|
49
|
+
const { status, body } = await unixBrokerRequest(
|
|
50
|
+
`/mcp/${server}`,
|
|
51
|
+
{
|
|
52
|
+
jsonrpc: "2.0",
|
|
53
|
+
id: 1,
|
|
54
|
+
method: "tools/call",
|
|
55
|
+
params: { name: tool, arguments: args },
|
|
56
|
+
},
|
|
57
|
+
// Tests run the worker outside a container; there the host sets only HOME.
|
|
58
|
+
process.env.PRECHECK_BROKER_SOCKET ?? BROKER_PATH,
|
|
59
|
+
);
|
|
60
|
+
if (status !== 200)
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${server}/${tool}: ${REFUSALS[status] ?? "the broker call failed"}`,
|
|
63
|
+
);
|
|
64
|
+
if (!isRecord(body)) throw new Error(`${server}/${tool}: no answer`);
|
|
65
|
+
if (isRecord(body.error))
|
|
66
|
+
throw new Error(`${server}/${tool}: ${String(body.error.message)}`);
|
|
67
|
+
const result = body.result;
|
|
68
|
+
if (!isRecord(result)) throw new Error(`${server}/${tool}: no result`);
|
|
69
|
+
if (result.isError === true)
|
|
70
|
+
throw new Error(`${server}/${tool}: ${firstText(result) ?? "failed"}`);
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
call,
|
|
75
|
+
/** The result's structured content, or its first text content parsed as JSON (or as text). */
|
|
76
|
+
async json(
|
|
77
|
+
server: string,
|
|
78
|
+
tool: string,
|
|
79
|
+
args: Record<string, unknown> = {},
|
|
80
|
+
): Promise<unknown> {
|
|
81
|
+
const result = await call(server, tool, args);
|
|
82
|
+
if (result.structuredContent !== undefined)
|
|
83
|
+
return result.structuredContent;
|
|
84
|
+
const text = firstText(result);
|
|
85
|
+
if (text === undefined) return undefined;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
} catch {
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function firstText(result: Record<string, unknown>): string | undefined {
|
|
96
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
97
|
+
const text = content.find(
|
|
98
|
+
(item): item is { text: string } =>
|
|
99
|
+
isRecord(item) && item.type === "text" && typeof item.text === "string",
|
|
100
|
+
);
|
|
101
|
+
return text?.text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function message(error: unknown): string {
|
|
105
|
+
return (error instanceof Error ? error.message : String(error)).slice(
|
|
106
|
+
0,
|
|
107
|
+
1000,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Runs the script and returns what to print; never throws. */
|
|
112
|
+
async function answer(): Promise<string> {
|
|
113
|
+
try {
|
|
114
|
+
const input: unknown = JSON.parse(
|
|
115
|
+
await boundedText(Bun.stdin.stream(), 64 * 1024),
|
|
116
|
+
);
|
|
117
|
+
if (!isInput(input)) throw new Error("invalid precheck input");
|
|
118
|
+
// stdout carries only the answer; whatever the script logs goes to stderr.
|
|
119
|
+
for (const level of ["log", "info", "debug", "warn"] as const)
|
|
120
|
+
console[level] = console.error;
|
|
121
|
+
const path = join(tmpdir(), `precheck-${randomUUID()}.mjs`);
|
|
122
|
+
await Bun.write(path, input.script);
|
|
123
|
+
let module: unknown;
|
|
124
|
+
try {
|
|
125
|
+
module = await import(path);
|
|
126
|
+
} finally {
|
|
127
|
+
await rm(path, { force: true });
|
|
128
|
+
}
|
|
129
|
+
const check = isRecord(module) ? module.default : undefined;
|
|
130
|
+
if (typeof check !== "function")
|
|
131
|
+
throw new Error("the script has no default export function");
|
|
132
|
+
const result: unknown = await check({
|
|
133
|
+
mcp: mcpClient(input.servers),
|
|
134
|
+
firedAt: new Date(input.firedAt),
|
|
135
|
+
timeZone: input.timeZone,
|
|
136
|
+
today: input.today,
|
|
137
|
+
schedule: input.schedule,
|
|
138
|
+
});
|
|
139
|
+
return JSON.stringify({ ok: true, result: result ?? null });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return JSON.stringify({ ok: false, error: message(error) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Exit once the answer is written, even if the script left timers or sockets behind.
|
|
146
|
+
await Bun.write(Bun.stdout, await answer());
|
|
147
|
+
process.exit(0);
|