mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(code): cli sandboxes, enterprise timeouts, secrets projections, resolver lift, workflow exec cancellations (#6247)
* feat(code): cli sandboxes, enterprise timeouts, secrets projections, resolver lift * fix(execution): harden compatibility and secret diagnostics * fix(execution): harden generated JavaScript literals * fix(execution): align timeout cleanup semantics * fix(tables): decouple stale job cleanup * fix(execution): drain stale workflow backlog * test(sandbox): make deadline assertions timing-safe * fix(execution): lock cleanup candidate batches * fix(execution): preserve cleanup failure metrics * cancel route fixes * separate out mship template and func template * fix * fix(execution): harden secret projection and block runs * fix(workflow): validate draft execution state * run from block ui disabling * feat(copilot): expose Sim sandboxes to mothership * feat(copilot): expose sandbox capability catalog in VFS * Updates * fix legacy logs showing up * fix(copilot): keep sandbox config visible * fix model provenance issues * fix lint' * more lint * more * test(files): align provenance copy query order * consolidate migrations, rollout compat * integration projections * update skills * fix * add provenance linters * fix: address review and compatibility regressions * fix: make tool boundary audit Bun 1.3 compatible --------- Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
This commit is contained in:
co-authored by
Siddharth Ganesan
parent
5baa7a41ec
commit
117fe3137b
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { findToolRequestBoundaryViolations } from './check-tool-request-boundary'
|
||||
|
||||
describe('tool request boundary audit', () => {
|
||||
test('rejects direct and aliased ToolConfig request execution', () => {
|
||||
const violations = findToolRequestBoundaryViolations(`
|
||||
const direct = mistralParserTool.request.body(params)
|
||||
const computed = tool.request['headers'](params)
|
||||
const typedRequest = (customTool as ToolConfig).request
|
||||
const typed = typedRequest[\`method\`](params)
|
||||
const optional = tool.request?.url
|
||||
const requestConfig = customTool.request
|
||||
const url = requestConfig.url
|
||||
const { body } = requestConfig
|
||||
`)
|
||||
|
||||
expect(violations.map((violation) => violation.expression)).toEqual([
|
||||
'mistralParserTool.request.body',
|
||||
"tool.request['headers']",
|
||||
'typedRequest[\`method\`]',
|
||||
'tool.request?.url',
|
||||
'requestConfig.url',
|
||||
'body',
|
||||
])
|
||||
})
|
||||
|
||||
test('allows declarations and ordinary request objects', () => {
|
||||
expect(
|
||||
findToolRequestBoundaryViolations(`
|
||||
const tool = { request: { url: '/api/tool', headers: () => ({}) } }
|
||||
const request = options.request
|
||||
request.headers.get('authorization')
|
||||
incomingRequest.headers.get('authorization')
|
||||
`)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fails when production code reads an executable ToolConfig request member outside the canonical
|
||||
* transport. Tool definitions may declare request config, but only request-transport.ts may
|
||||
* materialize its URL, method, headers, or body. The direct-access check is intentionally
|
||||
* syntactic and zero-exception: ordinary nested request objects must first be bound to a local
|
||||
* before their wire members are read, keeping the reserved ToolConfig shape impossible to
|
||||
* reintroduce silently.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, extname, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse } from '@babel/parser'
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const APP = join(ROOT, 'apps/sim')
|
||||
const CANONICAL_TRANSPORT = join(APP, 'tools/request-transport.ts')
|
||||
const REQUEST_MEMBERS = new Set(['url', 'method', 'headers', 'body'])
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
line: number
|
||||
expression: string
|
||||
}
|
||||
|
||||
interface SyntaxNode extends Record<string, unknown> {
|
||||
type: string
|
||||
start?: number | null
|
||||
end?: number | null
|
||||
loc?: { start: { line: number } } | null
|
||||
}
|
||||
|
||||
function isProductionSource(path: string): boolean {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
return (
|
||||
SOURCE_EXTENSIONS.has(extname(path)) &&
|
||||
!normalized.endsWith('.d.ts') &&
|
||||
!/\.(?:test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/.test(normalized) &&
|
||||
!normalized.includes('/__tests__/')
|
||||
)
|
||||
}
|
||||
|
||||
function collectProductionSources(dir: string, found: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.next') {
|
||||
continue
|
||||
}
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) collectProductionSources(path, found)
|
||||
else if (isProductionSource(path)) found.push(path)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function isSyntaxNode(value: unknown): value is SyntaxNode {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function getChildNodes(node: SyntaxNode): SyntaxNode[] {
|
||||
const children: SyntaxNode[] = []
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (isSyntaxNode(item)) children.push(item)
|
||||
}
|
||||
} else if (isSyntaxNode(value)) {
|
||||
children.push(value)
|
||||
}
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
function unwrapExpression(expression: SyntaxNode): SyntaxNode {
|
||||
let current = expression
|
||||
while (
|
||||
[
|
||||
'ParenthesizedExpression',
|
||||
'TSAsExpression',
|
||||
'TSTypeAssertion',
|
||||
'TSNonNullExpression',
|
||||
'TSSatisfiesExpression',
|
||||
'TypeCastExpression',
|
||||
].includes(current.type) &&
|
||||
isSyntaxNode(current.expression)
|
||||
) {
|
||||
current = current.expression
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function getStaticMemberAccess(
|
||||
expression: SyntaxNode
|
||||
): { target: SyntaxNode; member: string } | undefined {
|
||||
const current = unwrapExpression(expression)
|
||||
if (
|
||||
(current.type === 'MemberExpression' || current.type === 'OptionalMemberExpression') &&
|
||||
isSyntaxNode(current.object) &&
|
||||
isSyntaxNode(current.property)
|
||||
) {
|
||||
const property = current.property
|
||||
if (
|
||||
current.computed === false &&
|
||||
property.type === 'Identifier' &&
|
||||
typeof property.name === 'string'
|
||||
) {
|
||||
return { target: current.object, member: property.name }
|
||||
}
|
||||
if (
|
||||
current.computed === true &&
|
||||
property.type === 'StringLiteral' &&
|
||||
typeof property.value === 'string'
|
||||
) {
|
||||
return { target: current.object, member: property.value }
|
||||
}
|
||||
if (
|
||||
current.computed === true &&
|
||||
property.type === 'TemplateLiteral' &&
|
||||
Array.isArray(property.expressions) &&
|
||||
property.expressions.length === 0 &&
|
||||
Array.isArray(property.quasis) &&
|
||||
property.quasis.length === 1 &&
|
||||
isSyntaxNode(property.quasis[0])
|
||||
) {
|
||||
const value = property.quasis[0].value
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'cooked' in value &&
|
||||
typeof value.cooked === 'string'
|
||||
) {
|
||||
return { target: current.object, member: value.cooked }
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isLikelyToolIdentifier(expression: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(expression)
|
||||
return (
|
||||
current.type === 'Identifier' &&
|
||||
typeof current.name === 'string' &&
|
||||
(current.name === 'tool' || current.name.endsWith('Tool'))
|
||||
)
|
||||
}
|
||||
|
||||
export function findToolRequestBoundaryViolations(source: string, file = 'source.ts'): Violation[] {
|
||||
const extension = extname(file)
|
||||
const syntaxTree = parse(source, {
|
||||
sourceFilename: file,
|
||||
sourceType: 'unambiguous',
|
||||
errorRecovery: true,
|
||||
plugins: [
|
||||
...(extension === '.jsx' || extension === '.tsx' ? (['jsx'] as const) : []),
|
||||
...(!['.js', '.jsx', '.mjs', '.cjs'].includes(extension) ? (['typescript'] as const) : []),
|
||||
],
|
||||
})
|
||||
const requestAliases = new Set<string>()
|
||||
const violations: Violation[] = []
|
||||
const seen = new Set<number>()
|
||||
|
||||
const report = (node: SyntaxNode) => {
|
||||
if (typeof node.start !== 'number' || typeof node.end !== 'number' || !node.loc) return
|
||||
if (seen.has(node.start)) return
|
||||
seen.add(node.start)
|
||||
violations.push({
|
||||
file,
|
||||
line: node.loc.start.line,
|
||||
expression: source.slice(node.start, node.end),
|
||||
})
|
||||
}
|
||||
|
||||
const collectAliases = (node: SyntaxNode) => {
|
||||
if (
|
||||
node.type === 'VariableDeclarator' &&
|
||||
isSyntaxNode(node.id) &&
|
||||
node.id.type === 'Identifier' &&
|
||||
typeof node.id.name === 'string' &&
|
||||
isSyntaxNode(node.init)
|
||||
) {
|
||||
const access = getStaticMemberAccess(node.init)
|
||||
if (access?.member === 'request' && isLikelyToolIdentifier(access.target)) {
|
||||
requestAliases.add(node.id.name)
|
||||
}
|
||||
}
|
||||
for (const child of getChildNodes(node)) collectAliases(child)
|
||||
}
|
||||
collectAliases(syntaxTree.program)
|
||||
|
||||
const visit = (node: SyntaxNode) => {
|
||||
if (
|
||||
node.type === 'VariableDeclarator' &&
|
||||
isSyntaxNode(node.id) &&
|
||||
node.id.type === 'ObjectPattern' &&
|
||||
isSyntaxNode(node.init)
|
||||
) {
|
||||
const sourceAccess = getStaticMemberAccess(node.init)
|
||||
const sourceIsToolRequest =
|
||||
sourceAccess?.member === 'request' && isLikelyToolIdentifier(sourceAccess.target)
|
||||
const initializer = unwrapExpression(node.init)
|
||||
const sourceIsToolRequestAlias =
|
||||
initializer.type === 'Identifier' &&
|
||||
typeof initializer.name === 'string' &&
|
||||
requestAliases.has(initializer.name)
|
||||
if (sourceIsToolRequest || sourceIsToolRequestAlias) {
|
||||
const properties = Array.isArray(node.id.properties) ? node.id.properties : []
|
||||
for (const property of properties) {
|
||||
if (
|
||||
!isSyntaxNode(property) ||
|
||||
property.type !== 'ObjectProperty' ||
|
||||
!isSyntaxNode(property.key)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const key = property.key
|
||||
const member =
|
||||
key.type === 'Identifier' && typeof key.name === 'string'
|
||||
? key.name
|
||||
: key.type === 'StringLiteral' && typeof key.value === 'string'
|
||||
? key.value
|
||||
: undefined
|
||||
if (member && REQUEST_MEMBERS.has(member)) report(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
|
||||
const access = getStaticMemberAccess(node)
|
||||
if (access && REQUEST_MEMBERS.has(access.member)) {
|
||||
const target = unwrapExpression(access.target)
|
||||
const targetAccess = getStaticMemberAccess(target)
|
||||
if (
|
||||
targetAccess?.member === 'request' ||
|
||||
(target.type === 'Identifier' &&
|
||||
typeof target.name === 'string' &&
|
||||
requestAliases.has(target.name))
|
||||
) {
|
||||
report(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of getChildNodes(node)) visit(child)
|
||||
}
|
||||
visit(syntaxTree.program)
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const violations = collectProductionSources(APP)
|
||||
.filter((file) => file !== CANONICAL_TRANSPORT)
|
||||
.flatMap((file) => findToolRequestBoundaryViolations(readFileSync(file, 'utf8'), file))
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Direct ToolConfig request execution is forbidden outside the shared transport:')
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
` ${relative(ROOT, violation.file)}:${violation.line} ${violation.expression}`
|
||||
)
|
||||
}
|
||||
console.error('\nPass the ToolConfig to prepareToolRequest from @/tools/request-transport.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('✓ production tool requests are materialized only by the shared transport')
|
||||
}
|
||||
|
||||
if (import.meta.main) main()
|
||||
@@ -547,15 +547,15 @@ export const STORAGE_SETUP = defineCapabilitySetup(STORAGE_CAPABILITY, {
|
||||
})
|
||||
|
||||
export const SANDBOX_SETUP = defineCapabilitySetup(SANDBOX_CAPABILITY, {
|
||||
label: 'Remote sandboxes',
|
||||
message: 'Remote sandbox provider?',
|
||||
label: 'Function sandboxes',
|
||||
message: 'Function sandbox provider?',
|
||||
actions: {
|
||||
disabled: {
|
||||
label: 'Disabled',
|
||||
hint: 'local JavaScript execution only',
|
||||
env: {
|
||||
NEXT_PUBLIC_E2B_ENABLED: 'false',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'false',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'false',
|
||||
},
|
||||
currentWhen: {
|
||||
kind: 'all',
|
||||
@@ -567,7 +567,7 @@ export const SANDBOX_SETUP = defineCapabilitySetup(SANDBOX_CAPABILITY, {
|
||||
},
|
||||
{
|
||||
kind: 'not',
|
||||
condition: { kind: 'present', key: 'DAYTONA_SHELL_SNAPSHOT_ID' },
|
||||
condition: { kind: 'present', key: 'DAYTONA_FUNCTION_SNAPSHOT_ID' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -575,19 +575,37 @@ export const SANDBOX_SETUP = defineCapabilitySetup(SANDBOX_CAPABILITY, {
|
||||
},
|
||||
providers: {
|
||||
e2b: {
|
||||
hint: 'remote code interpreter sandboxes',
|
||||
hint: 'dedicated Function code and CLI sandboxes',
|
||||
env: {
|
||||
NEXT_PUBLIC_E2B_ENABLED: 'true',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'true',
|
||||
},
|
||||
prompts: [{ type: 'field', key: 'E2B_API_KEY', input: 'secret', required: true }],
|
||||
prompts: [
|
||||
{ type: 'field', key: 'E2B_API_KEY', input: 'secret', required: true },
|
||||
{
|
||||
type: 'field',
|
||||
key: 'E2B_FUNCTION_TEMPLATE_ID',
|
||||
input: 'text',
|
||||
required: true,
|
||||
validate: true,
|
||||
hint: 'immutable <template>:<build-id> ref printed by the Function E2B builder',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
key: 'E2B_FUNCTION_TEMPLATE_GENERATION',
|
||||
input: 'text',
|
||||
required: true,
|
||||
validate: true,
|
||||
hint: 'release generation printed by the Function E2B builder',
|
||||
},
|
||||
],
|
||||
currentWhen: { kind: 'truthy', key: 'E2B_ENABLED' },
|
||||
},
|
||||
daytona: {
|
||||
hint: 'remote Daytona sandboxes',
|
||||
hint: 'dedicated Function code and CLI sandboxes',
|
||||
env: {
|
||||
NEXT_PUBLIC_E2B_ENABLED: 'false',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'true',
|
||||
},
|
||||
prompts: [
|
||||
{
|
||||
@@ -598,10 +616,11 @@ export const SANDBOX_SETUP = defineCapabilitySetup(SANDBOX_CAPABILITY, {
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
key: 'DAYTONA_SHELL_SNAPSHOT_ID',
|
||||
key: 'DAYTONA_FUNCTION_SNAPSHOT_ID',
|
||||
input: 'text',
|
||||
required: true,
|
||||
validate: true,
|
||||
hint: 'immutable snapshot ID printed by the Function Daytona builder',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'bun:test'
|
||||
import { OAUTH_CLIENT_CAPABILITIES } from '../../apps/sim/lib/core/config/env-capabilities.ts'
|
||||
import { buildEnvCapabilityStatus } from './capability-status.ts'
|
||||
|
||||
const DAYTONA_FUNCTION_SNAPSHOT_ID = '00000000-0000-4000-8000-000000000002'
|
||||
|
||||
describe('env capability status', () => {
|
||||
it('reports built-in defaults without treating them as configured services', () => {
|
||||
const status = buildEnvCapabilityStatus({})
|
||||
@@ -26,12 +28,12 @@ describe('env capability status', () => {
|
||||
SANDBOX_PROVIDER: 'e2b',
|
||||
E2B_ENABLED: 'false',
|
||||
NEXT_PUBLIC_E2B_ENABLED: 'false',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'false',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'false',
|
||||
})
|
||||
|
||||
expect(status.features.sandbox).toEqual({
|
||||
id: 'sandbox',
|
||||
label: 'Remote sandboxes',
|
||||
label: 'Function sandboxes',
|
||||
setupCommand: 'bun run setup sandbox',
|
||||
state: 'default',
|
||||
providerId: 'disabled',
|
||||
@@ -62,8 +64,8 @@ describe('env capability status', () => {
|
||||
const status = buildEnvCapabilityStatus({
|
||||
SANDBOX_PROVIDER: 'daytona',
|
||||
DAYTONA_API_KEY: 'daytona-secret',
|
||||
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
|
||||
DAYTONA_FUNCTION_SNAPSHOT_ID,
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'true',
|
||||
STORAGE_PROVIDER: 's3',
|
||||
AWS_REGION: 'us-east-1',
|
||||
S3_BUCKET_NAME: 'files',
|
||||
@@ -79,11 +81,11 @@ describe('env capability status', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports Daytona as missing when its default shell snapshot is absent', () => {
|
||||
it('reports Daytona as missing when its Function snapshot is absent', () => {
|
||||
const status = buildEnvCapabilityStatus({
|
||||
SANDBOX_PROVIDER: 'daytona',
|
||||
DAYTONA_API_KEY: 'daytona-secret',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'true',
|
||||
})
|
||||
|
||||
expect(status.features.sandbox).toMatchObject({
|
||||
@@ -91,15 +93,15 @@ describe('env capability status', () => {
|
||||
providerId: 'daytona',
|
||||
issue: { state: 'missing' },
|
||||
})
|
||||
expect(status.features.sandbox.issue?.message).toContain('DAYTONA_SHELL_SNAPSHOT_ID')
|
||||
expect(status.features.sandbox.issue?.message).toContain('DAYTONA_FUNCTION_SNAPSHOT_ID')
|
||||
})
|
||||
|
||||
it('reports an untagged or floating Daytona shell snapshot as invalid', () => {
|
||||
it('reports a mutable Daytona Function snapshot name as invalid', () => {
|
||||
const status = buildEnvCapabilityStatus({
|
||||
SANDBOX_PROVIDER: 'daytona',
|
||||
DAYTONA_API_KEY: 'daytona-secret',
|
||||
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:latest',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
|
||||
DAYTONA_FUNCTION_SNAPSHOT_ID: 'mothership-shell:latest',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'true',
|
||||
})
|
||||
|
||||
expect(status.features.sandbox).toMatchObject({
|
||||
@@ -107,14 +109,14 @@ describe('env capability status', () => {
|
||||
providerId: 'daytona',
|
||||
issue: { state: 'invalid' },
|
||||
})
|
||||
expect(status.features.sandbox.issue?.message).toContain('explicit, non-floating name:tag')
|
||||
expect(status.features.sandbox.issue?.message).toContain('immutable Daytona snapshot ID')
|
||||
})
|
||||
|
||||
it('reports remote sandbox server/browser drift as partial', () => {
|
||||
const status = buildEnvCapabilityStatus({
|
||||
SANDBOX_PROVIDER: 'daytona',
|
||||
DAYTONA_API_KEY: 'daytona-secret',
|
||||
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
|
||||
DAYTONA_FUNCTION_SNAPSHOT_ID,
|
||||
})
|
||||
|
||||
expect(status.features.sandbox).toMatchObject({
|
||||
@@ -122,7 +124,7 @@ describe('env capability status', () => {
|
||||
providerId: 'daytona',
|
||||
issue: { state: 'partial' },
|
||||
})
|
||||
expect(status.features.sandbox.issue?.message).toContain('NEXT_PUBLIC_SANDBOX_ENABLED')
|
||||
expect(status.features.sandbox.issue?.message).toContain('NEXT_PUBLIC_SANDBOXES_ENABLED')
|
||||
})
|
||||
|
||||
it('captures partial and invalid entries without aborting the snapshot', () => {
|
||||
|
||||
@@ -245,8 +245,10 @@ function inspectSandbox(values: EnvCapabilityValues): SandboxCapabilityStatus {
|
||||
coherenceProblems.push('E2B_ENABLED and NEXT_PUBLIC_E2B_ENABLED disagree')
|
||||
}
|
||||
const remoteAvailable = providerId !== null && providerId !== 'disabled'
|
||||
if (remoteAvailable !== isTruthyEnvCapabilityValue(values, 'NEXT_PUBLIC_SANDBOX_ENABLED')) {
|
||||
coherenceProblems.push('remote sandbox availability and NEXT_PUBLIC_SANDBOX_ENABLED disagree')
|
||||
if (remoteAvailable !== isTruthyEnvCapabilityValue(values, 'NEXT_PUBLIC_SANDBOXES_ENABLED')) {
|
||||
coherenceProblems.push(
|
||||
'remote sandbox availability and NEXT_PUBLIC_SANDBOXES_ENABLED disagree'
|
||||
)
|
||||
}
|
||||
if (coherenceProblems.length > 0) {
|
||||
return {
|
||||
|
||||
+25
-15
@@ -4,7 +4,6 @@ import {
|
||||
CORE_CONFIGURATION_KEYS,
|
||||
EMAIL_CAPABILITY,
|
||||
EnvCapabilityConfigurationError,
|
||||
hasEnvCapabilityValue,
|
||||
inspectCapability,
|
||||
inspectOAuthClientCapability,
|
||||
OAUTH_CLIENT_CAPABILITIES,
|
||||
@@ -382,25 +381,36 @@ function checkCoherence(ctx: CheckContext): Finding[] {
|
||||
})
|
||||
}
|
||||
|
||||
// NEXT_PUBLIC_SANDBOX_ENABLED is not a 1:1 twin: remote execution is available
|
||||
// under E2B_ENABLED or, when SANDBOX_PROVIDER=daytona, DAYTONA_API_KEY. Without
|
||||
// it the Function block hides its language dropdown and sandbox selector even
|
||||
// though the server would happily run Python.
|
||||
const sandboxProvider = inspectCapability(SANDBOX_CAPABILITY, sim.vars).providerId
|
||||
/**
|
||||
* Function sandbox visibility is not a 1:1 server/client twin. The selected
|
||||
* provider is ready only when its credential and immutable Function base are
|
||||
* valid, while the browser separately reads the public visibility flag.
|
||||
*/
|
||||
const sandboxInspection = inspectCapability(SANDBOX_CAPABILITY, sim.vars)
|
||||
const sandboxProvider = sandboxInspection.providerId
|
||||
const selectedSandboxProvider = sandboxInspection.providers.find(
|
||||
(provider) => provider.id === sandboxProvider
|
||||
)
|
||||
const remoteSandboxAvailable =
|
||||
sandboxProvider === 'daytona'
|
||||
? hasEnvCapabilityValue(sim.vars, 'DAYTONA_API_KEY')
|
||||
: sandboxProvider === 'e2b'
|
||||
? isTruthy(sim.vars.get('E2B_ENABLED'))
|
||||
: false
|
||||
if (remoteSandboxAvailable && !isTruthy(sim.vars.get('NEXT_PUBLIC_SANDBOX_ENABLED'))) {
|
||||
!sandboxInspection.error && selectedSandboxProvider?.state === 'ready'
|
||||
const publicSandboxEnabled = isTruthy(sim.vars.get('NEXT_PUBLIC_SANDBOXES_ENABLED'))
|
||||
if (remoteSandboxAvailable && !publicSandboxEnabled) {
|
||||
findings.push({
|
||||
group: 'coherence',
|
||||
status: 'fail',
|
||||
message:
|
||||
'remote sandboxes are configured but NEXT_PUBLIC_SANDBOX_ENABLED is unset — the Function block will hide its language and sandbox controls',
|
||||
fix: 'doctor --fix sets NEXT_PUBLIC_SANDBOX_ENABLED=true',
|
||||
autofix: () => writeEnvValues(sim.target, { NEXT_PUBLIC_SANDBOX_ENABLED: 'true' }),
|
||||
'remote sandboxes are configured but NEXT_PUBLIC_SANDBOXES_ENABLED is unset — the Function block will hide its language and sandbox controls',
|
||||
fix: 'doctor --fix sets NEXT_PUBLIC_SANDBOXES_ENABLED=true',
|
||||
autofix: () => writeEnvValues(sim.target, { NEXT_PUBLIC_SANDBOXES_ENABLED: 'true' }),
|
||||
})
|
||||
} else if (!remoteSandboxAvailable && publicSandboxEnabled) {
|
||||
findings.push({
|
||||
group: 'coherence',
|
||||
status: 'fail',
|
||||
message:
|
||||
'NEXT_PUBLIC_SANDBOXES_ENABLED is on but the selected provider lacks credentials or a valid immutable Function base — the UI exposes a runtime that will reject execution',
|
||||
fix: 'doctor --fix sets NEXT_PUBLIC_SANDBOXES_ENABLED=false; finish provider setup before enabling it',
|
||||
autofix: () => writeEnvValues(sim.target, { NEXT_PUBLIC_SANDBOXES_ENABLED: 'false' }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import { buildCapabilitySetupTransition } from './capability-setup.ts'
|
||||
import type { ConfigurationSource } from './configuration-sources.ts'
|
||||
import { reconcileLlmSetup, resolveFeatureSetupDestination } from './feature-setup.ts'
|
||||
|
||||
const E2B_FUNCTION_TEMPLATE_ID = 'sim-function:00000000-0000-4000-8000-000000000001'
|
||||
const DAYTONA_FUNCTION_SNAPSHOT_ID = '00000000-0000-4000-8000-000000000002'
|
||||
|
||||
function source(
|
||||
kind: ConfigurationSource['kind'],
|
||||
managedByCurrentCheckout: boolean,
|
||||
@@ -59,21 +62,44 @@ describe('resolveFeatureSetupDestination', () => {
|
||||
})
|
||||
|
||||
describe('sandbox capability setup', () => {
|
||||
it('requires an explicit non-floating snapshot tag', () => {
|
||||
const validate = (value: string) =>
|
||||
validateCapabilityFieldInput(SANDBOX_CAPABILITY, 'DAYTONA_SHELL_SNAPSHOT_ID', value)
|
||||
expect(validate('mothership-shell:v1')).toBeUndefined()
|
||||
expect(validate('mothership-shell')).toContain('name:tag')
|
||||
expect(validate('mothership-shell:latest')).toContain('name:tag')
|
||||
it('requires immutable Function base references', () => {
|
||||
expect(
|
||||
validateCapabilityFieldInput(
|
||||
SANDBOX_CAPABILITY,
|
||||
'DAYTONA_FUNCTION_SNAPSHOT_ID',
|
||||
DAYTONA_FUNCTION_SNAPSHOT_ID
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
validateCapabilityFieldInput(
|
||||
SANDBOX_CAPABILITY,
|
||||
'DAYTONA_FUNCTION_SNAPSHOT_ID',
|
||||
'mothership-shell:v1'
|
||||
)
|
||||
).toContain('immutable Daytona snapshot ID')
|
||||
expect(
|
||||
validateCapabilityFieldInput(
|
||||
SANDBOX_CAPABILITY,
|
||||
'E2B_FUNCTION_TEMPLATE_ID',
|
||||
E2B_FUNCTION_TEMPLATE_ID
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
validateCapabilityFieldInput(
|
||||
SANDBOX_CAPABILITY,
|
||||
'E2B_FUNCTION_TEMPLATE_ID',
|
||||
'sim-function:latest'
|
||||
)
|
||||
).toContain('immutable E2B build reference')
|
||||
})
|
||||
|
||||
it('writes Daytona API and shell snapshot configuration and disables E2B', () => {
|
||||
it('writes Daytona API and Function snapshot configuration and disables E2B', () => {
|
||||
const result = buildCapabilitySetupTransition(
|
||||
SANDBOX_SETUP,
|
||||
'daytona',
|
||||
{
|
||||
DAYTONA_API_KEY: 'daytona-key',
|
||||
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
|
||||
DAYTONA_FUNCTION_SNAPSHOT_ID,
|
||||
},
|
||||
{}
|
||||
)
|
||||
@@ -81,19 +107,28 @@ describe('sandbox capability setup', () => {
|
||||
expect(result.remove).toContain('E2B_API_KEY')
|
||||
expect(result.values).toMatchObject({
|
||||
DAYTONA_API_KEY: 'daytona-key',
|
||||
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
|
||||
DAYTONA_FUNCTION_SNAPSHOT_ID,
|
||||
E2B_ENABLED: 'false',
|
||||
NEXT_PUBLIC_E2B_ENABLED: 'false',
|
||||
NEXT_PUBLIC_SANDBOX_ENABLED: 'true',
|
||||
NEXT_PUBLIC_SANDBOXES_ENABLED: 'true',
|
||||
})
|
||||
})
|
||||
|
||||
it('removes stale Daytona configuration for E2B and disabled modes', () => {
|
||||
expect(
|
||||
buildCapabilitySetupTransition(SANDBOX_SETUP, 'e2b', { E2B_API_KEY: 'e2b-key' }, {}).remove
|
||||
).toEqual(expect.arrayContaining(['DAYTONA_API_KEY', 'DAYTONA_SHELL_SNAPSHOT_ID']))
|
||||
buildCapabilitySetupTransition(
|
||||
SANDBOX_SETUP,
|
||||
'e2b',
|
||||
{
|
||||
E2B_API_KEY: 'e2b-key',
|
||||
E2B_FUNCTION_TEMPLATE_ID,
|
||||
E2B_FUNCTION_TEMPLATE_GENERATION: '1',
|
||||
},
|
||||
{}
|
||||
).remove
|
||||
).toEqual(expect.arrayContaining(['DAYTONA_API_KEY', 'DAYTONA_FUNCTION_SNAPSHOT_ID']))
|
||||
expect(buildCapabilitySetupTransition(SANDBOX_SETUP, 'disabled', {}, {}).remove).toEqual(
|
||||
expect.arrayContaining(['DAYTONA_API_KEY', 'DAYTONA_SHELL_SNAPSHOT_ID'])
|
||||
expect.arrayContaining(['DAYTONA_API_KEY', 'DAYTONA_FUNCTION_SNAPSHOT_ID'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user