Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea9b4a695 | ||
|
|
fb3ad11f3a | ||
|
|
3fae7e084e | ||
|
|
fa3d0da80b | ||
|
|
f48794c2cc |
@@ -6,18 +6,15 @@ OpenCode V2 plugin providing a `coordinate` agent and fast/good/deep worker pair
|
|||||||
|
|
||||||
Requires **OpenCode V2** (`opencode2`), **Bun 1.3.14+**, Bash, curl, jq, and `sha256sum` (Linux/WSL). The native plugin API is pinned to `@opencode/plugin@0.0.0-beta-19378`; use the matching OpenCode beta.
|
Requires **OpenCode V2** (`opencode2`), **Bun 1.3.14+**, Bash, curl, jq, and `sha256sum` (Linux/WSL). The native plugin API is pinned to `@opencode/plugin@0.0.0-beta-19378`; use the matching OpenCode beta.
|
||||||
|
|
||||||
Download and inspect [install.sh](install.sh), then run it without sudo:
|
Run the installer without sudo:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl --fail --show-error --location \
|
curl -fsSL https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh | bash
|
||||||
https://git.krtss.de/StanPonomarev/stanp-opencode/raw/branch/main/install.sh \
|
|
||||||
--output install.sh
|
|
||||||
bash install.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
From a checkout, run `bash install.sh` directly. Rerun the same script to upgrade to the latest published, non-prerelease Gitea release.
|
From a checkout, run `bash install.sh` directly. Rerun the same script to upgrade to the latest published, non-prerelease Gitea release.
|
||||||
|
|
||||||
The installer downloads `stanp-opencode.tgz` and its SHA-256 checksum, verifies the archive, and installs its dependencies with lifecycle scripts disabled. It activates the plugin only after importing and checking the native V2 export. Downloads, dependency installation, and validation failures leave the previous active plugin untouched. The checksum detects corruption; it is not a signature independent of the release server.
|
The installer downloads `stanp-opencode.tgz` and its SHA-256 checksum, verifies and extracts the self-contained archive, then activates the plugin only after importing and checking the native V2 export. Download, extraction, and validation failures leave the previous active plugin untouched. The checksum detects corruption; it is not a signature independent of the release server.
|
||||||
|
|
||||||
Installation defaults to `~/.config/opencode` and respects `XDG_CONFIG_HOME`. Override it explicitly when needed:
|
Installation defaults to `~/.config/opencode` and respects `XDG_CONFIG_HOME`. Override it explicitly when needed:
|
||||||
|
|
||||||
@@ -25,13 +22,13 @@ Installation defaults to `~/.config/opencode` and respects `XDG_CONFIG_HOME`. Ov
|
|||||||
OPENCODE_CONFIG_DIR=/path/to/opencode-config bash install.sh
|
OPENCODE_CONFIG_DIR=/path/to/opencode-config bash install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer atomically updates the `plugins/stanp-opencode` symlink for [V2 automatic plugin discovery](https://opencode.ai/v2/docs/plugins), with dependencies in a private `stanp-opencode/install.*` directory beneath the same config directory. It does not edit `opencode.json` or `opencode.jsonc`, install OpenCode itself, or configure provider credentials. Older private installation directories are retained so running sessions can finish; remove unused ones after restarting OpenCode.
|
The installer atomically replaces the package directory at `plugins/stanp-opencode` and adds its default GPT configuration to `opencode.json`. Existing unrelated JSON settings and `opencode.jsonc` are preserved. It does not create symlinks, install package dependencies, install OpenCode itself, or configure provider credentials.
|
||||||
|
|
||||||
Restart `opencode2` after installation (or `opencode2 service restart` for an existing service). Connect your provider, then select `coordinate` in the agent picker. The default configuration uses OpenAI GPT models; agents are only enabled when the required models are available.
|
Restart `opencode2` after installation (or `opencode2 service restart` for an existing service). Connect your provider, then select `coordinate` in the agent picker. The default configuration uses OpenAI GPT models; agents are only enabled when the required models are available.
|
||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). When using the installer, point the example's plugin package entry at the installed package directory rather than the unpublished npm package name:
|
See [GPT configuration](examples/gpt.opencode.jsonc) and [custom model assignments](examples/custom.opencode.jsonc). When using the installer, point the example's plugin package entry at the installed package directory rather than the unpublished npm package name. Configured local plugin paths must be directories; pointing at `plugins/stanp-opencode.js` causes OpenCode to reject that configured entry and auto-load the file without its options:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -52,6 +49,8 @@ This relative path assumes the configuration file is in the selected global conf
|
|||||||
|
|
||||||
The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them.
|
The plugin supplies session-local `todoread` and `todowrite` tools when no other plugin provides them. In this pinned V2 beta, custom tools cannot open permission prompts: an `ask` or `deny` rule blocks these todo tools rather than bypassing the rule. Explicitly allow them if you want to use them.
|
||||||
|
|
||||||
|
When an MCP server named `codegraph` is already configured, its tools are exposed directly to `coordinate` and retain the built-in `build` agent's matching allow, ask, and deny rules. The plugin does not configure or start a missing server, and no other MCP server is exposed this way.
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
Remove the `plugins/stanp-opencode` symlink from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode. Once no running session uses it, the private `stanp-opencode` installation directory can also be removed.
|
Remove `plugins/stanp-opencode` from the selected config directory and remove any explicit configuration entry for it, then restart OpenCode.
|
||||||
|
|||||||
+45
-12
@@ -1,15 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
for command in bun curl jq sha256sum; do
|
for command in bun curl jq sha256sum tar; do
|
||||||
command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; }
|
command -v "$command" >/dev/null || { printf 'Required command not found: %s\n' "$command" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
config="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}"
|
config="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}"
|
||||||
mkdir -p "$config"
|
mkdir -p "$config"
|
||||||
config=$(cd "$config" && pwd -P)
|
config=$(cd "$config" && pwd -P)
|
||||||
mkdir -p "$config/stanp-opencode" "$config/plugins"
|
mkdir -p "$config/plugins"
|
||||||
install=$(mktemp -d "$config/stanp-opencode/install.XXXXXXXX")
|
install=$(mktemp -d "$config/install.stanp-opencode.XXXXXXXX")
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [[ -n "$install" ]]; then rm -rf "$install"; fi
|
if [[ -n "$install" ]]; then rm -rf "$install"; fi
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ trap cleanup EXIT
|
|||||||
api='https://git.krtss.de/api/v1/repos/StanPonomarev/stanp-opencode/releases/latest'
|
api='https://git.krtss.de/api/v1/repos/StanPonomarev/stanp-opencode/releases/latest'
|
||||||
curl --fail-with-body --silent --show-error --location --proto '=https' --proto-redir '=https' "$api" -o "$install/release.json"
|
curl --fail-with-body --silent --show-error --location --proto '=https' --proto-redir '=https' "$api" -o "$install/release.json"
|
||||||
for asset in stanp-opencode.tgz stanp-opencode.tgz.sha256; do
|
for asset in stanp-opencode.tgz stanp-opencode.tgz.sha256; do
|
||||||
url=$(jq -er --arg name "$asset" '[.assets[] | select(.name == $name) | .browser_download_url] | if length == 1 then .[0] | select(type == "string" and startswith("https://")) else error("Missing or duplicate release asset: " + $name) end' "$install/release.json")
|
url=$(jq -er --arg name "$asset" '[.assets[] | select(.name == $name)] | if length == 1 then .[0] | select((.id | type) == "number" and (.browser_download_url | type) == "string" and (.browser_download_url | startswith("https://"))) | "\(.browser_download_url)?asset_id=\(.id)" else error("Missing or duplicate release asset: " + $name) end' "$install/release.json")
|
||||||
curl --fail-with-body --silent --show-error --location --proto '=https' --proto-redir '=https' "$url" -o "$install/$asset"
|
curl --fail-with-body --silent --show-error --location --proto '=https' --proto-redir '=https' "$url" -o "$install/$asset"
|
||||||
done
|
done
|
||||||
read -r checksum _ < "$install/stanp-opencode.tgz.sha256"
|
read -r checksum _ < "$install/stanp-opencode.tgz.sha256"
|
||||||
@@ -26,13 +26,14 @@ if [[ ! "$checksum" =~ ^[a-fA-F0-9]{64}$ ]]; then
|
|||||||
printf 'Invalid release checksum\n' >&2
|
printf 'Invalid release checksum\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! (cd "$install" && printf '%s stanp-opencode.tgz\n' "$checksum" | sha256sum --check --status); then
|
hash_output=$(sha256sum "$install/stanp-opencode.tgz")
|
||||||
|
read -r actual _ <<< "$hash_output"
|
||||||
|
if [[ ! "$actual" =~ ^[a-fA-F0-9]{64}$ || "$actual" != "$checksum" ]]; then
|
||||||
printf 'Release checksum mismatch; installation unchanged\n' >&2
|
printf 'Release checksum mismatch; installation unchanged\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '{"private":true,"type":"module"}\n' > "$install/package.json"
|
tar -xzf "$install/stanp-opencode.tgz" -C "$install"
|
||||||
(cd "$install" && bun add --production --ignore-scripts --no-cache ./stanp-opencode.tgz)
|
|
||||||
bun -e '
|
bun -e '
|
||||||
if (!await Bun.file(process.argv[1]).exists()) {
|
if (!await Bun.file(process.argv[1]).exists()) {
|
||||||
throw new Error("Latest release lacks the OpenCode V2 directory entrypoint. Publish a V2-compatible release first.");
|
throw new Error("Latest release lacks the OpenCode V2 directory entrypoint. Publish a V2-compatible release first.");
|
||||||
@@ -41,10 +42,42 @@ bun -e '
|
|||||||
if (!plugin || plugin.id !== "stanp-opencode" || typeof plugin.setup !== "function") {
|
if (!plugin || plugin.id !== "stanp-opencode" || typeof plugin.setup !== "function") {
|
||||||
throw new Error("Latest release is not a native OpenCode V2 plugin. Publish a V2-compatible release first.");
|
throw new Error("Latest release is not a native OpenCode V2 plugin. Publish a V2-compatible release first.");
|
||||||
}
|
}
|
||||||
' "$install/node_modules/stanp-opencode/index.js"
|
' "$install/package/dist/index.js"
|
||||||
|
|
||||||
tag=$(jq -r '.tag_name' "$install/release.json")
|
tag=$(jq -r '.tag_name' "$install/release.json")
|
||||||
ln -s "../stanp-opencode/$(basename "$install")/node_modules/stanp-opencode" "$install/plugin"
|
config_file="$config/opencode.json"
|
||||||
mv -Tf "$install/plugin" "$config/plugins/stanp-opencode"
|
config_source="$config_file"
|
||||||
install=""
|
if [[ ! -e "$config_source" ]]; then
|
||||||
printf 'Installed stanp-opencode %s into %s/plugins/stanp-opencode\nRestart opencode2 to load it.\n' "$tag" "$config"
|
config_source="$install/empty-config.json"
|
||||||
|
printf '{}\n' > "$config_source"
|
||||||
|
fi
|
||||||
|
jq -e '
|
||||||
|
if type != "object" then error("opencode.json must contain an object") else . end
|
||||||
|
| if (.plugins // []) | type != "array" then error("opencode.json plugins must be an array") else . end
|
||||||
|
| .["$schema"] = (.["$schema"] // "https://opencode.ai/config.json")
|
||||||
|
| .plugins = (.plugins // [])
|
||||||
|
| if any(.plugins[]?; .package == "./plugins/stanp-opencode") then . else
|
||||||
|
.plugins += [{
|
||||||
|
package: "./plugins/stanp-opencode",
|
||||||
|
options: { mode: "gpt", provider: "openai", lunaPriority: true }
|
||||||
|
}]
|
||||||
|
end
|
||||||
|
' "$config_source" > "$install/opencode.json"
|
||||||
|
target="$config/plugins/stanp-opencode"
|
||||||
|
staged="$config/plugins/.stanp-opencode.new.$$"
|
||||||
|
backup="$config/plugins/.stanp-opencode.old.$$"
|
||||||
|
mkdir "$staged"
|
||||||
|
mv "$install/package/dist/index.js" "$staged/index.js"
|
||||||
|
cat > "$staged/package.json" <<'EOF'
|
||||||
|
{"name":"stanp-opencode","type":"module","main":"./index.js","exports":"./index.js"}
|
||||||
|
EOF
|
||||||
|
if [[ -e "$target" || -L "$target" ]]; then mv "$target" "$backup"; fi
|
||||||
|
if ! mv "$staged" "$target"; then
|
||||||
|
if [[ -e "$backup" || -L "$backup" ]]; then mv "$backup" "$target"; fi
|
||||||
|
printf 'Could not activate plugin; installation unchanged\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf "$backup"
|
||||||
|
rm -f "$config/plugins/stanp-opencode.js"
|
||||||
|
mv "$install/opencode.json" "$config_file"
|
||||||
|
printf 'Installed stanp-opencode %s into %s/plugins/stanp-opencode and updated %s\nRestart opencode2 to load it.\n' "$tag" "$config" "$config_file"
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
"format": "oxfmt --write .",
|
"format": "oxfmt --write .",
|
||||||
"format:check": "oxfmt --check .",
|
"format:check": "oxfmt --check .",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --packages external && tsc --project tsconfig.build.json --emitDeclarationOnly"
|
"build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --project tsconfig.build.json --emitDeclarationOnly"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode/client": "0.0.0-beta-19378",
|
"@opencode/client": "0.0.0-beta-19378",
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export default Plugin.define({
|
|||||||
Agent.ID.make("build"),
|
Agent.ID.make("build"),
|
||||||
).permissions;
|
).permissions;
|
||||||
let permissionKey = JSON.stringify(inherited);
|
let permissionKey = JSON.stringify(inherited);
|
||||||
|
let hasCodegraph = false;
|
||||||
|
let codegraphTools: string[] = [];
|
||||||
|
let agentTransformReady = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
// Subscribe before the first read: configuration can publish its catalog while
|
// Subscribe before the first read: configuration can publish its catalog while
|
||||||
// asynchronous setup is running.
|
// asynchronous setup is running.
|
||||||
@@ -80,6 +83,31 @@ export default Plugin.define({
|
|||||||
let workers: (typeof workerNames)[number][] = [];
|
let workers: (typeof workerNames)[number][] = [];
|
||||||
const active = new Set<string>();
|
const active = new Set<string>();
|
||||||
|
|
||||||
|
await context.mcp.transform((editor) => {
|
||||||
|
hasCodegraph = editor.get("codegraph") !== undefined;
|
||||||
|
if (hasCodegraph)
|
||||||
|
editor.update("codegraph", (server) => {
|
||||||
|
server.codemode = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await context.tool.transform((editor) => {
|
||||||
|
const next = editor
|
||||||
|
.list()
|
||||||
|
.map((tool) => tool.id)
|
||||||
|
.filter((id) => id.startsWith("codegraph_"))
|
||||||
|
.sort();
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(codegraphTools)) return;
|
||||||
|
codegraphTools = next;
|
||||||
|
if (agentTransformReady)
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void context.agent
|
||||||
|
.reload()
|
||||||
|
.catch((error) =>
|
||||||
|
console.error("stanp-opencode agent reload failed", error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await context.agent.transform((editor) => {
|
await context.agent.transform((editor) => {
|
||||||
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
|
const deep = selected.mode === "custom" || models.has("gpt-6-astra");
|
||||||
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
const candidates = workerNames.filter((name) => deep || !name.startsWith("deep-"));
|
||||||
@@ -146,6 +174,13 @@ export default Plugin.define({
|
|||||||
.filter((rule) => matches(action, rule.action))
|
.filter((rule) => matches(action, rule.action))
|
||||||
.map((rule) => ({ ...rule, action })),
|
.map((rule) => ({ ...rule, action })),
|
||||||
),
|
),
|
||||||
|
...(hasCodegraph
|
||||||
|
? codegraphTools.flatMap((action) =>
|
||||||
|
inherited
|
||||||
|
.filter((rule) => matches(action, rule.action))
|
||||||
|
.map((rule) => ({ ...rule, action })),
|
||||||
|
)
|
||||||
|
: []),
|
||||||
...workers.map((resource) => ({
|
...workers.map((resource) => ({
|
||||||
action: "subagent",
|
action: "subagent",
|
||||||
resource,
|
resource,
|
||||||
@@ -154,6 +189,7 @@ export default Plugin.define({
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
agentTransformReady = true;
|
||||||
async function assertTodo(agentID: Agent.ID, action: string) {
|
async function assertTodo(agentID: Agent.ID, action: string) {
|
||||||
const { data: agent } = await context.agent.get({ agentID });
|
const { data: agent } = await context.agent.get({ agentID });
|
||||||
const effect =
|
const effect =
|
||||||
|
|||||||
+76
-6
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
const installer = fileURLToPath(new URL("../install.sh", import.meta.url));
|
const installer = fileURLToPath(new URL("../install.sh", import.meta.url));
|
||||||
const directories: string[] = [];
|
const directories: string[] = [];
|
||||||
const configContents = '{\n // Keep user settings and comments.\n "theme": "dark"\n}\n';
|
const configContents = '{\n // Keep user settings and comments.\n "theme": "dark"\n}\n';
|
||||||
|
const jsonConfig = { permissions: { shell: "ask" }, plugins: [{ package: "existing-plugin" }] };
|
||||||
const previousPlugin = 'export default { id: "previous", setup() { return "preserved"; } };\n';
|
const previousPlugin = 'export default { id: "previous", setup() { return "preserved"; } };\n';
|
||||||
const previousTarget = "../stanp-opencode/install.previous";
|
const previousTarget = "../stanp-opencode/install.previous";
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ async function run(command: string[], cwd: string, env: NodeJS.ProcessEnv) {
|
|||||||
return { exitCode, stdout, stderr };
|
return { exitCode, stdout, stderr };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function install(mode: "native" | "checksum" | "legacy") {
|
async function install(mode: "native" | "checksum" | "legacy", configured = false) {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "stanp-install-"));
|
const directory = await mkdtemp(join(tmpdir(), "stanp-install-"));
|
||||||
directories.push(directory);
|
directories.push(directory);
|
||||||
const config =
|
const config =
|
||||||
@@ -60,6 +61,27 @@ async function install(mode: "native" | "checksum" | "legacy") {
|
|||||||
]);
|
]);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(join(config, "opencode.jsonc"), configContents),
|
writeFile(join(config, "opencode.jsonc"), configContents),
|
||||||
|
writeFile(
|
||||||
|
join(config, "opencode.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
configured
|
||||||
|
? {
|
||||||
|
...jsonConfig,
|
||||||
|
plugins: [
|
||||||
|
...jsonConfig.plugins,
|
||||||
|
{
|
||||||
|
package: "./plugins/stanp-opencode",
|
||||||
|
options: {
|
||||||
|
mode: "gpt",
|
||||||
|
provider: "existing-provider",
|
||||||
|
lunaPriority: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: jsonConfig,
|
||||||
|
),
|
||||||
|
),
|
||||||
symlink(previousTarget, join(config, "plugins", "stanp-opencode")),
|
symlink(previousTarget, join(config, "plugins", "stanp-opencode")),
|
||||||
writeFile(join(config, "stanp-opencode", "install.previous", "index.js"), previousPlugin),
|
writeFile(join(config, "stanp-opencode", "install.previous", "index.js"), previousPlugin),
|
||||||
writeFile(
|
writeFile(
|
||||||
@@ -114,7 +136,8 @@ async function install(mode: "native" | "checksum" | "legacy") {
|
|||||||
join(directory, "release.json"),
|
join(directory, "release.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
tag_name: "v1.0.0",
|
tag_name: "v1.0.0",
|
||||||
assets: ["stanp-opencode.tgz", "stanp-opencode.tgz.sha256"].map((name) => ({
|
assets: ["stanp-opencode.tgz", "stanp-opencode.tgz.sha256"].map((name, id) => ({
|
||||||
|
id: id + 1,
|
||||||
name,
|
name,
|
||||||
browser_download_url: `https://fixtures.invalid/${name}`,
|
browser_download_url: `https://fixtures.invalid/${name}`,
|
||||||
})),
|
})),
|
||||||
@@ -135,8 +158,8 @@ while (($#)); do
|
|||||||
done
|
done
|
||||||
case "$url" in
|
case "$url" in
|
||||||
https://git.krtss.de/api/v1/repos/StanPonomarev/stanp-opencode/releases/latest) source=release.json ;;
|
https://git.krtss.de/api/v1/repos/StanPonomarev/stanp-opencode/releases/latest) source=release.json ;;
|
||||||
https://fixtures.invalid/stanp-opencode.tgz) source=stanp-opencode.tgz ;;
|
https://fixtures.invalid/stanp-opencode.tgz?asset_id=1) source=stanp-opencode.tgz ;;
|
||||||
https://fixtures.invalid/stanp-opencode.tgz.sha256) source=stanp-opencode.tgz.sha256 ;;
|
https://fixtures.invalid/stanp-opencode.tgz.sha256?asset_id=2) source=stanp-opencode.tgz.sha256 ;;
|
||||||
*) printf 'Unexpected fixture URL: %s\\n' "$url" >&2; exit 1 ;;
|
*) printf 'Unexpected fixture URL: %s\\n' "$url" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
if [[ -n "$output" ]]; then
|
if [[ -n "$output" ]]; then
|
||||||
@@ -144,15 +167,27 @@ if [[ -n "$output" ]]; then
|
|||||||
else
|
else
|
||||||
cat "$FIXTURE_DIR/$source"
|
cat "$FIXTURE_DIR/$source"
|
||||||
fi
|
fi
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
join(bin, "sha256sum"),
|
||||||
|
`#!/usr/bin/env bash
|
||||||
|
if [[ "\${1:-}" == -* ]]; then
|
||||||
|
printf 'usage: sha256sum [-bctwz] [files ...]\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PATH="\${PATH#*:}"
|
||||||
|
export PATH
|
||||||
|
exec sha256sum "$@"
|
||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
await chmod(join(bin, "curl"), 0o755);
|
await Promise.all([chmod(join(bin, "curl"), 0o755), chmod(join(bin, "sha256sum"), 0o755)]);
|
||||||
const result = await run(["bash", installer], directory, env);
|
const result = await run(["bash", installer], directory, env);
|
||||||
return { directory, config, env, result };
|
return { directory, config, env, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("installs an importable native plugin under XDG without changing user configuration or running scripts", async () => {
|
test("installs an importable native plugin and default config under XDG without running scripts", async () => {
|
||||||
const { directory, config, env, result } = await install("native");
|
const { directory, config, env, result } = await install("native");
|
||||||
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||||
// The generated package path is only known at runtime; exercise its module-loading boundary.
|
// The generated package path is only known at runtime; exercise its module-loading boundary.
|
||||||
@@ -171,18 +206,53 @@ test("installs an importable native plugin under XDG without changing user confi
|
|||||||
stdout: '["stanp-opencode","native-ready"]\n',
|
stdout: '["stanp-opencode","native-ready"]\n',
|
||||||
});
|
});
|
||||||
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
||||||
|
expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
plugins: [
|
||||||
|
{ package: "existing-plugin" },
|
||||||
|
{
|
||||||
|
package: "./plugins/stanp-opencode",
|
||||||
|
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: { shell: "ask" },
|
||||||
|
});
|
||||||
|
expect(await Bun.file(join(config, "plugins", "stanp-opencode", "package.json")).exists()).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
||||||
).toBe(previousPlugin);
|
).toBe(previousPlugin);
|
||||||
expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false);
|
expect(await Bun.file(env.POSTINSTALL_MARKER).exists()).toBe(false);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
test("preserves an existing stanp-opencode configuration", async () => {
|
||||||
|
const { config, result } = await install("native", true);
|
||||||
|
expect(result, result.stderr).toMatchObject({ exitCode: 0 });
|
||||||
|
const installed = JSON.parse(await readFile(join(config, "opencode.json"), "utf8"));
|
||||||
|
expect(installed.plugins).toEqual([
|
||||||
|
{ package: "existing-plugin" },
|
||||||
|
{
|
||||||
|
package: "./plugins/stanp-opencode",
|
||||||
|
options: {
|
||||||
|
mode: "gpt",
|
||||||
|
provider: "existing-provider",
|
||||||
|
lunaPriority: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(installed.permissions).toEqual(jsonConfig.permissions);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
for (const mode of ["checksum", "legacy"] as const) {
|
for (const mode of ["checksum", "legacy"] as const) {
|
||||||
test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => {
|
test(`${mode} failure preserves the installed plugin in a custom path containing spaces`, async () => {
|
||||||
const { directory, config, env, result } = await install(mode);
|
const { directory, config, env, result } = await install(mode);
|
||||||
expect(result.exitCode, result.stderr).not.toBe(0);
|
expect(result.exitCode, result.stderr).not.toBe(0);
|
||||||
expect(await readlink(join(config, "plugins", "stanp-opencode"))).toBe(previousTarget);
|
expect(await readlink(join(config, "plugins", "stanp-opencode"))).toBe(previousTarget);
|
||||||
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
expect(await readFile(join(config, "opencode.jsonc"), "utf8")).toBe(configContents);
|
||||||
|
expect(JSON.parse(await readFile(join(config, "opencode.json"), "utf8"))).toEqual(
|
||||||
|
jsonConfig,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
await readFile(join(config, "stanp-opencode", "install.previous", "index.js"), "utf8"),
|
||||||
).toBe(previousPlugin);
|
).toBe(previousPlugin);
|
||||||
|
|||||||
+140
-18
@@ -5,6 +5,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types";
|
|||||||
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool";
|
import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool";
|
||||||
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client";
|
||||||
import type { SessionContext } from "@opencode/plugin/promise/session";
|
import type { SessionContext } from "@opencode/plugin/promise/session";
|
||||||
|
import { z } from "zod";
|
||||||
import { gptAgents } from "../src/config.js";
|
import { gptAgents } from "../src/config.js";
|
||||||
import plugin from "../src/index.js";
|
import plugin from "../src/index.js";
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ type FixtureSetup = (
|
|||||||
async function host(
|
async function host(
|
||||||
models: ModelInfo[] = allModels,
|
models: ModelInfo[] = allModels,
|
||||||
options: Plugin.Context["options"] = { provider: "test" },
|
options: Plugin.Context["options"] = { provider: "test" },
|
||||||
|
configuredMcp: Record<
|
||||||
|
string,
|
||||||
|
{ type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
||||||
|
> = {},
|
||||||
|
configuredMcpTools: string[] = [],
|
||||||
) {
|
) {
|
||||||
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
||||||
const build: DeepMutable<Agent.Info> = Agent.Info.default(Agent.ID.make("build"));
|
const build: DeepMutable<Agent.Info> = Agent.Info.default(Agent.ID.make("build"));
|
||||||
@@ -48,6 +54,50 @@ async function host(
|
|||||||
});
|
});
|
||||||
const storage = new Map<string, JsonValue>();
|
const storage = new Map<string, JsonValue>();
|
||||||
const tools = new Map<string, Info>();
|
const tools = new Map<string, Info>();
|
||||||
|
const mcpTools = new Set(configuredMcpTools);
|
||||||
|
const mcpServers = new Map(Object.entries(structuredClone(configuredMcp)));
|
||||||
|
let mcpTransform!: (editor: {
|
||||||
|
get(name: string): (typeof configuredMcp)[string] | undefined;
|
||||||
|
update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void;
|
||||||
|
}) => void;
|
||||||
|
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
||||||
|
const editMcp = () => ({
|
||||||
|
get: (name: string) => mcpServers.get(name),
|
||||||
|
update(name: string, update: (server: (typeof configuredMcp)[string]) => void) {
|
||||||
|
const server = mcpServers.get(name);
|
||||||
|
if (server) update(server);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const editTools = (): ToolEditor => ({
|
||||||
|
get(id) {
|
||||||
|
const tool = tools.get(id);
|
||||||
|
return tool ? { ...tool, id } : undefined;
|
||||||
|
},
|
||||||
|
list: () => [
|
||||||
|
...[...tools].map(([id, tool]) => ({ ...tool, id })),
|
||||||
|
...[...mcpTools].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: id,
|
||||||
|
input: z.object({}),
|
||||||
|
options: { codemode: false },
|
||||||
|
async execute() {
|
||||||
|
return { content: "" };
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
namespace() {},
|
||||||
|
add(tool) {
|
||||||
|
tools.set(tool.name, tool);
|
||||||
|
},
|
||||||
|
update(id, update) {
|
||||||
|
const tool = tools.get(id);
|
||||||
|
if (tool) update(tool);
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
tools.delete(id);
|
||||||
|
},
|
||||||
|
});
|
||||||
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
// SAFETY: This test host implements the native APIs used by setup; unrelated context
|
||||||
// services are deliberately absent so unexpected new dependencies fail the test.
|
// services are deliberately absent so unexpected new dependencies fail the test.
|
||||||
const context = {
|
const context = {
|
||||||
@@ -73,6 +123,13 @@ async function host(
|
|||||||
storage.set(key, value);
|
storage.set(key, value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
mcp: {
|
||||||
|
async transform(callback: typeof mcpTransform) {
|
||||||
|
mcpTransform = callback;
|
||||||
|
callback(editMcp());
|
||||||
|
return registration;
|
||||||
|
},
|
||||||
|
},
|
||||||
agent: {
|
agent: {
|
||||||
async list() {
|
async list() {
|
||||||
return { data: [...agents.values()] };
|
return { data: [...agents.values()] };
|
||||||
@@ -119,24 +176,8 @@ async function host(
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
async transform(callback: (editor: ToolEditor) => void) {
|
async transform(callback: (editor: ToolEditor) => void) {
|
||||||
callback({
|
toolTransforms.push(callback);
|
||||||
get(id) {
|
callback(editTools());
|
||||||
const tool = tools.get(id);
|
|
||||||
return tool ? { ...tool, id } : undefined;
|
|
||||||
},
|
|
||||||
list: () => [...tools].map(([id, tool]) => ({ ...tool, id })),
|
|
||||||
namespace() {},
|
|
||||||
add(tool) {
|
|
||||||
tools.set(tool.name, tool);
|
|
||||||
},
|
|
||||||
update(id, update) {
|
|
||||||
const tool = tools.get(id);
|
|
||||||
if (tool) update(tool);
|
|
||||||
},
|
|
||||||
remove(id) {
|
|
||||||
tools.delete(id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return registration;
|
return registration;
|
||||||
},
|
},
|
||||||
async hook(_name: string, callback: typeof before) {
|
async hook(_name: string, callback: typeof before) {
|
||||||
@@ -153,6 +194,7 @@ async function host(
|
|||||||
return {
|
return {
|
||||||
agents,
|
agents,
|
||||||
build,
|
build,
|
||||||
|
mcpServers,
|
||||||
defaultAgent,
|
defaultAgent,
|
||||||
originalBuild,
|
originalBuild,
|
||||||
async refresh(next: ModelInfo[]) {
|
async refresh(next: ModelInfo[]) {
|
||||||
@@ -167,6 +209,21 @@ async function host(
|
|||||||
events.enqueue({ type: "agent.updated" });
|
events.enqueue({ type: "agent.updated" });
|
||||||
await reloaded.promise;
|
await reloaded.promise;
|
||||||
},
|
},
|
||||||
|
async changeCodegraph(
|
||||||
|
server:
|
||||||
|
| { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
|
||||||
|
| undefined,
|
||||||
|
toolNames: string[],
|
||||||
|
) {
|
||||||
|
if (server) mcpServers.set("codegraph", server);
|
||||||
|
else mcpServers.delete("codegraph");
|
||||||
|
mcpTools.clear();
|
||||||
|
for (const name of toolNames) mcpTools.add(name);
|
||||||
|
reloaded = Promise.withResolvers<void>();
|
||||||
|
mcpTransform(editMcp());
|
||||||
|
for (const callback of toolTransforms) callback(editTools());
|
||||||
|
await reloaded.promise;
|
||||||
|
},
|
||||||
async todo(name: string, sessionID: string, input: JsonValue = {}) {
|
async todo(name: string, sessionID: string, input: JsonValue = {}) {
|
||||||
// SAFETY: Todo executors use only sessionID and agent from the native context.
|
// SAFETY: Todo executors use only sessionID and agent from the native context.
|
||||||
return tools
|
return tools
|
||||||
@@ -257,6 +314,71 @@ test("worker inheritance and coordinator isolation preserve build and default",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("coordinator inherits only codegraph MCP permissions when that server exists", async () => {
|
||||||
|
const absent = await host();
|
||||||
|
expect(
|
||||||
|
absent.agents
|
||||||
|
.get("coordinate")
|
||||||
|
?.permissions.some((rule) => rule.action.startsWith("codegraph_")),
|
||||||
|
).toBe(false);
|
||||||
|
expect(absent.mcpServers.has("codegraph")).toBe(false);
|
||||||
|
|
||||||
|
const result = await host(
|
||||||
|
allModels,
|
||||||
|
{ provider: "test" },
|
||||||
|
{
|
||||||
|
codegraph: { type: "local", command: ["codegraph"], codemode: true },
|
||||||
|
docs: { type: "local", command: ["docs"], codemode: true },
|
||||||
|
},
|
||||||
|
["codegraph_codegraph_search", "codegraph_future_query", "docs_search"],
|
||||||
|
);
|
||||||
|
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
|
||||||
|
expect(result.mcpServers.get("docs")?.codemode).toBe(true);
|
||||||
|
let coordinate = result.agents.get("coordinate")!;
|
||||||
|
expect(coordinate.permissions).toContainEqual({
|
||||||
|
action: "codegraph_codegraph_search",
|
||||||
|
resource: "*",
|
||||||
|
effect: "allow",
|
||||||
|
});
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action.startsWith("docs_"))).toBe(false);
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "execute")).toBe(false);
|
||||||
|
|
||||||
|
await result.changeBuildPermissions([
|
||||||
|
{ action: "*", resource: "*", effect: "allow" },
|
||||||
|
{ action: "code*", resource: "*", effect: "ask" },
|
||||||
|
{ action: "*search", resource: "*", effect: "deny" },
|
||||||
|
{ action: "docs_*", resource: "*", effect: "allow" },
|
||||||
|
{ action: "shell", resource: "*", effect: "allow" },
|
||||||
|
]);
|
||||||
|
coordinate = result.agents.get("coordinate")!;
|
||||||
|
expect(coordinate.permissions).toContainEqual({
|
||||||
|
action: "codegraph_future_query",
|
||||||
|
resource: "*",
|
||||||
|
effect: "ask",
|
||||||
|
});
|
||||||
|
expect(coordinate.permissions).toContainEqual({
|
||||||
|
action: "codegraph_codegraph_search",
|
||||||
|
resource: "*",
|
||||||
|
effect: "deny",
|
||||||
|
});
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "docs_*")).toBe(false);
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
|
||||||
|
|
||||||
|
await result.changeCodegraph(undefined, []);
|
||||||
|
coordinate = result.agents.get("coordinate")!;
|
||||||
|
expect(coordinate.permissions.some((rule) => rule.action.startsWith("codegraph_"))).toBe(false);
|
||||||
|
await result.changeCodegraph({ type: "local", command: ["codegraph"], codemode: true }, [
|
||||||
|
"codegraph_added_later",
|
||||||
|
]);
|
||||||
|
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
|
||||||
|
expect(result.agents.get("coordinate")?.permissions).toContainEqual({
|
||||||
|
action: "codegraph_added_later",
|
||||||
|
resource: "*",
|
||||||
|
effect: "ask",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => {
|
test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => {
|
||||||
const result = await host(allModels.slice(0, 2));
|
const result = await host(allModels.slice(0, 2));
|
||||||
for (const invoke of [result.delegate, result.command]) {
|
for (const invoke of [result.delegate, result.command]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user