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.
- package/README.md +51 -0
- package/dist/assets/package-lock.json +371 -3
- package/dist/index.js +260 -71
- package/package.json +9 -2
- package/plugin/border-command.md +17 -0
- package/plugin/border.ts +239 -0
|
@@ -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.
|
package/plugin/border.ts
ADDED
|
@@ -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
|
+
};
|