From 50c76a9bcf27974f58fd0dd20cf470d46152d90f Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 13:46:41 -0500 Subject: [PATCH 01/15] Add project sync into sandboxes (git clone or mounted cloud volume) Fixes the "sandbox is empty" feedback: the local project is now synced into the sandbox at /tmp/workspace/ on first use. - Git repos: pushWorktree to a TensorLake-hosted repo, clone inside the sandbox, credentials configured so the agent can push changes back. - Plain folders: upload to a cloud volume and mount it into the sandbox (writes persist durably); skips node_modules and other build junk. - Mode auto-detected; TENSORLAKE_SYNC_MODE=git|volume|off overrides. - bash/ls/glob/grep now default to the synced project directory and the system prompt tells the model where the project lives. - Org/project scope resolved via API key introspection when not in env. - Bump tensorlake to 0.5.109 and @opencode-ai/plugin to 1.18.18. Co-Authored-By: Claude Fable 5 --- .opencode/plugin/tensorlake/core/client.ts | 18 +- .../plugin/tensorlake/core/project-sync.ts | 250 ++++++++ .../plugin/tensorlake/core/session-manager.ts | 48 ++ .opencode/plugin/tensorlake/index.ts | 4 +- .../tensorlake/plugins/system-transform.ts | 36 +- .opencode/plugin/tensorlake/tools/bash.ts | 2 +- .opencode/plugin/tensorlake/tools/glob.ts | 2 +- .opencode/plugin/tensorlake/tools/grep.ts | 2 +- .opencode/plugin/tensorlake/tools/ls.ts | 2 +- README.md | 30 +- package-lock.json | 598 +++++++++++++++--- package.json | 6 +- 12 files changed, 893 insertions(+), 105 deletions(-) create mode 100644 .opencode/plugin/tensorlake/core/project-sync.ts diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index 2c21bf9..5953d30 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -1,5 +1,5 @@ import { SandboxClient } from 'tensorlake' -import type { Sandbox } from 'tensorlake' +import type { Sandbox, FileSystemMount } from 'tensorlake' import { execFileSync } from 'child_process' import { logger } from './logger.js' @@ -49,7 +49,11 @@ export class TensorLakeClient { return this.apiKey.length > 0 } - async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number } = {}): Promise { + getApiKey(): string { + return this.apiKey + } + + async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number; fileSystems?: FileSystemMount[] } = {}): Promise { const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2') const memoryMb = parseInt(process.env.TENSORLAKE_MEMORY_MB ?? '4096', 10) const ephemeralDiskMb = parseInt(process.env.TENSORLAKE_DISK_MB ?? '10240', 10) @@ -61,10 +65,20 @@ export class TensorLakeClient { diskMb: ephemeralDiskMb, ...(opts.name ? { name: opts.name } : {}), ...(opts.timeoutSecs ? { timeoutSecs: opts.timeoutSecs } : {}), + ...(opts.fileSystems?.length ? { fileSystems: opts.fileSystems } : {}), }) return { sandbox_id: sandbox.sandboxId, status: 'running' } } + async listSandboxFileSystems(sandboxId: string): Promise { + const info = await this.sdk.get(sandboxId) + return info.fileSystems ?? [] + } + + async attachFileSystem(sandboxId: string, fileSystemId: string, mountPath: string): Promise { + await this.sdk.attachFileSystem(sandboxId, fileSystemId, mountPath) + } + async getSandbox(sandboxId: string): Promise { const info = await this.sdk.get(sandboxId) return { sandbox_id: info.sandboxId, status: info.status as unknown as string } diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts new file mode 100644 index 0000000..16e969d --- /dev/null +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -0,0 +1,250 @@ +import { existsSync, readdirSync, lstatSync } from 'fs' +import { join, basename } from 'path' +import { posix } from 'path' +import { RepositoryClient, FilesystemClient, CloudClient } from 'tensorlake' +import type { FileSystemMount } from 'tensorlake' +import { logger } from './logger.js' +import type { TensorLakeClient } from './client.js' + +export type SyncMode = 'git' | 'volume' | 'off' + +// Directories that are always regenerable and expensive to upload. +const SKIP_DIRS = new Set([ + '.git', + 'node_modules', + '.venv', + 'venv', + '__pycache__', + '.next', + '.nuxt', + '.turbo', + '.cache', + 'dist', + 'build', + 'target', + '.DS_Store', +]) + +// Files larger than this are skipped in volume mode. +const MAX_FILE_BYTES = 100 * 1024 * 1024 + +function apiUrl(): string | undefined { + return process.env.TENSORLAKE_API_URL +} + +type Scope = { organizationId: string; projectId: string } + +let cachedScope: Scope | null = null + +function pickId(value: unknown, keys: string[]): string | undefined { + if (value == null || typeof value !== 'object') return undefined + const obj = value as Record + for (const key of keys) { + const candidate = obj[key] + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return undefined +} + +/** + * Resolve the organization/project scope required by the git and filesystem + * APIs. Env vars win (PAT keys need them); org/project-scoped API keys are + * introspected once and the result cached for the process lifetime. + */ +async function resolveScope(apiKey: string): Promise { + const envOrg = process.env.TENSORLAKE_ORGANIZATION_ID + const envProj = process.env.TENSORLAKE_PROJECT_ID + if (envOrg && envProj) return { organizationId: envOrg, projectId: envProj } + if (cachedScope) return cachedScope + + const client = CloudClient.forCloud({ apiKey, ...(apiUrl() ? { apiUrl: apiUrl() } : {}) }) + try { + const intro = (await client.introspectApiKey()) as Record + const organizationId = + envOrg ?? + pickId(intro, ['organizationId', 'organization_id']) ?? + pickId(intro.organization, ['id', 'organizationId', 'organization_id']) + const projectId = + envProj ?? + pickId(intro, ['projectId', 'project_id']) ?? + pickId(intro.project, ['id', 'projectId', 'project_id']) + if (!organizationId || !projectId) { + throw new Error( + 'Could not resolve organization/project from the API key. Set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.', + ) + } + cachedScope = { organizationId, projectId } + return cachedScope + } finally { + client.close() + } +} + +async function cloudOptions(apiKey: string) { + const scope = await resolveScope(apiKey) + return { + apiKey, + ...(apiUrl() ? { apiUrl: apiUrl() } : {}), + ...scope, + } +} + +export function sanitizeName(input: string): string { + return input.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) +} + +/** Directory name the project is synced to inside the sandbox workspace. */ +export function projectDirName(worktree: string): string { + const name = basename(worktree ?? '').replace(/[^a-zA-Z0-9._-]/g, '-') + return name || 'project' +} + +/** + * Resolve how the local project is synced into the sandbox. + * TENSORLAKE_SYNC_MODE=git|volume|off overrides; default 'auto' picks + * git for git repositories and a mounted cloud volume otherwise. + */ +export function resolveSyncMode(worktree: string): SyncMode { + const env = (process.env.TENSORLAKE_SYNC_MODE ?? 'auto').toLowerCase() + if (env === 'git' || env === 'volume' || env === 'off') return env + if (!worktree || worktree === '/' || !existsSync(worktree)) return 'off' + return existsSync(join(worktree, '.git')) ? 'git' : 'volume' +} + +function repoName(projectId: string): string { + return sanitizeName(`opencode-${projectId}`) +} + +function volumeName(projectId: string): string { + return sanitizeName(`opencode-${projectId}`) +} + +/** + * Push the local worktree to a TensorLake-hosted git repository, then + * clone (or fast-forward) it inside the sandbox at `destDir`. + */ +export async function syncGitProject( + client: TensorLakeClient, + apiKey: string, + sandboxId: string, + worktree: string, + projectId: string, + destDir: string, +): Promise { + const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) + try { + const repo = repoName(projectId) + try { + await repos.info(repo) + } catch { + logger.info(`Creating hosted git repository ${repo}`) + await repos.create(repo, { defaultBranch: 'main' }) + } + + logger.info(`Pushing worktree ${worktree} to hosted repo ${repo}`) + const report = await repos.pushWorktree(repo, { + path: worktree, + branch: 'main', + message: 'Sync from OpenCode', + }) + logger.info(`pushWorktree done: ${JSON.stringify(report)}`) + + const cred = await repos.credential(repo) + const url = repos.url(repo) + const parsed = new URL(url) + const credLine = `${parsed.protocol}//${encodeURIComponent(cred.gitUsername)}:${encodeURIComponent(cred.token)}@${parsed.host}` + + const script = [ + 'set -e', + 'git config --global credential.helper store', + `printf '%s\\n' '${credLine}' > ~/.git-credentials`, + `if [ -d '${destDir}/.git' ]; then`, + ` cd '${destDir}' && git fetch origin main && git reset --hard origin/main`, + 'else', + ` rm -rf '${destDir}' && git clone --branch main '${url}' '${destDir}'`, + 'fi', + ].join('\n') + + const result = await client.executeCommand(sandboxId, script, '/', 300_000) + if (result.exitCode !== 0) { + throw new Error(`git sync failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`) + } + logger.info(`Project cloned into sandbox at ${destDir}`) + } finally { + repos.close() + } +} + +/** Recursively collect files to upload: remote path -> absolute local path. */ +function collectFiles(worktree: string): Record { + const files: Record = {} + const walk = (dir: string, prefix: string) => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue + const localPath = join(dir, entry) + let st + try { + st = lstatSync(localPath) + } catch { + continue + } + if (st.isSymbolicLink()) continue + const remotePath = prefix ? posix.join(prefix, entry) : entry + if (st.isDirectory()) { + walk(localPath, remotePath) + } else if (st.isFile()) { + if (st.size > MAX_FILE_BYTES) { + logger.warn(`Skipping ${localPath} (${st.size} bytes > ${MAX_FILE_BYTES})`) + continue + } + files[remotePath] = localPath + } + } + } + walk(worktree, '') + return files +} + +/** + * Ensure the project's cloud volume exists and holds the current worktree + * content. Returns the mount spec for the sandbox. Sandbox mounts address + * volumes by their filesystem name. + */ +export async function ensureVolumeWithProject( + apiKey: string, + worktree: string, + projectId: string, + mountPath: string, +): Promise { + const name = volumeName(projectId) + const options = await cloudOptions(apiKey) + const fsClient = new FilesystemClient(options) + let fs + try { + fs = await fsClient.get(name) + } catch { + logger.info(`Creating cloud volume ${name}`) + fs = await fsClient.create(name) + } + + const files = collectFiles(worktree) + const count = Object.keys(files).length + logger.info(`Uploading ${count} files from ${worktree} to volume ${name}`) + if (count > 0) { + await fs.writeFilesFromPaths(files, 'Sync from OpenCode') + } + + return { fileSystemId: name, mountPath } +} + +/** Attach the project volume to an already-running sandbox if not mounted. */ +export async function ensureVolumeMounted( + client: TensorLakeClient, + mount: FileSystemMount, + sandboxId: string, +): Promise { + const mounts = await client.listSandboxFileSystems(sandboxId) + if (mounts.some((m) => m.mountPath === mount.mountPath)) return + logger.info(`Attaching volume ${mount.fileSystemId} to sandbox ${sandboxId} at ${mount.mountPath}`) + await client.attachFileSystem(sandboxId, mount.fileSystemId, mount.mountPath) +} diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index a708ca4..6dddd15 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -5,6 +5,13 @@ import { logger } from './logger.js' import { toast } from './toast.js' import type { ProjectSessionData } from './types.js' import type { PluginInput } from '@opencode-ai/plugin' +import { + resolveSyncMode, + projectDirName, + syncGitProject, + ensureVolumeWithProject, + ensureVolumeMounted, +} from './project-sync.js' export class TensorLakeSessionManager { private readonly client: TensorLakeClient @@ -12,6 +19,8 @@ export class TensorLakeSessionManager { private readonly cache = new Map() // In-flight getSandbox promises keyed by sessionId — prevents concurrent double-resume private readonly inflight = new Map>() + // Sandboxes whose project sync already ran in this process + private readonly synced = new Set() public readonly workDir: string private readonly storageDir: string @@ -25,6 +34,43 @@ export class TensorLakeSessionManager { return this.client } + /** Directory inside the sandbox where the local project is synced. */ + projectDir(worktree: string): string { + if (resolveSyncMode(worktree) === 'off') return this.workDir + return join(this.workDir, projectDirName(worktree)) + } + + /** + * Sync the local project into the sandbox. Git repos are pushed to a + * TensorLake-hosted repo and cloned inside the sandbox; plain folders are + * pushed to a cloud volume mounted into the sandbox. Failures are logged + * and surfaced but never block the sandbox. + */ + private async syncProject(sandboxId: string, projectId: string, worktree: string): Promise { + if (this.synced.has(sandboxId)) return + const mode = resolveSyncMode(worktree) + if (mode === 'off') { + this.synced.add(sandboxId) + return + } + const destDir = this.projectDir(worktree) + try { + if (mode === 'git') { + toast.show({ title: 'Syncing project', message: `Pushing ${worktree} and cloning into the sandbox...`, variant: 'info' }) + await syncGitProject(this.client, this.client.getApiKey(), sandboxId, worktree, projectId, destDir) + } else { + toast.show({ title: 'Syncing project', message: `Uploading ${worktree} to a cloud volume...`, variant: 'info' }) + const mount = await ensureVolumeWithProject(this.client.getApiKey(), worktree, projectId, destDir) + await ensureVolumeMounted(this.client, mount, sandboxId) + } + this.synced.add(sandboxId) + toast.show({ title: 'Project synced', message: `Project available at ${destDir}`, variant: 'success' }) + } catch (err: any) { + logger.error(`Project sync (${mode}) failed: ${err?.stack ?? err}`) + toast.show({ title: 'Project sync failed', message: `${err?.message ?? err}. Sandbox is usable but empty.`, variant: 'error' }) + } + } + private storagePath(projectId: string): string { return join(this.storageDir, `${projectId}.json`) } @@ -154,6 +200,7 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, stored.sandboxId) const reused = storedSessionId !== sessionId toast.show({ title: 'Sandbox connected', message: reused ? 'Reusing sandbox from previous session.' : 'Connected to existing sandbox.', variant: 'info' }) + await this.syncProject(stored.sandboxId, projectId, worktree) return entry } catch (err) { logger.warn(`Failed to connect to sandbox ${stored.sandboxId}: ${err}`) @@ -181,6 +228,7 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, created.sandbox_id) toast.show({ title: 'Sandbox created', message: 'New sandbox is ready.', variant: 'success' }) + await this.syncProject(created.sandbox_id, projectId, worktree) return entry } diff --git a/.opencode/plugin/tensorlake/index.ts b/.opencode/plugin/tensorlake/index.ts index ac49e4a..89a3fc8 100644 --- a/.opencode/plugin/tensorlake/index.ts +++ b/.opencode/plugin/tensorlake/index.ts @@ -34,10 +34,12 @@ process.on('SIGINT', () => suspendAndExit('SIGINT')) async function tensorlakePlugin(ctx: PluginInput) { toast.initialize(ctx.client?.tui) + const worktree = ctx.project?.worktree ?? ctx.worktree ?? '' + const projectDir = sessionManager.projectDir(worktree) return { tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager), - 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR), + 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR, projectDir), } } diff --git a/.opencode/plugin/tensorlake/plugins/system-transform.ts b/.opencode/plugin/tensorlake/plugins/system-transform.ts index 1d5bb7c..41f28d1 100644 --- a/.opencode/plugin/tensorlake/plugins/system-transform.ts +++ b/.opencode/plugin/tensorlake/plugins/system-transform.ts @@ -1,17 +1,31 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { ExperimentalChatSystemTransformInput, ExperimentalChatSystemTransformOutput } from '../core/types.js' +import { resolveSyncMode } from '../core/project-sync.js' -export async function systemPromptTransform(ctx: PluginInput, workDir: string) { +export async function systemPromptTransform(ctx: PluginInput, workDir: string, projectDir: string) { + const worktree = ctx.project?.worktree ?? ctx.worktree ?? '' + const mode = resolveSyncMode(worktree) return async (_input: ExperimentalChatSystemTransformInput, output: ExperimentalChatSystemTransformOutput) => { - output.system.push( - [ - '## TensorLake Sandbox Integration', - 'This session is running inside a TensorLake sandbox.', - `The working directory is: ${workDir}`, - 'All bash commands, file reads/writes, and searches run inside the sandbox.', - `Put all project files in ${workDir}. Do NOT use paths from the host system.`, - "For long-running commands (servers, watchers), use the 'background' option.", - ].join('\n'), - ) + const lines = [ + '## TensorLake Sandbox Integration', + 'This session is running inside a TensorLake sandbox.', + 'All bash commands, file reads/writes, and searches run inside the sandbox.', + 'Do NOT use paths from the host system.', + "For long-running commands (servers, watchers), use the 'background' option.", + ] + if (mode === 'git') { + lines.push( + `The local project is synced into the sandbox as a git clone at: ${projectDir}`, + `Work in ${projectDir}. Commit and push to 'origin' to persist changes.`, + ) + } else if (mode === 'volume') { + lines.push( + `The local project is mounted into the sandbox on a cloud volume at: ${projectDir}`, + `Work in ${projectDir}. Writes there are persisted automatically.`, + ) + } else { + lines.push(`The working directory is: ${workDir}`, `Put all project files in ${workDir}.`) + } + output.system.push(lines.join('\n')) } } diff --git a/.opencode/plugin/tensorlake/tools/bash.ts b/.opencode/plugin/tensorlake/tools/bash.ts index a789650..2b521ce 100644 --- a/.opencode/plugin/tensorlake/tools/bash.ts +++ b/.opencode/plugin/tensorlake/tools/bash.ts @@ -17,7 +17,7 @@ export const bashTool = ( async execute(args: { command: string; background?: boolean }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) const client = sessionManager.getClient() - const workDir = sessionManager.workDir + const workDir = sessionManager.projectDir(worktree) if (args.background) { client.executeCommand(sandboxId, args.command, workDir, 300_000).catch(() => {}) diff --git a/.opencode/plugin/tensorlake/tools/glob.ts b/.opencode/plugin/tensorlake/tools/glob.ts index 97c2fa6..1772394 100644 --- a/.opencode/plugin/tensorlake/tools/glob.ts +++ b/.opencode/plugin/tensorlake/tools/glob.ts @@ -16,7 +16,7 @@ export const globTool = ( }, async execute(args: { pattern: string; path?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const searchPath = args.path ?? sessionManager.workDir + const searchPath = args.path ?? sessionManager.projectDir(worktree) const result = await sessionManager .getClient() .executeCommand(sandboxId, `find ${searchPath} -name "${args.pattern}" 2>/dev/null`, '/') diff --git a/.opencode/plugin/tensorlake/tools/grep.ts b/.opencode/plugin/tensorlake/tools/grep.ts index 098f544..58bfd77 100644 --- a/.opencode/plugin/tensorlake/tools/grep.ts +++ b/.opencode/plugin/tensorlake/tools/grep.ts @@ -17,7 +17,7 @@ export const grepTool = ( }, async execute(args: { pattern: string; path?: string; filePattern?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const searchPath = args.path ?? sessionManager.workDir + const searchPath = args.path ?? sessionManager.projectDir(worktree) const include = args.filePattern ? `--include="${args.filePattern}"` : '' const cmd = `grep -rn ${include} "${args.pattern}" ${searchPath} 2>/dev/null` const result = await sessionManager.getClient().executeCommand(sandboxId, cmd, '/') diff --git a/.opencode/plugin/tensorlake/tools/ls.ts b/.opencode/plugin/tensorlake/tools/ls.ts index 38b49e7..b431476 100644 --- a/.opencode/plugin/tensorlake/tools/ls.ts +++ b/.opencode/plugin/tensorlake/tools/ls.ts @@ -15,7 +15,7 @@ export const lsTool = ( }, async execute(args: { dirPath?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const path = args.dirPath ?? sessionManager.workDir + const path = args.dirPath ?? sessionManager.projectDir(worktree) const entries = await sessionManager.getClient().listDirectory(sandboxId, path) return entries.map((e) => (e.is_dir ? `${e.name}/` : e.name)).join('\n') }, diff --git a/README.md b/README.md index e467ac7..d3f34ba 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re > **No sandbox is created when you start OpenCode.** The sandbox is provisioned lazily on the model's first tool call in a session. If you launch OpenCode and nothing seems to happen, that's expected — ask the model to run a command to spin one up. - **Sandbox lifecycle** - A sandbox is created lazily on the **first intercepted tool call** in a session (not at launch) and deleted when the session is deleted. Sandbox state is persisted to disk so that reconnection is possible across OpenCode restarts. +- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed to a TensorLake-hosted git repo and cloned inside the sandbox; non-git folders are uploaded to a TensorLake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). - **Suspension/resume** - If a sandbox is found in a suspended state it is automatically resumed before use. -- **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox at `/tmp/workspace`. +- **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox and where the project lives. - **Toast notifications** - Sandbox status events (created, connected, resumed, deleted) surface as TUI toasts. - **Logging** - All plugin activity is written to `~/.local/share/opencode/log/tensorlake.log`. @@ -30,6 +31,31 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re --- +## Project sync + +The first time a sandbox is used (per OpenCode process), the plugin syncs your local project into it so the sandbox is not empty. The mode is chosen automatically: + +| Local project | Sync mode | How it works | +|---|---|---| +| Git repository (has `.git`) | `git` | The worktree is pushed to a TensorLake-hosted git repository (`opencode-`) via `pushWorktree` — no local git invocation, `.gitignore` respected — then cloned inside the sandbox. Git credentials are configured in the sandbox so the model can `git push` to persist changes back to the hosted repo. | +| Plain folder | `volume` | The folder is uploaded to a TensorLake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | + +The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. + +Override the automatic choice with `TENSORLAKE_SYNC_MODE`: + +```bash +export TENSORLAKE_SYNC_MODE=git # always use git push/clone +export TENSORLAKE_SYNC_MODE=volume # always use a mounted cloud volume +export TENSORLAKE_SYNC_MODE=off # disable project sync (pre-0.2.0 behavior) +``` + +Sync failures are surfaced as a toast and logged, but never block the sandbox — you just get an empty workspace. + +> Sync runs once per sandbox per OpenCode process. Restarting OpenCode re-syncs, picking up local changes (git mode fast-forwards the clone; volume mode uploads only changed content). + +--- + ## Prerequisites - An OpenCode installation (see [opencode.ai](https://opencode.ai)) @@ -114,6 +140,7 @@ For local paths: | `TENSORLAKE_MEMORY_MB` | `4096` | RAM allocated to the sandbox in MB | | `TENSORLAKE_DISK_MB` | `10240` | Ephemeral disk size allocated to the sandbox in MB | | `TENSORLAKE_SANDBOX_PROXY_URL` | (auto) | Override the sandbox proxy URL (useful for local development). When set, all sandboxes use this single URL instead of the `https://{id}.sandbox.tensorlake.ai` pattern | +| `TENSORLAKE_SYNC_MODE` | `auto` | How the local project is synced into the sandbox: `auto` (git repos → `git`, plain folders → `volume`), `git`, `volume`, or `off` | --- @@ -253,6 +280,7 @@ opencode-tensorlake-plugin/ ├── core/ │ ├── client.ts # TensorLake SDK client (SandboxClient wrapper) │ ├── logger.ts # file-based logger with rotation + │ ├── project-sync.ts # syncs the local project into the sandbox (git/volume) │ ├── session-manager.ts # sandbox lifecycle management │ ├── toast.ts # TUI toast queue │ └── types.ts # shared type definitions diff --git a/package-lock.json b/package-lock.json index 3f97020..eabad44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "tensorlake-opencode", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tensorlake-opencode", - "version": "0.1.1", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@opencode-ai/plugin": "1.15.5", - "tensorlake": "0.5.30", + "@opencode-ai/plugin": "1.18.18", + "tensorlake": "0.5.109", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, @@ -19,10 +19,63 @@ "typescript": "^5.4.0" } }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -33,9 +86,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -46,9 +99,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -59,9 +112,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -72,9 +125,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -85,9 +138,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -98,19 +151,20 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.5.tgz", - "integrity": "sha512-QvhrDLlQuLeFGET1zB2crMWPvou6PpAzYtKrbun9akWvaskyAcXIAVn3lNYX/InMcut5VzL6ERbPgvM7ucHfLA==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.18.tgz", + "integrity": "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.15.5", - "effect": "4.0.0-beta.65", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.18", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.14", - "@opentui/keymap": ">=0.2.14", - "@opentui/solid": ">=0.2.14" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -134,14 +188,71 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.5.tgz", - "integrity": "sha512-ozJuEmXzrOvia5n0L1KAuvpyf9ESGmTk1FiPhn0RK5X1whbzjlTXL0NAxqNCEkqETxL35jS1KHArEiTpvtJ6FQ==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.18.tgz", + "integrity": "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -152,12 +263,83 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -183,27 +365,42 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.65", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", - "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", + "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", + "ini": "^7.0.0", "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", + "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { "type": "individual", @@ -222,19 +419,78 @@ "node": ">=12.17.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/find-my-way-ts": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", "license": "MIT" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/google-proto-files": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/google-proto-files/-/google-proto-files-5.0.3.tgz", + "integrity": "sha512-HrKG4IIPUHaU1E4+J8yETm41N39+pm9z+ZBERNcq+q8ltHRcFwTcxSviqJSuxPiZJNTWmdBhp03xcElCRlQPxA==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.5.4", + "walkdir": "^0.4.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/isexe": { @@ -243,25 +499,49 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", "license": "Apache-2.0" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/msgpackr": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", - "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", "license": "MIT", "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "msgpackr-extract": "^3.0.4" } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -272,20 +552,38 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -310,10 +608,33 @@ "node": ">=8" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", "funding": [ { "type": "individual", @@ -326,6 +647,24 @@ ], "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -347,30 +686,62 @@ "node": ">=8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tensorlake": { - "version": "0.5.30", - "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.30.tgz", - "integrity": "sha512-XCNgeaYB2upve/BFs6yYbD7m4X+st7KTamti9V27sWFJmu5v7mXaYzEDoKuy5YfNGJE9WbeOyZqtNBcsjCMdsA==", + "version": "0.5.109", + "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.109.tgz", + "integrity": "sha512-kkO36ahveEBsRVPPEvRtMFVo4c8jiYcgX71nRjxAwsO4gRykhqo5OG9NB84WvvCrJVPk08OzU+bQlMx5rUrwRw==", "license": "Apache-2.0", "dependencies": { - "undici": "^8.1.0", + "@grpc/grpc-js": "^1.13.0", + "@grpc/proto-loader": "^0.8.0", + "ajv": "^8.17.1", + "fflate": "^0.8.2", + "google-proto-files": "^5.0.2", + "nanoid": "3.3.11", + "undici": "8.3.0", "ws": "^8.20.0" }, "bin": { - "function-executor": "bin/function-executor.cjs", - "tensorlake": "bin/tensorlake.cjs", - "tensorlake-create-sandbox-image": "bin/tensorlake-create-sandbox-image.cjs", + "function-executor": "bin/function-executor.js", + "tensorlake-create-sandbox-image": "bin/tensorlake-create-sandbox-image.js", "tensorlake-deploy": "bin/tensorlake-deploy.cjs", - "tl": "bin/tl.cjs" + "tensorlake-import-sandbox-image": "bin/tensorlake-import-sandbox-image.js", + "tensorlake-typescript-function-runner": "bin/tensorlake-typescript-function-runner.js" }, "engines": { "node": ">=22.0.0" } }, "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", "license": "MIT", "engines": { "node": ">=20" @@ -403,13 +774,12 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -419,6 +789,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -434,6 +813,23 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", @@ -467,6 +863,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -482,6 +887,33 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 1ec4b4b..cc1424a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tensorlake-opencode", - "version": "0.1.1", + "version": "0.2.0", "license": "Apache-2.0", "description": "OpenCode plugin that runs all sessions in TensorLake sandboxes for isolated execution environments", "keywords": [ @@ -28,8 +28,8 @@ "type-check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@opencode-ai/plugin": "1.15.5", - "tensorlake": "0.5.30", + "@opencode-ai/plugin": "1.18.18", + "tensorlake": "0.5.109", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, From 1ee853082a82f32d09871a205767d7b96dfc6fb3 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 13:51:40 -0500 Subject: [PATCH 02/15] Migrate from deprecated SandboxClient to Sandbox.create()/connect() statics Sandbox handles are now cached per sandbox id so repeated operations reuse resolved proxy routing. resume() refreshes routing on the same handle; terminate drops the cached handle. Removes the SDK's startup deprecation warning. Co-Authored-By: Claude Fable 5 --- .opencode/plugin/tensorlake/core/client.ts | 95 ++++++++++++++-------- 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index 5953d30..f555ced 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -1,16 +1,10 @@ -import { SandboxClient } from 'tensorlake' -import type { Sandbox, FileSystemMount } from 'tensorlake' +import { Sandbox } from 'tensorlake' +import type { FileSystemMount } from 'tensorlake' import { execFileSync } from 'child_process' import { logger } from './logger.js' const MANAGEMENT_API = process.env.TENSORLAKE_API_URL ?? 'https://api.tensorlake.ai' -function getSandboxProxyUrl(sandboxId: string): string { - const custom = process.env.TENSORLAKE_SANDBOX_PROXY_URL - if (custom) return custom - return `https://${sandboxId}.sandbox.tensorlake.ai` -} - export type SandboxInfo = { sandbox_id: string status: string @@ -34,16 +28,11 @@ export type DirectoryEntry = { } export class TensorLakeClient { - private readonly sdk: SandboxClient + // Connected handles keyed by sandboxId, so repeated operations reuse the + // resolved proxy routing instead of re-resolving on every call. + private readonly handles = new Map() - constructor(private readonly apiKey: string) { - this.sdk = SandboxClient.forCloud({ - apiKey, - apiUrl: MANAGEMENT_API, - organizationId: process.env.TENSORLAKE_ORGANIZATION_ID, - projectId: process.env.TENSORLAKE_PROJECT_ID, - }) - } + constructor(private readonly apiKey: string) {} hasApiKey(): boolean { return this.apiKey.length > 0 @@ -53,12 +42,47 @@ export class TensorLakeClient { return this.apiKey } + private clientOptions() { + return { + apiKey: this.apiKey, + apiUrl: MANAGEMENT_API, + ...(process.env.TENSORLAKE_ORGANIZATION_ID + ? { organizationId: process.env.TENSORLAKE_ORGANIZATION_ID } + : {}), + ...(process.env.TENSORLAKE_PROJECT_ID ? { projectId: process.env.TENSORLAKE_PROJECT_ID } : {}), + } + } + + private async connectSandbox(sandboxId: string): Promise { + const cached = this.handles.get(sandboxId) + if (cached) return cached + const proxyUrl = process.env.TENSORLAKE_SANDBOX_PROXY_URL + const sandbox = await Sandbox.connect({ + sandboxId, + ...(proxyUrl ? { proxyUrl } : {}), + ...this.clientOptions(), + }) + this.handles.set(sandboxId, sandbox) + return sandbox + } + + private dropHandle(sandboxId: string): void { + const handle = this.handles.get(sandboxId) + if (!handle) return + this.handles.delete(sandboxId) + try { + handle.close() + } catch { + // closing a stale handle is best-effort + } + } + async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number; fileSystems?: FileSystemMount[] } = {}): Promise { const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2') const memoryMb = parseInt(process.env.TENSORLAKE_MEMORY_MB ?? '4096', 10) const ephemeralDiskMb = parseInt(process.env.TENSORLAKE_DISK_MB ?? '10240', 10) logger.info(`Creating sandbox name=${opts.name ?? '(ephemeral)'} image=${opts.image ?? '(default)'} cpus=${cpus} memoryMb=${memoryMb} diskMb=${ephemeralDiskMb}`) - const sandbox = await this.sdk.createAndConnect({ + const sandbox = await Sandbox.create({ ...(opts.image ? { image: opts.image } : {}), cpus, memoryMb, @@ -66,35 +90,44 @@ export class TensorLakeClient { ...(opts.name ? { name: opts.name } : {}), ...(opts.timeoutSecs ? { timeoutSecs: opts.timeoutSecs } : {}), ...(opts.fileSystems?.length ? { fileSystems: opts.fileSystems } : {}), + ...this.clientOptions(), }) + this.handles.set(sandbox.sandboxId, sandbox) return { sandbox_id: sandbox.sandboxId, status: 'running' } } async listSandboxFileSystems(sandboxId: string): Promise { - const info = await this.sdk.get(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) + const info = await sandbox.info() return info.fileSystems ?? [] } async attachFileSystem(sandboxId: string, fileSystemId: string, mountPath: string): Promise { - await this.sdk.attachFileSystem(sandboxId, fileSystemId, mountPath) + const sandbox = await this.connectSandbox(sandboxId) + await sandbox.attachFileSystem(fileSystemId, mountPath) } async getSandbox(sandboxId: string): Promise { - const info = await this.sdk.get(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) + const info = await sandbox.info() return { sandbox_id: info.sandboxId, status: info.status as unknown as string } } async deleteSandbox(sandboxId: string): Promise { try { - await this.sdk.delete(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) + await sandbox.terminate() } catch (err: unknown) { if (String((err as Error)?.message ?? err).includes('404')) return throw err + } finally { + this.dropHandle(sandboxId) } } async suspendSandbox(sandboxId: string): Promise { - await this.sdk.suspend(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) + await sandbox.suspend() } suspendSandboxSync(sandboxId: string): void { @@ -121,7 +154,9 @@ export class TensorLakeClient { } async resumeSandbox(sandboxId: string): Promise { - await this.sdk.resume(sandboxId) + // resume() waits for running and refreshes the handle's proxy routing. + const sandbox = await this.connectSandbox(sandboxId) + await sandbox.resume() } async waitForSuspended(sandboxId: string, timeoutMs = 30_000): Promise { @@ -144,17 +179,13 @@ export class TensorLakeClient { throw new Error(`Sandbox ${sandboxId} did not become running within ${timeoutMs}ms`) } - private connectSandbox(sandboxId: string): Sandbox { - return this.sdk.connect(sandboxId, getSandboxProxyUrl(sandboxId)) - } - async executeCommand( sandboxId: string, command: string, workingDir = '/tmp/workspace', timeoutMs = 120_000, ): Promise { - const sandbox = this.connectSandbox(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) const result = await sandbox.run('sh', { args: ['-c', command], workingDir, @@ -168,18 +199,18 @@ export class TensorLakeClient { } async readFile(sandboxId: string, path: string): Promise { - const sandbox = this.connectSandbox(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) const data = await sandbox.readFile(path) return Buffer.from(data) } async writeFile(sandboxId: string, path: string, content: Buffer): Promise { - const sandbox = this.connectSandbox(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) await sandbox.writeFile(path, content) } async listDirectory(sandboxId: string, path: string): Promise { - const sandbox = this.connectSandbox(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) const response = await sandbox.listDirectory(path) return response.entries.map((e) => ({ name: e.name, From 18ad864078a7af81150e27256890e96791c472ef Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 13:58:45 -0500 Subject: [PATCH 03/15] Add real background processes to the bash tool background=true now uses sandbox.startProcess() instead of a fire-and-forget run, returning a pid. New bash_output tool reports process status plus output lines new since the last call; new bash_kill tool stops the process. Processes are started unnamed (non-managed) so the daemon keeps status and output queryable after exit or kill. Co-Authored-By: Claude Fable 5 --- .opencode/plugin/tensorlake/core/client.ts | 42 +++++++++++ .../tensorlake/plugins/system-transform.ts | 2 +- .opencode/plugin/tensorlake/tools.ts | 4 +- .opencode/plugin/tensorlake/tools/bash.ts | 74 ++++++++++++++++++- README.md | 5 +- 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index f555ced..92fce0c 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -27,6 +27,14 @@ export type DirectoryEntry = { size?: number } +export type ProcessStatusInfo = { + pid: number + status: string + exitCode?: number + signal?: number + command: string +} + export class TensorLakeClient { // Connected handles keyed by sandboxId, so repeated operations reuse the // resolved proxy routing instead of re-resolving on every call. @@ -198,6 +206,40 @@ export class TensorLakeClient { } } + // Processes are started unnamed (non-managed) on purpose: the daemon keeps + // tracking them after exit or kill, so status and output stay queryable by PID. + async startBackgroundProcess(sandboxId: string, command: string, workingDir: string): Promise { + const sandbox = await this.connectSandbox(sandboxId) + const info = await sandbox.startProcess('sh', { + args: ['-c', command], + workingDir, + }) + return info.pid + } + + async getProcessStatus(sandboxId: string, pid: number): Promise { + const sandbox = await this.connectSandbox(sandboxId) + const info = await sandbox.getProcess(pid) + return { + pid: info.pid, + status: info.status as unknown as string, + exitCode: info.exitCode, + signal: info.signal, + command: [info.command, ...(info.args ?? [])].join(' '), + } + } + + async getProcessOutput(sandboxId: string, pid: number): Promise { + const sandbox = await this.connectSandbox(sandboxId) + const output = await sandbox.getOutput(pid) + return output.lines + } + + async killProcess(sandboxId: string, pid: number): Promise { + const sandbox = await this.connectSandbox(sandboxId) + await sandbox.killProcess(pid) + } + async readFile(sandboxId: string, path: string): Promise { const sandbox = await this.connectSandbox(sandboxId) const data = await sandbox.readFile(path) diff --git a/.opencode/plugin/tensorlake/plugins/system-transform.ts b/.opencode/plugin/tensorlake/plugins/system-transform.ts index 41f28d1..f5fbf95 100644 --- a/.opencode/plugin/tensorlake/plugins/system-transform.ts +++ b/.opencode/plugin/tensorlake/plugins/system-transform.ts @@ -11,7 +11,7 @@ export async function systemPromptTransform(ctx: PluginInput, workDir: string, p 'This session is running inside a TensorLake sandbox.', 'All bash commands, file reads/writes, and searches run inside the sandbox.', 'Do NOT use paths from the host system.', - "For long-running commands (servers, watchers), use the 'background' option.", + "For long-running commands (servers, watchers), use bash with background=true; check on them with bash_output and stop them with bash_kill.", ] if (mode === 'git') { lines.push( diff --git a/.opencode/plugin/tensorlake/tools.ts b/.opencode/plugin/tensorlake/tools.ts index be6c8f0..9427e97 100644 --- a/.opencode/plugin/tensorlake/tools.ts +++ b/.opencode/plugin/tensorlake/tools.ts @@ -1,6 +1,6 @@ import type { TensorLakeSessionManager } from './core/session-manager.js' import type { PluginInput } from '@opencode-ai/plugin' -import { bashTool } from './tools/bash.js' +import { bashTool, bashOutputTool, bashKillTool } from './tools/bash.js' import { readTool } from './tools/read.js' import { writeTool } from './tools/write.js' import { editTool } from './tools/edit.js' @@ -16,6 +16,8 @@ export function createTensorLakeTools( ) { return { bash: bashTool(sessionManager, projectId, worktree, pluginCtx), + bash_output: bashOutputTool(sessionManager, projectId, worktree, pluginCtx), + bash_kill: bashKillTool(sessionManager, projectId, worktree, pluginCtx), read: readTool(sessionManager, projectId, worktree, pluginCtx), write: writeTool(sessionManager, projectId, worktree, pluginCtx), edit: editTool(sessionManager, projectId, worktree, pluginCtx), diff --git a/.opencode/plugin/tensorlake/tools/bash.ts b/.opencode/plugin/tensorlake/tools/bash.ts index 2b521ce..0d0caca 100644 --- a/.opencode/plugin/tensorlake/tools/bash.ts +++ b/.opencode/plugin/tensorlake/tools/bash.ts @@ -3,13 +3,29 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' import type { TensorLakeSessionManager } from '../core/session-manager.js' +// Lines already returned per background process (keyed by sandboxId:pid), +// so bash_output only shows output produced since the previous call. +const outputOffsets = new Map() + +function offsetKey(sandboxId: string, pid: number): string { + return `${sandboxId}:${pid}` +} + +function describeStatus(pid: number, status: string, exitCode?: number, signal?: number): string { + if (status === 'running') return `Background process ${pid} is running.` + const detail = + exitCode !== undefined ? ` with exit code ${exitCode}` : signal !== undefined ? ` by signal ${signal}` : '' + return `Background process ${pid} has ${status}${detail}.` +} + export const bashTool = ( sessionManager: TensorLakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Executes shell commands in a TensorLake sandbox', + description: + 'Executes shell commands in a TensorLake sandbox. Set background=true for long-running commands (servers, watchers); it returns a pid to use with bash_output and bash_kill.', args: { command: z.string(), background: z.boolean().optional(), @@ -20,8 +36,9 @@ export const bashTool = ( const workDir = sessionManager.projectDir(worktree) if (args.background) { - client.executeCommand(sandboxId, args.command, workDir, 300_000).catch(() => {}) - return `Command started in background: ${args.command}` + const pid = await client.startBackgroundProcess(sandboxId, args.command, workDir) + outputOffsets.set(offsetKey(sandboxId, pid), 0) + return `Started background process with pid ${pid}. Use bash_output with pid=${pid} to read its output, bash_kill to stop it.` } const result = await client.executeCommand(sandboxId, args.command, workDir) @@ -29,3 +46,54 @@ export const bashTool = ( return `Exit code: ${result.exitCode}\n${output}` }, }) + +export const bashOutputTool = ( + sessionManager: TensorLakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + 'Returns new output and status of a background process started with bash background=true. Only lines produced since the previous bash_output call for that pid are returned.', + args: { + pid: z.number().describe('Pid returned by bash when background=true'), + }, + async execute(args: { pid: number }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + + let status + try { + status = await client.getProcessStatus(sandboxId, args.pid) + } catch (err: any) { + return `No background process with pid ${args.pid} found in the sandbox (${err?.message ?? err}).` + } + const lines = await client.getProcessOutput(sandboxId, args.pid) + const key = offsetKey(sandboxId, args.pid) + const fresh = lines.slice(outputOffsets.get(key) ?? 0) + outputOffsets.set(key, lines.length) + + const header = describeStatus(status.pid, status.status, status.exitCode, status.signal) + const body = fresh.length > 0 ? fresh.join('\n') : '(no new output)' + return `${header}\n${body}` + }, +}) + +export const bashKillTool = ( + sessionManager: TensorLakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: 'Kills a background process started with bash background=true.', + args: { + pid: z.number().describe('Pid returned by bash when background=true'), + }, + async execute(args: { pid: number }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + await client.killProcess(sandboxId, args.pid) + outputOffsets.delete(offsetKey(sandboxId, args.pid)) + return `Background process ${args.pid} killed.` + }, +}) diff --git a/README.md b/README.md index d3f34ba..f280b5d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re - **Sandbox lifecycle** - A sandbox is created lazily on the **first intercepted tool call** in a session (not at launch) and deleted when the session is deleted. Sandbox state is persisted to disk so that reconnection is possible across OpenCode restarts. - **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed to a TensorLake-hosted git repo and cloned inside the sandbox; non-git folders are uploaded to a TensorLake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). +- **Background processes** - `bash` with `background=true` starts a real long-running process in the sandbox (dev server, watcher, etc.) and returns its pid. Two extra tools, `bash_output` and `bash_kill`, let the model read new output and stop the process. - **Suspension/resume** - If a sandbox is found in a suspended state it is automatically resumed before use. - **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox and where the project lives. - **Toast notifications** - Sandbox status events (created, connected, resumed, deleted) surface as TUI toasts. @@ -21,7 +22,9 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re | OpenCode tool | Sandbox implementation | |---|---| -| `bash` | `sandbox.run('sh', { args: ['-c', cmd] })` via SDK | +| `bash` | `sandbox.run('sh', { args: ['-c', cmd] })` via SDK; `background=true` uses `sandbox.startProcess(...)` and returns a pid | +| `bash_output` *(added)* | `sandbox.getProcess(pid)` + `sandbox.getOutput(pid)` — returns status and output lines new since the last call | +| `bash_kill` *(added)* | `sandbox.killProcess(pid)` | | `read` | `sandbox.readFile(path)` via SDK | | `write` | `sandbox.writeFile(path, content)` via SDK | | `edit` | read + string replace + write | From 5cbfd3caa7f1f88b578d075cbf1ca2b86409f1c3 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 14:47:47 -0500 Subject: [PATCH 04/15] Harden sandbox calls, project re-sync, and background output replay - client: route proxy-bound operations through withSandbox(), which drops stale handles (sandbox resumed on another host) and retries idempotent calls once; command execution never retries to avoid double-running. Use typed SDK errors instead of matching message strings. - project-sync: re-syncs fast-forward only instead of git reset --hard, so in-sandbox commits or changes are never clobbered; divergence is logged. - bash_output: cap replay after a plugin restart to the last 200 buffered lines and drop stale offsets when a pid disappears. Co-Authored-By: Claude Fable 5 --- .opencode/plugin/tensorlake/core/client.ts | 87 +++++++++++++------ .../plugin/tensorlake/core/project-sync.ts | 26 ++++-- .opencode/plugin/tensorlake/tools/bash.ts | 17 +++- 3 files changed, 93 insertions(+), 37 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index 92fce0c..f0b0800 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -1,4 +1,4 @@ -import { Sandbox } from 'tensorlake' +import { RemoteAPIError, Sandbox, SandboxConnectionError, SandboxNotFoundError } from 'tensorlake' import type { FileSystemMount } from 'tensorlake' import { execFileSync } from 'child_process' import { logger } from './logger.js' @@ -74,6 +74,39 @@ export class TensorLakeClient { return sandbox } + // Errors suggesting the cached handle's proxy routing is stale — e.g. the + // sandbox was suspended and resumed outside this process, moving it to a + // different host. Only resume() on this handle refreshes routing; info() + // never does, so the only self-heal is dropping the handle and reconnecting. + private isStaleHandleError(err: unknown): boolean { + return ( + err instanceof SandboxConnectionError || + err instanceof SandboxNotFoundError || + (err instanceof RemoteAPIError && [404, 502, 503].includes(err.statusCode)) + ) + } + + // Runs a proxy-routed operation. On a stale-handle error the handle is + // dropped so the next call reconnects with fresh routing; idempotent ops + // (retry: true) additionally reconnect and retry once themselves. Command + // execution uses retry: false to avoid any chance of running a command twice. + private async withSandbox( + sandboxId: string, + op: (sandbox: Sandbox) => Promise, + opts: { retry: boolean } = { retry: true }, + ): Promise { + const sandbox = await this.connectSandbox(sandboxId) + try { + return await op(sandbox) + } catch (err: unknown) { + if (!this.isStaleHandleError(err)) throw err + this.dropHandle(sandboxId) + if (!opts.retry) throw err + logger.warn(`Sandbox ${sandboxId} call failed (${(err as Error)?.message ?? err}); reconnecting and retrying once`) + return op(await this.connectSandbox(sandboxId)) + } + } + private dropHandle(sandboxId: string): void { const handle = this.handles.get(sandboxId) if (!handle) return @@ -126,7 +159,9 @@ export class TensorLakeClient { const sandbox = await this.connectSandbox(sandboxId) await sandbox.terminate() } catch (err: unknown) { - if (String((err as Error)?.message ?? err).includes('404')) return + // Already deleted — treat as success. + if (err instanceof SandboxNotFoundError) return + if (err instanceof RemoteAPIError && err.statusCode === 404) return throw err } finally { this.dropHandle(sandboxId) @@ -193,12 +228,16 @@ export class TensorLakeClient { workingDir = '/tmp/workspace', timeoutMs = 120_000, ): Promise { - const sandbox = await this.connectSandbox(sandboxId) - const result = await sandbox.run('sh', { - args: ['-c', command], - workingDir, - timeout: timeoutMs / 1000, - }) + const result = await this.withSandbox( + sandboxId, + (sandbox) => + sandbox.run('sh', { + args: ['-c', command], + workingDir, + timeout: timeoutMs / 1000, + }), + { retry: false }, + ) return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? '', @@ -209,17 +248,20 @@ export class TensorLakeClient { // Processes are started unnamed (non-managed) on purpose: the daemon keeps // tracking them after exit or kill, so status and output stay queryable by PID. async startBackgroundProcess(sandboxId: string, command: string, workingDir: string): Promise { - const sandbox = await this.connectSandbox(sandboxId) - const info = await sandbox.startProcess('sh', { - args: ['-c', command], - workingDir, - }) + const info = await this.withSandbox( + sandboxId, + (sandbox) => + sandbox.startProcess('sh', { + args: ['-c', command], + workingDir, + }), + { retry: false }, + ) return info.pid } async getProcessStatus(sandboxId: string, pid: number): Promise { - const sandbox = await this.connectSandbox(sandboxId) - const info = await sandbox.getProcess(pid) + const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.getProcess(pid)) return { pid: info.pid, status: info.status as unknown as string, @@ -230,30 +272,25 @@ export class TensorLakeClient { } async getProcessOutput(sandboxId: string, pid: number): Promise { - const sandbox = await this.connectSandbox(sandboxId) - const output = await sandbox.getOutput(pid) + const output = await this.withSandbox(sandboxId, (sandbox) => sandbox.getOutput(pid)) return output.lines } async killProcess(sandboxId: string, pid: number): Promise { - const sandbox = await this.connectSandbox(sandboxId) - await sandbox.killProcess(pid) + await this.withSandbox(sandboxId, (sandbox) => sandbox.killProcess(pid)) } async readFile(sandboxId: string, path: string): Promise { - const sandbox = await this.connectSandbox(sandboxId) - const data = await sandbox.readFile(path) + const data = await this.withSandbox(sandboxId, (sandbox) => sandbox.readFile(path)) return Buffer.from(data) } async writeFile(sandboxId: string, path: string, content: Buffer): Promise { - const sandbox = await this.connectSandbox(sandboxId) - await sandbox.writeFile(path, content) + await this.withSandbox(sandboxId, (sandbox) => sandbox.writeFile(path, content)) } async listDirectory(sandboxId: string, path: string): Promise { - const sandbox = await this.connectSandbox(sandboxId) - const response = await sandbox.listDirectory(path) + const response = await this.withSandbox(sandboxId, (sandbox) => sandbox.listDirectory(path)) return response.entries.map((e) => ({ name: e.name, is_dir: e.isDir, diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index 16e969d..fa1184a 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -111,11 +111,8 @@ export function resolveSyncMode(worktree: string): SyncMode { return existsSync(join(worktree, '.git')) ? 'git' : 'volume' } -function repoName(projectId: string): string { - return sanitizeName(`opencode-${projectId}`) -} - -function volumeName(projectId: string): string { +/** Name shared by the hosted git repo (git mode) and cloud volume (volume mode). */ +function syncResourceName(projectId: string): string { return sanitizeName(`opencode-${projectId}`) } @@ -133,7 +130,7 @@ export async function syncGitProject( ): Promise { const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) try { - const repo = repoName(projectId) + const repo = syncResourceName(projectId) try { await repos.info(repo) } catch { @@ -154,12 +151,17 @@ export async function syncGitProject( const parsed = new URL(url) const credLine = `${parsed.protocol}//${encodeURIComponent(cred.gitUsername)}:${encodeURIComponent(cred.token)}@${parsed.host}` + // Re-syncs only fast-forward: a sandbox clone with its own commits or + // uncommitted changes must never be clobbered by a hard reset. const script = [ 'set -e', 'git config --global credential.helper store', `printf '%s\\n' '${credLine}' > ~/.git-credentials`, `if [ -d '${destDir}/.git' ]; then`, - ` cd '${destDir}' && git fetch origin main && git reset --hard origin/main`, + ` cd '${destDir}' && git fetch origin main`, + ' if ! git merge --ff-only origin/main; then', + ' echo TENSORLAKE_SYNC_DIVERGED', + ' fi', 'else', ` rm -rf '${destDir}' && git clone --branch main '${url}' '${destDir}'`, 'fi', @@ -169,7 +171,13 @@ export async function syncGitProject( if (result.exitCode !== 0) { throw new Error(`git sync failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`) } - logger.info(`Project cloned into sandbox at ${destDir}`) + if (result.stdout.includes('TENSORLAKE_SYNC_DIVERGED')) { + logger.warn( + `Sandbox clone at ${destDir} has local commits or changes that diverge from the pushed project; left untouched instead of resetting`, + ) + } else { + logger.info(`Project cloned into sandbox at ${destDir}`) + } } finally { repos.close() } @@ -216,7 +224,7 @@ export async function ensureVolumeWithProject( projectId: string, mountPath: string, ): Promise { - const name = volumeName(projectId) + const name = syncResourceName(projectId) const options = await cloudOptions(apiKey) const fsClient = new FilesystemClient(options) let fs diff --git a/.opencode/plugin/tensorlake/tools/bash.ts b/.opencode/plugin/tensorlake/tools/bash.ts index 0d0caca..c905b5d 100644 --- a/.opencode/plugin/tensorlake/tools/bash.ts +++ b/.opencode/plugin/tensorlake/tools/bash.ts @@ -7,6 +7,10 @@ import type { TensorLakeSessionManager } from '../core/session-manager.js' // so bash_output only shows output produced since the previous call. const outputOffsets = new Map() +// With no stored offset (e.g. after a plugin restart) the whole buffer would +// count as "new"; cap the replay so a long-running server's log can't flood. +const MAX_REPLAY_LINES = 200 + function offsetKey(sandboxId: string, pid: number): string { return `${sandboxId}:${pid}` } @@ -62,20 +66,27 @@ export const bashOutputTool = ( const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) const client = sessionManager.getClient() + const key = offsetKey(sandboxId, args.pid) let status try { status = await client.getProcessStatus(sandboxId, args.pid) } catch (err: any) { + outputOffsets.delete(key) return `No background process with pid ${args.pid} found in the sandbox (${err?.message ?? err}).` } const lines = await client.getProcessOutput(sandboxId, args.pid) - const key = offsetKey(sandboxId, args.pid) - const fresh = lines.slice(outputOffsets.get(key) ?? 0) + const offset = outputOffsets.get(key) + let fresh = lines.slice(offset ?? 0) + let truncationNote = '' + if (offset === undefined && fresh.length > MAX_REPLAY_LINES) { + truncationNote = `(showing last ${MAX_REPLAY_LINES} of ${fresh.length} buffered lines)\n` + fresh = fresh.slice(-MAX_REPLAY_LINES) + } outputOffsets.set(key, lines.length) const header = describeStatus(status.pid, status.status, status.exitCode, status.signal) const body = fresh.length > 0 ? fresh.join('\n') : '(no new output)' - return `${header}\n${body}` + return `${header}\n${truncationNote}${body}` }, }) From 82858ab7e7bf78276554853860d425252ebe3087 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 14:48:00 -0500 Subject: [PATCH 05/15] Fix transitional-status handling and avoid blocking tool calls on re-sync - The in-memory-cache branch of _getSandbox now handles 'suspending' (wait for suspend to land, then resume) and waits for any other non-running status, matching the persisted-storage branch. Previously a cached sandbox mid-suspension was returned as if it were live. - Fix a latent deadlock on the cached terminated path: the recursive getSandbox() call returned the session's own still-pending in-flight promise, resolving the promise to itself. Recurse via _getSandbox. - Project sync no longer blocks the first tool call of every process when the sandbox already holds a project copy (e.g. resumed sandbox synced by an earlier process): ensureProjectAvailable() checks for the project dir once and lets the refresh push/clone run in the background; only a sandbox with no copy at all awaits the initial sync. Syncs are deduped per sandbox via an in-flight map. Co-Authored-By: Claude Fable 5 --- .../plugin/tensorlake/core/session-manager.ts | 83 +++++++++++++++++-- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index 6dddd15..86d7a36 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -21,6 +21,13 @@ export class TensorLakeSessionManager { private readonly inflight = new Map>() // Sandboxes whose project sync already ran in this process private readonly synced = new Set() + // In-flight sync promises keyed by sandboxId — prevents concurrent double-sync + private readonly syncInflight = new Map>() + // Sandboxes confirmed to already contain the project dir (check runs once per process) + private readonly hasProjectDir = new Set() + // Last failed sync attempt per sandbox — retried after a cooldown instead of on every tool call + private readonly syncFailedAt = new Map() + private static readonly SYNC_RETRY_COOLDOWN_MS = 60_000 public readonly workDir: string private readonly storageDir: string @@ -53,6 +60,8 @@ export class TensorLakeSessionManager { this.synced.add(sandboxId) return } + const failedAt = this.syncFailedAt.get(sandboxId) + if (failedAt !== undefined && Date.now() - failedAt < TensorLakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return const destDir = this.projectDir(worktree) try { if (mode === 'git') { @@ -64,13 +73,60 @@ export class TensorLakeSessionManager { await ensureVolumeMounted(this.client, mount, sandboxId) } this.synced.add(sandboxId) + this.syncFailedAt.delete(sandboxId) toast.show({ title: 'Project synced', message: `Project available at ${destDir}`, variant: 'success' }) } catch (err: any) { + this.syncFailedAt.set(sandboxId, Date.now()) logger.error(`Project sync (${mode}) failed: ${err?.stack ?? err}`) + // Tools default their working directory to destDir; create it so the + // sandbox really is usable (but empty) while sync is failing. + try { + await this.client.executeCommand(sandboxId, `mkdir -p ${destDir}`, '/') + } catch (mkdirErr) { + logger.warn(`Failed to create project dir after sync failure: ${mkdirErr}`) + } toast.show({ title: 'Project sync failed', message: `${err?.message ?? err}. Sandbox is usable but empty.`, variant: 'error' }) } } + /** Run syncProject at most once concurrently per sandbox. Never rejects (syncProject handles its own errors). */ + private syncProjectOnce(sandboxId: string, projectId: string, worktree: string): Promise { + const existing = this.syncInflight.get(sandboxId) + if (existing) return existing + const promise = this.syncProject(sandboxId, projectId, worktree) + .finally(() => this.syncInflight.delete(sandboxId)) + this.syncInflight.set(sandboxId, promise) + return promise + } + + /** + * Make sure tools can run against the project dir without always paying for + * a full sync up front. If the sandbox already contains the project dir + * (e.g. a resumed sandbox synced by an earlier process), the refresh sync + * runs in the background; only a sandbox with no project copy at all blocks + * on the initial sync. + */ + private async ensureProjectAvailable(sandboxId: string, projectId: string, worktree: string): Promise { + if (this.synced.has(sandboxId)) return + if (resolveSyncMode(worktree) === 'off') { + this.synced.add(sandboxId) + return + } + const sync = this.syncProjectOnce(sandboxId, projectId, worktree) + if (this.hasProjectDir.has(sandboxId)) return + const destDir = this.projectDir(worktree) + try { + const check = await this.client.executeCommand(sandboxId, `test -d '${destDir}'`, '/') + if (check.exitCode === 0) { + this.hasProjectDir.add(sandboxId) + return + } + } catch (err) { + logger.warn(`Failed to check for existing project dir: ${err}`) + } + await sync + } + private storagePath(projectId: string): string { return join(this.storageDir, `${projectId}.json`) } @@ -149,18 +205,26 @@ export class TensorLakeSessionManager { if (cached) { try { const info = await this.client.getSandbox(cached.sandboxId) - if (info.status === 'suspended') { - logger.info(`Resuming sandbox ${cached.sandboxId}`) - await this.client.resumeSandbox(cached.sandboxId) - await this.client.waitForRunning(cached.sandboxId) - toast.show({ title: 'Sandbox resumed', message: 'Sandbox resumed from suspension.', variant: 'info' }) - } else if (info.status === 'terminated') { + if (info.status === 'terminated') { logger.warn(`Sandbox ${cached.sandboxId} was terminated, creating new one`) this.cache.delete(sessionId) this.removeSession(projectId, sessionId) - return this.getSandbox(sessionId, projectId, worktree, pluginCtx) + // Call _getSandbox directly: getSandbox would return the still-pending + // in-flight promise for this session, resolving the promise to itself. + return this._getSandbox(sessionId, projectId, worktree, pluginCtx) + } + if (info.status === 'suspended' || info.status === 'suspending') { + logger.info(`Resuming sandbox ${cached.sandboxId} (was ${info.status})`) + if (info.status === 'suspending') await this.client.waitForSuspended(cached.sandboxId) + await this.client.resumeSandbox(cached.sandboxId) + await this.client.waitForRunning(cached.sandboxId) + toast.show({ title: 'Sandbox resumed', message: 'Sandbox resumed from suspension.', variant: 'info' }) + } else if (info.status !== 'running') { + await this.client.waitForRunning(cached.sandboxId) } this.updateSession(projectId, worktree, sessionId, cached.sandboxId) + // No-op when already synced; retries a previously failed sync (after cooldown) + await this.ensureProjectAvailable(cached.sandboxId, projectId, worktree) return cached } catch (err) { logger.warn(`Failed to check cached sandbox: ${err}`) @@ -200,7 +264,7 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, stored.sandboxId) const reused = storedSessionId !== sessionId toast.show({ title: 'Sandbox connected', message: reused ? 'Reusing sandbox from previous session.' : 'Connected to existing sandbox.', variant: 'info' }) - await this.syncProject(stored.sandboxId, projectId, worktree) + await this.ensureProjectAvailable(stored.sandboxId, projectId, worktree) return entry } catch (err) { logger.warn(`Failed to connect to sandbox ${stored.sandboxId}: ${err}`) @@ -228,7 +292,8 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, created.sandbox_id) toast.show({ title: 'Sandbox created', message: 'New sandbox is ready.', variant: 'success' }) - await this.syncProject(created.sandbox_id, projectId, worktree) + // A fresh sandbox has no project copy — the sync must finish before tools run + await this.syncProjectOnce(created.sandbox_id, projectId, worktree) return entry } From 4f74431fb9d98e6f27718424e730effadb13e51a Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 15:07:11 -0500 Subject: [PATCH 06/15] Loosen dependency pins to caret ranges tensorlake ^0.5.109 picks up patch fixes but blocks 0.6.x breaking changes; @opencode-ai/plugin is types-only so ^1.18.18 is safe. Co-Authored-By: Claude Fable 5 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index eabad44..f398a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@opencode-ai/plugin": "1.18.18", - "tensorlake": "0.5.109", + "@opencode-ai/plugin": "^1.18.18", + "tensorlake": "^0.5.109", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, diff --git a/package.json b/package.json index cc1424a..d0c80e9 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "type-check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@opencode-ai/plugin": "1.18.18", - "tensorlake": "0.5.109", + "@opencode-ai/plugin": "^1.18.18", + "tensorlake": "^0.5.109", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, From 0d05d69c9a7284b462a69d4b3c8992c06419326d Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 15:58:30 -0500 Subject: [PATCH 07/15] set up credential in opencode auth login --- .opencode/package-lock.json | 210 ++++++++---------- .opencode/plugin/tensorlake/core/client.ts | 15 +- .../plugin/tensorlake/core/session-manager.ts | 10 +- .opencode/plugin/tensorlake/index.ts | 9 +- README.md | 39 +++- 5 files changed, 138 insertions(+), 145 deletions(-) diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 88c770f..6f6a030 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,14 +5,25 @@ "packages": { "": { "dependencies": { - "@opencode-ai/plugin": "1.15.5", - "tensorlake": "^0.5.14" + "@opencode-ai/plugin": "1.18.18" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -23,9 +34,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -36,9 +47,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -49,9 +60,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -62,9 +73,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -75,9 +86,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -88,19 +99,20 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.5.tgz", - "integrity": "sha512-QvhrDLlQuLeFGET1zB2crMWPvou6PpAzYtKrbun9akWvaskyAcXIAVn3lNYX/InMcut5VzL6ERbPgvM7ucHfLA==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.18.tgz", + "integrity": "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.15.5", - "effect": "4.0.0-beta.65", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.18", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.14", - "@opentui/keymap": ">=0.2.14", - "@opentui/solid": ">=0.2.14" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -115,9 +127,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.5.tgz", - "integrity": "sha512-ozJuEmXzrOvia5n0L1KAuvpyf9ESGmTk1FiPhn0RK5X1whbzjlTXL0NAxqNCEkqETxL35jS1KHArEiTpvtJ6FQ==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.18.tgz", + "integrity": "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -154,27 +166,27 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.65", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", - "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", + "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", + "ini": "^7.0.0", "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", + "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" + "uuid": "^14.0.0", + "yaml": "^2.9.0" } }, "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { "type": "individual", @@ -200,12 +212,12 @@ "license": "MIT" }, "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/isexe": { @@ -214,6 +226,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", @@ -221,18 +239,18 @@ "license": "Apache-2.0" }, "node_modules/msgpackr": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", - "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", "license": "MIT", "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "msgpackr-extract": "^3.0.4" } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -243,18 +261,18 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", "license": "MIT" }, "node_modules/node-gyp-build-optional-packages": { @@ -282,9 +300,9 @@ } }, "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", "funding": [ { "type": "individual", @@ -318,48 +336,19 @@ "node": ">=8" } }, - "node_modules/tensorlake": { - "version": "0.5.14", - "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.14.tgz", - "integrity": "sha512-qeipeiNxoAv/7jks2XgI9q8AyFv36hv2YVOEUqdWo+ZjQmKpUZWFzDxZ+v0KBlkBsRpL/9RDvSZQgUjJfn9dFw==", - "license": "Apache-2.0", - "dependencies": { - "undici": "^8.1.0", - "ws": "^8.20.0" - }, - "bin": { - "function-executor": "bin/function-executor.cjs", - "tensorlake": "bin/tensorlake.cjs", - "tensorlake-create-sandbox-image": "bin/tensorlake-create-sandbox-image.cjs", - "tensorlake-deploy": "bin/tensorlake-deploy.cjs", - "tl": "bin/tl.cjs" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", "license": "MIT", "engines": { "node": ">=20" } }, - "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -384,27 +373,6 @@ "node": ">= 8" } }, - "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index f0b0800..e649e13 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -40,19 +40,21 @@ export class TensorLakeClient { // resolved proxy routing instead of re-resolving on every call. private readonly handles = new Map() - constructor(private readonly apiKey: string) {} + // Credentials are resolved lazily on every use so a key added via + // `opencode auth login` after startup is picked up without a restart. + constructor(private readonly resolveKey: () => string | undefined) {} hasApiKey(): boolean { - return this.apiKey.length > 0 + return (this.resolveKey() ?? '').length > 0 } getApiKey(): string { - return this.apiKey + return this.resolveKey() ?? '' } private clientOptions() { return { - apiKey: this.apiKey, + apiKey: this.getApiKey(), apiUrl: MANAGEMENT_API, ...(process.env.TENSORLAKE_ORGANIZATION_ID ? { organizationId: process.env.TENSORLAKE_ORGANIZATION_ID } @@ -174,10 +176,11 @@ export class TensorLakeClient { } suspendSandboxSync(sandboxId: string): void { + const apiKey = this.getApiKey() execFileSync('curl', [ '-s', '-X', 'POST', `${MANAGEMENT_API}/sandboxes/${sandboxId}/suspend`, - '-H', `Authorization: Bearer ${this.apiKey}`, + '-H', `Authorization: Bearer ${apiKey}`, ], { timeout: 10_000 }) const deadline = Date.now() + 30_000 while (Date.now() < deadline) { @@ -185,7 +188,7 @@ export class TensorLakeClient { const out = execFileSync('curl', [ '-s', `${MANAGEMENT_API}/sandboxes/${sandboxId}`, - '-H', `Authorization: Bearer ${this.apiKey}`, + '-H', `Authorization: Bearer ${apiKey}`, ], { timeout: 10_000 }).toString() const status = JSON.parse(out)?.status if (status === 'suspended' || status === 'terminated') return diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index 86d7a36..ee5ebfc 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import { TensorLakeClient } from './client.js' +import { LOGIN_HINT } from './credentials.js' import { logger } from './logger.js' import { toast } from './toast.js' import type { ProjectSessionData } from './types.js' @@ -31,8 +32,8 @@ export class TensorLakeSessionManager { public readonly workDir: string private readonly storageDir: string - constructor(apiKey: string, storageDir: string, workDir: string) { - this.client = new TensorLakeClient(apiKey) + constructor(resolveKey: () => string | undefined, storageDir: string, workDir: string) { + this.client = new TensorLakeClient(resolveKey) this.storageDir = storageDir this.workDir = workDir } @@ -195,8 +196,9 @@ export class TensorLakeSessionManager { if (pluginCtx?.client?.tui) toast.initialize(pluginCtx.client.tui) if (!this.client.hasApiKey()) { - const msg = 'TENSORLAKE_API_KEY is not set. Please set the environment variable.' - toast.show({ title: 'Sandbox error', message: msg, variant: 'error' }) + const msg = `No TensorLake credentials found. ${LOGIN_HINT}` + logger.error(`Tool call blocked for session ${sessionId}: ${msg}`) + toast.show({ title: 'TensorLake login required', message: msg, variant: 'error' }) throw new Error(msg) } diff --git a/.opencode/plugin/tensorlake/index.ts b/.opencode/plugin/tensorlake/index.ts index 89a3fc8..fa5d15d 100644 --- a/.opencode/plugin/tensorlake/index.ts +++ b/.opencode/plugin/tensorlake/index.ts @@ -2,8 +2,10 @@ import { join } from 'path' import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath, logger } from './core/logger.js' +import { resolveApiKey } from './core/credentials.js' import { TensorLakeSessionManager } from './core/session-manager.js' import { toast } from './core/toast.js' +import { authHook } from './plugins/auth.js' import { customTools } from './plugins/custom-tools.js' import { eventHandlers } from './plugins/session-events.js' import { systemPromptTransform } from './plugins/system-transform.js' @@ -13,11 +15,7 @@ const STORAGE_DIR = join(xdgData ?? '/tmp', 'opencode', 'storage', 'tensorlake') const WORK_DIR = '/tmp/workspace' setLogFilePath(LOG_FILE) -const sessionManager = new TensorLakeSessionManager( - process.env.TENSORLAKE_API_KEY ?? '', - STORAGE_DIR, - WORK_DIR, -) +const sessionManager = new TensorLakeSessionManager(resolveApiKey, STORAGE_DIR, WORK_DIR) function suspendAndExit(signal: string) { logger.info(`Received ${signal}, suspending sandboxes before exit`) @@ -37,6 +35,7 @@ async function tensorlakePlugin(ctx: PluginInput) { const worktree = ctx.project?.worktree ?? ctx.worktree ?? '' const projectDir = sessionManager.projectDir(worktree) return { + auth: authHook, tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager), 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR, projectDir), diff --git a/README.md b/README.md index f280b5d..05d0ec2 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Sync failures are surfaced as a toast and logged, but never block the sandbox ## Prerequisites - An OpenCode installation (see [opencode.ai](https://opencode.ai)) -- A TensorLake account and API key (sign up at [tensorlake.ai](https://tensorlake.ai)) +- A TensorLake account and a **project API key** (sign up at [tensorlake.ai](https://tensorlake.ai), then create a key at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) under your project → API Keys) --- @@ -99,12 +99,26 @@ You can list multiple plugins in the same array: } ``` -### 2. Set your API key +### 2. Log in + +The plugin registers TensorLake as a provider in OpenCode's standard auth flow. Run: + +```bash +opencode auth login +``` + +Select **TensorLake** from the provider list and paste a **project API key** (starts with `tl_apiKey_`). Create one at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) — open your project → **API Keys**. Project keys carry their own organization/project scope, so nothing else is needed. + +The key is validated against the TensorLake API before it is saved, and stored in OpenCode's credential store (`~/.local/share/opencode/auth.json`) alongside your other provider credentials. No environment variables, no shell profile edits. + +**CI / automation alternative:** set the `TENSORLAKE_API_KEY` environment variable instead. When both are present, the environment variable wins: ```bash -export TENSORLAKE_API_KEY=your_api_key_here +export TENSORLAKE_API_KEY=tl_apiKey_... ``` +Personal Access Tokens are supported only via the env-var path, and additionally require `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. Prefer project API keys. + ### Local development install OpenCode can also run plugins as TypeScript source files directly via its embedded Bun runtime. Use this option when you are modifying this repository locally. @@ -134,9 +148,9 @@ For local paths: | Variable | Default | Description | |---|---|---| -| `TENSORLAKE_API_KEY` | (required) | Your TensorLake API key | -| `TENSORLAKE_ORGANIZATION_ID` | (required for PAT keys) | Organization ID (e.g. `org_…`). Required when using a Personal Access Token; not needed for project-scoped keys. | -| `TENSORLAKE_PROJECT_ID` | (required for PAT keys) | Project ID (e.g. `project_…`). Required when using a Personal Access Token; not needed for project-scoped keys. | +| `TENSORLAKE_API_KEY` | (optional) | Overrides the key stored by `opencode auth login`. Use for CI/automation; interactive users should prefer `opencode auth login`. | +| `TENSORLAKE_ORGANIZATION_ID` | (required for PAT keys) | Organization ID (e.g. `org_…`). Only needed when `TENSORLAKE_API_KEY` is a Personal Access Token; never needed for project API keys. | +| `TENSORLAKE_PROJECT_ID` | (required for PAT keys) | Project ID (e.g. `project_…`). Only needed when `TENSORLAKE_API_KEY` is a Personal Access Token; never needed for project API keys. | | `TENSORLAKE_API_URL` | `https://api.tensorlake.ai` | Override the management API base URL | | `TENSORLAKE_IMAGE` | (server default) | Container image to use when creating a new sandbox. Leave unset to use the platform default. | | `TENSORLAKE_CPUS` | `2` | Number of vCPUs allocated to the sandbox | @@ -149,12 +163,18 @@ For local paths: ## How to Test +### Auth flow test + +1. Make sure `TENSORLAKE_API_KEY` is **not** exported and no TensorLake entry exists in `~/.local/share/opencode/auth.json`. +2. Start OpenCode and ask the model to run a command. The tool call should fail with a **"TensorLake login required"** toast telling you to run `opencode auth login`. +3. Run `opencode auth login`, select **TensorLake**, and paste a project API key. A wrong-prefix key is rejected at the prompt; a revoked or non-project key is rejected during validation. +4. Retry the tool call in the same OpenCode session — no restart needed. The sandbox should now be created. + ### Basic smoke test -1. Set the environment variable and start OpenCode in a project directory: +1. Log in once (`opencode auth login` → TensorLake → paste a project API key), then start OpenCode in a project directory: ```bash - export TENSORLAKE_API_KEY=your_key opencode ``` @@ -229,7 +249,8 @@ This is expected. The plugin does **not** create a sandbox at launch — a sandb To start one, ask the model to run a command (e.g. `Run: uname -a`). If you've made a tool call and still see no sandbox, check `~/.local/share/opencode/log/tensorlake.log`: - **File is missing entirely** → the plugin never loaded. See [Plugin not loading](#plugin-not-loading). -- **File exists but logs an auth error (401/403)** → verify `TENSORLAKE_API_KEY` is exported in the same environment OpenCode runs in (`echo $TENSORLAKE_API_KEY`). For PAT keys, also set `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. +- **"TensorLake login required" toast / `No TensorLake credentials found` in the log** → run `opencode auth login`, select TensorLake, and paste a project API key. No restart is needed — the plugin picks up the new credential within seconds; just retry the tool call. If you intended to use an env var instead, verify it's exported in the same environment OpenCode runs in (`echo $TENSORLAKE_API_KEY`). +- **File exists but logs an auth error (401/403)** → the stored key was deleted or revoked. Re-run `opencode auth login` with a fresh project API key. For env-var PAT keys, also set `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. - **File exists and logs `Creating new sandbox` but it hangs or errors** → the API call to create the sandbox failed; check the error message in the log. > Note: OpenCode installs the npm package into its own cache (`~/.cache/opencode/packages/`), not your project or global `node_modules`. You don't need to `npm install` the plugin yourself — listing it in `opencode.json` is enough. A manual `npm install` elsewhere has no effect on what OpenCode loads. From ebf08f68ff2c855f8c20c3541d62ec9be60f8d10 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 21:43:55 -0500 Subject: [PATCH 08/15] Add credentials resolver and auth hook missed from previous commit Co-Authored-By: Claude Fable 5 --- .../plugin/tensorlake/core/credentials.ts | 92 +++++++++++++++++++ .opencode/plugin/tensorlake/plugins/auth.ts | 47 ++++++++++ 2 files changed, 139 insertions(+) create mode 100644 .opencode/plugin/tensorlake/core/credentials.ts create mode 100644 .opencode/plugin/tensorlake/plugins/auth.ts diff --git a/.opencode/plugin/tensorlake/core/credentials.ts b/.opencode/plugin/tensorlake/core/credentials.ts new file mode 100644 index 0000000..159c248 --- /dev/null +++ b/.opencode/plugin/tensorlake/core/credentials.ts @@ -0,0 +1,92 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { xdgData } from 'xdg-basedir' +import { CloudClient } from 'tensorlake' +import { logger } from './logger.js' + +/** Provider id shown in `opencode auth login` and used as the auth.json key. */ +export const PROVIDER_ID = 'tensorlake' + +/** Project-scoped API keys carry their own org/project scope — no extra IDs needed. */ +export const PROJECT_KEY_PREFIX = 'tl_apiKey_' + +export const LOGIN_HINT = + 'Run `opencode auth login`, select TensorLake, and paste a project API key from https://cloud.tensorlake.ai — or set TENSORLAKE_API_KEY.' + +// OpenCode's credential store, written by `opencode auth login`. +const AUTH_FILE = join(xdgData ?? '/tmp', 'opencode', 'auth.json') + +// auth.json is re-read at most every STORE_TTL_MS so a login/logout done in +// another terminal is picked up without restarting OpenCode. +const STORE_TTL_MS = 15_000 +let storeCache: { key: string | undefined; readAt: number } | null = null + +function readStoredApiKey(): string | undefined { + const now = Date.now() + if (storeCache && now - storeCache.readAt < STORE_TTL_MS) return storeCache.key + let key: string | undefined + try { + if (existsSync(AUTH_FILE)) { + const store = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as Record + const entry = store[PROVIDER_ID] as { type?: string; key?: string } | undefined + if (entry?.type === 'api' && typeof entry.key === 'string' && entry.key.length > 0) { + key = entry.key + } + } + } catch (err) { + logger.warn(`Failed to read OpenCode auth store ${AUTH_FILE}: ${err}`) + } + storeCache = { key, readAt: now } + return key +} + +/** + * Credential resolution order: TENSORLAKE_API_KEY env var (CI/automation + * override) first, then the key stored by `opencode auth login`. + */ +export function resolveApiKey(): string | undefined { + const envKey = process.env.TENSORLAKE_API_KEY + if (envKey && envKey.length > 0) return envKey + return readStoredApiKey() +} + +function pickId(value: unknown, keys: string[]): string | undefined { + if (value == null || typeof value !== 'object') return undefined + const obj = value as Record + for (const key of keys) { + const candidate = obj[key] + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return undefined +} + +export type KeyValidation = { ok: true } | { ok: false; reason: string } + +/** + * Validate a key against the management API before it is stored. Project + * API keys introspect to a project id; anything without one (PATs, org-level + * tokens) is rejected so logged-in users never need to supply + * TENSORLAKE_ORGANIZATION_ID / TENSORLAKE_PROJECT_ID separately. + */ +export async function validateProjectApiKey(apiKey: string): Promise { + const apiUrl = process.env.TENSORLAKE_API_URL + const client = CloudClient.forCloud({ apiKey, ...(apiUrl ? { apiUrl } : {}) }) + try { + const intro = (await client.introspectApiKey()) as Record + const projectId = + pickId(intro, ['projectId', 'project_id']) ?? + pickId(intro.project, ['id', 'projectId', 'project_id']) + if (!projectId) { + return { + ok: false, + reason: + 'Key is valid but not project-scoped. Create a project API key at https://cloud.tensorlake.ai (Project → API Keys).', + } + } + return { ok: true } + } catch (err: any) { + return { ok: false, reason: `Key validation failed: ${err?.message ?? err}` } + } finally { + client.close() + } +} diff --git a/.opencode/plugin/tensorlake/plugins/auth.ts b/.opencode/plugin/tensorlake/plugins/auth.ts new file mode 100644 index 0000000..68f66b1 --- /dev/null +++ b/.opencode/plugin/tensorlake/plugins/auth.ts @@ -0,0 +1,47 @@ +import type { AuthHook } from '@opencode-ai/plugin' +import { PROVIDER_ID, PROJECT_KEY_PREFIX, validateProjectApiKey } from '../core/credentials.js' +import { logger } from '../core/logger.js' + +/** + * Registers TensorLake as a provider in `opencode auth login`. The entered key + * is validated against the management API (and required to be project-scoped) + * before OpenCode stores it in its own credential store (auth.json). The + * plugin never persists credentials itself. + */ +export const authHook: AuthHook = { + provider: PROVIDER_ID, + methods: [ + { + type: 'api', + label: 'Project API key', + prompts: [ + { + type: 'text', + key: 'key', + message: + 'Paste a project API key from https://cloud.tensorlake.ai (open your project → API Keys)', + placeholder: `${PROJECT_KEY_PREFIX}...`, + validate: (value: string) => { + const key = value.trim() + if (!key) return 'API key is required' + if (!key.startsWith(PROJECT_KEY_PREFIX)) { + return `Project API keys start with ${PROJECT_KEY_PREFIX} — create one in your project's API Keys page` + } + return undefined + }, + }, + ], + async authorize(inputs) { + const key = (inputs?.key ?? '').trim() + if (!key) return { type: 'failed' } + const result = await validateProjectApiKey(key) + if (!result.ok) { + logger.error(`TensorLake login rejected: ${result.reason}`) + return { type: 'failed' } + } + logger.info('TensorLake project API key validated and stored via opencode auth login') + return { type: 'success', key } + }, + }, + ], +} From f902b5bdf16d3d4f2b5a3909a5eb930b94b26906 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Tue, 18 Aug 2026 23:47:30 -0500 Subject: [PATCH 09/15] Sync real git history into the sandbox instead of worktree snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git-mode sync now pushes the repo's actual refs (git push HEAD:main) to the TensorLake-hosted mirror, so the sandbox clone has real commits, authors, and dates — git log/blame/diff work as expected and branches created in the sandbox share ancestry with the original repo. - Uncommitted local changes (modified + untracked) are captured with a temporary index and replayed onto the sandbox working tree uncommitted, but only when the sandbox tree is clean and at the same HEAD — agent work is never overwritten. - Non-fast-forward pushes (local rebase/amend) recreate the disposable mirror and push fresh, with a warning that mirror-only commits are lost. - Credentials go in an HTTP header (redacted from errors), never the URL. - Repos with no commits fall back to the previous pushWorktree snapshot. - Sandbox gets a fallback git identity so agents can commit. Co-Authored-By: Claude Fable 5 --- .../plugin/tensorlake/core/project-sync.ts | 177 ++++++++++++++++-- README.md | 21 ++- 2 files changed, 183 insertions(+), 15 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index fa1184a..4d90801 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -1,6 +1,9 @@ -import { existsSync, readdirSync, lstatSync } from 'fs' +import { existsSync, readdirSync, lstatSync, unlinkSync } from 'fs' import { join, basename } from 'path' import { posix } from 'path' +import { tmpdir } from 'os' +import { execFile } from 'child_process' +import { promisify } from 'util' import { RepositoryClient, FilesystemClient, CloudClient } from 'tensorlake' import type { FileSystemMount } from 'tensorlake' import { logger } from './logger.js' @@ -28,6 +31,11 @@ const SKIP_DIRS = new Set([ // Files larger than this are skipped in volume mode. const MAX_FILE_BYTES = 100 * 1024 * 1024 +// Uncommitted-changes patches larger than this are not synced into the sandbox. +const MAX_PATCH_BYTES = 50 * 1024 * 1024 + +const execFileAsync = promisify(execFile) + function apiUrl(): string | undefined { return process.env.TENSORLAKE_API_URL } @@ -116,9 +124,137 @@ function syncResourceName(projectId: string): string { return sanitizeName(`opencode-${projectId}`) } +/** Run git in the local worktree; rejects with stderr attached on failure. */ +async function localGit(worktree: string, args: string[], env?: Record): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd: worktree, + maxBuffer: MAX_PATCH_BYTES + 1024 * 1024, + timeout: 300_000, + ...(env ? { env: { ...process.env, ...env } } : {}), + }) + return stdout +} + +// Credentials go in an HTTP header instead of the remote URL so the token +// never appears in git's error output or the process list. +function gitAuthConfig(cred: { gitUsername: string; token: string }): string { + const basic = Buffer.from(`${cred.gitUsername}:${cred.token}`).toString('base64') + return `http.extraHeader=Authorization: Basic ${basic}` +} + +function redactAuth(text: string): string { + return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') +} + +/** + * Push the local repository's real history (HEAD) to the hosted mirror's main + * branch, preserving commits, authors, and dates. The mirror is disposable: + * when it rejects a non-fast-forward push (local history was rewritten by a + * rebase or amend), it is deleted, recreated, and pushed fresh. + */ +async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string): Promise { + const push = async () => { + const cred = await repos.credential(repo) + await localGit(worktree, ['-c', gitAuthConfig(cred), 'push', '--quiet', repos.url(repo), 'HEAD:refs/heads/main']) + } + try { + await push() + } catch (err: any) { + const detail = redactAuth(`${err?.stderr ?? ''} ${err?.message ?? err}`) + if (!/non-fast-forward|\[rejected\]|failed to push some refs|fetch first/i.test(detail)) { + throw new Error(`git push to hosted mirror ${repo} failed: ${detail}`) + } + logger.warn( + `Hosted mirror ${repo} rejected a non-fast-forward push (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, + ) + await repos.delete(repo) + await repos.create(repo, { defaultBranch: 'main' }) + try { + await push() + } catch (err2: any) { + throw new Error( + `git push after recreating mirror ${repo} failed: ${redactAuth(`${err2?.stderr ?? ''} ${err2?.message ?? err2}`)}`, + ) + } + } +} + /** - * Push the local worktree to a TensorLake-hosted git repository, then - * clone (or fast-forward) it inside the sandbox at `destDir`. + * Diff of everything not yet committed locally (modified, staged, untracked, + * deleted), built against a temporary index so the real index is untouched. + * Returns null when the working tree is clean or the patch is oversized. + */ +async function buildUncommittedPatch(worktree: string): Promise { + const tmpIndex = join(tmpdir(), `tensorlake-sync-index-${process.pid}-${Date.now()}`) + const env = { GIT_INDEX_FILE: tmpIndex } + try { + await localGit(worktree, ['read-tree', 'HEAD'], env) + await localGit(worktree, ['add', '-A'], env) + const patch = await localGit(worktree, ['diff', '--cached', '--binary', 'HEAD'], env) + if (!patch.trim()) return null + const bytes = Buffer.byteLength(patch) + if (bytes > MAX_PATCH_BYTES) { + logger.warn(`Uncommitted changes are ${bytes} bytes (> ${MAX_PATCH_BYTES}); not syncing them into the sandbox`) + return null + } + return patch + } finally { + try { + unlinkSync(tmpIndex) + } catch { + // temp index may not exist if an early git call failed + } + } +} + +/** + * Replicate uncommitted local changes into the sandbox working tree without + * committing them, so the sandbox mirrors the laptop's exact state. Applied + * only when the sandbox tree is clean and at the same HEAD as the laptop — + * agent work in the sandbox is never overwritten. + */ +async function applyUncommittedChanges( + client: TensorLakeClient, + sandboxId: string, + worktree: string, + destDir: string, + localHead: string, +): Promise { + const patch = await buildUncommittedPatch(worktree) + if (!patch) return + const state = await client.executeCommand(sandboxId, `cd '${destDir}' && git rev-parse HEAD && git status --porcelain`, '/') + if (state.exitCode !== 0) { + logger.warn(`Could not inspect sandbox clone before applying uncommitted changes: ${state.stderr || state.stdout}`) + return + } + const [sandboxHead, ...statusLines] = state.stdout.trim().split('\n') + const dirty = statusLines.some((line) => line.trim() !== '') + if (sandboxHead !== localHead || dirty) { + logger.info( + `Skipping uncommitted-changes sync: sandbox tree ${dirty ? 'has its own modifications' : `is at ${sandboxHead?.slice(0, 8)}, not ${localHead.slice(0, 8)}`}`, + ) + return + } + const patchPath = '/tmp/.tensorlake-sync.patch' + await client.writeFile(sandboxId, patchPath, Buffer.from(patch)) + const apply = await client.executeCommand( + sandboxId, + `cd '${destDir}' && git apply --whitespace=nowarn '${patchPath}'; code=$?; rm -f '${patchPath}'; exit $code`, + '/', + ) + if (apply.exitCode !== 0) { + logger.warn(`Failed to apply uncommitted local changes in sandbox: ${apply.stderr || apply.stdout}`) + } else { + logger.info(`Applied uncommitted local changes (${Buffer.byteLength(patch)} bytes) to ${destDir}`) + } +} + +/** + * Sync the local git repository into the sandbox with full commit history: + * push real refs to a TensorLake-hosted mirror, then clone (or fast-forward) + * the mirror inside the sandbox at `destDir`, and finally replay uncommitted + * local changes onto the sandbox working tree. A repo with no commits yet + * falls back to a single snapshot commit via pushWorktree. */ export async function syncGitProject( client: TensorLakeClient, @@ -138,13 +274,24 @@ export async function syncGitProject( await repos.create(repo, { defaultBranch: 'main' }) } - logger.info(`Pushing worktree ${worktree} to hosted repo ${repo}`) - const report = await repos.pushWorktree(repo, { - path: worktree, - branch: 'main', - message: 'Sync from OpenCode', - }) - logger.info(`pushWorktree done: ${JSON.stringify(report)}`) + let localHead: string | null = null + try { + localHead = (await localGit(worktree, ['rev-parse', 'HEAD'])).trim() + } catch { + // no commits yet, or no usable local git — use the snapshot path + } + + if (localHead) { + logger.info(`Pushing real history (HEAD ${localHead.slice(0, 8)}) from ${worktree} to hosted mirror ${repo}`) + await pushRealHistory(repos, repo, worktree) + } else { + logger.info(`Local repository has no commits; pushing worktree snapshot to ${repo}`) + await repos.pushWorktree(repo, { + path: worktree, + branch: 'main', + message: 'Sync from OpenCode', + }) + } const cred = await repos.credential(repo) const url = repos.url(repo) @@ -152,11 +299,14 @@ export async function syncGitProject( const credLine = `${parsed.protocol}//${encodeURIComponent(cred.gitUsername)}:${encodeURIComponent(cred.token)}@${parsed.host}` // Re-syncs only fast-forward: a sandbox clone with its own commits or - // uncommitted changes must never be clobbered by a hard reset. + // uncommitted changes must never be clobbered by a hard reset. The git + // identity lets agents commit their work inside the sandbox. const script = [ 'set -e', 'git config --global credential.helper store', `printf '%s\\n' '${credLine}' > ~/.git-credentials`, + `git config --global user.name >/dev/null 2>&1 || git config --global user.name 'OpenCode Agent'`, + `git config --global user.email >/dev/null 2>&1 || git config --global user.email 'opencode-agent@tensorlake.ai'`, `if [ -d '${destDir}/.git' ]; then`, ` cd '${destDir}' && git fetch origin main`, ' if ! git merge --ff-only origin/main; then', @@ -173,10 +323,13 @@ export async function syncGitProject( } if (result.stdout.includes('TENSORLAKE_SYNC_DIVERGED')) { logger.warn( - `Sandbox clone at ${destDir} has local commits or changes that diverge from the pushed project; left untouched instead of resetting`, + `Sandbox clone at ${destDir} has commits or changes that diverge from the pushed project; left untouched instead of resetting. Delete the session's sandbox to start from a fresh clone.`, ) } else { logger.info(`Project cloned into sandbox at ${destDir}`) + if (localHead) { + await applyUncommittedChanges(client, sandboxId, worktree, destDir, localHead) + } } } finally { repos.close() diff --git a/README.md b/README.md index 05d0ec2..3aa78a5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re > **No sandbox is created when you start OpenCode.** The sandbox is provisioned lazily on the model's first tool call in a session. If you launch OpenCode and nothing seems to happen, that's expected — ask the model to run a command to spin one up. - **Sandbox lifecycle** - A sandbox is created lazily on the **first intercepted tool call** in a session (not at launch) and deleted when the session is deleted. Sandbox state is persisted to disk so that reconnection is possible across OpenCode restarts. -- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed to a TensorLake-hosted git repo and cloned inside the sandbox; non-git folders are uploaded to a TensorLake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). +- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed — with full commit history — to a TensorLake-hosted git mirror and cloned inside the sandbox; non-git folders are uploaded to a TensorLake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). - **Background processes** - `bash` with `background=true` starts a real long-running process in the sandbox (dev server, watcher, etc.) and returns its pid. Two extra tools, `bash_output` and `bash_kill`, let the model read new output and stop the process. - **Suspension/resume** - If a sandbox is found in a suspended state it is automatically resumed before use. - **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox and where the project lives. @@ -40,7 +40,7 @@ The first time a sandbox is used (per OpenCode process), the plugin syncs your l | Local project | Sync mode | How it works | |---|---|---| -| Git repository (has `.git`) | `git` | The worktree is pushed to a TensorLake-hosted git repository (`opencode-`) via `pushWorktree` — no local git invocation, `.gitignore` respected — then cloned inside the sandbox. Git credentials are configured in the sandbox so the model can `git push` to persist changes back to the hosted repo. | +| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed (`git push HEAD:main`) to a TensorLake-hosted mirror (`opencode-`), which is then cloned inside the sandbox — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror. A repo with no commits yet is synced as a single snapshot commit instead. | | Plain folder | `volume` | The folder is uploaded to a TensorLake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. @@ -55,7 +55,22 @@ export TENSORLAKE_SYNC_MODE=off # disable project sync (pre-0.2.0 behavior) Sync failures are surfaced as a toast and logged, but never block the sandbox — you just get an empty workspace. -> Sync runs once per sandbox per OpenCode process. Restarting OpenCode re-syncs, picking up local changes (git mode fast-forwards the clone; volume mode uploads only changed content). +> Sync runs once per sandbox per OpenCode process. Restarting OpenCode re-syncs, picking up local changes (git mode fast-forwards the clone; volume mode uploads only changed content). A sandbox clone that has its own commits or edits is never reset — re-sync only fast-forwards, and uncommitted local changes are only replayed onto a clean sandbox tree. + +### Getting agent commits back to your machine (git mode) + +The sandbox clone's `origin` is the TensorLake-hosted mirror, so when the agent commits and runs `git push`, the work lands on the mirror. Fetch it locally: + +```bash +# one-time: add the mirror as a remote — username is always `t`, +# get the repo URL and a short-lived token with the TensorLake CLI: +tl git token opencode- +git remote add tensorlake https://t:@ +git fetch tensorlake +git merge tensorlake/main # or cherry-pick / diff as you prefer +``` + +> If you rewrite local history (rebase, amend), the next sync recreates the mirror from your rewritten history — any commits that existed only on the mirror are discarded, so fetch agent work before rebasing. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. --- From 4db280a04686106f236ca90aa64fb1ad617bb183 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Wed, 19 Aug 2026 00:23:58 -0500 Subject: [PATCH 10/15] Fix sync-scope staleness and harden git/volume sync - Key the cached org/project scope by API key so a no-restart auth login with a key from another project no longer reuses the stale scope - Distinguish a mirror that is ahead (agent commits) from rewritten local history before recreating the hosted mirror on non-fast-forward pushes - Track uploaded paths in a volume sync manifest so local deletions propagate without touching agent-created files - Use POSIX joins for guest sandbox paths on Windows hosts Co-Authored-By: Claude Fable 5 --- .../plugin/tensorlake/core/project-sync.ts | 124 ++++++++++++++++-- .../plugin/tensorlake/core/session-manager.ts | 5 +- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index 4d90801..1a60995 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -34,6 +34,11 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024 // Uncommitted-changes patches larger than this are not synced into the sandbox. const MAX_PATCH_BYTES = 50 * 1024 * 1024 +// Volume-root record of the paths uploaded by the last sync. Deleting a file +// locally must delete it remotely too, but only for paths this plugin +// uploaded — files created by agents inside the mounted volume are theirs. +const SYNC_MANIFEST_PATH = '.tensorlake-sync-manifest.json' + const execFileAsync = promisify(execFile) function apiUrl(): string | undefined { @@ -42,7 +47,9 @@ function apiUrl(): string | undefined { type Scope = { organizationId: string; projectId: string } -let cachedScope: Scope | null = null +// Cached per key: the stored key can change without a restart (auth login in +// another terminal), and a new key may belong to a different project. +let cachedScope: { apiKey: string; scope: Scope } | null = null function pickId(value: unknown, keys: string[]): string | undefined { if (value == null || typeof value !== 'object') return undefined @@ -63,7 +70,7 @@ async function resolveScope(apiKey: string): Promise { const envOrg = process.env.TENSORLAKE_ORGANIZATION_ID const envProj = process.env.TENSORLAKE_PROJECT_ID if (envOrg && envProj) return { organizationId: envOrg, projectId: envProj } - if (cachedScope) return cachedScope + if (cachedScope && cachedScope.apiKey === apiKey) return cachedScope.scope const client = CloudClient.forCloud({ apiKey, ...(apiUrl() ? { apiUrl: apiUrl() } : {}) }) try { @@ -81,8 +88,8 @@ async function resolveScope(apiKey: string): Promise { 'Could not resolve organization/project from the API key. Set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.', ) } - cachedScope = { organizationId, projectId } - return cachedScope + cachedScope = { apiKey, scope: { organizationId, projectId } } + return cachedScope.scope } finally { client.close() } @@ -146,11 +153,30 @@ function redactAuth(text: string): string { return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') } +/** + * True when the mirror's main branch already contains local HEAD — i.e. the + * mirror is simply ahead (an agent pushed commits the laptop hasn't fetched + * yet), as opposed to local history having been rewritten. Fetches the + * mirror's main into FETCH_HEAD to make the ancestry check possible. + */ +async function mirrorContainsLocalHead(repos: RepositoryClient, repo: string, worktree: string): Promise { + const cred = await repos.credential(repo) + await localGit(worktree, ['-c', gitAuthConfig(cred), 'fetch', '--quiet', repos.url(repo), 'main']) + try { + await localGit(worktree, ['merge-base', '--is-ancestor', 'HEAD', 'FETCH_HEAD']) + return true + } catch { + return false + } +} + /** * Push the local repository's real history (HEAD) to the hosted mirror's main - * branch, preserving commits, authors, and dates. The mirror is disposable: - * when it rejects a non-fast-forward push (local history was rewritten by a - * rebase or amend), it is deleted, recreated, and pushed fresh. + * branch, preserving commits, authors, and dates. A non-fast-forward rejection + * has two causes that must be told apart: the mirror being ahead of the laptop + * (agents commit and push their work to it), in which case the push is simply + * skipped, or local history having been rewritten by a rebase or amend, in + * which case the mirror is deleted, recreated, and pushed fresh. */ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string): Promise { const push = async () => { @@ -164,8 +190,24 @@ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: if (!/non-fast-forward|\[rejected\]|failed to push some refs|fetch first/i.test(detail)) { throw new Error(`git push to hosted mirror ${repo} failed: ${detail}`) } + let remoteAhead: boolean + try { + remoteAhead = await mirrorContainsLocalHead(repos, repo, worktree) + } catch (checkErr: any) { + // Indeterminate state: never recreate the mirror without proof of a + // rewrite, or agent commits that exist only on the mirror would be lost. + throw new Error( + `Hosted mirror ${repo} rejected a non-fast-forward push and its state could not be inspected; not recreating it to avoid discarding remote-only commits: ${redactAuth(`${checkErr?.stderr ?? ''} ${checkErr?.message ?? checkErr}`)}`, + ) + } + if (remoteAhead) { + logger.info( + `Hosted mirror ${repo} is ahead of local HEAD (agent commits not yet fetched locally); skipping push — local history is already on the mirror.`, + ) + return + } logger.warn( - `Hosted mirror ${repo} rejected a non-fast-forward push (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, + `Hosted mirror ${repo} rejected a non-fast-forward push and its main branch does not contain local HEAD (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, ) await repos.delete(repo) await repos.create(repo, { defaultBranch: 'main' }) @@ -390,14 +432,55 @@ export async function ensureVolumeWithProject( const files = collectFiles(worktree) const count = Object.keys(files).length + const stale = await staleRemotePaths(fs, files) logger.info(`Uploading ${count} files from ${worktree} to volume ${name}`) if (count > 0) { await fs.writeFilesFromPaths(files, 'Sync from OpenCode') } + // Record what this sync uploaded and drop previously uploaded paths that no + // longer exist locally, so local deletions propagate to later sandboxes. + // Non-fatal: the uploads above already landed, and the manifest self-heals + // on the next successful sync. + try { + if (stale.length > 0) logger.info(`Removing ${stale.length} locally deleted files from volume ${name}`) + const manifest = JSON.stringify({ paths: Object.keys(files).sort() }) + await fs.writeFiles({ [SYNC_MANIFEST_PATH]: manifest }, 'Sync from OpenCode (manifest)', stale) + } catch (err) { + logger.warn(`Failed to update sync manifest / remove deleted files on volume ${name}: ${err}`) + } + return { fileSystemId: name, mountPath } } +/** + * Previously uploaded paths that have since been deleted locally. Computed + * from the volume's sync manifest so files created by agents inside the + * mounted volume (which were never uploaded from the laptop) are never + * touched. Paths now under SKIP_DIRS are also left alone — the sandbox may + * own them (e.g. an agent-built dist/). + */ +async function staleRemotePaths( + fs: { readText(path: string): Promise }, + files: Record, +): Promise { + let previous: string[] + try { + const parsed = JSON.parse(await fs.readText(SYNC_MANIFEST_PATH)) as { paths?: unknown } + if (!Array.isArray(parsed.paths)) return [] + previous = parsed.paths.filter((p): p is string => typeof p === 'string') + } catch { + // first sync, or a missing/corrupt manifest — never guess at deletions + return [] + } + return previous.filter( + (path) => + path !== SYNC_MANIFEST_PATH && + !(path in files) && + !path.split('/').some((segment) => SKIP_DIRS.has(segment)), + ) +} + /** Attach the project volume to an already-running sandbox if not mounted. */ export async function ensureVolumeMounted( client: TensorLakeClient, @@ -408,4 +491,29 @@ export async function ensureVolumeMounted( if (mounts.some((m) => m.mountPath === mount.mountPath)) return logger.info(`Attaching volume ${mount.fileSystemId} to sandbox ${sandboxId} at ${mount.mountPath}`) await client.attachFileSystem(sandboxId, mount.fileSystemId, mount.mountPath) + // attachFileSystem resolves when the control plane records the mount; the + // guest may see the path slightly later. Callers mark the sandbox synced and + // run tools with mountPath as cwd immediately after, so wait for it here. + await waitForGuestPath(client, sandboxId, mount.mountPath) +} + +async function waitForGuestPath( + client: TensorLakeClient, + sandboxId: string, + path: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + try { + const check = await client.executeCommand(sandboxId, `test -d '${path}'`, '/') + if (check.exitCode === 0) return + } catch (err) { + logger.warn(`Mount readiness check failed: ${err}`) + } + if (Date.now() >= deadline) { + throw new Error(`Volume mount at ${path} did not become visible in the sandbox within ${timeoutMs}ms`) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } } diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index ee5ebfc..7a55ad7 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { join } from 'path' +import { join, posix } from 'path' import { TensorLakeClient } from './client.js' import { LOGIN_HINT } from './credentials.js' import { logger } from './logger.js' @@ -45,7 +45,8 @@ export class TensorLakeSessionManager { /** Directory inside the sandbox where the local project is synced. */ projectDir(worktree: string): string { if (resolveSyncMode(worktree) === 'off') return this.workDir - return join(this.workDir, projectDirName(worktree)) + // Guest (Linux sandbox) path — must stay POSIX even on a Windows host + return posix.join(this.workDir, projectDirName(worktree)) } /** From c829b4d99e37e287b32bd2fa0d6d60969543ad26 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Wed, 19 Aug 2026 00:46:54 -0500 Subject: [PATCH 11/15] Fix code-review findings in volume/git sync hardening - Deletion propagation: track every path still present locally (oversized, symlink, unreadable included) so files skipped from an upload are never deleted from the volume; drop the prototype-chain-prone `in` check. - Move the sync manifest off the volume onto the laptop's storage dir so sandbox agents can't see, commit, or tamper with the file driving remote deletions; validate delete paths and scrub the legacy on-volume manifest. - Mirror ancestry check: treat only merge-base exit 1 as "not an ancestor" (other errors route to the indeterminate path instead of recreating the mirror), and fetch into a throwaway ref with --no-write-fetch-head so the user's FETCH_HEAD is never clobbered. - Mount readiness: always wait for the guest even when the control plane already lists the mount, probe for a real mountpoint instead of `test -d` (the sync-failure fallback mkdirs a decoy at that path), and bound each probe to 5s so the 30s deadline is honest. - Project-dir fast path requires a non-empty directory so the empty decoy left by a failed sync doesn't count as a project copy. Co-Authored-By: Claude Fable 5 --- .../plugin/tensorlake/core/project-sync.ts | 184 +++++++++++++----- .../plugin/tensorlake/core/session-manager.ts | 11 +- 2 files changed, 140 insertions(+), 55 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index 1a60995..36c5144 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, lstatSync, unlinkSync } from 'fs' -import { join, basename } from 'path' +import { existsSync, readdirSync, lstatSync, unlinkSync, readFileSync, writeFileSync, mkdirSync } from 'fs' +import { join, basename, dirname } from 'path' import { posix } from 'path' import { tmpdir } from 'os' import { execFile } from 'child_process' @@ -34,10 +34,11 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024 // Uncommitted-changes patches larger than this are not synced into the sandbox. const MAX_PATCH_BYTES = 50 * 1024 * 1024 -// Volume-root record of the paths uploaded by the last sync. Deleting a file -// locally must delete it remotely too, but only for paths this plugin -// uploaded — files created by agents inside the mounted volume are theirs. -const SYNC_MANIFEST_PATH = '.tensorlake-sync-manifest.json' +// Older versions wrote the sync manifest into the volume root, i.e. into the +// tree mounted as the agent's project dir. That let sandbox code rewrite the +// file that drives laptop-side deletions, so the manifest now lives on the +// laptop (see syncManifestPath) and this legacy on-volume copy is scrubbed. +const LEGACY_VOLUME_MANIFEST_PATH = '.tensorlake-sync-manifest.json' const execFileAsync = promisify(execFile) @@ -153,20 +154,40 @@ function redactAuth(text: string): string { return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') } +// Throwaway ref the mirror's main is fetched into for the ancestry check, so +// the user's FETCH_HEAD (which their own fetch/merge workflows may be reading) +// is never touched. +const MIRROR_CHECK_REF = 'refs/tensorlake/mirror-check' + /** * True when the mirror's main branch already contains local HEAD — i.e. the * mirror is simply ahead (an agent pushed commits the laptop hasn't fetched * yet), as opposed to local history having been rewritten. Fetches the - * mirror's main into FETCH_HEAD to make the ancestry check possible. + * mirror's main into a temporary ref to make the ancestry check possible. */ async function mirrorContainsLocalHead(repos: RepositoryClient, repo: string, worktree: string): Promise { const cred = await repos.credential(repo) - await localGit(worktree, ['-c', gitAuthConfig(cred), 'fetch', '--quiet', repos.url(repo), 'main']) + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + repos.url(repo), + `+main:${MIRROR_CHECK_REF}`, + ]) try { - await localGit(worktree, ['merge-base', '--is-ancestor', 'HEAD', 'FETCH_HEAD']) + await localGit(worktree, ['merge-base', '--is-ancestor', 'HEAD', MIRROR_CHECK_REF]) return true - } catch { - return false + } catch (err: any) { + // Exit code 1 is merge-base's defined "not an ancestor" answer. Anything + // else (128, a timeout) means the check itself failed, and must propagate + // so the caller treats the mirror state as indeterminate instead of + // recreating the mirror and discarding its commits. + if (err?.code === 1) return false + throw err + } finally { + await localGit(worktree, ['update-ref', '-d', MIRROR_CHECK_REF]).catch(() => {}) } } @@ -378,24 +399,37 @@ export async function syncGitProject( } } -/** Recursively collect files to upload: remote path -> absolute local path. */ -function collectFiles(worktree: string): Record { +/** + * Recursively collect files to upload (remote path -> absolute local path) + * plus every path that still exists locally, uploaded or not. Deletion + * propagation must key off `present`, not `files`: a path can be skipped from + * the upload (oversized, symlink, unreadable) while very much still existing. + */ +function collectFiles(worktree: string): { files: Record; present: Set } { const files: Record = {} + const present = new Set() const walk = (dir: string, prefix: string) => { for (const entry of readdirSync(dir)) { if (SKIP_DIRS.has(entry)) continue const localPath = join(dir, entry) + const remotePath = prefix ? posix.join(prefix, entry) : entry let st try { st = lstatSync(localPath) } catch { + // Unreadable is not deleted — keep it out of the upload but never let + // its absence from `files` be read as a local deletion. + present.add(remotePath) + continue + } + if (st.isSymbolicLink()) { + present.add(remotePath) continue } - if (st.isSymbolicLink()) continue - const remotePath = prefix ? posix.join(prefix, entry) : entry if (st.isDirectory()) { walk(localPath, remotePath) } else if (st.isFile()) { + present.add(remotePath) if (st.size > MAX_FILE_BYTES) { logger.warn(`Skipping ${localPath} (${st.size} bytes > ${MAX_FILE_BYTES})`) continue @@ -405,19 +439,21 @@ function collectFiles(worktree: string): Record { } } walk(worktree, '') - return files + return { files, present } } /** * Ensure the project's cloud volume exists and holds the current worktree * content. Returns the mount spec for the sandbox. Sandbox mounts address - * volumes by their filesystem name. + * volumes by their filesystem name. `manifestDir` is a laptop-local directory + * where the record of uploaded paths is kept between syncs. */ export async function ensureVolumeWithProject( apiKey: string, worktree: string, projectId: string, mountPath: string, + manifestDir: string, ): Promise { const name = syncResourceName(projectId) const options = await cloudOptions(apiKey) @@ -430,53 +466,87 @@ export async function ensureVolumeWithProject( fs = await fsClient.create(name) } - const files = collectFiles(worktree) + const { files, present } = collectFiles(worktree) const count = Object.keys(files).length - const stale = await staleRemotePaths(fs, files) + const manifestPath = syncManifestPath(manifestDir, name) + const stale = staleRemotePaths(readSyncManifest(manifestPath), present) logger.info(`Uploading ${count} files from ${worktree} to volume ${name}`) if (count > 0) { await fs.writeFilesFromPaths(files, 'Sync from OpenCode') } - // Record what this sync uploaded and drop previously uploaded paths that no - // longer exist locally, so local deletions propagate to later sandboxes. - // Non-fatal: the uploads above already landed, and the manifest self-heals - // on the next successful sync. + // Drop previously uploaded paths that no longer exist locally, so local + // deletions propagate to later sandboxes. Non-fatal: the uploads above + // already landed, and paths that fail to delete stay in the manifest so the + // deletion is retried on the next sync. + let undeleted: string[] = [] + if (stale.length > 0) { + try { + logger.info(`Removing ${stale.length} locally deleted files from volume ${name}`) + await fs.writeFiles({}, 'Sync from OpenCode (remove deleted files)', stale) + } catch (err) { + logger.warn(`Failed to remove deleted files from volume ${name}: ${err}`) + undeleted = stale + } + } + writeSyncManifest(manifestPath, [...Object.keys(files), ...undeleted]) + + // Scrub the manifest older versions wrote into the volume root, where every + // sandbox agent could see and commit it. try { - if (stale.length > 0) logger.info(`Removing ${stale.length} locally deleted files from volume ${name}`) - const manifest = JSON.stringify({ paths: Object.keys(files).sort() }) - await fs.writeFiles({ [SYNC_MANIFEST_PATH]: manifest }, 'Sync from OpenCode (manifest)', stale) - } catch (err) { - logger.warn(`Failed to update sync manifest / remove deleted files on volume ${name}: ${err}`) + await fs.deleteFile(LEGACY_VOLUME_MANIFEST_PATH, 'Remove legacy sync manifest') + } catch { + // already gone — the common case } return { fileSystemId: name, mountPath } } -/** - * Previously uploaded paths that have since been deleted locally. Computed - * from the volume's sync manifest so files created by agents inside the - * mounted volume (which were never uploaded from the laptop) are never - * touched. Paths now under SKIP_DIRS are also left alone — the sandbox may - * own them (e.g. an agent-built dist/). - */ -async function staleRemotePaths( - fs: { readText(path: string): Promise }, - files: Record, -): Promise { - let previous: string[] +/** Laptop-local record of the paths the last sync uploaded to a volume. */ +function syncManifestPath(manifestDir: string, volumeName: string): string { + return join(manifestDir, `${volumeName}.sync-manifest.json`) +} + +function readSyncManifest(manifestPath: string): string[] { try { - const parsed = JSON.parse(await fs.readText(SYNC_MANIFEST_PATH)) as { paths?: unknown } + const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { paths?: unknown } if (!Array.isArray(parsed.paths)) return [] - previous = parsed.paths.filter((p): p is string => typeof p === 'string') + return parsed.paths.filter((p): p is string => typeof p === 'string') } catch { // first sync, or a missing/corrupt manifest — never guess at deletions return [] } +} + +function writeSyncManifest(manifestPath: string, paths: string[]): void { + try { + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ paths: [...new Set(paths)].sort() })) + } catch (err) { + logger.warn(`Failed to write sync manifest ${manifestPath}: ${err}`) + } +} + +// Deletion paths are sent to the volume API verbatim, so accept only plain +// relative paths — no absolute paths, no `.`/`..` segments, no backslashes. +function isSafeRelativePath(path: string): boolean { + if (!path || path.startsWith('/') || path.includes('\\')) return false + return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') +} + +/** + * Previously uploaded paths that have since been deleted locally. Computed + * against everything still present in the worktree (not just the uploaded + * set), so files skipped from an upload are never treated as deleted, and + * only from the manifest of past uploads, so files created by agents inside + * the mounted volume are never touched. Paths now under SKIP_DIRS are also + * left alone — the sandbox may own them (e.g. an agent-built dist/). + */ +function staleRemotePaths(previous: string[], present: Set): string[] { return previous.filter( (path) => - path !== SYNC_MANIFEST_PATH && - !(path in files) && + isSafeRelativePath(path) && + !present.has(path) && !path.split('/').some((segment) => SKIP_DIRS.has(segment)), ) } @@ -488,25 +558,33 @@ export async function ensureVolumeMounted( sandboxId: string, ): Promise { const mounts = await client.listSandboxFileSystems(sandboxId) - if (mounts.some((m) => m.mountPath === mount.mountPath)) return - logger.info(`Attaching volume ${mount.fileSystemId} to sandbox ${sandboxId} at ${mount.mountPath}`) - await client.attachFileSystem(sandboxId, mount.fileSystemId, mount.mountPath) - // attachFileSystem resolves when the control plane records the mount; the - // guest may see the path slightly later. Callers mark the sandbox synced and - // run tools with mountPath as cwd immediately after, so wait for it here. - await waitForGuestPath(client, sandboxId, mount.mountPath) + if (!mounts.some((m) => m.mountPath === mount.mountPath)) { + logger.info(`Attaching volume ${mount.fileSystemId} to sandbox ${sandboxId} at ${mount.mountPath}`) + await client.attachFileSystem(sandboxId, mount.fileSystemId, mount.mountPath) + } + // Both attachFileSystem and the control plane's mount listing reflect + // control-plane state; the guest materializes the mount asynchronously on + // the dataplane. Callers mark the sandbox synced and run tools with + // mountPath as cwd immediately after, so always wait for the guest to see a + // real mount — including when the control plane already listed it. + await waitForGuestMount(client, sandboxId, mount.mountPath) } -async function waitForGuestPath( +async function waitForGuestMount( client: TensorLakeClient, sandboxId: string, path: string, timeoutMs = 30_000, + probeTimeoutMs = 5_000, ): Promise { + // `test -d` is not enough here: the sync-failure fallback mkdirs a plain + // directory at this exact path, which would satisfy it while the volume is + // not mounted at all. Require the path to be an actual mountpoint. + const probe = `mountpoint -q '${path}' 2>/dev/null || awk -v p='${path}' '$2 == p { found = 1 } END { exit !found }' /proc/mounts` const deadline = Date.now() + timeoutMs for (;;) { try { - const check = await client.executeCommand(sandboxId, `test -d '${path}'`, '/') + const check = await client.executeCommand(sandboxId, probe, '/', probeTimeoutMs) if (check.exitCode === 0) return } catch (err) { logger.warn(`Mount readiness check failed: ${err}`) diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index 7a55ad7..8fc1243 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -71,7 +71,7 @@ export class TensorLakeSessionManager { await syncGitProject(this.client, this.client.getApiKey(), sandboxId, worktree, projectId, destDir) } else { toast.show({ title: 'Syncing project', message: `Uploading ${worktree} to a cloud volume...`, variant: 'info' }) - const mount = await ensureVolumeWithProject(this.client.getApiKey(), worktree, projectId, destDir) + const mount = await ensureVolumeWithProject(this.client.getApiKey(), worktree, projectId, destDir, this.storageDir) await ensureVolumeMounted(this.client, mount, sandboxId) } this.synced.add(sandboxId) @@ -118,7 +118,14 @@ export class TensorLakeSessionManager { if (this.hasProjectDir.has(sandboxId)) return const destDir = this.projectDir(worktree) try { - const check = await this.client.executeCommand(sandboxId, `test -d '${destDir}'`, '/') + // Must be non-empty: the sync-failure fallback below leaves an empty + // decoy directory at destDir that must not count as a project copy. + const check = await this.client.executeCommand( + sandboxId, + `[ -d '${destDir}' ] && [ -n "$(ls -A '${destDir}' 2>/dev/null)" ]`, + '/', + 15_000, + ) if (check.exitCode === 0) { this.hasProjectDir.add(sandboxId) return From 8c79026a8eb1e6d4a83ab9461cd7e6086d2e3751 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Wed, 19 Aug 2026 09:47:17 -0500 Subject: [PATCH 12/15] Normalize brand capitalization to "Tensorlake" Rename TensorLakeClient, TensorLakeSessionManager, and createTensorLakeTools along with all prose, tool descriptions, log messages, and README references. Co-Authored-By: Claude Opus 5 (1M context) --- .opencode/plugin/tensorlake/core/client.ts | 2 +- .../plugin/tensorlake/core/credentials.ts | 2 +- .../plugin/tensorlake/core/project-sync.ts | 12 ++--- .../plugin/tensorlake/core/session-manager.ts | 18 ++++---- .opencode/plugin/tensorlake/index.ts | 4 +- .opencode/plugin/tensorlake/plugins/auth.ts | 6 +-- .../plugin/tensorlake/plugins/custom-tools.ts | 10 ++-- .../tensorlake/plugins/session-events.ts | 4 +- .../tensorlake/plugins/system-transform.ts | 4 +- .opencode/plugin/tensorlake/tools.ts | 6 +-- .opencode/plugin/tensorlake/tools/bash.ts | 10 ++-- .opencode/plugin/tensorlake/tools/edit.ts | 6 +-- .opencode/plugin/tensorlake/tools/glob.ts | 6 +-- .opencode/plugin/tensorlake/tools/grep.ts | 6 +-- .opencode/plugin/tensorlake/tools/ls.ts | 6 +-- .opencode/plugin/tensorlake/tools/read.ts | 6 +-- .opencode/plugin/tensorlake/tools/write.ts | 6 +-- README.md | 46 +++++++++---------- package.json | 2 +- 19 files changed, 81 insertions(+), 81 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index e649e13..3dd94fb 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -35,7 +35,7 @@ export type ProcessStatusInfo = { command: string } -export class TensorLakeClient { +export class TensorlakeClient { // Connected handles keyed by sandboxId, so repeated operations reuse the // resolved proxy routing instead of re-resolving on every call. private readonly handles = new Map() diff --git a/.opencode/plugin/tensorlake/core/credentials.ts b/.opencode/plugin/tensorlake/core/credentials.ts index 159c248..6705f3c 100644 --- a/.opencode/plugin/tensorlake/core/credentials.ts +++ b/.opencode/plugin/tensorlake/core/credentials.ts @@ -11,7 +11,7 @@ export const PROVIDER_ID = 'tensorlake' export const PROJECT_KEY_PREFIX = 'tl_apiKey_' export const LOGIN_HINT = - 'Run `opencode auth login`, select TensorLake, and paste a project API key from https://cloud.tensorlake.ai — or set TENSORLAKE_API_KEY.' + 'Run `opencode auth login`, select Tensorlake, and paste a project API key from https://cloud.tensorlake.ai — or set TENSORLAKE_API_KEY.' // OpenCode's credential store, written by `opencode auth login`. const AUTH_FILE = join(xdgData ?? '/tmp', 'opencode', 'auth.json') diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index 36c5144..d1a4044 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -7,7 +7,7 @@ import { promisify } from 'util' import { RepositoryClient, FilesystemClient, CloudClient } from 'tensorlake' import type { FileSystemMount } from 'tensorlake' import { logger } from './logger.js' -import type { TensorLakeClient } from './client.js' +import type { TensorlakeClient } from './client.js' export type SyncMode = 'git' | 'volume' | 'off' @@ -277,7 +277,7 @@ async function buildUncommittedPatch(worktree: string): Promise { * agent work in the sandbox is never overwritten. */ async function applyUncommittedChanges( - client: TensorLakeClient, + client: TensorlakeClient, sandboxId: string, worktree: string, destDir: string, @@ -314,13 +314,13 @@ async function applyUncommittedChanges( /** * Sync the local git repository into the sandbox with full commit history: - * push real refs to a TensorLake-hosted mirror, then clone (or fast-forward) + * push real refs to a Tensorlake-hosted mirror, then clone (or fast-forward) * the mirror inside the sandbox at `destDir`, and finally replay uncommitted * local changes onto the sandbox working tree. A repo with no commits yet * falls back to a single snapshot commit via pushWorktree. */ export async function syncGitProject( - client: TensorLakeClient, + client: TensorlakeClient, apiKey: string, sandboxId: string, worktree: string, @@ -553,7 +553,7 @@ function staleRemotePaths(previous: string[], present: Set): string[] { /** Attach the project volume to an already-running sandbox if not mounted. */ export async function ensureVolumeMounted( - client: TensorLakeClient, + client: TensorlakeClient, mount: FileSystemMount, sandboxId: string, ): Promise { @@ -571,7 +571,7 @@ export async function ensureVolumeMounted( } async function waitForGuestMount( - client: TensorLakeClient, + client: TensorlakeClient, sandboxId: string, path: string, timeoutMs = 30_000, diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index 8fc1243..e90c386 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join, posix } from 'path' -import { TensorLakeClient } from './client.js' +import { TensorlakeClient } from './client.js' import { LOGIN_HINT } from './credentials.js' import { logger } from './logger.js' import { toast } from './toast.js' @@ -14,8 +14,8 @@ import { ensureVolumeMounted, } from './project-sync.js' -export class TensorLakeSessionManager { - private readonly client: TensorLakeClient +export class TensorlakeSessionManager { + private readonly client: TensorlakeClient // In-memory cache: sessionId -> { sandboxId } private readonly cache = new Map() // In-flight getSandbox promises keyed by sessionId — prevents concurrent double-resume @@ -33,12 +33,12 @@ export class TensorLakeSessionManager { private readonly storageDir: string constructor(resolveKey: () => string | undefined, storageDir: string, workDir: string) { - this.client = new TensorLakeClient(resolveKey) + this.client = new TensorlakeClient(resolveKey) this.storageDir = storageDir this.workDir = workDir } - getClient(): TensorLakeClient { + getClient(): TensorlakeClient { return this.client } @@ -51,7 +51,7 @@ export class TensorLakeSessionManager { /** * Sync the local project into the sandbox. Git repos are pushed to a - * TensorLake-hosted repo and cloned inside the sandbox; plain folders are + * Tensorlake-hosted repo and cloned inside the sandbox; plain folders are * pushed to a cloud volume mounted into the sandbox. Failures are logged * and surfaced but never block the sandbox. */ @@ -63,7 +63,7 @@ export class TensorLakeSessionManager { return } const failedAt = this.syncFailedAt.get(sandboxId) - if (failedAt !== undefined && Date.now() - failedAt < TensorLakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return + if (failedAt !== undefined && Date.now() - failedAt < TensorlakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return const destDir = this.projectDir(worktree) try { if (mode === 'git') { @@ -204,9 +204,9 @@ export class TensorLakeSessionManager { if (pluginCtx?.client?.tui) toast.initialize(pluginCtx.client.tui) if (!this.client.hasApiKey()) { - const msg = `No TensorLake credentials found. ${LOGIN_HINT}` + const msg = `No Tensorlake credentials found. ${LOGIN_HINT}` logger.error(`Tool call blocked for session ${sessionId}: ${msg}`) - toast.show({ title: 'TensorLake login required', message: msg, variant: 'error' }) + toast.show({ title: 'Tensorlake login required', message: msg, variant: 'error' }) throw new Error(msg) } diff --git a/.opencode/plugin/tensorlake/index.ts b/.opencode/plugin/tensorlake/index.ts index fa5d15d..06f752a 100644 --- a/.opencode/plugin/tensorlake/index.ts +++ b/.opencode/plugin/tensorlake/index.ts @@ -3,7 +3,7 @@ import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath, logger } from './core/logger.js' import { resolveApiKey } from './core/credentials.js' -import { TensorLakeSessionManager } from './core/session-manager.js' +import { TensorlakeSessionManager } from './core/session-manager.js' import { toast } from './core/toast.js' import { authHook } from './plugins/auth.js' import { customTools } from './plugins/custom-tools.js' @@ -15,7 +15,7 @@ const STORAGE_DIR = join(xdgData ?? '/tmp', 'opencode', 'storage', 'tensorlake') const WORK_DIR = '/tmp/workspace' setLogFilePath(LOG_FILE) -const sessionManager = new TensorLakeSessionManager(resolveApiKey, STORAGE_DIR, WORK_DIR) +const sessionManager = new TensorlakeSessionManager(resolveApiKey, STORAGE_DIR, WORK_DIR) function suspendAndExit(signal: string) { logger.info(`Received ${signal}, suspending sandboxes before exit`) diff --git a/.opencode/plugin/tensorlake/plugins/auth.ts b/.opencode/plugin/tensorlake/plugins/auth.ts index 68f66b1..070801a 100644 --- a/.opencode/plugin/tensorlake/plugins/auth.ts +++ b/.opencode/plugin/tensorlake/plugins/auth.ts @@ -3,7 +3,7 @@ import { PROVIDER_ID, PROJECT_KEY_PREFIX, validateProjectApiKey } from '../core/ import { logger } from '../core/logger.js' /** - * Registers TensorLake as a provider in `opencode auth login`. The entered key + * Registers Tensorlake as a provider in `opencode auth login`. The entered key * is validated against the management API (and required to be project-scoped) * before OpenCode stores it in its own credential store (auth.json). The * plugin never persists credentials itself. @@ -36,10 +36,10 @@ export const authHook: AuthHook = { if (!key) return { type: 'failed' } const result = await validateProjectApiKey(key) if (!result.ok) { - logger.error(`TensorLake login rejected: ${result.reason}`) + logger.error(`Tensorlake login rejected: ${result.reason}`) return { type: 'failed' } } - logger.info('TensorLake project API key validated and stored via opencode auth login') + logger.info('Tensorlake project API key validated and stored via opencode auth login') return { type: 'success', key } }, }, diff --git a/.opencode/plugin/tensorlake/plugins/custom-tools.ts b/.opencode/plugin/tensorlake/plugins/custom-tools.ts index 891d611..c5e44ee 100644 --- a/.opencode/plugin/tensorlake/plugins/custom-tools.ts +++ b/.opencode/plugin/tensorlake/plugins/custom-tools.ts @@ -1,9 +1,9 @@ import type { PluginInput } from '@opencode-ai/plugin' -import { createTensorLakeTools } from '../tools.js' +import { createTensorlakeTools } from '../tools.js' import { logger } from '../core/logger.js' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' -export async function customTools(ctx: PluginInput, sessionManager: TensorLakeSessionManager) { - logger.info('OpenCode started with TensorLake plugin') - return createTensorLakeTools(sessionManager, ctx.project.id, ctx.project.worktree, ctx) +export async function customTools(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { + logger.info('OpenCode started with Tensorlake plugin') + return createTensorlakeTools(sessionManager, ctx.project.id, ctx.project.worktree, ctx) } diff --git a/.opencode/plugin/tensorlake/plugins/session-events.ts b/.opencode/plugin/tensorlake/plugins/session-events.ts index fdaf6d5..beda6a6 100644 --- a/.opencode/plugin/tensorlake/plugins/session-events.ts +++ b/.opencode/plugin/tensorlake/plugins/session-events.ts @@ -2,9 +2,9 @@ import type { PluginInput } from '@opencode-ai/plugin' import { EVENT_TYPE_SESSION_DELETED, EVENT_TYPE_SERVER_INSTANCE_DISPOSED, type EventSessionDeleted } from '../core/types.js' import { toast } from '../core/toast.js' import { logger } from '../core/logger.js' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' -export async function eventHandlers(ctx: PluginInput, sessionManager: TensorLakeSessionManager) { +export async function eventHandlers(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { const projectId = ctx.project.id return async (args: any) => { const event = args.event diff --git a/.opencode/plugin/tensorlake/plugins/system-transform.ts b/.opencode/plugin/tensorlake/plugins/system-transform.ts index f5fbf95..c5ebc51 100644 --- a/.opencode/plugin/tensorlake/plugins/system-transform.ts +++ b/.opencode/plugin/tensorlake/plugins/system-transform.ts @@ -7,8 +7,8 @@ export async function systemPromptTransform(ctx: PluginInput, workDir: string, p const mode = resolveSyncMode(worktree) return async (_input: ExperimentalChatSystemTransformInput, output: ExperimentalChatSystemTransformOutput) => { const lines = [ - '## TensorLake Sandbox Integration', - 'This session is running inside a TensorLake sandbox.', + '## Tensorlake Sandbox Integration', + 'This session is running inside a Tensorlake sandbox.', 'All bash commands, file reads/writes, and searches run inside the sandbox.', 'Do NOT use paths from the host system.', "For long-running commands (servers, watchers), use bash with background=true; check on them with bash_output and stop them with bash_kill.", diff --git a/.opencode/plugin/tensorlake/tools.ts b/.opencode/plugin/tensorlake/tools.ts index 9427e97..0530af1 100644 --- a/.opencode/plugin/tensorlake/tools.ts +++ b/.opencode/plugin/tensorlake/tools.ts @@ -1,4 +1,4 @@ -import type { TensorLakeSessionManager } from './core/session-manager.js' +import type { TensorlakeSessionManager } from './core/session-manager.js' import type { PluginInput } from '@opencode-ai/plugin' import { bashTool, bashOutputTool, bashKillTool } from './tools/bash.js' import { readTool } from './tools/read.js' @@ -8,8 +8,8 @@ import { lsTool } from './tools/ls.js' import { globTool } from './tools/glob.js' import { grepTool } from './tools/grep.js' -export function createTensorLakeTools( - sessionManager: TensorLakeSessionManager, +export function createTensorlakeTools( + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, diff --git a/.opencode/plugin/tensorlake/tools/bash.ts b/.opencode/plugin/tensorlake/tools/bash.ts index c905b5d..6ed1d57 100644 --- a/.opencode/plugin/tensorlake/tools/bash.ts +++ b/.opencode/plugin/tensorlake/tools/bash.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' // Lines already returned per background process (keyed by sandboxId:pid), // so bash_output only shows output produced since the previous call. @@ -23,13 +23,13 @@ function describeStatus(pid: number, status: string, exitCode?: number, signal?: } export const bashTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ description: - 'Executes shell commands in a TensorLake sandbox. Set background=true for long-running commands (servers, watchers); it returns a pid to use with bash_output and bash_kill.', + 'Executes shell commands in a Tensorlake sandbox. Set background=true for long-running commands (servers, watchers); it returns a pid to use with bash_output and bash_kill.', args: { command: z.string(), background: z.boolean().optional(), @@ -52,7 +52,7 @@ export const bashTool = ( }) export const bashOutputTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, @@ -91,7 +91,7 @@ export const bashOutputTool = ( }) export const bashKillTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, diff --git a/.opencode/plugin/tensorlake/tools/edit.ts b/.opencode/plugin/tensorlake/tools/edit.ts index 27b5b6e..b961e9e 100644 --- a/.opencode/plugin/tensorlake/tools/edit.ts +++ b/.opencode/plugin/tensorlake/tools/edit.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const editTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Replaces a string in a file in the TensorLake sandbox', + description: 'Replaces a string in a file in the Tensorlake sandbox', args: { filePath: z.string(), oldString: z.string(), diff --git a/.opencode/plugin/tensorlake/tools/glob.ts b/.opencode/plugin/tensorlake/tools/glob.ts index 1772394..febf16a 100644 --- a/.opencode/plugin/tensorlake/tools/glob.ts +++ b/.opencode/plugin/tensorlake/tools/glob.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const globTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Finds files matching a glob pattern in the TensorLake sandbox', + description: 'Finds files matching a glob pattern in the Tensorlake sandbox', args: { pattern: z.string(), path: z.string().optional(), diff --git a/.opencode/plugin/tensorlake/tools/grep.ts b/.opencode/plugin/tensorlake/tools/grep.ts index 58bfd77..5453a6f 100644 --- a/.opencode/plugin/tensorlake/tools/grep.ts +++ b/.opencode/plugin/tensorlake/tools/grep.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const grepTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Searches for a text pattern in files in the TensorLake sandbox', + description: 'Searches for a text pattern in files in the Tensorlake sandbox', args: { pattern: z.string(), path: z.string().optional(), diff --git a/.opencode/plugin/tensorlake/tools/ls.ts b/.opencode/plugin/tensorlake/tools/ls.ts index b431476..986307d 100644 --- a/.opencode/plugin/tensorlake/tools/ls.ts +++ b/.opencode/plugin/tensorlake/tools/ls.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const lsTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Lists files in a directory in the TensorLake sandbox', + description: 'Lists files in a directory in the Tensorlake sandbox', args: { dirPath: z.string().optional(), }, diff --git a/.opencode/plugin/tensorlake/tools/read.ts b/.opencode/plugin/tensorlake/tools/read.ts index b01a57c..b1d4d63 100644 --- a/.opencode/plugin/tensorlake/tools/read.ts +++ b/.opencode/plugin/tensorlake/tools/read.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const readTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Reads a file from the TensorLake sandbox', + description: 'Reads a file from the Tensorlake sandbox', args: { filePath: z.string(), }, diff --git a/.opencode/plugin/tensorlake/tools/write.ts b/.opencode/plugin/tensorlake/tools/write.ts index 9342874..2cfacc3 100644 --- a/.opencode/plugin/tensorlake/tools/write.ts +++ b/.opencode/plugin/tensorlake/tools/write.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const writeTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Writes content to a file in the TensorLake sandbox', + description: 'Writes content to a file in the Tensorlake sandbox', args: { filePath: z.string(), content: z.string(), diff --git a/README.md b/README.md index 3aa78a5..d120e7d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # tensorlake-opencode -An OpenCode plugin that runs all AI sessions inside isolated [TensorLake](https://tensorlake.ai) sandboxes. Every bash command, file read/write, and search is executed in the sandbox rather than on your local machine. +An OpenCode plugin that runs all AI sessions inside isolated [Tensorlake](https://tensorlake.ai) sandboxes. Every bash command, file read/write, and search is executed in the sandbox rather than on your local machine. --- ## Overview -When the plugin is active, OpenCode intercepts the standard tool calls (bash, read, write, edit, ls, glob, grep) and routes them to a TensorLake sandbox. +When the plugin is active, OpenCode intercepts the standard tool calls (bash, read, write, edit, ls, glob, grep) and routes them to a Tensorlake sandbox. > **No sandbox is created when you start OpenCode.** The sandbox is provisioned lazily on the model's first tool call in a session. If you launch OpenCode and nothing seems to happen, that's expected — ask the model to run a command to spin one up. - **Sandbox lifecycle** - A sandbox is created lazily on the **first intercepted tool call** in a session (not at launch) and deleted when the session is deleted. Sandbox state is persisted to disk so that reconnection is possible across OpenCode restarts. -- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed — with full commit history — to a TensorLake-hosted git mirror and cloned inside the sandbox; non-git folders are uploaded to a TensorLake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). +- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed — with full commit history — to a Tensorlake-hosted git mirror and cloned inside the sandbox; non-git folders are uploaded to a Tensorlake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). - **Background processes** - `bash` with `background=true` starts a real long-running process in the sandbox (dev server, watcher, etc.) and returns its pid. Two extra tools, `bash_output` and `bash_kill`, let the model read new output and stop the process. - **Suspension/resume** - If a sandbox is found in a suspended state it is automatically resumed before use. - **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox and where the project lives. @@ -40,8 +40,8 @@ The first time a sandbox is used (per OpenCode process), the plugin syncs your l | Local project | Sync mode | How it works | |---|---|---| -| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed (`git push HEAD:main`) to a TensorLake-hosted mirror (`opencode-`), which is then cloned inside the sandbox — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror. A repo with no commits yet is synced as a single snapshot commit instead. | -| Plain folder | `volume` | The folder is uploaded to a TensorLake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | +| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed (`git push HEAD:main`) to a Tensorlake-hosted mirror (`opencode-`), which is then cloned inside the sandbox — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror. A repo with no commits yet is synced as a single snapshot commit instead. | +| Plain folder | `volume` | The folder is uploaded to a Tensorlake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. @@ -59,11 +59,11 @@ Sync failures are surfaced as a toast and logged, but never block the sandbox ### Getting agent commits back to your machine (git mode) -The sandbox clone's `origin` is the TensorLake-hosted mirror, so when the agent commits and runs `git push`, the work lands on the mirror. Fetch it locally: +The sandbox clone's `origin` is the Tensorlake-hosted mirror, so when the agent commits and runs `git push`, the work lands on the mirror. Fetch it locally: ```bash # one-time: add the mirror as a remote — username is always `t`, -# get the repo URL and a short-lived token with the TensorLake CLI: +# get the repo URL and a short-lived token with the Tensorlake CLI: tl git token opencode- git remote add tensorlake https://t:@ git fetch tensorlake @@ -77,7 +77,7 @@ git merge tensorlake/main # or cherry-pick / diff as you prefer ## Prerequisites - An OpenCode installation (see [opencode.ai](https://opencode.ai)) -- A TensorLake account and a **project API key** (sign up at [tensorlake.ai](https://tensorlake.ai), then create a key at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) under your project → API Keys) +- A Tensorlake account and a **project API key** (sign up at [tensorlake.ai](https://tensorlake.ai), then create a key at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) under your project → API Keys) --- @@ -116,15 +116,15 @@ You can list multiple plugins in the same array: ### 2. Log in -The plugin registers TensorLake as a provider in OpenCode's standard auth flow. Run: +The plugin registers Tensorlake as a provider in OpenCode's standard auth flow. Run: ```bash opencode auth login ``` -Select **TensorLake** from the provider list and paste a **project API key** (starts with `tl_apiKey_`). Create one at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) — open your project → **API Keys**. Project keys carry their own organization/project scope, so nothing else is needed. +Select **Tensorlake** from the provider list and paste a **project API key** (starts with `tl_apiKey_`). Create one at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) — open your project → **API Keys**. Project keys carry their own organization/project scope, so nothing else is needed. -The key is validated against the TensorLake API before it is saved, and stored in OpenCode's credential store (`~/.local/share/opencode/auth.json`) alongside your other provider credentials. No environment variables, no shell profile edits. +The key is validated against the Tensorlake API before it is saved, and stored in OpenCode's credential store (`~/.local/share/opencode/auth.json`) alongside your other provider credentials. No environment variables, no shell profile edits. **CI / automation alternative:** set the `TENSORLAKE_API_KEY` environment variable instead. When both are present, the environment variable wins: @@ -180,14 +180,14 @@ For local paths: ### Auth flow test -1. Make sure `TENSORLAKE_API_KEY` is **not** exported and no TensorLake entry exists in `~/.local/share/opencode/auth.json`. -2. Start OpenCode and ask the model to run a command. The tool call should fail with a **"TensorLake login required"** toast telling you to run `opencode auth login`. -3. Run `opencode auth login`, select **TensorLake**, and paste a project API key. A wrong-prefix key is rejected at the prompt; a revoked or non-project key is rejected during validation. +1. Make sure `TENSORLAKE_API_KEY` is **not** exported and no Tensorlake entry exists in `~/.local/share/opencode/auth.json`. +2. Start OpenCode and ask the model to run a command. The tool call should fail with a **"Tensorlake login required"** toast telling you to run `opencode auth login`. +3. Run `opencode auth login`, select **Tensorlake**, and paste a project API key. A wrong-prefix key is rejected at the prompt; a revoked or non-project key is rejected during validation. 4. Retry the tool call in the same OpenCode session — no restart needed. The sandbox should now be created. ### Basic smoke test -1. Log in once (`opencode auth login` → TensorLake → paste a project API key), then start OpenCode in a project directory: +1. Log in once (`opencode auth login` → Tensorlake → paste a project API key), then start OpenCode in a project directory: ```bash opencode @@ -202,7 +202,7 @@ For local paths: On startup you should see a single line: ``` - [2024-01-15T10:00:01.000Z] [INFO] OpenCode started with TensorLake plugin + [2024-01-15T10:00:01.000Z] [INFO] OpenCode started with Tensorlake plugin ``` If this file doesn't exist after launch, the plugin never loaded — see [Plugin not loading](#plugin-not-loading). @@ -222,19 +222,19 @@ In the OpenCode chat prompt, type: Run: echo "hello from sandbox" && uname -a ``` -The model will call the `bash` tool. Because the plugin intercepts it, the command executes inside the TensorLake sandbox. You should see Linux kernel information from the sandbox VM rather than your local machine. +The model will call the `bash` tool. Because the plugin intercepts it, the command executes inside the Tensorlake sandbox. You should see Linux kernel information from the sandbox VM rather than your local machine. ### File read/write test ``` -Write the text "Hello TensorLake" to /tmp/workspace/test.txt, then read it back. +Write the text "Hello Tensorlake" to /tmp/workspace/test.txt, then read it back. ``` The model will: 1. Call `write` with `filePath=/tmp/workspace/test.txt` → routed to `sandbox.writeFile()` via SDK 2. Call `read` with `filePath=/tmp/workspace/test.txt` → routed to `sandbox.readFile()` via SDK -The response should echo back `Hello TensorLake`. +The response should echo back `Hello Tensorlake`. ### Directory listing test @@ -246,7 +246,7 @@ This triggers the `ls` tool, which calls `sandbox.listDirectory('/tmp/workspace' ### Verifying sandbox deletion -Delete the OpenCode session from the session list. The plugin handles the `session.deleted` event and calls `sdk.delete(sandboxId)` via the TensorLake SDK. Confirm in the log: +Delete the OpenCode session from the session list. The plugin handles the `session.deleted` event and calls `sdk.delete(sandboxId)` via the Tensorlake SDK. Confirm in the log: ``` […] [INFO] Deleting sandbox sandbox-xyz for session abc123 @@ -264,7 +264,7 @@ This is expected. The plugin does **not** create a sandbox at launch — a sandb To start one, ask the model to run a command (e.g. `Run: uname -a`). If you've made a tool call and still see no sandbox, check `~/.local/share/opencode/log/tensorlake.log`: - **File is missing entirely** → the plugin never loaded. See [Plugin not loading](#plugin-not-loading). -- **"TensorLake login required" toast / `No TensorLake credentials found` in the log** → run `opencode auth login`, select TensorLake, and paste a project API key. No restart is needed — the plugin picks up the new credential within seconds; just retry the tool call. If you intended to use an env var instead, verify it's exported in the same environment OpenCode runs in (`echo $TENSORLAKE_API_KEY`). +- **"Tensorlake login required" toast / `No Tensorlake credentials found` in the log** → run `opencode auth login`, select Tensorlake, and paste a project API key. No restart is needed — the plugin picks up the new credential within seconds; just retry the tool call. If you intended to use an env var instead, verify it's exported in the same environment OpenCode runs in (`echo $TENSORLAKE_API_KEY`). - **File exists but logs an auth error (401/403)** → the stored key was deleted or revoked. Re-run `opencode auth login` with a fresh project API key. For env-var PAT keys, also set `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. - **File exists and logs `Creating new sandbox` but it hangs or errors** → the API call to create the sandbox failed; check the error message in the log. @@ -317,7 +317,7 @@ opencode-tensorlake-plugin/ ├── index.ts # plugin factory ├── tools.ts # assembles all tools ├── core/ - │ ├── client.ts # TensorLake SDK client (SandboxClient wrapper) + │ ├── client.ts # Tensorlake SDK client (SandboxClient wrapper) │ ├── logger.ts # file-based logger with rotation │ ├── project-sync.ts # syncs the local project into the sandbox (git/volume) │ ├── session-manager.ts # sandbox lifecycle management @@ -361,7 +361,7 @@ export TENSORLAKE_MEMORY_MB=8192 export TENSORLAKE_DISK_MB=20480 ``` -Or edit the defaults directly in `TensorLakeClient.createSandbox` inside `.opencode/plugin/tensorlake/core/client.ts`: +Or edit the defaults directly in `TensorlakeClient.createSandbox` inside `.opencode/plugin/tensorlake/core/client.ts`: ```typescript const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2') diff --git a/package.json b/package.json index d0c80e9..e636489 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tensorlake-opencode", "version": "0.2.0", "license": "Apache-2.0", - "description": "OpenCode plugin that runs all sessions in TensorLake sandboxes for isolated execution environments", + "description": "OpenCode plugin that runs all sessions in Tensorlake sandboxes for isolated execution environments", "keywords": [ "tensorlake", "opencode", From 4b0abf469947a3014dc2ac7e8a1c6e7e7da857d0 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Fri, 21 Aug 2026 13:14:19 -0500 Subject: [PATCH 13/15] add round trip of project sync --- .../plugin/tensorlake/core/project-sync.ts | 203 +++++++++++++++--- .../plugin/tensorlake/core/session-manager.ts | 51 +++++ .../tensorlake/plugins/session-events.ts | 16 +- .../tensorlake/plugins/system-transform.ts | 9 +- README.md | 36 +++- 5 files changed, 269 insertions(+), 46 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index d1a4044..a6eb917 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -132,6 +132,37 @@ function syncResourceName(projectId: string): string { return sanitizeName(`opencode-${projectId}`) } +// Branch names that are safe to embed in the single-quoted sandbox sync +// script and in refspecs. Git allows more than this (e.g. quotes), but such +// names cannot be forwarded verbatim, so they sync as 'main' instead. +const SAFE_BRANCH_RE = /^[A-Za-z0-9._/-]+$/ + +/** + * The branch the project syncs under: the local checkout's current branch, + * kept under the same name on the mirror and in the sandbox clone, so agent + * pushes land exactly where the user expects. Falls back to 'main' for a + * detached HEAD or a name that cannot be embedded safely. + */ +export async function resolveSyncBranch(worktree: string): Promise { + let name: string + try { + // --show-current also answers on an unborn branch (fresh `git init`), + // where rev-parse HEAD has nothing to resolve. + name = (await localGit(worktree, ['branch', '--show-current'])).trim() + } catch { + return 'main' + } + if (!name) { + logger.warn('Local checkout is a detached HEAD; syncing as branch main') + return 'main' + } + if (name.startsWith('-') || !SAFE_BRANCH_RE.test(name)) { + logger.warn(`Local branch name ${JSON.stringify(name)} cannot be synced verbatim; syncing as branch main`) + return 'main' + } + return name +} + /** Run git in the local worktree; rejects with stderr attached on failure. */ async function localGit(worktree: string, args: string[], env?: Record): Promise { const { stdout } = await execFileAsync('git', args, { @@ -154,18 +185,37 @@ function redactAuth(text: string): string { return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') } -// Throwaway ref the mirror's main is fetched into for the ancestry check, so -// the user's FETCH_HEAD (which their own fetch/merge workflows may be reading) -// is never touched. +// Throwaway ref the mirror's sync branch is fetched into for the ancestry +// check, so the user's FETCH_HEAD (which their own fetch/merge workflows may +// be reading) is never touched. const MIRROR_CHECK_REF = 'refs/tensorlake/mirror-check' +/** True when `ancestor` is reachable from `descendant`. */ +async function isAncestor(worktree: string, ancestor: string, descendant: string): Promise { + try { + await localGit(worktree, ['merge-base', '--is-ancestor', ancestor, descendant]) + return true + } catch (err: any) { + // Exit code 1 is merge-base's defined "not an ancestor" answer. Anything + // else (128, a timeout) means the check itself failed, and must propagate + // so callers treat the state as indeterminate instead of acting on it. + if (err?.code === 1) return false + throw err + } +} + /** - * True when the mirror's main branch already contains local HEAD — i.e. the + * True when the mirror's sync branch already contains local HEAD — i.e. the * mirror is simply ahead (an agent pushed commits the laptop hasn't fetched * yet), as opposed to local history having been rewritten. Fetches the - * mirror's main into a temporary ref to make the ancestry check possible. + * mirror's branch into a temporary ref to make the ancestry check possible. */ -async function mirrorContainsLocalHead(repos: RepositoryClient, repo: string, worktree: string): Promise { +async function mirrorContainsLocalHead( + repos: RepositoryClient, + repo: string, + worktree: string, + branch: string, +): Promise { const cred = await repos.credential(repo) await localGit(worktree, [ '-c', @@ -174,35 +224,27 @@ async function mirrorContainsLocalHead(repos: RepositoryClient, repo: string, wo '--quiet', '--no-write-fetch-head', repos.url(repo), - `+main:${MIRROR_CHECK_REF}`, + `+refs/heads/${branch}:${MIRROR_CHECK_REF}`, ]) try { - await localGit(worktree, ['merge-base', '--is-ancestor', 'HEAD', MIRROR_CHECK_REF]) - return true - } catch (err: any) { - // Exit code 1 is merge-base's defined "not an ancestor" answer. Anything - // else (128, a timeout) means the check itself failed, and must propagate - // so the caller treats the mirror state as indeterminate instead of - // recreating the mirror and discarding its commits. - if (err?.code === 1) return false - throw err + return await isAncestor(worktree, 'HEAD', MIRROR_CHECK_REF) } finally { await localGit(worktree, ['update-ref', '-d', MIRROR_CHECK_REF]).catch(() => {}) } } /** - * Push the local repository's real history (HEAD) to the hosted mirror's main + * Push the local repository's real history (HEAD) to the mirror's sync * branch, preserving commits, authors, and dates. A non-fast-forward rejection * has two causes that must be told apart: the mirror being ahead of the laptop * (agents commit and push their work to it), in which case the push is simply * skipped, or local history having been rewritten by a rebase or amend, in * which case the mirror is deleted, recreated, and pushed fresh. */ -async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string): Promise { +async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string, branch: string): Promise { const push = async () => { const cred = await repos.credential(repo) - await localGit(worktree, ['-c', gitAuthConfig(cred), 'push', '--quiet', repos.url(repo), 'HEAD:refs/heads/main']) + await localGit(worktree, ['-c', gitAuthConfig(cred), 'push', '--quiet', repos.url(repo), `HEAD:refs/heads/${branch}`]) } try { await push() @@ -213,7 +255,7 @@ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: } let remoteAhead: boolean try { - remoteAhead = await mirrorContainsLocalHead(repos, repo, worktree) + remoteAhead = await mirrorContainsLocalHead(repos, repo, worktree, branch) } catch (checkErr: any) { // Indeterminate state: never recreate the mirror without proof of a // rewrite, or agent commits that exist only on the mirror would be lost. @@ -228,10 +270,10 @@ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: return } logger.warn( - `Hosted mirror ${repo} rejected a non-fast-forward push and its main branch does not contain local HEAD (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, + `Hosted mirror ${repo} rejected a non-fast-forward push and its ${branch} branch does not contain local HEAD (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, ) await repos.delete(repo) - await repos.create(repo, { defaultBranch: 'main' }) + await repos.create(repo, { defaultBranch: branch }) try { await push() } catch (err2: any) { @@ -330,11 +372,12 @@ export async function syncGitProject( const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) try { const repo = syncResourceName(projectId) + const branch = await resolveSyncBranch(worktree) try { await repos.info(repo) } catch { logger.info(`Creating hosted git repository ${repo}`) - await repos.create(repo, { defaultBranch: 'main' }) + await repos.create(repo, { defaultBranch: branch }) } let localHead: string | null = null @@ -345,13 +388,15 @@ export async function syncGitProject( } if (localHead) { - logger.info(`Pushing real history (HEAD ${localHead.slice(0, 8)}) from ${worktree} to hosted mirror ${repo}`) - await pushRealHistory(repos, repo, worktree) + logger.info( + `Pushing real history (HEAD ${localHead.slice(0, 8)}, branch ${branch}) from ${worktree} to hosted mirror ${repo}`, + ) + await pushRealHistory(repos, repo, worktree, branch) } else { - logger.info(`Local repository has no commits; pushing worktree snapshot to ${repo}`) + logger.info(`Local repository has no commits; pushing worktree snapshot to ${repo} (branch ${branch})`) await repos.pushWorktree(repo, { path: worktree, - branch: 'main', + branch, message: 'Sync from OpenCode', }) } @@ -362,7 +407,9 @@ export async function syncGitProject( const credLine = `${parsed.protocol}//${encodeURIComponent(cred.gitUsername)}:${encodeURIComponent(cred.token)}@${parsed.host}` // Re-syncs only fast-forward: a sandbox clone with its own commits or - // uncommitted changes must never be clobbered by a hard reset. The git + // uncommitted changes must never be clobbered by a hard reset. A clean + // clone left on another branch (the user switched branches locally) + // switches to the sync branch; a dirty one is left alone. The git // identity lets agents commit their work inside the sandbox. const script = [ 'set -e', @@ -371,12 +418,20 @@ export async function syncGitProject( `git config --global user.name >/dev/null 2>&1 || git config --global user.name 'OpenCode Agent'`, `git config --global user.email >/dev/null 2>&1 || git config --global user.email 'opencode-agent@tensorlake.ai'`, `if [ -d '${destDir}/.git' ]; then`, - ` cd '${destDir}' && git fetch origin main`, - ' if ! git merge --ff-only origin/main; then', + ` cd '${destDir}' && git fetch origin '${branch}'`, + ` current="$(git branch --show-current)"`, + ` if [ "$current" != '${branch}' ]; then`, + ' if [ -n "$(git status --porcelain)" ]; then', + ' echo TENSORLAKE_SYNC_DIVERGED', + ' else', + ` git checkout -q '${branch}' 2>/dev/null || git checkout -q -b '${branch}' 'origin/${branch}'`, + ` git merge --ff-only 'origin/${branch}' || echo TENSORLAKE_SYNC_DIVERGED`, + ' fi', + ` elif ! git merge --ff-only 'origin/${branch}'; then`, ' echo TENSORLAKE_SYNC_DIVERGED', ' fi', 'else', - ` rm -rf '${destDir}' && git clone --branch main '${url}' '${destDir}'`, + ` rm -rf '${destDir}' && git clone --branch '${branch}' '${url}' '${destDir}'`, 'fi', ].join('\n') @@ -399,6 +454,92 @@ export async function syncGitProject( } } +/** What a sync-back pass did, for user-facing reporting. */ +export type SyncBackResult = + | { kind: 'up-to-date' } + | { kind: 'fast-forwarded'; branch: string; commits: number; oid: string } + | { kind: 'staged'; branch: string; ref: string; commits: number; oid: string; reason: string } + +/** + * Pull agent commits from the hosted mirror back into the local repository. + * Fetches every mirror branch into refs/remotes/tensorlake/* (so nothing an + * agent pushed is ever stranded on the mirror), then fast-forwards the local + * sync branch when that is safe. Unsafe cases — a dirty worktree or diverged + * histories — leave the commits on the tensorlake/ tracking ref for + * the user to merge deliberately; the local checkout is never disturbed. + */ +export async function syncBackFromMirror( + apiKey: string, + worktree: string, + projectId: string, +): Promise { + const branch = await resolveSyncBranch(worktree) + const repo = syncResourceName(projectId) + const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) + try { + const cred = await repos.credential(repo) + // --prune drops tracking refs for branches deleted (or recreated) on the + // mirror, so a stale tensorlake/* ref can't masquerade as agent work. + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + '--prune', + repos.url(repo), + '+refs/heads/*:refs/remotes/tensorlake/*', + ]) + } finally { + repos.close() + } + + const trackingRef = `refs/remotes/tensorlake/${branch}` + const shortRef = `tensorlake/${branch}` + let mirrorOid: string + try { + mirrorOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', trackingRef])).trim() + } catch { + return { kind: 'up-to-date' } // branch not on the mirror yet + } + + let localOid: string + try { + localOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).trim() + } catch { + // No local branch to advance (e.g. a repo that had no commits at sync + // time). The fetch above already saved the agent's work locally. + return { kind: 'staged', branch, ref: shortRef, commits: 0, oid: mirrorOid, reason: `local branch ${branch} not found` } + } + + if (mirrorOid === localOid) return { kind: 'up-to-date' } + // Mirror behind the laptop: the next inbound sync's push handles that. + if (await isAncestor(worktree, mirrorOid, localOid)) return { kind: 'up-to-date' } + + const commits = + parseInt((await localGit(worktree, ['rev-list', '--count', `${localOid}..${mirrorOid}`])).trim(), 10) || 0 + if (!(await isAncestor(worktree, localOid, mirrorOid))) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: mirrorOid, reason: 'local and agent histories diverged' } + } + + const current = (await localGit(worktree, ['branch', '--show-current'])).trim() + if (current !== branch) { + // The branch exists but is not checked out (detached HEAD fallback): + // advance the ref directly — no worktree files are involved. The + // old-value argument makes it a compare-and-swap against races. + await localGit(worktree, ['update-ref', `refs/heads/${branch}`, mirrorOid, localOid]) + return { kind: 'fast-forwarded', branch, commits, oid: mirrorOid } + } + + const dirty = (await localGit(worktree, ['status', '--porcelain'])).trim() !== '' + if (dirty) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: mirrorOid, reason: 'local uncommitted changes' } + } + + await localGit(worktree, ['merge', '--ff-only', '--quiet', mirrorOid]) + return { kind: 'fast-forwarded', branch, commits, oid: mirrorOid } +} + /** * Recursively collect files to upload (remote path -> absolute local path) * plus every path that still exists locally, uploaded or not. Deletion diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index e90c386..5a61d49 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -10,6 +10,7 @@ import { resolveSyncMode, projectDirName, syncGitProject, + syncBackFromMirror, ensureVolumeWithProject, ensureVolumeMounted, } from './project-sync.js' @@ -29,6 +30,12 @@ export class TensorlakeSessionManager { // Last failed sync attempt per sandbox — retried after a cooldown instead of on every tool call private readonly syncFailedAt = new Map() private static readonly SYNC_RETRY_COOLDOWN_MS = 60_000 + // In-flight sync-back per worktree — session.idle can fire faster than a fetch completes + private readonly syncBackInflight = new Map>() + // Last failed sync-back — retried after a cooldown instead of on every idle + private syncBackFailedAt = 0 + // Mirror commit already reported as "staged, not merged" — suppresses repeat toasts + private lastStagedOid: string | null = null public readonly workDir: string private readonly storageDir: string @@ -136,6 +143,50 @@ export class TensorlakeSessionManager { await sync } + /** + * Pull agent commits from the hosted mirror back into the local checkout. + * Runs after each agent turn (session.idle) so a "commit and push" by the + * agent lands on the user's local branch within seconds; a turn where the + * agent pushed nothing costs one cheap fetch. Only sessions that actually + * have a sandbox in this process trigger it, and only in git mode. + */ + async syncBack(sessionId: string, projectId: string, worktree: string): Promise { + if (!this.cache.has(sessionId)) return + if (resolveSyncMode(worktree) !== 'git') return + if (Date.now() - this.syncBackFailedAt < TensorlakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return + const existing = this.syncBackInflight.get(worktree) + if (existing) return existing + const run = this._syncBack(worktree, projectId).finally(() => this.syncBackInflight.delete(worktree)) + this.syncBackInflight.set(worktree, run) + return run + } + + private async _syncBack(worktree: string, projectId: string): Promise { + try { + const result = await syncBackFromMirror(this.client.getApiKey(), worktree, projectId) + if (result.kind === 'fast-forwarded') { + this.lastStagedOid = null + logger.info(`Sync-back: fast-forwarded local ${result.branch} by ${result.commits} agent commit(s)`) + toast.show({ + title: 'Agent work pulled', + message: `${result.commits} commit(s) from the sandbox are now on your local '${result.branch}' branch.`, + variant: 'success', + }) + } else if (result.kind === 'staged' && result.oid !== this.lastStagedOid) { + this.lastStagedOid = result.oid + logger.info(`Sync-back: staged ${result.commits} agent commit(s) on ${result.ref} (${result.reason})`) + toast.show({ + title: 'Agent work fetched', + message: `${result.commits} commit(s) are on '${result.ref}' but not merged (${result.reason}). Merge when ready: git merge ${result.ref}`, + variant: 'warning', + }) + } + } catch (err: any) { + this.syncBackFailedAt = Date.now() + logger.warn(`Sync-back from mirror failed: ${err?.message ?? err}`) + } + } + private storagePath(projectId: string): string { return join(this.storageDir, `${projectId}.json`) } diff --git a/.opencode/plugin/tensorlake/plugins/session-events.ts b/.opencode/plugin/tensorlake/plugins/session-events.ts index beda6a6..0e7e6d4 100644 --- a/.opencode/plugin/tensorlake/plugins/session-events.ts +++ b/.opencode/plugin/tensorlake/plugins/session-events.ts @@ -1,14 +1,26 @@ import type { PluginInput } from '@opencode-ai/plugin' -import { EVENT_TYPE_SESSION_DELETED, EVENT_TYPE_SERVER_INSTANCE_DISPOSED, type EventSessionDeleted } from '../core/types.js' +import { + EVENT_TYPE_SESSION_DELETED, + EVENT_TYPE_SESSION_IDLE, + EVENT_TYPE_SERVER_INSTANCE_DISPOSED, + type EventSessionDeleted, + type EventSessionIdle, +} from '../core/types.js' import { toast } from '../core/toast.js' import { logger } from '../core/logger.js' import type { TensorlakeSessionManager } from '../core/session-manager.js' export async function eventHandlers(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { const projectId = ctx.project.id + const worktree = ctx.project?.worktree ?? (ctx as any).worktree ?? '' return async (args: any) => { const event = args.event - if (event.type === EVENT_TYPE_SESSION_DELETED) { + if (event.type === EVENT_TYPE_SESSION_IDLE) { + // The agent just finished a turn: pull whatever it pushed to the mirror + // back into the local checkout. Failures are logged inside syncBack. + const sessionId = (event as EventSessionIdle).properties.sessionID + await sessionManager.syncBack(sessionId, projectId, worktree) + } else if (event.type === EVENT_TYPE_SESSION_DELETED) { const sessionId = (event as EventSessionDeleted).properties.info.id try { await sessionManager.deleteSandbox(sessionId, projectId) diff --git a/.opencode/plugin/tensorlake/plugins/system-transform.ts b/.opencode/plugin/tensorlake/plugins/system-transform.ts index c5ebc51..b30eb44 100644 --- a/.opencode/plugin/tensorlake/plugins/system-transform.ts +++ b/.opencode/plugin/tensorlake/plugins/system-transform.ts @@ -1,6 +1,6 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { ExperimentalChatSystemTransformInput, ExperimentalChatSystemTransformOutput } from '../core/types.js' -import { resolveSyncMode } from '../core/project-sync.js' +import { resolveSyncMode, resolveSyncBranch } from '../core/project-sync.js' export async function systemPromptTransform(ctx: PluginInput, workDir: string, projectDir: string) { const worktree = ctx.project?.worktree ?? ctx.worktree ?? '' @@ -14,9 +14,12 @@ export async function systemPromptTransform(ctx: PluginInput, workDir: string, p "For long-running commands (servers, watchers), use bash with background=true; check on them with bash_output and stop them with bash_kill.", ] if (mode === 'git') { + // Resolved per message, not at plugin startup: the user can switch + // local branches between turns and later syncs follow the new branch. + const branch = await resolveSyncBranch(worktree) lines.push( - `The local project is synced into the sandbox as a git clone at: ${projectDir}`, - `Work in ${projectDir}. Commit and push to 'origin' to persist changes.`, + `The local project is synced into the sandbox as a git clone at: ${projectDir} (branch: ${branch})`, + `Work in ${projectDir} on branch '${branch}'. Commit and push to 'origin ${branch}' to persist changes; pushed commits are pulled back into the user's local checkout automatically.`, ) } else if (mode === 'volume') { lines.push( diff --git a/README.md b/README.md index d120e7d..e1ed2bd 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The first time a sandbox is used (per OpenCode process), the plugin syncs your l | Local project | Sync mode | How it works | |---|---|---| -| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed (`git push HEAD:main`) to a Tensorlake-hosted mirror (`opencode-`), which is then cloned inside the sandbox — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror. A repo with no commits yet is synced as a single snapshot commit instead. | +| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed to a Tensorlake-hosted mirror (`opencode-`) **under your current branch name**, and the mirror is cloned inside the sandbox on that same branch — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror; **pushed commits are pulled back into your local branch automatically** (see below). A repo with no commits yet is synced as a single snapshot commit instead. | | Plain folder | `volume` | The folder is uploaded to a Tensorlake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. @@ -57,20 +57,36 @@ Sync failures are surfaced as a toast and logged, but never block the sandbox > Sync runs once per sandbox per OpenCode process. Restarting OpenCode re-syncs, picking up local changes (git mode fast-forwards the clone; volume mode uploads only changed content). A sandbox clone that has its own commits or edits is never reset — re-sync only fast-forwards, and uncommitted local changes are only replayed onto a clean sandbox tree. -### Getting agent commits back to your machine (git mode) +### Where agent commits go (git mode) -The sandbox clone's `origin` is the Tensorlake-hosted mirror, so when the agent commits and runs `git push`, the work lands on the mirror. Fetch it locally: +**Agent commits do not go to GitHub directly.** Inside the sandbox, `origin` points to the Tensorlake-hosted mirror (`opencode-`) — not to your GitHub/GitLab remote. The plugin never copies your GitHub credentials or remotes into the sandbox. When the agent commits and runs `git push`, the work lands on the mirror, on the same branch you have checked out locally. + +**The plugin pulls agent commits back to your machine automatically.** After each agent turn, it fetches the mirror into `refs/remotes/tensorlake/*` and fast-forwards your local branch when that is safe (your worktree is clean and the histories have not diverged). You see a toast when commits land. So the everyday flow is: ```bash -# one-time: add the mirror as a remote — username is always `t`, -# get the repo URL and a short-lived token with the Tensorlake CLI: -tl git token opencode- -git remote add tensorlake https://t:@ -git fetch tensorlake -git merge tensorlake/main # or cherry-pick / diff as you prefer +# you are on feature-x; the agent works in the sandbox on feature-x +# agent commits and pushes -> your local feature-x advances automatically + +# review, then send it to GitHub from your machine as usual: +git push origin feature-x +gh pr create ``` -> If you rewrite local history (rebase, amend), the next sync recreates the mirror from your rewritten history — any commits that existed only on the mirror are discarded, so fetch agent work before rebasing. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. +When a fast-forward is not safe, nothing in your checkout is touched. The commits wait on the `tensorlake/` tracking ref, a toast tells you why (uncommitted local changes, or diverged histories), and you merge on your own terms: + +```bash +git stash # if the blocker was uncommitted changes +git merge tensorlake/feature-x +git stash pop +``` + +The mirror is a normal [Tensorlake git repository](https://docs.tensorlake.ai/git/introduction), so the standard `tl git` commands (`tl git list`, `tl git token`, …) work with it if you ever want to inspect it directly. + +To push agent work to GitHub, let the sync-back land it (or merge the tracking ref), then push from your machine. Alternatively, add your GitHub remote and credentials inside the sandbox yourself — the plugin does not do this for you. + +> **Branch naming.** The sync branch is whatever `git branch --show-current` reports on your machine when the sync runs — one name everywhere: local, mirror, and sandbox. A detached HEAD (or a branch name that cannot be embedded safely) syncs as `main`. If you switch local branches, the next sandbox sync follows: a clean sandbox clone switches to the new branch; one with its own edits is left untouched. + +> If you rewrite local history (rebase, amend), the next sync recreates the mirror from your rewritten history — any commits that existed only on the mirror are discarded. The automatic sync-back makes this window small (agent commits normally reach your machine within a turn), but rebasing mid-turn while the agent still has unpushed or unfetched work can lose it. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. --- From 2997b0c12a7bd6a601eb2e375a7ba7145b428f95 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Fri, 21 Aug 2026 14:19:36 -0500 Subject: [PATCH 14/15] fix authentification --- .../plugin/tensorlake/core/credentials.ts | 50 +++++-------------- .../plugin/tensorlake/core/session-manager.ts | 16 +++++- .opencode/plugin/tensorlake/plugins/auth.ts | 47 +++++++---------- README.md | 2 +- 4 files changed, 45 insertions(+), 70 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/credentials.ts b/.opencode/plugin/tensorlake/core/credentials.ts index 6705f3c..b56b7c0 100644 --- a/.opencode/plugin/tensorlake/core/credentials.ts +++ b/.opencode/plugin/tensorlake/core/credentials.ts @@ -1,7 +1,6 @@ import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { xdgData } from 'xdg-basedir' -import { CloudClient } from 'tensorlake' import { logger } from './logger.js' /** Provider id shown in `opencode auth login` and used as the auth.json key. */ @@ -50,43 +49,18 @@ export function resolveApiKey(): string | undefined { return readStoredApiKey() } -function pickId(value: unknown, keys: string[]): string | undefined { - if (value == null || typeof value !== 'object') return undefined - const obj = value as Record - for (const key of keys) { - const candidate = obj[key] - if (typeof candidate === 'string' && candidate.length > 0) return candidate - } - return undefined -} - -export type KeyValidation = { ok: true } | { ok: false; reason: string } - /** - * Validate a key against the management API before it is stored. Project - * API keys introspect to a project id; anything without one (PATs, org-level - * tokens) is rejected so logged-in users never need to supply - * TENSORLAKE_ORGANIZATION_ID / TENSORLAKE_PROJECT_ID separately. + * OpenCode's built-in API-key prompt cannot validate the key at login (the + * plugin never sees the masked value), so keys are checked here on first use. + * A non-project key still works when its scope is supplied via env vars, so + * this is a warning, not a hard failure. */ -export async function validateProjectApiKey(apiKey: string): Promise { - const apiUrl = process.env.TENSORLAKE_API_URL - const client = CloudClient.forCloud({ apiKey, ...(apiUrl ? { apiUrl } : {}) }) - try { - const intro = (await client.introspectApiKey()) as Record - const projectId = - pickId(intro, ['projectId', 'project_id']) ?? - pickId(intro.project, ['id', 'projectId', 'project_id']) - if (!projectId) { - return { - ok: false, - reason: - 'Key is valid but not project-scoped. Create a project API key at https://cloud.tensorlake.ai (Project → API Keys).', - } - } - return { ok: true } - } catch (err: any) { - return { ok: false, reason: `Key validation failed: ${err?.message ?? err}` } - } finally { - client.close() - } +export function projectKeyWarning(apiKey: string): string | undefined { + if (apiKey.startsWith(PROJECT_KEY_PREFIX)) return undefined + if (process.env.TENSORLAKE_ORGANIZATION_ID && process.env.TENSORLAKE_PROJECT_ID) return undefined + return ( + `The stored key is not a project API key (${PROJECT_KEY_PREFIX}...). ` + + 'Sandbox calls may fail. Re-run `opencode auth login` with a project API key from ' + + 'https://cloud.tensorlake.ai (Project → API Keys), or set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.' + ) } diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index 5a61d49..94f550d 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { join, posix } from 'path' import { TensorlakeClient } from './client.js' -import { LOGIN_HINT } from './credentials.js' +import { LOGIN_HINT, projectKeyWarning } from './credentials.js' import { logger } from './logger.js' import { toast } from './toast.js' import type { ProjectSessionData } from './types.js' @@ -36,6 +36,8 @@ export class TensorlakeSessionManager { private syncBackFailedAt = 0 // Mirror commit already reported as "staged, not merged" — suppresses repeat toasts private lastStagedOid: string | null = null + // Keys already checked for project scope — the warning fires once per key + private readonly warnedKeys = new Set() public readonly workDir: string private readonly storageDir: string @@ -261,6 +263,18 @@ export class TensorlakeSessionManager { throw new Error(msg) } + // Login can no longer validate the key (OpenCode masks it away from the + // plugin), so warn once per key when it doesn't look project-scoped. + const apiKey = this.client.getApiKey() + if (!this.warnedKeys.has(apiKey)) { + this.warnedKeys.add(apiKey) + const warning = projectKeyWarning(apiKey) + if (warning) { + logger.warn(warning) + toast.show({ title: 'Check your Tensorlake API key', message: warning, variant: 'warning' }) + } + } + // Check in-memory cache const cached = this.cache.get(sessionId) if (cached) { diff --git a/.opencode/plugin/tensorlake/plugins/auth.ts b/.opencode/plugin/tensorlake/plugins/auth.ts index 070801a..6d26a8e 100644 --- a/.opencode/plugin/tensorlake/plugins/auth.ts +++ b/.opencode/plugin/tensorlake/plugins/auth.ts @@ -1,12 +1,19 @@ import type { AuthHook } from '@opencode-ai/plugin' -import { PROVIDER_ID, PROJECT_KEY_PREFIX, validateProjectApiKey } from '../core/credentials.js' -import { logger } from '../core/logger.js' +import { PROVIDER_ID } from '../core/credentials.js' /** - * Registers Tensorlake as a provider in `opencode auth login`. The entered key - * is validated against the management API (and required to be project-scoped) - * before OpenCode stores it in its own credential store (auth.json). The - * plugin never persists credentials itself. + * Registers Tensorlake as a provider in `opencode auth login`. OpenCode shows + * its own masked "Enter your API key" prompt for `api` methods and stores the + * key in its credential store (auth.json). The method must not define a text + * prompt or `authorize`: OpenCode renders text prompts unmasked (echoing the + * key on screen) and never passes the masked key to `authorize`, so a text + * prompt forces the user to enter the key twice. The key is checked lazily on + * first use instead (see session-manager.ts). + * + * The single-option select below is the only way to show where to get the key: + * OpenCode's key prompt message is hardcoded and `api` methods have no + * instructions field. Pasting a key into the select is inert, so it cannot + * leak the key. Its value lands in auth.json metadata, which nothing reads. */ export const authHook: AuthHook = { provider: PROVIDER_ID, @@ -16,32 +23,12 @@ export const authHook: AuthHook = { label: 'Project API key', prompts: [ { - type: 'text', - key: 'key', - message: - 'Paste a project API key from https://cloud.tensorlake.ai (open your project → API Keys)', - placeholder: `${PROJECT_KEY_PREFIX}...`, - validate: (value: string) => { - const key = value.trim() - if (!key) return 'API key is required' - if (!key.startsWith(PROJECT_KEY_PREFIX)) { - return `Project API keys start with ${PROJECT_KEY_PREFIX} — create one in your project's API Keys page` - } - return undefined - }, + type: 'select', + key: 'hint', + message: 'Get your API key from https://cloud.tensorlake.ai (open your project → API Keys)', + options: [{ label: 'I have my key — continue', value: 'ok' }], }, ], - async authorize(inputs) { - const key = (inputs?.key ?? '').trim() - if (!key) return { type: 'failed' } - const result = await validateProjectApiKey(key) - if (!result.ok) { - logger.error(`Tensorlake login rejected: ${result.reason}`) - return { type: 'failed' } - } - logger.info('Tensorlake project API key validated and stored via opencode auth login') - return { type: 'success', key } - }, }, ], } diff --git a/README.md b/README.md index e1ed2bd..8be3fc7 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ For local paths: 1. Make sure `TENSORLAKE_API_KEY` is **not** exported and no Tensorlake entry exists in `~/.local/share/opencode/auth.json`. 2. Start OpenCode and ask the model to run a command. The tool call should fail with a **"Tensorlake login required"** toast telling you to run `opencode auth login`. -3. Run `opencode auth login`, select **Tensorlake**, and paste a project API key. A wrong-prefix key is rejected at the prompt; a revoked or non-project key is rejected during validation. +3. Run `opencode auth login` and select **Tensorlake**. A hint shows where to get a key (press Enter to continue), then paste a project API key at the masked **Enter your API key** prompt. The prompt does not validate the key; a non-project key triggers a **"Check your Tensorlake API key"** warning toast on the first tool call. 4. Retry the tool call in the same OpenCode session — no restart needed. The sandbox should now be created. ### Basic smoke test From 5c5d09812587bc8bda4de25e88dc7a5849e45227 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Fri, 21 Aug 2026 14:24:10 -0500 Subject: [PATCH 15/15] bump up to the latest sdk and opencode --- package-lock.json | 24 ++++++++++++------------ package.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index f398a6d..01fe63d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@opencode-ai/plugin": "^1.18.18", - "tensorlake": "^0.5.109", + "@opencode-ai/plugin": "^1.18.21", + "tensorlake": "^0.5.112", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, @@ -151,13 +151,13 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.18.18", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.18.tgz", - "integrity": "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.21.tgz", + "integrity": "sha512-wQXMbSvg3pG75F8fYAiJxYuyr6Cl9Hd74lSCY2yznZnejpwa+ksd2kMphmgSo+/UgLhb2AId9KAI6dsRhprzTg==", "license": "MIT", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode-ai/sdk": "1.18.18", + "@opencode-ai/sdk": "1.18.21", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, @@ -188,9 +188,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.18.18", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.18.tgz", - "integrity": "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.21.tgz", + "integrity": "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -713,9 +713,9 @@ } }, "node_modules/tensorlake": { - "version": "0.5.109", - "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.109.tgz", - "integrity": "sha512-kkO36ahveEBsRVPPEvRtMFVo4c8jiYcgX71nRjxAwsO4gRykhqo5OG9NB84WvvCrJVPk08OzU+bQlMx5rUrwRw==", + "version": "0.5.112", + "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.112.tgz", + "integrity": "sha512-Issapl4HIyah5t3BQeAj3Ym9D6G5K6IWoHevqaW3/Cq8Jdp0Tgnx/bPEq1ku4n8jlx6NQbYioCHfdZWZerh5YQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.13.0", diff --git a/package.json b/package.json index e636489..9a21215 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "type-check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@opencode-ai/plugin": "^1.18.18", - "tensorlake": "^0.5.109", + "@opencode-ai/plugin": "^1.18.21", + "tensorlake": "^0.5.112", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" },