diff --git a/biome.jsonc b/biome.jsonc index 77ebbb5c..8ee10617 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "css": { "parser": { diff --git a/connectors/sandbox--boxd.md b/connectors/sandbox--boxd.md index 8b2ca870..d0aca4f9 100644 --- a/connectors/sandbox--boxd.md +++ b/connectors/sandbox--boxd.md @@ -59,7 +59,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * const session = await harness.session(); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Box as BoxdBox } from '@boxd-sh/sdk'; @@ -234,7 +234,7 @@ export function boxd(box: BoxdBox, options?: BoxdConnectorOptions): SandboxFacto let readyPromise: Promise | undefined; return { async createSessionEnv({ cwd }: { id: string; cwd?: string }): Promise { - const sandboxCwd = cwd ?? options?.cwd ?? '/home/boxd'; + const sandboxCwd = resolveSandboxCwd(options?.cwd ?? '/home/boxd', cwd); // Probe once per box, not once per session. readyPromise ??= waitForReady(box, options?.readyTimeoutMs ?? 30_000); await readyPromise; diff --git a/connectors/sandbox--daytona.md b/connectors/sandbox--daytona.md index babe1264..2183270a 100644 --- a/connectors/sandbox--daytona.md +++ b/connectors/sandbox--daytona.md @@ -55,7 +55,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * const session = await harness.session(); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Sandbox as DaytonaSandbox } from '@daytona/sdk'; @@ -144,7 +144,7 @@ class DaytonaSandboxApi implements SandboxApi { export function daytona(sandbox: DaytonaSandbox): SandboxFactory { return { async createSessionEnv({ cwd }: { id: string; cwd?: string }): Promise { - const sandboxCwd = cwd ?? (await sandbox.getWorkDir()) ?? '/home/daytona'; + const sandboxCwd = resolveSandboxCwd((await sandbox.getWorkDir()) ?? '/home/daytona', cwd); const api = new DaytonaSandboxApi(sandbox); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--e2b.md b/connectors/sandbox--e2b.md index 690bc8e4..d8b27fa2 100644 --- a/connectors/sandbox--e2b.md +++ b/connectors/sandbox--e2b.md @@ -62,7 +62,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * const session = await harness.session(); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Sandbox as E2BSandbox } from 'e2b'; @@ -170,7 +170,7 @@ export function e2b(sandbox: E2BSandbox): SandboxFactory { // The E2B base template's default user is `user` with home // directory /home/user. Sessions inherit this unless the caller // overrides cwd. - const sandboxCwd = cwd ?? '/home/user'; + const sandboxCwd = resolveSandboxCwd('/home/user', cwd); const api = new E2BSandboxApi(sandbox); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--exedev.md b/connectors/sandbox--exedev.md index 0daac3ff..6e4a0e64 100644 --- a/connectors/sandbox--exedev.md +++ b/connectors/sandbox--exedev.md @@ -85,7 +85,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * } * ``` */ -import { createSandboxSessionEnv } from "@flue/sdk/sandbox"; +import { createSandboxSessionEnv, resolveSandboxCwd } from "@flue/sdk/sandbox"; import type { FileStat, SandboxApi, @@ -620,16 +620,17 @@ export function exedev(vm: ExeDevVm | string, options?: ExeDevConnectorOptions): const { ssh } = await sshConnect(resolvedVm, options ?? {}); const api = new ExeDevSandboxApi(ssh); - let sandboxCwd = cwd ?? "/home/user"; + let defaultCwd = "/home/user"; if (!cwd) { try { const { stdout } = await api.exec("echo $HOME"); const detected = stdout.trim(); - if (detected) sandboxCwd = detected; + if (detected) defaultCwd = detected; } catch { // Fall back to /home/user. } } + const sandboxCwd = resolveSandboxCwd(defaultCwd, cwd); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--islo.md b/connectors/sandbox--islo.md index d8db3a81..66dc2005 100644 --- a/connectors/sandbox--islo.md +++ b/connectors/sandbox--islo.md @@ -63,7 +63,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * ``` */ import { spawn } from 'node:child_process'; -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; export interface IsloConnectorOptions { @@ -209,7 +209,7 @@ export function islo(name: string, options?: IsloConnectorOptions): SandboxFacto const cliPath = options?.cliPath ?? 'islo'; return { async createSessionEnv({ cwd }: { id: string; cwd?: string }): Promise { - const sandboxCwd = cwd ?? options?.cwd ?? '/workspace'; + const sandboxCwd = resolveSandboxCwd(options?.cwd ?? '/workspace', cwd); const api = new IsloSandboxApi(name, cliPath); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--mirage.md b/connectors/sandbox--mirage.md index 93d939a0..e89bc983 100644 --- a/connectors/sandbox--mirage.md +++ b/connectors/sandbox--mirage.md @@ -73,7 +73,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * const session = await harness.session(); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Workspace as MirageWorkspace } from '@struktoai/mirage-core'; @@ -297,7 +297,7 @@ export function mirage( // Mirage workspaces are mount-rooted at `/`. `/` is a safe no-op // default; pin via `options.cwd` to default to a specific writable // mount (e.g. `/data`). - const sandboxCwd = cwd ?? options?.cwd ?? '/'; + const sandboxCwd = resolveSandboxCwd(options?.cwd ?? '/', cwd); const api = new MirageSandboxApi(workspace, id); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--modal.md b/connectors/sandbox--modal.md index f49752c4..2f382ffc 100644 --- a/connectors/sandbox--modal.md +++ b/connectors/sandbox--modal.md @@ -71,7 +71,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * const session = await harness.session(); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Sandbox as ModalSandbox } from 'modal'; @@ -250,7 +250,7 @@ class ModalSandboxApi implements SandboxApi { export function modal(sandbox: ModalSandbox, options?: ModalConnectorOptions): SandboxFactory { return { async createSessionEnv({ cwd }: { id: string; cwd?: string }): Promise { - const sandboxCwd = cwd ?? options?.cwd ?? '/'; + const sandboxCwd = resolveSandboxCwd(options?.cwd ?? '/', cwd); const api = new ModalSandboxApi(sandbox); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--smolvm.md b/connectors/sandbox--smolvm.md index 2dccb39f..869fe4f4 100644 --- a/connectors/sandbox--smolvm.md +++ b/connectors/sandbox--smolvm.md @@ -74,7 +74,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * }); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Machine } from 'smolvm-embedded'; @@ -172,7 +172,7 @@ class SmolvmSandboxApi implements SandboxApi { export function smolvm(machine: Machine): SandboxFactory { return { async createSessionEnv({ cwd }: { id: string; cwd?: string }): Promise { - const sandboxCwd = cwd ?? '/workspace'; + const sandboxCwd = resolveSandboxCwd('/workspace', cwd); const api = new SmolvmSandboxApi(machine); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/connectors/sandbox--vercel.md b/connectors/sandbox--vercel.md index ac83465a..b01c5678 100644 --- a/connectors/sandbox--vercel.md +++ b/connectors/sandbox--vercel.md @@ -54,7 +54,7 @@ Write this file verbatim. Do not "improve" it — it conforms to the published * const session = await harness.session(); * ``` */ -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, SessionEnv, FileStat } from '@flue/sdk/sandbox'; import type { Sandbox as VercelSandbox } from '@vercel/sandbox'; @@ -170,7 +170,7 @@ class VercelSandboxApi implements SandboxApi { export function vercel(sandbox: VercelSandbox): SandboxFactory { return { async createSessionEnv({ cwd }: { id: string; cwd?: string }): Promise { - const sandboxCwd = cwd ?? '/vercel/sandbox'; + const sandboxCwd = resolveSandboxCwd('/vercel/sandbox', cwd); const api = new VercelSandboxApi(sandbox); return createSandboxSessionEnv(api, sandboxCwd); }, diff --git a/docs/sandbox-connector-spec.md b/docs/sandbox-connector-spec.md index 7c2b9863..c6c37dae 100644 --- a/docs/sandbox-connector-spec.md +++ b/docs/sandbox-connector-spec.md @@ -33,11 +33,14 @@ a factory function (e.g. `daytona(...)`) returning a `SandboxFactory`. A connector is one TypeScript file. It exports a factory function that takes an already-initialized provider sandbox plus options, and returns a `SandboxFactory`. Flue calls `factory.createSessionEnv({ id, cwd })` once per -session and uses the returned `SessionEnv` for all shell/file operations. +session and uses the returned `SessionEnv` for all shell/file operations. The +`cwd` value is the caller's `init({ cwd })` value when provided. Use +`resolveSandboxCwd(defaultCwd, cwd)` to resolve relative cwd values against +your provider's default cwd before creating the `SessionEnv`. ```ts // .flue/connectors/.ts (or ./connectors/.ts) -import { createSandboxSessionEnv } from '@flue/sdk/sandbox'; +import { createSandboxSessionEnv, resolveSandboxCwd } from '@flue/sdk/sandbox'; import type { SandboxApi, SandboxFactory, @@ -54,7 +57,7 @@ class ProviderSandboxApi implements SandboxApi { export function provider(sandbox: ProviderSandbox): SandboxFactory { return { async createSessionEnv({ cwd }): Promise { - const sandboxCwd = cwd ?? '/workspace'; // pick a sensible default + const sandboxCwd = resolveSandboxCwd('/workspace', cwd); // pick a sensible default const api = new ProviderSandboxApi(sandbox); return createSandboxSessionEnv(api, sandboxCwd); }, @@ -74,6 +77,8 @@ All from `@flue/sdk/sandbox`: - `createSandboxSessionEnv(api, cwd)` — wraps your `SandboxApi` into a `SessionEnv` that Flue can drive. +- `resolveSandboxCwd(defaultCwd, cwd?)` — resolves Flue's optional cwd against + your provider's default cwd. - `SandboxApi` — the interface you implement. - `SandboxFactory` — what your factory returns. - `SessionEnv` — what `createSandboxSessionEnv` returns. You don't construct diff --git a/examples/assistant/package.json b/examples/assistant/package.json index 605fa21f..28d55dd3 100644 --- a/examples/assistant/package.json +++ b/examples/assistant/package.json @@ -2,6 +2,9 @@ "name": "assistant", "private": true, "type": "module", + "scripts": { + "check:types": "tsc --noEmit" + }, "dependencies": { "@flue/sdk": "workspace:*", "@cloudflare/sandbox": "*", diff --git a/examples/assistant/tsconfig.json b/examples/assistant/tsconfig.json index b361441c..3a417ed2 100644 --- a/examples/assistant/tsconfig.json +++ b/examples/assistant/tsconfig.json @@ -1,3 +1,5 @@ { - "extends": "../../tsconfig.base.json" + "extends": "../../tsconfig.base.json", + "include": [".flue/**/*.ts"], + "exclude": ["dist"] } diff --git a/examples/cloudflare/package.json b/examples/cloudflare/package.json index d1bc0f5f..f5260c55 100644 --- a/examples/cloudflare/package.json +++ b/examples/cloudflare/package.json @@ -2,6 +2,9 @@ "name": "cloudflare", "private": true, "type": "module", + "scripts": { + "check:types": "tsc --noEmit" + }, "dependencies": { "@flue/sdk": "workspace:*", "agents": "*", diff --git a/examples/cloudflare/tsconfig.json b/examples/cloudflare/tsconfig.json index b361441c..3a417ed2 100644 --- a/examples/cloudflare/tsconfig.json +++ b/examples/cloudflare/tsconfig.json @@ -1,3 +1,5 @@ { - "extends": "../../tsconfig.base.json" + "extends": "../../tsconfig.base.json", + "include": [".flue/**/*.ts"], + "exclude": ["dist"] } diff --git a/examples/hello-world/.flue/agents/fs-surface-test.ts b/examples/hello-world/.flue/agents/fs-surface-test.ts index b149836f..92bcc610 100644 --- a/examples/hello-world/.flue/agents/fs-surface-test.ts +++ b/examples/hello-world/.flue/agents/fs-surface-test.ts @@ -28,8 +28,8 @@ export default async function ({ init }: FlueContext) { check('session.fs writeFile/readFile round-trip', sRead === 'session.fs content'); // agent.fs round-trip - await agent.fs.writeFile('/tmp/agent.txt', 'agent.fs content'); - const aRead = await agent.fs.readFile('/tmp/agent.txt'); + await harness.fs.writeFile('/tmp/agent.txt', 'agent.fs content'); + const aRead = await harness.fs.readFile('/tmp/agent.txt'); check('agent.fs writeFile/readFile round-trip', aRead === 'agent.fs content'); // session.fs writes are visible to session.shell @@ -38,8 +38,8 @@ export default async function ({ init }: FlueContext) { check('session.fs visible to session.shell', viaShell.stdout.trim() === 'staged by SDK'); // agent.fs writes are visible to agent.shell - await agent.fs.writeFile('/tmp/agent-visible.txt', 'staged by agent.fs'); - const aViaShell = await agent.shell('cat /tmp/agent-visible.txt'); + await harness.fs.writeFile('/tmp/agent-visible.txt', 'staged by agent.fs'); + const aViaShell = await harness.shell('cat /tmp/agent-visible.txt'); check('agent.fs visible to agent.shell', aViaShell.stdout.trim() === 'staged by agent.fs'); // mkdir / readdir / exists / rm diff --git a/examples/hello-world/.flue/agents/with-sandbox.ts b/examples/hello-world/.flue/agents/with-sandbox.ts index 522bf7ac..5112091d 100644 --- a/examples/hello-world/.flue/agents/with-sandbox.ts +++ b/examples/hello-world/.flue/agents/with-sandbox.ts @@ -10,7 +10,7 @@ export default async function ({ init }: FlueContext) { const sandbox = await client.create(); const harness = await init({ - sandbox: daytona(sandbox, { cleanup: true }), + sandbox: daytona(sandbox), model: 'anthropic/claude-sonnet-4-6', }); const session = await harness.session(); diff --git a/examples/hello-world/package.json b/examples/hello-world/package.json index 51061c36..e9973f34 100644 --- a/examples/hello-world/package.json +++ b/examples/hello-world/package.json @@ -2,6 +2,9 @@ "name": "hello-world", "private": true, "type": "module", + "scripts": { + "check:types": "tsc --noEmit" + }, "dependencies": { "@flue/sdk": "workspace:*", "@daytona/sdk": "*", diff --git a/package.json b/package.json index de24bb54..8027db8d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "dev": "turbo dev", "build": "turbo build", - "check": "turbo run build check:lint check:types", + "check": "turbo run build check:lint check:types check:smoke", "check:lint": "biome lint .", "check:types": "turbo run check:types", "format": "turbo run format:lint format:style", diff --git a/packages/cli/package.json b/packages/cli/package.json index 02f8c782..effcd490 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,7 @@ "scripts": { "prebuild": "tsx scripts/generate-connector-index.ts", "build": "tsdown && mv dist/flue.mjs dist/flue.js", + "check:types": "tsc --noEmit", "prepublishOnly": "cp ../../README.md ." }, "dependencies": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c81514a9..e999c0e2 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -47,6 +47,7 @@ }, "scripts": { "build": "tsdown", + "check:smoke": "tsx scripts/cwd-contract-smoke.ts", "check:types": "tsc --noEmit", "prepublishOnly": "cp ../../README.md ." }, diff --git a/packages/sdk/scripts/cwd-contract-smoke.ts b/packages/sdk/scripts/cwd-contract-smoke.ts new file mode 100644 index 00000000..2e241198 --- /dev/null +++ b/packages/sdk/scripts/cwd-contract-smoke.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { createFlueContext, InMemorySessionStore } from '../src/internal.ts'; +import { createLocalSessionEnv } from '../src/node/local-env.ts'; +import { + createSandboxSessionEnv, + resolveSandboxCwd, + type SandboxApi, +} from '../src/sandbox.ts'; +import type { + AgentConfig, + FileStat, + FlueContext, + SandboxFactory, + SessionEnv, + ShellResult, +} from '../src/types.ts'; + +const agentConfig: AgentConfig = { + systemPrompt: '', + skills: {}, + roles: {}, + model: undefined, + resolveModel: () => undefined, +}; + +function createTestContext(id: string, root: string): FlueContext { + return createFlueContext({ + id, + runId: `${id}-run`, + payload: {}, + env: {}, + agentConfig, + createDefaultEnv: async () => createLocalSessionEnv({ cwd: root }), + createLocalEnv: async () => createLocalSessionEnv({ cwd: root }), + defaultStore: new InMemorySessionStore(), + }); +} + +class RecordingSandboxApi implements SandboxApi { + async exec( + command: string, + options?: { cwd?: string; env?: Record; timeout?: number }, + ): Promise { + return { + stdout: command === 'pwd' ? `${options?.cwd ?? ''}\n` : '', + stderr: '', + exitCode: 0, + }; + } + + async readFile(path: string): Promise { + throw new Error(`unexpected readFile(${path})`); + } + + async readFileBuffer(path: string): Promise { + throw new Error(`unexpected readFileBuffer(${path})`); + } + + async writeFile(_path: string, _content: string | Uint8Array): Promise {} + + async stat(path: string): Promise { + throw new Error(`unexpected stat(${path})`); + } + + async readdir(_path: string): Promise { + return []; + } + + async exists(_path: string): Promise { + return false; + } + + async mkdir(_path: string, _options?: { recursive?: boolean }): Promise {} + + async rm(_path: string, _options?: { recursive?: boolean; force?: boolean }): Promise {} +} + +async function smokeLocalCwdWrapper(): Promise { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'flue-cwd-local-'))); + const projectDir = path.join(root, 'project'); + const nestedDir = path.join(projectDir, 'nested'); + await fs.mkdir(nestedDir, { recursive: true }); + + const ctx = createTestContext('local-cwd', root); + const harness = await ctx.init({ model: false, sandbox: 'local', cwd: 'project' }); + + await harness.fs.writeFile('marker.txt', 'ok'); + assert.equal(await fs.readFile(path.join(projectDir, 'marker.txt'), 'utf8'), 'ok'); + + const pwd = await harness.shell('pwd'); + assert.equal(pwd.stdout.trim(), projectDir); + + const nestedPwd = await harness.shell('pwd', { cwd: 'nested' }); + assert.equal(nestedPwd.stdout.trim(), nestedDir); +} + +async function smokeSandboxFactoryCwdContract(): Promise { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'flue-cwd-factory-'))); + let factoryOptions: { id: string; cwd?: string } | undefined; + let createCount = 0; + + const factory: SandboxFactory = { + async createSessionEnv(options): Promise { + factoryOptions = options; + createCount += 1; + const api = new RecordingSandboxApi(); + return createSandboxSessionEnv(api, resolveSandboxCwd('/workspace', options.cwd)); + }, + }; + + const ctx = createTestContext('factory-cwd', root); + const harness = await ctx.init({ model: false, sandbox: factory, cwd: 'project' }); + + assert.deepEqual(factoryOptions, { id: 'factory-cwd', cwd: 'project' }); + assert.equal(createCount, 1); + + const pwd = await harness.shell('pwd'); + assert.equal(pwd.stdout.trim(), '/workspace/project'); +} + +assert.equal(resolveSandboxCwd('/workspace', undefined), '/workspace'); +assert.equal(resolveSandboxCwd('/workspace', 'project'), '/workspace/project'); +assert.equal(resolveSandboxCwd('/workspace', '/tmp/project'), '/tmp/project'); +assert.equal(resolveSandboxCwd('/workspace/app', '../other'), '/workspace/other'); + +await smokeLocalCwdWrapper(); +await smokeSandboxFactoryCwdContract(); diff --git a/packages/sdk/src/abort.ts b/packages/sdk/src/abort.ts index 2c19c8c1..b7487855 100644 --- a/packages/sdk/src/abort.ts +++ b/packages/sdk/src/abort.ts @@ -53,6 +53,7 @@ export function createCallHandle( abort(reason?: unknown) { controller.abort(reason); }, + // biome-ignore lint/suspicious/noThenProperty: CallHandle intentionally implements PromiseLike so callers can await it. then(onFulfilled, onRejected) { return promise.then(onFulfilled, onRejected); }, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 46cd39f2..9f8fd26c 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -123,8 +123,15 @@ export function createFlueContext(config: FlueContextConfig): FlueContextInterna try { assertRoleExists(config.agentConfig.roles, options.role); const sandbox = options.sandbox; - const baseEnv = await resolveSessionEnv(config.id, sandbox, config, options.cwd); - const env = options.cwd ? createCwdSessionEnv(baseEnv, options.cwd) : baseEnv; + const { env: baseEnv, cwdHandled } = await resolveSessionEnv( + config.id, + sandbox, + config, + options.cwd, + ); + const env = options.cwd && !cwdHandled + ? createCwdSessionEnv(baseEnv, baseEnv.resolvePath(options.cwd)) + : baseEnv; const store: SessionStore = options.persist ?? config.defaultStore; const localContext = await discoverSessionContext(env); @@ -222,21 +229,26 @@ function isSandboxFactory(value: unknown): value is SandboxFactory { ); } +interface ResolvedSessionEnv { + env: SessionEnv; + cwdHandled: boolean; +} + /** Resolve sandbox option to SessionEnv: empty → local → BashFactory → platform hook → SandboxFactory. */ async function resolveSessionEnv( id: string, sandbox: AgentInit['sandbox'], config: FlueContextConfig, cwd: string | undefined, -): Promise { +): Promise { if (sandbox === undefined || sandbox === 'empty') { - return config.createDefaultEnv(); + return { env: await config.createDefaultEnv(), cwdHandled: false }; } if (sandbox === 'local') { - return config.createLocalEnv(); + return { env: await config.createLocalEnv(), cwdHandled: false }; } if (isBashFactory(sandbox)) { - return bashFactoryToSessionEnv(sandbox); + return { env: await bashFactoryToSessionEnv(sandbox), cwdHandled: false }; } if (isBashLike(sandbox)) { throw new Error( @@ -246,10 +258,10 @@ async function resolveSessionEnv( } if (config.resolveSandbox) { const resolved = await config.resolveSandbox(sandbox); - if (resolved) return resolved; + if (resolved) return { env: resolved, cwdHandled: false }; } if (isSandboxFactory(sandbox)) { - return sandbox.createSessionEnv({ id, cwd }); + return { env: await sandbox.createSessionEnv({ id, cwd }), cwdHandled: true }; } throw new Error('[flue] Invalid sandbox option passed to init().'); } diff --git a/packages/sdk/src/cloudflare/workers-ai-provider.ts b/packages/sdk/src/cloudflare/workers-ai-provider.ts index 56ca1463..242d4086 100644 --- a/packages/sdk/src/cloudflare/workers-ai-provider.ts +++ b/packages/sdk/src/cloudflare/workers-ai-provider.ts @@ -195,8 +195,7 @@ async function* iterateSseChunks(body: ReadableStream): AsyncIterabl const decoder = new TextDecoder(); let buffer = ''; try { - // biome-ignore lint/suspicious/noConstantCondition: explicit stream loop - while (true) { + for (;;) { const { done, value } = await reader.read(); if (done) { if (buffer.trim().length > 0) { diff --git a/packages/sdk/src/config.ts b/packages/sdk/src/config.ts index 700e671d..4a48f2e3 100644 --- a/packages/sdk/src/config.ts +++ b/packages/sdk/src/config.ts @@ -166,7 +166,7 @@ export function resolveConfigPath(opts: ResolveConfigPathOptions): string | unde * Returns the raw module default — caller is responsible for validation. */ async function loadConfigModule(absConfigPath: string): Promise { - const fileUrl = pathToFileURL(absConfigPath).href + `?t=${Date.now()}`; + const fileUrl = `${pathToFileURL(absConfigPath).href}?t=${Date.now()}`; try { const mod = await import(fileUrl); return mod.default ?? mod; @@ -241,7 +241,10 @@ export async function resolveConfig(opts: ResolveConfigOptions): Promise { if (p.startsWith('/')) return normalizePath(p); - if (scopedCwd === '/') return normalizePath('/' + p); - return normalizePath(scopedCwd + '/' + p); + if (scopedCwd === '/') return normalizePath(`/${p}`); + return normalizePath(`${scopedCwd}/${p}`); }; return { exec: (cmd, opts) => parentEnv.exec(cmd, { - cwd: opts?.cwd ?? scopedCwd, + cwd: opts?.cwd ? resolvePath(opts.cwd) : scopedCwd, env: opts?.env, timeout: opts?.timeout, signal: opts?.signal, @@ -56,6 +56,18 @@ export function createCwdSessionEnv(parentEnv: SessionEnv, cwd: string): Session }; } +/** + * Resolve an optional user-provided cwd against a sandbox provider's default cwd. + * Connector factories should use this before calling createSandboxSessionEnv(). + */ +export function resolveSandboxCwd(defaultCwd: string, cwd?: string): string { + const normalizedDefault = normalizePath(defaultCwd); + if (!cwd) return normalizedDefault; + if (cwd.startsWith('/')) return normalizePath(cwd); + if (normalizedDefault === '/') return normalizePath(`/${cwd}`); + return normalizePath(`${normalizedDefault}/${cwd}`); +} + export async function bashFactoryToSessionEnv(factory: BashFactory): Promise { const bash = await factory(); assertBashLike(bash); @@ -178,8 +190,8 @@ export interface SandboxApi { export function createSandboxSessionEnv(api: SandboxApi, cwd: string): SessionEnv { const resolvePath = (p: string): string => { if (p.startsWith('/')) return normalizePath(p); - if (cwd === '/') return normalizePath('/' + p); - return normalizePath(cwd + '/' + p); + if (cwd === '/') return normalizePath(`/${p}`); + return normalizePath(`${cwd}/${p}`); }; return { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 65af7ac4..8b452cf2 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -304,8 +304,10 @@ export interface AgentInit { * / CI runner that already provides the isolation boundary. * - `BashFactory`: User-configured just-bash factory. Called once to construct the runtime. * - `SandboxFactory`: Connector-wrapped external sandbox (Daytona, CF Containers, etc.). + * - Platform sandbox object: recognized by the active target's resolver, such as + * `@cloudflare/sandbox` stubs on the Cloudflare target. */ - sandbox?: 'empty' | 'local' | SandboxFactory | BashFactory; + sandbox?: 'empty' | 'local' | SandboxFactory | BashFactory | object; /** Defaults to platform store (in-memory on Node, DO SQLite on Cloudflare). */ persist?: SessionStore; @@ -624,7 +626,13 @@ export interface ShellResult { // ─── Sandbox ──────────────────────────────────────────────────────────────── -/** Wraps external sandboxes (Daytona, CF Containers, etc.) into Flue's SessionEnv. */ +/** + * Wraps external sandboxes (Daytona, CF Containers, etc.) into Flue's SessionEnv. + * + * Flue passes AgentInit.cwd through as `cwd` when provided. Connector factories + * should resolve relative cwd values against their provider's default cwd before + * returning a SessionEnv. + */ export interface SandboxFactory { createSessionEnv(options: { id: string; cwd?: string }): Promise; } diff --git a/tsconfig.base.json b/tsconfig.base.json index 224390d3..d5d1482d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,5 +25,6 @@ // DX "preserveWatchOutput": true - } + }, + "exclude": ["dist", "node_modules"] } diff --git a/turbo.jsonc b/turbo.jsonc index dbaaf18a..09e5946d 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -18,6 +18,10 @@ "dependsOn": ["build", "^build", "^check:types"], "outputLogs": "errors-only" }, + "check:smoke": { + "dependsOn": ["build"], + "outputLogs": "errors-only" + }, "//#format:lint": {}, "//#format:style": { "dependsOn": ["format:lint"]