border-customs 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -717,7 +717,7 @@ plugin entry ships as `exports["./server"]`):
717
717
  ```jsonc
718
718
  {
719
719
  // pin the exact version — an @latest channel hits the registry on every cold start
720
- "plugin": ["border-customs@0.6.0"]
720
+ "plugin": ["border-customs@0.7.1"]
721
721
  }
722
722
  ```
723
723
 
@@ -738,6 +738,18 @@ exit 2 and never overwritten, uninstall removes only marker-bearing files, and i
738
738
  report `up to date`. Either route, restart opencode afterwards — tools and commands are
739
739
  scanned at startup.
740
740
 
741
+ **OpenCode 2 (beta) compatibility (0.7.0).** The same module is dual-shaped: V1 hosts call
742
+ `server()` (object entry form, opencode ≥ 1.18.29 required); V2 hosts (`@opencode-ai/cli@beta`,
743
+ bin `opencode2`) call `setup(ctx)` and register the identical tool through
744
+ `ctx.tool.transform` — hand-written `{id, setup}` (the SDK's `define()` is identity), plain
745
+ JSON Schema input, zero V2-SDK runtime dependency. Live-verified on beta-19271 via
746
+ project-local `.opencode/plugins/` (both single-file and package-directory shapes; directory
747
+ discovery resolves the root `index.ts` re-export, which is why the package ships it). V2
748
+ activates plugins lazily — listing them requires `POST /api/plugin/await-activation` first.
749
+ The `/border` slash command stays V1-only until the V2 command-delivery API settles; the
750
+ `border` tool is the supported V2 surface. Config keys are shared (V2 normalizes
751
+ `plugin`→`plugins`). Global file-drop discovery under V2 is untested and not claimed.
752
+
741
753
  What loads is one agent tool `border` plus a `/border` slash command. The tool spawns the CLI
742
754
  argv-only (no shell; 300 s cap, 64 KiB per stream) restricted to the closed command list
743
755
  `check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`, `roundtrip`, `--help`, and
@@ -803,6 +815,31 @@ code.
803
815
 
804
816
  ## Changelog
805
817
 
818
+ ### 0.7.1 (2026-09-30)
819
+ - Fix: **release-gate test ruler race** (`test/push.integration.test.ts` I4) — the
820
+ "dry-run made ZERO verdaccio requests" invariant counted lines in verdaccio's stdout
821
+ log, an eventually-consistent view: on a saturated CI runner the gate-leg probe lines
822
+ of an earlier phase land after the snapshot, inflating the dry-run delta (v0.7.0 CI
823
+ run 36671401844 failed `6 !== 2` while the identical bytes pass locally). The suite
824
+ now takes every snapshot through a FIFO drain barrier (sentinel request whose log line
825
+ must be visible before counting; sentinel traffic is excluded from the count). The
826
+ zero-network dry-run contract itself is unchanged — verified request-free on both sides
827
+ of the barrier; only the measuring instrument was repaired.
828
+
829
+ ### 0.7.0 (2026-09-28)
830
+ - Add: **OpenCode 2 (beta) plugin compatibility** — the plugin module is dual-shaped
831
+ (`{id, setup}` for V2 hosts, frozen V1 `server()`), sharing the pre-spawn
832
+ allowlist/`--yes` gates and the 0.5.1 identity handshake across both shapes;
833
+ refusal/handshake/rendering byte-parity between edges is pinned by
834
+ `test/opencode.v2shape.test.ts` while the V1 path stays 16/16 on unmodified
835
+ tests. Root `index.ts` re-export shipped for V2 directory discovery (which
836
+ bypasses the exports map; live-proven on `@opencode-ai/cli` beta-19271).
837
+ - Fix: the live V1 probe is anti-vacuous — opting in (`BORDER_OPENCODE_PROBE=1`)
838
+ without a resolvable binary or built dist now FAILS naming the remedy instead
839
+ of skipping green-adjacent (`BORDER_OPENCODE_BIN` override added).
840
+ - Docs: support matrix (V1 object entry ≥ 1.18.29; V2 project-local verified;
841
+ `/border` command V1-only pending the V2 delivery API).
842
+
806
843
  ### 0.6.0 (2026-09-25)
807
844
  - Add: **the exfil lens** — five rules (`exfil-rfc1918`, `exfil-ssh-target`,
808
845
  `exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`) that flag
@@ -963,6 +1000,10 @@ MIT, see [LICENSE](LICENSE).
963
1000
 
964
1001
  ## 中文概要
965
1002
 
1003
+ **0.7.1(2026-09-30)发布门禁尺伤修复**:I4 集成测试的"dry-run 对 registry 零请求"断言按 verdaccio stdout 日志行计数,而日志是最终一致视图——CI 满载 runner 上更早阶段的合法 probe 行晚于快照落盘,把差额错记到 dry-run 头上(v0.7.0 run 36671401844 红 `6 !== 2`,同字节本机绿)。修复=尺子不松语义:所有快照前后加 FIFO drain 屏障(哨兵请求行可见⇒先前请求行必已落盘;哨兵流量不计入计数)。dry-run 零网络契约本身未动,两侧实测请求为零。
1004
+
1005
+ **0.7.0(2026-09-28)OpenCode 2(beta)兼容**:插件模块双形状——V1 宿主调 `server()`(对象入口需 ≥1.18.29),V2 宿主(`opencode2`)调 `setup(ctx)` 经 `ctx.tool.transform` 注册同一个工具;spawn 前门(allowlist/`--yes`)与 0.5.1 身份握手两形状共享,双边渲染逐字 parity 由测试钉死。V2 懒激活(断言前须 `POST /api/plugin/await-activation`);`/border` 命令暂仅 V1(V2 投递 API 未定形);已在 beta-19271 项目本地两形态活体验证。V1 活体探针反真空化:opt-in 缺前提即 FAIL 点名补救。
1006
+
966
1007
  border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI/crates/RubyGems 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 PASS 记录时才允许 `border push --yes` 放行。指纹是 sha256(head、porcelain 摘要、规则哈希、暴露面、ref 集合、有效目标)六元组,任何一处变动,旧的 PASS 立即失效,必须重查。流水线:gitleaks(历史+工作区+tag,内置 8.30.1 规则,仓库自带的 ignore 文件直接判 CRITICAL)+ secretlint(进程内,AWS Key 规则强制开启)+ 原生规则(AI 会话产物闭集、提交身份白名单含传输对象检查)+ 注册表预检(版本已存在=必须 bump,名称被外人占有=拒绝;空响应/超时/解析失败一律 exit 2,沉默绝不等于不存在)。构件只构建一次进 `.border/dist/`,扫描的就是发布的字节,发布时再哈希比对,不一致直接拒发。跳过台账让重复检查不到 1 秒,但回放前先重算指纹并重新 pack 验证新鲜度。报告只输出掩码片段(sha256 摘要 + 前4…后4)。push 是多目标状态机,多 remote 先做全有或全无的 fast-forward 预检,永不 force-push;npm/twine/cargo/gem 的凭据经 stdio 透传,border 从不触碰。0.2.0 新增 crates.io 与 RubyGems 通道(公开 crates.io 固定、rubygems 可用 host 覆盖私有镜像),既有配置行为不变。0.3.0 新增残留扫描(residue gate):发布字节里的安装期钩子按 T0-T4 闭集签名表分类(闭集 T1 树内钩子降为 MEDIUM,未知形态原样保留 CRITICAL),外加跨包管理器写入、配对标记缺失(`# BEGIN` 块只检测不豁免)与 gem 不可解析扩展共七条 `residue-*` 规则;`residue.enabled` 是唯一豁免开关且改动指纹使旧 PASS 失效,规则表与分类器源码进入 rulesHash,改一个签名即强制重查。静态分析只证明"能力"不证明"事实",border 不是恶意软件沙箱,从不执行被读代码。0.4.0 新增证明阀
967
1008
  `residue.requireProof`(默认关,strict):开启后,发布构件若触发阻断级 `residue-*` 发现,通道 PASS
968
1009
  还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 `border roundtrip` 实测记录——check 自身从不
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "border-customs",
9
- "version": "0.6.0",
9
+ "version": "0.7.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": "^1.17.4",
package/dist/index.js CHANGED
@@ -45059,7 +45059,7 @@ import { createHash as createHash18 } from "node:crypto";
45059
45059
  import { basename as basename5 } from "node:path";
45060
45060
  var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
45061
45061
  var SCAN_REQUEST_TIMEOUT_MS = 6e4;
45062
- var SCAN_USER_AGENT = "border-customs/0.6.0 (+https://github.com/sumteclab/border; border scan)";
45062
+ var SCAN_USER_AGENT = "border-customs/0.7.1 (+https://github.com/sumteclab/border; border scan)";
45063
45063
  var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
45064
45064
  var defaultScanFetcher = async (url2, init) => {
45065
45065
  if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
package/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // provenance: .omo/plans/border-opencode-v2-plugin.md T1 — V2 project-directory plugin
2
+ // discovery resolves <dir>/index directly (bypassing the package exports map, live-proven
3
+ // on cli beta-19271); this re-export lets the unpacked package directory itself serve as a
4
+ // V2 local plugin. Name-based imports continue to enter through exports["./server"].
5
+ import plugin from "./plugin/border.ts";
6
+
7
+ export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "//": "publint is a runtime dep (exact pin): the npm-stage engine is invoked via bin resolution against the installed layout, so consumers get the publint-fail leg out of the box; the dev suite resolves the same node_modules/.bin/publint — no devDependencies entry is needed for it to stay green.",
5
5
  "description": "Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine",
6
6
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "dist",
17
- "plugin"
17
+ "plugin",
18
+ "index.ts"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=22.0.0"
package/plugin/border.ts CHANGED
@@ -1,4 +1,4 @@
1
- // border-opencode-plugin v0.6.0
1
+ // border-opencode-plugin v0.7.1
2
2
  // Official opencode plugin adapter for the border fail-closed push gate.
3
3
  // Registers ONE agent tool, `border`, that drives the border CLI — argv-only
4
4
  // (node:child_process execFile, never a shell), top-level commands restricted
@@ -6,9 +6,17 @@
6
6
  // the human gate: nothing here can push for real (a bare `border push` is a
7
7
  // DRY-RUN by the CLI's own contract, and `--yes` is refused outright).
8
8
  //
9
- // V1 plugin format (opencode >= 1.14): default export { id, server }, where
9
+ // Dual-shape default export — one module, two host generations:
10
+ // V1 plugin format (opencode >= 1.18.29, object entry): { id, server }, where
10
11
  // server(input, options) resolves to Hooks; Hooks.tool is a
11
- // { [name]: ToolDefinition } record (see @opencode-ai/plugin).
12
+ // { [name]: ToolDefinition } record (see @opencode-ai/plugin). The V1 host
13
+ // ignores the extra `setup` key (this is the official dual-shape form).
14
+ // V2 plugin format (@opencode-ai/cli beta line): the loader accepts only
15
+ // { id, setup } (or { id, effect }) and silently drops anything else, so the
16
+ // SAME tool registers through setup(ctx) -> ctx.tool.transform ->
17
+ // editor.add(Info). Both edges share the pre-spawn gate (gateArgv) and the
18
+ // execute substance (buildToolResult) below, so behavior stays identical;
19
+ // test/opencode.v2shape.test.ts pins V1↔V2 rendering and refusal parity.
12
20
  // This file ships as-is (package.json "files") and reaches a session by two
13
21
  // routes, neither compiling it through border's tsc:
14
22
  // (A) copied into <CONFIG>/plugins/ by `border opencode install`, which also
@@ -234,8 +242,160 @@ contract: it reports the verdict the gate would produce without touching any
234
242
  remote.
235
243
  `;
236
244
 
245
+ // --- V2 loader surface: local structural types ---------------------------------
246
+ // shape proven from @opencode-ai/plugin beta-19271 .d.ts; live-gated by
247
+ // test/opencode.v2.probe (plan T3). Deliberately NO V2 SDK import here (wave
248
+ // plan D1/R3 pending): only the two members the setup() seam touches are
249
+ // typed — ctx.tool.transform and editor.add — everything else stays opaque.
250
+
251
+ /** Parsed tool input, mirroring the V1 args schema (command required, extra optional). */
252
+ interface V2ToolArgs {
253
+ readonly command: string;
254
+ readonly extra?: readonly string[];
255
+ }
256
+
257
+ /** The V2 promise-entry result envelope: the rendered text in `content`. */
258
+ interface V2ToolResult {
259
+ readonly content: string;
260
+ }
261
+
262
+ /** One tool registration handed to ToolEditor.add by the transform callback. */
263
+ export interface V2ToolInfo {
264
+ readonly name: string;
265
+ readonly description: string;
266
+ /** Pure JSON Schema — ValueSchema accepts it without a schema-library import. */
267
+ readonly input: Record<string, unknown>;
268
+ readonly execute: (args: V2ToolArgs, context?: unknown) => Promise<V2ToolResult>;
269
+ }
270
+
271
+ export interface V2ToolEditor {
272
+ add(info: V2ToolInfo): void;
273
+ }
274
+
275
+ interface V2ToolRegistry {
276
+ transform(callback: (editor: V2ToolEditor) => void): Promise<unknown>;
277
+ }
278
+
279
+ export interface V2PluginContext {
280
+ readonly tool: V2ToolRegistry;
281
+ }
282
+
283
+ /**
284
+ * The ONE tool description, byte-shared by both edges: the V1 tool() schema
285
+ * and the V2 Info both render exactly this text (single source of truth).
286
+ */
287
+ const TOOL_DESCRIPTION =
288
+ "Run the border fail-closed push-gate CLI on this machine. " +
289
+ "Pass the top-level command word in `command` (one of: check, push, status, " +
290
+ "llm-request, llm-ingest, scan, roundtrip, --help) and every remaining argv " +
291
+ "token in `extra`. The call is spawned argv-only (no shell) with a 300s " +
292
+ "timeout; the result always ends with the CLI exit code: 0 pass, 1 " +
293
+ "gate-blocked or partial push, 2 gate could not answer. Before running your " +
294
+ "argv the tool handshakes its binary candidate with `--help` and refuses " +
295
+ "everything (exit 2, cannot-answer) unless the responder provably is the " +
296
+ "border CLI — it never falls back to another binary. Honest privilege " +
297
+ "note: this tool runs the border CLI, and the CLI IS the gate that decides " +
298
+ "what leaves this machine — it carries the same power as the binary, so an " +
299
+ "agent may only *ask* the gate, never bypass it. `push --yes` is " +
300
+ "deliberately NOT reachable here: real pushes are the human gate and belong " +
301
+ "to a terminal. A bare `border push` through this tool is a DRY-RUN by the " +
302
+ "CLI's own contract.";
303
+
304
+ /**
305
+ * The pre-spawn gate — the deliberate seam split, load-bearing for security:
306
+ * gateArgv(argv) decides WHETHER the argv may run at all (allowlist
307
+ * membership, `--yes` refusal) and returns the refusal text or null;
308
+ * buildToolResult(argv) does the actual work (handshake, spawn, render)
309
+ * and NEVER re-gates — it trusts its caller.
310
+ * Every edge (today: V1 tool.execute and V2 Info.execute) MUST call gateArgv
311
+ * BEFORE buildToolResult. A future edge that skips the gate silently gets no
312
+ * gating; test/opencode.v2shape.test.ts pins refusal parity on both edges.
313
+ */
314
+ function gateArgv(argv: readonly string[]): string | null {
315
+ const command = argv[0] ?? "";
316
+ if (!ALLOWED_COMMANDS.includes(command)) {
317
+ return (
318
+ `border: refused '${command}' — not an allowed command. ` +
319
+ `Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
320
+ `and every other token in 'extra'.`
321
+ );
322
+ }
323
+ if (argv.includes("--yes")) {
324
+ return (
325
+ `border: refused '${command}' with '--yes' — a real push is the human gate ` +
326
+ `and happens in a terminal. Run \`border push --yes\` there if a visible ` +
327
+ `human go-ahead exists. A bare \`border push\` through this tool is a ` +
328
+ `DRY-RUN by the CLI's own contract.`
329
+ );
330
+ }
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * The tool-execute substance shared by both edges (wave plan D2): resolve the
336
+ * CLI via resolveVerified (identity handshake included), spawn it argv-only
337
+ * via runCli, render the sectioned result. No gates and no V1-only
338
+ * context.metadata here — those stay at each edge, see gateArgv above.
339
+ */
340
+ async function buildToolResult(argv: readonly string[]): Promise<string> {
341
+ const resolution = await resolveVerified();
342
+ if (!resolution.ok) {
343
+ return [
344
+ `$ border ${argv.join(" ")}`,
345
+ "exit: 2",
346
+ `note: cannot-answer — no border CLI passed the identity handshake, so nothing was run. Tried: ${resolution.reasons.join("; ")}. ` +
347
+ "Remedies: point BORDER_BIN at a real border CLI, run 'border opencode install' (route A), or npm i -g border-customs (PATH entry).",
348
+ "--- stdout ---\n(empty)",
349
+ "--- stderr ---\n(empty)",
350
+ ].join("\n");
351
+ }
352
+ const result = await runCli(resolution.resolution, argv, TIMEOUT_MS);
353
+ if (result.spawnFailed) verified = null;
354
+ const sections = [
355
+ `$ border ${argv.join(" ")}`,
356
+ `exit: ${String(result.status)}`,
357
+ ];
358
+ if (result.note.length > 0) sections.push(`note: ${result.note}`);
359
+ sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
360
+ sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
361
+ return sections.join("\n");
362
+ }
363
+
364
+ /** The V2-side tool registration: same gate + same substance as the V1 tool. */
365
+ function makeV2ToolInfo(): V2ToolInfo {
366
+ return {
367
+ name: "border",
368
+ description: TOOL_DESCRIPTION,
369
+ input: {
370
+ type: "object",
371
+ properties: {
372
+ command: { type: "string" },
373
+ extra: { type: "array", items: { type: "string" } },
374
+ },
375
+ required: ["command"],
376
+ additionalProperties: false,
377
+ },
378
+ execute: async (args: V2ToolArgs): Promise<V2ToolResult> => {
379
+ const argv: readonly string[] = [args.command.trim(), ...(args.extra ?? [])];
380
+ const refusal = gateArgv(argv);
381
+ if (refusal !== null) return { content: refusal };
382
+ return { content: await buildToolResult(argv) };
383
+ },
384
+ };
385
+ }
386
+
237
387
  export default {
388
+ // Shared plugin id: the V1 host reads it for the {id, server} object entry
389
+ // and the V2 loader reads this same id for its {id, setup} schema check.
238
390
  id: "border",
391
+ setup: async (ctx: V2PluginContext): Promise<void> => {
392
+ // transform's return is awaited (Promise<Registration>), but the callback
393
+ // body itself must stay SYNCHRONOUS — only editor.add inside, any async
394
+ // work belongs in Info.execute (contract E3; pinned by v2shape test (d)).
395
+ await ctx.tool.transform((editor: V2ToolEditor): void => {
396
+ editor.add(makeV2ToolInfo());
397
+ });
398
+ },
239
399
  server: async () => ({
240
400
  // Self-registering /border: opencode calls hook.config(cfg) once per
241
401
  // instance after ALL config sources are merged — file-based commands are
@@ -251,22 +411,7 @@ export default {
251
411
  },
252
412
  tool: {
253
413
  border: tool({
254
- description:
255
- "Run the border fail-closed push-gate CLI on this machine. " +
256
- "Pass the top-level command word in `command` (one of: check, push, status, " +
257
- "llm-request, llm-ingest, scan, roundtrip, --help) and every remaining argv " +
258
- "token in `extra`. The call is spawned argv-only (no shell) with a 300s " +
259
- "timeout; the result always ends with the CLI exit code: 0 pass, 1 " +
260
- "gate-blocked or partial push, 2 gate could not answer. Before running your " +
261
- "argv the tool handshakes its binary candidate with `--help` and refuses " +
262
- "everything (exit 2, cannot-answer) unless the responder provably is the " +
263
- "border CLI — it never falls back to another binary. Honest privilege " +
264
- "note: this tool runs the border CLI, and the CLI IS the gate that decides " +
265
- "what leaves this machine — it carries the same power as the binary, so an " +
266
- "agent may only *ask* the gate, never bypass it. `push --yes` is " +
267
- "deliberately NOT reachable here: real pushes are the human gate and belong " +
268
- "to a terminal. A bare `border push` through this tool is a DRY-RUN by the " +
269
- "CLI's own contract.",
414
+ description: TOOL_DESCRIPTION,
270
415
  args: {
271
416
  command: tool.schema
272
417
  .string()
@@ -276,47 +421,13 @@ export default {
276
421
  .optional()
277
422
  .describe("remaining argv tokens, e.g. ['--force']"),
278
423
  },
424
+ // V1 edge: gate → V1-only metadata → shared substance (see gateArgv).
279
425
  execute: async (args, context) => {
280
- const command = args.command.trim();
281
- if (!ALLOWED_COMMANDS.includes(command)) {
282
- return (
283
- `border: refused '${command}' — not an allowed command. ` +
284
- `Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
285
- `and every other token in 'extra'.`
286
- );
287
- }
288
- const extra = args.extra ?? [];
289
- if (extra.includes("--yes")) {
290
- return (
291
- `border: refused '${command}' with '--yes' — a real push is the human gate ` +
292
- `and happens in a terminal. Run \`border push --yes\` there if a visible ` +
293
- `human go-ahead exists. A bare \`border push\` through this tool is a ` +
294
- `DRY-RUN by the CLI's own contract.`
295
- );
296
- }
297
- const argv: readonly string[] = [command, ...extra];
426
+ const argv: readonly string[] = [args.command.trim(), ...(args.extra ?? [])];
427
+ const refusal = gateArgv(argv);
428
+ if (refusal !== null) return refusal;
298
429
  context.metadata({ title: `border ${argv.join(" ")}` });
299
- const resolution = await resolveVerified();
300
- if (!resolution.ok) {
301
- return [
302
- `$ border ${argv.join(" ")}`,
303
- "exit: 2",
304
- `note: cannot-answer — no border CLI passed the identity handshake, so nothing was run. Tried: ${resolution.reasons.join("; ")}. ` +
305
- "Remedies: point BORDER_BIN at a real border CLI, run 'border opencode install' (route A), or npm i -g border-customs (PATH entry).",
306
- "--- stdout ---\n(empty)",
307
- "--- stderr ---\n(empty)",
308
- ].join("\n");
309
- }
310
- const result = await runCli(resolution.resolution, argv, TIMEOUT_MS);
311
- if (result.spawnFailed) verified = null;
312
- const sections = [
313
- `$ border ${argv.join(" ")}`,
314
- `exit: ${String(result.status)}`,
315
- ];
316
- if (result.note.length > 0) sections.push(`note: ${result.note}`);
317
- sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
318
- sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
319
- return sections.join("\n");
430
+ return buildToolResult(argv);
320
431
  },
321
432
  }),
322
433
  },