Skip to content

Commit 938160b

Browse files
committed
feat(cli): embed persistent PTY service binaries
1 parent 004b647 commit 938160b

13 files changed

Lines changed: 280 additions & 5 deletions

File tree

packages/cli/script/build.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
99
import pkg from "../package.json"
1010
import { buildAppArchive } from "./app-assets"
1111
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
12+
import { resolveOpencodePty } from "./opencode-pty"
1213

1314
const dir = path.resolve(import.meta.dirname, "..")
1415
const binary = "opencode2"
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
7879
}
7980

8081
for (const item of targets) {
82+
const opencodePty = await resolveOpencodePty({
83+
platform: item.os,
84+
arch: item.arch,
85+
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
86+
})
87+
const opencodePtyPlugin: BunPlugin = {
88+
name: "opencode-pty-binary",
89+
setup(build) {
90+
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
91+
loader: "js",
92+
contents: opencodePty
93+
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
94+
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
95+
: "export default undefined",
96+
}))
97+
},
98+
}
8199
const simulationInputs = new Set<string>()
82100
const simulationGraphPlugin: BunPlugin = {
83101
name: "opencode-simulation-graph",
@@ -105,7 +123,7 @@ for (const item of targets) {
105123
const result = await Bun.build({
106124
entrypoints: ["./src/index.ts"],
107125
tsconfig: "./tsconfig.json",
108-
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
126+
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
109127
external: ["node-gyp"],
110128
format: "esm",
111129
minify: true,

packages/cli/script/node-assets.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
55
import { getNodeAssets } from "@opentui/core/node-assets"
66
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
77
import { collectFiles } from "./files"
8+
import { resolveOpencodePty } from "./opencode-pty"
89

910
const dir = path.resolve(import.meta.dirname, "..")
1011

@@ -18,6 +19,11 @@ export type NodeAsset = {
1819
}
1920

2021
export async function collectNodeAssets(target: NodeTarget) {
22+
const opencodePty = await resolveOpencodePty({
23+
platform: target.platform,
24+
arch: target.arch,
25+
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
26+
})
2127
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
2228
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
2329
const assets: NodeAsset[] = [
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
4147
key,
4248
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
4349
})),
50+
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
4451
...(await collectFiles(ptyRoot))
4552
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
4653
.map((relative) => ({
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { spawnSync } from "node:child_process"
2+
import { createHash } from "node:crypto"
3+
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
4+
import os from "node:os"
5+
import path from "node:path"
6+
7+
const VERSION = "0.1.5"
8+
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
9+
const SHA256 = {
10+
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
11+
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
12+
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
13+
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
14+
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
15+
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
16+
} as const
17+
18+
export type OpencodePtyAsset = {
19+
readonly source: string
20+
readonly version: string
21+
readonly sha256: string
22+
}
23+
24+
type Target = {
25+
readonly platform: string
26+
readonly arch: string
27+
readonly libc?: "glibc" | "musl"
28+
}
29+
30+
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
31+
32+
export function resolveOpencodePty(target: Target) {
33+
const rustTarget = targetName(target)
34+
if (!rustTarget) return Promise.resolve(undefined)
35+
const existing = pending.get(rustTarget)
36+
if (existing) return existing
37+
const result = acquire(rustTarget).catch((error) => {
38+
pending.delete(rustTarget)
39+
throw error
40+
})
41+
pending.set(rustTarget, result)
42+
return result
43+
}
44+
45+
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
46+
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
47+
const executable = path.join(root, "opencode-pty")
48+
const cached = await readFile(executable).catch(() => undefined)
49+
if (cached)
50+
return {
51+
source: executable,
52+
version: VERSION,
53+
sha256: createHash("sha256").update(cached).digest("hex"),
54+
}
55+
56+
await mkdir(root, { recursive: true })
57+
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
58+
const response = await fetch(`${RELEASE}/${archiveName}`)
59+
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
60+
const archive = new Uint8Array(await response.arrayBuffer())
61+
const actual = createHash("sha256").update(archive).digest("hex")
62+
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
63+
64+
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
65+
try {
66+
const archivePath = path.join(temporary, archiveName)
67+
await writeFile(archivePath, archive)
68+
run("tar", ["-xzf", archivePath, "-C", temporary])
69+
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
70+
const bytes = await readFile(source)
71+
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
72+
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
73+
await rename(staged, executable).catch(async (error) => {
74+
await rm(staged, { force: true })
75+
if (!(await readFile(executable).catch(() => undefined))) throw error
76+
})
77+
const installed = await readFile(executable)
78+
return {
79+
source: executable,
80+
version: VERSION,
81+
sha256: createHash("sha256").update(installed).digest("hex"),
82+
}
83+
} finally {
84+
await rm(temporary, { recursive: true, force: true })
85+
}
86+
}
87+
88+
function targetName(target: Target): keyof typeof SHA256 | undefined {
89+
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
90+
if (!arch) return undefined
91+
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
92+
if (target.platform === "linux" && target.libc === "musl")
93+
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
94+
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
95+
return undefined
96+
}
97+
98+
function run(command: string, args: readonly string[]) {
99+
const result = spawnSync(command, args, { stdio: "inherit" })
100+
if (result.error) throw result.error
101+
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
102+
}

packages/cli/src/node/target.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
1313
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
1414
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
1515
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
16+
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
1617

1718
return {
1819
platform: targetPlatform,
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
2526
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
2627
fffFfiPackage,
2728
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
29+
opencodePtyAsset,
2830
}
2931
}
3032

packages/cli/test/node-assets.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
88
const keys = assets.map((asset) => asset.key)
99

1010
expect(new Set(keys).size).toBe(keys.length)
11+
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
1112
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
1213
{
1314
key: shellParserWasmAssets.runtime,

packages/cli/vite.node.config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
120120
input.target.platform === "darwin"
121121
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
122122
: undefined
123+
const opencodePtyAsset = input.target.opencodePtyAsset
123124
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
124125
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
125126
export const Agent = sdk.Agent
@@ -200,22 +201,24 @@ if (__ocIsSea()) {
200201
const __ocAssetRoot = __ocIsSea()
201202
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
202203
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
204+
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
203205
if (__ocIsSea()) {
206+
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
204207
for (const __ocKey of __ocAssetKeys()) {
205208
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
206209
if (__ocExists(__ocTarget)) continue
207210
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
208211
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
209212
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
213+
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
214+
__ocChmod(__ocTemporary, 0o755)
210215
try {
211216
__ocRename(__ocTemporary, __ocTarget)
212217
} catch (__ocError) {
213218
__ocRm(__ocTemporary, { force: true })
214219
if (!__ocExists(__ocTarget)) throw __ocError
215220
}
216221
}
217-
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
218-
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
219222
}
220223
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
221224
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
227230
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
228231
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
229232
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
233+
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
230234
try {
231235
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
232236
} catch {}

packages/core/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
"node": "./src/pty/pty.node.ts",
4242
"default": "./src/pty/pty.bun.ts"
4343
},
44+
"#persistent-pty-binary": {
45+
"workerd": "./src/persistent-pty/binary.workerd.ts",
46+
"bun": "./src/persistent-pty/binary.bun.ts",
47+
"node": "./src/persistent-pty/binary.node.ts",
48+
"default": "./src/persistent-pty/binary.bun.ts"
49+
},
4450
"#fff": {
4551
"workerd": "./src/filesystem/fff.workerd.ts",
4652
"bun": "./src/filesystem/fff.bun.ts",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { createHash } from "node:crypto"
2+
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
3+
import path from "node:path"
4+
import asset from "./pty-binding.js"
5+
6+
export async function resolveBinary(bin: string) {
7+
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
8+
if (!asset) return "opencode-pty"
9+
return install(bin, asset)
10+
}
11+
12+
export async function install(
13+
bin: string,
14+
input: { readonly path: string; readonly version: string; readonly sha256: string },
15+
) {
16+
const root = path.join(bin, "opencode-pty")
17+
await privateDirectory(root)
18+
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
19+
await privateDirectory(directory)
20+
const destination = path.join(directory, "opencode-pty")
21+
if (await exists(destination, input.sha256)) return destination
22+
23+
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
24+
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
25+
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
26+
try {
27+
const file = await open(temporary, "wx", 0o700)
28+
try {
29+
await file.writeFile(bytes)
30+
await file.sync()
31+
} finally {
32+
await file.close()
33+
}
34+
await chmod(temporary, 0o755)
35+
await rename(temporary, destination).catch(async (error) => {
36+
if (!(await exists(destination, input.sha256))) throw error
37+
})
38+
} finally {
39+
await rm(temporary, { force: true })
40+
}
41+
return validate(destination, input.sha256)
42+
}
43+
44+
async function privateDirectory(directory: string) {
45+
await mkdir(directory, { recursive: true, mode: 0o700 })
46+
const info = await lstat(directory)
47+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
48+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
49+
if (uid !== undefined && info.uid !== uid)
50+
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
51+
await chmod(directory, 0o700)
52+
}
53+
54+
async function exists(file: string, expected: string) {
55+
try {
56+
await validate(file, expected)
57+
return true
58+
} catch (error) {
59+
if (isMissing(error)) return false
60+
throw error
61+
}
62+
}
63+
64+
async function validate(file: string, expected?: string) {
65+
const info = await lstat(file)
66+
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
67+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
68+
if (uid !== undefined && info.uid !== uid)
69+
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
70+
if (expected && sha256(await readFile(file)) !== expected)
71+
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
72+
await chmod(file, 0o755)
73+
return file
74+
}
75+
76+
function sha256(bytes: Uint8Array) {
77+
return createHash("sha256").update(bytes).digest("hex")
78+
}
79+
80+
function isMissing(error: unknown): error is NodeJS.ErrnoException {
81+
return error instanceof Error && "code" in error && error.code === "ENOENT"
82+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export async function resolveBinary() {
2+
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export async function resolveBinary(): Promise<string> {
2+
throw new Error("Persistent PTYs are unavailable in this runtime")
3+
}

0 commit comments

Comments
 (0)