From 08908fce13cbc1c7a461d5182c78f47bd9fd5a26 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 16:48:56 +0200 Subject: [PATCH 1/9] feat(sdk): resolve the sandbox settings a RojConfig declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RojConfig` has always declared `sandboxed` and `extraBinds`, but nothing turned them into anything a session could read — the effective flag came from the preset alone. Add `applySandboxSettings(config)`, which folds the top-level values into every preset with an explicit precedence: a preset that sets `sandboxed` wins, otherwise the top-level value applies, otherwise `false`. The same order applies to `extraBinds`, which reach the shell plugin config — the only consumer of them — when that config declares none of its own. `describeSandboxPosture(presets)` renders the resolved posture as one line so a host can log it at startup. Defaults are unchanged: a config that declares nothing still resolves to `false`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/sdk/src/user-config.test.ts | 116 +++++++++++++++++++++++++++ packages/sdk/src/user-config.ts | 42 +++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/src/user-config.test.ts diff --git a/packages/sdk/src/user-config.test.ts b/packages/sdk/src/user-config.test.ts new file mode 100644 index 00000000..fa5858d2 --- /dev/null +++ b/packages/sdk/src/user-config.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'bun:test' +import type { Preset } from '~/core/preset/index.js' +import { filesystemPlugin } from '~/plugins/filesystem/index.js' +import { shellPlugin } from '~/plugins/shell/index.js' +import { createTestPreset } from '~/testing/preset-helpers.js' +import { applySandboxSettings, describeSandboxPosture, type RojConfig } from './user-config.js' + +const bind = { path: '/opt/tools', mode: 'ro' as const } +const presetBind = { path: '/srv/preset', mode: 'rw' as const } + +function preset(id: string, overrides?: Partial): Preset { + return { ...createTestPreset({ id }), ...overrides } +} + +function pluginConfigOf(result: Preset, index = 0): unknown { + return result.plugins?.[index].config +} + +describe('applySandboxSettings', () => { + describe('sandboxed precedence', () => { + it('falls back to the top-level config when the preset is silent', () => { + const config: RojConfig = { sandboxed: true, presets: [preset('a')] } + expect(applySandboxSettings(config)[0].sandboxed).toBe(true) + }) + + it('keeps an explicit preset opt-out over an enabled top-level config', () => { + const config: RojConfig = { sandboxed: true, presets: [preset('a', { sandboxed: false })] } + expect(applySandboxSettings(config)[0].sandboxed).toBe(false) + }) + + it('keeps an explicit preset opt-in over a disabled top-level config', () => { + const config: RojConfig = { sandboxed: false, presets: [preset('a', { sandboxed: true })] } + expect(applySandboxSettings(config)[0].sandboxed).toBe(true) + }) + + it('defaults to off when neither level declares anything', () => { + expect(applySandboxSettings({ presets: [preset('a')] })[0].sandboxed).toBe(false) + }) + + it('resolves each preset independently', () => { + const config: RojConfig = { + sandboxed: true, + presets: [preset('a'), preset('b', { sandboxed: false })], + } + expect(applySandboxSettings(config).map(p => p.sandboxed)).toEqual([true, false]) + }) + + it('leaves the input presets untouched', () => { + const original = preset('a') + applySandboxSettings({ sandboxed: true, presets: [original] }) + expect(original.sandboxed).toBeUndefined() + }) + }) + + describe('extraBinds precedence', () => { + it('applies the top-level binds to a shell plugin that declares none', () => { + const config: RojConfig = { + extraBinds: [bind], + presets: [preset('a', { plugins: [shellPlugin.configure({ cwd: '/tmp' })] })], + } + expect(pluginConfigOf(applySandboxSettings(config)[0])).toEqual({ cwd: '/tmp', extraBinds: [bind] }) + }) + + it('keeps the binds the shell plugin declares itself', () => { + const config: RojConfig = { + extraBinds: [bind], + presets: [preset('a', { plugins: [shellPlugin.configure({ cwd: '/tmp', extraBinds: [presetBind] })] })], + } + expect(pluginConfigOf(applySandboxSettings(config)[0])).toEqual({ cwd: '/tmp', extraBinds: [presetBind] }) + }) + + it('treats an explicit empty preset list as a declaration and keeps it', () => { + const config: RojConfig = { + extraBinds: [bind], + presets: [preset('a', { plugins: [shellPlugin.configure({ cwd: '/tmp', extraBinds: [] })] })], + } + expect(pluginConfigOf(applySandboxSettings(config)[0])).toEqual({ cwd: '/tmp', extraBinds: [] }) + }) + + it('leaves other plugins alone', () => { + const config: RojConfig = { + extraBinds: [bind], + presets: [preset('a', { plugins: [filesystemPlugin.configure({}), shellPlugin.configure({ cwd: '/tmp' })] })], + } + const result = applySandboxSettings(config)[0] + expect(pluginConfigOf(result, 0)).toEqual({}) + expect(pluginConfigOf(result, 1)).toEqual({ cwd: '/tmp', extraBinds: [bind] }) + }) + + it('is a no-op when the config declares no binds', () => { + const plugins = [shellPlugin.configure({ cwd: '/tmp' })] + const result = applySandboxSettings({ presets: [preset('a', { plugins })] })[0] + expect(result.plugins).toBe(plugins) + }) + + it('is a no-op for a preset that configures no plugins', () => { + const result = applySandboxSettings({ extraBinds: [bind], presets: [preset('a')] })[0] + expect(result.plugins).toBeUndefined() + }) + }) +}) + +describe('describeSandboxPosture', () => { + it('reports on when every preset is sandboxed', () => { + expect(describeSandboxPosture([preset('a', { sandboxed: true })])).toBe('on') + }) + + it('reports off when no preset is sandboxed', () => { + expect(describeSandboxPosture([preset('a', { sandboxed: false })])).toBe('off') + }) + + it('names both sides when presets disagree', () => { + const presets = [preset('a', { sandboxed: true }), preset('b', { sandboxed: false })] + expect(describeSandboxPosture(presets)).toBe('on for a; off for b') + }) +}) diff --git a/packages/sdk/src/user-config.ts b/packages/sdk/src/user-config.ts index bbd8a64b..0fac5231 100644 --- a/packages/sdk/src/user-config.ts +++ b/packages/sdk/src/user-config.ts @@ -5,6 +5,7 @@ * since it performs dynamic imports that require a runtime context. */ +import type { SessionPluginConfig } from '~/core/plugins/plugin-builder.js' import type { Preset } from '~/core/preset/index.js' import type { ExtraBind } from '~/plugins/shell/plugin.js' @@ -14,11 +15,11 @@ import type { ExtraBind } from '~/plugins/shell/plugin.js' export interface RojConfig { /** Base directory for sessions (default: cwd) */ sessionsDir?: string - /** Whether sandbox (bwrap) is active (default: true) */ + /** Sandbox (bwrap) posture for presets that do not set their own `sandboxed` (default: false) */ sandboxed?: boolean /** Enable snapshotter for tracking file changes (e.g. 'jj' for Jujutsu VCS) */ snapshotter?: 'jj' - /** Extra paths to bind-mount inside bwrap sandbox */ + /** Extra paths to bind-mount inside the bwrap sandbox, for presets whose shell plugin declares none */ extraBinds?: ExtraBind[] /** Presets available in this configuration */ presets: Preset[] @@ -65,3 +66,40 @@ export interface LocalResource { export function defineConfig(config: RojConfig): RojConfig { return config } + +const SHELL_PLUGIN_NAME = 'shell' + +/** + * Fold the top-level sandbox settings into every preset, so a `RojConfig` that + * declares them actually takes effect. A preset that sets its own value keeps it. + */ +export function applySandboxSettings(config: RojConfig): Preset[] { + return config.presets.map(preset => ({ + ...preset, + sandboxed: preset.sandboxed ?? config.sandboxed ?? false, + plugins: config.extraBinds?.length ? withExtraBinds(preset.plugins, config.extraBinds) : preset.plugins, + })) +} + +/** One-line summary of the resolved sandbox posture, for startup logging. */ +export function describeSandboxPosture(presets: Preset[]): string { + const on = presets.filter(p => p.sandboxed).map(p => p.id) + const off = presets.filter(p => !p.sandboxed).map(p => p.id) + if (on.length === 0) return 'off' + if (off.length === 0) return 'on' + return `on for ${on.join(', ')}; off for ${off.join(', ')}` +} + +// The shell plugin is the only consumer of extraBinds — a preset without it has nothing to bind into. +function withExtraBinds( + plugins: SessionPluginConfig[] | undefined, + extraBinds: ExtraBind[], +): SessionPluginConfig[] | undefined { + return plugins?.map(entry => { + if (entry.pluginName !== SHELL_PLUGIN_NAME) return entry + const current = entry.config + if (typeof current !== 'object' || current === null) return entry + if ('extraBinds' in current && current.extraBinds !== undefined) return entry + return { ...entry, config: { ...current, extraBinds } } + }) +} From 5ae29f243498f518a764e64076dde3a9af03a0db Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 16:49:01 +0200 Subject: [PATCH 2/9] fix(standalone-server): apply the sandbox settings the config declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader read `sandboxed` and then `main.ts` forwarded only `presets` and `localResources`, so `defineConfig({ sandboxed: true })` was accepted and silently dropped. `extraBinds` never made it out of the config file at all — the loader did not even parse it. Parse `extraBinds` (validating `path`, `mode` and `destPath` instead of trusting the shape), run the config through `applySandboxSettings` before handing the presets to the server, and log the resolved posture next to the preset list so it is visible rather than inferred. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/standalone-server/src/main.ts | 6 +- .../src/user-config-loader.test.ts | 56 +++++++++++++++++++ .../src/user-config-loader.ts | 30 ++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/standalone-server/src/user-config-loader.test.ts diff --git a/packages/standalone-server/src/main.ts b/packages/standalone-server/src/main.ts index cd34eec8..f1339f1b 100644 --- a/packages/standalone-server/src/main.ts +++ b/packages/standalone-server/src/main.ts @@ -8,6 +8,7 @@ */ import { resolve } from 'node:path' +import { applySandboxSettings, describeSandboxPosture } from '@roj-ai/sdk/user-config' import { startStandaloneServer } from './server.js' import { loadUserConfig } from './user-config-loader.js' @@ -29,8 +30,11 @@ async function main() { process.exit(1) } + const presets = applySandboxSettings(userConfig) + console.log(` Sandbox: ${describeSandboxPosture(presets)}`) + await startStandaloneServer({ - presets: userConfig.presets, + presets, localResources: userConfig.localResources, }) } diff --git a/packages/standalone-server/src/user-config-loader.test.ts b/packages/standalone-server/src/user-config-loader.test.ts new file mode 100644 index 00000000..cdc465b6 --- /dev/null +++ b/packages/standalone-server/src/user-config-loader.test.ts @@ -0,0 +1,56 @@ +import { afterAll, describe, expect, it } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadUserConfig } from './user-config-loader.js' + +const PRESET = `{ id: 'a', name: 'A', orchestrator: { system: 's', model: 'mock', tools: [], agents: [] }, agents: [] }` + +const dirs: string[] = [] + +async function writeConfig(body: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'roj-config-')) + dirs.push(dir) + const path = join(dir, 'roj.config.ts') + await writeFile(path, `export default { presets: [${PRESET}], ${body} }\n`) + return path +} + +afterAll(async () => { + await Promise.all(dirs.map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('loadUserConfig', () => { + it('returns the declared sandbox settings', async () => { + const path = await writeConfig(`sandboxed: true, extraBinds: [{ path: '/opt/tools', mode: 'ro' }]`) + const config = await loadUserConfig(path) + expect(config.sandboxed).toBe(true) + expect(config.extraBinds).toEqual([{ path: '/opt/tools', mode: 'ro', destPath: undefined }]) + }) + + it('keeps an explicit destPath', async () => { + const path = await writeConfig(`extraBinds: [{ path: '/opt/tools', mode: 'rw', destPath: '/tools' }]`) + const config = await loadUserConfig(path) + expect(config.extraBinds).toEqual([{ path: '/opt/tools', mode: 'rw', destPath: '/tools' }]) + }) + + it('leaves extraBinds undefined when the config omits them', async () => { + const config = await loadUserConfig(await writeConfig(`sandboxed: false`)) + expect(config.extraBinds).toBeUndefined() + }) + + it('rejects an unknown bind mode', async () => { + const path = await writeConfig(`extraBinds: [{ path: '/opt/tools', mode: 'rx' }]`) + await expect(loadUserConfig(path)).rejects.toThrow(/'mode' must be 'rw' or 'ro'/) + }) + + it('rejects a bind without a path', async () => { + const path = await writeConfig(`extraBinds: [{ mode: 'ro' }]`) + await expect(loadUserConfig(path)).rejects.toThrow(/missing required 'path'/) + }) + + it('rejects extraBinds that is not an array', async () => { + const path = await writeConfig(`extraBinds: { path: '/opt/tools', mode: 'ro' }`) + await expect(loadUserConfig(path)).rejects.toThrow(/'extraBinds' must be an array/) + }) +}) diff --git a/packages/standalone-server/src/user-config-loader.ts b/packages/standalone-server/src/user-config-loader.ts index 65a9ffdb..c9fa3e40 100644 --- a/packages/standalone-server/src/user-config-loader.ts +++ b/packages/standalone-server/src/user-config-loader.ts @@ -70,10 +70,40 @@ export async function loadUserConfig(configPath: string): Promise { presets, sandboxed: typedConfig.sandboxed as boolean | undefined, snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'], + extraBinds: parseExtraBinds(typedConfig.extraBinds, absolutePath), localResources, } } +type ExtraBind = NonNullable[number] + +function parseExtraBinds(raw: unknown, configPath: string): ExtraBind[] | undefined { + if (raw === undefined) return undefined + if (!Array.isArray(raw)) { + throw new Error(`'extraBinds' must be an array: ${configPath}`) + } + + const entries: unknown[] = raw + return entries.map((entry, i) => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`extraBinds[${i}] must be an object: ${configPath}`) + } + const path = 'path' in entry ? entry.path : undefined + const mode = 'mode' in entry ? entry.mode : undefined + const destPath = 'destPath' in entry ? entry.destPath : undefined + if (typeof path !== 'string' || !path) { + throw new Error(`extraBinds[${i}] missing required 'path': ${configPath}`) + } + if (mode !== 'rw' && mode !== 'ro') { + throw new Error(`extraBinds[${i}] 'mode' must be 'rw' or 'ro': ${configPath}`) + } + if (destPath !== undefined && (typeof destPath !== 'string' || !destPath)) { + throw new Error(`extraBinds[${i}] 'destPath' must be a non-empty string: ${configPath}`) + } + return { path, mode, destPath } + }) +} + function parseLocalResources(raw: unknown, configDir: string, configPath: string): LocalResource[] | undefined { if (raw === undefined) return undefined if (!Array.isArray(raw)) { From bcf0afd2a7d7a10349c39a4d6f8846e1a844abb6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 16:49:05 +0200 Subject: [PATCH 3/9] fix(sandbox-runtime): apply the sandbox settings the config declares Same gap as the standalone host: the loader read `sandboxed`, `main.ts` forwarded only `presets`, and `extraBinds` was never parsed. Parse the binds, resolve both settings through `applySandboxSettings`, and log the resolved posture at startup. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/sandbox-runtime/src/main.ts | 6 +++- .../sandbox-runtime/src/user-config-loader.ts | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/sandbox-runtime/src/main.ts b/packages/sandbox-runtime/src/main.ts index 3c665dba..f87906dc 100644 --- a/packages/sandbox-runtime/src/main.ts +++ b/packages/sandbox-runtime/src/main.ts @@ -22,6 +22,7 @@ import { resolve, dirname, join } from 'node:path' import { symlink, mkdir, rm } from 'node:fs/promises' import { createRequire } from 'node:module' +import { applySandboxSettings, describeSandboxPosture } from '@roj-ai/sdk/user-config' import { startServer } from './server.js' import { loadUserConfig } from './user-config-loader.js' @@ -63,7 +64,10 @@ async function main() { process.exit(1) } - await startServer({ presets: userConfig.presets }) + const presets = applySandboxSettings(userConfig) + console.log(` Sandbox: ${describeSandboxPosture(presets)}`) + + await startServer({ presets }) } main().catch((error) => { diff --git a/packages/sandbox-runtime/src/user-config-loader.ts b/packages/sandbox-runtime/src/user-config-loader.ts index 78cb0ffc..88fc79e5 100644 --- a/packages/sandbox-runtime/src/user-config-loader.ts +++ b/packages/sandbox-runtime/src/user-config-loader.ts @@ -78,5 +78,35 @@ export async function loadUserConfig(configPath: string): Promise { presets, sandboxed: typedConfig.sandboxed as boolean | undefined, snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'], + extraBinds: parseExtraBinds(typedConfig.extraBinds, absolutePath), } } + +type ExtraBind = NonNullable[number] + +function parseExtraBinds(raw: unknown, configPath: string): ExtraBind[] | undefined { + if (raw === undefined) return undefined + if (!Array.isArray(raw)) { + throw new Error(`'extraBinds' must be an array: ${configPath}`) + } + + const entries: unknown[] = raw + return entries.map((entry, i) => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`extraBinds[${i}] must be an object: ${configPath}`) + } + const path = 'path' in entry ? entry.path : undefined + const mode = 'mode' in entry ? entry.mode : undefined + const destPath = 'destPath' in entry ? entry.destPath : undefined + if (typeof path !== 'string' || !path) { + throw new Error(`extraBinds[${i}] missing required 'path': ${configPath}`) + } + if (mode !== 'rw' && mode !== 'ro') { + throw new Error(`extraBinds[${i}] 'mode' must be 'rw' or 'ro': ${configPath}`) + } + if (destPath !== undefined && (typeof destPath !== 'string' || !destPath)) { + throw new Error(`extraBinds[${i}] 'destPath' must be a non-empty string: ${configPath}`) + } + return { path, mode, destPath } + }) +} From 2f10f69954b48d96d2558b2f325671bdb4382692 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 19:02:11 +0200 Subject: [PATCH 4/9] refactor(sdk): fold the declared sandbox settings in bootstrap, not in each host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding the config in the host entry points made applying it something every new caller has to remember, and one already forgot: the bundle the platform CLI generates calls `startServer({ presets: config.presets })`, so a config shipped that way still lost its `sandboxed` and `extraBinds`. `bootstrap` is the choke point every host goes through, so the fold belongs there. It now resolves the presets and logs the posture it arrived at, which also gives the startup line one home instead of two. `SessionDefaults` names the slice a host must forward. Server option types extend it, so an entry point that forwards its options carries the settings whether or not its author thought about them. `extraBinds` now treats an explicit empty top-level list as a declaration, the way a preset-level one already counted — the two sides were not symmetric. `Session.environment` exposes what plugins and tools see, so a test can assert the posture a host resolved rather than the shape of an intermediate object. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/sdk/src/bootstrap.ts | 8 ++- packages/sdk/src/core/sessions/session.ts | 5 ++ packages/sdk/src/index.ts | 4 +- packages/sdk/src/user-config.test.ts | 8 +++ packages/sdk/src/user-config.ts | 74 +++++++++++++++++++---- 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/sdk/src/bootstrap.ts b/packages/sdk/src/bootstrap.ts index 1cdf84b0..3f9a76e2 100644 --- a/packages/sdk/src/bootstrap.ts +++ b/packages/sdk/src/bootstrap.ts @@ -45,7 +45,7 @@ import { sessionStatePlugin } from './plugins/session-state/plugin.js' import { resourcesPlugin } from './plugins/resources/plugin.js' import { uploadsPlugin } from './plugins/uploads/plugin.js' import { userChatPlugin } from './plugins/user-chat/plugin.js' -import type { RojConfig } from './user-config.js' +import { applySandboxSettings, describeSandboxPosture, type RojConfig } from './user-config.js' /** * All built-in plugin definitions passed to createSystem for type inference. @@ -174,8 +174,10 @@ export function bootstrap(config: Config, userConfig: RojConfig, platform: Platf const { llmProvider, llmProviders, llmLogger } = createLLMProvider(config, logger, platform) - const presets = new Map(userConfig.presets.map(p => [p.id, p])) - logger.info('Loaded presets', { count: presets.size }) + // The one place the declared sandbox settings are folded in — every host lands here. + const resolvedPresets = applySandboxSettings(userConfig) + const presets = new Map(resolvedPresets.map(p => [p.id, p])) + logger.info('Loaded presets', { count: presets.size, sandbox: describeSandboxPosture(resolvedPresets) }) const toolExecutor = new ToolExecutorImpl(logger) const dataFileStore = new SessionFileStore(config.dataPath, undefined, false, platform.fs, 'session') diff --git a/packages/sdk/src/core/sessions/session.ts b/packages/sdk/src/core/sessions/session.ts index c99a8eb2..e7e0b999 100644 --- a/packages/sdk/src/core/sessions/session.ts +++ b/packages/sdk/src/core/sessions/session.ts @@ -930,6 +930,11 @@ export class Session { } } + /** What plugins and tools see. Read-only, so a host can assert the posture it resolved. */ + get environment(): SessionEnvironment { + return this.getSessionEnvironment() + } + /** * Get session environment for tool context. */ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 49c8b738..f1ff3f76 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -29,8 +29,8 @@ export type { ConsoleLoggerConfig } from '~/lib/logger/index.js' export type { LogLevel, Logger } from '~/lib/logger/logger.js' // User config -export { defineConfig } from './user-config.js' -export type { LocalResource, RojConfig } from './user-config.js' +export { defineConfig, parseExtraBinds } from './user-config.js' +export type { LocalResource, RojConfig, SessionDefaults } from './user-config.js' // Transport adapters export { ClientAdapter, createAgentTransport, ServerAdapter } from './transport/adapter/index.js' diff --git a/packages/sdk/src/user-config.test.ts b/packages/sdk/src/user-config.test.ts index fa5858d2..c1a35b1e 100644 --- a/packages/sdk/src/user-config.test.ts +++ b/packages/sdk/src/user-config.test.ts @@ -93,6 +93,14 @@ describe('applySandboxSettings', () => { expect(result.plugins).toBe(plugins) }) + it('treats an explicit empty top-level list as a declaration, like a preset-level one', () => { + const config: RojConfig = { + extraBinds: [], + presets: [preset('a', { plugins: [shellPlugin.configure({ cwd: '/tmp' })] })], + } + expect(pluginConfigOf(applySandboxSettings(config)[0])).toEqual({ cwd: '/tmp', extraBinds: [] }) + }) + it('is a no-op for a preset that configures no plugins', () => { const result = applySandboxSettings({ extraBinds: [bind], presets: [preset('a')] })[0] expect(result.plugins).toBeUndefined() diff --git a/packages/sdk/src/user-config.ts b/packages/sdk/src/user-config.ts index 0fac5231..ede6c852 100644 --- a/packages/sdk/src/user-config.ts +++ b/packages/sdk/src/user-config.ts @@ -5,24 +5,36 @@ * since it performs dynamic imports that require a runtime context. */ +import { isAbsolute, resolve } from 'node:path' import type { SessionPluginConfig } from '~/core/plugins/plugin-builder.js' import type { Preset } from '~/core/preset/index.js' import type { ExtraBind } from '~/plugins/shell/plugin.js' +/** + * The config a host must hand to `bootstrap` for sessions to be built the way + * the user declared them. Server option types extend it, so a new entry point + * cannot quietly forward the presets alone. + */ +export interface SessionDefaults { + /** Presets available in this configuration */ + presets: Preset[] + /** Sandbox (bwrap) posture for presets that do not set their own `sandboxed` (default: false) */ + sandboxed?: boolean + /** + * Extra paths to bind-mount inside the bwrap sandbox, for presets whose shell + * plugin declares none. `path` is on the host, `destPath` inside the sandbox. + */ + extraBinds?: ExtraBind[] +} + /** * User configuration for the agent server. */ -export interface RojConfig { +export interface RojConfig extends SessionDefaults { /** Base directory for sessions (default: cwd) */ sessionsDir?: string - /** Sandbox (bwrap) posture for presets that do not set their own `sandboxed` (default: false) */ - sandboxed?: boolean /** Enable snapshotter for tracking file changes (e.g. 'jj' for Jujutsu VCS) */ snapshotter?: 'jj' - /** Extra paths to bind-mount inside the bwrap sandbox, for presets whose shell plugin declares none */ - extraBinds?: ExtraBind[] - /** Presets available in this configuration */ - presets: Preset[] /** * Local resource registry — files (typically ZIPs) on disk addressable by slug, * standing in for the platform's resource service. The standalone server reads @@ -70,14 +82,17 @@ export function defineConfig(config: RojConfig): RojConfig { const SHELL_PLUGIN_NAME = 'shell' /** - * Fold the top-level sandbox settings into every preset, so a `RojConfig` that - * declares them actually takes effect. A preset that sets its own value keeps it. + * Fold the top-level sandbox settings into every preset, so a config that + * declares them takes effect. A preset that sets its own value keeps it. + * + * `bootstrap` calls this, and every host goes through `bootstrap` — an entry + * point should forward its `SessionDefaults` rather than fold them itself. */ -export function applySandboxSettings(config: RojConfig): Preset[] { - return config.presets.map(preset => ({ +export function applySandboxSettings(settings: SessionDefaults): Preset[] { + return settings.presets.map(preset => ({ ...preset, - sandboxed: preset.sandboxed ?? config.sandboxed ?? false, - plugins: config.extraBinds?.length ? withExtraBinds(preset.plugins, config.extraBinds) : preset.plugins, + sandboxed: preset.sandboxed ?? settings.sandboxed ?? false, + plugins: settings.extraBinds !== undefined ? withExtraBinds(preset.plugins, settings.extraBinds) : preset.plugins, })) } @@ -90,6 +105,39 @@ export function describeSandboxPosture(presets: Preset[]): string { return `on for ${on.join(', ')}; off for ${off.join(', ')}` } +/** + * Validate the `extraBinds` of a config file. A relative host `path` resolves + * against the config directory, the way `localResources` does. `destPath` names + * a location inside the sandbox, so it has nothing to resolve against and must + * already be absolute. + */ +export function parseExtraBinds(raw: unknown, configDir: string, configPath: string): ExtraBind[] | undefined { + if (raw === undefined) return undefined + if (!Array.isArray(raw)) { + throw new Error(`'extraBinds' must be an array: ${configPath}`) + } + + const entries: unknown[] = raw + return entries.map((entry, i) => { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`extraBinds[${i}] must be an object: ${configPath}`) + } + const path = 'path' in entry ? entry.path : undefined + const mode = 'mode' in entry ? entry.mode : undefined + const destPath = 'destPath' in entry ? entry.destPath : undefined + if (typeof path !== 'string' || !path) { + throw new Error(`extraBinds[${i}] missing required 'path': ${configPath}`) + } + if (mode !== 'rw' && mode !== 'ro') { + throw new Error(`extraBinds[${i}] 'mode' must be 'rw' or 'ro': ${configPath}`) + } + if (destPath !== undefined && (typeof destPath !== 'string' || !isAbsolute(destPath))) { + throw new Error(`extraBinds[${i}] 'destPath' must be an absolute path inside the sandbox: ${configPath}`) + } + return { path: isAbsolute(path) ? path : resolve(configDir, path), mode, destPath } + }) +} + // The shell plugin is the only consumer of extraBinds — a preset without it has nothing to bind into. function withExtraBinds( plugins: SessionPluginConfig[] | undefined, From eed4a805a043106c4fc639b57039119bf87f3e7d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 19:02:16 +0200 Subject: [PATCH 5/9] fix(standalone-server): hand the loaded config to the server whole `main.ts` picked fields out of the loaded config, so anything it did not name was dropped; `server.ts` then rebuilt a config of just `{ presets }` for bootstrap, dropping them a second time. Both now forward what they were given. The loader drops its private copy of the bind parser for the shared one, which also resolves a relative `path` against the config directory the way `localResources` does. A relative path used to pass validation and then be resolved against the server's working directory instead. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/standalone-server/src/main.ts | 9 +---- packages/standalone-server/src/server.ts | 8 ++--- .../src/user-config-loader.test.ts | 13 +++++++- .../src/user-config-loader.ts | 33 ++----------------- 4 files changed, 19 insertions(+), 44 deletions(-) diff --git a/packages/standalone-server/src/main.ts b/packages/standalone-server/src/main.ts index f1339f1b..370efc01 100644 --- a/packages/standalone-server/src/main.ts +++ b/packages/standalone-server/src/main.ts @@ -8,7 +8,6 @@ */ import { resolve } from 'node:path' -import { applySandboxSettings, describeSandboxPosture } from '@roj-ai/sdk/user-config' import { startStandaloneServer } from './server.js' import { loadUserConfig } from './user-config-loader.js' @@ -30,13 +29,7 @@ async function main() { process.exit(1) } - const presets = applySandboxSettings(userConfig) - console.log(` Sandbox: ${describeSandboxPosture(presets)}`) - - await startStandaloneServer({ - presets, - localResources: userConfig.localResources, - }) + await startStandaloneServer(userConfig) } main().catch((error) => { diff --git a/packages/standalone-server/src/server.ts b/packages/standalone-server/src/server.ts index ed005e74..32c64633 100644 --- a/packages/standalone-server/src/server.ts +++ b/packages/standalone-server/src/server.ts @@ -12,7 +12,7 @@ * GET /health — health check */ -import type { Config, LLMMiddleware, LocalResource, Logger, Preset, SessionId, SessionManager } from '@roj-ai/sdk' +import type { Config, LLMMiddleware, LocalResource, Logger, SessionDefaults, SessionId, SessionManager } from '@roj-ai/sdk' import { bootstrap, createSystemFromServices, loadConfig, validateConfig } from '@roj-ai/sdk' import { createApp } from '@roj-ai/sdk/transport/http/app' import { createAgentTransport, ServerAdapter } from '@roj-ai/sdk/transport/adapter' @@ -29,8 +29,7 @@ import { proxyPreview } from './preview-proxy.js' import { createSessionFileRoute } from './session-file-route.js' import { generateTokenSecret } from './signed-token.js' -export interface StartStandaloneOptions { - presets: Preset[] +export interface StartStandaloneOptions extends SessionDefaults { config?: Partial instanceId?: string instanceName?: string @@ -85,7 +84,8 @@ export async function startStandaloneServer(options: StartStandaloneOptions): Pr })) : options.presets - const services = bootstrap(config, { presets }, createBunPlatform()) + // Forwarded whole: bootstrap folds the SessionDefaults, so nothing is dropped here. + const services = bootstrap(config, { ...options, presets }, createBunPlatform()) const { logger } = services warnIfStandaloneExposed(config.host, logger) diff --git a/packages/standalone-server/src/user-config-loader.test.ts b/packages/standalone-server/src/user-config-loader.test.ts index cdc465b6..0fbaa345 100644 --- a/packages/standalone-server/src/user-config-loader.test.ts +++ b/packages/standalone-server/src/user-config-loader.test.ts @@ -1,7 +1,7 @@ import { afterAll, describe, expect, it } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { loadUserConfig } from './user-config-loader.js' const PRESET = `{ id: 'a', name: 'A', orchestrator: { system: 's', model: 'mock', tools: [], agents: [] }, agents: [] }` @@ -39,6 +39,17 @@ describe('loadUserConfig', () => { expect(config.extraBinds).toBeUndefined() }) + it('resolves a relative bind path against the config directory', async () => { + const path = await writeConfig(`extraBinds: [{ path: './shared', mode: 'ro' }]`) + const config = await loadUserConfig(path) + expect(config.extraBinds?.[0].path).toBe(join(dirname(path), 'shared')) + }) + + it('rejects a relative destPath, which has nothing to resolve against', async () => { + const path = await writeConfig(`extraBinds: [{ path: '/opt/tools', mode: 'ro', destPath: './tools' }]`) + await expect(loadUserConfig(path)).rejects.toThrow(/'destPath' must be an absolute path/) + }) + it('rejects an unknown bind mode', async () => { const path = await writeConfig(`extraBinds: [{ path: '/opt/tools', mode: 'rx' }]`) await expect(loadUserConfig(path)).rejects.toThrow(/'mode' must be 'rw' or 'ro'/) diff --git a/packages/standalone-server/src/user-config-loader.ts b/packages/standalone-server/src/user-config-loader.ts index c9fa3e40..42cc3fe5 100644 --- a/packages/standalone-server/src/user-config-loader.ts +++ b/packages/standalone-server/src/user-config-loader.ts @@ -6,7 +6,7 @@ */ import type { LocalResource, Preset, RojConfig } from '@roj-ai/sdk' -import { validatePreset } from '@roj-ai/sdk' +import { parseExtraBinds, validatePreset } from '@roj-ai/sdk' import { existsSync } from 'node:fs' import { dirname, isAbsolute, resolve } from 'node:path' @@ -70,40 +70,11 @@ export async function loadUserConfig(configPath: string): Promise { presets, sandboxed: typedConfig.sandboxed as boolean | undefined, snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'], - extraBinds: parseExtraBinds(typedConfig.extraBinds, absolutePath), + extraBinds: parseExtraBinds(typedConfig.extraBinds, configDir, absolutePath), localResources, } } -type ExtraBind = NonNullable[number] - -function parseExtraBinds(raw: unknown, configPath: string): ExtraBind[] | undefined { - if (raw === undefined) return undefined - if (!Array.isArray(raw)) { - throw new Error(`'extraBinds' must be an array: ${configPath}`) - } - - const entries: unknown[] = raw - return entries.map((entry, i) => { - if (typeof entry !== 'object' || entry === null) { - throw new Error(`extraBinds[${i}] must be an object: ${configPath}`) - } - const path = 'path' in entry ? entry.path : undefined - const mode = 'mode' in entry ? entry.mode : undefined - const destPath = 'destPath' in entry ? entry.destPath : undefined - if (typeof path !== 'string' || !path) { - throw new Error(`extraBinds[${i}] missing required 'path': ${configPath}`) - } - if (mode !== 'rw' && mode !== 'ro') { - throw new Error(`extraBinds[${i}] 'mode' must be 'rw' or 'ro': ${configPath}`) - } - if (destPath !== undefined && (typeof destPath !== 'string' || !destPath)) { - throw new Error(`extraBinds[${i}] 'destPath' must be a non-empty string: ${configPath}`) - } - return { path, mode, destPath } - }) -} - function parseLocalResources(raw: unknown, configDir: string, configPath: string): LocalResource[] | undefined { if (raw === undefined) return undefined if (!Array.isArray(raw)) { From a3cdb0d25efc907d5fa651c941859c69d7634a3a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 19:02:20 +0200 Subject: [PATCH 6/9] fix(sandbox-runtime): hand the loaded config to the server whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same two drops as the standalone host — `main.ts` naming fields and `server.ts` rebuilding a `{ presets }` config for bootstrap — and the same move to the shared bind parser. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/sandbox-runtime/src/main.ts | 6 +--- packages/sandbox-runtime/src/server.ts | 8 ++--- .../sandbox-runtime/src/user-config-loader.ts | 34 ++----------------- 3 files changed, 8 insertions(+), 40 deletions(-) diff --git a/packages/sandbox-runtime/src/main.ts b/packages/sandbox-runtime/src/main.ts index f87906dc..6a0f0f05 100644 --- a/packages/sandbox-runtime/src/main.ts +++ b/packages/sandbox-runtime/src/main.ts @@ -22,7 +22,6 @@ import { resolve, dirname, join } from 'node:path' import { symlink, mkdir, rm } from 'node:fs/promises' import { createRequire } from 'node:module' -import { applySandboxSettings, describeSandboxPosture } from '@roj-ai/sdk/user-config' import { startServer } from './server.js' import { loadUserConfig } from './user-config-loader.js' @@ -64,10 +63,7 @@ async function main() { process.exit(1) } - const presets = applySandboxSettings(userConfig) - console.log(` Sandbox: ${describeSandboxPosture(presets)}`) - - await startServer({ presets }) + await startServer(userConfig) } main().catch((error) => { diff --git a/packages/sandbox-runtime/src/server.ts b/packages/sandbox-runtime/src/server.ts index 0b8d9897..a42a4dd5 100644 --- a/packages/sandbox-runtime/src/server.ts +++ b/packages/sandbox-runtime/src/server.ts @@ -5,7 +5,7 @@ * HTTP app, Bun.serve, session loading, and shutdown. */ -import type { Config, LLMMiddleware, Logger, Preset, SessionId } from '@roj-ai/sdk' +import type { Config, LLMMiddleware, Logger, SessionDefaults, SessionId } from '@roj-ai/sdk' import { bootstrap, createSystemFromServices, loadConfig, validateConfig } from '@roj-ai/sdk' import { type AppEnv, createApp } from '@roj-ai/sdk/transport/http/app' import { createAgentTransport, type IAgentTransport, ServerAdapter } from '@roj-ai/sdk/transport/adapter' @@ -18,8 +18,7 @@ import { SANDBOX_RUNTIME_NAME, SANDBOX_RUNTIME_VERSION } from './info.js' // Public types // ============================================================================ -export interface StartServerOptions { - presets: Preset[] +export interface StartServerOptions extends SessionDefaults { config?: Partial /** Global LLM middleware applied to all presets (prepended before preset-level middleware) */ llmMiddleware?: LLMMiddleware[] @@ -56,7 +55,8 @@ export async function startServer(options: StartServerOptions): Promise { presets, sandboxed: typedConfig.sandboxed as boolean | undefined, snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'], - extraBinds: parseExtraBinds(typedConfig.extraBinds, absolutePath), + extraBinds: parseExtraBinds(typedConfig.extraBinds, dirname(absolutePath), absolutePath), } } -type ExtraBind = NonNullable[number] - -function parseExtraBinds(raw: unknown, configPath: string): ExtraBind[] | undefined { - if (raw === undefined) return undefined - if (!Array.isArray(raw)) { - throw new Error(`'extraBinds' must be an array: ${configPath}`) - } - - const entries: unknown[] = raw - return entries.map((entry, i) => { - if (typeof entry !== 'object' || entry === null) { - throw new Error(`extraBinds[${i}] must be an object: ${configPath}`) - } - const path = 'path' in entry ? entry.path : undefined - const mode = 'mode' in entry ? entry.mode : undefined - const destPath = 'destPath' in entry ? entry.destPath : undefined - if (typeof path !== 'string' || !path) { - throw new Error(`extraBinds[${i}] missing required 'path': ${configPath}`) - } - if (mode !== 'rw' && mode !== 'ro') { - throw new Error(`extraBinds[${i}] 'mode' must be 'rw' or 'ro': ${configPath}`) - } - if (destPath !== undefined && (typeof destPath !== 'string' || !destPath)) { - throw new Error(`extraBinds[${i}] 'destPath' must be a non-empty string: ${configPath}`) - } - return { path, mode, destPath } - }) -} From 0c53116de1acf49fc8957b4d4407088406ee6691 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 19:02:26 +0200 Subject: [PATCH 7/9] fix(platform-cli): give the self-contained bundle the whole config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated entry called `startServer({ presets: config.presets })`, so a bundle uploaded through the CLI ran with everything else the user declared thrown away — the third and last entry point with the drop. The entry module is now a named function rather than a string built inline, so a test can hold it to that. Test files leave the build output, and the new test project joins the type-check step. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- .github/workflows/ci.yml | 1 + packages/platform-cli/src/build.test.ts | 16 +++++++++++++ packages/platform-cli/src/build.ts | 30 ++++++++++++++---------- packages/platform-cli/tsconfig.json | 2 +- packages/platform-cli/tsconfig.test.json | 13 ++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 packages/platform-cli/src/build.test.ts create mode 100644 packages/platform-cli/tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05473214..b27f0563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: run: | bunx tsc -p packages/sdk/tsconfig.test.json --noEmit bunx tsc -p packages/standalone-server/tsconfig.test.json --noEmit + bunx tsc -p packages/platform-cli/tsconfig.test.json --noEmit - name: Run tests run: bun run test diff --git a/packages/platform-cli/src/build.test.ts b/packages/platform-cli/src/build.test.ts new file mode 100644 index 00000000..47e64a98 --- /dev/null +++ b/packages/platform-cli/src/build.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test' +import { entrySource } from './build.js' + +describe('bundle entry source', () => { + it('hands the whole config to startServer, not just its presets', () => { + const source = entrySource('/app/roj.config.ts', false) + expect(source).toContain('startServer(config)') + expect(source).not.toContain('config.presets') + }) + + it('re-exports the config untouched for an external-SDK bundle', () => { + const source = entrySource('/app/roj.config.ts', true) + expect(source).toContain("import config from '/app/roj.config.ts'") + expect(source).toContain('export default config') + }) +}) diff --git a/packages/platform-cli/src/build.ts b/packages/platform-cli/src/build.ts index 8ec5e690..e439b09c 100644 --- a/packages/platform-cli/src/build.ts +++ b/packages/platform-cli/src/build.ts @@ -7,6 +7,23 @@ interface BundleMetadata { lockMinor?: boolean } +/** + * Entry module the bundle is built from. The self-contained one hands the whole + * config to `startServer` — passing `presets` alone would drop everything else + * the user declared. + */ +export function entrySource(absConfigPath: string, isExternal: boolean): string { + if (isExternal) return `import config from '${absConfigPath}'\nexport default config\n` + return ` +import config from '${absConfigPath}' +import { startServer } from '@roj-ai/sandbox-runtime/server' +startServer(config).catch((err) => { + console.error('Fatal:', err) + process.exit(1) +}) +` +} + export async function build(configPath: string, outPath: string): Promise { const absConfig = resolve(configPath) const absOut = resolve(outPath) @@ -18,18 +35,7 @@ export async function build(configPath: string, outPath: string): Promise const lockMinor = userConfig?.runtime?.lockMinor ?? true const entryPath = join(configDir, '.roj-entry.ts') - const entrySource = isExternal - ? `import config from '${absConfig}'\nexport default config\n` - : ` -import config from '${absConfig}' -import { startServer } from '@roj-ai/sandbox-runtime/server' -startServer({ presets: config.presets }).catch((err) => { - console.error('Fatal:', err) - process.exit(1) -}) -` - - await writeFile(entryPath, entrySource) + await writeFile(entryPath, entrySource(absConfig, isExternal)) try { await mkdir(dirname(absOut), { recursive: true }) diff --git a/packages/platform-cli/tsconfig.json b/packages/platform-cli/tsconfig.json index 1561dd94..946bc942 100644 --- a/packages/platform-cli/tsconfig.json +++ b/packages/platform-cli/tsconfig.json @@ -7,7 +7,7 @@ "types": ["@types/bun"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "references": [ { "path": "../client" }, { "path": "../sandbox-runtime" } diff --git a/packages/platform-cli/tsconfig.test.json b/packages/platform-cli/tsconfig.test.json new file mode 100644 index 00000000..34250d55 --- /dev/null +++ b/packages/platform-cli/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../client" }, + { "path": "../sandbox-runtime" } + ] +} From 5ec86bb71818cc483865ff738400bf0728a77f09 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 28 Aug 2026 19:02:33 +0200 Subject: [PATCH 8/9] test(standalone-server): pin the sandbox settings to a running host The resolver had unit coverage, but nothing ran a host: reverting the entry points, or hard-coding the flag at the one place a session reads it, left the suite green. Two tests that do run one. `server.test.ts` starts the server and reads the posture off the session it creates. `main.test.ts` boots the CLI as a subprocess and reads the posture off its startup log, which is the only cover for the entry point itself. Each kills a mutant that used to survive: dropping the config in `main.ts`, in `server.ts`, or in `bootstrap`, and hard-coding `sandboxed: false` in `Session.getSessionEnvironment`. The tests directory was outside the type-check project and is now inside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/standalone-server/tests/main.test.ts | 83 +++++++++++++++++++ .../standalone-server/tests/server.test.ts | 68 ++++++++++++++- packages/standalone-server/tsconfig.test.json | 5 +- 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 packages/standalone-server/tests/main.test.ts diff --git a/packages/standalone-server/tests/main.test.ts b/packages/standalone-server/tests/main.test.ts new file mode 100644 index 00000000..6f9c6535 --- /dev/null +++ b/packages/standalone-server/tests/main.test.ts @@ -0,0 +1,83 @@ +import { afterAll, describe, expect, it } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { z } from 'zod/v4' + +const MAIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'main.ts') + +const PRESET = `{ id: 'probe', name: 'Probe', orchestrator: { system: 's', model: 'mock', tools: [], agents: [] }, agents: [] }` + +const LoadedPresetsSchema = z.object({ message: z.literal('Loaded presets'), sandbox: z.string() }) + +const dirs: string[] = [] + +afterAll(async () => { + await Promise.all(dirs.map(dir => rm(dir, { recursive: true, force: true }))) +}) + +/** Boot the CLI against a config file and report the posture it logs. */ +async function loggedSandboxPosture(configBody: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'roj-standalone-main-')) + dirs.push(dir) + const configPath = join(dir, 'roj.config.ts') + await writeFile(configPath, `export default { presets: [${PRESET}], ${configBody} }\n`) + + const proc = Bun.spawn(['bun', MAIN, configPath], { + env: { + ...process.env, + PORT: '0', + HOST: '127.0.0.1', + PERSISTENCE: 'memory', + DATA_PATH: join(dir, 'data'), + ANTHROPIC_API_KEY: 'test-key-not-used', + LOG_FORMAT: 'json', + LOG_LEVEL: 'info', + }, + stdout: 'pipe', + stderr: 'pipe', + }) + + try { + const line = await readLine(proc.stdout, /"message":"Loaded presets"/, 20_000) + return LoadedPresetsSchema.parse(JSON.parse(line)).sandbox + } finally { + proc.kill() + await proc.exited + } +} + +async function readLine(stream: ReadableStream, match: RegExp, timeoutMs: number): Promise { + const decoder = new TextDecoder() + const reader = stream.getReader() + let buffer = '' + const deadline = Date.now() + timeoutMs + + try { + while (Date.now() < deadline) { + const next = await Promise.race([ + reader.read(), + Bun.sleep(deadline - Date.now()).then(() => 'timeout' as const), + ]) + if (next === 'timeout') break + if (next.done) break + buffer += decoder.decode(next.value, { stream: true }) + const line = buffer.split('\n').find(l => match.test(l)) + if (line) return line + } + } finally { + reader.cancel().catch(() => {}) + } + throw new Error(`No line matching ${match} within ${timeoutMs}ms. Output:\n${buffer}`) +} + +describe('standalone CLI', () => { + it('boots with the sandbox the config declares', async () => { + expect(await loggedSandboxPosture(`sandboxed: true`)).toBe('on') + }, 30_000) + + it('boots unsandboxed when the config declares nothing', async () => { + expect(await loggedSandboxPosture(`sandboxed: undefined`)).toBe('off') + }, 30_000) +}) diff --git a/packages/standalone-server/tests/server.test.ts b/packages/standalone-server/tests/server.test.ts index 44f1370c..52d9737c 100644 --- a/packages/standalone-server/tests/server.test.ts +++ b/packages/standalone-server/tests/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { ModelId } from '@roj-ai/sdk' +import { ModelId, type Preset } from '@roj-ai/sdk' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,6 +8,7 @@ import { isLoopbackHost, resolveStandaloneHost, startStandaloneServer, + type StartStandaloneOptions, warnIfStandaloneExposed, } from '../src/server.js' @@ -115,3 +116,68 @@ describe('standalone network boundary', () => { } }, 15_000) }) + +// ========================================================================= +// Sandbox settings reaching a running host +// ========================================================================= + +/** Minimal preset; `sandboxed` left absent unless a case declares it. */ +function probePreset(sandboxed?: boolean): Preset { + return { + id: 'probe', + name: 'Probe', + sandboxed, + orchestrator: { system: 'Test orchestrator', model: ModelId('mock'), tools: [], agents: [] }, + agents: [], + } +} + +/** Start a real host from the given config and report the posture its sessions get. */ +async function sessionSandboxedUnder(options: Omit): Promise { + const dataPath = await mkdtemp(join(tmpdir(), 'roj-standalone-sandbox-')) + let handle: Awaited> | undefined + try { + handle = await startStandaloneServer({ + ...options, + config: { + port: 0, + host: '127.0.0.1', + dataPath, + persistence: 'memory', + logLevel: 'error', + logFormat: 'console', + llmMock: () => ({ + content: 'unused', + toolCalls: [], + finishReason: 'stop', + metrics: { promptTokens: 0, completionTokens: 0, totalTokens: 0, latencyMs: 0, model: 'mock' }, + }), + }, + }) + + const created = await handle.sessionManager.createSession('probe') + if (!created.ok) throw new Error(`createSession failed: ${JSON.stringify(created.error)}`) + return created.value.environment.sandboxed + } finally { + await handle?.shutdown() + await rm(dataPath, { recursive: true, force: true }) + } +} + +describe('sandbox settings reach the session', () => { + it('applies the top-level sandboxed flag to a preset that is silent', async () => { + expect(await sessionSandboxedUnder({ presets: [probePreset()], sandboxed: true })).toBe(true) + }, 15_000) + + it('leaves sessions unsandboxed when nothing declares it', async () => { + expect(await sessionSandboxedUnder({ presets: [probePreset()] })).toBe(false) + }, 15_000) + + it('lets a preset opt out of an enabled top-level flag', async () => { + expect(await sessionSandboxedUnder({ presets: [probePreset(false)], sandboxed: true })).toBe(false) + }, 15_000) + + it('lets a preset opt in when the top-level flag is off', async () => { + expect(await sessionSandboxedUnder({ presets: [probePreset(true)], sandboxed: false })).toBe(true) + }, 15_000) +}) diff --git a/packages/standalone-server/tsconfig.test.json b/packages/standalone-server/tsconfig.test.json index e9818f9b..3105df2b 100644 --- a/packages/standalone-server/tsconfig.test.json +++ b/packages/standalone-server/tsconfig.test.json @@ -2,9 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "composite": false, - "noEmit": true + "noEmit": true, + "rootDir": "." }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"], "references": [ { "path": "../sdk" }, From f9764bb298fbad9eacde16001bbbb987cd5189b3 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 31 Aug 2026 14:13:41 +0200 Subject: [PATCH 9/9] docs(sdk): document the top-level extraBinds and how it folds into presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field reached the shell plugin but appeared in neither the config-levels list nor the sandbox reference, so the precedence — a preset's own extraBinds replace it rather than merging — was only visible in the code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM --- packages/sandbox-runtime/src/user-config-loader.ts | 1 - packages/sdk/CLAUDE.md | 2 +- skills/roj/references/paths-and-sandbox.md | 2 ++ 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sandbox-runtime/src/user-config-loader.ts b/packages/sandbox-runtime/src/user-config-loader.ts index f1bd9654..00fd70cd 100644 --- a/packages/sandbox-runtime/src/user-config-loader.ts +++ b/packages/sandbox-runtime/src/user-config-loader.ts @@ -81,4 +81,3 @@ export async function loadUserConfig(configPath: string): Promise { extraBinds: parseExtraBinds(typedConfig.extraBinds, dirname(absolutePath), absolutePath), } } - diff --git a/packages/sdk/CLAUDE.md b/packages/sdk/CLAUDE.md index 22bbcf72..fa9eb10d 100644 --- a/packages/sdk/CLAUDE.md +++ b/packages/sdk/CLAUDE.md @@ -89,5 +89,5 @@ await harness.shutdown() ## Config Levels 1. **System config** (`config.ts`): env vars — port, API keys, persistence mode, log format -2. **User config** (`roj.config.ts`): `defineConfig({ presets, sandboxed, snapshotter })` +2. **User config** (`roj.config.ts`): `defineConfig({ presets, sandboxed, extraBinds, snapshotter })` — `sandboxed` and `extraBinds` fold into every preset 3. **Plugin config**: per-preset (`pluginConfig`) and per-agent (`agentConfig`) overrides diff --git a/skills/roj/references/paths-and-sandbox.md b/skills/roj/references/paths-and-sandbox.md index 3df8ebfa..758c3f55 100644 --- a/skills/roj/references/paths-and-sandbox.md +++ b/skills/roj/references/paths-and-sandbox.md @@ -97,6 +97,8 @@ shellPlugin.configure(shell) | `env` | Extra env vars merged into the command's environment. | | `defaultEnabled` | Default `true`. **Every agent in the preset gets `run_command` unless explicitly disabled** with `shellPlugin.configureAgent({ enabled: false })`. `tools: []` on the agent definition does not opt the agent out. | +`extraBinds` can also be declared top-level in `roj.config.ts`, where it reaches every preset's shell plugin. A preset that declares its own keeps them — the two lists replace, they do not merge. + ### When you need extraBinds | Use case | Bind |