blob: 406bca17f89840716dedb645ceaf2dc99fd8a22a [file]
import type { StorageRef } from '@maka/core';
import {
isPathInside,
type BackgroundTaskStopper,
type EffectiveProductToolSurface,
type ProductToolSurfaceIdentity,
type PtyControlWriter,
type RuntimeResourceReader,
type ShellPlan,
type ShellRunLauncher,
} from '@maka/runtime';
import { isAbsolute } from 'node:path';
import type { Config, Task } from './contracts.js';
import type { HeavyTaskEvidenceRecorder } from './heavy-task-evidence.js';
import type { HeavyTaskModeSelection } from './heavy-task-policy.js';
import type { HeavyTaskProgressRecorder } from './heavy-task-progress.js';
import type { HeavyTaskSelfCheckRecorder } from './heavy-task-self-check.js';
import type {
EnvNetworkSecretPolicy,
TaskIsolationFacts,
ToolExecutorIdentity,
SupplementalToolSetIdentity,
} from './task-contracts.js';
import type { HeadlessSessionCapabilities } from './session-capabilities.js';
import type { HeadlessArtifactStore } from './headless-storage.js';
export interface IsolatedCommandInput {
command: string;
cwd: string;
timeoutMs?: number;
/**
* Bash-only opt-in. When true the executor keeps just the recoverable TAIL of
* a large output and never kills the command for output size. Omitted/false
* (the default) preserves FULL output up to the executor's buffer cap — so the
* Read/Glob/Grep command fallbacks return complete, head-first content instead
* of a silently head-dropped tail. Only buildIsolatedBashTool sets this.
*/
boundedTail?: boolean;
}
export interface IsolatedCommandResult {
exitCode: number;
stdout: string;
stderr: string;
stdoutTruncated?: boolean;
stderrTruncated?: boolean;
timedOut?: boolean;
}
export interface IsolatedToolExecutionControl {
abortSignal?: AbortSignal;
}
export interface IsolatedReadFileInput {
cwd: string;
path: string;
offset?: number;
limit?: number;
}
export type IsolatedReadFileResult =
| { content: string }
| {
kind: 'image';
mimeType: string;
ref: Extract<StorageRef, { kind: 'session_file' }>;
};
export interface IsolatedWriteFileInput {
cwd: string;
path: string;
content: string;
}
export interface IsolatedWriteFileResult {
ok: boolean;
path: string;
bytes: number;
}
export interface IsolatedEditFileInput {
cwd: string;
path: string;
oldString: string;
newString: string;
}
export interface IsolatedEditFileResult {
ok: boolean;
path: string;
replacements: number;
matchedVia?: string;
startLine?: number;
endLine?: number;
}
export interface IsolatedGlobInput {
cwd: string;
pattern: string;
searchCwd?: string;
}
export interface IsolatedGlobResult {
files: string[];
}
export interface IsolatedGrepInput {
cwd: string;
pattern: string;
path?: string;
glob?: string;
}
export interface IsolatedGrepResult {
matches: string[];
}
export const ISOLATED_HEADLESS_TOOL_NAMES = [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
] as const;
/**
* Managed shell sessions an executor can own, when its processes are LOCAL to
* the headless process.
*
* `IsolatedToolExecutor.exec` is deliberately stateless: one command in, one
* completed result out. That is the whole contract a remote bridge can honour,
* and it is why a model that needs an interactive or long-lived process today
* has to detach it with `nohup … &` and lose every handle on it.
*
* ShellRunProcessManager already owns the missing half — background runs, PTY
* screens, stdin writes, ref reads, termination — but it spawns inside the
* calling process, so only an executor whose workspace IS that process can
* expose it. The in-container Harbor cell is exactly that case; the Harbor HTTP
* bridge is not, and leaves this undefined.
*/
export interface IsolatedShellSessions
extends ShellRunLauncher,
RuntimeResourceReader,
BackgroundTaskStopper,
PtyControlWriter {
/**
* Environment for managed shell processes. REQUIRED, not optional: a launch
* that carries no env makes ShellRunProcessManager fall back to the headless
* process's own `process.env`, which in a Harbor cell holds the provider
* credentials the cell uses to call the model. `exec` strips those through
* childProcessEnv; managed launches must be handed the same stripped env.
*/
readonly commandEnv: NodeJS.ProcessEnv;
/**
* The executor's own default command timeout, so a managed foreground command
* is not killed earlier than the same operator's `exec` commands were.
*/
readonly defaultCommandTimeoutMs?: number;
/**
* Terminates every still-live managed session. Called once when the agent
* phase ends so no managed process survives into grading, and so PTY children
* are not orphaned by the cell process exiting.
*/
terminateAll(): Promise<void>;
}
/**
* Executes agent-visible shell commands outside the host credential process.
*
* Implementations can be a Harbor/Terminal-Bench environment, a Docker
* container, or another executor that gives the model a task workspace without
* inheriting host env/files. The headless runner does not infer that safety:
* callers must pass an explicit RealBackendIsolation record before any
* model-backed backend is allowed.
*/
export interface IsolatedToolExecutor {
exec(
input: IsolatedCommandInput,
control?: IsolatedToolExecutionControl,
): Promise<IsolatedCommandResult>;
/**
* Shell dialect this executor's Bash commands run in, surfaced so
* buildIsolatedBashTool can DECLARE it in the tool description. Selection
* without declaration is the original Windows bug (shell-detect.ts): the local
* executor runs PowerShell on Windows, and the model must be told. Omitted =
* POSIX (remote container / Linux host), where the historical description is
* the contract and no dialect sentence is added.
*/
shell?: ShellPlan;
/**
* Present only when this executor's processes run in the headless process, so
* the runtime's managed shell can own them. See IsolatedShellSessions.
*/
shellSessions?: IsolatedShellSessions;
/**
* Optional native file operations for executors that can address their
* external workspace without shelling through exec. If omitted,
* buildIsolatedHeadlessTools falls back to command-backed operations inside
* the isolated boundary.
*
* Read and Edit deliberately have NO native hook. Read must always run through
* READ_SCRIPT so every result carries the line-number / line+byte-cap / binary-
* guard contract (#92); a native pass-through would bypass it. Edit's matching
* logic must stay the single source of truth with the in-process builtin Edit,
* so buildIsolatedEditTool reads bytes through the executor, computes the edit
* in the headless process, and writes bytes back through the executor. Both
* hold regardless of which native ops an executor provides.
*/
writeFile?(
input: IsolatedWriteFileInput,
control?: IsolatedToolExecutionControl,
): Promise<IsolatedWriteFileResult>;
globFiles?(
input: IsolatedGlobInput,
control?: IsolatedToolExecutionControl,
): Promise<IsolatedGlobResult>;
grepFiles?(
input: IsolatedGrepInput,
control?: IsolatedToolExecutionControl,
): Promise<IsolatedGrepResult>;
}
export interface ExternalRealBackendIsolation {
kind: 'external';
/**
* Human-readable evidence for audit logs/errors, e.g. "Harbor task
* container" or "Docker workspace executor". It must be non-empty so a real
* backend cannot be enabled by an accidental truthy object.
*/
label: string;
/**
* Agent-visible workspace path when the external boundary owns the real
* filesystem. For Harbor HTTP this is the container cwd (for example `/app`),
* while the task-run control plane may still keep a local empty source
* workspace for ledger snapshots.
*/
workspaceDir?: string;
/**
* Persistent directory for freezing the agent-visible workspace after the
* run. Set only when the controller can read workspaceDir directly; remote
* executors such as Harbor HTTP must leave it unset.
*/
submittedSnapshotRoot?: string;
/**
* Optional command executor for callers that want to reuse the built-in
* headless Bash tool. A caller may omit this when its registered backend is
* already isolated internally.
*/
toolExecutor?: IsolatedToolExecutor;
}
export type RealBackendIsolation = ExternalRealBackendIsolation;
export interface HeadlessBackendContext extends Partial<HeadlessSessionCapabilities> {
config: Config;
task: Task;
/** Authoritative persistence root for this run. */
storageRoot: string;
/** Absolute throwaway workspace path for this run. */
workspaceDir: string;
/** Lease-bound storage for Headless-owned artifacts. */
artifactStore: HeadlessArtifactStore;
/**
* Present only for model-backed backends and only after the caller has
* explicitly asserted an isolation boundary.
*/
realBackendIsolation?: RealBackendIsolation;
/** Convenience alias for realBackendIsolation.toolExecutor. */
toolExecutor?: IsolatedToolExecutor;
/** One policy-filtered product-tool surface for this root Session / TaskRun. */
productToolSurface?: EffectiveProductToolSurface;
/** Heavy-task selection resolved for this task run. */
heavyTaskMode?: HeavyTaskModeSelection;
/** Present only when heavy-task mode is enabled for task-run backed tooling. */
heavyTaskProgress?: HeavyTaskProgressRecorder;
/** Present only when heavy-task mode is enabled for advisory public self-check tooling. */
heavyTaskSelfCheck?: HeavyTaskSelfCheckRecorder;
/** Present only when heavy-task mode is enabled for compact public evidence capture. */
heavyTaskEvidence?: HeavyTaskEvidenceRecorder;
}
/**
* Ends the agent phase's managed processes.
*
* Every headless orchestrator has to enforce the same rule — nothing the model
* left managed may still be running when the workspace is graded, and no PTY
* child may outlive the process that spawned it — so the rule is written once
* here and triggered from each orchestrator rather than remembered three times.
*
* Call it at the agent-to-verification handoff AND from the enclosing finally:
* the handoff covers the normal path, the finally covers the thrown one, and
* terminateAll is idempotent. Processes the model deliberately detached are not
* managed and are untouched — some tasks are graded against a service the agent
* was asked to leave running.
*/
export async function endManagedShellSessions(
isolation: RealBackendIsolation | undefined,
): Promise<void> {
try {
await isolation?.toolExecutor?.shellSessions?.terminateAll();
} catch {
// Best-effort: reaping is cleanup, and failing it must not mask the run's
// own outcome or replace a real error on the throwing path.
}
}
export function validateRealBackendIsolation(isolation: RealBackendIsolation | undefined): void {
if (!isolation) {
throw new Error(
'model-backed backend requires an isolated executor; pass realBackendIsolation with an explicit external isolation label',
);
}
if (isolation.kind !== 'external') {
throw new Error(
`unsupported real backend isolation kind: ${(isolation as { kind?: unknown }).kind}`,
);
}
if (typeof isolation.label !== 'string' || isolation.label.trim().length === 0) {
throw new Error('realBackendIsolation.label is required');
}
if (isolation.submittedSnapshotRoot !== undefined) {
if (!isolation.workspaceDir || !isAbsolute(isolation.workspaceDir)) {
throw new Error(
'realBackendIsolation.submittedSnapshotRoot requires an absolute workspaceDir',
);
}
if (!isAbsolute(isolation.submittedSnapshotRoot)) {
throw new Error('realBackendIsolation.submittedSnapshotRoot must be absolute');
}
if (isPathInside(isolation.workspaceDir, isolation.submittedSnapshotRoot)) {
throw new Error('realBackendIsolation.submittedSnapshotRoot must stay outside workspaceDir');
}
}
}
export function defaultEnvNetworkSecretPolicy(
isolation: RealBackendIsolation | undefined,
): EnvNetworkSecretPolicy {
if (isolation) {
return {
schemaVersion: 1,
env: 'inherit_none',
network: 'unrestricted_external_boundary',
secrets: 'brokered_by_executor',
};
}
return {
schemaVersion: 1,
env: 'inherit_none',
network: 'disabled',
secrets: 'none',
};
}
export function taskIsolationFacts(input: {
backendKind: string;
required: boolean;
isolation?: RealBackendIsolation;
assertionSource?: TaskIsolationFacts['assertionSource'];
validatedAt: number;
}): TaskIsolationFacts {
return {
schemaVersion: 1,
backendKind: input.backendKind,
required: input.required,
mode: input.isolation ? 'external' : 'inert_fake_backend',
...(input.isolation ? { label: input.isolation.label } : {}),
assertionSource: input.assertionSource ?? 'headless_deps',
validatedAt: input.validatedAt,
};
}
export function toolExecutorIdentity(input: {
executorId: string;
taskRunId: string;
attemptId?: string;
isolation?: RealBackendIsolation;
toolNames?: string[];
productToolSurface?: ProductToolSurfaceIdentity;
supplementalToolSets?: SupplementalToolSetIdentity[];
}): ToolExecutorIdentity {
const isolationMode = input.isolation ? 'external' : 'inert_fake_backend';
const supplementalToolSets = input.supplementalToolSets?.map((entry) => ({
label: entry.label,
toolNames: [...entry.toolNames],
}));
const toolNames = input.productToolSurface
? [
...input.productToolSurface.productToolNames,
...(supplementalToolSets?.flatMap((entry) => entry.toolNames) ?? []),
]
: (input.toolNames ?? ['headless_runtime']);
return {
schemaVersion: 1,
executorId: input.executorId,
taskRunId: input.taskRunId,
...(input.attemptId ? { attemptId: input.attemptId } : {}),
toolNames,
...(input.productToolSurface ? { productToolSurface: input.productToolSurface } : {}),
...(supplementalToolSets && supplementalToolSets.length > 0 ? { supplementalToolSets } : {}),
isolationMode,
label: input.isolation?.label ?? 'fake backend inert tool boundary',
commandPolicy: defaultEnvNetworkSecretPolicy(input.isolation),
};
}