border-customs 0.4.2 → 0.5.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.
@@ -0,0 +1,17 @@
1
+ <!-- border-opencode-command -->
2
+ Drive the border fail-closed push gate through the `border` tool.
3
+
4
+ User request: $ARGUMENTS
5
+
6
+ Interpret the request as ONE `border` CLI invocation: the first word is the
7
+ command (`check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`,
8
+ `roundtrip`, or `--help`) and the rest are argv tokens. Call the `border` tool
9
+ with `command` set to the first word and `extra` set to the remaining tokens,
10
+ then report the CLI exit code (0 pass / 1 gate-blocked or partial push / 2 gate
11
+ could not answer) and the relevant lines of its output. If no request was given,
12
+ call the tool with `command: "--help"` and summarize the command list from it.
13
+ `push --yes` is refused by the tool — a real push is the human gate and belongs
14
+ to a terminal: if the user asks for one, tell them to run `border push --yes`
15
+ there. A bare `border push` through the tool is a DRY-RUN by the CLI's own
16
+ contract: it reports the verdict the gate would produce without touching any
17
+ remote.
@@ -0,0 +1,239 @@
1
+ // border-opencode-plugin v0.5.0
2
+ // Official opencode plugin adapter for the border fail-closed push gate.
3
+ // Registers ONE agent tool, `border`, that drives the border CLI — argv-only
4
+ // (node:child_process execFile, never a shell), top-level commands restricted
5
+ // to an allowlist, hard 300s timeout, capped output. The CLI itself remains
6
+ // the human gate: nothing here can push for real (a bare `border push` is a
7
+ // DRY-RUN by the CLI's own contract, and `--yes` is refused outright).
8
+ //
9
+ // V1 plugin format (opencode >= 1.14): default export { id, server }, where
10
+ // server(input, options) resolves to Hooks; Hooks.tool is a
11
+ // { [name]: ToolDefinition } record (see @opencode-ai/plugin).
12
+ // This file ships as-is (package.json "files") and reaches a session by two
13
+ // routes, neither compiling it through border's tsc:
14
+ // (A) copied into <CONFIG>/plugins/ by `border opencode install`, which also
15
+ // drops commands/border.md; "@opencode-ai/plugin" then resolves in
16
+ // opencode's config-directory node_modules.
17
+ // (B) served as the package's "./server" export when the npm package name is
18
+ // listed in opencode.jsonc "plugin" — opencode's arborist install places
19
+ // @opencode-ai/plugin (its runtime dependency) next to the package in the
20
+ // cache, so the same import resolves there too. With no commands/border.md
21
+ // on disk, the config hook below self-registers the slash command instead.
22
+ // On this route the CLI is spawned FROM THE PACKAGE ITSELF (../dist/index.js
23
+ // lives inside the same tarball), so no global install or PATH entry is
24
+ // needed — that is what makes Route B genuinely install-free.
25
+
26
+ import { execFile } from "node:child_process";
27
+ import { existsSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import { tool, type Config } from "@opencode-ai/plugin";
30
+
31
+ /** Spawn cap: a CLI call that outlives this is killed and reported, never awaited forever. */
32
+ const TIMEOUT_MS = 300_000;
33
+ /** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
34
+ const MAX_OUTPUT_BYTES = 64 * 1024;
35
+
36
+ /** The eight tool-reachable top-level commands plus --help. Anything else is refused locally.
37
+ * A real push is the human gate and is deliberately not reachable here: any argv
38
+ * containing `--yes` is refused, and bare `border push` is DRY-RUN by CLI contract. */
39
+ const ALLOWED_COMMANDS: readonly string[] = [
40
+ "check",
41
+ "push",
42
+ "status",
43
+ "llm-request",
44
+ "llm-ingest",
45
+ "scan",
46
+ "roundtrip",
47
+ "--help",
48
+ ];
49
+
50
+ /** How to reach the CLI: a binary name/absolute path, optionally under node itself. */
51
+ interface BinResolution {
52
+ readonly bin: string;
53
+ /** argv to prepend before the CLI's own argv (the dist entry when hosted by node). */
54
+ readonly prefix: readonly string[];
55
+ }
56
+
57
+ /**
58
+ * Resolution order: BORDER_BIN (non-empty) wins on every route; else the dist
59
+ * bundle sitting next to this module in the package/repo layout (Route B and
60
+ * dev checkout, spawned under node); else the `border` bin from PATH (Route A).
61
+ */
62
+ function resolveBin(): BinResolution {
63
+ const configured = process.env["BORDER_BIN"];
64
+ if (configured !== undefined && configured.length > 0) return { bin: configured, prefix: [] };
65
+ const distEntry = fileURLToPath(new URL("../dist/index.js", import.meta.url));
66
+ if (existsSync(distEntry)) return { bin: process.execPath, prefix: [distEntry] };
67
+ return { bin: "border", prefix: [] };
68
+ }
69
+
70
+ interface CliResult {
71
+ readonly status: number;
72
+ readonly stdout: string;
73
+ readonly stderr: string;
74
+ readonly note: string;
75
+ }
76
+
77
+ /** The subset of the execFile error shape this adapter reads (node sets `code` on failures). */
78
+ interface SpawnError extends Error {
79
+ readonly code?: string | number | null;
80
+ readonly killed?: boolean;
81
+ readonly signal?: string | null;
82
+ }
83
+
84
+ /** Never rejects: every spawn failure becomes a structured result the agent can read. */
85
+ function runCli(bin: string, prefix: readonly string[], argv: readonly string[]): Promise<CliResult> {
86
+ return new Promise((resolve) => {
87
+ execFile(
88
+ bin,
89
+ [...prefix, ...argv],
90
+ { timeout: TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, shell: false },
91
+ (error, stdout, stderr) => {
92
+ const out = stdout.slice(0, MAX_OUTPUT_BYTES);
93
+ const errOut = stderr.slice(0, MAX_OUTPUT_BYTES);
94
+ if (error === null) {
95
+ resolve({ status: 0, stdout: out, stderr: errOut, note: "" });
96
+ return;
97
+ }
98
+ const failure = error as SpawnError;
99
+ if (failure.code === "ENOENT") {
100
+ resolve({
101
+ status: 127,
102
+ stdout: out,
103
+ stderr: errOut,
104
+ note:
105
+ "the border CLI could not be spawned. Three remedies: set BORDER_BIN to a " +
106
+ "real border binary, or run 'border opencode install' (route A), or " +
107
+ "npm i -g border-customs (route A, PATH entry).",
108
+ });
109
+ return;
110
+ }
111
+ if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
112
+ resolve({
113
+ status: -1,
114
+ stdout: out,
115
+ stderr: errOut,
116
+ note: `output exceeded ${MAX_OUTPUT_BYTES} bytes per stream and was truncated.`,
117
+ });
118
+ return;
119
+ }
120
+ if (failure.killed === true || (failure.signal !== undefined && failure.signal !== null)) {
121
+ resolve({
122
+ status: 124,
123
+ stdout: out,
124
+ stderr: errOut,
125
+ note:
126
+ `killed after ${TIMEOUT_MS / 1000}s timeout (signal ${failure.signal ?? "SIGTERM"}). ` +
127
+ "border check and roundtrip can be long — if you need a long roundtrip, " +
128
+ "prefer running it in a terminal.",
129
+ });
130
+ return;
131
+ }
132
+ resolve({
133
+ status: typeof failure.code === "number" ? failure.code : -1,
134
+ stdout: out,
135
+ stderr: errOut,
136
+ note:
137
+ typeof failure.code === "number" ? "" : `spawn failed: ${failure.message}`,
138
+ });
139
+ },
140
+ );
141
+ });
142
+ }
143
+
144
+ /**
145
+ * The /border slash-command template. ALSO published verbatim (after the marker
146
+ * line) as plugin/border-command.md — test/opencode.test.ts pins the byte-mirror.
147
+ */
148
+ export const COMMAND_TEMPLATE = `Drive the border fail-closed push gate through the \`border\` tool.
149
+
150
+ User request: $ARGUMENTS
151
+
152
+ Interpret the request as ONE \`border\` CLI invocation: the first word is the
153
+ command (\`check\`, \`push\`, \`status\`, \`llm-request\`, \`llm-ingest\`, \`scan\`,
154
+ \`roundtrip\`, or \`--help\`) and the rest are argv tokens. Call the \`border\` tool
155
+ with \`command\` set to the first word and \`extra\` set to the remaining tokens,
156
+ then report the CLI exit code (0 pass / 1 gate-blocked or partial push / 2 gate
157
+ could not answer) and the relevant lines of its output. If no request was given,
158
+ call the tool with \`command: "--help"\` and summarize the command list from it.
159
+ \`push --yes\` is refused by the tool — a real push is the human gate and belongs
160
+ to a terminal: if the user asks for one, tell them to run \`border push --yes\`
161
+ there. A bare \`border push\` through the tool is a DRY-RUN by the CLI's own
162
+ contract: it reports the verdict the gate would produce without touching any
163
+ remote.
164
+ `;
165
+
166
+ export default {
167
+ id: "border",
168
+ server: async () => ({
169
+ // Self-registering /border: opencode calls hook.config(cfg) once per
170
+ // instance after ALL config sources are merged — file-based commands are
171
+ // already in cfg.command by then. `??=` keeps a Route-A commands/border.md
172
+ // authoritative and only fills the gap for Route B; the command map is
173
+ // keyed by name, so a same-name file + injection never duplicates.
174
+ config: async (cfg: Config) => {
175
+ cfg.command ??= {};
176
+ cfg.command.border ??= {
177
+ description: "Drive the border push gate (usage: /border <command> [args...])",
178
+ template: COMMAND_TEMPLATE,
179
+ };
180
+ },
181
+ tool: {
182
+ border: tool({
183
+ description:
184
+ "Run the border fail-closed push-gate CLI on this machine. " +
185
+ "Pass the top-level command word in `command` (one of: check, push, status, " +
186
+ "llm-request, llm-ingest, scan, roundtrip, --help) and every remaining argv " +
187
+ "token in `extra`. The call is spawned argv-only (no shell) with a 300s " +
188
+ "timeout; the result always ends with the CLI exit code: 0 pass, 1 " +
189
+ "gate-blocked or partial push, 2 gate could not answer. Honest privilege " +
190
+ "note: this tool runs the border CLI, and the CLI IS the gate that decides " +
191
+ "what leaves this machine — it carries the same power as the binary, so an " +
192
+ "agent may only *ask* the gate, never bypass it. `push --yes` is " +
193
+ "deliberately NOT reachable here: real pushes are the human gate and belong " +
194
+ "to a terminal. A bare `border push` through this tool is a DRY-RUN by the " +
195
+ "CLI's own contract.",
196
+ args: {
197
+ command: tool.schema
198
+ .string()
199
+ .describe("border command word, e.g. 'check' or 'push' or '--help'"),
200
+ extra: tool.schema
201
+ .array(tool.schema.string())
202
+ .optional()
203
+ .describe("remaining argv tokens, e.g. ['--force']"),
204
+ },
205
+ execute: async (args, context) => {
206
+ const command = args.command.trim();
207
+ if (!ALLOWED_COMMANDS.includes(command)) {
208
+ return (
209
+ `border: refused '${command}' — not an allowed command. ` +
210
+ `Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
211
+ `and every other token in 'extra'.`
212
+ );
213
+ }
214
+ const extra = args.extra ?? [];
215
+ if (extra.includes("--yes")) {
216
+ return (
217
+ `border: refused '${command}' with '--yes' — a real push is the human gate ` +
218
+ `and happens in a terminal. Run \`border push --yes\` there if a visible ` +
219
+ `human go-ahead exists. A bare \`border push\` through this tool is a ` +
220
+ `DRY-RUN by the CLI's own contract.`
221
+ );
222
+ }
223
+ const argv: readonly string[] = [command, ...extra];
224
+ context.metadata({ title: `border ${argv.join(" ")}` });
225
+ const resolution = resolveBin();
226
+ const result = await runCli(resolution.bin, resolution.prefix, argv);
227
+ const sections = [
228
+ `$ border ${argv.join(" ")}`,
229
+ `exit: ${String(result.status)}`,
230
+ ];
231
+ if (result.note.length > 0) sections.push(`note: ${result.note}`);
232
+ sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
233
+ sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
234
+ return sections.join("\n");
235
+ },
236
+ }),
237
+ },
238
+ }),
239
+ };