@tachikomagundam/abathur 0.2.0 → 0.2.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 +14 -7
- package/dist/commands/opencode.js +26 -4
- package/dist/test/opencode.test.js +41 -15
- package/package.json +1 -1
- package/plugin/abathur-command.md +9 -8
- package/plugin/abathur.ts +21 -15
package/README.md
CHANGED
|
@@ -151,10 +151,14 @@ abathur opencode install # copies plugin assets into ~/.config/opencode/
|
|
|
151
151
|
After a restart the session gets two things:
|
|
152
152
|
|
|
153
153
|
- the agent tool **`abathur`** — spawns the `abathur` CLI argv-only (never a
|
|
154
|
-
shell), restricted to
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
shell), restricted to eight commands (`genome`, `run`, `status`, `bundle`,
|
|
155
|
+
`graft`, `self-eval`, `kernel`, `--help`), with a 120 s timeout and a 64 KB
|
|
156
|
+
output cap. The binary resolves from `PATH` unless `ABATHUR_BIN` overrides
|
|
157
|
+
it. Honest privilege note: the tool carries bash-equivalent privilege —
|
|
158
|
+
`run` and `genome` legitimately spawn mutator/engine binaries by design —
|
|
159
|
+
so the allowlist limits typos and UX, not capability. `promote` and
|
|
160
|
+
`tombstone` are deliberately NOT reachable through the tool: they are
|
|
161
|
+
human gates and must be run in a terminal.
|
|
158
162
|
- the user command **`/abathur <args…>`** — a slash command that tells the
|
|
159
163
|
agent to translate its arguments into a tool call and report the exit code.
|
|
160
164
|
|
|
@@ -416,9 +420,12 @@ abathur opencode install # 把插件资产复制进 ~/.config/opencode/
|
|
|
416
420
|
重启后会话获得两样东西:
|
|
417
421
|
|
|
418
422
|
- 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
|
|
419
|
-
shell
|
|
420
|
-
`
|
|
421
|
-
|
|
423
|
+
shell),顶层命令限定为八个(`genome`、`run`、`status`、`bundle`、`graft`、
|
|
424
|
+
`self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
|
|
425
|
+
`PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
|
|
426
|
+
bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
|
|
427
|
+
所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `tombstone` 刻意
|
|
428
|
+
不可经由工具触达:它们是人类闸门,必须在终端里运行。
|
|
422
429
|
- 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数翻译成工具调用
|
|
423
430
|
并回报退出码。
|
|
424
431
|
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
// {plugins,commands}/. No network, no shell, no opencode process contact.
|
|
6
6
|
// Identity rule: a target is ours only if its FIRST LINE carries our marker;
|
|
7
7
|
// a foreign file at a target path is refused (exit 2), never overwritten,
|
|
8
|
-
// never deleted — there is no --force anywhere.
|
|
8
|
+
// never deleted — there is no --force anywhere. Destination rule (0.2.1):
|
|
9
|
+
// a path that exists must be a plain regular file — directories and symlinks
|
|
10
|
+
// are refused via lstat, never followed, entered, or destroyed.
|
|
9
11
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
13
|
import os from "node:os";
|
|
12
14
|
import path from "node:path";
|
|
13
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -79,9 +81,24 @@ function refuseForeign(target) {
|
|
|
79
81
|
return cannotAnswer(`opencode: refusing to touch ${target.destination} — the file exists but is not ours ` +
|
|
80
82
|
`(its first line lacks the marker '${target.marker}')`, "resolve it manually: move the foreign file aside (or adopt the marker), then re-run 'abathur opencode install'");
|
|
81
83
|
}
|
|
84
|
+
/** Destination shape guard shared by install/uninstall (lstat: links are inspected, not followed). */
|
|
85
|
+
function requirePlainDestination(target) {
|
|
86
|
+
let shape;
|
|
87
|
+
try {
|
|
88
|
+
shape = lstatSync(target.destination);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return; // absent (or a not-yet-existing parent): the write path creates it fresh
|
|
92
|
+
}
|
|
93
|
+
if (!shape.isFile() || shape.isSymbolicLink()) {
|
|
94
|
+
cannotAnswer(`opencode: refusing to touch ${target.destination} — the path exists but is not a regular file ` +
|
|
95
|
+
`(directories and symlinks are never followed, entered, or destroyed)`, "resolve it manually: move the entry aside (or replace the symlink with a real file), then re-run 'abathur opencode install'");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
82
98
|
/** Atomicity rule shared by install/uninstall: validate EVERY target before writing to ANY. */
|
|
83
99
|
function requireNoForeign(targets) {
|
|
84
100
|
for (const target of targets) {
|
|
101
|
+
requirePlainDestination(target);
|
|
85
102
|
const current = readTarget(target);
|
|
86
103
|
if (current !== null && !carriesMarker(current, target.marker))
|
|
87
104
|
refuseForeign(target);
|
|
@@ -89,10 +106,15 @@ function requireNoForeign(targets) {
|
|
|
89
106
|
}
|
|
90
107
|
function opencodeInstall(env) {
|
|
91
108
|
const targets = pluginTargets(env);
|
|
109
|
+
// Read BOTH packaged assets up front: a missing second asset must fail before anything is written.
|
|
110
|
+
const prepared = targets.map((target) => ({ target, packaged: readPackagedAsset(target) }));
|
|
92
111
|
requireNoForeign(targets);
|
|
93
|
-
for (const target of
|
|
94
|
-
const packaged = readPackagedAsset(target);
|
|
112
|
+
for (const { target, packaged } of prepared) {
|
|
95
113
|
const { state } = classify(target, packaged);
|
|
114
|
+
// TOCTOU: identity is re-checked right before the write — a foreign file planted
|
|
115
|
+
// since validation is refused, never overwritten.
|
|
116
|
+
if (state === "foreign")
|
|
117
|
+
refuseForeign(target);
|
|
96
118
|
if (state === "up-to-date") {
|
|
97
119
|
writeStdout(`${target.destination}: up to date`);
|
|
98
120
|
continue;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { test } from "node:test";
|
|
@@ -98,6 +98,38 @@ test("install: foreign target without our marker ⇒ exit 2 naming the path, NOT
|
|
|
98
98
|
// Atomicity: the refusal must happen before ANY write, so the command target stays absent.
|
|
99
99
|
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
100
100
|
});
|
|
101
|
+
test("install: a DIRECTORY at a target path is refused (exit 2, named) — never entered or destroyed", async (t) => {
|
|
102
|
+
const { home, env } = await makeEnv(t);
|
|
103
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
104
|
+
await mkdir(pluginTs, { recursive: true });
|
|
105
|
+
const inner = path.join(pluginTs, "someone-elses-plugin.ts");
|
|
106
|
+
await writeFile(inner, "// lives inside, must survive\n", "utf8");
|
|
107
|
+
const run = abathur(env, "opencode", "install");
|
|
108
|
+
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
|
|
109
|
+
const combined = `${run.stdout}${run.stderr}`;
|
|
110
|
+
assert.ok(combined.includes(pluginTs), `refusal must name the path: ${combined}`);
|
|
111
|
+
assert.ok((await readFile(inner, "utf8")).includes("must survive"), "directory contents untouched");
|
|
112
|
+
// Atomicity still holds: validation precedes every write, so the sibling stays absent.
|
|
113
|
+
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
114
|
+
});
|
|
115
|
+
test("install: a SYMLINK at a target path is refused (exit 2) — inspected by lstat, never followed", async (t) => {
|
|
116
|
+
const { home, env } = await makeEnv(t);
|
|
117
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
118
|
+
// The marker check alone would pass here: readFileSync follows the link, and the
|
|
119
|
+
// old write would then destroy the external file. lstat must refuse the link itself.
|
|
120
|
+
const real = path.join(home, "elsewhere", "real-file.ts");
|
|
121
|
+
await mkdir(path.dirname(real), { recursive: true });
|
|
122
|
+
const original = `${PLUGIN_MARKER} 0.0.1\n// real file outside the opencode tree\n`;
|
|
123
|
+
await writeFile(real, original, "utf8");
|
|
124
|
+
await mkdir(path.dirname(pluginTs), { recursive: true });
|
|
125
|
+
await symlink(real, pluginTs);
|
|
126
|
+
const run = abathur(env, "opencode", "install");
|
|
127
|
+
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
|
|
128
|
+
assert.ok(`${run.stdout}${run.stderr}`.includes(pluginTs), `refusal must name the path: ${run.stdout}${run.stderr}`);
|
|
129
|
+
assert.equal(await readFile(real, "utf8"), original, "file behind the link must be untouched");
|
|
130
|
+
assert.equal((await lstat(pluginTs)).isSymbolicLink(), true, "the symlink itself must survive too");
|
|
131
|
+
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
132
|
+
});
|
|
101
133
|
test("install: our marker'd older file is overwritten with packaged bytes", async (t) => {
|
|
102
134
|
const { home, env } = await makeEnv(t);
|
|
103
135
|
const { pluginTs } = targetPaths(home);
|
|
@@ -197,20 +229,14 @@ test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowli
|
|
|
197
229
|
assert.ok(bytes.includes('id: "abathur"'), "file plugins must export id (resolvePluginId)");
|
|
198
230
|
assert.ok(/server:\s*async\s*\(\)/.test(bytes), "server must be an async function (readV1Plugin)");
|
|
199
231
|
assert.ok(bytes.includes("tool.schema"), "args must be built via tool.schema (no local zod dep)");
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"self-eval",
|
|
209
|
-
"kernel",
|
|
210
|
-
"--help",
|
|
211
|
-
]) {
|
|
212
|
-
assert.ok(bytes.includes(`"${command}"`), `allowlist must contain ${command}`);
|
|
213
|
-
}
|
|
232
|
+
// Exact-array pin (0.2.1): parse the ALLOWED_COMMANDS literal from source — membership
|
|
233
|
+
// checks alone would let promote/tombstone creep back in via order or extras.
|
|
234
|
+
const literal = bytes.match(/const ALLOWED_COMMANDS[^=]*=\s*\[([^\]]*)\]/);
|
|
235
|
+
assert.ok(literal !== null, "ALLOWED_COMMANDS array literal must exist");
|
|
236
|
+
const allowed = [...(literal[1] ?? "").matchAll(/"([^"]+)"/g)].map((found) => found[1] ?? "");
|
|
237
|
+
assert.deepEqual(allowed, ["genome", "run", "status", "bundle", "graft", "self-eval", "kernel", "--help"]);
|
|
238
|
+
assert.ok(!allowed.includes("promote"), "promote is terminal-only — must not be tool-reachable");
|
|
239
|
+
assert.ok(!allowed.includes("tombstone"), "tombstone is terminal-only — must not be tool-reachable");
|
|
214
240
|
assert.ok(bytes.includes("execFile"), "must spawn via execFile argv-only");
|
|
215
241
|
assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
|
|
216
242
|
assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
|
package/package.json
CHANGED
|
@@ -4,11 +4,12 @@ Drive the abathur evolution harness through the `abathur` tool on this machine.
|
|
|
4
4
|
User request: $ARGUMENTS
|
|
5
5
|
|
|
6
6
|
Interpret the request as one `abathur` CLI invocation: the first word is the
|
|
7
|
-
top-level command (`genome`, `run`, `status`, `
|
|
8
|
-
`
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
human gates
|
|
7
|
+
top-level command (`genome`, `run`, `status`, `bundle`, `graft`, `self-eval`,
|
|
8
|
+
`kernel`, or `--help`) and the rest are argv tokens. Call the `abathur` tool
|
|
9
|
+
with `command` set to the first word and `extra` set to the remaining tokens,
|
|
10
|
+
then report the CLI exit code (0 ok / 1 blocked decision / 2 cannot-answer)
|
|
11
|
+
and the relevant lines of its output. If no request was given, call the tool
|
|
12
|
+
with `command: "--help"` and summarize the command list. `promote` and
|
|
13
|
+
`tombstone` cannot be called through the tool at all — the tool refuses them.
|
|
14
|
+
They are human gates that belong to a terminal: if the user asks for one,
|
|
15
|
+
tell them to run `abathur promote …` / `abathur tombstone …` there.
|
package/plugin/abathur.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// abathur-opencode-plugin v0.2.
|
|
1
|
+
// abathur-opencode-plugin v0.2.1
|
|
2
2
|
// Official opencode plugin adapter for the abathur evolution harness.
|
|
3
3
|
// Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
|
|
4
4
|
// argv-only (node:child_process execFile, never a shell), top-level commands
|
|
@@ -21,13 +21,12 @@ const TIMEOUT_MS = 120_000;
|
|
|
21
21
|
/** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
|
|
22
22
|
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
23
23
|
|
|
24
|
-
/** The
|
|
24
|
+
/** The seven tool-reachable top-level commands plus --help. Anything else is refused locally.
|
|
25
|
+
* promote/tombstone are human gates and deliberately terminal-only (0.2.1): not reachable here. */
|
|
25
26
|
const ALLOWED_COMMANDS: readonly string[] = [
|
|
26
27
|
"genome",
|
|
27
28
|
"run",
|
|
28
29
|
"status",
|
|
29
|
-
"promote",
|
|
30
|
-
"tombstone",
|
|
31
30
|
"bundle",
|
|
32
31
|
"graft",
|
|
33
32
|
"self-eval",
|
|
@@ -89,14 +88,18 @@ function runCli(bin: string, argv: readonly string[]): Promise<CliResult> {
|
|
|
89
88
|
}
|
|
90
89
|
if (typeof failure.code !== "number") {
|
|
91
90
|
const timedOut = failure.killed === true || (failure.signal !== undefined && failure.signal !== null);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
if (timedOut) {
|
|
92
|
+
let note = `killed after ${String(TIMEOUT_MS / 1000)}s timeout (signal: ${String(failure.signal ?? "SIGTERM")})`;
|
|
93
|
+
if (argv[0] === "run") {
|
|
94
|
+
// The timeout kills the CLI only; mutator/bench children are detached by design.
|
|
95
|
+
note +=
|
|
96
|
+
" — abathur run spawns detached children (mutator/bench sessions) " +
|
|
97
|
+
"that may still be running — the next 'abathur run' reaps them";
|
|
98
|
+
}
|
|
99
|
+
resolve({ status: 124, stdout: out, stderr: errOut, note });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
resolve({ status: -1, stdout: out, stderr: errOut, note: `spawn failed: ${failure.message}` });
|
|
100
103
|
return;
|
|
101
104
|
}
|
|
102
105
|
// Ordinary non-zero exit: the CLI's own verdict (blocked / cannot-answer).
|
|
@@ -114,11 +117,14 @@ export default {
|
|
|
114
117
|
description:
|
|
115
118
|
"Run the abathur evolution-harness CLI on this machine. " +
|
|
116
119
|
"Pass the top-level command word in `command` (one of: genome, run, status, " +
|
|
117
|
-
"
|
|
120
|
+
"bundle, graft, self-eval, kernel, --help) and every " +
|
|
118
121
|
"remaining argv token in `extra`. The call is spawned argv-only (no shell) " +
|
|
119
122
|
"with a 120s timeout; the result text always ends with the CLI exit code " +
|
|
120
|
-
"(0 ok, 1 blocked decision, 2 cannot-answer).
|
|
121
|
-
"
|
|
123
|
+
"(0 ok, 1 blocked decision, 2 cannot-answer). Honest privilege note: this " +
|
|
124
|
+
"tool carries bash-equivalent privilege — `run` and `genome` legitimately " +
|
|
125
|
+
"spawn mutator/engine binaries by design — so the allowlist limits typos " +
|
|
126
|
+
"and UX, not capability. `promote` and `tombstone` are deliberately NOT " +
|
|
127
|
+
"reachable here: they are human gates, run in a terminal.",
|
|
122
128
|
args: {
|
|
123
129
|
command: tool.schema
|
|
124
130
|
.string()
|