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
105 changes: 105 additions & 0 deletions packages/runtime/src/__tests__/shell-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import { describe, test } from 'node:test';
import {
buildLocalForegroundBashTool,
buildManagedBashTool,
buildWriteStdinTool,
createWriteStdinSchemas,
shapeTerminalResult,
WRITE_STDIN_EXAMPLE_REF,
WRITE_STDIN_MINIMAL_EXAMPLES,
type ShellRunLauncher,
} from '../shell-tools.js';
import type { ShellPlan } from '../shell-detect.js';
Expand Down Expand Up @@ -276,6 +280,107 @@ describe('shapeTerminalResult sandbox denial projection', () => {
});
});

describe('WriteStdin provider/strict contract conformance', () => {
const { providerParameters, strictParameters } = createWriteStdinSchemas();

test('every documented minimal example is accepted by BOTH the provider and strict layers', () => {
assert.ok(WRITE_STDIN_MINIMAL_EXAMPLES.length >= 7, 'expected one example per action shape');
for (const { label, payload } of WRITE_STDIN_MINIMAL_EXAMPLES) {
const provider = providerParameters.safeParse(payload);
assert.ok(
provider.success,
`provider schema rejected the documented "${label}" example: ${
provider.success ? '' : provider.error.message
}`,
);
const strict = strictParameters.safeParse(payload);
assert.ok(
strict.success,
`strict validator rejected the documented "${label}" example: ${
strict.success ? '' : strict.error.message
}`,
);
}
});

test('the description advertises a ref/actions example the schemas actually accept', () => {
const controls = {
writeStdin: () => Promise.reject(new Error('not used')),
resize: () => Promise.reject(new Error('not used')),
} as unknown as Parameters<typeof buildWriteStdinTool>[0];
const tool = buildWriteStdinTool(controls);
const match = tool.description.match(/\{"ref":"[^"]+","actions":\[[^\]]+\]\}/);
assert.ok(match, `description is missing a concrete minimal example: ${tool.description}`);
const advertised = JSON.parse(match[0].replace('<id>', 'sr_example'));
assert.ok(
providerParameters.safeParse(advertised).success,
'the advertised example must pass the provider schema',
);
assert.ok(
strictParameters.safeParse(advertised).success,
'the advertised example must pass the strict validator',
);
});

test('provider null/0/empty placeholders are tolerated and normalized away by the strict layer', () => {
// A provider that fills every optional field with a null/0/'' placeholder
// rather than omitting it must still round-trip to the minimal legal action.
const withPlaceholders = {
ref: WRITE_STDIN_EXAMPLE_REF,
actions: [
{
type: 'key',
key: 'enter',
text: '',
event: null,
x: 0,
y: 0,
button: null,
direction: null,
modifiers: [],
},
],
size: null,
};
const strict = strictParameters.safeParse(withPlaceholders);
assert.ok(
strict.success,
`strict validator should normalize provider placeholders, got: ${
strict.success ? '' : strict.error.message
}`,
);
assert.deepEqual(strict.data.actions, [{ type: 'key', key: 'enter' }]);
});

test('strict validation is not vacuous: contract violations are rejected', () => {
// Mouse click without a button is structurally invalid.
assert.equal(
strictParameters.safeParse({
ref: WRITE_STDIN_EXAMPLE_REF,
actions: [{ type: 'mouse', event: 'click', x: 0, y: 0 }],
}).success,
false,
);
// A non-canonical ref must be refused even when the actions are legal.
assert.equal(
strictParameters.safeParse({
ref: 'not-a-runtime-ref',
actions: [{ type: 'key', key: 'enter' }],
}).success,
false,
);
// input and actions are mutually exclusive.
assert.equal(
strictParameters.safeParse({
ref: WRITE_STDIN_EXAMPLE_REF,
input: 'x',
actions: [{ type: 'key', key: 'enter' }],
}).success,
false,
);
});
});

function fakeToolContext() {
return {
sessionId: 'session-1',
Expand Down
82 changes: 81 additions & 1 deletion packages/runtime/src/shell-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,81 @@ export function buildStopBackgroundTaskTool(backgroundTasks: BackgroundTaskStopp
};
}

export function buildWriteStdinTool(ptyControls: PtyControlWriter): MakaTool {
/** A syntactically valid PTY ref used only in the documented WriteStdin examples. */
export const WRITE_STDIN_EXAMPLE_REF = 'maka://runtime/background-tasks/sr_example';

/**
* One minimal legal payload per WriteStdin action type (plus a resize-only
* call). Each entry is valid under the loose provider schema AND passes strict
* validation after normalization — the WriteStdin contract conformance test
* asserts both, so the documented shape can never drift from what the runtime
* actually accepts. The `key (chord)` entry shows the ctrl-modified printable
* form the description points at.
*/
export const WRITE_STDIN_MINIMAL_EXAMPLES: readonly {
readonly label: string;
readonly payload: Readonly<Record<string, unknown>>;
}[] = [
{
label: 'text',
payload: { ref: WRITE_STDIN_EXAMPLE_REF, actions: [{ type: 'text', text: 'hello' }] },
},
{
label: 'key (named)',
payload: { ref: WRITE_STDIN_EXAMPLE_REF, actions: [{ type: 'key', key: 'enter' }] },
},
{
label: 'key (chord)',
payload: {
ref: WRITE_STDIN_EXAMPLE_REF,
actions: [{ type: 'key', key: 'c', modifiers: ['ctrl'] }],
},
},
{
label: 'mouse click',
payload: {
ref: WRITE_STDIN_EXAMPLE_REF,
actions: [{ type: 'mouse', event: 'click', x: 0, y: 0, button: 'left' }],
},
},
{
label: 'mouse move',
payload: {
ref: WRITE_STDIN_EXAMPLE_REF,
actions: [{ type: 'mouse', event: 'move', x: 1, y: 1 }],
},
},
{
label: 'mouse scroll',
payload: {
ref: WRITE_STDIN_EXAMPLE_REF,
actions: [{ type: 'mouse', event: 'scroll', x: 0, y: 0, direction: 'up' }],
},
},
{ label: 'resize only', payload: { ref: WRITE_STDIN_EXAMPLE_REF, size: { cols: 80, rows: 24 } } },
];

/**
* Build the two-layer WriteStdin schema pair as a single unit so the
* provider-visible (loose) schema and the strict runtime validator can be
* exercised together by conformance tests. The loose provider schema exists so
* providers that inject `null`/`0`/`''` placeholders are tolerated; the strict
* validator (via {@link normalizeProviderWriteStdinInput}) normalizes those away
* and enforces the real contract. {@link WRITE_STDIN_MINIMAL_EXAMPLES} pins a
* minimal legal payload per action type that must pass BOTH layers.
*/
/** The validated shape the strict WriteStdin schema yields after normalization. */
export interface WriteStdinInput {
ref: string;
input?: string;
actions?: TerminalInputAction[];
size?: { cols: number; rows: number };
}

export function createWriteStdinSchemas(): {
providerParameters: z.ZodTypeAny;
strictParameters: z.ZodType<WriteStdinInput, unknown>;
} {
const terminalAction = z.unknown().transform((value, context): TerminalInputAction => {
try {
return parseTerminalInputAction(value);
Expand Down Expand Up @@ -522,6 +596,11 @@ export function buildWriteStdinTool(ptyControls: PtyControlWriter): MakaTool {
})
.strict()
.describe('Send ordered terminal actions and/or resize a background PTY');
return { providerParameters, strictParameters };
}

export function buildWriteStdinTool(ptyControls: PtyControlWriter): MakaTool {
const { providerParameters, strictParameters } = createWriteStdinSchemas();
const providerSchema = zodSchema(providerParameters);
const parameters = jsonSchema(async () => await providerSchema.jsonSchema, {
validate: async (value) => {
Expand All @@ -540,6 +619,7 @@ export function buildWriteStdinTool(ptyControls: PtyControlWriter): MakaTool {
`Named keys are ${TERMINAL_INPUT_NAMED_KEYS.join(', ')}. Use a printable ASCII key with ctrl or alt for chords such as Ctrl-B; use text for ordinary typing. ` +
'Mouse coordinates are zero-based terminal cells and work only while the application has enabled SGR cell mouse reporting. ' +
'Actions are written atomically in their listed order. Text is ordinary audited tool-call data, not a secure secret channel. ' +
'Minimal example: {"ref":"maka://runtime/background-tasks/<id>","actions":[{"type":"key","key":"enter"}]}. ' +
'The returned output is the terminal state at that cut, not output attributed to this input; use Read on the ref to observe later output.',
parameters,
permissionArgs: (input) => parseInput(input),
Expand Down