Files
gbrain/test/minions-shell.test.ts
T
d838d4792b feat: queue resilience — wall-clock timeouts, backpressure, --no-worker, env concurrency (#379)
* feat: queue resilience — wall-clock timeouts, backpressure, --no-worker, env concurrency, shell guard

Prevents stall-induced queue blockage discovered in production (OpenClaw):

1. Wall-clock timeout sweep: dead-letters active jobs exceeding 2× timeout_ms
   (or 2 × lockDuration × max_stalled). Catches jobs stuck while holding DB
   connections where FOR UPDATE SKIP LOCKED stall detection skips them.

2. Submission backpressure (maxWaiting): caps waiting jobs per name at
   submission time. Prevents autopilot-cycle flood when the queue is blocked.

3. --no-worker flag for autopilot: skips spawning the built-in worker child.
   For environments where the worker lifecycle is managed externally (systemd,
   Docker, OpenClaw service-manager).

4. GBRAIN_WORKER_CONCURRENCY env var: fallback for --concurrency when the
   worker is spawned by autopilot (which can't pass CLI flags to the child).

5. Shell job env guard with clear logging: shell handler is always registered
   but throws UnrecoverableError with a clear message when
   GBRAIN_ALLOW_SHELL_JOBS=1 is not set, instead of silently not registering.

* feat: v0.19.1 Lane A — maxWaiting atomic guard, concurrency clamp, --max-waiting CLI

Addresses three production-hardening findings from the CEO + Eng + Codex
adversarial review of PR #379:

D2/H2: maxWaiting was TOCTOU-racy — two concurrent submitters could both
see waitingCount < max and both insert. Wrap the count+select+insert in
pg_advisory_xact_lock keyed on (name, queue). Serializes concurrent
decisions for the SAME key while leaving different keys fully parallel.
Lock auto-releases on txn commit/rollback — no cleanup path to leak.
Also fix the missing queue-scope bug: count and select now filter on
(name, queue) not name alone, so cross-queue same-name jobs don't
suppress each other.

D3/H3: resolveWorkerConcurrency silently accepted NaN / 0 / negative from
parseInt. `inFlight.size < NaN` is always false → worker claims nothing →
silent wedge from a single-typo env var. Clamp to ≥1 with a loud stderr
warning naming the bad value.

D5/H5: `gbrain jobs submit` never parsed `--max-waiting N` despite the
MinionJobInput field. Wire the flag with clamp [1, 100], mirror
`--max-stalled`. Extract `parseMaxWaitingFlag` for unit testing.

Q1: Silent coalesce was invisible by design. New
src/core/minions/backpressure-audit.ts mirrors shell-audit.ts's ISO-week
JSONL pattern: `~/.gbrain/audit/backpressure-YYYY-Www.jsonl`. Coalesce
events write one JSONL line with (queue, name, waiting_count, max_waiting,
returned_job_id, ts). Best-effort — disk-full never blocks submission.

A2: `gbrain jobs smoke --wedge-rescue` new opt-in regression case.
Forges a wedged-worker row state, invokes handleStalled + handleTimeouts
+ handleWallClockTimeouts in order, asserts only wall-clock evicts.
Mirrors the v0.14.3 `--sigkill-rescue` shape.

Tests: 23 new unit cases in test/minions.test.ts covering wall-clock
timeout (3 cases + non-interference with handleTimeouts), maxWaiting
(coalesce, clamp 0, floor, concurrent-submitter race via Promise.all,
cross-queue isolation, unset fallthrough), concurrency clamp (7 cases
incl. NaN/0/negative), parseMaxWaitingFlag (5 cases), backpressure
audit file write.

Part of v0.19.1 plan at ~/.claude/plans/ok-wintermute-wrote-this-polished-matsumoto.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: v0.19.1 Lane B — doctor queue_health, autopilot peer probe, runbook

A5 / D4: New `queue_health` check in `gbrain doctor`. Postgres-only (PGLite
has no multi-process worker surface). Two subchecks, both cheap (single
SELECT each, status-index-covered):

- stalled-forever: any active job with started_at > 1h. Surfaces the
  worst offenders (top 5 by started_at ASC) with `gbrain jobs get/cancel`
  fix hints. The incident that motivated v0.19.1 ran 90+ min before the
  operator noticed.
- waiting-depth: per-name waiting count exceeds threshold. Default 10,
  overridable via GBRAIN_QUEUE_WAITING_THRESHOLD env (D9). Signals a
  submitter probably needs maxWaiting set.

Worker-heartbeat subcheck from the original plan dropped (D4/H4): no
minion_workers table exists, and lock_until-on-active-jobs is a lossy
proxy that can't distinguish idle-worker from dead-worker. Tracked as
follow-up B7.

A4: --no-worker peer-liveness probe in autopilot. When --no-worker is
set, every cycle runs a cheap SELECT checking for any active job whose
lock_until was refreshed in the last 2 minutes. After 3 consecutive
idle ticks, logs a loud WARNING naming the silent-wedge vector and
referencing B7 as the ground-truth follow-up. Re-arms on next live
signal so the warning doesn't spam every cycle.

A6: New docs/guides/queue-operations-runbook.md (one viewport, ~60
lines). "My queue looks wedged — what do I run?" in order of
escalation. What each doctor subcheck means. Self-check for the
--no-worker / no-worker-running footgun.

CLAUDE.md: key-files updates for handleWallClockTimeouts (v0.19.0 Layer
3 kill shot), maxWaiting advisory-lock rewrite (v0.19.1 D2), queue_health
doctor check (v0.19.1 D4), and backpressure-audit.ts.

Tests: all 143 minions + 13 doctor unit tests pass. No new test cases
required in Lane B; the doctor queue_health exercise is in the E2E
verification step (needs real PG to produce meaningful stalled-forever
rows). The --no-worker probe is exercised by the smoke case's wedge
setup in Lane A.

README: unchanged. Existing `gbrain jobs submit` examples don't show
--max-stalled, so no --max-waiting precedent to extend per A6 conditional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: v0.19.1 Lane C — CHANGELOG entry, VERSION bump, remove SPEC.md

VERSION: 0.19.0 → 0.19.1 (patch; bug-fix-dominant, no schema change,
no new user-facing vocabulary).

CHANGELOG: new v0.19.1 entry at the top with the full release-summary
template per CLAUDE.md — bold two-line headline, lead paragraph, "numbers
that matter" before/after table measured against the real incident,
"what this means for OpenClaw users" closer, required "To take
advantage of v0.19.1" block naming the worker-restart requirement,
itemized changes by area, and "For contributors" section closing the
loop on the stale autopilot-idempotency narrative the CEO review was
based on.

Mechanism reframing per D1/H1: the 18-job pile-up was NOT caused by
missing idempotency (autopilot already passes
`idempotency_key: autopilot-cycle:${slot}` at autopilot.ts:241). The
18 jobs were 18 DIFFERENT slots stacking up behind the wedged one.
`maxWaiting` still caps the pile; the incident just wasn't about
idempotency. Adversarial review caught this before ship.

SPEC.md: deleted from repo root. It was Wintermute's planning artifact
for the original PR, not a shipped spec. Design docs belong under
docs/designs/ per repo convention; leaving one at repo root set a
precedent this repo doesn't want (A7/D11). CHANGELOG + the plan file
at ~/.claude/plans/ok-wintermute-wrote-this-polished-matsumoto.md are
the durable artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: --wedge-rescue smoke state — both stall+timeout sweeps must skip

Smoke case was setting lock_until in the past, so handleStalled's
requeue path fired before handleWallClockTimeouts had a chance to
evict. Production scenario is "lock_until still live (worker
renewing) + timeout_at disqualified" — only wall-clock matches.

Single-connection smoke can't simulate a row lock held by another
txn, so we force the equivalent outcome:
- lock_until = now() + 30s → handleStalled skips (not a stall)
- timeout_at = NULL → handleTimeouts skips (needs NOT NULL)
- started_at = now() - 10s, timeout_ms=1000 → wall-clock matches
  (2 × timeout_ms = 2000ms threshold exceeded)

Verified: SMOKE PASS — Minions healthy + wedge rescue in 0.14s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: CI failures — shell-handler tests + llms-full.txt drift

Two CI failure clusters, both pre-existing but surfaced by the v0.20.3
merge:

1) test/minions-shell.test.ts — 12 failing cases. The shell handler
   throws UnrecoverableError when GBRAIN_ALLOW_SHELL_JOBS !== '1' (the
   production RCE guard at shell.ts:210). The unit tests exercise
   handler mechanics, not the guard, but never set the env var — so
   every invocation exits through the guard path instead of the code
   being tested. Fix: set GBRAIN_ALLOW_SHELL_JOBS=1 in beforeAll,
   restore in afterAll. The env-guard IS still tested separately via
   the test/minions.test.ts case added in v0.20.3 Lane A which toggles
   the var itself.

2) llms-full.txt — stale against CLAUDE.md. Key-files entries for
   queue.ts, doctor.ts, and the new backpressure-audit.ts updated in
   v0.20.3 Lane B triggered the build-llms drift guard. Regenerated
   via `bun run build:llms`; no behavior change, just the inlined-docs
   bundle catching up to source.

Full test run: 2367 pass, 0 fail across 137 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:09:28 -07:00

355 lines
14 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionQueue } from '../src/core/minions/queue.ts';
import { UnrecoverableError } from '../src/core/minions/types.ts';
import type { MinionJobContext } from '../src/core/minions/types.ts';
import { shellHandler } from '../src/core/minions/handlers/shell.ts';
import { computeAuditFilename, resolveAuditDir, logShellSubmission } from '../src/core/minions/handlers/shell-audit.ts';
import { isProtectedJobName, PROTECTED_JOB_NAMES } from '../src/core/minions/protected-names.ts';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
let engine: PGLiteEngine;
let queue: MinionQueue;
// The shell handler at src/core/minions/handlers/shell.ts:210 throws
// UnrecoverableError when GBRAIN_ALLOW_SHELL_JOBS !== '1'. That's the
// production-worker RCE guard. Unit tests here exercise the handler
// mechanics, not the guard, so we enable it for the whole file and
// restore on teardown. The separate "rejects when env not set" case
// (in the minion-shell submission E2E / the queue-resilience wave)
// toggles the var itself.
let prevAllowShellJobs: string | undefined;
beforeAll(async () => {
prevAllowShellJobs = process.env.GBRAIN_ALLOW_SHELL_JOBS;
process.env.GBRAIN_ALLOW_SHELL_JOBS = '1';
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
queue = new MinionQueue(engine);
}, 60_000);
afterAll(async () => {
await engine.disconnect();
if (prevAllowShellJobs === undefined) delete process.env.GBRAIN_ALLOW_SHELL_JOBS;
else process.env.GBRAIN_ALLOW_SHELL_JOBS = prevAllowShellJobs;
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM minion_jobs');
});
// Build a minimal MinionJobContext for unit tests. Real worker provides this;
// here we mock it so the handler can be exercised without spinning up Postgres.
function makeCtx(
data: Record<string, unknown>,
opts: { signal?: AbortSignal; shutdownSignal?: AbortSignal } = {},
): MinionJobContext {
return {
id: 1,
name: 'shell',
data,
attempts_made: 0,
signal: opts.signal ?? new AbortController().signal,
shutdownSignal: opts.shutdownSignal ?? new AbortController().signal,
updateProgress: async () => {},
updateTokens: async () => {},
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
}
// ---- protected-names ---------------------------------------------------------
describe('protected-names', () => {
test('shell is protected', () => {
expect(isProtectedJobName('shell')).toBe(true);
expect(PROTECTED_JOB_NAMES.has('shell')).toBe(true);
});
test('normalization: whitespace is trimmed before check', () => {
expect(isProtectedJobName(' shell ')).toBe(true);
expect(isProtectedJobName('\tshell\n')).toBe(true);
});
test('case-sensitive: Shell is NOT protected', () => {
expect(isProtectedJobName('Shell')).toBe(false);
expect(isProtectedJobName('SHELL')).toBe(false);
});
test('non-protected names pass through', () => {
expect(isProtectedJobName('sync')).toBe(false);
expect(isProtectedJobName('embed')).toBe(false);
expect(isProtectedJobName('')).toBe(false);
});
});
// ---- MinionQueue.add trusted guard ------------------------------------------
describe('MinionQueue.add protected-name guard', () => {
test('add("shell", ...) without trusted arg throws', async () => {
expect(queue.add('shell', { cmd: 'echo', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
});
test('add("shell", ..., opts, {allowProtectedSubmit:true}) succeeds', async () => {
const job = await queue.add('shell', { cmd: 'echo', cwd: '/tmp' }, undefined, { allowProtectedSubmit: true });
expect(job.name).toBe('shell');
expect(job.status).toBe('waiting');
});
// Whitespace bypass defense (Codex #1)
test('add(" shell ", ...) without trusted arg throws (whitespace bypass defense)', async () => {
expect(queue.add(' shell ', { cmd: 'echo', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
});
test('add(" shell ", ...) with trusted arg inserts normalized name "shell"', async () => {
const job = await queue.add(' shell ', { cmd: 'echo', cwd: '/tmp' }, undefined, { allowProtectedSubmit: true });
expect(job.name).toBe('shell');
});
test('add("Shell", ...) is treated as non-protected (case-sensitive)', async () => {
const job = await queue.add('Shell', {});
expect(job.name).toBe('Shell');
expect(job.status).toBe('waiting');
});
// Regression: non-protected names unaffected (Codex iron-rule)
test('REGRESSION: add("sync", ...) without trusted arg still succeeds', async () => {
const job = await queue.add('sync', { full: true });
expect(job.name).toBe('sync');
expect(job.status).toBe('waiting');
});
test('REGRESSION: trusted flag does NOT bypass empty-name check', async () => {
expect(queue.add('', {}, undefined, { allowProtectedSubmit: true })).rejects.toThrow(/cannot be empty/);
});
});
// ---- Shell handler: validation ----------------------------------------------
describe('shell handler: validation', () => {
test('both cmd and argv → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo', argv: ['echo'], cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('neither cmd nor argv → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('cwd missing → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo ok' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('cwd not absolute → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo ok', cwd: 'relative/path' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('argv non-array (string) → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ argv: 'echo ok', cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('argv with non-string entries → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ argv: ['echo', 42], cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('env with non-string values → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo', cwd: '/tmp', env: { FOO: 42 } }));
expect(p).rejects.toThrow(UnrecoverableError);
});
});
// ---- Shell handler: spawn + output ------------------------------------------
describe('shell handler: spawn', () => {
test('cmd happy path: echo ok → exit 0, stdout captured', async () => {
const res = await shellHandler(makeCtx({ cmd: 'echo ok', cwd: '/tmp' })) as any;
expect(res.exit_code).toBe(0);
expect(res.stdout_tail).toBe('ok\n');
expect(res.stderr_tail).toBe('');
expect(typeof res.duration_ms).toBe('number');
expect(res.duration_ms).toBeGreaterThanOrEqual(0);
expect(typeof res.pid).toBe('number');
});
test('argv happy path: ["echo","hi"] → exit 0, stdout "hi\\n"', async () => {
const res = await shellHandler(makeCtx({ argv: ['echo', 'hi'], cwd: '/tmp' })) as any;
expect(res.exit_code).toBe(0);
expect(res.stdout_tail).toBe('hi\n');
});
test('non-zero exit → Error with stderr in message', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo fail 1>&2; exit 7', cwd: '/tmp' }));
await expect(p).rejects.toThrow(/exit 7/);
});
test('argv with bogus binary → Error (retryable)', async () => {
const p = shellHandler(makeCtx({ argv: ['gbrain-nonexistent-binary-xyz'], cwd: '/tmp' }));
// spawn emits 'error' on ENOENT
await expect(p).rejects.toThrow();
});
test('result shape includes all declared keys', async () => {
const res = await shellHandler(makeCtx({ cmd: 'echo ok', cwd: '/tmp' })) as any;
expect(Object.keys(res).sort()).toEqual(['duration_ms', 'exit_code', 'pid', 'stderr_tail', 'stdout_tail']);
});
});
// ---- Shell handler: env allowlist -------------------------------------------
describe('shell handler: env allowlist', () => {
test('process env leak prevention: a faux secret is NOT in child env', async () => {
const saved = process.env.SHELL_TEST_SECRET;
process.env.SHELL_TEST_SECRET = 'should-not-leak';
try {
const res = await shellHandler(makeCtx({
cmd: 'echo "secret=${SHELL_TEST_SECRET:-EMPTY}"',
cwd: '/tmp',
})) as any;
expect(res.stdout_tail).toBe('secret=EMPTY\n');
} finally {
if (saved === undefined) delete process.env.SHELL_TEST_SECRET;
else process.env.SHELL_TEST_SECRET = saved;
}
});
test('PATH is inherited from worker', async () => {
const res = await shellHandler(makeCtx({
cmd: 'echo "path=$PATH"',
cwd: '/tmp',
})) as any;
expect(res.stdout_tail.startsWith('path=')).toBe(true);
expect(res.stdout_tail.length).toBeGreaterThan('path=\n'.length);
});
test('caller-supplied env key is added', async () => {
const res = await shellHandler(makeCtx({
cmd: 'echo "val=$MY_CUSTOM"',
cwd: '/tmp',
env: { MY_CUSTOM: 'hello' },
})) as any;
expect(res.stdout_tail).toBe('val=hello\n');
});
test('caller-supplied env can override allowlisted key (PATH)', async () => {
const res = await shellHandler(makeCtx({
cmd: 'echo "path=$PATH"',
cwd: '/tmp',
env: { PATH: '/custom/bin' },
})) as any;
expect(res.stdout_tail).toBe('path=/custom/bin\n');
});
});
// ---- Shell handler: abort --------------------------------------------------
describe('shell handler: abort', () => {
test('ctx.signal.abort triggers SIGTERM and handler throws aborted', async () => {
const ac = new AbortController();
const promise = shellHandler(makeCtx(
{ cmd: 'sleep 30', cwd: '/tmp' },
{ signal: ac.signal },
));
// Give spawn a beat to start
setTimeout(() => ac.abort(new Error('cancel')), 50);
await expect(promise).rejects.toThrow(/aborted/);
});
test('ctx.shutdownSignal.abort also triggers kill', async () => {
const shutdownCtl = new AbortController();
const promise = shellHandler(makeCtx(
{ cmd: 'sleep 30', cwd: '/tmp' },
{ shutdownSignal: shutdownCtl.signal },
));
setTimeout(() => shutdownCtl.abort(new Error('shutdown')), 50);
await expect(promise).rejects.toThrow(/aborted/);
});
test('pre-aborted signal → immediate kill', async () => {
const ac = new AbortController();
ac.abort(new Error('cancel'));
const promise = shellHandler(makeCtx(
{ cmd: 'sleep 30', cwd: '/tmp' },
{ signal: ac.signal },
));
await expect(promise).rejects.toThrow(/aborted/);
});
});
// ---- shell-audit: ISO-week filename ----------------------------------------
describe('shell-audit: computeAuditFilename', () => {
test('2027-01-01 is ISO week 53 of 2026', () => {
expect(computeAuditFilename(new Date('2027-01-01T12:00:00Z'))).toBe('shell-jobs-2026-W53.jsonl');
});
test('2026-12-28 (Monday) is ISO week 53 of 2026', () => {
expect(computeAuditFilename(new Date('2026-12-28T12:00:00Z'))).toBe('shell-jobs-2026-W53.jsonl');
});
test('2027-01-04 (Monday) is ISO week 1 of 2027', () => {
expect(computeAuditFilename(new Date('2027-01-04T12:00:00Z'))).toBe('shell-jobs-2027-W01.jsonl');
});
test('2026-04-19 (mid-year reference)', () => {
const f = computeAuditFilename(new Date('2026-04-19T00:00:00Z'));
expect(f).toMatch(/^shell-jobs-2026-W\d{2}\.jsonl$/);
});
});
// ---- shell-audit: write path -----------------------------------------------
describe('shell-audit: write', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-audit-test-'));
process.env.GBRAIN_AUDIT_DIR = tmpDir;
});
afterAll(() => {
delete process.env.GBRAIN_AUDIT_DIR;
});
test('GBRAIN_AUDIT_DIR env override resolves to the custom dir', () => {
expect(resolveAuditDir()).toBe(tmpDir);
});
test('writes a JSONL line; creates dir if missing', () => {
const inner = path.join(tmpDir, 'nested-not-yet-created');
process.env.GBRAIN_AUDIT_DIR = inner;
logShellSubmission({
caller: 'cli', remote: false, job_id: 42, cwd: '/tmp', cmd_display: 'echo ok',
});
const files = fs.readdirSync(inner);
expect(files.length).toBe(1);
const content = fs.readFileSync(path.join(inner, files[0]), 'utf8').trim();
const parsed = JSON.parse(content);
expect(parsed.caller).toBe('cli');
expect(parsed.job_id).toBe(42);
expect(parsed.cmd_display).toBe('echo ok');
expect(parsed.ts).toBeDefined();
});
test('argv_display stored as JSON array (Codex #11)', () => {
logShellSubmission({
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp',
argv_display: ['node', 'script.mjs', '--date', '2026-04-18'],
});
const files = fs.readdirSync(tmpDir);
const content = fs.readFileSync(path.join(tmpDir, files[0]), 'utf8').trim();
const parsed = JSON.parse(content);
expect(Array.isArray(parsed.argv_display)).toBe(true);
expect(parsed.argv_display).toEqual(['node', 'script.mjs', '--date', '2026-04-18']);
});
test('does NOT log env values', () => {
logShellSubmission({
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp', cmd_display: 'echo ok',
});
const files = fs.readdirSync(tmpDir);
const content = fs.readFileSync(path.join(tmpDir, files[0]), 'utf8');
expect(content).not.toContain('env');
});
test('write failure (EACCES) is non-blocking', () => {
// Point at a read-only target. /dev/null is not a directory.
process.env.GBRAIN_AUDIT_DIR = '/dev/null/not-a-dir';
// Should not throw — failures go to stderr.
expect(() => logShellSubmission({
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp',
})).not.toThrow();
});
});
// ---- shell handler: UTF-8-safe output truncation ---------------------------
describe('shell handler: output truncation', () => {
test('stdout > 64KB is truncated and marker is prepended', async () => {
// Emit ~100KB of stdout to force truncation
const res = await shellHandler(makeCtx({
cmd: `yes ok | head -c 100000`,
cwd: '/tmp',
})) as any;
expect(res.exit_code).toBe(0);
expect(res.stdout_tail).toMatch(/^\[truncated \d+ bytes\]/);
expect(res.stdout_tail.length).toBeGreaterThan(0);
// Tail must contain characters we emitted
expect(res.stdout_tail).toContain('ok');
});
});