UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

258 lines (257 loc) • 19.5 kB
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>; }