Skip to content

Commit f34d649

Browse files
authored
feat(gates): runtime + CI gates for the five incident classes (#190)
- provision-payload size gate (240KB, per-section breakdown) before the sandbox create POST in ensureWorkspaceSandbox - env-var size gate at the same choke point (120KB/entry, 200KB total; E2BIG class) - composed-systemPrompt byte budget in composeAgentProfile (default 40KB, warnOnly escape hatch, top-3 section report) - chat-stream error events fail loud when no onErrorEvent is wired: synthesized into the transcript text channel + console.error - generalize the catalog browser-safe walker into a declared manifest of 14 browser subpaths (tests/browser-safe-subpaths.test.ts)
1 parent 6af5888 commit f34d649

8 files changed

Lines changed: 488 additions & 45 deletions

File tree

src/profile/index.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi } from 'vitest'
22
import type {
33
AgentProfile,
44
AgentProfileFileMount,
55
AgentProfileMcpServer,
66
} from '@tangle-network/sandbox'
77
import {
8+
assertSystemPromptWithinBudget,
89
composeAgentProfile,
10+
DEFAULT_MAX_SYSTEM_PROMPT_BYTES,
11+
largestPromptSections,
912
makeEvolvableSection,
1013
stripComments,
1114
userSkillMounts,
@@ -217,3 +220,61 @@ describe('composeAgentProfile — canonical wire shape', () => {
217220
expect(out.prompt?.instructions).toContain('per-turn directive')
218221
})
219222
})
223+
224+
describe('composeAgentProfile — system-prompt byte budget', () => {
225+
// Two markdown sections whose combined size blows the default 40KB budget;
226+
// "Big Section" dominates so the error must name it first.
227+
const OVER_BUDGET_PROMPT =
228+
`# Big Section\n${'a'.repeat(45_000)}\n## Small Section\n${'b'.repeat(2_000)}`
229+
230+
it('passes an under-budget prompt through unchanged (default budget)', () => {
231+
const out = composeAgentProfile(BASE, {}, { systemPrompt: 'short prompt' })
232+
expect(out.prompt?.systemPrompt).toBe('short prompt')
233+
})
234+
235+
it('REDS on a composed prompt over the default 40KB budget, reporting size and top sections', () => {
236+
expect(() => composeAgentProfile(BASE, {}, { systemPrompt: OVER_BUDGET_PROMPT })).toThrow(
237+
/is 4\d{4} bytes over the 40000-byte budget[\s\S]*Big Section/,
238+
)
239+
})
240+
241+
it('respects a custom maxSystemPromptBytes', () => {
242+
expect(() =>
243+
composeAgentProfile(BASE, {}, { systemPrompt: 'x'.repeat(200) }, { maxSystemPromptBytes: 100 }),
244+
).toThrow(/over the 100-byte budget/)
245+
// And a raised budget lets a known-big prompt through.
246+
const out = composeAgentProfile(
247+
BASE,
248+
{},
249+
{ systemPrompt: OVER_BUDGET_PROMPT },
250+
{ maxSystemPromptBytes: 100_000 },
251+
)
252+
expect(out.prompt?.systemPrompt).toBe(OVER_BUDGET_PROMPT)
253+
})
254+
255+
it('warnOnly downgrades the throw to a console.warn that still yells', () => {
256+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
257+
try {
258+
const out = composeAgentProfile(BASE, {}, { systemPrompt: OVER_BUDGET_PROMPT }, { warnOnly: true })
259+
expect(out.prompt?.systemPrompt).toBe(OVER_BUDGET_PROMPT)
260+
expect(warn).toHaveBeenCalledTimes(1)
261+
expect(String(warn.mock.calls[0]?.[0])).toMatch(/over the 40000-byte budget/)
262+
} finally {
263+
warn.mockRestore()
264+
}
265+
})
266+
267+
it('assertSystemPromptWithinBudget measures UTF-8 bytes, not code units', () => {
268+
// 3 bytes per char in UTF-8; 40 chars over a 100-byte cap.
269+
expect(() => assertSystemPromptWithinBudget('€'.repeat(40), { maxSystemPromptBytes: 100 })).toThrow(
270+
/is 120 bytes/,
271+
)
272+
expect(DEFAULT_MAX_SYSTEM_PROMPT_BYTES).toBe(40_000)
273+
})
274+
275+
it('largestPromptSections ranks heading-delimited sections by bytes, preamble included', () => {
276+
const sections = largestPromptSections(`intro\n# A\n${'a'.repeat(50)}\n## B\n${'b'.repeat(500)}`)
277+
expect(sections[0]).toEqual({ title: 'B', bytes: expect.any(Number) })
278+
expect(sections.map((s) => s.title)).toEqual(['B', 'A', '(preamble)'])
279+
})
280+
})

src/profile/index.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,74 @@ export interface ProfileOverlay {
109109
name?: string
110110
}
111111

112+
/** Byte budget on the FINAL composed `prompt.systemPrompt`. Past this the
113+
* model degrades sharply (a 122,659-byte prompt shipped once and the model
114+
* returned empty answers), so the default gate throws well before that. */
115+
export const DEFAULT_MAX_SYSTEM_PROMPT_BYTES = 40_000
116+
117+
/** Budget config for the composed system prompt. */
118+
export interface ComposeProfileBudget {
119+
/** Byte cap on the composed `prompt.systemPrompt`.
120+
* Default {@link DEFAULT_MAX_SYSTEM_PROMPT_BYTES}. */
121+
maxSystemPromptBytes?: number
122+
/** Downgrade the over-budget throw to a `console.warn` — the escape hatch
123+
* for a product with a known-big prompt that must still ship (it yells on
124+
* every compose instead of blocking). */
125+
warnOnly?: boolean
126+
}
127+
128+
/** Largest markdown-heading-delimited sections of a prompt, by UTF-8 bytes.
129+
* Cheap heuristic: split on `#`-heading lines; the preamble before the first
130+
* heading reports as "(preamble)". */
131+
export function largestPromptSections(
132+
prompt: string,
133+
top = 3,
134+
): Array<{ title: string; bytes: number }> {
135+
const encoder = new TextEncoder()
136+
const sections: Array<{ title: string; bytes: number }> = []
137+
let title = '(preamble)'
138+
let start = 0
139+
const flush = (end: number) => {
140+
const body = prompt.slice(start, end)
141+
if (body.trim()) sections.push({ title, bytes: encoder.encode(body).byteLength })
142+
}
143+
const headingRe = /^#{1,6}\s+(.+)$/gm
144+
for (const match of prompt.matchAll(headingRe)) {
145+
flush(match.index)
146+
title = (match[1] ?? '').trim() || '(untitled section)'
147+
start = match.index
148+
}
149+
flush(prompt.length)
150+
return sections.sort((a, b) => b.bytes - a.bytes).slice(0, top)
151+
}
152+
153+
/** Enforce {@link ComposeProfileBudget} on a composed system prompt: over
154+
* budget throws (or warns with `warnOnly`) with the actual size and the
155+
* top-3 largest sections. Exported so a product assembling its prompt
156+
* outside {@link composeAgentProfile} (e.g. via the `/prompt` assembler) can
157+
* run the same gate at its own final-composition point. */
158+
export function assertSystemPromptWithinBudget(
159+
systemPrompt: string,
160+
budget: ComposeProfileBudget = {},
161+
): void {
162+
const max = budget.maxSystemPromptBytes ?? DEFAULT_MAX_SYSTEM_PROMPT_BYTES
163+
const bytes = new TextEncoder().encode(systemPrompt).byteLength
164+
if (bytes <= max) return
165+
const sections = largestPromptSections(systemPrompt)
166+
.map((s) => `"${s.title}" (${s.bytes}B)`)
167+
.join(', ')
168+
const message =
169+
`composed systemPrompt is ${bytes} bytes — over the ${max}-byte budget ` +
170+
`(oversized prompts degrade to empty answers). ` +
171+
(sections ? `Largest sections: ${sections}. ` : '') +
172+
`Trim the prompt, move content to skills/knowledge mounts, or raise maxSystemPromptBytes deliberately.`
173+
if (budget.warnOnly) {
174+
console.warn(`[profile] ${message}`)
175+
return
176+
}
177+
throw new Error(message)
178+
}
179+
112180
/** Project per-user skills onto SDK file mounts at the harness skill-discovery
113181
* path. No tier gate — a user skill is mounted because the user added it.
114182
* Sorted by path for determinism (matches {@link registrySkills}). */
@@ -140,11 +208,16 @@ export function userSkillMounts(userSkills: UserSkill[]): AgentProfileFileMount[
140208
* not a hand-rolled spread. `mergeAgentProfiles(base, overlay)` returns
141209
* `undefined` only when BOTH are `undefined`; `base` is always defined here, so
142210
* the result is non-`undefined` by construction and we assert that to the caller.
211+
*
212+
* The composed `prompt.systemPrompt` is byte-budgeted here — the single point
213+
* where the FINAL prompt exists ({@link assertSystemPromptWithinBudget};
214+
* default {@link DEFAULT_MAX_SYSTEM_PROMPT_BYTES}, `warnOnly` escape hatch).
143215
*/
144216
export function composeAgentProfile(
145217
base: AgentProfile,
146218
channels: ProfileChannels = {},
147219
overlay: ProfileOverlay = {},
220+
budget: ComposeProfileBudget = {},
148221
): AgentProfile {
149222
const shellInput: ComposeShellResourcesInput = {
150223
skills: channels.skills,
@@ -174,6 +247,10 @@ export function composeAgentProfile(
174247
const merged = mergeAgentProfiles(base, overlayProfile)
175248
if (!merged)
176249
throw new Error('composeAgentProfile: mergeAgentProfiles returned undefined for a defined base')
250+
// Byte-budget gate on the FINAL composed systemPrompt — this is the single
251+
// point where every channel and overlay has been merged in.
252+
const systemPrompt = merged.prompt?.systemPrompt
253+
if (typeof systemPrompt === 'string') assertSystemPromptWithinBudget(systemPrompt, budget)
177254
return pruneEmptyResourceChannels(merged)
178255
}
179256

src/sandbox/index.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ import {
6363
runSandboxToolPathSetup,
6464
splitDeferredProfileFiles,
6565
writeProfileFilesToBox,
66+
assertProvisionPayloadWithinCap,
67+
assertEnvWithinLimits,
68+
PROVISION_PAYLOAD_MAX_BYTES,
69+
ENV_VALUE_MAX_BYTES,
70+
ENV_TOTAL_MAX_BYTES,
6671
SandboxRuntimeAuthRefreshError,
6772
type SandboxRuntimeConfig,
6873
type SecretStore,
@@ -698,6 +703,92 @@ describe('mintSandboxScopedToken', () => {
698703
})
699704
})
700705

706+
describe('provision S-cost gates', () => {
707+
it('assertEnvWithinLimits REDS on a single env value over the per-entry cap, naming the variable', () => {
708+
expect(() =>
709+
assertEnvWithinLimits({ SMALL: 'ok', OPENCODE_CONFIG_CONTENT: 'x'.repeat(ENV_VALUE_MAX_BYTES) }),
710+
).toThrow(/OPENCODE_CONFIG_CONTENT is \d+ bytes[\s\S]*MAX_ARG_STRLEN/)
711+
})
712+
713+
it('assertEnvWithinLimits REDS on total env size over the block cap, naming the largest entry', () => {
714+
const env: Record<string, string> = {}
715+
for (let i = 0; i < 4; i += 1) env[`VAR_${i}`] = 'x'.repeat(60_000)
716+
env.VAR_3 = 'x'.repeat(61_000)
717+
expect(() => assertEnvWithinLimits(env)).toThrow(
718+
new RegExp(`env block is \\d+ bytes total — over the ${ENV_TOTAL_MAX_BYTES}-byte gate[\\s\\S]*VAR_3`),
719+
)
720+
})
721+
722+
it('assertEnvWithinLimits passes a normal env untouched', () => {
723+
expect(() => assertEnvWithinLimits({ WORKSPACE_ID: 'w1', NODE_ENV: 'test' })).not.toThrow()
724+
})
725+
726+
it('assertProvisionPayloadWithinCap REDS over 240KB with a per-section breakdown and the defer hint', () => {
727+
const payload = {
728+
env: { WORKSPACE_ID: 'w1' },
729+
secrets: ['SECRET_A'],
730+
backend: {
731+
profile: {
732+
name: 'big',
733+
resources: {
734+
files: [
735+
{ path: 'corpus.md', resource: { kind: 'inline', name: 'corpus', content: 'x'.repeat(280_000) } },
736+
],
737+
},
738+
} as AgentProfile,
739+
},
740+
}
741+
expect(() => assertProvisionPayloadWithinCap(payload)).toThrow(
742+
new RegExp(
743+
`payload is \\d+ bytes — over the ${PROVISION_PAYLOAD_MAX_BYTES}-byte gate` +
744+
`[\\s\\S]*profile=\\d+B \\(files=\\d+B\\), env=\\d+B, secrets=\\d+B` +
745+
`[\\s\\S]*deferProfileFiles: true or move content to resources`,
746+
),
747+
)
748+
})
749+
750+
it('assertProvisionPayloadWithinCap passes an under-cap payload', () => {
751+
expect(() =>
752+
assertProvisionPayloadWithinCap({ env: { A: 'a' }, secrets: [], backend: { profile: PROFILE } }),
753+
).not.toThrow()
754+
})
755+
756+
it('ensureWorkspaceSandbox rejects an over-cap payload BEFORE the create POST', async () => {
757+
listMock.mockResolvedValue([])
758+
createMock.mockResolvedValue(fakeBox())
759+
const bigProfile: AgentProfile = {
760+
name: 'big',
761+
resources: {
762+
files: [
763+
{ path: 'corpus.md', resource: { kind: 'inline', name: 'corpus', content: 'x'.repeat(280_000) } },
764+
],
765+
},
766+
} as AgentProfile
767+
await expect(
768+
ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'https://s' }, { profile: () => bigProfile }), {
769+
workspaceId: 'w1',
770+
harness: 'opencode',
771+
}),
772+
).rejects.toThrow(/over the 240000-byte gate[\s\S]*deferProfileFiles/)
773+
expect(createMock).not.toHaveBeenCalled()
774+
})
775+
776+
it('ensureWorkspaceSandbox rejects an E2BIG-class env var BEFORE the create POST', async () => {
777+
listMock.mockResolvedValue([])
778+
createMock.mockResolvedValue(fakeBox())
779+
await expect(
780+
ensureWorkspaceSandbox(
781+
shellFor(
782+
{ apiKey: 'k', baseUrl: 'https://s' },
783+
{ env: async () => ({ OPENCODE_CONFIG_CONTENT: 'x'.repeat(130_000) }) },
784+
),
785+
{ workspaceId: 'w1', harness: 'opencode' },
786+
),
787+
).rejects.toThrow(/OPENCODE_CONFIG_CONTENT/)
788+
expect(createMock).not.toHaveBeenCalled()
789+
})
790+
})
791+
701792
describe('ensureWorkspaceSandbox — new seams', () => {
702793
beforeEach(() => {
703794
resetClientCache()

src/sandbox/index.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,94 @@ async function deleteBox(box: SandboxInstance): Promise<Outcome<void>> {
10091009
// named harness) is what enforces correctness.
10101010
type CreatePayload = Parameters<Sandbox['create']>[0]
10111011

1012+
// ---------------------------------------------------------------------------
1013+
// Provision-time S-cost gates. Both run immediately before `client.create` —
1014+
// each catches an input class that can NEVER produce a working sandbox, so
1015+
// failing at POST-time with actionable detail beats a platform 4xx (or a box
1016+
// that boots and then E2BIGs on every exec).
1017+
1018+
/** Gate on the provision body: the platform orchestrator caps the create
1019+
* payload at 256 KiB; 240 KB leaves headroom for transport framing. An
1020+
* over-cap payload fails provisioning 100% of the time (a 282 KB payload
1021+
* shipped once and no sandbox could ever be created). */
1022+
export const PROVISION_PAYLOAD_MAX_BYTES = 240_000
1023+
1024+
/** Per-variable env gate: the kernel rejects any single `NAME=value` env entry
1025+
* over MAX_ARG_STRLEN (131072 bytes) with E2BIG, killing every exec inside
1026+
* the box. 120 KB leaves headroom for the name and framing. */
1027+
export const ENV_VALUE_MAX_BYTES = 120_000
1028+
1029+
/** Total env gate: the whole environment block shares the payload budget with
1030+
* the profile; past 200 KB the provision body cannot stay under the cap. */
1031+
export const ENV_TOTAL_MAX_BYTES = 200_000
1032+
1033+
function utf8ByteLength(value: unknown): number {
1034+
return new TextEncoder().encode(typeof value === 'string' ? value : JSON.stringify(value ?? null))
1035+
.byteLength
1036+
}
1037+
1038+
/** The provision-payload sections the size gates need to see. Structural so
1039+
* the gate is testable without the SDK's (unexported) create-payload type. */
1040+
export interface ProvisionPayloadSections {
1041+
env?: Record<string, string>
1042+
secrets?: readonly string[]
1043+
backend?: { profile?: AgentProfile }
1044+
}
1045+
1046+
/**
1047+
* Throw when the serialized provision payload exceeds
1048+
* {@link PROVISION_PAYLOAD_MAX_BYTES}. The error carries a per-section byte
1049+
* breakdown (profile/files/env/secrets) so the offending channel is named, not
1050+
* guessed.
1051+
*/
1052+
export function assertProvisionPayloadWithinCap(payload: ProvisionPayloadSections): void {
1053+
const total = utf8ByteLength(payload)
1054+
if (total <= PROVISION_PAYLOAD_MAX_BYTES) return
1055+
const profile = payload.backend?.profile
1056+
const files = profile?.resources?.files ?? []
1057+
const breakdown =
1058+
`profile=${utf8ByteLength(profile ?? null)}B ` +
1059+
`(files=${utf8ByteLength(files)}B), ` +
1060+
`env=${utf8ByteLength(payload.env ?? {})}B, ` +
1061+
`secrets=${utf8ByteLength(payload.secrets ?? [])}B`
1062+
throw new Error(
1063+
`sandbox provision payload is ${total} bytes — over the ${PROVISION_PAYLOAD_MAX_BYTES}-byte gate ` +
1064+
`(the platform caps the create body at 256 KiB; an over-cap payload can never create a sandbox). ` +
1065+
`Breakdown: ${breakdown}. ` +
1066+
`Hint: set deferProfileFiles: true or move content to resources.`,
1067+
)
1068+
}
1069+
1070+
/**
1071+
* Throw when any single env value exceeds {@link ENV_VALUE_MAX_BYTES} or the
1072+
* whole env block exceeds {@link ENV_TOTAL_MAX_BYTES}, naming the offending
1073+
* variable. This is the E2BIG incident class: the box may even provision, but
1074+
* every exec inside it dies on the oversized entry.
1075+
*/
1076+
export function assertEnvWithinLimits(env: Record<string, string>): void {
1077+
let total = 0
1078+
let largest: { name: string; bytes: number } | null = null
1079+
for (const [name, value] of Object.entries(env)) {
1080+
const bytes = utf8ByteLength(`${name}=${value}`)
1081+
total += bytes
1082+
if (!largest || bytes > largest.bytes) largest = { name, bytes }
1083+
if (bytes > ENV_VALUE_MAX_BYTES) {
1084+
throw new Error(
1085+
`sandbox env var ${name} is ${bytes} bytes — over the ${ENV_VALUE_MAX_BYTES}-byte gate ` +
1086+
`(kernel MAX_ARG_STRLEN is 131072 bytes per env entry; anything larger E2BIGs every exec). ` +
1087+
`Write large content to a file mount or resource instead of an env var.`,
1088+
)
1089+
}
1090+
}
1091+
if (total > ENV_TOTAL_MAX_BYTES) {
1092+
const worst = largest ? ` Largest: ${largest.name} (${largest.bytes}B).` : ''
1093+
throw new Error(
1094+
`sandbox env block is ${total} bytes total — over the ${ENV_TOTAL_MAX_BYTES}-byte gate.${worst} ` +
1095+
`Write large content to a file mount or resource instead of env vars.`,
1096+
)
1097+
}
1098+
}
1099+
10121100
// Generic exec+sidecar liveness probe. Absent probe => always alive (the prior
10131101
// reuse-on-metadata-match behavior). With a probe: the container must answer an
10141102
// `echo alive` exec within execTimeoutMs, and the sidecar process must be found
@@ -1457,6 +1545,12 @@ export async function ensureWorkspaceSandbox(
14571545
},
14581546
} as CreatePayload
14591547

1548+
// S-cost gates: an oversized env entry (E2BIG class) or an over-cap
1549+
// provision body can never produce a working sandbox — fail loud here,
1550+
// before the POST, with the offending section named.
1551+
assertEnvWithinLimits(env)
1552+
assertProvisionPayloadWithinCap(payload as ProvisionPayloadSections)
1553+
14601554
let box = await client.create(payload)
14611555

14621556
await box.waitFor('running', { timeoutMs: 120_000, ...(onProgress ? { onProgress } : {}) })

0 commit comments

Comments
 (0)