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" } + ] +} diff --git a/packages/sandbox-runtime/src/main.ts b/packages/sandbox-runtime/src/main.ts index 3c665dba..6a0f0f05 100644 --- a/packages/sandbox-runtime/src/main.ts +++ b/packages/sandbox-runtime/src/main.ts @@ -63,7 +63,7 @@ async function main() { process.exit(1) } - await startServer({ presets: userConfig.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, 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/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 new file mode 100644 index 00000000..c1a35b1e --- /dev/null +++ b/packages/sdk/src/user-config.test.ts @@ -0,0 +1,124 @@ +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('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() + }) + }) +}) + +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..ede6c852 100644 --- a/packages/sdk/src/user-config.ts +++ b/packages/sdk/src/user-config.ts @@ -5,23 +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 - /** Whether sandbox (bwrap) is active (default: true) */ - sandboxed?: boolean /** Enable snapshotter for tracking file changes (e.g. 'jj' for Jujutsu VCS) */ snapshotter?: 'jj' - /** Extra paths to bind-mount inside bwrap sandbox */ - 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 @@ -65,3 +78,76 @@ 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 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(settings: SessionDefaults): Preset[] { + return settings.presets.map(preset => ({ + ...preset, + sandboxed: preset.sandboxed ?? settings.sandboxed ?? false, + plugins: settings.extraBinds !== undefined ? withExtraBinds(preset.plugins, settings.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(', ')}` +} + +/** + * 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, + 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 } } + }) +} diff --git a/packages/standalone-server/src/main.ts b/packages/standalone-server/src/main.ts index cd34eec8..370efc01 100644 --- a/packages/standalone-server/src/main.ts +++ b/packages/standalone-server/src/main.ts @@ -29,10 +29,7 @@ async function main() { process.exit(1) } - await startStandaloneServer({ - presets: userConfig.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 new file mode 100644 index 00000000..0fbaa345 --- /dev/null +++ b/packages/standalone-server/src/user-config-loader.test.ts @@ -0,0 +1,67 @@ +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 { 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('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'/) + }) + + 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..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,6 +70,7 @@ export async function loadUserConfig(configPath: string): Promise { presets, sandboxed: typedConfig.sandboxed as boolean | undefined, snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'], + extraBinds: parseExtraBinds(typedConfig.extraBinds, configDir, absolutePath), localResources, } } 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" }, 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 |