@sema-agent/core
Version:
Stateless, task-oriented AI agent core
266 lines (265 loc) • 20.8 kB
TypeScript
import { type FileHandle } from "node:fs/promises";
import { type BoundFileObject, type ExecResult, type ExecutionEnv, ExecutionError, type ExecutionEnvExecOptions, FileError, type FileInfo, type Result } from "../harness/types.js";
import { type SecretEnvFinding } from "../../core/secret-env.js";
import type { EngineNotice } from "../../core/engine-notice.js";
import type { SymlinkChain } from "../harness/types.js";
import type { BackgroundHardKillOutcome, BackgroundShellCapability, BackgroundShellId, BackgroundPoll, BackgroundSpawnOptions } from "../../core/background-shell.js";
import { BackgroundShellError } from "../../core/background-shell.js";
import type { SchedulerCapability, ScheduledIntent, SchedulerContext, ScheduledTaskId, ScheduledTaskSummary } from "../../core/scheduler.js";
import { SchedulerError } from "../../core/scheduler.js";
/** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */
export declare function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined;
/** review finding (win32): `where bash.exe` can resolve to `System32\bash.exe` — the
* WSL LAUNCHER, not a native shell. Running it drops into a Linux subsystem where Windows cwd/paths
* do not exist; silently adopting it is exactly the "wrong shell, false green" failure D1 forbids.
* Filter it (and the WindowsApps alias) out of PATH discovery; Git-Bash candidates are tried first. */
export declare function isWslBashLauncher(p: string): boolean;
/** Shell discovery for the platform (ask ①: exported as the single SDK source so a host
* adapter does not mirror the platform logic). Explicit `customShellPath` wins (missing → err);
* win32 = Git-Bash discovery (ProgramFiles → PATH, WSL launcher filtered) with fail-loud
* `shell_unavailable` (never a silent cmd.exe fallback); POSIX = /bin/bash → PATH bash → sh. */
export declare function getShellConfig(customShellPath?: string): Promise<Result<{
shell: string;
args: string[];
}, ExecutionError>>;
/** test seam (mirrors the notice-seat announce reset): the once-per-process fallback latches. */
export declare function __resetSecretScrubAnnouncement(): void;
/** RB-235: the spool-reclaim decision, extracted PURE so every rule is unit-pinnable.
* Both reclaim sites (foreground `reclaimSpool`, background `syncSpool`) route through this one
* function so they cannot drift apart.
*
* The hazard: `truncateSync(path, 0)` while the writer is LIVE races its O_APPEND appends — a
* buffered write landing between the caller's stat and the truncate is destroyed whole
* (stdio-block granularity) and NO reader-side counter ever sees it (`truncated` /
* `bytesDroppedBeforeCursor` / `bytesFromStart` all undercount by exactly the raced amount). An
* earlier narrowing kept the truncate but moved it to the HARD cap, so live truncations became rare
* instead of routine; an external black-box audit of that build still measured whole 8192-byte
* blocks disappearing, so rarity was never the fix. Ruled 2026-08-03: drop truncate-under-a-live-
* writer entirely.
*
* The rule, in one line: **the file of a writer that can still append is append-only.** While the
* writer lives, only the reader's cursor moves; physical reclamation happens after it is terminal.
* - writer LIVE → never reclaim (no window exists to lose bytes in);
* - writer TERMINAL + fully consumed → truncate at the ROTATE mark (race-free);
* - writer TERMINAL + reader behind at HARD cap → truncate, byte-accounting the unread remainder
* (`skippedBytes` → recordSkippedBytes, the honest `[... N bytes truncated ...]` disclosure).
*
* Cost, stated honestly: a live producer that outruns the reader is no longer capped on disk — its
* spool grows for as long as it runs (bounded by the exec timeout / background hard wall, reclaimed
* in full at its death). Capping it would mean destroying bytes we cannot count, which is the defect
* itself; no portable Node syscall releases the consumed prefix of a file another process is
* appending to. The reader stays bounded regardless — `skipAhead` drops the un-retainable middle and
* accounts every skipped byte. The same unbounded-while-live posture already holds for the retain
* lane (design/128) and for the detached-descendant residual (RB-26), and matches the CC anchor's
* shape (named temp files, no rotation at all) with the file never outliving its writer.
*
* RESIDUAL, unchanged by this rule: `writerLive` describes the DIRECT child. A detached descendant
* holding the same fd can still append after the child is reaped, so a terminal-writer truncate can
* in principle race it — the documented RB-26 posture, and undetectable from this side of the fd. */
export declare function decideSpoolReclaim(args: {
size: number;
cursor: number;
writerLive: boolean;
rotateBytes?: number;
hardCapBytes?: number;
}): {
reclaim: false;
} | {
reclaim: true;
skippedBytes: number;
};
/** design/128 T1-1: open the spool file pair ALL-OR-NOTHING — a failure opening the
* second file must close the first fd before rethrowing (the caller's catch only clears its references, so
* a half-open pair would leak fdOut for the env's lifetime). `wx` + 0600 (never follow / never reuse an
* existing path). Exported for direct fault-injection tests (pre-create a path → EEXIST). `a` (not
* `w`): O_APPEND makes every child/descendant write an atomic append at the current end — which is
* what lets the foreground quota rotation truncate a fully-consumed file to zero and have the next
* write land at offset 0 (a plain-fd writer would keep its own offset and leave an unreadable hole). */
export declare function openSpoolPair(base: string): {
outPath: string;
errPath: string;
fds: [number, number];
};
export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCapability, SchedulerCapability {
cwd: string;
/** `ExecutionEnv.homeDir` = the `HOME` the shell child receives (its `~`/`$HOME`/`${HOME}` all expand to it), read LIVE off the same `shellEnv`-over-`inheritEnv` merge each exec gets; ABSENT when the
* child gets none (an allowlist that withholds it: `~` then falls back to passwd while `$HOME` is empty — two values, no single home, home-relative operands stay fail-closed). Residual: a per-exec `env.HOME` override. */
get homeDir(): string | undefined;
/** `ExecutionEnv.cdPath` = the `CDPATH` the shell child receives, read LIVE off the same merge each exec gets (never the
* engine process's own environment): its entries, `[]` when the child gets none. Residual: a per-exec `env.CDPATH`. */
get cdPath(): readonly string[];
/** The environment the next shell child would receive, for a READ (the scrub seat is a no-op: a read reports nothing).
* `undefined` when no child would be spawned at all — the policy is refused against the own credentials declared
* since construction, and every spawn throws that reason — so a reader sees "the child gets none", never a throw. */
private childEnvForRead;
private shellPath?;
private shellEnv?;
private inheritEnv;
private readonly scheduler?;
private readonly bgShells;
private bgCounter;
private readonly retainBackgroundProcesses;
private readonly onSecretEnvScrub?;
private readonly onNotice?;
/** design/128 T1-1: lazily-created env-owned spool dir (mkdtemp under os tmp — unpredictable, never the cwd). */
private bgSpoolDir?;
readonly backgroundCapabilities: BackgroundShellCapability["backgroundCapabilities"];
constructor(options: {
cwd: string;
shellPath?: string;
shellEnv?: NodeJS.ProcessEnv;
/** VENDORED EDIT (D1, security): how much of `process.env` the shell child inherits. Default `"scrub"` =
* fail-closed (drop secret-pattern keys, keep PATH/build/locale). `"all"` = full inherit (trusted env);
* `string[]` = strict allowlist. `shellEnv`/per-exec `env` always pass through. Any other value — an unknown
* word, a non-string, a list entry that is not a variable name — throws here (`inheritEnvPolicyProblem`'s
* reason), the same judgment the oracle gate's `inheritEnv` meets. Whatever the policy, the deployment's own
* credentials (`declareWithheldEnv`: by name, and by a value equal to a declared or engine-held credential value)
* are withheld from the inherited part; a list naming a declared name throws here when the declaration came
* first, and every spawn refuses it (`spawn_error` / `spawn_failed`) whenever it came. */
inheritEnv?: "all" | "scrub" | string[];
/** design/105: inject a resident scheduler daemon backend (the shell daemon). Absent ⇒ scheduling INERT. */
scheduler?: SchedulerCapability;
/**
* design/128 T1-1 — deliver-a-living-service mode (default false). When true, background processes
* OUTLIVE the run: `disposeBackgroundShells()` becomes a no-op (the runner's every-exit-path dispose
* included) and background stdio is spooled to env-owned temp files (0600, `wx`, unpredictable path)
* so the child never dies of EPIPE after the host exits. DEPLOYMENT-declared at env construction (the
* env lane; `TaskSpec.retainBackgroundProcesses` is the task lane, without this spooling guarantee on
* every platform — docs/background-process-lifetime.md). The 2h bg-timeout reaper and `killBackground` still work
* while the host lives; after host exit, cleanup is the deployment's responsibility (e.g. the TB
* container teardown). v1 covers processes spawned via `execBackground` only (a detach-adopted
* foreground exec keeps pipe stdio — best-effort survival). Incompatible with durable-suspend
* deployments (their pre-suspend dispose becomes a no-op too — do not combine).
*/
retainBackgroundProcesses?: boolean;
/** The D1 scrub decision's observation seat: called with every NON-EMPTY finding set withheld from a
* shell child — the default "scrub" leg's, and on any leg the declared own credentials the inherited part
* held (kind `own-credential`). Absent ⇒ a once-per-process console summary (loud enough to notice,
* quiet enough not to spam). An allow list withholds nothing else, so it reports nothing. */
onSecretEnvScrub?: (findings: readonly SecretEnvFinding[]) => void;
/** #1081 — the structured-notice seat for the SAME withheld-keys disclosure (`EngineNotice`,
* `code: "config.secret_env_scrubbed"`, `detail.names`); independent of `onSecretEnvScrub` (a raw
* findings collector) except: that seat wired alone stays console-silent, as before this existed. */
onNotice?: (notice: EngineNotice) => void;
});
get schedulerCapabilities(): SchedulerCapability["schedulerCapabilities"];
schedule(intent: ScheduledIntent, ctx: SchedulerContext): Promise<Result<{
id: ScheduledTaskId;
}, SchedulerError>>;
cancel(id: ScheduledTaskId, ctx: SchedulerContext): Promise<Result<void, SchedulerError>>;
list(ctx: SchedulerContext): Promise<Result<ScheduledTaskSummary[], SchedulerError>>;
absolutePath(path: string): Promise<Result<string, FileError>>;
joinPath(parts: string[]): Promise<Result<string, FileError>>;
exec(command: string, options?: ExecutionEnvExecOptions): Promise<Result<ExecResult, ExecutionError>>;
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
readTextLines(path: string, options?: {
maxLines?: number;
abortSignal?: AbortSignal;
}): Promise<Result<string[], FileError>>;
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
/** RB-221: whole-file replace via same-directory staging + fsync + rename (CC 2.1.220 `Q5`,
* (pretty220.js:60808, historical; not relocated in 250 — see cc-250 anchors)). The former single-step `writeFile` was an in-place O_TRUNC: an abort mid-write
* (Esc during a large Write — ctx.signal reaches here directly), ENOSPC, or a crash left the target as
* a torn prefix of the NEW content with the original unrecoverable. Post-fix contract: the target is
* either fully old or fully new; `aborted` means it was never touched. Known semantic change (same as
* CC): rename breaks a hard link — the other link keeps the old content. */
writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** RB-221: the historical single-step in-place overwrite — kept for targets where
* staging+rename would change node identity (symlink chains, non-regular files) and as the
* CC-parity fallback for filesystems where the rename cannot work. NOT atomic by design. */
private writeFileInPlace;
/** Atomic exclusive create ({@link FileSystem.writeFileExclusive}): Node `wx` flag = one
* `open(O_CREAT|O_EXCL)` syscall — an existing file returns FileError `"already_exists"` and is left
* untouched. The EEXIST→already_exists mapping lives HERE, on the exclusive-open step only — a parent
* `mkdir` can also throw EEXIST (parent path component is a regular file) and that is NOT "the target
* already exists" (fable review). Parent directories are created like writeFile. */
writeFileExclusive(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>>;
fileInfo(path: string): Promise<Result<FileInfo, FileError>>;
/**
* design/381 DV-8 — {@link FileInfo.mode}'s write half. NO-FOLLOW by construction: `lchmod` exists
* only on BSD, so a symlink at the leaf is REFUSED (`invalid`) instead of being traversed, which
* would re-permission whatever the link points at — the same posture every other guard in this
* family takes when it cannot verify what it is about to touch. Absent on platforms without the
* permission face ({@link HAS_POSIX_MODE_FACE}), where the caller's disclosure is the honest answer.
*/
setFileMode(path: string, mode: number, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
canonicalPath(path: string): Promise<Result<string, FileError>>;
/** {@link FileSystem.fileIdentity}: `stat` (links followed) read as BIGINT — an inode past 2^53 (an APFS volume root)
* is not exact as a number, and two inodes that collapse to one number would name two directories as one. */
fileIdentity(path: string): Promise<Result<string, FileError>>;
/** {@link FileSystem.openBound}: opened without following a leaf link and without blocking (a FIFO swapped in is refused,
* not waited on); right after the open the held object must be at the path ({@link heldIsAt}), and it must be a regular
* file or a directory. The held object reads by position from its descriptor and sizes by `fstat`; a directory lists by
* {@link listOpenDirectory}. win32 holds files only. */
openBound(path: string, abortSignal?: AbortSignal): Promise<Result<BoundFileObject, FileError>>;
/** Is the object just opened ({@link openBound}) at the canonical `resolved`? Linux answers for the descriptor itself: the
* kernel's path of the held object (`/proc/self/fd/<fd>`) must be `resolved` — whatever the path named before or after;
* an unreadable answer is no. Elsewhere Node has no descriptor→path (darwin: no `F_GETPATH`), so the path is read
* instead: it must name the held object (`lstat` identity) and be canonical — an ancestor turned into a link before the
* open and put back between those two reads passes. */
private heldIsAt;
/** The platform's listing of a directory already held open ({@link openBound}): Linux through the descriptor
* (`/proc/self/fd/<fd>` — the listing is of the held object whatever the path names); darwin, which cannot enumerate an
* open directory from Node (`/dev/fd/<fd>` of a directory answers ENOTDIR), lists the PATH once and requires it to
* name the held object afterwards — a path swapped away and back during that one listing can list another directory's
* entry NAMES (never bytes; every entry still meets its own read judgment). */
protected listOpenDirectory(held: FileHandle, resolved: string, identity: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
readLink(path: string): Promise<Result<string, FileError>>;
/**
* The LEAF symlink chain ({@link FileSystem.canonicalChain}): {@link walkSymlinkChain} over {@link readLinkTarget}
* (not the `readLink` method: a wrapper counting or overriding it sees only a caller's own calls).
*/
canonicalChain(path: string): Promise<Result<SymlinkChain, FileError>>;
exists(path: string): Promise<Result<boolean, FileError>>;
createDir(path: string, options?: {
recursive?: boolean;
}): Promise<Result<void, FileError>>;
remove(path: string, options?: {
recursive?: boolean;
force?: boolean;
}): Promise<Result<void, FileError>>;
createTempDir(prefix?: string): Promise<Result<string, FileError>>;
createTempFile(options?: {
prefix?: string;
suffix?: string;
}): Promise<Result<string, FileError>>;
/** design/116 detach: adopt a RUNNING foreground child as a background shell. Pre-fills the bg tails with
* the output captured so far (so TaskOutput's first read has the full history), attaches fresh data/close
* handlers, and arms the default bg timeout. Returns undefined when the live-shell limit is hit (detach
* refused; the exec keeps running in the foreground). */
private adoptRunningChild;
spawnBackground(command: string, options?: BackgroundSpawnOptions): Promise<Result<{
shellId: BackgroundShellId;
}, BackgroundShellError>>;
pollBackground(shellId: BackgroundShellId): Promise<Result<BackgroundPoll, BackgroundShellError>>;
killBackground(shellId: BackgroundShellId): Promise<Result<void, BackgroundShellError>>;
/** The synchronous hard kill seat ({@link BackgroundShellCapability.killBackgroundNow}). Reads process truth,
* never `entry.status` (other stops flip it before their attempt, which can fail or wait on a timer). Once
* node has observed the leader's exit its pid may name a stranger: `already_gone`, nothing signalled. Before
* that the leader is still our child (live or an unreaped zombie), so ONE `kill(-pid, SIGKILL)` reaches its
* group and nothing else — no descendant snapshot (a process that left the group is outside this seat).
* win32 has no synchronous hard kill: no process groups, and the tree kill's MSYS group discovery needs its
* deferred step, so this seat answers `no_pgid` and the caller's ordinary kill (the ladder) does the work.
* Only a landed signal sets the latch; a refused one is `no_pgid`. */
killBackgroundNow(shellId: BackgroundShellId): BackgroundHardKillOutcome;
/** design/128 T1-1: incrementally read a retained shell's spool files into the in-memory tails, so
* pollBackground serves the same cursor semantics whether stdio came over pipes or spool files.
* Bounded single read per stream per poll (the tail keeps only its rolling window anyway).
* reads are positioned by spool.fileCursor (the FILE offset — rotation truncates
* the file), while poll slicing keeps using the logical stream totalBytes; the two advance in
* lockstep here but are NOT interchangeable after a rotation. */
private syncSpool;
/** +: reclaim an EPHEMERAL spool (detach-adopted, or any non-retain bg
* spawn — the whole unix bg lane is spool-backed now) — fold the final residue into the tails,
* unlink both files, and drop the spool reference (later syncs become no-ops). Idempotent;
* never touches design/128 retain-lane spools (no `ephemeral` flag). A still-writing detached
* descendant keeps its fd into the unlinked inode (RB-26 posture — no EPIPE, auto-reclaim at
* its exit). */
private reclaimEphemeralSpool;
disposeBackgroundShells(opts?: {
except?: readonly BackgroundShellId[];
}): Promise<void>;
cleanup(): Promise<void>;
}