diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts index e75e179..bef1745 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts @@ -31,24 +31,28 @@ class Logger { } catch (err) { // Directory may already exist, ignore } - // Trim log file if it exceeds 3MB (keep last 1MB) + // Trim by byte length (not characters) so the 1MB target holds for non-ASCII logs try { const stats = statSync(this.logFile) const maxSize = 3 * 1024 * 1024 const keepSize = 1024 * 1024 if (stats.size > maxSize) { - const content = readFileSync(this.logFile, 'utf8') - const trimmed = content.slice(-keepSize) + const buffer = readFileSync(this.logFile) + const trimmed = buffer.subarray(buffer.length - keepSize) // Drop partial first line so we don't start mid-log const firstNewline = trimmed.indexOf('\n') - writeFileSync(this.logFile, firstNewline >= 0 ? trimmed.slice(firstNewline + 1) : trimmed) + writeFileSync(this.logFile, firstNewline >= 0 ? trimmed.subarray(firstNewline + 1) : trimmed) } } catch (err) { // File may not exist yet, ignore } const timestamp = new Date().toISOString() const logEntry = `[${timestamp}] [${level}] ${message}\n` - appendFileSync(this.logFile, logEntry) + try { + appendFileSync(this.logFile, logEntry) + } catch (err) { + // Best-effort logging: never let a write failure crash the caller + } } info(message: string): void { diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts index 32f40a1..57db5ad 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts @@ -26,20 +26,34 @@ export class ProjectDataStorage { } /** - * Get the file path for a project's session data + * Get the file path for a project's session data. + * encodeURIComponent gives a reversible, collision-free encoding that also + * strips path separators, so a projectId can't traverse outside storageDir + * and distinct ids can't collide onto the same file. */ private getProjectFilePath(projectId: string): string { - return join(this.storageDir, `${projectId}.json`) + return join(this.storageDir, `${encodeURIComponent(projectId)}.json`) } /** - * List known project IDs from storage. + * List known project IDs from storage, decoded to the canonical form used by callers. + * Filenames that can't be decoded (e.g. hand-created files with invalid percent escapes) + * are skipped: exposing them would return an id that can't round-trip through + * getProjectFilePath, causing subsequent load/save/remove to silently target the wrong file. */ private listProjectIds(): string[] { try { - return readdirSync(this.storageDir) - .filter((name) => name.endsWith('.json')) - .map((name) => name.slice(0, -'.json'.length)) + const ids: string[] = [] + for (const name of readdirSync(this.storageDir)) { + if (!name.endsWith('.json')) continue + const encoded = name.slice(0, -'.json'.length) + try { + ids.push(decodeURIComponent(encoded)) + } catch { + logger.warn(`Skipping project data file with undecodable name: ${name}`) + } + } + return ids } catch (err) { logger.error(`Failed to list project data files: ${err}`) return [] @@ -86,20 +100,26 @@ export class ProjectDataStorage { sessions: {}, } - // Remove from source first (best effort). + // Write the destination first and confirm it landed on disk, so a write + // failure can never delete the source before the copy is safely persisted. + destination.sessions[sessionId] = found + // Prefer the worktree for the project we're actually operating on. + destination.worktree = worktree + this.save(projectId, destination) + + if (!this.load(projectId)?.sessions?.[sessionId]) { + logger.error(`Migration of session ${sessionId} to project ${projectId} did not persist; leaving source intact`) + return found + } + + // Destination is safe; now remove from the source (best effort). try { delete otherData!.sessions[sessionId] - this.save(otherProjectId, otherData!.worktree, otherData!.sessions) + this.save(otherProjectId, otherData!) } catch (err) { logger.warn(`Failed to remove session ${sessionId} from project ${otherProjectId}: ${err}`) } - // Add to destination and persist. - destination.sessions[sessionId] = found - // Prefer the worktree for the project we're actually operating on. - destination.worktree = worktree - this.save(projectId, destination.worktree, destination.sessions) - logger.info(`Migrated session ${sessionId} from project ${otherProjectId} to project ${projectId}`) return found } @@ -108,21 +128,42 @@ export class ProjectDataStorage { } /** - * Save project session data to disk + * Read-only lookup of a session across all project files. Unlike getSession, this never + * migrates or writes, so it is safe to use on the delete path. */ - save(projectId: string, worktree: string, sessions: Record): void { - const filePath = this.getProjectFilePath(projectId) - const projectData: ProjectSessionData = { - projectId, - worktree, - sessions, + findSession(sessionId: string): { projectId: string; worktree: string; session: SessionInfo } | undefined { + for (const projectId of this.listProjectIds()) { + const data = this.load(projectId) + const session = data?.sessions?.[sessionId] + if (session && data) { + // Return the filename-derived projectId (the value that maps back to the file we + // just loaded), NOT data.projectId. The delete cleanup path passes this value to + // removeSession → load → getProjectFilePath; if we returned the raw canonical id + // and it doesn't round-trip identically (e.g. legacy files with a different + // sanitization scheme), the cleanup would silently target a different file and + // leave the stale mapping behind. + return { projectId, worktree: data.worktree, session } + } } + return undefined + } + /** + * Save project session data to disk. + * + * `storageKey` identifies WHICH FILE on disk to write (round-trips through + * getProjectFilePath). `projectData.projectId` is the CANONICAL id written into + * the JSON body — kept separate so callers who reached a file via a filename-derived + * key (e.g. findSession → removeSession for legacy files) don't clobber the + * pre-existing canonical id with the storage key. + */ + save(storageKey: string, projectData: ProjectSessionData): void { + const filePath = this.getProjectFilePath(storageKey) try { writeFileSync(filePath, JSON.stringify(projectData, null, 2)) - logger.info(`Saved project data for ${projectId}`) + logger.info(`Saved project data for ${projectData.projectId}`) } catch (err) { - logger.error(`Failed to save project data for ${projectId}: ${err}`) + logger.error(`Failed to save project data for ${projectData.projectId} at ${filePath}: ${err}`) } } @@ -173,7 +214,11 @@ export class ProjectDataStorage { } } - this.save(projectId, worktree, projectData.sessions) + // Refresh worktree from the current call (state that can change over time), but + // NEVER refresh projectData.projectId — that's identity, set at creation, and + // preserving it is the whole point of save()'s two-parameter API. + projectData.worktree = worktree + this.save(projectId, projectData) } /** @@ -183,7 +228,7 @@ export class ProjectDataStorage { const projectData = this.load(projectId) if (projectData && projectData.sessions[sessionId]) { delete projectData.sessions[sessionId] - this.save(projectId, worktree, projectData.sessions) + this.save(projectId, projectData) } } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 55baeb4..7520ad1 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -8,7 +8,7 @@ * Stores data per-project in ~/.local/share/opencode/storage/daytona/{projectId}.json */ -import { Daytona, type Sandbox } from '@daytona/sdk' +import { Daytona, DaytonaNotFoundError, type Sandbox } from '@daytona/sdk' import { logger } from './logger' import type { SessionSandboxMap, SandboxInfo } from './types' import { SessionGitManager } from '../git/session-git-manager' @@ -112,43 +112,58 @@ export class DaytonaSessionManager { // If we have a sandboxId but not a full sandbox object, reconnect to it if (this.isPartiallyInitialized(existing)) { - logger.info(`Reconnecting to existing sandbox: ${existing.id}`) - const daytona = new Daytona({ apiKey: this.apiKey }) - const reconnectStart = Date.now() - logger.info(`Daytona get begin sandboxId=${existing.id}`) - const sandbox = await daytona.get(existing.id) - logger.info(`Daytona get done sandboxId=${existing.id} in ${Date.now() - reconnectStart}ms`) - logger.info(`Starting sandbox begin sandboxId=${sandbox.id}`) - await sandbox.start() - logger.info(`Starting sandbox done sandboxId=${sandbox.id} in ${Date.now() - reconnectStart}ms`) - this.sessionSandboxes.set(sessionId, sandbox) - // Preserve branch number if it exists for this sandbox - let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id) - if (!branchNumber) { - try { - branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree) - } catch { - // No local git repo (or git unavailable) shouldn't block sandbox usage. - branchNumber = undefined + try { + logger.info(`Reconnecting to existing sandbox: ${existing.id}`) + const daytona = new Daytona({ apiKey: this.apiKey }) + const reconnectStart = Date.now() + logger.info(`Daytona get begin sandboxId=${existing.id}`) + const sandbox = await daytona.get(existing.id) + logger.info(`Daytona get done sandboxId=${existing.id} in ${Date.now() - reconnectStart}ms`) + logger.info(`Starting sandbox begin sandboxId=${sandbox.id}`) + await sandbox.start() + logger.info(`Starting sandbox done sandboxId=${sandbox.id} in ${Date.now() - reconnectStart}ms`) + this.sessionSandboxes.set(sessionId, sandbox) + // Preserve branch number if it exists for this sandbox + let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id) + if (!branchNumber) { + try { + branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree) + } catch { + // No local git repo (or git unavailable) shouldn't block sandbox usage. + branchNumber = undefined + } } - } - this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber) - toast.show({ - title: 'Sandbox connected', - message: `Connected to existing sandbox.`, - variant: 'info', - }) + this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber) + toast.show({ + title: 'Sandbox connected', + message: `Connected to existing sandbox.`, + variant: 'info', + }) - // Even if git syncing is disabled, ensure the project directory exists in the sandbox. - if (!branchNumber) { - try { - await new DaytonaSandboxGitManager(sandbox, this.repoPath).ensureDirectory() - } catch (err) { - logger.warn(`Failed to ensure sandbox project directory exists: ${err}`) + // Even if git syncing is disabled, ensure the project directory exists in the sandbox. + if (!branchNumber) { + try { + await new DaytonaSandboxGitManager(sandbox, this.repoPath).ensureDirectory() + } catch (err) { + logger.warn(`Failed to ensure sandbox project directory exists: ${err}`) + } } - } - return sandbox + return sandbox + } catch (err) { + // Only treat 404 as "sandbox is confirmed gone" — clear the mapping and fall through + // to create a replacement. For transient errors (network, auth, timeout, provisioning), + // preserve the mapping and propagate so the caller can retry later without losing the + // session→sandbox binding and its branchNumber. + if (err instanceof DaytonaNotFoundError) { + logger.error(`Sandbox ${existing.id} no longer exists; creating a replacement.`) + this.sessionSandboxes.delete(sessionId) + this.dataStorage.removeSession(projectId, worktree, sessionId) + } else { + logger.error(`Failed to reconnect to sandbox ${existing.id}: ${err}`) + throw err + } + } } // If not in cache/storage for this project, try to recover from other projects and migrate. @@ -220,39 +235,59 @@ export class DaytonaSessionManager { /** * Delete the sandbox associated with the given session ID */ - async deleteSandbox(sessionId: string, projectId: string): Promise { + async deleteSandbox(sessionId: string, projectId: string): Promise { let sandbox = this.sessionSandboxes.get(sessionId) + // Read-only lookup so deleting never migrates sessions or rewrites project metadata. + const stored = this.dataStorage.findSession(sessionId) + + let sandboxGone = false + // If not in cache, try to load from storage and reconnect if (!sandbox || this.isPartiallyInitialized(sandbox)) { - const storedWorktree = this.dataStorage.load(projectId)?.worktree ?? '' - const sessionInfo = this.dataStorage.getSession(projectId, storedWorktree, sessionId) - if (sessionInfo?.sandboxId) { + if (stored?.session.sandboxId) { const daytona = new Daytona({ apiKey: this.apiKey }) try { - sandbox = await daytona.get(sessionInfo.sandboxId) + sandbox = await daytona.get(stored.session.sandboxId) this.sessionSandboxes.set(sessionId, sandbox) } catch (err) { - logger.error(`Failed to reconnect to sandbox ${sessionInfo.sandboxId}: ${err}`) + if (err instanceof DaytonaNotFoundError) { + sandboxGone = true + logger.warn(`Sandbox ${stored.session.sandboxId} no longer exists; clearing stale mapping.`) + } else { + // Non-404: we cannot confirm the sandbox is gone. Surface the error so the + // event handler shows a "Delete failed" toast instead of silently reporting + // success while leaving a running sandbox on the Daytona account. + logger.error(`Failed to reconnect to sandbox ${stored.session.sandboxId}: ${err}`) + throw err + } } + } else { + sandboxGone = true } } // Delete the sandbox if we have a fully initialized one + let deleted = false if (this.isFullyInitialized(sandbox)) { logger.info(`Removing sandbox for session: ${sessionId}`) await sandbox.delete() - this.sessionSandboxes.delete(sessionId) - - // Remove from storage - const projectData = this.dataStorage.load(projectId) - if (projectData) { - this.dataStorage.removeSession(projectId, projectData.worktree, sessionId) - } - + deleted = true + sandboxGone = true logger.info(`Sandbox deleted successfully.`) } else { logger.warn(`No sandbox found for session: ${sessionId}`) } + + // Clear the local mapping when the sandbox is gone (deleted or already absent) so a + // stale entry can't cause repeated reconnect failures. Preserve it on transient errors. + if (sandboxGone) { + this.sessionSandboxes.delete(sessionId) + const cleanupProjectId = stored?.projectId ?? projectId + const cleanupWorktree = stored?.worktree ?? this.dataStorage.load(projectId)?.worktree ?? '' + this.dataStorage.removeSession(cleanupProjectId, cleanupWorktree, sessionId) + } + + return deleted } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 1a4580e..e40e3dd 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -4,7 +4,9 @@ */ import { logger } from '../core/logger' -import { execSync, spawnSync } from 'child_process' +import { spawnSync } from 'child_process' +import { realpathSync } from 'fs' +import { isAbsolute, resolve as pathResolve } from 'path' type ExecResult = { ok: boolean @@ -13,25 +15,88 @@ type ExecResult = { status: number | null } -function execCommand(cmd: string, options: any = {}): ExecResult { - try { - const stdout = execSync(cmd, { - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8', - ...options, - }) as unknown as string - return { ok: true, stdout: stdout ?? '', stderr: '', status: 0 } - } catch (err: any) { - const stdout = err?.stdout?.toString?.() ?? '' - const stderr = err?.stderr?.toString?.() ?? err?.message ?? String(err) - const status = typeof err?.status === 'number' ? err.status : null - return { ok: false, stdout, stderr, status } +type ExecOptions = { + cwd?: string + env?: NodeJS.ProcessEnv +} + +// Runs git with an explicit argument vector and no shell, so interpolated values +// (SSH URLs, refs, remote names) can never be interpreted as shell syntax. +function execGit(args: string[], options: ExecOptions = {}): ExecResult { + const res = spawnSync('git', args, { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + ...options, + }) + if (res.error) { + return { ok: false, stdout: '', stderr: res.error.message, status: null } } + const status = typeof res.status === 'number' ? res.status : null + return { ok: status === 0, stdout: res.stdout ?? '', stderr: res.stderr ?? '', status } } export class HostGitManager { - // No constructor needed; use global logger - private operationQueue: Promise = Promise.resolve() + // Per-repo serialization: one queue per git-common-dir. Linked worktrees (git worktree + // add) of the same repo share `.git/config` and refs, so they must share a queue to + // avoid racing on `remote add/remove` and `.git/config.lock`. Different repos still + // proceed in parallel. + private static operationQueues = new Map>() + + // cwd → queue-key cache. `git rev-parse --git-common-dir` shells out; caching avoids + // running it on every enqueue. Repo layout doesn't move at runtime, so this is stable. + private static queueKeyCache = new Map() + + /** + * Resolve the serialization key for a worktree: the CANONICAL absolute path of its + * shared git dir (common across linked worktrees of the same repo). Canonicalizing + * with realpath collapses symlinked aliases onto the same key, so two callers + * reaching the same repo via different symlink paths still share a queue and can't + * race on `.git/config`. Falls back to (canonical) cwd if git can't identify a repo. + */ + private static queueKeyFor(cwd: string): string { + const cached = HostGitManager.queueKeyCache.get(cwd) + if (cached) return cached + const res = execGit(['rev-parse', '--git-common-dir'], { cwd }) + const rawPath = !res.ok + ? cwd + : (() => { + const out = res.stdout.trim() + return isAbsolute(out) ? out : pathResolve(cwd, out) + })() + let key: string + try { + key = realpathSync(rawPath) + } catch { + // Path may have been deleted between git resolution and our stat; use as-is + key = rawPath + } + HostGitManager.queueKeyCache.set(cwd, key) + return key + } + + /** + * Chain `fn` onto the queue for the repo containing `cwd`. The stored promise is + * always-resolves so a failure doesn't poison the chain; callers still see errors via + * `await` on the returned promise. Cleared from the map when the tail settles to avoid + * leaking entries for repos that are no longer active. + */ + private static enqueue(cwd: string, fn: () => Promise): Promise { + const queueKey = HostGitManager.queueKeyFor(cwd) + const prev = HostGitManager.operationQueues.get(queueKey) ?? Promise.resolve() + const operation = prev.then(fn) + const stored: Promise = operation.then( + () => undefined, + () => undefined, + ) + HostGitManager.operationQueues.set(queueKey, stored) + stored.then(() => { + if (HostGitManager.operationQueues.get(queueKey) === stored) { + HostGitManager.operationQueues.delete(queueKey) + } + }) + return operation + } + /** Cached OID of an empty commit used to reserve branch refs (branches must point at commits, not blobs). */ private emptyCommitOidCache = new Map() @@ -40,7 +105,7 @@ export class HostGitManager { * @returns true if a git repo exists, false otherwise */ hasRepo(cwd?: string): boolean { - return execCommand('git rev-parse --is-inside-work-tree', cwd ? { cwd } : {}).ok + return execGit(['rev-parse', '--is-inside-work-tree'], cwd ? { cwd } : {}).ok } /** @@ -56,7 +121,7 @@ export class HostGitManager { } const base = `refs/heads/${prefix}/` - const listRes = execCommand(`git for-each-ref --format='%(refname:strip=3)' ${base}`, { cwd }) + const listRes = execGit(['for-each-ref', '--format=%(refname:strip=3)', base], { cwd }) if (!listRes.ok) throw new Error(listRes.stderr) const list = listRes.stdout.trim() const nums = @@ -80,7 +145,7 @@ export class HostGitManager { continue } const oid = this.getOrCreateEmptyCommitOid(cwd) - const result = execCommand(`git update-ref "${ref}" "${oid}"`, { cwd }) + const result = execGit(['update-ref', ref, oid], { cwd }) if (result.ok) { logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`) return n @@ -90,12 +155,16 @@ export class HostGitManager { } } const oid = this.getOrCreateEmptyCommitOid(cwd) - const last = execCommand(`git update-ref "${base}${n}" "${oid}"`, { cwd }) + const last = execGit(['update-ref', `${base}${n}`, oid], { cwd }) + if (last.ok) { + logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`) + return n + } throw new Error(`Failed to allocate branch number after ${attempts} attempts. Last error: ${last.stderr}`) } private refExists(cwd: string, ref: string): boolean { - return execCommand(`git show-ref --verify --quiet "${ref}"`, { cwd }).ok + return execGit(['show-ref', '--verify', '--quiet', ref], { cwd }).ok } /** @@ -106,7 +175,7 @@ export class HostGitManager { private getOrCreateEmptyCommitOid(cwd: string): string { const cached = this.emptyCommitOidCache.get(cwd) if (cached) return cached - const headRes = execCommand('git rev-parse HEAD', { cwd }) + const headRes = execGit(['rev-parse', 'HEAD'], { cwd }) const head = headRes.ok ? headRes.stdout.trim() : '' if (head) { this.emptyCommitOidCache.set(cwd, head) @@ -114,9 +183,7 @@ export class HostGitManager { } // Create an empty tree (idempotent) then a commit pointing at it. - // Branch refs must point at commits, so we can't reserve with a blob. const treeResult = spawnSync('git', ['hash-object', '-t', 'tree', '-w', '--stdin'], { - // Empty stdin => empty tree input: '', cwd, encoding: 'utf8', @@ -140,11 +207,7 @@ export class HostGitManager { GIT_COMMITTER_EMAIL: reservationCommitEmail, } - // Create the commit - const commitRes = execCommand(`git commit-tree ${treeOid} -m "${reservationCommitMessage}"`, { - cwd, - env: commitEnv, - }) + const commitRes = execGit(['commit-tree', treeOid, '-m', reservationCommitMessage], { cwd, env: commitEnv }) if (!commitRes.ok) throw new Error(`Failed to create empty commit: ${commitRes.stderr}`) const commitOid = commitRes.stdout.trim() this.emptyCommitOidCache.set(cwd, commitOid) @@ -157,63 +220,53 @@ export class HostGitManager { * @param sshUrl The SSH URL of the sandbox remote. * @param branch The branch to push to. * @param cwd Worktree path to run git in. - * @returns true if push was successful, false if no repo exists + * @returns true if push succeeded, false if no repo exists. Throws if the push fails. */ async pushLocalToSandboxRemote(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise { if (!this.hasRepo(cwd)) { logger.warn('No local git repository found. Skipping push to sandbox.') return false } - try { - logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`) - const operation = this.operationQueue.then(async () => { - const statusRes = execCommand('git status --porcelain', { cwd }) - if (!statusRes.ok) { - throw new Error(statusRes.stderr) - } - if (statusRes.stdout.trim().length > 0) { - logger.warn('Local repository has uncommitted changes; pushing HEAD only (no auto-commit).') - } + logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`) + await HostGitManager.enqueue(cwd, async () => { + const statusRes = execGit(['status', '--porcelain'], { cwd }) + if (!statusRes.ok) { + throw new Error(statusRes.stderr) + } + if (statusRes.stdout.trim().length > 0) { + logger.warn('Local repository has uncommitted changes; pushing HEAD only (no auto-commit).') + } - this.setRemote(remoteName, sshUrl, cwd) - let attempts = 0 - while (attempts < 3) { - try { - const pushRes = execCommand(`git push ${remoteName} HEAD:${branch}`, { cwd }) - if (!pushRes.ok) throw new Error(pushRes.stderr) - logger.info(`✓ Pushed local changes to ${remoteName}`) - return - } catch (e) { - attempts++ - if (attempts >= 3) { - logger.error(`Error pushing to ${remoteName} after 3 attempts: ${e}`) - } else { - logger.warn(`Push attempt ${attempts} failed, retrying...`) - } - } + this.setRemote(remoteName, sshUrl, cwd) + let attempts = 0 + while (attempts < 3) { + const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd }) + if (pushRes.ok) { + logger.info(`✓ Pushed local changes to ${remoteName}`) + return } - }) - this.operationQueue = operation - await operation - return true - } catch (e) { - logger.error(`Error pushing to sandbox: ${e}`) - return false - } + attempts++ + if (attempts >= 3) { + logger.error(`Error pushing to ${remoteName} after 3 attempts: ${pushRes.stderr}`) + throw new Error(pushRes.stderr) + } + logger.warn(`Push attempt ${attempts} failed, retrying...`) + } + }) + return true } private setRemote(remoteName: string, sshUrl: string, cwd: string): void { - try { - // remove existing remote if it exists - execCommand(`git remote remove ${remoteName}`, { cwd }) - execCommand(`git remote add ${remoteName} ${sshUrl}`, { cwd }) - } catch (e) { - logger.warn(`Could not set sandbox remote: ${e}`) + // Remote may not exist yet — ignore this result. `remote add` below is the check that matters. + execGit(['remote', 'remove', remoteName], { cwd }) + const addRes = execGit(['remote', 'add', remoteName, sshUrl], { cwd }) + if (!addRes.ok) { + throw new Error(`Failed to configure sandbox remote '${remoteName}': ${addRes.stderr}`) } } async pull(remoteName: string, sshUrl: string, branch: string, cwd: string, localBranch?: string): Promise { - const operation = this.operationQueue.then(async () => { + await HostGitManager.enqueue(cwd, async () => { this.setRemote(remoteName, sshUrl, cwd) let attempts = 0 let lastError: unknown = undefined @@ -223,23 +276,23 @@ export class HostGitManager { if (localBranch) { // Fetch into FETCH_HEAD only (never into refs/heads) so we don't hit // "refusing to fetch into branch checked out" when this branch is checked out. - const fetchRes = execCommand(`git fetch ${remoteName} ${branch}`, { cwd }) + const fetchRes = execGit(['fetch', remoteName, branch], { cwd }) if (!fetchRes.ok) throw new Error(fetchRes.stderr) - const updateRefRes = execCommand(`git update-ref refs/heads/${localBranch} FETCH_HEAD`, { cwd }) + const updateRefRes = execGit(['update-ref', `refs/heads/${localBranch}`, 'FETCH_HEAD'], { cwd }) if (!updateRefRes.ok) throw new Error(updateRefRes.stderr) // Only reset working directory if we're currently on this branch - const currentBranchRes = execCommand(`git rev-parse --abbrev-ref HEAD`, { cwd }) + const currentBranchRes = execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) const currentBranch = currentBranchRes.ok ? currentBranchRes.stdout.trim() : '' if (currentBranch === localBranch) { - const resetRes = execCommand(`git reset --hard refs/heads/${localBranch}`, { cwd }) + const resetRes = execGit(['reset', '--hard', `refs/heads/${localBranch}`], { cwd }) if (!resetRes.ok) throw new Error(resetRes.stderr) } logger.info(`✓ Force pulled latest changes from sandbox into ${localBranch}`) } else { - const pullRes = execCommand(`git pull ${remoteName} ${branch}`, { cwd }) + const pullRes = execGit(['pull', remoteName, branch], { cwd }) if (!pullRes.ok) throw new Error(pullRes.stderr) logger.info('✓ Pulled latest changes from sandbox') } @@ -258,31 +311,25 @@ export class HostGitManager { // If we got here, all attempts failed. throw lastError ?? new Error('Pull failed after 3 attempts') }) - this.operationQueue = operation - await operation } async push(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise { - const operation = this.operationQueue.then(async () => { + await HostGitManager.enqueue(cwd, async () => { this.setRemote(remoteName, sshUrl, cwd) let attempts = 0 while (attempts < 3) { - try { - const pushRes = execCommand(`git push ${remoteName} HEAD:${branch}`, { cwd }) - if (!pushRes.ok) throw new Error(pushRes.stderr) + const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd }) + if (pushRes.ok) { logger.info('✓ Pushed changes to sandbox') return - } catch (e) { - attempts++ - if (attempts >= 3) { - logger.error(`Error pushing to sandbox after 3 attempts: ${e}`) - } else { - logger.warn(`Push attempt ${attempts} failed, retrying...`) - } } + attempts++ + if (attempts >= 3) { + logger.error(`Error pushing to sandbox after 3 attempts: ${pushRes.stderr}`) + throw new Error(pushRes.stderr) + } + logger.warn(`Push attempt ${attempts} failed, retrying...`) } }) - this.operationQueue = operation - await operation } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts index f1f20e2..29b1d37 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts @@ -56,8 +56,15 @@ export class DaytonaSandboxGitManager { return true } + async getCurrentBranch(): Promise { + const branch = await this.runGitCommand('git rev-parse --abbrev-ref HEAD') + return branch.trim() + } + async resetToRemote(branch: string): Promise { - const checkout = await this.runGitCommand(`git checkout -B ${branch}`) + // Check out the branch the host just pushed. Using -f (not -B) checks out the + // pushed commit instead of resetting the branch ref to the sandbox's current HEAD. + const checkout = await this.runGitCommand(`git checkout -f ${branch}`) logger.info(`Checked out branch '${branch}': ${checkout}`) await this.runGitCommand('git reset --hard') await this.runGitCommand('git clean -fd') diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index 04b97e4..8be5ea8 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -48,14 +48,6 @@ export class SessionGitManager { return `ssh://${sshAccess.token}@ssh.app.daytona.io${this.repoPath}` } - /** - * Check if local git repository exists - * @returns true if repo exists, false otherwise - */ - hasLocalRepo(): boolean { - return this.hostGit.hasRepo(this.worktree) - } - /** * Initialize git in the sandbox and sync with host * Used when a new sandbox is created for a session @@ -120,7 +112,10 @@ export class SessionGitManager { const sshUrl = await this.getSshUrl() - await this.hostGit.pull(this.remoteName, sshUrl, this.branch, this.worktree, this.localBranch) + // Pull the branch the sandbox actually committed to, which may differ from the + // initial 'opencode' branch, so commits are never left unsynced. + const sandboxBranch = await this.sandboxGit.getCurrentBranch() + await this.hostGit.pull(this.remoteName, sshUrl, sandboxBranch, this.worktree, this.localBranch) toast.show({ title: 'Changes synced', message: `Changes have been synced to ${this.localBranch} in your local repository`, diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index 54441e4..69d4ed6 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -24,6 +24,7 @@ */ import { join } from 'path' +import { homedir } from 'os' import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath } from './core/logger' @@ -35,8 +36,9 @@ import { systemPromptTransform } from './plugins/system-transform' export type { EventSessionDeleted, LogLevel, SandboxInfo, SessionInfo, ProjectSessionData } from './core/types' -const LOG_FILE = join(xdgData, 'opencode', 'log', 'daytona.log') -const STORAGE_DIR = join(xdgData, 'opencode', 'storage', 'daytona') +const xdgDataDir = xdgData ?? join(homedir(), '.local', 'share') +const LOG_FILE = join(xdgDataDir, 'opencode', 'log', 'daytona.log') +const STORAGE_DIR = join(xdgDataDir, 'opencode', 'storage', 'daytona') const REPO_PATH = '/home/daytona/project' setLogFilePath(LOG_FILE) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts index 5c9eaa9..ca5e865 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts @@ -21,8 +21,10 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes if (event.type === EVENT_TYPE_SESSION_DELETED) { const sessionId = (event as EventSessionDeleted).properties.info.id try { - await sessionManager.deleteSandbox(sessionId, projectId) - toast.show({ title: 'Session deleted', message: 'Sandbox deleted successfully.', variant: 'success' }) + const deleted = await sessionManager.deleteSandbox(sessionId, projectId) + if (deleted) { + toast.show({ title: 'Session deleted', message: 'Sandbox deleted successfully.', variant: 'success' }) + } } catch (err: any) { toast.show({ title: 'Delete failed', message: err?.message || 'Failed to delete sandbox.', variant: 'error' }) throw err @@ -40,13 +42,9 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes `[idle] done sessionId=${sessionId} sandboxId=${sandbox.id} synced=${didSync} in ${Date.now() - start}ms`, ) } catch (err: any) { + // autoCommitAndPull already shows a toast; only log here to avoid a duplicate + // error toast and noisy propagation out of the idle event hook. logger.error(`[idle] error sessionId=${sessionId} in ${Date.now() - start}ms: ${err}`) - toast.show({ - title: 'Auto-commit error', - message: err?.message || 'Failed to auto-commit and pull.', - variant: 'error', - }) - throw err } } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts index 57a1a1e..bf9d368 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts @@ -12,11 +12,9 @@ import { readTool } from './tools/read' import { writeTool } from './tools/write' import { editTool } from './tools/edit' import { multieditTool } from './tools/multiedit' -import { patchTool } from './tools/patch' import { lsTool } from './tools/ls' import { globTool } from './tools/glob' import { grepTool } from './tools/grep' -import { lspTool } from './tools/lsp' import { getPreviewURLTool } from './tools/get-preview-url' import type { DaytonaSessionManager } from './core/session-manager' @@ -35,11 +33,9 @@ export function createDaytonaTools( write: writeTool(sessionManager, projectId, worktree, pluginCtx), edit: editTool(sessionManager, projectId, worktree, pluginCtx), multiedit: multieditTool(sessionManager, projectId, worktree, pluginCtx), - patch: patchTool(sessionManager, projectId, worktree, pluginCtx), ls: lsTool(sessionManager, projectId, worktree, pluginCtx), glob: globTool(sessionManager, projectId, worktree, pluginCtx), grep: grepTool(sessionManager, projectId, worktree, pluginCtx), - lsp: lspTool(sessionManager, projectId, worktree, pluginCtx), getPreviewURL: getPreviewURLTool(sessionManager, projectId, worktree, pluginCtx), } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts index c339279..e3757c2 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts @@ -4,6 +4,7 @@ */ import { z } from 'zod' +import { DaytonaNotFoundError } from '@daytona/sdk' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' import type { DaytonaSessionManager } from '../core/session-manager' @@ -28,7 +29,10 @@ export const bashTool = ( const execSessionId = `exec-session-${sessionId}` try { await sandbox.process.getSession(execSessionId) - } catch { + } catch (err) { + if (!(err instanceof DaytonaNotFoundError)) { + throw err + } await sandbox.process.createSession(execSessionId) } await sandbox.process.executeSessionCommand(execSessionId, { diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts index 3057d71..430aee4 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts @@ -25,6 +25,18 @@ export const editTool = ( const buffer = await sandbox.fs.downloadFile(args.filePath) const decoder = new TextDecoder() const content = decoder.decode(buffer) + if (args.oldString === '') { + throw new Error(`oldString must be non-empty; refusing to prepend to ${args.filePath}.`) + } + const occurrences = content.split(args.oldString).length - 1 + if (occurrences === 0) { + throw new Error(`oldString not found in ${args.filePath}; no changes were made.`) + } + if (occurrences > 1) { + throw new Error( + `oldString is ambiguous (${occurrences} matches) in ${args.filePath}; no changes were made.`, + ) + } const newContent = content.replace(args.oldString, args.newString) await sandbox.fs.uploadFile(Buffer.from(newContent), args.filePath) return `Edited ${args.filePath}` diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts index 54e755b..0eccabc 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts @@ -16,7 +16,7 @@ export const getPreviewURLTool = ( ) => ({ description: 'Gets a preview URL for the Daytona sandbox', args: { - port: z.number(), + port: z.number().int().min(1).max(65535), }, async execute(args: { port: number }, ctx: ToolContext) { const sandbox = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts index eeb08ec..3607f6c 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts @@ -26,6 +26,14 @@ export const grepTool = ( throw new Error('Work directory not available') } const matches = await sandbox.fs.findFiles(workDir, args.pattern) - return matches.map((m: Match) => `${m.file}:${m.line}: ${m.content}`).join('\n') + const maxMatches = 100 + const formatted = matches + .slice(0, maxMatches) + .map((m: Match) => `${m.file}:${m.line}: ${m.content}`) + .join('\n') + if (matches.length > maxMatches) { + return `${formatted}\n... (${matches.length - maxMatches} more matches truncated; refine your pattern)` + } + return formatted }, }) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts deleted file mode 100644 index ba14eec..0000000 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright Daytona Platforms Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod' -import type { PluginInput } from '@opencode-ai/plugin' -import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { DaytonaSessionManager } from '../core/session-manager' - -export const lspTool = ( - sessionManager: DaytonaSessionManager, - projectId: string, - worktree: string, - pluginCtx: PluginInput, -) => ({ - description: 'LSP operation in Daytona sandbox (code intelligence)', - args: { - op: z.string(), - filePath: z.string(), - line: z.number(), - }, - async execute(args: { op: string; filePath: string; line: number }, ctx: ToolContext) { - return `LSP operations are not yet implemented in the Daytona plugin.` - }, -}) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts index a340fe3..69a51ab 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts @@ -30,7 +30,21 @@ export const multieditTool = ( const decoder = new TextDecoder() let content = decoder.decode(buffer) - for (const edit of args.edits) { + for (const [i, edit] of args.edits.entries()) { + if (edit.oldString === '') { + throw new Error(`edits[${i}].oldString is empty; refusing to prepend to ${args.filePath}.`) + } + const occurrences = content.split(edit.oldString).length - 1 + if (occurrences === 0) { + throw new Error( + `edits[${i}].oldString not found in ${args.filePath}: ${JSON.stringify(edit.oldString)}`, + ) + } + if (occurrences > 1) { + throw new Error( + `edits[${i}].oldString is ambiguous (${occurrences} matches) in ${args.filePath}: ${JSON.stringify(edit.oldString)}`, + ) + } content = content.replace(edit.oldString, edit.newString) } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts deleted file mode 100644 index 389992a..0000000 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright Daytona Platforms Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod' -import type { PluginInput } from '@opencode-ai/plugin' -import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { DaytonaSessionManager } from '../core/session-manager' - -export const patchTool = ( - sessionManager: DaytonaSessionManager, - projectId: string, - worktree: string, - pluginCtx: PluginInput, -) => ({ - description: 'Applies a patch to the project in Daytona sandbox', - args: { - patchText: z.string().describe('The full patch text that describes all changes to be made'), - }, - async execute(args: { filePath: string; oldSnippet: string; newSnippet: string }, ctx: ToolContext) { - return `Patch operations are not yet implemented in the Daytona plugin.` - }, -}) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts index 974405e..886c212 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts @@ -22,6 +22,6 @@ export const writeTool = ( async execute(args: { filePath: string; content: string }, ctx: ToolContext) { const sandbox = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) await sandbox.fs.uploadFile(Buffer.from(args.content), args.filePath) - return `Written ${args.content.length} bytes to ${args.filePath}` + return `Written ${Buffer.byteLength(args.content, 'utf8')} bytes to ${args.filePath}` }, }) diff --git a/packages/opencode-plugin/.opencode/plugin/index.ts b/packages/opencode-plugin/.opencode/plugin/index.ts index 81728da..a56047d 100644 --- a/packages/opencode-plugin/.opencode/plugin/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/index.ts @@ -8,4 +8,4 @@ * Re-exports the default plugin from daytona. */ -export { default } from './daytona/index.js' +export { default } from './daytona/index' diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 746e358..24d99ed 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -30,7 +30,7 @@ To install the plugin globally, edit `~/.config/opencode/opencode.json`. This plugin requires a [Daytona account](https://www.daytona.io/) and [Daytona API key](https://app.daytona.io/dashboard/keys) to create sandboxes. -Set your Daytona API key and URL as environment variables: +Set your Daytona API key as an environment variable: ```bash export DAYTONA_API_KEY="your-api-key" @@ -108,8 +108,8 @@ The plugin only synchronizes changes from the sandbox to your system. To pass lo The plugin keeps track of which sandbox belongs to each OpenCode project using local state files. This data is stored in a separate JSON file for each project: -- On macOS: `~/.local/share/opencode/storage/daytona/[projectid].json`. -- On Windows: `%LOCALAPPDATA%\opencode\storage\daytona\[projectid].json`. +- Default (when `XDG_DATA_HOME` is unset): `~/.local/share/opencode/storage/daytona/[projectid].json`. +- When `XDG_DATA_HOME` is set: `$XDG_DATA_HOME/opencode/storage/daytona/[projectid].json`. Each JSON file contains the sandbox metadata for each session in the project, including when the sandbox was created, and when it was last used. @@ -173,7 +173,7 @@ The published package contains the compiled `.js`/`.d.ts`; the `.ts` sources are #### Test the built package -After building, create a test project and add a plugin file to load the built plugin (replace `[ABSOLUTE_PATH_TO_DAYTONA]` with your clone path, e.g. `/Users/you/daytona`): +After building, create a test project and add a plugin file to load the built plugin (replace `[ABSOLUTE_PATH_TO_REPO]` with your clone path, e.g. `/Users/you/integrations`): ```bash mkdir -p ~/myproject && cd ~/myproject