Sitelet https://github.com/NodeOps-app/createos-plugins/commit/be8214f4eae2ded20bc2f21e1d437457f7b46e76
Skip to content

Commit be8214f

Browse files
committed
refactor(opencode-plugin): rewrite on CLI runtime, drop engine
1 parent 4c81605 commit be8214f

25 files changed

Lines changed: 3002 additions & 2794 deletions

‎packages/opencode-plugin/README.md‎

Lines changed: 153 additions & 190 deletions
Large diffs are not rendered by default.

‎packages/opencode-plugin/index.ts‎

Lines changed: 1 addition & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,151 +1 @@
1-
/**
2-
* @createos/opencode — run OpenCode's tools inside a remote, ephemeral CreateOS Sandbox.
3-
*
4-
* CLI-only: every operation goes through `createos` CLI. No HTTP client,
5-
* no API key env vars — just `createos login` and go.
6-
*/
7-
8-
import type { Plugin } from "@opencode-ai/plugin";
9-
import * as cli from "./src/cli.ts";
10-
import { createTools, type ToolSandbox } from "./src/tools.ts";
11-
import { shortId } from "./src/util.ts";
12-
13-
interface ActiveSandbox {
14-
sandboxId: string;
15-
cwd: string;
16-
}
17-
18-
export const CreateOSPlugin: Plugin = async ({ project, client, $, directory }) => {
19-
let active: ActiveSandbox | null = null;
20-
let initPromise: Promise<void> | null = null;
21-
const hostCwd = directory;
22-
23-
await client.app.log({
24-
body: { service: "createos", level: "info", message: "Plugin initialized" },
25-
});
26-
27-
if (process.env.CREATEOS_ENABLED === "false") {
28-
await client.app.log({
29-
body: { service: "createos", level: "info", message: "Disabled via CREATEOS_ENABLED=false" },
30-
});
31-
return {};
32-
}
33-
34-
// Lazy sandbox init — triggered on first tool call
35-
async function ensureSandbox(): Promise<ActiveSandbox> {
36-
if (active) return active;
37-
38-
if (!initPromise) {
39-
initPromise = (async () => {
40-
if (!(await cli.isCreateOSInstalled($))) {
41-
await client.app.log({
42-
body: { service: "createos", level: "info", message: "CLI not found — installing..." },
43-
});
44-
if (!(await cli.autoInstallCLI($))) {
45-
throw new Error(
46-
"Failed to install CreateOS CLI. Run: curl -sfL https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh | sh",
47-
);
48-
}
49-
}
50-
51-
if (!(await cli.isLoggedIn($))) {
52-
throw new Error("Not logged in to CreateOS. Run: createos login");
53-
}
54-
55-
const shape = process.env.CREATEOS_SHAPE ?? "s-2vcpu-2gb";
56-
const rootfs = process.env.CREATEOS_ROOTFS;
57-
const networkFlag = process.env.CREATEOS_NETWORKS;
58-
const networks = networkFlag
59-
? networkFlag
60-
.split(",")
61-
.map((n) => n.trim())
62-
.filter(Boolean)
63-
: undefined;
64-
65-
await client.app.log({
66-
body: { service: "createos", level: "info", message: `Creating sandbox (${shape})...` },
67-
});
68-
69-
const sandbox = await cli.createSandbox($, {
70-
shape,
71-
rootfs,
72-
ingress: true,
73-
networks,
74-
name: `opencode-${shortId(project?.id ?? "session")}`,
75-
});
76-
77-
const cwd = "/root/workspace";
78-
await cli.sandboxExec($, sandbox.id, `mkdir -p ${cwd}`);
79-
80-
active = { sandboxId: sandbox.id, cwd };
81-
82-
await client.app.log({
83-
body: {
84-
service: "createos",
85-
level: "info",
86-
message: `Sandbox ready: ${shortId(sandbox.id)} (${shape})${sandbox.ingress_url_template ? ` · ingress: ${sandbox.ingress_url_template}` : ""}`,
87-
},
88-
});
89-
})();
90-
}
91-
92-
await initPromise;
93-
if (!active) throw new Error("Sandbox initialization failed");
94-
return active;
95-
}
96-
97-
// Build tools with lazy init wrapper
98-
const getActive = (): ToolSandbox | null => active;
99-
const baseTools = createTools($, getActive);
100-
101-
const tools: Record<string, any> = {};
102-
for (const [name, def] of Object.entries(baseTools)) {
103-
const original = (def as any).execute;
104-
tools[name] = {
105-
...def,
106-
execute: async (args: any, ctx: any) => {
107-
await ensureSandbox();
108-
return original(args, ctx);
109-
},
110-
};
111-
}
112-
113-
return {
114-
"experimental.session.compacting": async (_input: any, output: any) => {
115-
if (!active) return;
116-
output.context.push(
117-
`## CreateOS Sandbox Environment\n` +
118-
`Sandbox: ${active.sandboxId}\n` +
119-
`Cwd: ${active.cwd}\n` +
120-
`Host dir: ${hostCwd}\n` +
121-
`All tools run remotely in this sandbox.\n` +
122-
`\n` +
123-
`Quick rules:\n` +
124-
`- Work with a finish line (a build, a test suite, a script) → sandbox_offload dir="${hostCwd}" command="…". ` +
125-
`ONE call: it creates the box, stages the dir, runs, and destroys the box. Do not hand-roll that out of ` +
126-
`sandbox_create + sandbox_exec — that drops egress restriction, the keepalive, and the guaranteed destroy.\n` +
127-
`- Several variants of that at once (shards, a config matrix) → sandbox_fanout\n` +
128-
`- Untrusted code or any ad-hoc script/snippet you would otherwise run locally → sandbox_run_code (code + lang)\n` +
129-
`- "mount/sync this dir" → sandbox_sync local_dir="${hostCwd}" remote_dir="/root/project"\n` +
130-
`- Port access → sandbox_preview_url (public URL) > sandbox_tunnel (localhost) > device VPN (last resort)\n` +
131-
`- Multi-node → sandbox_network_create + sandbox_create with network + sandbox_exec on other sandboxes\n` +
132-
`- Questions about CreateOS Sandbox itself (REST API, SDKs, CLI, limits) → fetch the matching page listed in ` +
133-
`https://createos.sh/docs/llms.txt (under /Sandbox/); every page is raw markdown at https://createos.sh/docs<path>.md`,
134-
);
135-
},
136-
137-
event: async ({ event }: { event: { type: string } }) => {
138-
if (event.type === "session.deleted" && active) {
139-
try {
140-
await cli.cleanupTempKey($);
141-
} catch {}
142-
try {
143-
await cli.destroySandbox($, active.sandboxId);
144-
} catch {}
145-
active = null;
146-
}
147-
},
148-
149-
tool: tools,
150-
};
151-
};
1+
export { default } from "./src/plugin.ts";
Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,28 @@
11
{
22
"name": "@createos/opencode",
3-
"version": "0.2.0",
4-
"description": "OpenCode plugin that runs all tool calls inside a remote CreateOS Sandbox",
5-
"type": "module",
6-
"main": "dist/index.js",
7-
"module": "dist/index.js",
8-
"repository": {
9-
"type": "git",
10-
"url": "git+https://github.com/NodeOps-app/createos-plugin.git",
11-
"directory": "packages/opencode-plugin"
12-
},
13-
"author": "CreateOS",
14-
"license": "Apache-2.0",
15-
"keywords": [
16-
"opencode",
17-
"opencode-plugin",
18-
"createos",
19-
"sandbox"
20-
],
3+
"version": "2.0.0",
4+
"description": "CreateOS sandbox tools and remote execution for OpenCode V2",
215
"files": [
22-
"dist"
6+
"index.ts",
7+
"src/**/*.ts",
8+
"src/guest.py",
9+
"!src/**/*.test.ts",
10+
"README.md"
2311
],
12+
"type": "module",
13+
"exports": {
14+
".": "./index.ts",
15+
"./rpc": "./src/rpc.ts"
16+
},
2417
"scripts": {
25-
"build": "bun build index.ts --outdir=dist --target node",
26-
"build:minify": "bun build index.ts --outdir=dist --target node --minify",
27-
"prepublishOnly": "bun run build:minify",
28-
"typecheck": "tsc --noEmit"
18+
"check": "tsc --noEmit",
19+
"test": "bun test"
2920
},
3021
"dependencies": {
31-
"@opencode-ai/plugin": "^1.16.2"
22+
"@opencode/plugin": "2.0.16"
3223
},
3324
"devDependencies": {
34-
"@types/node": "^22",
35-
"typescript": "^5.6.0"
25+
"@types/bun": "^1.3.0",
26+
"typescript": "^5.9.3"
3627
}
3728
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { spawn, type ChildProcess } from "node:child_process";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { subprocess } from "./cli.ts";
6+
import { log } from "./util.ts";
7+
8+
/** Plugin-owned local transports; child groups and ephemeral SSH keys share one lifetime. */
9+
export class Background {
10+
private entries = new Map<string, { child: ChildProcess; key?: string; done: Promise<void> }>();
11+
async start(args: string[], key?: string): Promise<string> {
12+
const id = crypto.randomUUID();
13+
const child = spawn(process.env.CREATEOS_BIN ?? "createos", args, {
14+
stdio: "ignore",
15+
detached: true,
16+
env: { ...process.env, NO_COLOR: "1" },
17+
});
18+
const done = new Promise<void>((resolve) => child.once("close", () => resolve()));
19+
await new Promise<void>((resolve, reject) => {
20+
child.once("spawn", resolve);
21+
child.once("error", reject);
22+
});
23+
this.entries.set(id, { child, key, done });
24+
return id;
25+
}
26+
async watch(id: string, local: string, remote: string, mode: string): Promise<string> {
27+
const dir = await mkdtemp(join(tmpdir(), "createos-sync-"));
28+
try {
29+
const key = join(dir, "id_ed25519");
30+
const result = await subprocess("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", key]);
31+
if (result.code !== 0) throw new Error(result.stderr);
32+
return await this.start(
33+
[
34+
"sandbox",
35+
"sync",
36+
"--yes",
37+
"--local",
38+
local,
39+
"--remote",
40+
remote,
41+
"--mode",
42+
mode,
43+
"--exclude",
44+
".git",
45+
"--exclude",
46+
"node_modules",
47+
"--exclude",
48+
".env",
49+
"-i",
50+
key,
51+
id,
52+
],
53+
dir,
54+
);
55+
} catch (error) {
56+
await rm(dir, { recursive: true, force: true });
57+
throw error;
58+
}
59+
}
60+
list() {
61+
return [...this.entries].map(([id, entry]) => ({
62+
id,
63+
running: entry.child.exitCode === null && entry.child.signalCode === null,
64+
}));
65+
}
66+
async stop(id: string): Promise<void> {
67+
const entry = this.entries.get(id);
68+
if (!entry) throw new Error("Unknown local transport ID");
69+
if (entry.child.pid && entry.child.exitCode === null && entry.child.signalCode === null) {
70+
const kill = (signal: NodeJS.Signals) => {
71+
try {
72+
process.kill(-entry.child.pid!, signal);
73+
} catch (error) {
74+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
75+
}
76+
};
77+
kill("SIGTERM");
78+
const timer = setTimeout(() => {
79+
try {
80+
kill("SIGKILL");
81+
} catch (error) {
82+
log("transport.kill.failed", error);
83+
}
84+
}, 1000);
85+
try {
86+
await entry.done;
87+
} finally {
88+
clearTimeout(timer);
89+
}
90+
}
91+
if (entry.key) await rm(entry.key, { recursive: true, force: true });
92+
this.entries.delete(id);
93+
}
94+
async close(): Promise<void> {
95+
const results = await Promise.allSettled([...this.entries.keys()].map((id) => this.stop(id)));
96+
const errors = results.filter((item) => item.status === "rejected").map((item) => item.reason);
97+
if (errors.length) throw new AggregateError(errors, "Transport cleanup failed");
98+
}
99+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { test, expect } from "bun:test";
2+
import { CLI, subprocess, type Result } from "./cli.ts";
3+
const json = (value: unknown): Result => ({
4+
code: 0,
5+
stdout: JSON.stringify(value),
6+
stderr: "",
7+
truncated: false,
8+
});
9+
10+
test("managed execution returns remote exit code and both output streams", async () => {
11+
const cli = new CLI(async (args) => {
12+
if (args.includes("start")) return json({ process_id: "opaque-process" });
13+
if (args.includes("wait")) return json({ exit_code: 7 });
14+
if (args.includes("attach")) return { code: 0, stdout: "out", stderr: "err", truncated: false };
15+
return json({});
16+
});
17+
expect(await cli.execute("box", "exit 7", "/work", 1000)).toEqual({
18+
code: 7,
19+
stdout: "out",
20+
stderr: "err",
21+
truncated: false,
22+
processId: "opaque-process",
23+
});
24+
});
25+
26+
test("cancellation during process allocation stops the returned remote tree", async () => {
27+
const controller = new AbortController();
28+
const calls: string[][] = [];
29+
const cli = new CLI(async (args) => {
30+
calls.push(args);
31+
if (args.includes("start")) {
32+
controller.abort();
33+
return json({ process_id: "late-process" });
34+
}
35+
return json({});
36+
});
37+
await expect(cli.execute("box", "sleep 100", "/work", 1000, controller.signal)).rejects.toThrow(
38+
"late-process",
39+
);
40+
expect(calls.some((args) => args.includes("stop") && args.includes("late-process"))).toBe(true);
41+
expect(calls.some((args) => args.includes("wait"))).toBe(false);
42+
});
43+
44+
test("CLI arguments are literal; output is bounded while streams drain", async () => {
45+
const result = await subprocess("bash", [
46+
"-c",
47+
"printf '%s' \"$1\"; head -c 3000000 /dev/zero",
48+
"_",
49+
"$(echo injected)",
50+
]);
51+
expect(result.stdout.startsWith("$(echo injected)")).toBe(true);
52+
expect(result.truncated).toBe(true);
53+
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(2 * 1024 * 1024);
54+
});
55+
56+
test("timeout terminates a blocked CLI child", async () => {
57+
await expect(subprocess("sleep", ["60"], { timeout: 20 })).rejects.toThrow("timed out");
58+
});
59+
60+
test("zero deadline remains cancellable without imposing an immediate timeout", async () => {
61+
const controller = new AbortController();
62+
const running = subprocess("sleep", ["60"], { timeout: 0, signal: controller.signal });
63+
const abort = setTimeout(() => controller.abort(), 20);
64+
try {
65+
await expect(running).rejects.toThrow("cancelled");
66+
} finally {
67+
clearTimeout(abort);
68+
}
69+
});

0 commit comments

Comments
 (0)