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
157 changes: 157 additions & 0 deletions packages/cli/src/__tests__/run-command-fixture.ts
Original file line number Diff line number Diff line change
@@ -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<SessionEvent> {
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<void>((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<MakaRunContext> {
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<void>((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;
},
);
134 changes: 134 additions & 0 deletions packages/cli/src/__tests__/run-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(['-']), {
Expand Down Expand Up @@ -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<void>((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<typeof setTimeout> | undefined;
await Promise.race([
ready,
new Promise<never>((_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<typeof setTimeout> | 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',
Expand Down