From f5fff1038f646be8d9b7f6ab4bb9bae96c1883bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E9=80=8A?= <72533078+UncertaintyDeterminesYou4ndMe@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:40:26 +0800 Subject: [PATCH] test(cli): restore the run-command process-boundary contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the six contracts #2476 kept on a real subprocess and 802855c51 dropped with the process fixture: piped non-TTY stdin (run -, implicit stdin prompt, positional plus stdin context), SIGINT delivery observed as exit 130 with empty stdout, and the fail-closed sandbox boundary reaching a non-interactive run. The injected MakaRunDeps seam covers these semantics in process but not the boundary itself. The new fixture is a minimal subprocess entry over runMakaTextCliCore with default environment deps and four scripted scenarios — none of the in-process duplication the removal targeted. Generated-by: Claude Code --- .../cli/src/__tests__/run-command-fixture.ts | 157 ++++++++++++++++++ .../cli/src/__tests__/run-command.test.ts | 134 +++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 packages/cli/src/__tests__/run-command-fixture.ts diff --git a/packages/cli/src/__tests__/run-command-fixture.ts b/packages/cli/src/__tests__/run-command-fixture.ts new file mode 100644 index 0000000000..0bb9a5f4c8 --- /dev/null +++ b/packages/cli/src/__tests__/run-command-fixture.ts @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionEvent } from '@maka/core/events'; +import type { SessionSummary } from '@maka/core/session'; +import { + runMakaTextCliCore, + type MakaRunContext, + type MakaRunContextInput, + type MakaRunRuntime, +} from '../run-command-core.js'; + +// Subprocess entry for the run-command process-contract tests: real piped +// stdin, SIGINT delivered by the operating system and observed as an exit +// code, and the fail-closed sandbox boundary reaching a non-interactive run. +// Ordinary command semantics are covered in process through the same adapter +// seam — keep this fixture limited to what a real child process is genuinely +// needed for. +const scenario = process.env.MAKA_RUN_FIXTURE_SCENARIO ?? 'echo'; +let observer: MakaRunContextInput['runOutcomeObserver']; +let boundaryDenied = false; +let releaseStop: (() => void) | undefined; +let releaseGraphWait: (() => void) | undefined; + +const summary: SessionSummary = { + id: 'session-fixture', + cwd: process.cwd(), + name: 'fixture', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'fixture', + connectionLocked: true, + model: 'fixture-model', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', +}; + +const runtime: MakaRunRuntime = { + createSession: async () => summary, + readExecutionBoundary: async () => ({ kind: 'managed', access: 'writable', revision: 0 }), + setExecutionBoundaryKind: async () => {}, + async *sendMessage(_sessionId, input): AsyncIterable { + if (scenario === 'sandbox-boundary') { + yield { + type: 'sandbox_boundary_request', + id: 'event-boundary', + turnId: input.turnId, + ts: 1, + requestId: 'boundary-1', + toolUseId: 'tool-boundary', + justification: 'Read an external file.', + expansion: { + filesystem: { + entries: [{ path: '/outside/file.txt', access: 'read', scope: 'exact' }], + }, + }, + }; + if (!boundaryDenied) throw new Error('sandbox boundary request was not denied'); + // A completed outcome with output makes the fail-closed exit code + // load-bearing: only the boundary-failure classification may turn + // this run into exit 1 with empty stdout. + await observer?.({ + outcomeId: 'run-fixture', + status: 'completed', + finalOutput: 'should not be emitted', + sandboxBoundary: 'none', + }); + return; + } + if (scenario === 'graph-wait' && input.turnOrchestration?.mode !== 'graph') { + throw new Error('expected graph orchestration'); + } + if (scenario === 'slow') { + // Ready is written only once the core has installed its SIGINT handler + // (it registers before consuming this stream), so the test's signal + // cannot race the default handler. The interval keeps the child alive + // while it waits for the interrupt. + process.stderr.write('fixture-ready\n'); + const keepAlive = setInterval(() => {}, 1_000); + await new Promise((resolve) => { + releaseStop = resolve; + }); + clearInterval(keepAlive); + return; + } + await observer?.({ + outcomeId: 'run-fixture', + status: 'completed', + finalOutput: + scenario === 'graph-wait' ? 'initial graph supervisor output' : `prompt=${input.text}`, + sandboxBoundary: 'none', + }); + }, + respondToSandboxBoundary: async (_sessionId, response) => { + boundaryDenied = response.decision === 'deny' && response.requestId === 'boundary-1'; + }, + stopSession: async () => { + releaseStop?.(); + }, +}; + +async function createContext(input: MakaRunContextInput): Promise { + observer = input.runOutcomeObserver; + return { + runtime, + target: { connection: { slug: 'fixture' }, model: 'fixture-model' }, + ...(input.enableAgentGraph + ? { + agentGraph: { + reserveActivity: () => ({ release: () => {} }), + waitForCompletion: async () => { + process.stderr.write('fixture-ready\n'); + const keepAlive = setInterval(() => {}, 1_000); + await new Promise((resolve) => { + releaseGraphWait = resolve; + }); + clearInterval(keepAlive); + }, + }, + } + : {}), + close: async () => { + releaseGraphWait?.(); + }, + }; +} + +runMakaTextCliCore(process.argv.slice(2), { createContext, listSessions: async () => [] }).then( + (code) => { + process.exitCode = code; + }, + (error) => { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; + }, +); diff --git a/packages/cli/src/__tests__/run-command.test.ts b/packages/cli/src/__tests__/run-command.test.ts index 54dc9700ef..d174a5be55 100644 --- a/packages/cli/src/__tests__/run-command.test.ts +++ b/packages/cli/src/__tests__/run-command.test.ts @@ -18,11 +18,16 @@ */ import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { SessionSummary } from '@maka/core/session'; import { parseMakaRunArgs, runMakaTextCliCore, type MakaRunAdapter } from '../run-command-core.js'; +const fixturePath = fileURLToPath(new URL('./run-command-fixture.js', import.meta.url)); + describe('maka run argument parsing', () => { test('recognizes stdin prompt mode and rejects malformed limits', () => { assert.deepEqual(parseMakaRunArgs(['-']), { @@ -129,6 +134,135 @@ describe('maka run argument parsing', () => { }); }); +// The contracts below live at the process boundary — a real pipe, a real +// signal, a real exit code — which the injected MakaRunDeps seam cannot +// express. Everything else about `maka run` is covered in process above. +describe('maka run process contract', () => { + test('uses stdin as the complete prompt for run -', async () => { + const result = await runFixture(['-'], { input: 'from stdin\nsecond line' }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'prompt=from stdin\nsecond line\n'); + }); + + test('uses non-TTY stdin as the prompt when no positional prompt is provided', async () => { + const result = await runFixture([], { input: 'implicit stdin prompt' }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'prompt=implicit stdin prompt\n'); + }); + + test('combines a positional instruction with piped stdin context', async () => { + const result = await runFixture(['summarize'], { input: 'document body' }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'prompt=summarize\n\ndocument body\n'); + }); + + test('fails closed when a sandbox boundary request reaches non-interactive run', async () => { + const result = await runFixture(['hello'], { scenario: 'sandbox-boundary' }); + assert.equal(result.code, 1); + assert.match(result.stderr, /sandbox boundary expansion is unavailable/); + assert.doesNotMatch(result.stderr, /not denied/); + assert.equal(result.stdout, ''); + }); + + test('returns exit 130 on SIGINT', async () => { + const result = await interruptFixture(['hello'], 'slow'); + assert.equal(result.signal, null); + assert.equal(result.code, 130, result.stderr); + assert.equal(result.stdout, ''); + }); + + test('returns exit 130 when SIGINT interrupts Graph completion wait', async () => { + const result = await interruptFixture(['implement it', '--graph'], 'graph-wait'); + assert.equal(result.signal, null); + assert.equal(result.code, 130, result.stderr); + assert.equal(result.stdout, ''); + }); +}); + +function runFixture( + args: string[], + options: { scenario?: string; input?: string } = {}, +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fixturePath, ...args], { + // The scenario is always set explicitly so an ambient variable from the + // developer's shell can never repoint a test at another scenario. + env: { ...process.env, MAKA_RUN_FIXTURE_SCENARIO: options.scenario ?? 'echo' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + // A child that never exits must fail the suite, not hang it. + const guard = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`fixture did not exit\n${stderr}`)); + }, 15_000); + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + child.on('close', (code) => { + clearTimeout(guard); + resolve({ code, stdout, stderr }); + }); + child.stdin.end(options.input ?? ''); + }); +} + +async function interruptFixture( + args: string[], + scenario: string, +): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> { + const child = spawn(process.execPath, [fixturePath, ...args], { + env: { ...process.env, MAKA_RUN_FIXTURE_SCENARIO: scenario }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + child.stdin.end(); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + const ready = new Promise((resolve) => { + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + if (stderr.includes('fixture-ready')) resolve(); + }); + }); + // A fixture that never reports ready must fail the suite, not hang it. + let readyTimer: ReturnType | undefined; + await Promise.race([ + ready, + new Promise((_resolve, reject) => { + readyTimer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`fixture never became ready\n${stderr}`)); + }, 10_000); + }), + ]); + if (readyTimer !== undefined) clearTimeout(readyTimer); + child.kill('SIGINT'); + + // A regression that ignores the interrupt would otherwise hang the suite: + // give the child a bounded window, then force it down and let the signal + // assertion report the failure. + let killTimer: ReturnType | undefined; + const exited = once(child, 'exit') as Promise<[number | null, NodeJS.Signals | null]>; + const result = await Promise.race([ + exited.then(([code, signal]) => ({ code, signal })), + new Promise<{ code: null; signal: 'SIGKILL' }>((resolve) => { + killTimer = setTimeout(() => { + child.kill('SIGKILL'); + resolve({ code: null, signal: 'SIGKILL' }); + }, 2_000); + }), + ]); + if (killTimer !== undefined) clearTimeout(killTimer); + return { ...result, stdout, stderr }; +} + function sessionSummary(): SessionSummary { return { id: 'session-1',