diff --git a/service/scripts/test-ptc-replay-bash-smoke.ts b/service/scripts/test-ptc-replay-bash-smoke.ts index e01331b..05937d3 100644 --- a/service/scripts/test-ptc-replay-bash-smoke.ts +++ b/service/scripts/test-ptc-replay-bash-smoke.ts @@ -519,5 +519,46 @@ get_weather '{"city":"Rome"}' ); } +{ + // Regression: input past ARG_MAX (~128KB) must still replay from history, + // not get silently re-issued as a new pending call each run. + const bigExpr = '1+1;#' + 'x'.repeat(300_000); + const user = ` +result=$(calculate '{"expression":"${bigExpr}"}') +echo "Result: $result" +`; + const r1 = runBash(assemble(user), {}); + const p1 = extractPending(r1.stdout); + assert(r1.exitCode === 0, 'large_input: first run exit 0'); + assert(p1.pending?.[0]?.call_id === 'call_001', 'large_input: first pending is call_001'); + assert( + typeof p1.pending?.[0]?.input_hash === 'string' && p1.pending[0].input_hash.length === 64, + 'large_input: pending carries a hash for the oversized input', + ); + + const history = { + call_001: { + result: 'ok-large', + tool_name: 'calculate', + input_hash: hashToolInput({ expression: bigExpr }), + received_at: 1, + }, + }; + const r2 = runBash(assemble(user), history); + const p2 = extractPending(r2.stdout); + assert(r2.exitCode === 0, 'large_input_replay: exit 0'); + assert(p2.pending === null, 'large_input_replay: no pending re-emitted for the same oversized call'); + assert(p2.stdout.includes('Result: "ok-large"'), 'large_input_replay: cached result used instead of re-invoking'); +} + +{ + // Regression: a stray second JSON document must be rejected, not silently + // truncated to the first one. + const user = `calculate '{"expression":"1+1"} {"expression":"2+2"}'`; + const r = runBash(assemble(user), {}); + assert(r.exitCode === 1, 'multi_doc_input: exit 1'); + assert(r.stderr.includes('must be a single JSON object'), 'multi_doc_input: stderr explains the rejection'); +} + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/service/src/config.test.ts b/service/src/config.test.ts index 2f88a87..242d64f 100644 --- a/service/src/config.test.ts +++ b/service/src/config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { parsePlanLimits, + resolvePositiveIntEnv, resolveRuntimeSessionMode, resolveSandboxBackend, } from './config'; @@ -33,6 +34,28 @@ describe('sandbox execution configuration', () => { }); }); +describe('resolvePositiveIntEnv', () => { + test('falls back to the default when unset or blank', () => { + expect(resolvePositiveIntEnv(undefined, 42)).toBe(42); + expect(resolvePositiveIntEnv('', 42)).toBe(42); + expect(resolvePositiveIntEnv(' ', 42)).toBe(42); + }); + + test('accepts positive finite values and floors them', () => { + expect(resolvePositiveIntEnv('100', 42)).toBe(100); + expect(resolvePositiveIntEnv('100.9', 42)).toBe(100); + }); + + test('falls back to the default for zero, negative, non-finite, or non-numeric values', () => { + expect(resolvePositiveIntEnv('0', 42)).toBe(42); + expect(resolvePositiveIntEnv('-5', 42)).toBe(42); + expect(resolvePositiveIntEnv('Infinity', 42)).toBe(42); + expect(resolvePositiveIntEnv('-Infinity', 42)).toBe(42); + expect(resolvePositiveIntEnv('NaN', 42)).toBe(42); + expect(resolvePositiveIntEnv('not-a-number', 42)).toBe(42); + }); +}); + describe('parsePlanLimits', () => { test('returns an empty catalog when unset or blank', () => { expect(parsePlanLimits(undefined)).toEqual({}); diff --git a/service/src/config.ts b/service/src/config.ts index c9dcb68..b90aae2 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -206,6 +206,17 @@ export function lambdaMicrovmNumericConfigError( return undefined; } +export function resolvePositiveIntEnv(raw: string | undefined, defaultValue: number): number { + if (raw == null || raw.trim() === '') { + return defaultValue; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return defaultValue; + } + return Math.floor(parsed); +} + export function resolveEgressGrantTtlSeconds(rawTtlSeconds: string | undefined, jobTimeoutMs: number): number { const defaultTtlSeconds = Math.max(1, Math.ceil((jobTimeoutMs + EGRESS_GRANT_GRACE_MS) / 1000)); if (rawTtlSeconds == null || rawTtlSeconds.trim() === '') { @@ -273,6 +284,9 @@ export const env = { EGRESS_GATEWAY_FILE_SERVER_URL: process.env.EGRESS_GATEWAY_FILE_SERVER_URL ?? process.env.FILE_SERVER_URL ?? 'http://localhost:3000', EGRESS_GATEWAY_TOOL_CALL_SERVER_URL: process.env.EGRESS_GATEWAY_TOOL_CALL_SERVER_URL ?? process.env.TOOL_CALL_SERVER_URL ?? 'http://localhost:3033', EGRESS_GATEWAY_MAX_TOOL_CALL_BYTES: Number(process.env.EGRESS_GATEWAY_MAX_TOOL_CALL_BYTES) || 1024 * 1024, + // Per-entry / aggregate caps for PTC tool results persisted in `tool_history:` (see replay-state.ts). + PTC_MAX_TOOL_RESULT_BYTES: resolvePositiveIntEnv(process.env.PTC_MAX_TOOL_RESULT_BYTES, 5_000_000), + PTC_MAX_TOOL_HISTORY_TOTAL_BYTES: resolvePositiveIntEnv(process.env.PTC_MAX_TOOL_HISTORY_TOTAL_BYTES, 40_000_000), EGRESS_GATEWAY_MAX_FILE_BYTES: Number(process.env.EGRESS_GATEWAY_MAX_FILE_BYTES ?? process.env.SANDBOX_MAX_FILE_SIZE) || 10_000_000, EGRESS_GATEWAY_MAX_PATH_LENGTH: Number(process.env.EGRESS_GATEWAY_MAX_PATH_LENGTH ?? process.env.SANDBOX_MAX_PATH_LENGTH) || 256, EGRESS_GATEWAY_MAX_NESTING_DEPTH: Number(process.env.EGRESS_GATEWAY_MAX_NESTING_DEPTH ?? process.env.SANDBOX_MAX_NESTING_DEPTH) || 10, diff --git a/service/src/preamble-bash.ts b/service/src/preamble-bash.ts index 2a27c99..4ec3fc0 100644 --- a/service/src/preamble-bash.ts +++ b/service/src/preamble-bash.ts @@ -417,18 +417,20 @@ _ptc_next_call_id() { _ptc_history_matches_by_signature() { local _ptc_name="$1" - local _ptc_input="$2" + local _ptc_input_file="$2" local _ptc_input_hash="$3" local _ptc_call_site="$4" if [ ! -r "$_PTC_HISTORY_PATH" ]; then return 0 fi + # Path, not inline: large input can exceed ARG_MAX via --argjson. jq -c \\ --arg nm "$_ptc_name" \\ --arg site "$_ptc_call_site" \\ --arg hash "$_ptc_input_hash" \\ - --argjson inp "$_ptc_input" \\ - 'to_entries + --slurpfile inp_arr "$_ptc_input_file" \\ + '($inp_arr[0]) as $inp + | to_entries | map(select((.value | type) == "object" and .value.tool_name == $nm and ((.value.input_hash == $hash) or (.value.input == $inp)))) @@ -474,13 +476,15 @@ _ptc_print_history_entry() { _ptc_history_entry_matches_current_call() { local _ptc_entry="$1" local _ptc_name="$2" - local _ptc_input="$3" + local _ptc_input_file="$3" local _ptc_input_hash="$4" + # Path, same ARG_MAX reason as above. printf '%s' "$_ptc_entry" | jq -e \\ --arg nm "$_ptc_name" \\ --arg hash "$_ptc_input_hash" \\ - --argjson inp "$_ptc_input" \\ - 'if type != "object" then true + --slurpfile inp_arr "$_ptc_input_file" \\ + '($inp_arr[0]) as $inp + | if type != "object" then true elif (has("tool_name") and .tool_name != $nm) then false elif (has("input_hash") or has("input")) then ((.input_hash == $hash) or (.input == $inp)) @@ -494,8 +498,9 @@ _ptc_call_tool() { local _ptc_input="\${2:-\$_ptc_default_input}" local _ptc_call_site="\${BASH_LINENO[1]:-\${BASH_LINENO[0]:-0}}" - if ! printf '%s' "$_ptc_input" | jq -e 'type == "object"' >/dev/null 2>&1; then - _ptc_write_error "tool input for $_ptc_name must be a JSON object, got: $_ptc_input" + # Reject extra trailing JSON values instead of silently dropping them. + if ! printf '%s' "$_ptc_input" | jq -e -n '[inputs] as $docs | ($docs | length) == 1 and ($docs[0] | type) == "object"' >/dev/null 2>&1; then + _ptc_write_error "tool input for $_ptc_name must be a single JSON object, got: $_ptc_input" exit 1 fi @@ -505,8 +510,13 @@ _ptc_call_tool() { exit 1 fi + # Large input can exceed ARG_MAX via --argjson; write once, reuse path below. + local _ptc_input_tmp + _ptc_input_tmp="$(mktemp -t _ptc_input.XXXXXX 2>/dev/null || mktemp /tmp/_ptc_input.XXXXXX)" + printf '%s' "$_ptc_input" > "$_ptc_input_tmp" + local _ptc_matches - _ptc_matches=$(_ptc_history_matches_by_signature "$_ptc_name" "$_ptc_input" "$_ptc_input_hash" "$_ptc_call_site") + _ptc_matches=$(_ptc_history_matches_by_signature "$_ptc_name" "$_ptc_input_tmp" "$_ptc_input_hash" "$_ptc_call_site") if ! _ptc_acquire_lock; then exit 1 @@ -522,6 +532,7 @@ _ptc_call_tool() { printf '%s\\n' "$_ptc_matched_call_id" >> "$_PTC_CONSUMED_FILE" _ptc_mark_counter_at_least "$_ptc_matched_call_id" _ptc_release_lock + rm -f "$_ptc_input_tmp" _ptc_print_history_entry "$_ptc_matched_entry" return $? fi @@ -538,26 +549,28 @@ _ptc_call_tool() { if [ -z "$_ptc_entry" ] || [ "$_ptc_entry" = "null" ]; then break fi - if _ptc_history_entry_matches_current_call "$_ptc_entry" "$_ptc_name" "$_ptc_input" "$_ptc_input_hash"; then + if _ptc_history_entry_matches_current_call "$_ptc_entry" "$_ptc_name" "$_ptc_input_tmp" "$_ptc_input_hash"; then printf '%s\\n' "$_ptc_call_id" >> "$_PTC_CONSUMED_FILE" _ptc_release_lock + rm -f "$_ptc_input_tmp" _ptc_print_history_entry "$_ptc_entry" return $? fi done - if ! jq -c -n \\ + if ! printf '%s' "$_ptc_input" | jq -c -n \\ --arg cid "$_ptc_call_id" \\ --arg nm "$_ptc_name" \\ --arg hash "$_ptc_input_hash" \\ --arg site "$_ptc_call_site" \\ - --argjson inp "$_ptc_input" \\ - '{call_id:$cid,tool_name:$nm,input:$inp,input_hash:$hash,call_site:$site}' >> "$_PTC_PENDING_FILE"; then + '{call_id:$cid,tool_name:$nm,input:input,input_hash:$hash,call_site:$site}' >> "$_PTC_PENDING_FILE"; then _ptc_write_error "failed to serialize pending tool call for $_ptc_name" _ptc_release_lock + rm -f "$_ptc_input_tmp" exit 1 fi _ptc_release_lock + rm -f "$_ptc_input_tmp" exit 0 } diff --git a/service/src/service/replay-state.ts b/service/src/service/replay-state.ts index 562e06d..3abf62f 100644 --- a/service/src/service/replay-state.ts +++ b/service/src/service/replay-state.ts @@ -60,13 +60,13 @@ export const REPLAY_LOCK_TTL_MS = Math.max(10 * 60 * 1000, env.JOB_TIMEOUT * 2 + * Redis hash and the `_ptc_history.json` injected into the sandbox bounded * regardless of how pathological a single tool result is. Scaled * proportionally with `MAX_EXECUTION_STATE_BYTES` (ratio 1:2 vs exec_state). */ -export const MAX_TOOL_RESULT_BYTES = 5_000_000; +export const MAX_TOOL_RESULT_BYTES = env.PTC_MAX_TOOL_RESULT_BYTES; /** Aggregate cap across ALL results persisted for a single execution. Scaled * proportionally with `MAX_EXECUTION_STATE_BYTES` (ratio 4:1 vs exec_state) * so a long replay flow can accumulate ~8 saturating tool results before * being asked to break work into a fresh execution. */ -export const MAX_TOOL_HISTORY_TOTAL_BYTES = 40_000_000; +export const MAX_TOOL_HISTORY_TOTAL_BYTES = env.PTC_MAX_TOOL_HISTORY_TOTAL_BYTES; /** Maximum number of keys `scanKeys` will return in a single call. The janitor * runs every `STALE_CLEANUP_INTERVAL` and processes whatever this yields; if