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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/platform-cli/src/build.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
30 changes: 18 additions & 12 deletions packages/platform-cli/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const absConfig = resolve(configPath)
const absOut = resolve(outPath)
Expand All @@ -18,18 +35,7 @@ export async function build(configPath: string, outPath: string): Promise<void>
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 })
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
13 changes: 13 additions & 0 deletions packages/platform-cli/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "../client" },
{ "path": "../sandbox-runtime" }
]
}
2 changes: 1 addition & 1 deletion packages/sandbox-runtime/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async function main() {
process.exit(1)
}

await startServer({ presets: userConfig.presets })
await startServer(userConfig)
}

main().catch((error) => {
Expand Down
8 changes: 4 additions & 4 deletions packages/sandbox-runtime/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<Config>
/** Global LLM middleware applied to all presets (prepended before preset-level middleware) */
llmMiddleware?: LLMMiddleware[]
Expand Down Expand Up @@ -56,7 +55,8 @@ export async function startServer(options: StartServerOptions): Promise<ServerHa
}))
: 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

// Reap service processes left behind by a previous agent, before any session can load
Expand Down
5 changes: 3 additions & 2 deletions packages/sandbox-runtime/src/user-config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
*/

import type { 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 { resolve } from 'node:path'
import { dirname, resolve } from 'node:path'

/**
* Load user configuration from a TypeScript file.
Expand Down Expand Up @@ -78,5 +78,6 @@ export async function loadUserConfig(configPath: string): Promise<RojConfig> {
presets,
sandboxed: typedConfig.sandboxed as boolean | undefined,
snapshotter: typedConfig.snapshotter as RojConfig['snapshotter'],
extraBinds: parseExtraBinds(typedConfig.extraBinds, dirname(absolutePath), absolutePath),
}
}
2 changes: 1 addition & 1 deletion packages/sdk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 5 additions & 3 deletions packages/sdk/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/core/sessions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
124 changes: 124 additions & 0 deletions packages/sdk/src/user-config.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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')
})
})
Loading
Loading