Skip to content
Open
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
41 changes: 41 additions & 0 deletions service/scripts/test-ptc-replay-bash-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
23 changes: 23 additions & 0 deletions service/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
parsePlanLimits,
resolvePositiveIntEnv,
resolveRuntimeSessionMode,
resolveSandboxBackend,
} from './config';
Expand Down Expand Up @@ -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({});
Expand Down
14 changes: 14 additions & 0 deletions service/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() === '') {
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 26 additions & 13 deletions service/src/preamble-bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand Down Expand Up @@ -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))
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Comment thread
kenzaelk98 marked this conversation as resolved.
_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
}

Expand Down
4 changes: 2 additions & 2 deletions service/src/service/replay-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down