import type { TerminationOutcome, TurnQuiescence } from './mojo-process-tree.js'; import type { SessionBackend, SessionAbortDestroyResult, SessionDestroyResult, SessionShutdownDetachResult, SpawnOpts } from './types.js'; import type { MojoAuthStatus, MojoLivePatch, EffectiveMojoConfig, MojoCancelOutcome, MojoLocalCloseResidual } from './mojo-types.js'; /** * Pre-exec cgroup enrolment shim (P0: the spawn→enrolment window). * * Post-spawn migration cannot capture descendants the child forked before the * parent's `cgroup.procs` write landed — cgroup v2 does not retroactively move * an existing process's descendants — so the enrolment has to happen INSIDE the * child, before any target code runs. `/bin/sh` writes its own pid into the * prepared boundary and only then `exec`s the real launch, keeping the same * pid. Between fork and the write the process executes just this shim, which * forks nothing, so every descendant of the target is born enrolled. * * The `|| exit 97` is the handshake: an enrolment failure must NOT fall through * to running a credentialed binary outside the boundary. Exit 97 is reserved — * the parent maps it to a refused turn (see MOJO_ENROLL_FAILED_EXIT). * * Invoked as: sh -c SHIM * ($0 = name, $1 = procs path; after `shift`, "$@" = bin + args.) */ export declare const MOJO_CGROUP_ENROLL_SHIM = "echo \"$$\" > \"$1\" || exit 97; shift; exec \"$@\""; export declare const MOJO_ENROLL_FAILED_EXIT = 97; export declare class MojoBackend implements SessionBackend { /** Mutable: applyLivePatch rotates credentials without a refork. */ private config; private readonly sessionId; private dataCb; private taskDoneCb; private exitCb; private taskIdCb; private turnFinalCb; private outputBuffer; /** * This turn's assistant answer, accumulated from the SAME text the user * sees on the card (emitText is the single choke point for model prose — * tool-call/warning chrome goes through emitLine and is deliberately left * out). Reset per turn in runTurn(); handed to turnFinalCb at settleTurn(). */ private turnFinalText; /** mojo-side session id — the resume lineage. */ private cliSessionId; private child; private killed; private closing; /** A close attempt observed evidence that something credentialed may still be * alive (an unproven local subtree, or a dispatched turn with no lineage). * * ONE-WAY for the lifetime of this backend: nothing clears it. The CLOSE stays * retryable though — a later destroySession() whose terminateChildProven() * succeeds proceeds to the remote cancel and returns ok:true — and that * liveness property IS covered by a test, because a fence that also wedged the * close would be worse than the bug it fixes. * * Honest scope note: the one-way lifetime itself is an implementation fact, not * a tested guarantee. Once a close succeeds `killed` refuses writes anyway, so * clearing this field at that point is an equivalent mutation (verified: it * survives). Do not read it as a proven invariant. */ private admissionFenced; /** Graceful daemon shutdown is a non-cancelling detach. Fence only writes * arriving after prepare, then wait just long enough for an already accepted * first turn to publish its `system/init` lineage. */ private shutdownDetaching; private shutdownDetachPrepared; private shutdownDetachAttempt; private shutdownDetachInFlight; private shutdownDetachAbortInFlight; private shutdownDetachWake; private lineageWaiters; /** At least one turn crossed the adapter boundary while no lineage was * known. A later process exit without `system/init` cannot prove that no * remote session was created, so shutdown must not persist authoritative * null merely because the local write promise settled. */ private acceptedWriteWithoutLineage; /** Inherited by every descendant of every turn, so the subtree stays * enumerable after setsid/reparenting. Per BACKEND, not per turn: a tool left * behind by an earlier turn must still be found. */ /** * Env nonce injected into the turn subtree, inherited by every descendant. * * NOT `readonly`, and NOT freshly random per instance: a replacement worker * generation builds a NEW backend for the SAME session, and a new nonce would * make the previous generation's tree unenumerable forever (the env signal is * the only one that survives setsid + reparenting). So it is adopted from an * inherited containment handle whenever one is outstanding. */ private treeNonce; /** * Worker generation, used only for operator-facing logs on the handle. Derived * from how many handles this session already has outstanding, so a replacement * generation is distinguishable from the first one. */ private readonly containmentGeneration; /** * The cgroup boundary prepared for the CURRENT turn, created before spawn so * the child can enrol itself pre-exec (see MOJO_CGROUP_ENROLL_SHIM). Null on * hosts without cgroup v2 delegation — those turns get a weak handle instead. */ private preparedBoundary; /** Realpath of this session's isolated workspace (host execution only). * Populated lazily by resolveCwd(); spawn and close share this exact * string so the close-side daemon-registry match cannot drift. */ private isolatedWorkspace?; /** True for control-plane-only instances (the workerless orphan-cancel * helper): they never run an agent turn, so isolating their cwd would * only mint a junk workspace dir (and potentially a junk daemon) for a * sentinel session id. */ private readonly controlPlaneOnly; /** One-shot resolver for the CURRENT runTurn promise, fired by settleTurn. * The turn is accounted for by its result event, never by the client * process ending — see runTurn for why the process may outlive the turn. */ private turnResolve; /** * Latched once a strong boundary proved unusable at runtime — the shim's * enrolment write was rejected (exit 97). The prepare-time probe only opens * cgroup.procs; a host that rejects the pid WRITE (delegation containment) * would otherwise fail EVERY turn with exit 97 forever. After the first such * failure this backend degrades to the weak post-spawn handle instead. */ private strongBoundaryUnusable; /** True for the turn currently in flight iff it launched through the cgroup * enrolment shim, so a genuine mojo `exit 97` is not misread as an enrolment * failure (and vice versa) on weak-handle hosts where no shim runs. */ private usedEnrolShim; /** * Latched when a spawned turn's containment handle could NOT be persisted * AND the started subtree could not be proven terminated afterwards. While * set, every close/destroy proof is refused: there is a tree nothing durable * describes, so publishing a closed row would drop the device-isolation * blocker over a subtree we cannot enumerate. */ private containmentUnrecorded; /** * Root pid of the most recent turn, kept AFTER `this.child` is cleared. * * The child's own `close` handler nulls `this.child`, so a later `/close` had * nothing left to check and skipped the subtree scan entirely — the exact hole * that let a survivor go unnoticed once its parent had exited. */ private lastTurnPid; /** * Recycle-proof identity of `lastTurnPid`, captured AT SPAWN. * * The pid number alone is not a handle: by the time teardown runs, the kernel * may have recycled it onto an unrelated process, and `kill(-pid)` would then * signal a stranger's whole process group. Binding boot id + starttime at * spawn is what lets the signal path prove it is still aiming at OUR child. */ private turnIdentity; /** * Evidence class of the last quiescence attempt. DIAGNOSTIC ONLY. * * The previous wording claimed the blocker decision requires * `boundaryProof === true` on this value. It did not, and still does not: * nothing in production reads `TurnQuiescence.boundaryProof`, so that was a * claim about code that was never written. The real gate is * `TerminationOutcome.boundaryProven` (see the close path below), which is * derived from `containmentReleaseDecision` in mojo-containment.ts. This field * is kept for logs and for tests that assert the grading, and it is read * through the `lastTurnQuiescence` getter only. */ private lastQuiescence; private lastTermination; /** True once the current turn has emitted its `result` event, so a late * process exit cannot fire a second turn boundary. */ private turnSettled; /** Buffer for partial NDJSON lines across stdout chunks. */ private stdoutTail; /** Set when --include-partial deltas have already rendered this turn's text, * so the trailing whole-segment `text` event isn't printed twice. */ private streamedThisTurn; private readonly cliTimeoutMs; /** How long /close waits for an in-flight turn to publish its session id * before tearing down. Must stay well under the worker's close/restart * race so teardown never becomes the thing that times out. */ private readonly destroySettleMs; /** * Captured from spawn(). The worker owns the authoritative cwd + env (the * BOTMUX_* session context, per-bot `env`, credential paths, proxies) and * hands them over exactly once; ignoring them silently drops repo selection, * per-bot tokens and proxy settings. `config` values still win where set, so * an explicit bots.json override remains authoritative. */ private spawnOpts; /** * Resolved launch PREFIX from BotConfig.wrapperCli (e.g. `env VAR=x mojo`, * a ttadk gateway). The worker resolves the prefix into a real bin + args and * passes them to spawn(); a PTY CLI is wrapped once for the life of its * process, but mojo is invoked per turn, so the prefix must be re-applied to * EVERY invocation. Null when no wrapper is configured, in which case the * plain binary is used. */ private launchPrefix; /** Guard so the config-side wrapper resolution is attempted at most once. */ private wrapperResolved; /** Resolved once per session — see resolveBin. */ private pinnedBin; /** * Live JWT, THREE states — the distinction is why a clear used to fail: * undefined → no live snapshot received; resolve from config/env as before * string → use exactly this * null → explicitly cleared; do NOT fall back to any config-layer env * * The daemon already folds the ambient fallback into the snapshot it sends, so * `null` genuinely means "no credential from any config layer". Previously a * clear only set `config.jwt = undefined`, and buildEnv then re-read `jwtEnv` * out of the init-time `config.env` / `injectEnv`, reviving a stale token. */ private liveJwt; /** * Generic CLI args the worker composed for this session (today: CLI_EXTRA_ARGS, * e.g. `--timeout 77`). The mojo adapter's buildArgs() returns [], so anything * arriving here came from the worker's shared arg pipeline and must be applied * to every turn — dropping it made the flag work with a wrapper configured * (buildWrappedLaunch folds spawnArgs into the prefix) but silently vanish * without one. */ private extraCliArgs; private writeChain; constructor(config: EffectiveMojoConfig, sessionId: string, opts?: { controlPlaneOnly?: boolean; }); spawn(bin: string, args: string[], opts: SpawnOpts): void; /** * Resolve the executable + leading args for one invocation, re-applying the * wrapperCli prefix when present. * * The prefix normally arrives pre-resolved from the worker via spawn(). The * daemon's workerless cancel path never calls spawn(), so when a wrapper is * configured but unresolved we resolve it here from the config — otherwise * `/close` would run an unwrapped binary that a wrapper-dependent setup * (e.g. a gateway that injects auth) cannot reach. */ private resolveLaunch; /** * Resolve the binary ONCE and reuse it for every turn of this session. * * Without pinning, a bare `mojo` was re-resolved on each turn against the * then-current PATH, so anything able to influence the environment between * turns could substitute the executable. The live patch no longer carries * `env` at all, but pinning removes the class of problem rather than one * instance of it — and it also keeps a session on one binary if PATH shifts * underneath a long-running worker. */ private resolveBin; /** * Find an executable using the PATH the CHILD will actually see. * * Layered exactly like buildEnv (worker env → per-bot injectEnv → mojo.env), * so a per-bot PATH override takes effect. Falls back to the caller's own PATH * when spawn() has not run (direct/unit use). */ private locateOnEffectivePath; /** Lazily resolve (and memoize) `config.wrapperCli` when spawn() never ran. */ private resolveConfiguredWrapper; /** bots.json `mojo.cwd` wins; otherwise the worker's session working dir. */ /** The operator-facing working directory (the repo). Kept separate from * resolveCwd(): host execution runs the CLI in an isolated per-session * directory instead, and the decorate() preamble points the agent back * here for repo work. */ private realWorkingDir; /** True when this config executes tools on the bot host (shared derivation * with buildEnv/buildArgs — see deriveMojoExecutionMode). */ private hostExecution; /** HOME for the isolated workspace root. Prefer the CHILD env the worker * hands over — in production it equals the daemon's own HOME (so the * workerless close path, which uses os.homedir(), matches), while in * tests it keeps backend instances from minting directories under the * developer's real ~/.botmux (the full suite did exactly that once). */ private isolationHome; private resolveCwd; write(data: string): boolean; /** * Rotate the JWT on a LIVE session, without a refork. * * Needed because the config is otherwise read once at worker init, so every * subsequent per-turn CLI invocation kept using the ORIGINAL token — a rotated * credential never took effect. * * Takes a COMPLETE snapshot rather than a sparse diff, so the two states that * a sparse patch could not express both work: * - `jwt: null` → cleared (a deleted `mojo.jwt` must not linger) * - `jwt: ` → rolled back (A → B → A must return to A) * * Only the JWT is patchable. An `env` patch would be equivalent to replacing * the launcher — see MOJO_LIVE_PATCH_KEYS. */ applyLivePatch(patch: MojoLivePatch): void; resize(_cols: number, _rows: number): void; onData(cb: (data: string) => void): void; /** * NOT fired on per-turn CLI exit — the binary is spawned and exits every * turn, so forwarding that would tear the session down after the first * reply. It IS fired from kill(), mirroring RiffBackend: the worker needs to * learn the backend is gone on teardown / daemon restart, and nothing else * tells it. */ onExit(cb: (code: number | null, signal: string | null) => void): void; /** Turn boundary — required: an API-backed backend produces no PTY output, so * botmux's idle detector never fires and nothing else re-arms prompt-ready. */ onTaskDone(cb: () => void): void; /** This turn's assistant answer, for the worker's final_output bridge. A * headless mojo session has no terminal the user could read instead, so an * answer the agent never `botmux send`s would otherwise reach nobody. */ onTurnFinal(cb: (text: string) => void): void; /** Lineage id updates — forwarded to the daemon so multi-turn context * survives a daemon restart. */ onTaskId(cb: (taskId: string | null) => void): void; captureCurrentScreen(): string; captureViewport(): string; getPaneSize(): { cols: number; rows: number; } | null; getChildPid(): number | null; /** * SIGTERM, then PROVE the child is gone (escalating to SIGKILL). * * `child.kill('SIGTERM')` returning true only means the signal was delivered. * A child that ignores it keeps executing with the injected credential while * the explicit close publishes the row as `closed` — and a closed row is * filtered out of the device-isolation inventory * (mergePersistedDeviceIsolationSessions), so the blocker vanishes with the * process still alive. That is exactly the state this backend must never * report as a successful teardown. * * Returns false when termination could not be proven; the caller must then * refuse the close rather than let the row be published as closed. */ /** Overridable so a behaviour test can exercise the escalation ladder without * burning the production budget in wall-clock. Production never changes it. */ protected get terminationProofBudgetMs(): number; /** Overridable so a test can point the scan at a synthetic /proc. */ protected get procRoot(): string; /** Overridable so a test can point boundary preparation at a synthetic * cgroup root. `undefined` = the real /sys/fs/cgroup. */ protected get cgroupRoot(): string | undefined; /** Overridable so a test can point the delegated-parent lookup * (/proc/self/cgroup) at a synthetic /proc. `undefined` = the real /proc. */ protected get cgroupProcRoot(): string | undefined; /** * SIGTERM the whole turn SUBTREE, then gather the best evidence this host can * give that nothing in it survives (escalating to SIGKILL). * * Read the result precisely. `ok: true` means "no executing member was * found", NOT "the credential is now unreachable". Enumeration cannot see a * descendant that both setsid'd and scrubbed its own environ, so a clean scan * is a DIAGNOSTIC signal only — `boundaryProven` is the field that says * whether a real boundary was established, and only kernel-level containment * (a per-session cgroup) can set it true. `ok: true` with * `boundaryProven: false` is therefore legal and, for Linux weak handles, the * common case; destroySession() below consumes the two fields separately and * downgrades the second case to a residual close. * * Three fail-open paths are closed here. * * 1. `child.kill()` returning true only means the signal was DELIVERED. A * child that ignores SIGTERM keeps executing with the injected credential * while the close publishes the row as `closed` — and a closed row is * filtered out of the device-isolation inventory, so the blocker vanishes * with the process still alive. * 2. Signalling the direct pid leaves DESCENDANTS alive. * 3. Signalling the process GROUP still leaves descendants that escaped it via * setsid/detached. Enumeration therefore unions PGID, the inherited env * nonce and the PPID chain — see mojo-process-tree. * * Reports `ok: false` whenever no such evidence could be obtained, INCLUDING when * the scan itself fails: "cannot enumerate" must never read as "nothing is * running". The caller then refuses the close, which keeps the row active and * so keeps the device-isolation blocker in place. (destroySession makes ONE * exception, for a platform that can never enumerate at all — see the * `unsupported-platform` branch there.) * * Zombie members are discounted, because a reaped process executes nothing and * cannot use the credential; only a definite `Z` state qualifies. */ private terminateChildProven; /** Structured evidence from the last termination attempt; see TerminationOutcome. */ get lastTerminationOutcome(): TerminationOutcome | null; /** Evidence class of the last termination attempt; see TurnQuiescence. */ get lastTurnQuiescence(): TurnQuiescence | null; /** * SIGTERM the whole turn SUBTREE, then try to establish that nothing in it * survives (escalating to SIGKILL), and report WHAT KIND of evidence we got. */ protected proveTurnQuiescence(): Promise; /** * Mint and PERSIST the containment handle for a freshly spawned turn root. * * Extracted so it can be exercised directly: doing this at close time would be * too late, because a crash between spawn and record is exactly the window that * loses the tree, and a lost tree can never be proven quiescent afterwards. */ private recordTurnContainment; /** * Terminate a subtree whose containment handle could not be persisted. * * `containmentUnrecorded` is already latched by the caller. It is cleared * only when the boundary is PROVEN empty (rmdir accepted by the kernel) — * anything less keeps every close proof refused, because a tree nothing * durable describes must not be closable. */ private containUnrecordedSpawn; /** * Discharge every DURABLE handle this session owns, after the in-memory ladder * believes its own root is gone. * * The in-memory verdict only ever speaks for the pid THIS backend spawned. A * replacement generation inherits handles describing trees it never spawned, and * those must be proven independently or the close stays refused. A handle leaves * the store only against a `proven: true` verdict — `releaseContainmentHandle` * takes the verdict itself and throws on anything else, so "clear the blocker * without proof" is not representable here. */ private dischargeContainment; /** Pids that must never be signalled, whatever a scan says. */ private selfPids; /** * SIGKILL an inherited tree whose recorded root identity has already been * re-verified by the caller. * * SIGTERM is skipped deliberately: this tree belongs to a previous worker * generation that is already gone, so nobody is waiting to shut it down * gracefully, and the graceful attempt was made when that generation closed. * Every enumerated member is signalled individually as well, because a * descendant may have left the group via setsid and would survive the group * signal alone. * * Best effort by design: this only creates the CHANCE for the proof below to * succeed. If anything survives, the proof still refuses the close. */ private signalInheritedTree; kill(): void; /** Test-only view of the adopted lineage, so a teardown test can assert it is * still unset inside the pre-init window it is exercising. */ get cliSessionIdForTest(): string | undefined; /** /close teardown — cancel the server-side session so it stops consuming * cloud sandbox time after the IM session is gone. */ destroySession(): Promise; /** * Roll back a FAILED prepare (restore write admission). * * Only valid when the cancel did not succeed. A proven cancel is irreversible: * the remote session is gone, so restoring admission would produce a session * that looks active but can never continue. */ abortDestroySession(): SessionAbortDestroyResult; /** * Prepare a daemon-restart detach without cancelling the remote Mojo * session. Unlike Riff, a Mojo turn can legitimately run for 60 seconds; * shutdown only needs the lineage from its first `system/init`, not the * whole answer. Therefore a pre-init turn waits at most destroySettleMs, * while a known lineage (or an idle backend with no accepted turn) prepares * immediately. */ prepareShutdownDetach(): Promise; abortShutdownDetach(): Promise; commitShutdownDetach(): void; private buildArgs; /** Retry the "session still RUNNING" race with backoff (see SESSION_BUSY_RE). */ private runTurnWithBusyRetry; /** Resolves `true` when the turn was rejected because the session is still * RUNNING (caller should retry), `false` once the turn is accounted for. */ private runTurn; /** * Drop a dead resume lineage so the NEXT message starts a fresh session * instead of re-sending the same doomed `-r ` forever. * * Mirrors RiffBackend's broken-lineage path: the `null` broadcast is what * clears the DAEMON-side persisted id — without it a daemon restart would * resurrect the very session we just declared dead. * * Returns true when the lineage was dropped (caller must not treat the turn * as a generic failure). */ private maybeDropLineage; /** Parse NDJSON incrementally — a chunk may split a line in half. */ private consume; private flushTail; private handleLine; private adoptSession; private handleResult; /** * Fire the turn boundary exactly once. * * This is the ONLY authority on when a mojo turn ends, which is why the * worker must not run its generic IdleDetector for this backend: that * detector infers "done" from ~2s of output quiescence, and a mojo turn goes * quiet for far longer while a tool runs. An early idle would re-arm * prompt-ready mid-turn, flushing queued messages into a session that is * still RUNNING (rejected — see SESSION_BUSY_RE) and attributing the reply * to the wrong turn/card. See the `isRemoteBackendType` gate in worker.ts. */ private settleTurn; /** `error` is an object ({code, message, retryable}) on both envelope shapes; * naive interpolation yields "[object Object]". */ private fmtErr; /** Condense a tool_result payload into one status line. The output is a JSON * string for shell-like tools ({return_code, stdout, stderr, status}) but may * be arbitrary text for others, so both shapes are handled. */ private summarizeToolResult; private clip; private summarizeInput; /** * Prepend the platform-owned skill block and the operator's systemPrompt. * * Order matters and is deliberate: the skill catalog is APPENDED after the * operator prompt, never merged into it. Folding it into `systemPrompt` would * mean a bot that sets its own prompt silently loses skill discovery — the * same trap riff documented for its mandatory routing rules. * * `builtinSkillBlock` is only populated for `prompt` / `off`; in `global` * mode the files are already on disk (~/.mojo/skills) so it stays empty. */ private decorate; /** * Host-execution guidance (undefined in cloud mode). Two compensations for * the isolated per-session cwd: * 1. the initial working directory is NOT the repo — point the agent at * the real one so repo work still lands in the right place; * 2. every botmux command carries an explicit `--session-id`: the * execution daemon's env belongs to whichever session spawned it, so * an inherited BOTMUX_SESSION_ID must never be what routes a reply * (defence in depth — the isolated daemon already carries the right * env, this survives even a regression back to a shared daemon). */ private hostGuidanceBlock; private buildEnv; /** Single-shot CLI call returning one JSON envelope (session.* subcommands). */ private runCliJson; private runCli; /** Probe the authoritative model list: an invalid --model exits 2 and prints * "可用模型:a、b、c" to stderr. */ probeModels(): Promise; /** `mojo auth status --json` → {logged_in, identity, mode, source, expires_at}. */ authStatus(): Promise; private emitLine; /** Normalize newlines for xterm rendering (bare \n → \r\n). */ private emitText; } /** * Cancel a mojo session by id WITHOUT a live backend instance. * * The daemon needs this on the workerless `/close` path: the worker is already * gone, so `MojoBackend.destroySession()` is unreachable, yet the server-side * session must stop consuming cloud sandbox time (and stop an agent that may * still hold injected credentials). Mirrors `cancelRiffTaskById`. * * One retry, then a STRUCTURED outcome — see MojoCancelOutcome for why this is no * longer a boolean. */ export declare function cancelMojoSessionById(config: EffectiveMojoConfig, sessionId: string): Promise; /** The workerless local-subtree proof, split into its two independent answers. */ export interface WorkerlessLocalSubtreeProof { /** Non-null → quiescence itself is unproven; the close must be REFUSED. */ unproven: MojoCancelOutcome | null; /** * Quiescence was proven, but at least one release decision kept its handle * (weak evidence carries no boundary proof), so the close may proceed ONLY * as `closed_with_residual`. Discarding this — the old contract returned * bare null here — published a plain `closed` row while the handle and the * device-isolation blocker silently stayed behind. */ residual: MojoLocalCloseResidual | null; } /** * Prove (and discharge) the LOCAL subtree a dead worker may have left behind. * * `unproven: null` means every outstanding handle was proven quiescent (or none * existed). `residual` then reports whether any of those proofs was weak-only, * in which case the handle stays in the store and the caller must surface a * residual close instead of a plain one. */ export declare function proveWorkerlessLocalSubtree(sessionId: string, opts?: { procRoot?: string; }): WorkerlessLocalSubtreeProof; //# sourceMappingURL=mojo-backend.d.ts.map