@struktoai/mirage-dsh 0.0.7-alpha.2 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +28 -29
- package/cordis.patch.yml +38 -6
- package/dist/{chunk-M4JOF35U.js → chunk-FRF6WPLN.js} +133 -44
- package/dist/chunk-FRF6WPLN.js.map +1 -0
- package/dist/chunk-G2GHA2F6.js +211 -0
- package/dist/chunk-G2GHA2F6.js.map +1 -0
- package/dist/{chunk-QTBMLMKT.js → chunk-KUIV2Z3M.js} +243 -326
- package/dist/chunk-KUIV2Z3M.js.map +1 -0
- package/dist/{chunk-4CZV24HW.js → chunk-OHZACFWO.js} +36 -14
- package/dist/chunk-OHZACFWO.js.map +1 -0
- package/dist/{chunk-2F2PJ7D7.js → chunk-OZ2F4R6T.js} +74 -17
- package/dist/chunk-OZ2F4R6T.js.map +1 -0
- package/dist/chunk-QH5H3VND.js +110 -0
- package/dist/chunk-QH5H3VND.js.map +1 -0
- package/dist/chunk-T4BSJAGN.js +64 -0
- package/dist/chunk-T4BSJAGN.js.map +1 -0
- package/dist/{fs-B3Zi9oo6.d.ts → fs-BNOIFi9o.d.ts} +48 -4
- package/dist/index.d.ts +8 -3
- package/dist/index.js +15 -6
- package/dist/plugin/fs.d.ts +1 -1
- package/dist/plugin/fs.js +2 -2
- package/dist/plugin/service.d.ts +2 -1
- package/dist/plugin/service.js +1 -1
- package/dist/plugin/shell.d.ts +1 -1
- package/dist/plugin/shell.js +3 -2
- package/dist/plugin/spill-store.d.ts +3 -0
- package/dist/plugin/spill-store.js +8 -0
- package/dist/plugin/spill-store.js.map +1 -0
- package/dist/plugin/subprocess.d.ts +8 -0
- package/dist/plugin/subprocess.js +9 -0
- package/dist/plugin/subprocess.js.map +1 -0
- package/dist/{service-BK6NR_RX.d.ts → service-vCjLPYMI.d.ts} +29 -17
- package/dist/{shell-BTf2wsDy.d.ts → shell-BAyBocjm.d.ts} +25 -35
- package/dist/spill-store-BfljgRi0.d.ts +38 -0
- package/dist/subprocess-CInesMpR.d.ts +23 -0
- package/package.json +24 -12
- package/dist/chunk-2F2PJ7D7.js.map +0 -1
- package/dist/chunk-4CZV24HW.js.map +0 -1
- package/dist/chunk-M4JOF35U.js.map +0 -1
- package/dist/chunk-QTBMLMKT.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shell.ts"],"sourcesContent":["// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport { DSH_ENV_PREFIX, ShellExecutor } from '@deepseek-ai/dsh-shell'\nimport type {\n CollectedOutput,\n ShellExecRequest,\n ShellExecSpec,\n ShellExecution,\n ShellProcessRead,\n ShellProcessStatus,\n ShellRunResult,\n ShellSandboxInfo,\n} from '@deepseek-ai/dsh-shell'\nimport type { SubprocessOutputRead } from '@deepseek-ai/dsh-subprocess'\nimport {\n Channel,\n JobConsole,\n KILLED_OUTCOME,\n exitOutcome,\n} from '@struktoai/mirage-core/shell/console/index'\nimport type {\n ConsoleChunk,\n ConsoleStore,\n ReadResult,\n} from '@struktoai/mirage-core/shell/console/index'\nimport { setCwd } from '@struktoai/mirage-core/workspace/session/shell_dirs'\nimport { sessionView } from '@struktoai/mirage-core/workspace/session/state'\nimport type {\n ExecuteOptions,\n ExecuteResult,\n} from '@struktoai/mirage-core/workspace/workspace/workspace'\nimport type { Workspace } from '@struktoai/mirage-node'\nimport { StreamTail, TailBuffer } from './text.ts'\nimport { SpillSink, ensureDirPath, type SpillTarget } from './spill.ts'\n\nimport type { Refusal } from '@struktoai/mirage-core/types'\nimport { refusalLine } from '@struktoai/mirage-core/workspace/tools/io_text'\nimport { rstripSlash } from '@struktoai/mirage-core/utils/slash'\n\nconst DEFAULT_TIMEOUT_MS = 120_000\nconst MAX_TIMEOUT_MS = 600_000\nconst DEFAULT_STDOUT_MAX_BYTES = 200_000\nconst DEFAULT_STDERR_MAX_BYTES = 64_000\nconst STDERR_MARKER = new TextEncoder().encode('\\n--- stderr ---\\n')\n// Monotonic within the process, so concurrent background commands never\n// collide on a spill filename. Not reset, so it needs no time or randomness.\nlet spillCounter = 0\n\n// The mount whose writability a `read-only` policy keeps, because dsh's\n// own definition of that mode keeps it: \"permits only required sinks such\n// as /dev/null\". Narrowing it too would make `cmd > /dev/null` fail, which\n// no read-only sandbox anywhere does.\nconst SINK_PREFIX = '/dev'\n\n// How mirage refuses a write the session's mount grants do not allow: the\n// write itself is refused, in the read-only voice, whether a command or a\n// redirection made it. Hide refusals keep `Permission denied`. Only\n// consulted for a call that ran under `read-only`, so the only permission\n// error these can catch is the one this executor just imposed.\nconst DENIAL_SIGNATURES = ['read-only mount at ', ': Permission denied', ': Read-only file system']\n\n/** Configuration for the mirage shell executor. */\nexport interface MirageShellConfig {\n /**\n * Default working directory for commands. Defaults to `/`. With\n * `sessionId` it instead seeds the bound session's initial cwd, and the\n * session's own cwd is the default from then on.\n */\n workdir?: string\n /** Default foreground timeout in milliseconds. Defaults to 120000. */\n defaultTimeoutMs?: number\n /** Upper cap on any requested timeout. Defaults to 600000. */\n maxTimeoutMs?: number\n /** Default stdout capture budget in bytes. Defaults to 200000. */\n stdoutMaxBytes?: number\n /** stderr capture budget in bytes. Defaults to 64000. */\n stderrMaxBytes?: number\n /**\n * Bind every command to this named workspace session. By default each\n * command runs in an ephemeral fork of the workspace's default session,\n * so nothing persists between calls, which is the one-shot contract of\n * dsh's bash tool. With a session bound, `cd`, `export`, and function\n * definitions persist across calls, the persistent-shell contract. The\n * session is created on first use if the workspace does not have it; an\n * existing session is adopted as is.\n *\n * A spec carrying an explicit `env`, or a `workdir` that names a real\n * directory in this world, still runs as a one-call subshell of the\n * bound session, per mirage's `ExecuteOptions` semantics: both say\n * \"just for this command\". The two things dsh injects on every call\n * are deliberately not read that way, since neither carries that\n * intent and either would fork every command and leave the binding\n * with nothing to persist. A workdir resolved on the harness's own\n * machine names nothing here and is dropped; the managed `DSH_*`\n * snapshot is seeded into the session instead.\n */\n sessionId?: string\n /**\n * When set, a background command whose streamed output overruns its\n * delta budget spills its full stdout and stderr to files under this\n * workspace directory, and `readOutput()` points at them so a reader\n * can recover what the delta dropped. The directory is a workspace\n * path (e.g. `/tmp` on a ram mount), so the agent reads the spill\n * through the same VFS as everything else; the writes go through the\n * workspace, so they appear in history like any other write. Unset\n * (the default) means no spill: output that overruns is simply\n * flagged `lossy`, the honest \"no safe path available\" answer.\n */\n spillDir?: string\n}\n\nfunction executeOptions(\n spec: ShellExecSpec,\n workdir: string,\n signal: AbortSignal,\n sessionId: string | undefined,\n bound: boolean,\n fallbackWorkdir: string,\n sink?: JobConsole,\n): ExecuteOptions & { stream?: false } {\n // A per-call `env` makes mirage fork a subshell, exactly as `cwd` does,\n // so what goes in it decides whether anything can persist. Bound to a\n // session, only a genuine per-call override belongs here: dsh sends a\n // non-empty managed `DSH_*` snapshot on every single call, and carrying\n // that per call would fork every command and quietly undo the binding.\n // Those facts are seeded into the session instead, by `applyManagedEnv`.\n const managed = (spec.dshEnv as Record<string, string> | undefined) ?? {}\n const env = bound ? { ...(spec.env ?? {}) } : { ...(spec.env ?? {}), ...managed }\n // Unbound, `cwd` is always present so every command runs in an ephemeral\n // fork: isolation must not hinge on a spec happening to carry a workdir,\n // and a read-only call runs in a *named* twin session, so without a cwd\n // its `cd` and exports would persist into the next nominally one-shot\n // call. Bound, an absent workdir runs in the session itself, which is\n // what lets its state persist. Either way the decision is the binding's,\n // never the session this particular call happens to land in.\n const cwd = workdir !== '' ? workdir : bound ? undefined : fallbackWorkdir\n return {\n signal,\n ...(sessionId !== undefined ? { sessionId } : {}),\n ...(cwd !== undefined ? { cwd } : {}),\n ...(Object.keys(env).length > 0 ? { env } : {}),\n ...(spec.stdin !== undefined ? { stdin: new TextEncoder().encode(spec.stdin) } : {}),\n ...(sink !== undefined ? { sink } : {}),\n }\n}\n\n/** Where and as whom one command runs, settled before it starts. */\ninterface Prepared {\n ws: Workspace\n sessionId: string | undefined\n bound: boolean\n workdir: string\n}\n\n/**\n * Wait for `work` until `signal` fires; the reason the signal carries is\n * then the rejection, and the work runs on unwatched.\n *\n * @param work the step being waited for.\n * @param signal the signal that ends the wait.\n * @returns what the step resolved with.\n */\nfunction untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const abort = (): void => {\n reject(signal.reason as Error)\n }\n signal.addEventListener('abort', abort, { once: true })\n work.then(resolve, reject).finally(() => {\n signal.removeEventListener('abort', abort)\n })\n })\n}\n\n/** How a settled execution ended, read once it has: the first cause wins. */\ninterface Classification {\n timedOut: boolean\n aborted: boolean\n}\n\n/** What an execution needs from its executor, beyond the run itself. */\ninterface ExecutionParts {\n controller: AbortController\n /** Budget of the consuming `readOutput` backlog. */\n budget: number\n stdoutMaxBytes: number\n stderrMaxBytes: number\n timeoutMs: number\n spill: SpillSink | null\n classify: () => Classification\n /** The sandbox facts once the run settled, its denial read off them. */\n verdict: (result: ExecuteResult | null, stderr: string) => ShellSandboxInfo | undefined\n disarm: () => void\n}\n\n/**\n * The console store one execution streams through: each chunk goes to the\n * execution as the command emits it, and none is retained. The command\n * awaits every emit, so a spill write holds it back rather than piling up\n * behind it, and no retention budget can drop a chunk before it was read:\n * memory holds only what the execution's bounded tails and backlog keep.\n */\nclass ExecutionStore implements ConsoleStore {\n private nextSeq = 0\n private isClosed = false\n private waiters: (() => void)[] = []\n\n constructor(private readonly deliver: (chunk: ConsoleChunk) => Promise<void>) {}\n\n get closed(): boolean {\n return this.isClosed\n }\n\n async append(channel: Channel, data: Uint8Array): Promise<ConsoleChunk> {\n const chunk: ConsoleChunk = { seq: this.nextSeq, ts: Date.now() / 1000, channel, data }\n this.nextSeq += 1\n await this.deliver(chunk)\n return chunk\n }\n\n readFrom(): Promise<ReadResult> {\n return Promise.resolve([[], this.nextSeq, false])\n }\n\n wait(): Promise<void> {\n if (this.isClosed) return Promise.resolve()\n return new Promise<void>((resolve) => {\n this.waiters.push(resolve)\n })\n }\n\n close(): Promise<void> {\n this.isClosed = true\n for (const resolve of this.waiters.splice(0)) resolve()\n return Promise.resolve()\n }\n}\n\n/**\n * One command over the workspace executor, streamed through a `JobConsole`:\n * the handle `execute` returns, whether the caller awaits `result()` (a\n * foreground run) or keeps the handle (a background one).\n *\n * The command runs with the console as its `sink`, so each statement of a\n * compound line lands as it finishes rather than the whole line arriving at\n * the end (a single command still shows up in one chunk, having nothing to\n * emit before it completes). Each chunk goes to three places: the consuming\n * `readOutput` backlog, which hands back and clears, so consecutive reads\n * never re-deliver; and one bounded tail per stream, which `observed` reads\n * at a caller's own offsets and `result()` projects once the command is\n * over. Unread output is bounded on every path: a tail or the backlog that\n * overruns its budget drops its head, keeping the tail, and the full stream\n * moves to spill files. `kill()` aborts cooperatively (the executor observes\n * the signal between pipeline stages and inside sleep).\n */\nclass MirageShellExecution implements ShellExecution {\n status: ShellProcessStatus = 'running'\n exitCode: number | null = null\n signal: NodeJS.Signals | null = null\n sandbox?: ShellSandboxInfo\n readonly done: Promise<void>\n readonly observed: ShellExecution['observed']\n\n private readonly parts: ExecutionParts\n private readonly console: JobConsole\n private readonly pending: TailBuffer\n private readonly stdoutTail: StreamTail\n private readonly stderrTail: StreamTail\n private failure: { error: unknown } | null = null\n private settledResult: Promise<ShellRunResult> | null = null\n private lossy = false\n private inStderr = false\n private settled = false\n\n /**\n * @param launch starts the command streaming into the console it is\n * handed; null when the deadline expired while it was being prepared.\n * @param parts what the execution needs from its executor.\n */\n constructor(\n launch: ((sink: JobConsole) => Promise<ExecuteResult>) | null,\n parts: ExecutionParts,\n ) {\n this.parts = parts\n this.console = new JobConsole(\n new ExecutionStore((chunk) =>\n chunk.channel === Channel.CONTROL ? Promise.resolve() : this.appendChunk(chunk),\n ),\n )\n this.pending = new TailBuffer(parts.budget)\n this.stdoutTail = new StreamTail(parts.stdoutMaxBytes)\n this.stderrTail = new StreamTail(parts.stderrMaxBytes)\n this.observed = {\n stdout: { readFrom: (from) => this.readStream(this.stdoutTail, from, 'stdout') },\n stderr: { readFrom: (from) => this.readStream(this.stderrTail, from, 'stderr') },\n }\n // A null launch is a deadline that expired while the command was still\n // being prepared: it settles at once, timed out, with no output.\n this.done =\n launch === null\n ? this.settleExpired()\n : launch(this.console).then(\n (result) => this.settle(result, null),\n (err: unknown) => this.settle(null, err),\n )\n }\n\n private readStream(\n tail: StreamTail,\n fromByte: number,\n channel: 'stdout' | 'stderr',\n ): SubprocessOutputRead {\n const read = tail.readFrom(fromByte)\n const spillPath =\n channel === 'stdout' ? this.parts.spill?.stdoutPath : this.parts.spill?.stderrPath\n return { ...read, ...(spillPath !== undefined ? { spillPath } : {}) }\n }\n\n private async appendChunk(chunk: ConsoleChunk): Promise<void> {\n const spill = this.parts.spill\n // The full, uncapped stream goes to the spill sink (if enabled)\n // before anything is capped, so nothing a tail or the backlog drops\n // is lost to a reader that follows the spill path.\n if (spill !== null) await spill.ingest(chunk.channel, chunk.data)\n const tail = chunk.channel === Channel.STDERR ? this.stderrTail : this.stdoutTail\n tail.append(chunk.data)\n // stderr rides the same backlog as stdout, opened by a marker so the\n // reader can tell the two apart; a run of stderr chunks marks once.\n let dropped = false\n if (chunk.channel === Channel.STDERR) {\n if (!this.inStderr) {\n dropped = this.pending.append(STDERR_MARKER)\n this.inStderr = true\n }\n } else {\n this.inStderr = false\n }\n // The backlog bounds itself as it grows, so a reader that never\n // drains cannot grow it without limit and an append costs the chunk\n // rather than everything buffered before it. The tail is kept (the\n // freshest output).\n dropped = this.pending.append(chunk.data) || dropped\n if (dropped) this.lossy = true\n // Something just dropped bytes; move the full stream to files so a\n // reader can still recover them from the spill path.\n if ((dropped || tail.truncated) && spill !== null) await spill.begin()\n }\n\n private async settle(result: ExecuteResult | null, err: unknown): Promise<void> {\n this.settled = true\n let outcome: string\n if (result !== null) {\n this.status = 'completed'\n this.exitCode = result.exitCode\n outcome = exitOutcome(result.exitCode)\n } else {\n this.status = 'killed'\n this.signal = 'SIGTERM'\n outcome = KILLED_OUTCOME\n // Mirage answers an abort by throwing, so only a throw no abort\n // explains is an infrastructure failure: `result()` rejects with it,\n // and the read path carries it on stderr for a background reader.\n if (!this.parts.controller.signal.aborted) {\n this.failure = { error: err }\n const message = err instanceof Error ? err.message : String(err)\n await this.console.emit(Channel.STDERR, new TextEncoder().encode(message))\n }\n }\n // A refusal's reason is one more stderr line, after what the shell\n // printed in bash's own words.\n if (result !== null) {\n const before = this.stderrTail.readFrom(0).text\n const line = refusalLine(result.refusal)\n if (line !== '') {\n const lead = before === '' || before.endsWith('\\n') ? '' : '\\n'\n await this.console.emit(Channel.STDERR, new TextEncoder().encode(lead + line))\n }\n }\n // Every emit was awaited as it was made, so everything the command\n // printed has landed by now and a read after `done` is whole.\n await this.console.finish(outcome)\n this.stdoutTail.end()\n this.stderrTail.end()\n const stderr = this.stderrTail.readFrom(0).text\n const sandbox = this.parts.verdict(result, stderr)\n if (sandbox !== undefined) this.sandbox = sandbox\n this.parts.disarm()\n }\n\n private async settleExpired(): Promise<void> {\n this.settled = true\n this.status = 'killed'\n await this.console.finish(KILLED_OUTCOME)\n const sandbox = this.parts.verdict(null, '')\n if (sandbox !== undefined) this.sandbox = sandbox\n this.parts.disarm()\n }\n\n readOutput(): ShellProcessRead {\n const delta = this.pending.take()\n const lossy = this.lossy\n this.lossy = false\n const spill = this.parts.spill\n return {\n delta,\n lossy,\n ...(spill?.stdoutPath !== undefined ? { stdoutSpillPath: spill.stdoutPath } : {}),\n ...(spill?.stderrPath !== undefined ? { stderrSpillPath: spill.stderrPath } : {}),\n }\n }\n\n kill(): boolean {\n if (this.settled) return false\n this.parts.controller.abort()\n return true\n }\n\n result(): Promise<ShellRunResult> {\n this.settledResult ??= this.done.then(() => {\n if (this.failure !== null) throw this.failure.error\n return {\n exitCode: this.exitCode,\n signal: this.signal,\n ...this.parts.classify(),\n timeoutMs: this.parts.timeoutMs,\n stdout: this.collected(this.stdoutTail, 'stdout'),\n stderr: this.collected(this.stderrTail, 'stderr'),\n ...(this.sandbox !== undefined ? { sandbox: this.sandbox } : {}),\n }\n })\n return this.settledResult\n }\n\n private collected(tail: StreamTail, channel: 'stdout' | 'stderr'): CollectedOutput {\n const read = this.readStream(tail, 0, channel)\n return {\n text: read.text,\n truncated: read.lossy,\n ...(read.spillPath !== undefined ? { spillPath: read.spillPath } : {}),\n }\n }\n}\n\n/**\n * Mirage-backed implementation of `ctx.shell`: `run` executes the command\n * line with mirage's own shell (coreutils-faithful commands, installed\n * CLIs, the policy layer) against the shared `ctx.mirage` workspace, so a\n * path from `ctx.fs` means the same file here. There is no OS process\n * behind a command: `signal` in results is a compatibility value for kills,\n * and abort/timeout act cooperatively at the executor's own boundaries.\n *\n * Every command runs in an ephemeral fork of the workspace's default\n * session, so no shell state survives from one call to the next, matching\n * the one-shot contract of dsh's bash tool. Configuring a `sessionId`\n * binds all commands to one named session instead, whose cwd, exports,\n * and functions persist across calls.\n */\nexport class MirageShellExecutor extends ShellExecutor {\n static readonly inject = ['mirage']\n\n private readonly workdir: string\n private readonly defaultTimeoutMs: number\n private readonly maxTimeoutMs: number\n private readonly stdoutMaxBytes: number\n private readonly stderrMaxBytes: number\n private readonly sessionId: string | undefined\n private readonly spillDir: string | undefined\n private sessionReady: Promise<void> | null = null\n private readOnlyReady: Promise<string> | null = null\n private readonly seeding = new Map<string, Promise<unknown>>()\n private issued = 0\n private readonly seeded = new Map<string, number>()\n\n constructor(ctx: Context, config: MirageShellConfig = {}) {\n super(ctx)\n this.workdir = config.workdir ?? '/'\n this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS\n this.maxTimeoutMs = config.maxTimeoutMs ?? MAX_TIMEOUT_MS\n this.stdoutMaxBytes = config.stdoutMaxBytes ?? DEFAULT_STDOUT_MAX_BYTES\n this.stderrMaxBytes = config.stderrMaxBytes ?? DEFAULT_STDERR_MAX_BYTES\n this.sessionId = config.sessionId\n this.spillDir = config.spillDir\n }\n\n // The workspace may still be building (declarative mounts resolve\n // asynchronously), so every execution awaits the service's `ready`.\n private workspace(): Promise<Workspace> {\n return this.ctx.mirage.ready\n }\n\n /**\n * The directory this command actually runs in.\n *\n * dsh fills an unspecified workdir from the calling session's cwd (by\n * way of the sandbox policy's workspace root), and that is a directory\n * on the harness's own machine, which names nothing here. Running\n * there leaves `pwd` reporting a path the agent cannot reach and every\n * relative path failing, and, because a per-call cwd forks a subshell,\n * it also defeats a bound session on every call. So a workdir that is\n * not a directory in this world is treated as unset: the configured\n * default when unbound, the session's own cwd when bound.\n *\n * @param spec the resolved spec whose workdir is being placed.\n * @returns the workdir to execute under, `''` meaning the session's own.\n */\n private async worldWorkdir(spec: ShellExecSpec): Promise<string> {\n if (spec.workdir === '') return ''\n const ws = await this.workspace()\n if (await ws.vfs.isDir(spec.workdir)) return spec.workdir\n return this.sessionId === undefined ? this.workdir : ''\n }\n\n // A spill sink for one background command, or null when no spill\n // directory is configured. The target reaches the live workspace so\n // the full stream lands on a mount the agent can read back.\n private newSpill(): SpillSink | null {\n const dir = this.spillDir\n if (dir === undefined) return null\n const target: SpillTarget = {\n ensureDir: async (d) => {\n const ws = await this.workspace()\n await ensureDirPath({ exists: (p) => ws.vfs.exists(p), mkdir: (p) => ws.vfs.mkdir(p) }, d)\n },\n write: async (p, bytes) => {\n const ws = await this.workspace()\n await ws.vfs.write(p, bytes)\n },\n append: async (p, bytes) => {\n const ws = await this.workspace()\n await ws.vfs.append(p, bytes)\n },\n }\n spillCounter += 1\n const log = this.ctx.logger('mirage-dsh')\n return new SpillSink(target, dir, `mirage-shell-${spillCounter.toString()}`, (err: unknown) => {\n log.debug('spill to %s failed, output will not be recoverable: %o', dir, err)\n })\n }\n\n /**\n * With every runtime in the world reaching only the vfs\n * (`ctx.mirage.vfsOnly`), the workspace dispatch is the single gate\n * for anything a command can do, so this executor behaves like a\n * workspace-write sandbox: reads and writes land only where mounts\n * (and their modes) allow. Declaring it lets sandbox-aware plugins\n * (dsh's permission presets) compose over this executor. A world\n * holding a runtime with entry points around the gate (the host `local`\n * python, a remote sandbox) voids that claim, so this answers\n * undefined then (the base contract's \"does not sandbox\") and those\n * plugins refuse to compose instead of trusting a lie.\n */\n override get sandboxMode(): ShellExecutor['sandboxMode'] {\n return this.ctx.mirage.vfsOnly ? 'workspace-write' : undefined\n }\n\n /**\n * The mode this one call runs under: the policy the caller resolved\n * for it, or this executor's own default when the call carried none.\n * Undefined keeps the \"no claim\" answer for a world some runtime can\n * act outside of, where no mode would be true.\n *\n * @param spec the resolved spec whose policy is being read.\n * @returns the effective mode, or undefined when nothing is claimed.\n */\n private modeFor(spec: ShellExecSpec): ShellExecutor['sandboxMode'] {\n const declared = this.sandboxMode\n if (declared === undefined) return undefined\n return spec.sandboxPolicy?.mode ?? declared\n }\n\n /**\n * The session this call runs in: the read-only twin when the policy\n * confines it to reads, else this executor's own binding.\n *\n * `workspace-write` and `danger-full-access` both run in the ordinary\n * session, because the mounts and their modes already are the\n * workspace boundary and mirage has nothing wider to grant.\n *\n * @param spec the resolved spec whose policy selects the session.\n * @returns the session id to execute under, or undefined for the default.\n */\n private async sessionFor(spec: ShellExecSpec): Promise<string | undefined> {\n if (this.modeFor(spec) !== 'read-only') return this.sessionId\n return this.readOnlySession()\n }\n\n /**\n * The sandbox facts to stamp on this run's result and process handle,\n * or undefined when the world is not fully workspace-bound (no claim).\n *\n * `enforcement` is 'full': when every runtime reaches only the vfs, the\n * workspace gate cannot be bypassed, so unlike an OS sandbox on an older\n * kernel there is no promised effect it fails to govern. `runnerFailed`\n * is false because the workspace executor is the runner and a failure to\n * run surfaces as a rejected/aborted execution, not a runner that never\n * started.\n *\n * @param spec the resolved spec this run was built from.\n * @param denied whether the run was refused a write by the narrowing.\n * @returns the facts to stamp, or undefined when nothing is claimed.\n */\n private sandboxInfo(spec: ShellExecSpec, denied = false): ShellSandboxInfo | undefined {\n const mode = this.modeFor(spec)\n if (mode === undefined) return undefined\n return { mode, denied, enforcement: 'full', runnerFailed: false }\n }\n\n /**\n * Whether this run was refused, by the session's permission document\n * or by the read-only narrowing.\n *\n * The document's refusals ride the result itself: a `Deny`, an\n * unanswered ask and a policy that raised all leave `refusal` on the\n * `ExecuteResult`, whatever the line did with its streams (`2>&1`, a\n * trailing command that owns the status). That record is read for\n * every call, because a role's `commands.deny` and `commands.ask`\n * rules bind under `workspace-write` and `danger-full-access` alike:\n * a mode says what the mounts allow, and says nothing about whether a\n * rule forbids the line. The narrowing has no record, since it is\n * EROFS/EACCES from the mounts, so its signatures are still read off\n * stderr, and only for a call that ran read-only, where this executor\n * is what imposed it.\n *\n * @param spec the resolved spec this run was built from.\n * @param result what the workspace answered.\n * @param stderr the run's captured standard error.\n * @returns true when something refused the run.\n */\n private wasDenied(\n spec: ShellExecSpec,\n result: { readonly refusal: Refusal | null },\n stderr: string,\n ): boolean {\n if (result.refusal !== null) return true\n if (this.modeFor(spec) !== 'read-only') return false\n return DENIAL_SIGNATURES.some((signature) => stderr.includes(signature))\n }\n\n resolve(request: ShellExecRequest): ShellExecSpec {\n // Bound to a session, an unspecified workdir stays empty so the\n // session's own cwd governs; filling the default here would turn\n // every call into a subshell and nothing would ever persist.\n const workdir = request.workdir ?? (this.sessionId === undefined ? this.workdir : '')\n return {\n command: request.command,\n workdir,\n timeoutMs: Math.min(request.timeoutMs ?? this.defaultTimeoutMs, this.maxTimeoutMs),\n onExpiry: request.onExpiry ?? 'kill',\n stdoutMaxBytes: request.stdoutMaxBytes ?? this.stdoutMaxBytes,\n signal: request.signal,\n stdin: request.stdin,\n env: request.env,\n dshEnv: request.dshEnv,\n sandboxPolicy: request.sandboxPolicy,\n }\n }\n\n private ensureSession(): Promise<void> {\n if (this.sessionId === undefined) return Promise.resolve()\n this.sessionReady ??= this.provisionSession(this.sessionId).catch((err: unknown) => {\n this.sessionReady = null\n throw err\n })\n return this.sessionReady\n }\n\n /**\n * Seed this call's managed `DSH_*` snapshot into the bound session.\n *\n * These are harness facts about the session (its home, its id), not\n * overrides for one command, and on a bound session they have to live\n * in the session: handed over as a per-call `env` they would fork a\n * subshell on every call, since dsh never sends an empty snapshot.\n *\n * The snapshot replaces rather than merges, per the seam's own rule\n * that a fact absent from the current snapshot must not inherit a\n * stale value from an earlier one. Only the managed namespace is\n * touched, so a variable the agent exported itself is left alone.\n *\n * @param ws the live workspace holding the session.\n * @param sessionId the session this call runs in.\n * @param managed the call's managed snapshot.\n */\n private async applyManagedEnv(\n ws: Workspace,\n sessionId: string,\n managed: Record<string, string>,\n ): Promise<void> {\n const session = ws.getSession(sessionId)\n const view = sessionView(session)\n for (const key of Object.keys(session.env)) {\n if (key.startsWith(DSH_ENV_PREFIX) && !(key in managed)) await view.unset(key)\n }\n for (const [key, value] of Object.entries(managed)) await view.set(key, value)\n }\n\n private readOnlySession(): Promise<string> {\n this.readOnlyReady ??= this.provisionReadOnly().catch((err: unknown) => {\n this.readOnlyReady = null\n throw err\n })\n return this.readOnlyReady\n }\n\n /**\n * Create (once) the session a read-only call runs in: a twin of the\n * session this executor would otherwise use, with every grant it holds\n * narrowed to `read`, so mirage's own dispatch is what refuses the\n * write rather than a second permission layer bolted on here.\n *\n * The twin narrows, never widens, and that takes every part of the\n * source's view, which is what `narrow` in core stamps: modes, hidden\n * paths, hidden variables, command rules. Its modes cover every mount\n * at `read` (the one exception is the null sink, per\n * {@link SINK_PREFIX}), which is at least as narrow as whatever the\n * source held, since `read` is the weakest mode there is; naming a\n * mount only narrows it, so a prefix the map omits would keep its own\n * mode rather than disappear. The other three are copied from the\n * source session rather than recompiled, because the profile it was\n * created under is not something a session records.\n *\n * Leaving any of them behind widens. Hides are the obvious one: a\n * binding confined to `/allowed` would read `/secret` in read-only\n * mode although the same command is refused outside it. Command rules\n * are the one modes cannot stand in for, because a mode bounds a\n * mount and an account CLI reaches a service: a profile that denies\n * `slack message send` or `git push` still denies it here, where\n * every mount being `read` says nothing at all about it.\n *\n * The policy's `workspaceRoot` is deliberately not consulted anywhere:\n * it is a directory on the harness's machine, so containment against\n * it says nothing about this world. The mounts are the boundary.\n *\n * @returns the id of the read-only session.\n */\n private async provisionReadOnly(): Promise<string> {\n const ws = await this.workspace()\n const sessionId = `${this.sessionId ?? 'mirage-dsh'}::read-only`\n await ws.ensureSessionsLoaded()\n if (ws.listSessions().some((s) => s.sessionId === sessionId)) return sessionId\n const source = ws.getSession(this.sessionId ?? ws.defaultSessionId)\n const grants: Record<string, string> = {}\n for (const entry of ws.mounts()) {\n grants[entry.prefix] = rstripSlash(entry.prefix) === SINK_PREFIX ? 'exec' : 'read'\n }\n const hidden = source.visibility.paths\n const hide = [...(hidden?.paths ?? []), ...(hidden?.patterns ?? [])]\n const twin = ws.createSession(sessionId, {\n mounts: grants,\n ...(hide.length > 0 ? { permissions: { paths: { hide } } } : {}),\n })\n twin.commands = source.commands\n twin.visibility = {\n ...twin.visibility,\n vars: source.visibility.vars,\n commands: source.visibility.commands,\n }\n setCwd(twin, this.workdir)\n return sessionId\n }\n\n private async provisionSession(sessionId: string): Promise<void> {\n const ws = await this.workspace()\n await ws.ensureSessionsLoaded()\n if (ws.listSessions().some((s) => s.sessionId === sessionId)) return\n setCwd(ws.createSession(sessionId), this.workdir)\n }\n\n /**\n * Where and as whom one command runs, settled before it starts: the\n * session binding, the workdir in this world, and its managed env.\n *\n * Seeding the env is the one step that writes, so it comes last and\n * waits for any seed already running on the same session (the bound\n * one and its read-only twin queue apart). Calls are numbered as they\n * arrive, and a seed is skipped once a later call has seeded the same\n * session, so the newest snapshot wins: a slow or abandoned preparation\n * never lands an old one over it, and a stall before the seed holds up\n * no other call. A call carrying no snapshot seeds nothing, so it\n * never counts as the newest, but it still waits for the seeds already\n * running, so its command never sees one half applied.\n *\n * @param spec the resolved spec being prepared.\n * @returns the workspace, session and workdir the command runs under.\n */\n private async prepare(spec: ShellExecSpec): Promise<Prepared> {\n const ticket = ++this.issued\n await this.ensureSession()\n const ws = await this.workspace()\n const sessionId = await this.sessionFor(spec)\n const bound = this.sessionId !== undefined\n const workdir = await this.worldWorkdir(spec)\n const managed = spec.dshEnv as Record<string, string> | undefined\n if (bound && sessionId !== undefined) {\n const running = this.seeding.get(sessionId) ?? Promise.resolve()\n if (managed === undefined) {\n await running\n } else {\n const seed = running.then(async () => {\n if (ticket < (this.seeded.get(sessionId) ?? 0)) return\n await this.applyManagedEnv(ws, sessionId, managed)\n this.seeded.set(sessionId, ticket)\n })\n this.seeding.set(\n sessionId,\n seed.catch(() => undefined),\n )\n await seed\n }\n }\n return { ws, sessionId, bound, workdir }\n }\n\n async execute(spec: ShellExecSpec): Promise<ShellExecution> {\n const controller = new AbortController()\n let timedOut = false\n let aborted = false\n // `none` arms no deadline: the caller's signal and `kill()` are then\n // the only ways the command stops.\n const timer =\n spec.onExpiry === 'kill'\n ? setTimeout(() => {\n timedOut = true\n controller.abort()\n }, spec.timeoutMs)\n : undefined\n const onAbort = (): void => {\n if (!timedOut && !aborted) {\n aborted = true\n controller.abort()\n }\n }\n // An already-aborted signal never fires its listener, so it is\n // treated as fired here: the command must not run at all.\n if (spec.signal?.aborted === true) onAbort()\n else spec.signal?.addEventListener('abort', onAbort, { once: true })\n const disarm = (): void => {\n clearTimeout(timer)\n spec.signal?.removeEventListener('abort', onAbort)\n }\n const parts: ExecutionParts = {\n controller,\n budget: spec.stdoutMaxBytes,\n stdoutMaxBytes: spec.stdoutMaxBytes,\n stderrMaxBytes: this.stderrMaxBytes,\n timeoutMs: spec.timeoutMs,\n spill: this.newSpill(),\n classify: () => ({ timedOut, aborted }),\n verdict: (result, stderr) =>\n this.sandboxInfo(spec, result !== null && this.wasDenied(spec, result, stderr)),\n disarm,\n }\n let prepared: Prepared\n try {\n // The wait ends at the deadline or a cancel even if a step has\n // stalled; the preparation runs on, and its seed lands only if no\n // later call has seeded the session first.\n controller.signal.throwIfAborted()\n prepared = await untilAborted(this.prepare(spec), controller.signal)\n } catch (err) {\n // Expiry while the command was still being prepared settles a\n // timed-out handle with no output; a caller's cancellation or a\n // failure to prepare is the caller's to see.\n if (!parts.classify().timedOut) {\n disarm()\n throw err\n }\n return new MirageShellExecution(null, parts)\n }\n const { ws, sessionId, bound, workdir } = prepared\n return new MirageShellExecution(\n (sink) =>\n ws.shell(\n spec.command,\n executeOptions(spec, workdir, controller.signal, sessionId, bound, this.workdir, sink),\n ),\n parts,\n )\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -32,15 +32,19 @@ function askThroughApproval(ctx) {
|
|
|
32
32
|
import { Service } from "@deepseek-ai/cordis";
|
|
33
33
|
import { buildRuntime } from "@struktoai/mirage-core/runtime/table";
|
|
34
34
|
import { parseMountMode } from "@struktoai/mirage-core/types";
|
|
35
|
-
import {
|
|
35
|
+
import { snakeToCamel } from "@struktoai/mirage-core/utils/normalize";
|
|
36
|
+
import { Mount } from "@struktoai/mirage-core/workspace/mount/spec";
|
|
37
|
+
import { Workspace, buildVfs, parseSessionProfile } from "@struktoai/mirage-node";
|
|
36
38
|
function isMountBlock(entry) {
|
|
37
39
|
if (Array.isArray(entry)) return false;
|
|
38
|
-
return typeof entry.
|
|
40
|
+
return typeof entry.vfs === "string";
|
|
39
41
|
}
|
|
40
42
|
async function resolveMount(entry) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const vfs = await buildVfs(entry.vfs, entry.config ?? {});
|
|
44
|
+
return new Mount(vfs, {
|
|
45
|
+
...entry.mode !== void 0 ? { mode: parseMountMode(entry.mode) } : {},
|
|
46
|
+
vfsRef: entry.vfs
|
|
47
|
+
});
|
|
44
48
|
}
|
|
45
49
|
function parseProfiles(raw) {
|
|
46
50
|
const out = {};
|
|
@@ -49,9 +53,16 @@ function parseProfiles(raw) {
|
|
|
49
53
|
}
|
|
50
54
|
return out;
|
|
51
55
|
}
|
|
56
|
+
function camelKeys(block) {
|
|
57
|
+
return Object.fromEntries(Object.entries(block).map(([key, value]) => [snakeToCamel(key), value]));
|
|
58
|
+
}
|
|
52
59
|
function toRuntimeEntry(entry) {
|
|
53
60
|
if (typeof entry === "string") return entry;
|
|
54
|
-
|
|
61
|
+
const { name, config, ...options } = entry;
|
|
62
|
+
return buildRuntime(
|
|
63
|
+
name,
|
|
64
|
+
config === void 0 ? options : { ...options, config: camelKeys(config) }
|
|
65
|
+
);
|
|
55
66
|
}
|
|
56
67
|
function builtEntry(entry) {
|
|
57
68
|
return typeof entry === "string" ? buildRuntime(entry) : entry;
|
|
@@ -126,6 +137,17 @@ var MirageService = class extends Service {
|
|
|
126
137
|
}
|
|
127
138
|
return this.built;
|
|
128
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The live workspace, or null while it is still building.
|
|
142
|
+
*
|
|
143
|
+
* The non-throwing twin of {@link workspace}, for a synchronous seam
|
|
144
|
+
* method whose honest answer before `ready` is "this world cannot tell
|
|
145
|
+
* you" rather than an exception. Reads `built` directly, as
|
|
146
|
+
* {@link vfsOnly} does.
|
|
147
|
+
*/
|
|
148
|
+
get workspaceIfReady() {
|
|
149
|
+
return this.built;
|
|
150
|
+
}
|
|
129
151
|
/**
|
|
130
152
|
* The decision ledger: every ask this world has raised and every
|
|
131
153
|
* answer given, questions still waiting included.
|
|
@@ -140,9 +162,9 @@ var MirageService = class extends Service {
|
|
|
140
162
|
return this.workspace.decisions;
|
|
141
163
|
}
|
|
142
164
|
/**
|
|
143
|
-
* What a line would do here, without doing it:
|
|
144
|
-
*
|
|
145
|
-
*
|
|
165
|
+
* What a line would do here, without doing it: the line's verdict, with
|
|
166
|
+
* the exact exit code and stderr the agent would get, and its parse tree,
|
|
167
|
+
* every command carrying its own outcome and the policies that spoke.
|
|
146
168
|
*
|
|
147
169
|
* A dry run through the same gate the dispatcher uses, so a host
|
|
148
170
|
* reading this and an agent typing the line cannot be told different
|
|
@@ -153,7 +175,7 @@ var MirageService = class extends Service {
|
|
|
153
175
|
*
|
|
154
176
|
* @param line the command line to judge.
|
|
155
177
|
* @param sessionId the session to judge it in; the default when empty.
|
|
156
|
-
* @returns
|
|
178
|
+
* @returns the line's explanation.
|
|
157
179
|
*/
|
|
158
180
|
async explain(line, sessionId = "") {
|
|
159
181
|
const ws = await this.ready;
|
|
@@ -169,11 +191,11 @@ var MirageService = class extends Service {
|
|
|
169
191
|
* sandbox can act around the gate. Answers before `ready` from the
|
|
170
192
|
* constructor-resolved entries (mounts resolve asynchronously,
|
|
171
193
|
* runtimes never do) and from the live workspace afterwards, so a
|
|
172
|
-
* later `addRuntime` is seen.
|
|
194
|
+
* later `addRuntime` or `removeRuntime` is seen.
|
|
173
195
|
*/
|
|
174
196
|
get vfsOnly() {
|
|
175
|
-
const entries = this.built === null ? this.plannedRuntimes ?? [] : this.built.
|
|
176
|
-
return entries.every((entry) => entry.reach === "
|
|
197
|
+
const entries = this.built === null ? this.plannedRuntimes ?? [] : this.built.runtimes();
|
|
198
|
+
return entries.every((entry) => entry.reach === "workspace");
|
|
177
199
|
}
|
|
178
200
|
async open(mounts, options) {
|
|
179
201
|
const resolved = {};
|
|
@@ -193,4 +215,4 @@ export {
|
|
|
193
215
|
askThroughApproval,
|
|
194
216
|
MirageService
|
|
195
217
|
};
|
|
196
|
-
//# sourceMappingURL=chunk-
|
|
218
|
+
//# sourceMappingURL=chunk-OHZACFWO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/approval.ts","../src/service.ts"],"sourcesContent":["// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { AskHandler } from '@struktoai/mirage-core/policy/decisions'\nimport type { Decision } from '@struktoai/mirage-core/policy/types'\nimport { Outcome, Scope } from '@struktoai/mirage-core/policy/types'\nimport { shellQuote } from '@struktoai/mirage-core/utils/quote'\n\n/**\n * The tool an approval request is attributed to. A mirage ask is raised\n * by the command plane, which is what dsh's bash tool called, so the\n * audit trail names that tool rather than the head word of the line\n * (which the request's own reason already carries).\n */\nexport const APPROVAL_TOOL_NAME = 'bash'\n\n/**\n * The outcomes dsh's approval channel answers with.\n *\n * Declared here rather than imported: the type lives in\n * `@deepseek-ai/dsh-sandbox`, which this package does not depend on, and\n * dsh itself treats the channel structurally for the same reason (its\n * `EscalationApprover` is \"a minimal STRUCTURAL function shape, not the\n * approval service type\"). Staying structural is what lets any approver\n * be wired, dsh's own included.\n */\nexport type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'\n\n/**\n * The one question this bridge asks of dsh: `ctx.approval` narrowed to\n * the call it makes. An `ApprovalService` satisfies it structurally,\n * without this package importing the approval or agent packages.\n */\nexport interface Approver {\n request(req: {\n agent: object | undefined\n toolName: string\n callId: string\n reason: string\n signal?: AbortSignal\n }): Promise<ApprovalOutcome>\n}\n\n/**\n * dsh's approval channel, or null when the composition has none.\n *\n * Read through `ctx.get`, not as `ctx.approval`. A plain property read\n * of a service this plugin does not `inject` does not answer undefined,\n * it throws (`cannot get property \"approval\" without inject`), so\n * probing that way would turn every ask in a composition without an\n * approver into a thrown error instead of a pending one. `ctx.get` is\n * cordis's documented read \"without the inject requirement\", and its\n * default strictness answers only a provider whose fiber is live, so an\n * unloaded approval plugin reads as absent rather than as a stale\n * channel nobody is listening on.\n *\n * Declaring `inject` instead would be wrong in the other direction: it\n * would make an approval channel a load requirement, and a headless\n * composition that legitimately has none would fail to compose at all.\n *\n * Read at ask time rather than captured when the plugin loads, because\n * cordis composes plugins in whatever order the profile lists them and a\n * snapshot taken at load would miss an approver registered after this.\n *\n * @param ctx the cordis context to read.\n * @returns the approver, or null when nothing provides one.\n */\nexport function approverOf(ctx: Context): Approver | null {\n const held: unknown = ctx.get('approval')\n if (held === null || typeof held !== 'object') return null\n const { request } = held as { request?: unknown }\n return typeof request === 'function' ? (held as Approver) : null\n}\n\n/**\n * What the human is shown: the rule's reason, and the line it is about.\n *\n * The reason alone is the operator's sentence (\"deletes are reviewed\")\n * and names nothing being deleted, so the line is quoted beside it. The\n * words come off the record rather than being re-rendered, so what was\n * approved is what was asked.\n *\n * Each word is rendered as a shell would read it back, because this\n * string is the whole of what the human authorizes and the words are\n * already-expanded values, not source. Joined raw, `rm -- 'quarterly\n * report'` reads as two operands and a newline inside a name forges a\n * line break in the prompt, so the human would be nodding at something\n * other than the run. `shellQuote` is GNU's diagnostic rendering: an\n * ordinary name stays bare, and only a word a shell would read as\n * something else is dressed.\n *\n * @param record the ledger record being asked about.\n * @returns the prompt text.\n */\nexport function approvalReason(record: Decision): string {\n const line = [record.command, ...record.argv].map(shellQuote).join(' ')\n return `${record.reason}: ${line}`\n}\n\n/**\n * Bridge mirage's ask to dsh's approval channel: the handler that fills\n * `WorkspaceOptions.onAsk`.\n *\n * Mirage owns every outcome (`policy/decisions.ts`); this only decides\n * which of its branches an approval answer lands on. Every answer from a\n * live channel is one of mirage's two settled verbs, because\n * `request` blocks until the channel answers and so never reports \"no\n * answer yet\":\n *\n * * `allowed-once` is dsh's only yes and grants exactly the line that\n * asked, so it maps to ALLOW at ONCE and never SESSION. Widening one\n * nod into a standing grant is the one thing the human did not\n * consent to; a host that wants SESSION answers writes them to the\n * ledger itself (`ws.decisions.answer`), the surface built for it.\n * * `rejected` is an explicit refusal.\n * * `cancelled` is a refusal too: the human was shown the prompt and\n * dismissed it, which is an answer rather than the absence of one.\n * Leaving it pending would re-prompt them, since a retry reuses the\n * waiting record and would call `request` again.\n * * `unavailable` is a permission check that could not run, so it\n * fails closed. Pending here would be fail-open-into-retry.\n *\n * The three refusals match what dsh does with the same vocabulary for\n * its own escalations, where `approveEscalation` throws on a rejection,\n * a cancellation and an unanswerable ask alike.\n *\n * The run's own signal rides along, so a prompt raised for a line whose\n * run is then killed or times out is dismissed with it rather than left\n * on somebody's screen for a command that no longer exists. The ledger\n * stops waiting either way — it bounds the wait by the same signal — but\n * only the channel can take its prompt down.\n *\n * With no approver on the context the handler answers null, which is\n * mirage's \"nobody has answered yet\": the ask stays pending in the\n * ledger. That is deliberate, and it is why the service installs this\n * unconditionally rather than only under a composed approver — the two\n * are the same answer to mirage (`onAsk` unset and a null return both\n * render pending), and installing always is what lets an approver\n * composed after this plugin still be found.\n *\n * Refusing instead would rewrite an operator's `ask` rule as a `deny`,\n * and the embedder may well be answering `ws.decisions.pending()` out\n * of band.\n *\n * @param ctx the cordis context carrying `ctx.approval`.\n * @returns the handler to pass as `onAsk`.\n */\nexport function askThroughApproval(ctx: Context): AskHandler {\n return async (record: Decision, signal?: AbortSignal): Promise<Decision | null> => {\n const approver = approverOf(ctx)\n if (approver === null) return null\n const outcome = await approver.request({\n agent: undefined,\n toolName: APPROVAL_TOOL_NAME,\n callId: record.id,\n reason: approvalReason(record),\n ...(signal !== undefined ? { signal } : {}),\n })\n const verb = outcome === 'allowed-once' ? Outcome.ALLOW : Outcome.DENY\n return { ...record, outcome: verb, scope: Scope.ONCE }\n }\n}\n","// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n\nimport { Service } from '@deepseek-ai/cordis'\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { ShellExplanation } from '@struktoai/mirage-core/policy/types'\nimport type { Runtime, RuntimeEntry } from '@struktoai/mirage-core/runtime/base'\nimport { buildRuntime } from '@struktoai/mirage-core/runtime/table'\nimport { parseMountMode } from '@struktoai/mirage-core/types'\nimport { snakeToCamel } from '@struktoai/mirage-core/utils/normalize'\nimport { Mount } from '@struktoai/mirage-core/workspace/mount/spec'\nimport type { MountSpec } from '@struktoai/mirage-core/workspace/workspace/workspace'\nimport { Workspace, buildVfs, parseSessionProfile } from '@struktoai/mirage-node'\nimport type { NodeWorkspaceOptions, SessionProfile } from '@struktoai/mirage-node'\nimport { askThroughApproval } from './approval.ts'\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n mirage: MirageService\n }\n}\n\n/**\n * A declarative mount, YAML-friendly: the VFS is named by its\n * registry type and built through `buildVfs`, so a dsh bundle\n * patch can mount anything without holding a live instance. Told apart\n * from a live `Mount` by the type of `vfs`: a string names a\n * registry entry, an object is the instance itself.\n */\nexport interface MirageMountBlock {\n /** VFS registry name (`ram`, `s3`, `slack`, `redis`, ...). */\n vfs: string\n /** Mount mode: `read`, `write`, or `exec`. Omitted = the workspace default. */\n mode?: string\n /** VFS constructor config, passed to the registry factory. */\n config?: Record<string, unknown>\n}\n\n/** One mount entry: a live spec or instance, or a declarative block. */\nexport type MirageMount = MountSpec | Mount | MirageMountBlock\n\n/** A runtime entry, YAML-friendly: a name, or a name with its options. */\nexport interface MirageRuntimeBlock {\n /** Runtime registry name (`monty`, `pyodide`, `quickjs`, ...). */\n name: string\n /** Command words this runtime captures (e.g. `['python', 'python3']`). */\n captures?: string[]\n /** The runtime's own config block; a key it does not have is refused. */\n config?: Record<string, unknown>\n}\n\n/**\n * Configuration for the shared mirage execution world. Exactly one of\n * `workspace` (adopt a live workspace the embedder owns and closes) or\n * `mounts` (construct a workspace here; the service closes it when its\n * plugin unloads) must be given.\n */\nexport interface MirageConfig {\n /** A live workspace to adopt; its lifecycle stays with the embedder. */\n workspace?: Workspace\n /** Mounts for a service-owned workspace, keyed by mount prefix. */\n mounts?: Record<string, MirageMount>\n /**\n * Runtimes for a service-owned workspace, built via `buildRuntime`.\n * The YAML-friendly twin of `workspaceOptions.runtimes`; pass one or\n * the other, not both.\n */\n runtimes?: (string | MirageRuntimeBlock)[]\n /**\n * The roles (`profiles:` in mirage config), each a whole permission\n * document: its `commands.allow` list, its `commands.ask` and\n * `commands.deny` rules, the mount sections carrying rules of their\n * own, and the paths and vars it hides. The YAML-friendly twin of\n * `workspaceOptions.profiles`; pass one or the other, not both.\n *\n * Written as the document, not as an already-parsed profile: every\n * entry goes through `parseSessionProfile`, so a misspelled field is a\n * load error rather than a first-session one. That parse is what lets\n * a dsh bundle patch carry a policy at all, since\n * `workspaceOptions.profiles` wants profiles already parsed and a\n * patch file holds plain YAML.\n */\n profiles?: Record<string, unknown>\n /**\n * Which role shapes a session created without one, the workspace's own\n * session included. A name `profiles` does not define is an error. The\n * YAML-friendly twin of `workspaceOptions.profile`.\n */\n profile?: string\n /** Options forwarded to the service-owned workspace's constructor. */\n workspaceOptions?: NodeWorkspaceOptions\n}\n\nfunction isMountBlock(entry: MirageMount): entry is MirageMountBlock {\n if (Array.isArray(entry)) return false\n return typeof (entry as MirageMountBlock).vfs === 'string'\n}\n\nasync function resolveMount(entry: MirageMountBlock): Promise<MountSpec> {\n const vfs = await buildVfs(entry.vfs, entry.config ?? {})\n return new Mount(vfs, {\n ...(entry.mode !== undefined ? { mode: parseMountMode(entry.mode) } : {}),\n vfsRef: entry.vfs,\n })\n}\n\n/**\n * Validate a `profiles` block: every role through the core document\n * validator, naming the role in the error so a typo in a patch file\n * points at the block that holds it.\n *\n * @param raw the roles as written, keyed by role name.\n * @returns the parsed roles, ready for `WorkspaceOptions.profiles`.\n */\nfunction parseProfiles(raw: Record<string, unknown>): Record<string, SessionProfile> {\n const out: Record<string, SessionProfile> = {}\n for (const [name, block] of Object.entries(raw)) {\n out[name] = parseSessionProfile(block, `profile \\`${name}\\``)\n }\n return out\n}\n\n// A config block is snake_case, as in a workspace file; the runtime's\n// own keys are camelCase. Only the keys: values pass through untouched.\nfunction camelKeys(block: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(block).map(([key, value]) => [snakeToCamel(key), value]))\n}\n\nfunction toRuntimeEntry(entry: string | MirageRuntimeBlock): RuntimeEntry {\n if (typeof entry === 'string') return entry\n const { name, config, ...options } = entry\n return buildRuntime(\n name,\n config === undefined ? options : { ...options, config: camelKeys(config) },\n )\n}\n\n// A name shorthand builds exactly as the workspace would build it; holding\n// the instance early is what lets `vfsOnly` classify the world before the\n// (asynchronous) mounts finish resolving.\nfunction builtEntry(entry: RuntimeEntry): Runtime {\n return typeof entry === 'string' ? buildRuntime(entry) : entry\n}\n\n/**\n * The one mirage `Workspace` behind every mirage-backed dsh provider\n * (`ctx.mirage`, mirroring how `ctx.e2b` owns one sandbox for the E2B\n * adapters). `MirageFileSystem` and `MirageShellExecutor` both inject it,\n * which is what keeps `ctx.fs` targets and `ctx.shell` commands in one\n * execution world: a `processPath` handed to the shell resolves there.\n *\n * Construction with live mounts is synchronous; a declarative mount\n * block defers to `buildVfs`, so consumers await `ready` (the\n * adapters do) and `workspace` throws until it resolves, mirroring how\n * the E2B service owner exposes its sandbox.\n */\nexport class MirageService extends Service {\n static readonly provide = 'mirage'\n\n /** Resolves to the shared workspace once every mount is built. */\n readonly ready: Promise<Workspace>\n\n private built: Workspace | null = null\n private readonly plannedRuntimes: readonly Runtime[] | undefined\n\n constructor(ctx: Context, config: MirageConfig = {}) {\n super(ctx, 'mirage')\n if (config.workspace !== undefined && config.mounts !== undefined) {\n throw new Error('mirage: pass either workspace or mounts, not both')\n }\n if (config.workspace !== undefined) {\n // An adopted workspace is already built, so runtimes and constructor\n // options can no longer reach it. Saying so beats accepting a config\n // whose runtimes silently never load, which is what the workspace and\n // mounts pair above already refuses to do.\n // `profiles` and `profile` join them for the same reason, and one\n // more: an adopted workspace's roles and its `onAsk` are the\n // embedder's own, so silently re-answering its asks through\n // `ctx.approval` would take over a seam it may already have filled.\n if (\n config.runtimes !== undefined ||\n config.workspaceOptions !== undefined ||\n config.profiles !== undefined ||\n config.profile !== undefined\n ) {\n throw new Error(\n 'mirage: runtimes, profiles, profile and workspaceOptions configure a service-owned workspace, not an adopted workspace',\n )\n }\n this.built = config.workspace\n this.ready = Promise.resolve(config.workspace)\n return\n }\n if (config.mounts === undefined) {\n throw new Error('mirage: one of workspace or mounts is required')\n }\n const options = { ...(config.workspaceOptions ?? {}) }\n if (config.runtimes !== undefined) {\n if (options.runtimes !== undefined) {\n throw new Error('mirage: pass runtimes or workspaceOptions.runtimes, not both')\n }\n options.runtimes = config.runtimes.map(toRuntimeEntry)\n }\n if (config.profiles !== undefined) {\n if (options.profiles !== undefined) {\n throw new Error('mirage: pass profiles or workspaceOptions.profiles, not both')\n }\n options.profiles = parseProfiles(config.profiles)\n }\n if (config.profile !== undefined) {\n if (options.profile !== undefined) {\n throw new Error('mirage: pass profile or workspaceOptions.profile, not both')\n }\n options.profile = config.profile\n }\n // How an asked line gets answered. Installed for every service-owned\n // workspace rather than only under a composed `ctx.approval`, because\n // the two are one answer to mirage (an unset `onAsk` and a handler\n // returning null both leave the ask pending) and installing always is\n // what finds an approver composed after this plugin. An explicit\n // `workspaceOptions.onAsk` is the embedder's decision and stands.\n options.onAsk ??= askThroughApproval(ctx)\n this.plannedRuntimes = options.runtimes?.map(builtEntry)\n if (this.plannedRuntimes !== undefined) options.runtimes = [...this.plannedRuntimes]\n const blocks = Object.values(config.mounts).some(isMountBlock)\n if (blocks) {\n this.ready = this.open(config.mounts, options)\n // A declarative mount resolves asynchronously and nothing awaits\n // `ready` until a provider's first call, so a failure here would\n // reach the process as an unhandled rejection and take the whole\n // harness down over a typo in a patch file. This only marks the\n // rejection observed; `ready` still rejects for every real caller,\n // and the disposer below still sees it.\n void this.ready.catch(() => undefined)\n } else {\n const owned = new Workspace(config.mounts as Record<string, MountSpec | Mount>, options)\n this.built = owned\n this.ready = Promise.resolve(owned)\n }\n ctx.effect(\n () => async () => {\n const ws = await this.ready.catch(() => null)\n if (ws !== null) await ws.close()\n },\n 'mirage workspace',\n )\n }\n\n /** The live workspace; throws until `ready` resolves. */\n get workspace(): Workspace {\n if (this.built === null) {\n throw new Error('mirage: workspace is not ready yet; await ctx.mirage.ready')\n }\n return this.built\n }\n\n /**\n * The live workspace, or null while it is still building.\n *\n * The non-throwing twin of {@link workspace}, for a synchronous seam\n * method whose honest answer before `ready` is \"this world cannot tell\n * you\" rather than an exception. Reads `built` directly, as\n * {@link vfsOnly} does.\n */\n get workspaceIfReady(): Workspace | null {\n return this.built\n }\n\n /**\n * The decision ledger: every ask this world has raised and every\n * answer given, questions still waiting included.\n *\n * The host's handle on the ask layer. A composition with an approval\n * channel answers inline and rarely reads this; one without it finds\n * its pending questions here (`pending()`) and answers them out of\n * band (`answer(id, outcome, scope)`), which is what makes an `ask`\n * rule usable with no human at the keyboard.\n */\n get decisions(): Workspace['decisions'] {\n return this.workspace.decisions\n }\n\n /**\n * What a line would do here, without doing it: the line's verdict, with\n * the exact exit code and stderr the agent would get, and its parse tree,\n * every command carrying its own outcome and the policies that spoke.\n *\n * A dry run through the same gate the dispatcher uses, so a host\n * reading this and an agent typing the line cannot be told different\n * things. It spends no ONCE answer, records no question and never\n * reaches the approval channel, which is what makes it safe to call\n * about a line nobody typed — a pre-execute check, or an operator\n * asking why a command was refused.\n *\n * @param line the command line to judge.\n * @param sessionId the session to judge it in; the default when empty.\n * @returns the line's explanation.\n */\n async explain(line: string, sessionId = ''): Promise<ShellExplanation> {\n const ws = await this.ready\n return ws.explain(line, sessionId)\n }\n\n /**\n * True while every runtime in the world reaches only the vfs\n * (`Runtime.reach`), meaning the workspace dispatch is the single\n * gate for anything the agent can execute: every effect passes\n * mount modes, session grants, and policy before touching the real\n * backend behind a mount. The default world qualifies, and so do\n * the bridged engines, while the host `local` python or a remote\n * sandbox can act around the gate. Answers before `ready` from the\n * constructor-resolved entries (mounts resolve asynchronously,\n * runtimes never do) and from the live workspace afterwards, so a\n * later `addRuntime` or `removeRuntime` is seen.\n */\n get vfsOnly(): boolean {\n const entries = this.built === null ? (this.plannedRuntimes ?? []) : this.built.runtimes()\n return entries.every((entry) => entry.reach === 'workspace')\n }\n\n private async open(\n mounts: Record<string, MirageMount>,\n options: NodeWorkspaceOptions,\n ): Promise<Workspace> {\n const resolved: Record<string, MountSpec | Mount> = {}\n for (const [prefix, entry] of Object.entries(mounts)) {\n resolved[prefix] = isMountBlock(entry) ? await resolveMount(entry) : entry\n }\n const ws = new Workspace(resolved, options)\n this.built = ws\n return ws\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -53,14 +53,15 @@ function charBoundary(bytes, from) {
|
|
|
53
53
|
while (start < bytes.byteLength && ((bytes[start] ?? 0) & 192) === 128) start++;
|
|
54
54
|
return start;
|
|
55
55
|
}
|
|
56
|
-
function
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
56
|
+
function charEnd(bytes) {
|
|
57
|
+
const end = bytes.byteLength;
|
|
58
|
+
for (let back = 1; back <= Math.min(4, end); back++) {
|
|
59
|
+
const byte = bytes[end - back] ?? 0;
|
|
60
|
+
if ((byte & 192) === 128) continue;
|
|
61
|
+
const width = byte >= 194 && byte <= 223 ? 2 : byte >= 224 && byte <= 239 ? 3 : byte >= 240 && byte <= 244 ? 4 : 1;
|
|
62
|
+
return width > back ? end - back : end;
|
|
63
|
+
}
|
|
64
|
+
return end;
|
|
64
65
|
}
|
|
65
66
|
var TailBuffer = class {
|
|
66
67
|
budget;
|
|
@@ -95,6 +96,33 @@ var TailBuffer = class {
|
|
|
95
96
|
}
|
|
96
97
|
return true;
|
|
97
98
|
}
|
|
99
|
+
/** How many bytes are held. */
|
|
100
|
+
get size() {
|
|
101
|
+
return this.bytes;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The held bytes after the first `skip`, joined, leaving them held: only
|
|
105
|
+
* what is asked for is copied.
|
|
106
|
+
*
|
|
107
|
+
* @param skip how many of the oldest held bytes to leave out.
|
|
108
|
+
* @returns the rest, oldest first.
|
|
109
|
+
*/
|
|
110
|
+
since(skip) {
|
|
111
|
+
const out = new Uint8Array(Math.max(0, this.bytes - skip));
|
|
112
|
+
let left = skip;
|
|
113
|
+
let at = 0;
|
|
114
|
+
for (const part of this.parts) {
|
|
115
|
+
if (left >= part.byteLength) {
|
|
116
|
+
left -= part.byteLength;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const piece = part.subarray(left);
|
|
120
|
+
left = 0;
|
|
121
|
+
out.set(piece, at);
|
|
122
|
+
at += piece.byteLength;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
98
126
|
/**
|
|
99
127
|
* Drain everything held, decoded as text.
|
|
100
128
|
*
|
|
@@ -106,12 +134,7 @@ var TailBuffer = class {
|
|
|
106
134
|
*/
|
|
107
135
|
take() {
|
|
108
136
|
if (this.parts.length === 0) return "";
|
|
109
|
-
const joined =
|
|
110
|
-
let at = 0;
|
|
111
|
-
for (const part of this.parts) {
|
|
112
|
-
joined.set(part, at);
|
|
113
|
-
at += part.byteLength;
|
|
114
|
-
}
|
|
137
|
+
const joined = this.since(0);
|
|
115
138
|
this.parts = [];
|
|
116
139
|
this.bytes = 0;
|
|
117
140
|
return new TextDecoder("utf-8", { fatal: false }).decode(
|
|
@@ -119,6 +142,40 @@ var TailBuffer = class {
|
|
|
119
142
|
);
|
|
120
143
|
}
|
|
121
144
|
};
|
|
145
|
+
var StreamTail = class {
|
|
146
|
+
tail;
|
|
147
|
+
offset = 0;
|
|
148
|
+
ended = false;
|
|
149
|
+
constructor(max) {
|
|
150
|
+
this.tail = new TailBuffer(max);
|
|
151
|
+
}
|
|
152
|
+
/** Whether the tail has lost its head, so the stream is truncated. */
|
|
153
|
+
get truncated() {
|
|
154
|
+
return this.offset > this.tail.size;
|
|
155
|
+
}
|
|
156
|
+
append(chunk) {
|
|
157
|
+
this.offset += chunk.byteLength;
|
|
158
|
+
this.tail.append(chunk);
|
|
159
|
+
}
|
|
160
|
+
/** Mark the stream finished, so a read decodes through its last byte. */
|
|
161
|
+
end() {
|
|
162
|
+
this.ended = true;
|
|
163
|
+
}
|
|
164
|
+
readFrom(fromByte) {
|
|
165
|
+
if (!Number.isSafeInteger(fromByte) || fromByte < 0 || fromByte > this.offset)
|
|
166
|
+
throw new Error("invalid output offset");
|
|
167
|
+
const start = this.offset - this.tail.size;
|
|
168
|
+
const skip = Math.max(0, fromByte - start);
|
|
169
|
+
let bytes = this.tail.since(skip);
|
|
170
|
+
if (skip === 0 && start > 0) bytes = bytes.subarray(charBoundary(bytes, 0));
|
|
171
|
+
const stop = this.ended ? bytes.byteLength : charEnd(bytes);
|
|
172
|
+
return {
|
|
173
|
+
text: new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(0, stop)),
|
|
174
|
+
nextOffset: this.offset - bytes.byteLength + stop,
|
|
175
|
+
lossy: fromByte < start
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
};
|
|
122
179
|
|
|
123
180
|
export {
|
|
124
181
|
decodeStrictText,
|
|
@@ -126,7 +183,7 @@ export {
|
|
|
126
183
|
detectsCrlf,
|
|
127
184
|
restoreLineEndings,
|
|
128
185
|
applyLiteralEdit,
|
|
129
|
-
|
|
130
|
-
|
|
186
|
+
TailBuffer,
|
|
187
|
+
StreamTail
|
|
131
188
|
};
|
|
132
|
-
//# sourceMappingURL=chunk-
|
|
189
|
+
//# sourceMappingURL=chunk-OZ2F4R6T.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/text.ts"],"sourcesContent":["// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n\nimport { FsError } from '@deepseek-ai/dsh-fs'\nimport type { SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'\n\nconst BINARY_SAMPLE_BYTES = 8192\n\n// dsh text semantics: a NUL in the leading sample or invalid UTF-8 is a\n// binary file, refused as FS_NOT_TEXT rather than decoded lossily (the\n// mirage facade's cat decodes with fatal: false, which the dsh\n// seam contract forbids).\nexport function decodeStrictText(bytes: Uint8Array, displayPath: string): string {\n if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {\n throw new FsError(`cannot read \"${displayPath}\": binary file`, 'FS_NOT_TEXT')\n }\n try {\n return new TextDecoder('utf-8', { fatal: true }).decode(bytes)\n } catch (error: unknown) {\n throw new FsError(`cannot read \"${displayPath}\": invalid UTF-8 text`, 'FS_NOT_TEXT', {\n cause: error,\n })\n }\n}\n\nexport function normalizeLineEndings(value: string): string {\n return value.replaceAll('\\r\\n', '\\n')\n}\n\n// CRLF-majority sniff over a bounded sample, the same heuristic dsh's own\n// backends use to keep a rewritten file in its original line-ending style.\nexport function detectsCrlf(value: string): boolean {\n const sample = value.slice(0, 4096)\n const crlf = sample.split('\\r\\n').length - 1\n const lf = sample.split('\\n').length - 1 - crlf\n return crlf > lf\n}\n\nexport function restoreLineEndings(value: string, crlf: boolean): string {\n return crlf ? normalizeLineEndings(value).replaceAll('\\n', '\\r\\n') : value\n}\n\nfunction countOccurrences(haystack: string, needle: string): number {\n if (needle.length === 0) return 0\n let count = 0\n let index = haystack.indexOf(needle)\n while (index !== -1) {\n count++\n index = haystack.indexOf(needle, index + needle.length)\n }\n return count\n}\n\n// Literal replacement with dsh's edit taxonomy: zero matches and an empty\n// oldString are FS_EDIT_NOT_FOUND, several matches without replaceAll is\n// FS_AMBIGUOUS_EDIT.\nexport function applyLiteralEdit(\n content: string,\n oldString: string,\n newString: string,\n replaceAll: boolean,\n displayPath: string,\n): string {\n const matches = countOccurrences(content, oldString)\n if (matches === 0) {\n throw new FsError(`cannot edit \"${displayPath}\": oldString not found`, 'FS_EDIT_NOT_FOUND')\n }\n if (matches > 1 && !replaceAll) {\n throw new FsError(\n `cannot edit \"${displayPath}\": oldString matches ${String(matches)} locations; pass replaceAll or a longer unique string`,\n 'FS_AMBIGUOUS_EDIT',\n )\n }\n return replaceAll\n ? content.replaceAll(oldString, newString)\n : content.replace(oldString, newString)\n}\n\n// The first index at or after `from` that starts a UTF-8 sequence, so a tail\n// taken from there decodes as characters rather than as replacement marks.\nfunction charBoundary(bytes: Uint8Array, from: number): number {\n let start = from\n while (start < bytes.byteLength && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++\n return start\n}\n\n// The end of the last whole UTF-8 sequence, so a read of a stream still\n// arriving leaves a character it holds only part of for the next read. Only\n// a valid lead byte opens one; any other byte (0xC0, 0xC1, 0xF5 and up)\n// decodes as a replacement mark at once rather than waiting on bytes that\n// cannot complete it.\nfunction charEnd(bytes: Uint8Array): number {\n const end = bytes.byteLength\n for (let back = 1; back <= Math.min(4, end); back++) {\n const byte = bytes[end - back] ?? 0\n if ((byte & 0xc0) === 0x80) continue\n const width =\n byte >= 0xc2 && byte <= 0xdf\n ? 2\n : byte >= 0xe0 && byte <= 0xef\n ? 3\n : byte >= 0xf0 && byte <= 0xf4\n ? 4\n : 1\n return width > back ? end - back : end\n }\n return end\n}\n\n/**\n * A bounded backlog of output bytes: the newest `budget` bytes, with the\n * oldest dropped as they overflow.\n *\n * Held as bytes rather than as a string because the string form had to\n * re-encode everything already buffered on every append to measure it,\n * which is quadratic in a command's output and lands on the same event\n * loop that serves the workspace. Appending here costs the chunk, not\n * the backlog.\n */\nexport class TailBuffer {\n private readonly budget: number\n private parts: Uint8Array[] = []\n private bytes = 0\n\n constructor(budget: number) {\n this.budget = budget\n }\n\n /**\n * Append a chunk, dropping the oldest bytes that no longer fit.\n *\n * @param data the bytes to append.\n * @returns true when bytes were dropped to make room.\n */\n append(data: Uint8Array): boolean {\n this.parts.push(data)\n this.bytes += data.byteLength\n if (this.bytes <= this.budget) return false\n let excess = this.bytes - this.budget\n while (excess > 0) {\n const head = this.parts[0]\n if (head === undefined) break\n if (head.byteLength <= excess) {\n this.parts.shift()\n excess -= head.byteLength\n this.bytes -= head.byteLength\n } else {\n this.parts[0] = head.subarray(excess)\n this.bytes -= excess\n excess = 0\n }\n }\n return true\n }\n\n /** How many bytes are held. */\n get size(): number {\n return this.bytes\n }\n\n /**\n * The held bytes after the first `skip`, joined, leaving them held: only\n * what is asked for is copied.\n *\n * @param skip how many of the oldest held bytes to leave out.\n * @returns the rest, oldest first.\n */\n since(skip: number): Uint8Array {\n const out = new Uint8Array(Math.max(0, this.bytes - skip))\n let left = skip\n let at = 0\n for (const part of this.parts) {\n if (left >= part.byteLength) {\n left -= part.byteLength\n continue\n }\n const piece = part.subarray(left)\n left = 0\n out.set(piece, at)\n at += piece.byteLength\n }\n return out\n }\n\n /**\n * Drain everything held, decoded as text.\n *\n * The head may sit mid-character, since dropping is byte-exact, so it\n * is re-aligned before decoding rather than rendered as a replacement\n * mark.\n *\n * @returns the buffered text; the buffer is left empty.\n */\n take(): string {\n if (this.parts.length === 0) return ''\n const joined = this.since(0)\n this.parts = []\n this.bytes = 0\n return new TextDecoder('utf-8', { fatal: false }).decode(\n joined.subarray(charBoundary(joined, 0)),\n )\n }\n}\n\n/**\n * One collected output stream as a whole-stream offset reader over its\n * bounded tail: `readFrom(0)` after it settles is the batch result, and an\n * offset that slid out of the tail reads `lossy` with the whole tail.\n */\nexport class StreamTail implements SubprocessOutputReader {\n private readonly tail: TailBuffer\n private offset = 0\n private ended = false\n\n constructor(max: number) {\n this.tail = new TailBuffer(max)\n }\n\n /** Whether the tail has lost its head, so the stream is truncated. */\n get truncated(): boolean {\n return this.offset > this.tail.size\n }\n\n append(chunk: Uint8Array): void {\n this.offset += chunk.byteLength\n this.tail.append(chunk)\n }\n\n /** Mark the stream finished, so a read decodes through its last byte. */\n end(): void {\n this.ended = true\n }\n\n readFrom(fromByte: number): SubprocessOutputRead {\n if (!Number.isSafeInteger(fromByte) || fromByte < 0 || fromByte > this.offset)\n throw new Error('invalid output offset')\n const start = this.offset - this.tail.size\n const skip = Math.max(0, fromByte - start)\n let bytes = this.tail.since(skip)\n // A tail that lost its head can begin mid-character; the reader is\n // handed whole characters only, so the stray continuation bytes go.\n if (skip === 0 && start > 0) bytes = bytes.subarray(charBoundary(bytes, 0))\n // A stream still arriving can stop mid-character too: the read ends\n // before it, so the next read decodes it whole.\n const stop = this.ended ? bytes.byteLength : charEnd(bytes)\n return {\n text: new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, stop)),\n nextOffset: this.offset - bytes.byteLength + stop,\n lossy: fromByte < start,\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// src/spill.ts
|
|
2
|
+
import { Channel } from "@struktoai/mirage-core/shell/console/index";
|
|
3
|
+
import { concat } from "@struktoai/mirage-core/utils/bytes";
|
|
4
|
+
async function ensureDirPath(dirs, dir) {
|
|
5
|
+
let path = "";
|
|
6
|
+
for (const part of dir.split("/").filter((p) => p !== "")) {
|
|
7
|
+
path += `/${part}`;
|
|
8
|
+
if (await dirs.exists(path)) continue;
|
|
9
|
+
try {
|
|
10
|
+
await dirs.mkdir(path);
|
|
11
|
+
} catch (err) {
|
|
12
|
+
if (!await dirs.exists(path)) throw err;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function totalLength(parts) {
|
|
17
|
+
return parts.reduce((sum, p) => sum + p.byteLength, 0);
|
|
18
|
+
}
|
|
19
|
+
var SpillSink = class {
|
|
20
|
+
stdoutPath;
|
|
21
|
+
stderrPath;
|
|
22
|
+
target;
|
|
23
|
+
dir;
|
|
24
|
+
base;
|
|
25
|
+
onFailure;
|
|
26
|
+
started = false;
|
|
27
|
+
failed = false;
|
|
28
|
+
stdoutParts = [];
|
|
29
|
+
stderrParts = [];
|
|
30
|
+
constructor(target, dir, base, onFailure = () => void 0) {
|
|
31
|
+
this.target = target;
|
|
32
|
+
this.dir = dir;
|
|
33
|
+
this.base = base;
|
|
34
|
+
this.onFailure = onFailure;
|
|
35
|
+
}
|
|
36
|
+
/** Buffer a chunk before spill starts, or append it to the file after. */
|
|
37
|
+
async ingest(channel, data) {
|
|
38
|
+
if (this.failed) return;
|
|
39
|
+
if (!this.started) {
|
|
40
|
+
if (channel === Channel.STDERR) this.stderrParts.push(data);
|
|
41
|
+
else this.stdoutParts.push(data);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await this.appendFile(channel, data);
|
|
45
|
+
}
|
|
46
|
+
/** Flush the buffered streams to files; called once, on the first overrun. */
|
|
47
|
+
async begin() {
|
|
48
|
+
if (this.started || this.failed) return;
|
|
49
|
+
try {
|
|
50
|
+
await this.target.ensureDir(this.dir);
|
|
51
|
+
if (totalLength(this.stdoutParts) > 0) {
|
|
52
|
+
this.stdoutPath = `${this.dir}/${this.base}.stdout`;
|
|
53
|
+
await this.target.write(this.stdoutPath, concat(this.stdoutParts));
|
|
54
|
+
}
|
|
55
|
+
if (totalLength(this.stderrParts) > 0) {
|
|
56
|
+
this.stderrPath = `${this.dir}/${this.base}.stderr`;
|
|
57
|
+
await this.target.write(this.stderrPath, concat(this.stderrParts));
|
|
58
|
+
}
|
|
59
|
+
this.started = true;
|
|
60
|
+
this.stdoutParts = [];
|
|
61
|
+
this.stderrParts = [];
|
|
62
|
+
} catch (err) {
|
|
63
|
+
this.onFailure(err);
|
|
64
|
+
this.disable();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async appendFile(channel, data) {
|
|
68
|
+
try {
|
|
69
|
+
if (channel === Channel.STDERR) {
|
|
70
|
+
if (this.stderrPath === void 0) {
|
|
71
|
+
this.stderrPath = `${this.dir}/${this.base}.stderr`;
|
|
72
|
+
await this.target.write(this.stderrPath, data);
|
|
73
|
+
} else {
|
|
74
|
+
await this.target.append(this.stderrPath, data);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
if (this.stdoutPath === void 0) {
|
|
78
|
+
this.stdoutPath = `${this.dir}/${this.base}.stdout`;
|
|
79
|
+
await this.target.write(this.stdoutPath, data);
|
|
80
|
+
} else {
|
|
81
|
+
await this.target.append(this.stdoutPath, data);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
this.onFailure(err);
|
|
86
|
+
this.disable();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Give up on spilling and report no path.
|
|
91
|
+
*
|
|
92
|
+
* Called on a write failure, and by a reader that discovers it lost
|
|
93
|
+
* bytes before they ever reached here: a file missing the middle of
|
|
94
|
+
* the stream is worse than no file, because the path presents it as
|
|
95
|
+
* the whole one.
|
|
96
|
+
*/
|
|
97
|
+
disable() {
|
|
98
|
+
this.failed = true;
|
|
99
|
+
this.stdoutPath = void 0;
|
|
100
|
+
this.stderrPath = void 0;
|
|
101
|
+
this.stdoutParts = [];
|
|
102
|
+
this.stderrParts = [];
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export {
|
|
107
|
+
ensureDirPath,
|
|
108
|
+
SpillSink
|
|
109
|
+
};
|
|
110
|
+
//# sourceMappingURL=chunk-QH5H3VND.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/spill.ts"],"sourcesContent":["// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========\n\nimport { Channel } from '@struktoai/mirage-core/shell/console/index'\nimport { concat } from '@struktoai/mirage-core/utils/bytes'\n\n/** Where a spill sink writes: a workspace directory it can create and extend. */\nexport interface SpillTarget {\n ensureDir(dir: string): Promise<void>\n write(path: string, bytes: Uint8Array): Promise<void>\n append(path: string, bytes: Uint8Array): Promise<void>\n}\n\n/** The two directory facts `ensureDirPath` needs from a workspace. */\nexport interface DirMaker {\n exists(path: string): Promise<boolean>\n mkdir(path: string): Promise<void>\n}\n\n/**\n * Create `dir` and any missing ancestor, treating an existing one as\n * done.\n *\n * `mkdir` is one level and is not idempotent, so a probe alone is not\n * enough: two commands that first overrun at the same moment both see\n * the spill directory missing, one creates it and the other is refused\n * for a directory that is now exactly what it wanted. Losing that race\n * must not cost a command its spill, so the refusal is re-checked\n * against existence and only a directory that is still missing is a\n * real failure.\n *\n * @param dirs the workspace's `exists`/`mkdir`.\n * @param dir absolute workspace path to create.\n */\nexport async function ensureDirPath(dirs: DirMaker, dir: string): Promise<void> {\n let path = ''\n for (const part of dir.split('/').filter((p) => p !== '')) {\n path += `/${part}`\n if (await dirs.exists(path)) continue\n try {\n await dirs.mkdir(path)\n } catch (err) {\n if (!(await dirs.exists(path))) throw err\n }\n }\n}\n\nfunction totalLength(parts: Uint8Array[]): number {\n return parts.reduce((sum, p) => sum + p.byteLength, 0)\n}\n\n/**\n * Keeps the full, uncapped stdout and stderr of one command in workspace\n * files so a reader can recover output the delta budget dropped.\n *\n * Memory stays bounded: each channel buffers in memory only until the\n * delivered delta first overruns its budget, at which point `begin()`\n * flushes both buffers to their files and every later chunk appends\n * straight to the file. A write failure (no writable mount at the\n * configured path) disables the sink and leaves the paths undefined, the\n * honest \"no safe path available\" answer, so the process still streams,\n * just without a spill to point at.\n */\nexport class SpillSink {\n stdoutPath: string | undefined\n stderrPath: string | undefined\n\n private readonly target: SpillTarget\n private readonly dir: string\n private readonly base: string\n private readonly onFailure: (err: unknown) => void\n private started = false\n private failed = false\n private stdoutParts: Uint8Array[] = []\n private stderrParts: Uint8Array[] = []\n\n constructor(\n target: SpillTarget,\n dir: string,\n base: string,\n onFailure: (err: unknown) => void = () => undefined,\n ) {\n this.target = target\n this.dir = dir\n this.base = base\n this.onFailure = onFailure\n }\n\n /** Buffer a chunk before spill starts, or append it to the file after. */\n async ingest(channel: Channel, data: Uint8Array): Promise<void> {\n if (this.failed) return\n if (!this.started) {\n if (channel === Channel.STDERR) this.stderrParts.push(data)\n else this.stdoutParts.push(data)\n return\n }\n await this.appendFile(channel, data)\n }\n\n /** Flush the buffered streams to files; called once, on the first overrun. */\n async begin(): Promise<void> {\n if (this.started || this.failed) return\n try {\n await this.target.ensureDir(this.dir)\n if (totalLength(this.stdoutParts) > 0) {\n this.stdoutPath = `${this.dir}/${this.base}.stdout`\n await this.target.write(this.stdoutPath, concat(this.stdoutParts))\n }\n if (totalLength(this.stderrParts) > 0) {\n this.stderrPath = `${this.dir}/${this.base}.stderr`\n await this.target.write(this.stderrPath, concat(this.stderrParts))\n }\n this.started = true\n this.stdoutParts = []\n this.stderrParts = []\n } catch (err) {\n // Reported, never swallowed: a spill quietly vanishing reads later\n // as \"the harness lost my output\" with nothing to explain it.\n this.onFailure(err)\n this.disable()\n }\n }\n\n private async appendFile(channel: Channel, data: Uint8Array): Promise<void> {\n try {\n if (channel === Channel.STDERR) {\n if (this.stderrPath === undefined) {\n this.stderrPath = `${this.dir}/${this.base}.stderr`\n await this.target.write(this.stderrPath, data)\n } else {\n await this.target.append(this.stderrPath, data)\n }\n } else {\n if (this.stdoutPath === undefined) {\n this.stdoutPath = `${this.dir}/${this.base}.stdout`\n await this.target.write(this.stdoutPath, data)\n } else {\n await this.target.append(this.stdoutPath, data)\n }\n }\n } catch (err) {\n this.onFailure(err)\n this.disable()\n }\n }\n\n /**\n * Give up on spilling and report no path.\n *\n * Called on a write failure, and by a reader that discovers it lost\n * bytes before they ever reached here: a file missing the middle of\n * the stream is worse than no file, because the path presents it as\n * the whole one.\n */\n disable(): void {\n this.failed = true\n this.stdoutPath = undefined\n this.stderrPath = undefined\n this.stdoutParts = []\n this.stderrParts = []\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureDirPath
|
|
3
|
+
} from "./chunk-QH5H3VND.js";
|
|
4
|
+
|
|
5
|
+
// src/spill-store.ts
|
|
6
|
+
import { Buffer } from "buffer";
|
|
7
|
+
import { createHash, randomBytes } from "crypto";
|
|
8
|
+
import { posix } from "path";
|
|
9
|
+
import { SpillLocator, SpillStore } from "@deepseek-ai/dsh-spill";
|
|
10
|
+
var DEFAULT_SPILL_DIR = "/tmp/dsh-spill";
|
|
11
|
+
var NAME_PREFIX_BYTES = 6;
|
|
12
|
+
var SESSION_DIGEST_CHARS = 12;
|
|
13
|
+
function encodeSegment(raw) {
|
|
14
|
+
if (raw.length === 0) return "~";
|
|
15
|
+
if (raw === ".") return "~002E";
|
|
16
|
+
if (raw === "..") return "~002E~002E";
|
|
17
|
+
let out = "";
|
|
18
|
+
for (const ch of raw) {
|
|
19
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
20
|
+
out += ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch) ? ch : `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
function sessionDirName(sessionId) {
|
|
25
|
+
const digest = createHash("sha256").update(sessionId).digest("hex");
|
|
26
|
+
return `session-${digest.slice(0, SESSION_DIGEST_CHARS)}`;
|
|
27
|
+
}
|
|
28
|
+
var MirageSpillStore = class extends SpillStore {
|
|
29
|
+
static inject = ["mirage"];
|
|
30
|
+
dir;
|
|
31
|
+
constructor(ctx, config = {}) {
|
|
32
|
+
super(ctx);
|
|
33
|
+
const dir = config.dir ?? DEFAULT_SPILL_DIR;
|
|
34
|
+
if (!posix.isAbsolute(dir)) {
|
|
35
|
+
throw new Error(`mirage: spill dir must be an absolute workspace path, got ${dir}`);
|
|
36
|
+
}
|
|
37
|
+
this.dir = posix.resolve("/", dir);
|
|
38
|
+
}
|
|
39
|
+
async saveText(input) {
|
|
40
|
+
const workspace = await this.ctx.mirage.ready;
|
|
41
|
+
const dir = posix.join(this.dir, sessionDirName(input.owner.sessionId));
|
|
42
|
+
const name = `${randomBytes(NAME_PREFIX_BYTES).toString("hex")}-${encodeSegment(input.suggestedName)}`;
|
|
43
|
+
const path = posix.join(dir, name);
|
|
44
|
+
try {
|
|
45
|
+
await ensureDirPath(
|
|
46
|
+
{ exists: (p) => workspace.vfs.exists(p), mkdir: (p) => workspace.vfs.mkdir(p) },
|
|
47
|
+
dir
|
|
48
|
+
);
|
|
49
|
+
await workspace.vfs.write(path, input.content);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`mirage: cannot write spill artifact to ${path}`, { cause: err });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
locator: SpillLocator(path),
|
|
55
|
+
bytes: Buffer.byteLength(input.content, "utf8"),
|
|
56
|
+
retrievalHint: "Use read with offset/limit, or grep this workspace path to search within it."
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
MirageSpillStore
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=chunk-T4BSJAGN.js.map
|