daytonaWorkspacePlugin.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import type { Daytona, Sandbox } from "@daytonaio/sdk"
  2. import type { Plugin } from "@opencode-ai/plugin"
  3. import { join } from "node:path"
  4. import { fileURLToPath } from "node:url"
  5. import { tmpdir } from "node:os"
  6. import { access, mkdir } from "node:fs/promises"
  7. import { randomUUID } from "node:crypto"
  8. let client: Promise<Daytona> | undefined
  9. let daytona = function daytona(): Promise<Daytona> {
  10. if (client == null) {
  11. client = import("@daytonaio/sdk").then(
  12. ({ Daytona }) =>
  13. new Daytona({
  14. apiKey: "dtn_2ffe19d27837953f1a46cc297d8a5331d4c46b00856eb5f4a4afded3f3426038",
  15. }),
  16. )
  17. }
  18. return client
  19. }
  20. const preview = new Map<string, { url: string; token: string }>()
  21. const repo = "/home/daytona/workspace/repo"
  22. const local = fileURLToPath(
  23. new URL("./packages/opencode/dist/opencode-linux-x64-baseline/bin/opencode", import.meta.url),
  24. )
  25. const bootstrap = fileURLToPath(new URL("./daytonaWorkspaceBootstrap.sh", import.meta.url))
  26. async function exists(file: string) {
  27. return access(file)
  28. .then(() => true)
  29. .catch(() => false)
  30. }
  31. function sh(value: string) {
  32. return `'${value.replace(/'/g, `'"'"'`)}'`
  33. }
  34. async function boot() {
  35. return Bun.file(bootstrap).text()
  36. }
  37. // Internally Daytona uses axios, which tries to overwrite stack
  38. // traces when a failure happens. That path fails in Bun, however, so
  39. // when something goes wrong you only see a very obscure error.
  40. async function withSandbox<T>(name: string, fn: (sandbox: Sandbox) => Promise<T>) {
  41. const stack = Error.captureStackTrace
  42. // @ts-expect-error temporary compatibility hack for Daytona's axios stack handling in Bun
  43. Error.captureStackTrace = undefined
  44. try {
  45. return await fn(await (await daytona()).get(name))
  46. } finally {
  47. Error.captureStackTrace = stack
  48. }
  49. }
  50. export const DaytonaWorkspacePlugin: Plugin = async ({ experimental_workspace, worktree, project }) => {
  51. experimental_workspace.register("daytona", {
  52. name: "Daytona",
  53. description: "Create a remote Daytona workspace",
  54. configure(config) {
  55. return config
  56. },
  57. async create(config) {
  58. const temp = join(tmpdir(), `opencode-daytona-${randomUUID()}`)
  59. console.log("creating sandbox...")
  60. const sandbox = await (
  61. await daytona()
  62. ).create({
  63. name: config.name,
  64. envVars: {
  65. foo: "bar",
  66. },
  67. })
  68. const sid = `setup-${randomUUID()}`
  69. await sandbox.process.createSession(sid)
  70. try {
  71. console.log("creating ssh...")
  72. const ssh = await withSandbox(config.name, (sandbox) => sandbox.createSshAccess())
  73. console.log("daytona:", ssh.sshCommand)
  74. const run = async (command: string, opts?: { stream?: boolean }) => {
  75. if (!opts?.stream) {
  76. const result = await sandbox.process.executeCommand(command)
  77. if (result.exitCode === 0) return result
  78. throw new Error(result.result || `sandbox command failed: ${command}`)
  79. }
  80. const res = await sandbox.process.executeSessionCommand(sid, { command, runAsync: true })
  81. if (!res.cmdId) throw new Error(`sandbox command failed to start: ${command}`)
  82. let out = ""
  83. let err = ""
  84. await sandbox.process.getSessionCommandLogs(
  85. sid,
  86. res.cmdId,
  87. (chunk) => {
  88. out += chunk
  89. process.stdout.write(chunk)
  90. },
  91. (chunk) => {
  92. err += chunk
  93. process.stderr.write(chunk)
  94. },
  95. )
  96. for (let i = 0; i < 120; i++) {
  97. const cmd = await sandbox.process.getSessionCommand(sid, res.cmdId)
  98. if (typeof cmd.exitCode !== "number") {
  99. await Bun.sleep(500)
  100. continue
  101. }
  102. if (cmd.exitCode === 0) return cmd
  103. throw new Error(err || out || `sandbox command failed: ${command}`)
  104. }
  105. throw new Error(`sandbox command timed out waiting for exit code: ${command}`)
  106. }
  107. const dir = join(temp, "repo")
  108. const tar = join(temp, "repo.tgz")
  109. const scr = join(temp, "bootstrap.sh")
  110. const source = `file://${worktree}`
  111. await mkdir(temp, { recursive: true })
  112. const args = ["clone", "--depth", "1", "--no-local"]
  113. if (config.branch) args.push("--branch", config.branch)
  114. args.push(source, dir)
  115. console.log("git cloning...")
  116. const clone = Bun.spawn(["git", ...args], {
  117. cwd: tmpdir(),
  118. stdout: "pipe",
  119. stderr: "pipe",
  120. })
  121. const code = await clone.exited
  122. if (code !== 0) throw new Error(await new Response(clone.stderr).text())
  123. console.log("tarring...")
  124. const packed = Bun.spawn(["tar", "-czf", tar, "-C", temp, "repo"], {
  125. stdout: "ignore",
  126. stderr: "pipe",
  127. })
  128. if ((await packed.exited) !== 0) throw new Error(await new Response(packed.stderr).text())
  129. console.log("writing bootstrap script...")
  130. await Bun.write(scr, await boot())
  131. console.log("uploading files...")
  132. await sandbox.fs.uploadFile(tar, "repo.tgz")
  133. await sandbox.fs.uploadFile(scr, "bootstrap.sh")
  134. console.log("local", local)
  135. if (await exists(local)) {
  136. console.log("uploading local binary...")
  137. await sandbox.fs.uploadFile(local, "opencode")
  138. }
  139. console.log("bootstrapping workspace...")
  140. await run(`bash bootstrap.sh ${sh(project.id)}`, {
  141. stream: true,
  142. })
  143. return
  144. } finally {
  145. await sandbox.process.deleteSession(sid).catch(() => undefined)
  146. }
  147. },
  148. async remove(config) {
  149. const sandbox = await (await daytona()).get(config.name).catch(() => undefined)
  150. if (!sandbox) return
  151. await (await daytona()).delete(sandbox)
  152. preview.delete(config.name)
  153. },
  154. async target(config) {
  155. let link = preview.get(config.name)
  156. if (!link) {
  157. link = await withSandbox(config.name, (sandbox) => sandbox.getPreviewLink(3096))
  158. preview.set(config.name, link)
  159. }
  160. return {
  161. type: "remote",
  162. url: link.url,
  163. headers: {
  164. "x-daytona-preview-token": link.token,
  165. "x-daytona-skip-preview-warning": "true",
  166. "x-opencode-directory": repo,
  167. },
  168. }
  169. },
  170. })
  171. return {}
  172. }
  173. export default DaytonaWorkspacePlugin