pi-roundtable-sandbox 0.7.3 → 0.7.4

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4
4
+
5
+ - Let `SandboxResearchWorker` run the host's own web tools (`tools`, `scope`, `aborted`), and make `ScopedSandboxDelegator` refuse and fail with readable reasons (scrubbed) instead of generic text, with no title limit.
6
+
3
7
  ## 0.7.3
4
8
 
5
9
  - Add the opt-in Pi subscription mode (`PiSandboxRuntime`, `PiSandboxBroker`, `PiDockerContainerDriver`), worker-initiated transport, controlled `safeFetch`, bounded media in and reply files out, scoped delegation and a research worker, with the sealed default unchanged.
package/README.md CHANGED
@@ -312,14 +312,19 @@ Allowed ranges are 1 millisecond–120 seconds, 0–10 redirects and 1 byte–32
312
312
  `assertPublicUrl(url)` refuses a URL that is not credential-free HTTP(S) or does not resolve only to public addresses. Use it before handing a model-supplied URL to a third-party reader; it does not pin a later connection, so host-side fetches of that URL should still use `safeFetch`.
313
313
 
314
314
  `ScopedSandboxDelegator` fixes one declared target, channel-local report destination and host-bound author, with no owner/agent dispatcher or origin thread.
315
- Its default limit is two jobs per channel, 4,000 task characters, 200 title characters, 80,000 report characters and ten minutes.
315
+ Its default limit is two jobs per channel, 4,000 task characters, 80,000 report characters and ten minutes.
316
+ A refusal reads "title and task are required", "the task is N characters; keep it within 4000" or "this channel already has N delegated tasks running; wait for one to report back".
317
+ A failed job reports its error message scrubbed of credentials and bounded with `scrubDiagnostic`, or "the worker ran out of time" after the deadline.
316
318
  Configure `maxRunning` (1–10) and `timeoutMs` (1–1,200 seconds) explicitly when preserving an application's existing limits.
317
319
  `run(task, context)` receives only bound channel/author/signal, and `deliver(job, result)` posts through the application's background-report adapter.
318
320
  `runningChannels`, `idle` and `dispose` support host lifecycle handling; jobs are process-local and are cancelled on disposal.
319
321
  `SandboxResearchWorker` is an optional host subscription adapter with explicit `modelRuntime`, `agentDir`, `workDir`, `model`, `thinking`, `search`, and `extractFetched` options.
320
322
  It creates an unsaved Pi session with only `web_search` and controlled `fetch_content`, no shell, host memory, skills/context discovery or owner tools.
321
323
  `extractFetched` receives already bounded, pinned-fetch bytes and must not re-fetch their URL.
322
- Alternatively `fetchContent(url, signal)` replaces the built-in fetch plus `extractFetched` with a host-owned fetch-and-extract; the host is then responsible for refusing unsafe and private addresses and for bounding time and size. One of the two is required.
324
+ Alternatively `fetchContent(url, signal)` replaces the built-in fetch plus `extractFetched` with a host-owned fetch-and-extract; the host is then responsible for refusing unsafe and private addresses and for bounding time and size.
325
+ Or `tools` replaces both built-in tools with the host's own: `extensionPaths` (installed Pi extension packages, such as `pi-web-access`), `extensionFactories` (for example a guard that vets each call before it runs), the `toolNames` that stay active, and an optional `prompt`; `search`, `fetchContent` and `extractFetched` are then unused.
326
+ `scope(run)` wraps the whole session so a host can bind a fetch guard to this run, and `aborted` words a deadline stop (default "the worker ran out of time").
327
+ One of `search` with a fetch option, or `tools`, is required.
323
328
  Host search/model credentials stay in the trusted host process, and report delivery must remain in the declared guest channel.
324
329
  These hooks broaden the sealed threat model: review every adapter, apply provider spend limits and host quotas, and never substitute an unrestricted default delegation worker.
325
330
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-sandbox",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "@biomejs/biome": "2.5.15",
53
53
  "@types/bun": "1.4.2",
54
54
  "discord.js": "14.27.0",
55
- "pi-roundtable": "0.7.3",
55
+ "pi-roundtable": "0.7.4",
56
56
  "typebox": "1.3.34",
57
57
  "typescript": "7.0.2"
58
58
  }
package/src/index.ts CHANGED
@@ -86,6 +86,7 @@ export {
86
86
  } from "./plugin.ts";
87
87
  export type { SandboxReply, SandboxTurn, ToolSpec } from "./protocol.ts";
88
88
  export {
89
+ type ResearchTools,
89
90
  type SandboxResearchOptions,
90
91
  SandboxResearchWorker,
91
92
  } from "./research-worker.ts";
@@ -1,4 +1,7 @@
1
- import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionFactory,
3
+ ModelRuntime,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import {
3
6
  activeToolsExtension,
4
7
  runWorkerTask,
@@ -6,13 +9,24 @@ import {
6
9
  } from "pi-roundtable/kit";
7
10
  import { type SafeFetchResult, safeFetch } from "./safe-fetch.ts";
8
11
 
12
+ /** A host's own web tools, for example pi-web-access, in place of the built-in `web_search` and `fetch_content`. */
13
+ export interface ResearchTools {
14
+ /** Installed Pi extension packages that register the tools. */
15
+ extensionPaths?: string[];
16
+ /** Extensions the host builds itself, such as a guard that vets each call before it runs. */
17
+ extensionFactories?: { name: string; factory: ExtensionFactory }[];
18
+ /** The only tools active in the session. */
19
+ toolNames: string[];
20
+ /** Replaces the standing research prompt. */
21
+ prompt?: string;
22
+ }
9
23
  export interface SandboxResearchOptions {
10
24
  modelRuntime: ModelRuntime;
11
25
  agentDir: string;
12
26
  workDir: string;
13
27
  model: string;
14
28
  thinking: "low" | "medium" | "high" | "xhigh";
15
- search(query: string, signal: AbortSignal): Promise<string>;
29
+ search?(query: string, signal: AbortSignal): Promise<string>;
16
30
  /** Parse only these already-bounded fetched bytes; never re-fetch the URL. */
17
31
  extractFetched?(
18
32
  result: SafeFetchResult,
@@ -24,14 +38,31 @@ export interface SandboxResearchOptions {
24
38
  * (see `assertPublicUrl` and `safeFetch`) and for bounding time and size.
25
39
  */
26
40
  fetchContent?(url: string, signal: AbortSignal): Promise<string>;
41
+ /**
42
+ * The host's own web tools replace the built-in two, and `search`, `fetchContent` and
43
+ * `extractFetched` are unused. The host then vets the tools' network use itself.
44
+ */
45
+ tools?: ResearchTools;
46
+ /** Wraps the whole session, for example to scope a fetch guard to this run. */
47
+ scope?<T>(run: () => Promise<T>): Promise<T>;
48
+ /** The message of a run stopped by its deadline; default: "the worker ran out of time". */
49
+ aborted?: string;
27
50
  }
28
51
  /** Host subscription research with no built-in tools, host memory, discovery, or shell. */
29
52
  export class SandboxResearchWorker {
30
53
  constructor(readonly options: SandboxResearchOptions) {
31
- if (!options.fetchContent && !options.extractFetched)
32
- throw new Error("Research needs fetchContent or extractFetched");
54
+ if (!options.tools && !options.fetchContent && !options.extractFetched)
55
+ throw new Error("Research needs tools, fetchContent or extractFetched");
56
+ if (!options.tools && !options.search)
57
+ throw new Error("Research needs search unless it has its own tools");
33
58
  }
34
- async run(task: string, signal: AbortSignal): Promise<string> {
59
+ run(task: string, signal: AbortSignal): Promise<string> {
60
+ const scope = this.options.scope;
61
+ return scope
62
+ ? scope(() => this.#run(task, signal))
63
+ : this.#run(task, signal);
64
+ }
65
+ async #run(task: string, signal: AbortSignal): Promise<string> {
35
66
  signal.throwIfAborted();
36
67
  const {
37
68
  createAgentSession,
@@ -41,6 +72,61 @@ export class SandboxResearchWorker {
41
72
  } = await import("@earendil-works/pi-coding-agent");
42
73
  const { Type } = await import("typebox");
43
74
  const options = this.options;
75
+ const tools = options.tools as ResearchTools;
76
+ const search = options.search as NonNullable<typeof options.search>;
77
+ const builtin: { name: string; factory: ExtensionFactory }[] = [
78
+ {
79
+ name: "research-tools",
80
+ factory: (pi) => {
81
+ pi.registerTool({
82
+ name: "web_search",
83
+ label: "Search",
84
+ description: "Search public sources and return a sourced answer.",
85
+ parameters: Type.Object({
86
+ query: Type.String({ minLength: 1, maxLength: 4000 }),
87
+ }),
88
+ execute: async (_id, input, callSignal) =>
89
+ toolText(
90
+ await search(
91
+ input.query,
92
+ AbortSignal.any([
93
+ signal,
94
+ ...(callSignal ? [callSignal] : []),
95
+ ]),
96
+ ),
97
+ ),
98
+ });
99
+ pi.registerTool({
100
+ name: "fetch_content",
101
+ label: "Fetch page",
102
+ description:
103
+ "Read a public HTTP(S) page. Private addresses are refused.",
104
+ parameters: Type.Object({
105
+ url: Type.String({ maxLength: 8000 }),
106
+ }),
107
+ execute: async (_id, input, callSignal) => {
108
+ const bound = AbortSignal.any([
109
+ signal,
110
+ ...(callSignal ? [callSignal] : []),
111
+ ]);
112
+ if (options.fetchContent)
113
+ return toolText(await options.fetchContent(input.url, bound));
114
+ return toolText(
115
+ await (
116
+ options.extractFetched as NonNullable<
117
+ typeof options.extractFetched
118
+ >
119
+ )(await safeFetch(input.url, { signal: bound }), bound),
120
+ );
121
+ },
122
+ });
123
+ },
124
+ },
125
+ {
126
+ name: "research-only",
127
+ factory: activeToolsExtension(() => ["web_search", "fetch_content"]),
128
+ },
129
+ ];
44
130
  const loader = new DefaultResourceLoader({
45
131
  cwd: options.workDir,
46
132
  agentDir: options.agentDir,
@@ -52,61 +138,21 @@ export class SandboxResearchWorker {
52
138
  noPromptTemplates: true,
53
139
  noThemes: true,
54
140
  noContextFiles: true,
55
- extensionFactories: [
56
- {
57
- name: "research-tools",
58
- factory: (pi) => {
59
- pi.registerTool({
60
- name: "web_search",
61
- label: "Search",
62
- description: "Search public sources and return a sourced answer.",
63
- parameters: Type.Object({
64
- query: Type.String({ minLength: 1, maxLength: 4000 }),
65
- }),
66
- execute: async (_id, input, callSignal) =>
67
- toolText(
68
- await options.search(
69
- input.query,
70
- AbortSignal.any([
71
- signal,
72
- ...(callSignal ? [callSignal] : []),
73
- ]),
74
- ),
75
- ),
76
- });
77
- pi.registerTool({
78
- name: "fetch_content",
79
- label: "Fetch page",
80
- description:
81
- "Read a public HTTP(S) page. Private addresses are refused.",
82
- parameters: Type.Object({
83
- url: Type.String({ maxLength: 8000 }),
84
- }),
85
- execute: async (_id, input, callSignal) => {
86
- const bound = AbortSignal.any([
87
- signal,
88
- ...(callSignal ? [callSignal] : []),
89
- ]);
90
- if (options.fetchContent)
91
- return toolText(await options.fetchContent(input.url, bound));
92
- return toolText(
93
- await (
94
- options.extractFetched as NonNullable<
95
- typeof options.extractFetched
96
- >
97
- )(await safeFetch(input.url, { signal: bound }), bound),
98
- );
99
- },
100
- });
101
- },
102
- },
103
- {
104
- name: "research-only",
105
- factory: activeToolsExtension(() => ["web_search", "fetch_content"]),
106
- },
107
- ],
141
+ ...(options.tools?.extensionPaths
142
+ ? { additionalExtensionPaths: options.tools.extensionPaths }
143
+ : {}),
144
+ extensionFactories: options.tools
145
+ ? [
146
+ ...(options.tools.extensionFactories ?? []),
147
+ {
148
+ name: "research-only",
149
+ factory: activeToolsExtension(() => tools.toolNames),
150
+ },
151
+ ]
152
+ : builtin,
108
153
  appendSystemPrompt: [
109
- "You are a research worker. Research the task using public sources. Report in the task's language, self-contained, with source links; state what could not be verified. You have no host shell, owner data, memory, or agent tools.",
154
+ options.tools?.prompt ??
155
+ "You are a research worker. Research the task using public sources. Report in the task's language, self-contained, with source links; state what could not be verified. You have no host shell, owner data, memory, or agent tools.",
110
156
  ],
111
157
  });
112
158
  await loader.reload();
@@ -126,7 +172,7 @@ export class SandboxResearchWorker {
126
172
  model: options.model,
127
173
  task,
128
174
  signal,
129
- aborted: "Research deadline exceeded",
175
+ aborted: options.aborted ?? "the worker ran out of time",
130
176
  });
131
177
  }
132
178
  }
@@ -5,6 +5,9 @@ import type {
5
5
  Logger,
6
6
  } from "pi-roundtable";
7
7
  import { DelegationError } from "pi-roundtable";
8
+ import { scrubDiagnostic } from "pi-roundtable/kit";
9
+
10
+ const MAX_TASK_CHARS = 4_000;
8
11
 
9
12
  export interface ScopedDelegatorOptions {
10
13
  target: string;
@@ -53,25 +56,27 @@ export class ScopedSandboxDelegator {
53
56
  request.author.tier
54
57
  )
55
58
  throw new DelegationError("Delegation scope refused");
56
- if (
57
- !request.title.trim() ||
58
- request.title.length > 200 ||
59
- !request.task.trim() ||
60
- request.task.length > 4000
61
- )
62
- throw new DelegationError("Invalid delegation title/task");
63
- if (
64
- [...this.#jobs.values()].filter((job) => job.channel === request.channel)
65
- .length >= (this.#options.maxRunning ?? 2)
66
- )
67
- throw new DelegationError("This channel has too many running tasks");
59
+ const task = request.task.trim();
60
+ if (!request.title.trim() || !task)
61
+ throw new DelegationError("title and task are required");
62
+ if (task.length > MAX_TASK_CHARS)
63
+ throw new DelegationError(
64
+ `the task is ${task.length} characters; keep it within ${MAX_TASK_CHARS}`,
65
+ );
66
+ const busy = [...this.#jobs.values()].filter(
67
+ (job) => job.channel === request.channel,
68
+ ).length;
69
+ if (busy >= (this.#options.maxRunning ?? 2))
70
+ throw new DelegationError(
71
+ `this channel already has ${busy} delegated tasks running; wait for one to report back`,
72
+ );
68
73
  const job: DelegationJob = {
69
74
  id: this.#next++,
70
75
  channel: request.channel,
71
76
  target: this.#options.target,
72
77
  author: { id: request.author.id, name: request.author.name },
73
78
  title: request.title.trim(),
74
- task: request.task.trim(),
79
+ task,
75
80
  startedAt: new Date(),
76
81
  };
77
82
  const controller = new AbortController();
@@ -91,8 +96,14 @@ export class ScopedSandboxDelegator {
91
96
  if (typeof report !== "string" || report.length > 80_000)
92
97
  throw new Error("Research report too large");
93
98
  result = { ok: true, report };
94
- } catch {
95
- result = { ok: false, error: "Sandbox task failed" };
99
+ } catch (error) {
100
+ // The worker's own reason reaches the asking agent, scrubbed of credentials and bounded.
101
+ const reason = signal.aborted
102
+ ? "the worker ran out of time"
103
+ : scrubDiagnostic(
104
+ error instanceof Error ? error.message : String(error),
105
+ );
106
+ result = { ok: false, error: reason || "the task failed" };
96
107
  }
97
108
  await this.#options.deliver(job, result);
98
109
  })()
@@ -0,0 +1,47 @@
1
+ import {
2
+ createFauxCore,
3
+ fauxAssistantMessage,
4
+ fauxToolCall,
5
+ } from "@earendil-works/pi-ai";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { toolText } from "pi-roundtable/kit";
8
+
9
+ /** A model that calls the host's fetch tool, then a tool the host left inactive, and reports both results. */
10
+ export default function fauxResearch(pi: ExtensionAPI) {
11
+ const core = createFauxCore({ provider: "faux", models: [{ id: "worker" }] });
12
+ core.setResponses([
13
+ fauxAssistantMessage(
14
+ fauxToolCall("fetch_content", { url: "https://example.invalid/page" }),
15
+ { stopReason: "toolUse" },
16
+ ),
17
+ fauxAssistantMessage(fauxToolCall("host_secret_tool", {}), {
18
+ stopReason: "toolUse",
19
+ }),
20
+ (context) =>
21
+ fauxAssistantMessage(
22
+ `results=${JSON.stringify(context.messages.filter((message) => message.role === "toolResult"))}`,
23
+ ),
24
+ ]);
25
+ for (const name of ["web_search", "fetch_content", "get_search_content"])
26
+ pi.registerTool({
27
+ name,
28
+ label: name,
29
+ description: `host ${name}`,
30
+ parameters: { type: "object", properties: {} } as never,
31
+ execute: async () => toolText(`HOST ${name} ran`),
32
+ });
33
+ pi.registerTool({
34
+ name: "host_secret_tool",
35
+ label: "secret",
36
+ description: "must stay inactive",
37
+ parameters: { type: "object", properties: {} } as never,
38
+ execute: async () => toolText("no"),
39
+ });
40
+ pi.registerProvider("faux", {
41
+ api: core.api,
42
+ apiKey: "offline",
43
+ baseUrl: "http://faux.invalid",
44
+ streamSimple: core.streamSimple,
45
+ models: core.models,
46
+ });
47
+ }