@coreplane/switchboard 1.209.0 → 1.210.0
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/dist/assets/deploy/cloudflare-resident/worker.ts +37 -9
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +22 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +11 -0
- package/dist/assets/src/execution/binaryRead.ts +58 -0
- package/dist/cli.js +174 -6
- package/package.json +1 -1
|
@@ -95,6 +95,15 @@ import {
|
|
|
95
95
|
type ThreadDepsMechanism,
|
|
96
96
|
} from "../../src/execution/residentDepCache.js";
|
|
97
97
|
import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
|
|
98
|
+
import {
|
|
99
|
+
base64ByteLength,
|
|
100
|
+
MAX_READ_BASE64_CHARS,
|
|
101
|
+
MAX_READ_BYTES,
|
|
102
|
+
readCommandFor,
|
|
103
|
+
readEncodingOf,
|
|
104
|
+
type Base64ReadAnswer,
|
|
105
|
+
type ReadEncoding,
|
|
106
|
+
} from "../../src/execution/binaryRead.js";
|
|
98
107
|
import {
|
|
99
108
|
capBytesFor,
|
|
100
109
|
capWrappedCommand,
|
|
@@ -5170,36 +5179,53 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5170
5179
|
}
|
|
5171
5180
|
|
|
5172
5181
|
/** POST /read: cat the file AS THE THREAD USER — the OS layer (not just
|
|
5173
|
-
* the prefix check) is what confines a symlink pointing outside.
|
|
5174
|
-
|
|
5175
|
-
|
|
5182
|
+
* the prefix check) is what confines a symlink pointing outside. A
|
|
5183
|
+
* `base64` read (src/execution/binaryRead.ts) runs `base64 -w0` instead,
|
|
5184
|
+
* under the binary cap; an overflow is the named refusal, never a slice
|
|
5185
|
+
* of the encoding. */
|
|
5186
|
+
async readThreadFile(
|
|
5187
|
+
threadKey: string,
|
|
5188
|
+
path: string,
|
|
5189
|
+
encoding: ReadEncoding = "utf8",
|
|
5190
|
+
): Promise<{ content: string; truncated: boolean } | Base64ReadAnswer | ThreadErr> {
|
|
5191
|
+
return this.withThreadBusy(threadKey, () => this.readThreadFileImpl(threadKey, path, encoding));
|
|
5176
5192
|
}
|
|
5177
5193
|
|
|
5178
5194
|
private async readThreadFileImpl(
|
|
5179
5195
|
threadKey: string,
|
|
5180
5196
|
path: string,
|
|
5181
|
-
|
|
5197
|
+
encoding: ReadEncoding,
|
|
5198
|
+
): Promise<{ content: string; truncated: boolean } | Base64ReadAnswer | ThreadErr> {
|
|
5182
5199
|
const pre = await this.threadPreflight(threadKey);
|
|
5183
5200
|
if ("error" in pre) return pre;
|
|
5184
5201
|
const resolved = confineThreadPath(pre.binding.worktreePath, path);
|
|
5185
5202
|
if (!resolved)
|
|
5186
5203
|
return { error: `path-escape: ${JSON.stringify(path)} does not stay inside the thread worktree`, status: 400 };
|
|
5204
|
+
const cap = encoding === "base64" ? MAX_READ_BASE64_CHARS : READ_CONTENT_CAP;
|
|
5187
5205
|
let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
|
|
5188
5206
|
try {
|
|
5189
5207
|
r = await this.threadRun(
|
|
5190
5208
|
pre.binding.user,
|
|
5191
5209
|
pre.binding.worktreePath,
|
|
5192
|
-
|
|
5210
|
+
readCommandFor(encoding, resolved),
|
|
5193
5211
|
DEFAULT_EXEC_TIMEOUT_MS,
|
|
5194
|
-
capBytesFor(
|
|
5212
|
+
capBytesFor(cap),
|
|
5195
5213
|
);
|
|
5196
5214
|
} catch (err) {
|
|
5197
5215
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
5198
5216
|
throw err;
|
|
5199
5217
|
}
|
|
5200
5218
|
if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
|
|
5201
|
-
const truncated = r.stdout.length >
|
|
5202
|
-
|
|
5219
|
+
const truncated = r.stdout.length > cap;
|
|
5220
|
+
if (encoding === "base64") {
|
|
5221
|
+
// Padding hides up to two bytes inside the cap's char count, so the
|
|
5222
|
+
// decoded size is checked too — the cap is bytes, not characters.
|
|
5223
|
+
const content = r.stdout.trimEnd();
|
|
5224
|
+
return truncated || base64ByteLength(content) > MAX_READ_BYTES
|
|
5225
|
+
? { encoding, tooLarge: true }
|
|
5226
|
+
: { encoding, content };
|
|
5227
|
+
}
|
|
5228
|
+
return { content: truncated ? r.stdout.slice(0, cap) : r.stdout, truncated };
|
|
5203
5229
|
}
|
|
5204
5230
|
|
|
5205
5231
|
/** POST /write: content travels via the SDK file API into the thread's
|
|
@@ -7186,7 +7212,9 @@ async function handleRead(env: Env, body: Record<string, unknown>): Promise<Resp
|
|
|
7186
7212
|
if (ctx instanceof Response) return ctx;
|
|
7187
7213
|
if (typeof body.path !== "string")
|
|
7188
7214
|
return json({ error: "path must be a string relative to the thread worktree" }, 400);
|
|
7189
|
-
const
|
|
7215
|
+
const encoding = readEncodingOf(body);
|
|
7216
|
+
if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
|
|
7217
|
+
const result = await ctx.stub.readThreadFile(ctx.threadKey, body.path, encoding);
|
|
7190
7218
|
if ("error" in result) return threadErrResponse(result);
|
|
7191
7219
|
return json(result);
|
|
7192
7220
|
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// first deploy; the SDK is young and its surface may shift.
|
|
19
19
|
import { getSandbox, Sandbox, type ExecOptions, type ExecResult } from "@cloudflare/sandbox";
|
|
20
20
|
import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
|
|
21
|
+
import {
|
|
22
|
+
base64ByteLength,
|
|
23
|
+
MAX_READ_BYTES,
|
|
24
|
+
readEncodingOf,
|
|
25
|
+
type Base64ReadAnswer,
|
|
26
|
+
} from "../../src/execution/binaryRead.js";
|
|
21
27
|
import {
|
|
22
28
|
EXEC_KEEPALIVE_INTERVAL_MS,
|
|
23
29
|
SANDBOX_SLEEP_AFTER,
|
|
@@ -265,7 +271,22 @@ export default {
|
|
|
265
271
|
);
|
|
266
272
|
}
|
|
267
273
|
case "/read": {
|
|
268
|
-
|
|
274
|
+
// `encoding: "base64"` is a binary read (src/execution/binaryRead.ts):
|
|
275
|
+
// the SDK encodes the bytes, and a file over the cap is refused by
|
|
276
|
+
// name inside a 200 — the client counts a non-2xx as a sick Worker.
|
|
277
|
+
const encoding = readEncodingOf(body);
|
|
278
|
+
if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
|
|
279
|
+
const path = abs(String(body.path ?? ""));
|
|
280
|
+
if (encoding === "base64") {
|
|
281
|
+
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path, { encoding: "base64" }));
|
|
282
|
+
const content = typeof file === "string" ? file : (file?.content ?? "");
|
|
283
|
+
const answer: Base64ReadAnswer =
|
|
284
|
+
base64ByteLength(content) > MAX_READ_BYTES
|
|
285
|
+
? { encoding: "base64", tooLarge: true }
|
|
286
|
+
: { encoding: "base64", content };
|
|
287
|
+
return json(answer);
|
|
288
|
+
}
|
|
289
|
+
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path));
|
|
269
290
|
return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
|
|
270
291
|
}
|
|
271
292
|
case "/write": {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.210.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.210.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -18999,7 +18999,7 @@
|
|
|
18999
18999
|
},
|
|
19000
19000
|
"packages/switchboard": {
|
|
19001
19001
|
"name": "@coreplane/switchboard",
|
|
19002
|
-
"version": "1.
|
|
19002
|
+
"version": "1.210.0",
|
|
19003
19003
|
"license": "Apache-2.0",
|
|
19004
19004
|
"dependencies": {
|
|
19005
19005
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.210.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -170,6 +170,13 @@ const IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (nativ
|
|
|
170
170
|
const SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
|
|
171
171
|
const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun — and no Docker.`;
|
|
172
172
|
|
|
173
|
+
// Both coding prompts carry this verbatim (docs/reference/specs/agent-coding.md
|
|
174
|
+
// item 10): the one way a run's screenshot reaches the person. Said once so
|
|
175
|
+
// the sandbox and resident variants cannot drift on it.
|
|
176
|
+
const SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording — it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
|
|
177
|
+
SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request — commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too — unless the request names one destination. Never attach a subset and link the rest.
|
|
178
|
+
Text stays in your message; do not attach what you can say.`;
|
|
179
|
+
|
|
173
180
|
const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
174
181
|
|
|
175
182
|
You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
|
|
@@ -197,6 +204,8 @@ ${UNIT_HANDOFF}
|
|
|
197
204
|
|
|
198
205
|
${PR_DESCRIPTION_TEMPLATE}
|
|
199
206
|
|
|
207
|
+
${SHOW_FILES}
|
|
208
|
+
|
|
200
209
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
201
210
|
|
|
202
211
|
If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
|
|
@@ -235,6 +244,8 @@ ${UNIT_HANDOFF}
|
|
|
235
244
|
|
|
236
245
|
${PR_DESCRIPTION_TEMPLATE}
|
|
237
246
|
|
|
247
|
+
${SHOW_FILES}
|
|
248
|
+
|
|
238
249
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
239
250
|
|
|
240
251
|
Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// A binary read on the Executor seam (docs/reference/specs/execution.md item 19):
|
|
2
|
+
// the whole file as bytes, for a tool that hands a workspace artifact — a
|
|
3
|
+
// screenshot, a PDF — to somewhere that needs the bytes, not a text view. Both
|
|
4
|
+
// remote executors ask their Worker's `/read` route for `encoding: "base64"`;
|
|
5
|
+
// the Worker answers `{ content, encoding: "base64" }`. This module is the
|
|
6
|
+
// contract both ends share: the caps, the request/answer shape, the resident's
|
|
7
|
+
// read command. It is bundled into the Workers too, so it stays free of Node
|
|
8
|
+
// imports.
|
|
9
|
+
|
|
10
|
+
/** The most bytes one `readBytes` hands over. A binary cannot be truncated
|
|
11
|
+
* the way text output is, so a larger file is refused by name, never trimmed.
|
|
12
|
+
* 10 MiB covers a full-page 2× screenshot or a PDF several times over while
|
|
13
|
+
* keeping one read well inside a Worker isolate's memory. */
|
|
14
|
+
export const MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** The base64 length a `MAX_READ_BYTES` file encodes to (four chars per three
|
|
17
|
+
* bytes, padded) — the Worker-side output cap for a base64 read. */
|
|
18
|
+
export const MAX_READ_BASE64_CHARS = Math.ceil(MAX_READ_BYTES / 3) * 4;
|
|
19
|
+
|
|
20
|
+
export type ReadEncoding = "utf8" | "base64";
|
|
21
|
+
|
|
22
|
+
/** The encoding a `/read` body asks for. Absent is the body every pre-binary
|
|
23
|
+
* client sends — a text read, exactly as before; `"base64"` asks for bytes;
|
|
24
|
+
* anything else is refused by name rather than silently read as text. */
|
|
25
|
+
export function readEncodingOf(body: Record<string, unknown>): ReadEncoding | { error: string } {
|
|
26
|
+
if (body.encoding === undefined) return "utf8";
|
|
27
|
+
if (body.encoding === "base64") return "base64";
|
|
28
|
+
return { error: `encoding must be "base64" or absent, got ${JSON.stringify(body.encoding)}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The command a resident runs for a read of an already-confined path: `cat`
|
|
32
|
+
* for text; `base64 -w0` for bytes — one unwrapped line, so the Durable
|
|
33
|
+
* Object's character cap slices a plain string and nothing else. */
|
|
34
|
+
export function readCommandFor(encoding: ReadEncoding, resolvedPath: string): string {
|
|
35
|
+
return encoding === "base64" ? `base64 -w0 -- ${resolvedPath}` : `cat -- ${resolvedPath}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How many bytes a base64 string decodes to, padding discounted. */
|
|
39
|
+
export function base64ByteLength(b64: string): number {
|
|
40
|
+
const trimmed = b64.replace(/\s+/g, "");
|
|
41
|
+
if (trimmed.length === 0) return 0;
|
|
42
|
+
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0;
|
|
43
|
+
return Math.floor((trimmed.length * 3) / 4) - padding;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Worker's answer to a base64 read: the bytes, or the named refusal of a
|
|
47
|
+
* file over the cap — HTTP 200 either way. The clients classify a non-2xx or
|
|
48
|
+
* an in-body `error` as a sick Worker (fail-fast counts it); a large file is
|
|
49
|
+
* the model's mistake, not infrastructure, so it travels as a plain field. */
|
|
50
|
+
export type Base64ReadAnswer = { encoding: "base64"; content: string } | { encoding: "base64"; tooLarge: true };
|
|
51
|
+
|
|
52
|
+
/** One message for a file over the cap, for every implementation. `bytes` is
|
|
53
|
+
* the size when the reader could measure it; a resident sees only that its
|
|
54
|
+
* capped base64 stream overflowed. */
|
|
55
|
+
export function tooLargeMessage(path: string, bytes?: number): string {
|
|
56
|
+
const size = bytes === undefined ? "" : ` (${bytes} bytes)`;
|
|
57
|
+
return `${path}${size} is over the ${MAX_READ_BYTES}-byte cap of a binary read`;
|
|
58
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1573,7 +1573,7 @@ function getAgent(name) {
|
|
|
1573
1573
|
}
|
|
1574
1574
|
return a;
|
|
1575
1575
|
}
|
|
1576
|
-
var MACHINE_CLASSES, IDENTITIES, RUNAWAY_TURNS_PER_MINUTE, PR_DESCRIPTION_TEMPLATE, NEVER_MERGE, CONTRACT_HEADINGS_LIST, UNIT_CONTRACT, UNIT_HANDOFF, IMAGE_TOOLCHAIN, SANDBOX_TOOLCHAIN, RESIDENT_TOOLCHAIN, CODING_SYSTEM, CODING_SYSTEM_RESIDENT, REVIEW_VERDICT_INSTRUCTION, REVIEW_SPEC_CHECK, REVIEW_UNIT_CONTRACT, REVIEW_WHOLE_CHANGE, REVIEW_SYSTEM, REVIEW_SYSTEM_RESIDENT, RESEARCH_SYSTEM, GENERAL_SYSTEM, EXPLORE_SYSTEM, CONDUCTOR_SYSTEM, AGENTS;
|
|
1576
|
+
var MACHINE_CLASSES, IDENTITIES, RUNAWAY_TURNS_PER_MINUTE, PR_DESCRIPTION_TEMPLATE, NEVER_MERGE, CONTRACT_HEADINGS_LIST, UNIT_CONTRACT, UNIT_HANDOFF, IMAGE_TOOLCHAIN, SANDBOX_TOOLCHAIN, RESIDENT_TOOLCHAIN, SHOW_FILES, CODING_SYSTEM, CODING_SYSTEM_RESIDENT, REVIEW_VERDICT_INSTRUCTION, REVIEW_SPEC_CHECK, REVIEW_UNIT_CONTRACT, REVIEW_WHOLE_CHANGE, REVIEW_SYSTEM, REVIEW_SYSTEM_RESIDENT, RESEARCH_SYSTEM, GENERAL_SYSTEM, EXPLORE_SYSTEM, CONDUCTOR_SYSTEM, AGENTS;
|
|
1577
1577
|
var init_registry = __esm({
|
|
1578
1578
|
"../../src/agents/registry.ts"() {
|
|
1579
1579
|
"use strict";
|
|
@@ -1598,6 +1598,9 @@ EVERY PR includes one that already exists when you push \u2014 opened by a perso
|
|
|
1598
1598
|
IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (native modules build); ffmpeg (frames out of a video \u2014 \`ffmpeg -i in.mp4 -vf fps=1 f_%03d.png\` \u2014 and video out of frames or a recording); and a headless Chromium through Playwright \u2014 \`playwright screenshot <url> out.png\`, \`playwright pdf <url> out.pdf\`, or \`require('playwright')\` for a scripted page and \`recordVideo\``;
|
|
1599
1599
|
SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
|
|
1600
1600
|
RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun \u2014 and no Docker.`;
|
|
1601
|
+
SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording \u2014 it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
|
|
1602
|
+
SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request \u2014 commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too \u2014 unless the request names one destination. Never attach a subset and link the rest.
|
|
1603
|
+
Text stays in your message; do not attach what you can say.`;
|
|
1601
1604
|
CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
1602
1605
|
|
|
1603
1606
|
You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
|
|
@@ -1625,6 +1628,8 @@ ${UNIT_HANDOFF}
|
|
|
1625
1628
|
|
|
1626
1629
|
${PR_DESCRIPTION_TEMPLATE}
|
|
1627
1630
|
|
|
1631
|
+
${SHOW_FILES}
|
|
1632
|
+
|
|
1628
1633
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (\u25CB pending items), then update it whenever an item starts (\u2731) or finishes (\u2713). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item \u2713 only after it has actually happened \u2014 never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
1629
1634
|
|
|
1630
1635
|
If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
|
|
@@ -1657,6 +1662,8 @@ ${UNIT_HANDOFF}
|
|
|
1657
1662
|
|
|
1658
1663
|
${PR_DESCRIPTION_TEMPLATE}
|
|
1659
1664
|
|
|
1665
|
+
${SHOW_FILES}
|
|
1666
|
+
|
|
1660
1667
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (\u25CB pending items), then update it whenever an item starts (\u2731) or finishes (\u2713). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item \u2713 only after it has actually happened \u2014 never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
1661
1668
|
|
|
1662
1669
|
Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
|
|
@@ -7849,9 +7856,23 @@ var init_host3 = __esm({
|
|
|
7849
7856
|
}
|
|
7850
7857
|
});
|
|
7851
7858
|
|
|
7859
|
+
// ../../src/execution/binaryRead.ts
|
|
7860
|
+
function tooLargeMessage(path, bytes) {
|
|
7861
|
+
const size = bytes === void 0 ? "" : ` (${bytes} bytes)`;
|
|
7862
|
+
return `${path}${size} is over the ${MAX_READ_BYTES}-byte cap of a binary read`;
|
|
7863
|
+
}
|
|
7864
|
+
var MAX_READ_BYTES, MAX_READ_BASE64_CHARS;
|
|
7865
|
+
var init_binaryRead = __esm({
|
|
7866
|
+
"../../src/execution/binaryRead.ts"() {
|
|
7867
|
+
"use strict";
|
|
7868
|
+
MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
7869
|
+
MAX_READ_BASE64_CHARS = Math.ceil(MAX_READ_BYTES / 3) * 4;
|
|
7870
|
+
}
|
|
7871
|
+
});
|
|
7872
|
+
|
|
7852
7873
|
// ../../src/execution/executor.ts
|
|
7853
7874
|
import { execFile } from "node:child_process";
|
|
7854
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
7875
|
+
import { closeSync, existsSync as existsSync8, fstatSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
7855
7876
|
import { dirname as dirname6, resolve as resolve6 } from "node:path";
|
|
7856
7877
|
function execDeadline(timeoutMs, signal) {
|
|
7857
7878
|
const deadline = new AbortController();
|
|
@@ -7869,6 +7890,15 @@ function truncate(s) {
|
|
|
7869
7890
|
return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `
|
|
7870
7891
|
...[truncated ${s.length - MAX_OUTPUT} chars]` : s;
|
|
7871
7892
|
}
|
|
7893
|
+
function decodeBase64Read(answer, at) {
|
|
7894
|
+
if (answer.encoding !== "base64") {
|
|
7895
|
+
throw new Error(
|
|
7896
|
+
`${at.where}: the Worker answered a text read to a request for bytes \u2014 it predates binary reads; redeploy it`
|
|
7897
|
+
);
|
|
7898
|
+
}
|
|
7899
|
+
if (answer.tooLarge === true) throw new Error(tooLargeMessage(at.path));
|
|
7900
|
+
return new Uint8Array(Buffer.from(typeof answer.content === "string" ? answer.content : "", "base64"));
|
|
7901
|
+
}
|
|
7872
7902
|
async function runLocalCommand(command2, cwd) {
|
|
7873
7903
|
const r = await runBash(command2, cwd);
|
|
7874
7904
|
const output = [r.stdout, r.stderr].filter(Boolean).join("\n--- stderr ---\n");
|
|
@@ -7902,6 +7932,7 @@ var init_executor = __esm({
|
|
|
7902
7932
|
"../../src/execution/executor.ts"() {
|
|
7903
7933
|
"use strict";
|
|
7904
7934
|
init_bashTimeout();
|
|
7935
|
+
init_binaryRead();
|
|
7905
7936
|
init_clock();
|
|
7906
7937
|
init_bashTimeout();
|
|
7907
7938
|
ExecInfraError = class extends Error {
|
|
@@ -7921,6 +7952,8 @@ var init_executor = __esm({
|
|
|
7921
7952
|
ExecHealthTracker = class {
|
|
7922
7953
|
constructor(inner) {
|
|
7923
7954
|
this.inner = inner;
|
|
7955
|
+
const innerReadBytes = inner.readBytes?.bind(inner);
|
|
7956
|
+
if (innerReadBytes) this.readBytes = (path, opts) => this.track(() => innerReadBytes(path, opts));
|
|
7924
7957
|
}
|
|
7925
7958
|
inner;
|
|
7926
7959
|
consecutiveInfraFailures = 0;
|
|
@@ -7928,6 +7961,9 @@ var init_executor = __esm({
|
|
|
7928
7961
|
* evidence the runner's abort diagnosis quotes instead of guessing a cause.
|
|
7929
7962
|
* Cleared by a successful op along with the count. */
|
|
7930
7963
|
lastInfraError;
|
|
7964
|
+
/** Present exactly when the inner executor reads bytes — a decorator that
|
|
7965
|
+
* always offered it would promise what the transport cannot do. */
|
|
7966
|
+
readBytes;
|
|
7931
7967
|
async track(op) {
|
|
7932
7968
|
try {
|
|
7933
7969
|
const out = await op();
|
|
@@ -7982,6 +8018,18 @@ ${parts}`);
|
|
|
7982
8018
|
async readFile(path) {
|
|
7983
8019
|
return truncate(readFileSync9(this.confine(path), "utf8"));
|
|
7984
8020
|
}
|
|
8021
|
+
/** The size is checked on the open handle and the bytes read from the same
|
|
8022
|
+
* handle, so a file that grows between the two calls cannot slip past the cap. */
|
|
8023
|
+
async readBytes(path) {
|
|
8024
|
+
const fd = openSync(this.confine(path), "r");
|
|
8025
|
+
try {
|
|
8026
|
+
const size = fstatSync(fd).size;
|
|
8027
|
+
if (size > MAX_READ_BYTES) throw new Error(tooLargeMessage(path, size));
|
|
8028
|
+
return new Uint8Array(readFileSync9(fd));
|
|
8029
|
+
} finally {
|
|
8030
|
+
closeSync(fd);
|
|
8031
|
+
}
|
|
8032
|
+
}
|
|
7985
8033
|
async writeFile(path, content) {
|
|
7986
8034
|
const abs = this.confine(path);
|
|
7987
8035
|
mkdirSync5(dirname6(abs), { recursive: true });
|
|
@@ -8476,6 +8524,13 @@ ${parts}`);
|
|
|
8476
8524
|
const r = await this.call("/read", { path }, void 0, void 0, opts?.span);
|
|
8477
8525
|
return truncate(String(r.content ?? ""));
|
|
8478
8526
|
}
|
|
8527
|
+
/** The same `/read` route asked for `encoding: "base64"` (src/execution/binaryRead.ts);
|
|
8528
|
+
* the Worker refuses an over-cap file by name, and one that predates
|
|
8529
|
+
* binary reads answers text, which `decodeBase64Read` names instead of decoding. */
|
|
8530
|
+
async readBytes(path, opts) {
|
|
8531
|
+
const r = await this.call("/read", { path, encoding: "base64" }, void 0, void 0, opts?.span);
|
|
8532
|
+
return decodeBase64Read(r, { where: "sandbox worker /read", path });
|
|
8533
|
+
}
|
|
8479
8534
|
async writeFile(path, content, opts) {
|
|
8480
8535
|
await this.call("/write", { path, content }, void 0, void 0, opts?.span);
|
|
8481
8536
|
return `Wrote ${path}`;
|
|
@@ -9045,6 +9100,26 @@ ${parts}`);
|
|
|
9045
9100
|
}
|
|
9046
9101
|
return truncate(String(data.content ?? ""));
|
|
9047
9102
|
}
|
|
9103
|
+
/** The same `/read` route asked for `encoding: "base64"` (src/execution/binaryRead.ts),
|
|
9104
|
+
* with the same re-attach on an evicted worktree; a missing file is the
|
|
9105
|
+
* route's 404 as for `readFile`, an over-cap file and a Worker that predates
|
|
9106
|
+
* binary reads are `decodeBase64Read`'s plain errors. */
|
|
9107
|
+
async readBytes(path, opts) {
|
|
9108
|
+
const { status: status3, data } = await this.opWithReattach(
|
|
9109
|
+
"/read",
|
|
9110
|
+
{ path, encoding: "base64" },
|
|
9111
|
+
void 0,
|
|
9112
|
+
void 0,
|
|
9113
|
+
opts?.span
|
|
9114
|
+
);
|
|
9115
|
+
if (status3 !== 200) {
|
|
9116
|
+
throw classifyError(new ExecInfraError(`resident /read: ${String(data.error ?? `HTTP ${status3}`)}`), {
|
|
9117
|
+
kind: "http",
|
|
9118
|
+
code: String(status3)
|
|
9119
|
+
});
|
|
9120
|
+
}
|
|
9121
|
+
return decodeBase64Read(data, { where: "resident /read", path });
|
|
9122
|
+
}
|
|
9048
9123
|
async writeFile(path, content, opts) {
|
|
9049
9124
|
const { status: status3, data } = await this.opWithReattach("/write", { path, content }, void 0, void 0, opts?.span);
|
|
9050
9125
|
if (status3 !== 200) {
|
|
@@ -23791,12 +23866,16 @@ var init_tracingExecutor = __esm({
|
|
|
23791
23866
|
if (innerRelease) this.release = (mode) => this.timed("exec.release", (s) => innerRelease(mode, { span: s }));
|
|
23792
23867
|
const innerMoveTo = inner.moveTo?.bind(inner);
|
|
23793
23868
|
if (innerMoveTo) this.moveTo = (sha) => this.timed("exec.move_to", (s) => innerMoveTo(sha, { span: s }));
|
|
23869
|
+
const innerReadBytes = inner.readBytes?.bind(inner);
|
|
23870
|
+
if (innerReadBytes)
|
|
23871
|
+
this.readBytes = (path) => this.timed("exec.read_bytes", (s) => innerReadBytes(path, { span: s }));
|
|
23794
23872
|
}
|
|
23795
23873
|
inner;
|
|
23796
23874
|
span;
|
|
23797
23875
|
backend;
|
|
23798
23876
|
release;
|
|
23799
23877
|
moveTo;
|
|
23878
|
+
readBytes;
|
|
23800
23879
|
/** Each op under its own `exec.*` span, handed to the inner executor as
|
|
23801
23880
|
* `opts.span` so its HTTP calls become `http.client` children (item 21). */
|
|
23802
23881
|
timed(name, fn, extra = {}) {
|
|
@@ -24796,6 +24875,60 @@ ${skill.body}${footer}`;
|
|
|
24796
24875
|
}
|
|
24797
24876
|
});
|
|
24798
24877
|
|
|
24878
|
+
// ../../src/tools/attach.ts
|
|
24879
|
+
function fileNameOf2(path) {
|
|
24880
|
+
const segments2 = path.split("/").filter(Boolean);
|
|
24881
|
+
return segments2[segments2.length - 1] ?? path;
|
|
24882
|
+
}
|
|
24883
|
+
var attachFileTool;
|
|
24884
|
+
var init_attach = __esm({
|
|
24885
|
+
"../../src/tools/attach.ts"() {
|
|
24886
|
+
"use strict";
|
|
24887
|
+
init_binaryRead();
|
|
24888
|
+
attachFileTool = {
|
|
24889
|
+
name: "attach_file",
|
|
24890
|
+
description: `Post a file from the workspace into the conversation so the person sees it inline \u2014 a screenshot (e.g. from \`playwright screenshot\`), a rendered PDF, a recording, a log. Use it whenever you produce an image worth showing: a link to a file is not a picture. Whole files only, up to ${MAX_READ_BYTES} bytes.`,
|
|
24891
|
+
inputSchema: {
|
|
24892
|
+
type: "object",
|
|
24893
|
+
properties: {
|
|
24894
|
+
path: { type: "string", description: "Relative path of the file in the workspace" },
|
|
24895
|
+
comment: {
|
|
24896
|
+
type: "string",
|
|
24897
|
+
description: "One line posted with the file saying what it shows (default: the file name)"
|
|
24898
|
+
}
|
|
24899
|
+
},
|
|
24900
|
+
required: ["path"]
|
|
24901
|
+
},
|
|
24902
|
+
async run(input, ctx) {
|
|
24903
|
+
const path = String(input.path ?? "").trim();
|
|
24904
|
+
if (!path) return "error: path is required";
|
|
24905
|
+
const name = fileNameOf2(path);
|
|
24906
|
+
const lead = typeof input.comment === "string" && input.comment.trim() ? input.comment.trim() : name;
|
|
24907
|
+
if (!ctx.attach) {
|
|
24908
|
+
return "attach_file is not available here: this conversation's channel takes no file uploads \u2014 link to the file instead";
|
|
24909
|
+
}
|
|
24910
|
+
const readBytes = ctx.executor.readBytes?.bind(ctx.executor);
|
|
24911
|
+
if (!readBytes) {
|
|
24912
|
+
return "attach_file is not available here: this workspace cannot hand files over \u2014 link to the file instead";
|
|
24913
|
+
}
|
|
24914
|
+
let bytes;
|
|
24915
|
+
try {
|
|
24916
|
+
bytes = await readBytes(path);
|
|
24917
|
+
} catch (err2) {
|
|
24918
|
+
return `error: could not read ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`;
|
|
24919
|
+
}
|
|
24920
|
+
if (bytes.byteLength === 0) return `error: ${path} is empty \u2014 nothing to attach`;
|
|
24921
|
+
try {
|
|
24922
|
+
await ctx.attach({ name, bytes, lead });
|
|
24923
|
+
} catch (err2) {
|
|
24924
|
+
return `error: the channel refused the upload of ${name}: ${err2 instanceof Error ? err2.message : String(err2)}`;
|
|
24925
|
+
}
|
|
24926
|
+
return `attached ${name} (${bytes.byteLength} bytes) to the conversation`;
|
|
24927
|
+
}
|
|
24928
|
+
};
|
|
24929
|
+
}
|
|
24930
|
+
});
|
|
24931
|
+
|
|
24799
24932
|
// ../../src/core/dispatch/awaitChildren.ts
|
|
24800
24933
|
function decideWait(inputs) {
|
|
24801
24934
|
const { children, now, budgetEndsAt, timeoutAt, stop, followUpPending } = inputs;
|
|
@@ -24904,6 +25037,7 @@ function watchedChild(io, on) {
|
|
|
24904
25037
|
}
|
|
24905
25038
|
};
|
|
24906
25039
|
if (io.attach) watched2.attach = (file) => io.attach(file);
|
|
25040
|
+
if (io.attachFile) watched2.attachFile = (file) => io.attachFile(file);
|
|
24907
25041
|
if (io.runFinished) watched2.runFinished = (receipt) => io.runFinished(receipt);
|
|
24908
25042
|
if (io.openThread) watched2.openThread = (lead) => io.openThread(lead);
|
|
24909
25043
|
return watched2;
|
|
@@ -25433,6 +25567,7 @@ var init_workspace = __esm({
|
|
|
25433
25567
|
init_web();
|
|
25434
25568
|
init_github();
|
|
25435
25569
|
init_skills();
|
|
25570
|
+
init_attach();
|
|
25436
25571
|
init_runs2();
|
|
25437
25572
|
bashTool = {
|
|
25438
25573
|
name: "bash",
|
|
@@ -25801,6 +25936,7 @@ ${raw.trim()}`;
|
|
|
25801
25936
|
bashTool,
|
|
25802
25937
|
readFileTool,
|
|
25803
25938
|
writeFileTool,
|
|
25939
|
+
attachFileTool,
|
|
25804
25940
|
updateStatusTool,
|
|
25805
25941
|
submitPrDescriptionTool,
|
|
25806
25942
|
submitHandoffTool,
|
|
@@ -28893,9 +29029,11 @@ async function runLoop2(deps, ctx) {
|
|
|
28893
29029
|
let runFailed = false;
|
|
28894
29030
|
let runDiagnosis;
|
|
28895
29031
|
const releaseWorkspace = (span) => round2.release({ hardStopped: run2.control.requested === "hard", ...span ? { span } : {} });
|
|
29032
|
+
const attachFile = io.attachFile?.bind(io);
|
|
28896
29033
|
const toolContext = {
|
|
28897
29034
|
executor,
|
|
28898
29035
|
reportProgress,
|
|
29036
|
+
...attachFile ? { attach: attachFile } : {},
|
|
28899
29037
|
web: webCapability(),
|
|
28900
29038
|
skills: deps.skills,
|
|
28901
29039
|
github: githubCapabilityFor(deps, msg.userId),
|
|
@@ -33616,6 +33754,20 @@ var init_slack = __esm({
|
|
|
33616
33754
|
${file.text}`));
|
|
33617
33755
|
}
|
|
33618
33756
|
}
|
|
33757
|
+
/** A run's binary artifact in the thread — a screenshot renders inline, a
|
|
33758
|
+
* PDF as a preview — through the same `files.uploadV2` with the bytes as
|
|
33759
|
+
* the file. No fallback: a text reply cannot carry bytes, so a failed upload
|
|
33760
|
+
* propagates for the caller to report. */
|
|
33761
|
+
async attachFile(file) {
|
|
33762
|
+
await this.client.files.uploadV2({
|
|
33763
|
+
channel_id: this.ev.channel,
|
|
33764
|
+
thread_ts: this.ev.threadTs,
|
|
33765
|
+
filename: file.name,
|
|
33766
|
+
title: file.name,
|
|
33767
|
+
file: Buffer.from(file.bytes),
|
|
33768
|
+
initial_comment: mdToMrkdwn(file.lead)
|
|
33769
|
+
});
|
|
33770
|
+
}
|
|
33619
33771
|
async post(mrkdwn) {
|
|
33620
33772
|
for (const chunk of chunkText(mrkdwn, SLACK_MSG_LIMIT)) {
|
|
33621
33773
|
await this.client.chat.postMessage({
|
|
@@ -37667,6 +37819,7 @@ function watched(io, on) {
|
|
|
37667
37819
|
}
|
|
37668
37820
|
};
|
|
37669
37821
|
if (io.attach) out.attach = (file) => io.attach(file);
|
|
37822
|
+
if (io.attachFile) out.attachFile = (file) => io.attachFile(file);
|
|
37670
37823
|
if (io.runFinished) out.runFinished = (receipt) => io.runFinished(receipt);
|
|
37671
37824
|
if (io.openThread) out.openThread = (lead) => io.openThread(lead);
|
|
37672
37825
|
return out;
|
|
@@ -39383,8 +39536,9 @@ init_source();
|
|
|
39383
39536
|
init_invokedAsScript();
|
|
39384
39537
|
init_secrets2();
|
|
39385
39538
|
import { Console } from "node:console";
|
|
39386
|
-
import { existsSync as existsSync14 } from "node:fs";
|
|
39387
|
-
import {
|
|
39539
|
+
import { existsSync as existsSync14, mkdtempSync, writeFileSync as writeFileSync9 } from "node:fs";
|
|
39540
|
+
import { tmpdir } from "node:os";
|
|
39541
|
+
import { basename as basename3, join as join15 } from "node:path";
|
|
39388
39542
|
var CONFIG_PATH3 = process.env.SWITCHBOARD_CONFIG ?? installationPath(OPERATOR_ROOT, "config/config.yaml");
|
|
39389
39543
|
var DATA_DIR2 = installationPath(OPERATOR_ROOT, "data");
|
|
39390
39544
|
var CLI_CALLER = { kind: "cli", id: CLI_ACTOR.id, actor: CLI_ACTOR };
|
|
@@ -39536,14 +39690,16 @@ ${cliCatalogue(commands)}`, stderr: "" };
|
|
|
39536
39690
|
}
|
|
39537
39691
|
}
|
|
39538
39692
|
var ConsoleIO = class _ConsoleIO {
|
|
39539
|
-
constructor(out = process.stdout, threadKey = "cli", prefix = "") {
|
|
39693
|
+
constructor(out = process.stdout, threadKey = "cli", prefix = "", attachments = {}) {
|
|
39540
39694
|
this.out = out;
|
|
39541
39695
|
this.threadKey = threadKey;
|
|
39542
39696
|
this.prefix = prefix;
|
|
39697
|
+
this.attachments = attachments;
|
|
39543
39698
|
}
|
|
39544
39699
|
out;
|
|
39545
39700
|
threadKey;
|
|
39546
39701
|
prefix;
|
|
39702
|
+
attachments;
|
|
39547
39703
|
/** The receipt of the run this request started, once it finished — undefined
|
|
39548
39704
|
* before that, and forever when no run was started (a config reply such as
|
|
39549
39705
|
* `help`, a refusal before a run existed). */
|
|
@@ -39553,6 +39709,15 @@ var ConsoleIO = class _ConsoleIO {
|
|
|
39553
39709
|
async reply(text) {
|
|
39554
39710
|
this.out.write("\n" + this.prefix + text + "\n");
|
|
39555
39711
|
}
|
|
39712
|
+
/** The harness's file upload: the bytes written under the attachments dir,
|
|
39713
|
+
* the lead printed like a reply with the path a person can open. */
|
|
39714
|
+
async attachFile(file) {
|
|
39715
|
+
this.attachments.dir ??= mkdtempSync(join15(tmpdir(), "switchboard-attachments-"));
|
|
39716
|
+
const path = join15(this.attachments.dir, basename3(file.name));
|
|
39717
|
+
writeFileSync9(path, file.bytes);
|
|
39718
|
+
await this.reply(`${file.lead}
|
|
39719
|
+
\u{1F4CE} ${file.name} (${file.bytes.byteLength} bytes) \u2192 ${path}`);
|
|
39720
|
+
}
|
|
39556
39721
|
runFinished(receipt) {
|
|
39557
39722
|
this.finished = receipt;
|
|
39558
39723
|
}
|
|
@@ -39575,7 +39740,10 @@ var ConsoleIO = class _ConsoleIO {
|
|
|
39575
39740
|
const n2 = ++this.children;
|
|
39576
39741
|
await this.reply(lead);
|
|
39577
39742
|
const threadKey = `${this.threadKey}/child-${n2}`;
|
|
39578
|
-
return {
|
|
39743
|
+
return {
|
|
39744
|
+
thread: { threadKey },
|
|
39745
|
+
io: new _ConsoleIO(this.out, threadKey, `${this.prefix}[child-${n2}] `, this.attachments)
|
|
39746
|
+
};
|
|
39579
39747
|
}
|
|
39580
39748
|
};
|
|
39581
39749
|
function askExitCode(finished) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coreplane/switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.210.0",
|
|
4
4
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://openswitchboard.dev",
|