Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 89 additions & 121 deletions .opencode/package-lock.json

Large diffs are not rendered by default.

218 changes: 175 additions & 43 deletions .opencode/plugin/tensorlake/core/client.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { SandboxClient } from 'tensorlake'
import type { 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'

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
Expand All @@ -33,69 +27,171 @@ export type DirectoryEntry = {
size?: number
}

export class TensorLakeClient {
private readonly sdk: SandboxClient
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.
private readonly handles = new Map<string, Sandbox>()

// 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.resolveKey() ?? '').length > 0
}

getApiKey(): string {
return this.resolveKey() ?? ''
}

constructor(private readonly apiKey: string) {
this.sdk = SandboxClient.forCloud({
apiKey,
private clientOptions() {
return {
apiKey: this.getApiKey(),
apiUrl: MANAGEMENT_API,
organizationId: process.env.TENSORLAKE_ORGANIZATION_ID,
projectId: process.env.TENSORLAKE_PROJECT_ID,
...(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<Sandbox> {
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
}

hasApiKey(): boolean {
return this.apiKey.length > 0
// Errors suggesting the cached handle is unusable — stale proxy routing
// (e.g. the sandbox was suspended and resumed outside this process, moving
// it to a different host) or stale credentials (401/403 after a key
// rotation; handles capture the key at connect time, so only a reconnect
// picks up the new one). Plain 404 is excluded on purpose: sandbox-level
// not-found already arrives as SandboxNotFoundError, so a bare 404 is a
// daemon endpoint's normal answer (missing file, unknown pid) and must not
// tear down a healthy handle.
private isStaleHandleError(err: unknown): boolean {
return (
err instanceof SandboxConnectionError ||
err instanceof SandboxNotFoundError ||
(err instanceof RemoteAPIError && [401, 403, 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<T>(
sandboxId: string,
op: (sandbox: Sandbox) => Promise<T>,
opts: { retry: boolean } = { retry: true },
): Promise<T> {
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))
}
}

async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number } = {}): Promise<CreateSandboxResponse> {
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<CreateSandboxResponse> {
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 proxyUrl = process.env.TENSORLAKE_SANDBOX_PROXY_URL
const sandbox = await Sandbox.create({
...(proxyUrl ? { proxyUrl } : {}),
...(opts.image ? { image: opts.image } : {}),
cpus,
memoryMb,
diskMb: ephemeralDiskMb,
...(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<FileSystemMount[]> {
const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info())
return info.fileSystems ?? []
}

async attachFileSystem(sandboxId: string, fileSystemId: string, mountPath: string): Promise<void> {
// retry: false — a retried attach after a mid-flight failure could double-attach
await this.withSandbox(sandboxId, (sandbox) => sandbox.attachFileSystem(fileSystemId, mountPath), { retry: false })
}

async getSandbox(sandboxId: string): Promise<SandboxInfo> {
const info = await this.sdk.get(sandboxId)
const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info())
return { sandbox_id: info.sandboxId, status: info.status as unknown as string }
}

async deleteSandbox(sandboxId: string): Promise<void> {
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
// Already deleted — treat as success.
if (err instanceof SandboxNotFoundError) return
if (err instanceof RemoteAPIError && err.statusCode === 404) return
throw err
} finally {
this.dropHandle(sandboxId)
}
}

async suspendSandbox(sandboxId: string): Promise<void> {
await this.sdk.suspend(sandboxId)
await this.withSandbox(sandboxId, (sandbox) => sandbox.suspend())
}

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) {
try {
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
Expand All @@ -107,7 +203,8 @@ export class TensorLakeClient {
}

async resumeSandbox(sandboxId: string): Promise<void> {
await this.sdk.resume(sandboxId)
// resume() waits for running and refreshes the handle's proxy routing.
await this.withSandbox(sandboxId, (sandbox) => sandbox.resume())
}

async waitForSuspended(sandboxId: string, timeoutMs = 30_000): Promise<void> {
Expand All @@ -124,49 +221,84 @@ export class TensorLakeClient {
while (Date.now() < deadline) {
const info = await this.getSandbox(sandboxId)
if (info.status === 'running') return
if (info.status === 'terminated') throw new Error(`Sandbox ${sandboxId} was terminated`)
// 'timeout' is terminal like 'terminated' — fail fast instead of polling to the deadline
if (info.status === 'terminated' || info.status === 'timeout') {
throw new Error(`Sandbox ${sandboxId} is ${info.status}`)
}
await new Promise((r) => setTimeout(r, 500))
}
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<ProcessResult> {
const sandbox = 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 ?? '',
stderr: result.stderr ?? '',
}
}

// 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<number> {
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<ProcessStatusInfo> {
const info = await this.withSandbox(sandboxId, (sandbox) => 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<string[]> {
const output = await this.withSandbox(sandboxId, (sandbox) => sandbox.getOutput(pid))
return output.lines
}

async killProcess(sandboxId: string, pid: number): Promise<void> {
await this.withSandbox(sandboxId, (sandbox) => sandbox.killProcess(pid))
}

async readFile(sandboxId: string, path: string): Promise<Buffer> {
const sandbox = 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<void> {
const sandbox = this.connectSandbox(sandboxId)
await sandbox.writeFile(path, content)
await this.withSandbox(sandboxId, (sandbox) => sandbox.writeFile(path, content))
}

async listDirectory(sandboxId: string, path: string): Promise<DirectoryEntry[]> {
const sandbox = 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,
Expand Down
66 changes: 66 additions & 0 deletions .opencode/plugin/tensorlake/core/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { xdgData } from 'xdg-basedir'
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<string, unknown>
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()
}

/**
* 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 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.'
)
}
Loading