blob: e5b937719bd1c475ba8fbc57535e9e70c96e6ea5 [file]
/*
* 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.
*/
export const SHELL_RUN_STATUSES = [
'starting',
'running',
'completed',
'failed',
'timed_out',
'cancelled',
'orphaned',
] as const;
export type ShellRunStatus = (typeof SHELL_RUN_STATUSES)[number];
export const SHELL_RUN_ACTIVE_STATUSES = ['starting', 'running'] as const;
export const SHELL_RUN_TERMINAL_STATUSES = [
'completed',
'failed',
'timed_out',
'cancelled',
'orphaned',
] as const;
export const SHELL_RUN_ID_MAX_CHARS = 128;
export const SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES = 512;
const SHELL_RUN_ID_PATTERN = new RegExp(`^[A-Za-z0-9_-]{1,${SHELL_RUN_ID_MAX_CHARS}}$`);
const PIPE_SHELL_OUTPUT_KEYS = new Set([
'mode',
'stdout',
'stderr',
'latestStream',
'stdoutTruncated',
'stderrTruncated',
'redacted',
]);
const PTY_SHELL_OUTPUT_KEYS = new Set([
'mode',
'screen',
'scrollback',
'lastAlternateScreen',
'cols',
'rows',
'cursor',
'alternateScreen',
'truncated',
'redacted',
]);
const PTY_CURSOR_KEYS = new Set(['x', 'y', 'visible']);
export type ShellRunTerminalStatus = (typeof SHELL_RUN_TERMINAL_STATUSES)[number];
export type ShellRunActiveStatus = (typeof SHELL_RUN_ACTIVE_STATUSES)[number];
export type ShellMode = 'pipes' | 'pty';
/**
* Determines whether a runtime shell resource may be summarized to the model.
* User-owned interactive terminals remain observable to their attached Client,
* but their command stream and output are not part of an agent turn.
*/
export type ShellRunVisibility = 'model' | 'user';
export interface PipeShellOutput {
mode: 'pipes';
stdout: string;
stderr: string;
latestStream?: 'stdout' | 'stderr';
stdoutTruncated: boolean;
stderrTruncated: boolean;
redacted: boolean;
}
export interface PtyShellOutput {
mode: 'pty';
screen: string;
scrollback: string;
lastAlternateScreen?: string;
cols: number;
rows: number;
cursor: {
x: number;
y: number;
visible: boolean;
};
alternateScreen: boolean;
truncated: boolean;
redacted: boolean;
}
export type ShellOutput = PipeShellOutput | PtyShellOutput;
export type ShellRunOperation =
| {
kind: 'stop';
applied: boolean;
}
| {
kind: 'pty_control';
failed: boolean;
input?: {
bytes: number;
queued: boolean;
};
resize?: {
cols: number;
rows: number;
applied: boolean;
changed: boolean;
};
};
export interface ShellRunRecord {
shellRunId: string;
sessionId: string;
sourceRunId?: string;
sourceTurnId: string;
sourceToolCallId: string;
/** Defaults to `model` for model-initiated Bash runs. */
visibility?: ShellRunVisibility;
cwd: string;
command: string;
status: ShellRunStatus;
/** Native root process id, when admitted by the process driver. */
pid?: number;
exitCode?: number;
failureMessage?: string;
startedAt: number;
updatedAt: number;
completedAt?: number;
timeoutMs?: number;
revision: number;
observedAt?: number;
output: ShellOutput;
sandboxExecution?: {
type: 'none' | 'macos-seatbelt' | 'linux' | 'windows';
enforced: boolean;
};
sandboxEscalation?: {
commandHash: string;
unsandboxed: true;
};
}
export type ShellRunPatch = Partial<
Pick<
ShellRunRecord,
| 'status'
| 'pid'
| 'exitCode'
| 'failureMessage'
| 'updatedAt'
| 'completedAt'
| 'observedAt'
| 'output'
>
>;
export interface ShellRunStore {
createShellRun(record: ShellRunRecord): Promise<ShellRunRecord>;
updateShellRun(
sessionId: string,
shellRunId: string,
patch: ShellRunPatch,
): Promise<ShellRunRecord>;
readShellRun(sessionId: string, shellRunId: string): Promise<ShellRunRecord>;
listSessionShellRuns(sessionId: string): Promise<ShellRunRecord[]>;
}
export function isShellRunStatus(value: unknown): value is ShellRunStatus {
return typeof value === 'string' && (SHELL_RUN_STATUSES as readonly string[]).includes(value);
}
export function isShellRunSourceToolCallId(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
new TextEncoder().encode(value).byteLength <= SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES
);
}
export function isShellRunId(value: unknown): value is string {
return typeof value === 'string' && SHELL_RUN_ID_PATTERN.test(value);
}
export function isTerminalShellRunStatus(value: ShellRunStatus): value is ShellRunTerminalStatus {
return (SHELL_RUN_TERMINAL_STATUSES as readonly string[]).includes(value);
}
export function isActiveShellRunStatus(value: ShellRunStatus): value is ShellRunActiveStatus {
return (SHELL_RUN_ACTIVE_STATUSES as readonly string[]).includes(value);
}
export function isValidShellRunStatusTransition(
current: ShellRunStatus,
next: ShellRunStatus,
): boolean {
if (current === next) return true;
if (current === 'starting') {
return next === 'running' || next === 'failed' || next === 'orphaned';
}
return current === 'running' && isTerminalShellRunStatus(next);
}
export function isShellOutput(value: unknown): value is ShellOutput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const output = value as Partial<ShellOutput>;
if (output.mode === 'pipes') {
return (
hasOnlyKeys(output, PIPE_SHELL_OUTPUT_KEYS) &&
typeof output.stdout === 'string' &&
typeof output.stderr === 'string' &&
(output.latestStream === undefined ||
output.latestStream === 'stdout' ||
output.latestStream === 'stderr') &&
typeof output.stdoutTruncated === 'boolean' &&
typeof output.stderrTruncated === 'boolean' &&
typeof output.redacted === 'boolean'
);
}
if (output.mode !== 'pty') return false;
const pty = output as Partial<PtyShellOutput>;
const cursor = pty.cursor;
return (
hasOnlyKeys(pty, PTY_SHELL_OUTPUT_KEYS) &&
typeof pty.screen === 'string' &&
typeof pty.scrollback === 'string' &&
(pty.lastAlternateScreen === undefined || typeof pty.lastAlternateScreen === 'string') &&
isPositiveInteger(pty.cols) &&
isPositiveInteger(pty.rows) &&
!!cursor &&
hasOnlyKeys(cursor, PTY_CURSOR_KEYS) &&
isNonNegativeInteger(cursor.x) &&
cursor.x <= pty.cols &&
isNonNegativeInteger(cursor.y) &&
cursor.y < pty.rows &&
typeof cursor.visible === 'boolean' &&
typeof pty.alternateScreen === 'boolean' &&
typeof pty.truncated === 'boolean' &&
typeof pty.redacted === 'boolean'
);
}
export function isValidShellRunState(value: {
status?: unknown;
completedAt?: unknown;
exitCode?: unknown;
failureMessage?: unknown;
observedAt?: unknown;
}): boolean {
switch (value.status) {
case 'starting':
case 'running':
return (
value.completedAt === undefined &&
value.exitCode === undefined &&
value.failureMessage === undefined &&
value.observedAt === undefined
);
case 'completed':
return (
isFiniteNumber(value.completedAt) &&
value.exitCode === 0 &&
value.failureMessage === undefined
);
case 'failed':
return (
isFiniteNumber(value.completedAt) &&
((isFiniteNumber(value.exitCode) && value.exitCode !== 0) ||
(value.exitCode === undefined &&
typeof value.failureMessage === 'string' &&
value.failureMessage.length > 0))
);
case 'timed_out':
return isFiniteNumber(value.completedAt) && value.exitCode === 124;
case 'cancelled':
return isFiniteNumber(value.completedAt) && value.exitCode === 130;
case 'orphaned':
return (
isFiniteNumber(value.completedAt) &&
value.exitCode === undefined &&
typeof value.failureMessage === 'string' &&
value.failureMessage.length > 0
);
default:
return false;
}
}
/**
* Store-independent ShellRun invariants.
*
* Every ShellRunStore has to enforce the same shape, patch, and transition
* rules — regardless of storage medium — and ShellRunProcessManager reads those
* rules back as behaviour (monotonic revisions, immutable terminal outcomes).
* They live here so a second store is a storage-medium change, not a second
* copy of the state machine.
*/
const SHELL_RUN_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const SHELL_RUN_PATCH_KEYS: ReadonlySet<string> = new Set([
'status',
'pid',
'exitCode',
'failureMessage',
'updatedAt',
'completedAt',
'observedAt',
'output',
]);
const SHELL_RUN_RECORD_KEYS: ReadonlySet<string> = new Set([
'shellRunId',
'sessionId',
'sourceRunId',
'sourceTurnId',
'sourceToolCallId',
'visibility',
'cwd',
'command',
'status',
'pid',
'startedAt',
'updatedAt',
'completedAt',
'timeoutMs',
'exitCode',
'failureMessage',
'sandboxExecution',
'sandboxEscalation',
'revision',
'observedAt',
'output',
]);
export function assertShellRunSessionId(value: string): void {
if (!SHELL_RUN_SESSION_ID_PATTERN.test(value)) throw new Error('Invalid session id');
}
export function assertShellRunIdentifier(value: string): void {
if (!isShellRunId(value)) throw new Error('Invalid shell run id');
}
export function shellRunNotFoundError(shellRunId: string): Error & { code?: string } {
const error = new Error(`ShellRun does not exist: ${shellRunId}`) as Error & { code?: string };
error.code = 'ENOENT';
return error;
}
export function normalizeShellRunRecord(
value: unknown,
sessionId: string,
shellRunId: string,
): ShellRunRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`Invalid ShellRun record for ${shellRunId}: expected an object`);
}
const record = value as Partial<ShellRunRecord>;
const requiredStrings = [
record.shellRunId,
record.sessionId,
record.sourceTurnId,
record.sourceToolCallId,
record.cwd,
record.command,
];
const optionalStrings = [record.sourceRunId, record.failureMessage];
const valid =
hasOnlyKeys(record, SHELL_RUN_RECORD_KEYS) &&
requiredStrings.every((item) => typeof item === 'string') &&
isShellRunSourceToolCallId(record.sourceToolCallId) &&
(record.visibility === undefined ||
record.visibility === 'model' ||
record.visibility === 'user') &&
record.sessionId === sessionId &&
record.shellRunId === shellRunId &&
isShellRunStatus(record.status) &&
(record.pid === undefined || isPositiveInteger(record.pid)) &&
isFiniteNumber(record.startedAt) &&
isFiniteNumber(record.updatedAt) &&
isPositiveInteger(record.revision) &&
isShellOutput(record.output) &&
(record.completedAt === undefined || isFiniteNumber(record.completedAt)) &&
(record.timeoutMs === undefined || isFiniteNumber(record.timeoutMs)) &&
(record.exitCode === undefined || isFiniteNumber(record.exitCode)) &&
(record.observedAt === undefined || isFiniteNumber(record.observedAt)) &&
isShellRunSandboxExecution(record.sandboxExecution) &&
isShellRunSandboxEscalation(record.sandboxEscalation, record.sandboxExecution) &&
optionalStrings.every((item) => item === undefined || typeof item === 'string');
if (!valid) {
throw new Error(`Invalid ShellRun record for ${shellRunId}: malformed fields`);
}
if (!isValidShellRunState(record)) {
throw new Error(`Invalid ShellRun record for ${shellRunId}: inconsistent state fields`);
}
return canonicalShellRunRecord(record as ShellRunRecord);
}
export function assertShellRunPatch(patch: ShellRunPatch): void {
for (const key of Object.keys(patch)) {
if (!SHELL_RUN_PATCH_KEYS.has(key)) {
throw new Error(`ShellRun field is immutable: ${key}`);
}
}
}
/**
* Applies a validated patch and returns the next record, or `current` unchanged
* when the patch is a no-op. The revision only advances on a real change, which
* is what callers use to tell a durable write from a redundant one.
*/
export function nextShellRunRecord(current: ShellRunRecord, patch: ShellRunPatch): ShellRunRecord {
assertShellRunPatch(patch);
const { sessionId, shellRunId } = current;
if (patch.output && patch.output.mode !== current.output.mode) {
throw new Error(`ShellRun output mode is immutable: ${current.output.mode}`);
}
const effectivePatch =
current.observedAt !== undefined && Object.hasOwn(patch, 'observedAt')
? { ...patch, observedAt: current.observedAt }
: patch;
const candidate = normalizeShellRunRecord(
{ ...current, ...effectivePatch, sessionId, shellRunId, revision: current.revision },
sessionId,
shellRunId,
);
if (!isValidShellRunStatusTransition(current.status, candidate.status)) {
throw new Error(`Invalid ShellRun status transition: ${current.status} -> ${candidate.status}`);
}
if (
isTerminalShellRunStatus(current.status) &&
(candidate.completedAt !== current.completedAt ||
candidate.exitCode !== current.exitCode ||
candidate.failureMessage !== current.failureMessage)
) {
throw new Error(`ShellRun terminal outcome is immutable: ${current.status}`);
}
if (shellRunRecordsEqual(candidate, current)) return current;
return normalizeShellRunRecord(
{ ...candidate, revision: current.revision + 1 },
sessionId,
shellRunId,
);
}
/**
* Structural equality for normalized ShellRun records. Records are plain
* JSON-safe data (string/number/boolean/null/arrays/objects), so a small
* recursive comparison is sufficient and keeps this module free of node:*
* imports so the renderer-facing `@maka/core` barrel stays browser-safe.
*/
function shellRunRecordsEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) {
return false;
}
if (Array.isArray(left) !== Array.isArray(right)) return false;
if (Array.isArray(left)) {
if (left.length !== (right as unknown[]).length) return false;
for (let index = 0; index < left.length; index += 1) {
if (!shellRunRecordsEqual(left[index], (right as unknown[])[index])) return false;
}
return true;
}
const leftKeys = Object.keys(left as Record<string, unknown>);
const rightKeys = Object.keys(right as Record<string, unknown>);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!Object.prototype.hasOwnProperty.call(right, key)) return false;
if (
!shellRunRecordsEqual(
(left as Record<string, unknown>)[key],
(right as Record<string, unknown>)[key],
)
) {
return false;
}
}
return true;
}
function isShellRunSandboxExecution(value: unknown): boolean {
if (value === undefined) return true;
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if (!hasOnlyKeys(value, new Set(['type', 'enforced']))) return false;
const execution = value as Record<string, unknown>;
return (
(execution.type === 'none' ||
execution.type === 'macos-seatbelt' ||
execution.type === 'linux' ||
execution.type === 'windows') &&
typeof execution.enforced === 'boolean' &&
execution.enforced === (execution.type !== 'none')
);
}
function isShellRunSandboxEscalation(value: unknown, execution: unknown): boolean {
if (value === undefined) return true;
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
if (!hasOnlyKeys(value, new Set(['commandHash', 'unsandboxed']))) return false;
const escalation = value as Record<string, unknown>;
const sandbox = execution as { type?: unknown; enforced?: unknown } | undefined;
return (
typeof escalation.commandHash === 'string' &&
escalation.commandHash.length > 0 &&
escalation.unsandboxed === true &&
sandbox?.type === 'none' &&
sandbox.enforced === false
);
}
function canonicalShellRunRecord(record: ShellRunRecord): ShellRunRecord {
return {
...(record.pid !== undefined ? { pid: record.pid } : {}),
shellRunId: record.shellRunId,
sessionId: record.sessionId,
...(record.sourceRunId !== undefined ? { sourceRunId: record.sourceRunId } : {}),
sourceTurnId: record.sourceTurnId,
sourceToolCallId: record.sourceToolCallId,
...(record.visibility !== undefined ? { visibility: record.visibility } : {}),
cwd: record.cwd,
command: record.command,
status: record.status,
startedAt: record.startedAt,
updatedAt: record.updatedAt,
...(record.completedAt !== undefined ? { completedAt: record.completedAt } : {}),
...(record.timeoutMs !== undefined ? { timeoutMs: record.timeoutMs } : {}),
...(record.exitCode !== undefined ? { exitCode: record.exitCode } : {}),
...(record.failureMessage !== undefined ? { failureMessage: record.failureMessage } : {}),
...(record.sandboxExecution !== undefined
? { sandboxExecution: { ...record.sandboxExecution } }
: {}),
...(record.sandboxEscalation !== undefined
? { sandboxEscalation: { ...record.sandboxEscalation } }
: {}),
revision: record.revision,
...(record.observedAt !== undefined ? { observedAt: record.observedAt } : {}),
output: canonicalShellOutput(record.output),
};
}
function canonicalShellOutput(output: ShellOutput): ShellOutput {
if (output.mode === 'pipes') {
return {
mode: 'pipes',
stdout: output.stdout,
stderr: output.stderr,
...(output.latestStream !== undefined ? { latestStream: output.latestStream } : {}),
stdoutTruncated: output.stdoutTruncated,
stderrTruncated: output.stderrTruncated,
redacted: output.redacted,
};
}
return {
mode: 'pty',
screen: output.screen,
scrollback: output.scrollback,
...(output.lastAlternateScreen !== undefined
? { lastAlternateScreen: output.lastAlternateScreen }
: {}),
cols: output.cols,
rows: output.rows,
cursor: { ...output.cursor },
alternateScreen: output.alternateScreen,
truncated: output.truncated,
redacted: output.redacted,
};
}
function hasOnlyKeys(value: object, allowed: ReadonlySet<string>): boolean {
return Object.keys(value).every((key) => allowed.has(key));
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isPositiveInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}