@sema-agent/core
Version:
Stateless, task-oriented AI agent core
258 lines (257 loc) • 19.5 kB
TypeScript
import { 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;
/**
* Anchor a RELATIVE symlink target at its link's own directory, UNFOLDED and with exactly one separator
* between the two. A directory that IS a root already ends in its own separator (`/`, `C:\`, `C:/`,
* `\\host\share\`, all of which `dirname` answers that way), and concatenating a second one mints a
* DOUBLE-separator spelling for a path that has one spelling — `/tmp -> private/tmp` came back as
* `//private/tmp`, which the judges that walk this chain then match against nothing, because a guarded row
* and a containment root are both written with single separators. Joining through `path.resolve`/`path.join`
* would fix the separator and fold `..` with it, which is the one thing this walk may not do.
*
* `separator` is the one the caller's `dirname` writes, passed rather than read from the module so the rule
* is a pure function of its inputs. A backslash counts as a terminator only where it IS a separator: on a
* POSIX tree a directory whose name ends in one (`/a/b\`) is an ordinary name, and reading it as terminated
* would rename `/a/b\/t` to `/a/b\t` — the same class of rewrite as folding `..` lexically.
*/
export declare function anchorAtLinkDir(linkDir: string, target: string, separator: string): string;
/** 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;
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. */
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 the default
* "scrub" leg withholds from a shell child. Absent ⇒ a once-per-process console summary (loud
* enough to notice, quiet enough not to spam). Never called on the "all"/allowlist legs. */
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>>;
readLink(path: string): Promise<Result<string, FileError>>;
/**
* The LEAF symlink chain ({@link FileSystem.canonicalChain}): `readlink` on the whole spelling, then on each
* target, until a hop is not a link (`EINVAL`), is absent (`ENOENT` / `ENOTDIR`), repeats, or the bound is
* reached. A relative target is ANCHORED at its link's directory and never folded — `..` is the filesystem's
* to resolve during the next `readlink`'s own lookup (a lexical fold reads `bridge/../next` as `next` and misses
* every link behind `bridge`). Anchoring puts EXACTLY ONE separator between the directory and the target
* ({@link anchorAtLinkDir}); a root directory already ends in its own. One syscall per hop, no `lstat`. On a
* win32 host a share-shaped hop is kept and ends the walk unprobed. Any other errno is an answer nobody can
* use (`err`), never a shortened chain.
*/
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>;
}