border-customs 0.5.1 → 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/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.5.1",
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.5.1
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
  },