@struktoai/mirage-dsh 0.0.1 → 0.0.6
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/cordis.patch.yml +49 -0
- package/dist/{chunk-CATVBGGR.js → chunk-2F2PJ7D7.js} +66 -4
- package/dist/{chunk-CATVBGGR.js.map → chunk-2F2PJ7D7.js.map} +1 -1
- package/dist/chunk-4CZV24HW.js +196 -0
- package/dist/chunk-4CZV24HW.js.map +1 -0
- package/dist/{chunk-C67TSSYB.js → chunk-M4JOF35U.js} +108 -15
- package/dist/chunk-M4JOF35U.js.map +1 -0
- package/dist/chunk-QTBMLMKT.js +698 -0
- package/dist/chunk-QTBMLMKT.js.map +1 -0
- package/dist/fs-B3Zi9oo6.d.ts +104 -0
- package/dist/index.d.ts +137 -5
- package/dist/index.js +14 -6
- package/dist/plugin/fs.d.ts +1 -1
- package/dist/plugin/fs.js +2 -2
- package/dist/plugin/service.d.ts +3 -2
- package/dist/plugin/service.js +1 -1
- package/dist/plugin/shell.d.ts +1 -1
- package/dist/plugin/shell.js +2 -2
- package/dist/{service-CxFYAvoV.d.ts → service-BK6NR_RX.d.ts} +51 -1
- package/dist/shell-BTf2wsDy.d.ts +258 -0
- package/package.json +10 -7
- package/dist/chunk-C67TSSYB.js.map +0 -1
- package/dist/chunk-J6F4OX6Z.js +0 -102
- package/dist/chunk-J6F4OX6Z.js.map +0 -1
- package/dist/chunk-ZSZVHBSL.js +0 -455
- package/dist/chunk-ZSZVHBSL.js.map +0 -1
- package/dist/fs-CuiUEA7A.d.ts +0 -54
- package/dist/shell-B0N_jppN.d.ts +0 -108
package/cordis.patch.yml
CHANGED
|
@@ -19,6 +19,55 @@
|
|
|
19
19
|
# config: { token: !!js process.env.SLACK_BOT_TOKEN }
|
|
20
20
|
# runtimes:
|
|
21
21
|
# - { name: monty, captures: [python, python3] }
|
|
22
|
+
#
|
|
23
|
+
# A profile may also carry a permission document, which is what turns
|
|
24
|
+
# mirage's allow/ask/deny into a policy for this bundle. Roles go under
|
|
25
|
+
# `profiles`, and `profile` names the one a session gets by default:
|
|
26
|
+
#
|
|
27
|
+
# - id: mirage
|
|
28
|
+
# config:
|
|
29
|
+
# profiles:
|
|
30
|
+
# agent:
|
|
31
|
+
# commands:
|
|
32
|
+
# allow: [cat, ls, grep, rm]
|
|
33
|
+
# ask:
|
|
34
|
+
# - { commands: [rm], reason: deletes are reviewed }
|
|
35
|
+
# deny:
|
|
36
|
+
# - { commands: [git push], reason: no publishing }
|
|
37
|
+
# profile: agent
|
|
38
|
+
#
|
|
39
|
+
# An `ask` rule is put to the human through dsh's own approval channel
|
|
40
|
+
# (`ctx.approval`), so the prompt the agent triggers is the one dsh
|
|
41
|
+
# already shows for its escalations. It quotes the line word by word as
|
|
42
|
+
# a shell would read it back, so a name carrying a space or a newline
|
|
43
|
+
# cannot pass itself off in the prompt as two operands or two lines.
|
|
44
|
+
# `allowed-once` runs the line;
|
|
45
|
+
# a rejection, a dismissal and an unanswerable ask all refuse it. With
|
|
46
|
+
# no approval plugin composed the ask has nowhere to go, so it stays a
|
|
47
|
+
# pending record the host answers through `ctx.mirage.decisions`
|
|
48
|
+
# (`pending()`, then `answer(id, outcome, scope)`) — deliberately not a
|
|
49
|
+
# refusal, since rewriting an `ask` rule as a `deny` would change what
|
|
50
|
+
# the document says. A question never outlives the run that raised it:
|
|
51
|
+
# the run's abort signal goes to the channel with the request and bounds
|
|
52
|
+
# the ledger's wait, so a line still unanswered when its timeout expires
|
|
53
|
+
# comes back as the kill, and a yes arriving after that is dropped
|
|
54
|
+
# rather than banked for the next identical line.
|
|
55
|
+
#
|
|
56
|
+
# Two facts about how the mirage providers read the surrounding dsh
|
|
57
|
+
# composition, neither of which needs a row here:
|
|
58
|
+
#
|
|
59
|
+
# * The standing file policy is enforced, not merely reported. Both
|
|
60
|
+
# seams narrow every mount grant to read for a `read-only` call
|
|
61
|
+
# (leaving the null sink writable, as that mode's definition
|
|
62
|
+
# requires), so dsh-base's `DSH_PERMISSION_MODE=read-only` now
|
|
63
|
+
# actually confines a mirage world instead of being ignored. The
|
|
64
|
+
# policy's `workspaceRoot` is a directory on the host and is never
|
|
65
|
+
# consulted: the mounts and their modes are the boundary here.
|
|
66
|
+
#
|
|
67
|
+
# * A workdir dsh resolved on its own machine (an unspecified one
|
|
68
|
+
# comes from the calling session's cwd) names nothing in this world,
|
|
69
|
+
# so it is ignored in favour of the workspace default rather than
|
|
70
|
+
# run in a directory the agent cannot reach.
|
|
22
71
|
|
|
23
72
|
# The host filesystem and bash providers step aside; mirage provides the
|
|
24
73
|
# same ctx.fs and ctx.shell seams over the workspace.
|
|
@@ -48,16 +48,77 @@ function applyLiteralEdit(content, oldString, newString, replaceAll, displayPath
|
|
|
48
48
|
}
|
|
49
49
|
return replaceAll ? content.replaceAll(oldString, newString) : content.replace(oldString, newString);
|
|
50
50
|
}
|
|
51
|
+
function charBoundary(bytes, from) {
|
|
52
|
+
let start = from;
|
|
53
|
+
while (start < bytes.byteLength && ((bytes[start] ?? 0) & 192) === 128) start++;
|
|
54
|
+
return start;
|
|
55
|
+
}
|
|
51
56
|
function tailCap(text, maxBytes) {
|
|
52
57
|
const bytes = new TextEncoder().encode(text);
|
|
53
58
|
if (bytes.byteLength <= maxBytes) return { text, truncated: false };
|
|
54
|
-
|
|
55
|
-
while (start < bytes.byteLength && ((bytes[start] ?? 0) & 192) === 128) start++;
|
|
59
|
+
const start = charBoundary(bytes, bytes.byteLength - maxBytes);
|
|
56
60
|
return {
|
|
57
61
|
text: new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(start)),
|
|
58
62
|
truncated: true
|
|
59
63
|
};
|
|
60
64
|
}
|
|
65
|
+
var TailBuffer = class {
|
|
66
|
+
budget;
|
|
67
|
+
parts = [];
|
|
68
|
+
bytes = 0;
|
|
69
|
+
constructor(budget) {
|
|
70
|
+
this.budget = budget;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Append a chunk, dropping the oldest bytes that no longer fit.
|
|
74
|
+
*
|
|
75
|
+
* @param data the bytes to append.
|
|
76
|
+
* @returns true when bytes were dropped to make room.
|
|
77
|
+
*/
|
|
78
|
+
append(data) {
|
|
79
|
+
this.parts.push(data);
|
|
80
|
+
this.bytes += data.byteLength;
|
|
81
|
+
if (this.bytes <= this.budget) return false;
|
|
82
|
+
let excess = this.bytes - this.budget;
|
|
83
|
+
while (excess > 0) {
|
|
84
|
+
const head = this.parts[0];
|
|
85
|
+
if (head === void 0) break;
|
|
86
|
+
if (head.byteLength <= excess) {
|
|
87
|
+
this.parts.shift();
|
|
88
|
+
excess -= head.byteLength;
|
|
89
|
+
this.bytes -= head.byteLength;
|
|
90
|
+
} else {
|
|
91
|
+
this.parts[0] = head.subarray(excess);
|
|
92
|
+
this.bytes -= excess;
|
|
93
|
+
excess = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Drain everything held, decoded as text.
|
|
100
|
+
*
|
|
101
|
+
* The head may sit mid-character, since dropping is byte-exact, so it
|
|
102
|
+
* is re-aligned before decoding rather than rendered as a replacement
|
|
103
|
+
* mark.
|
|
104
|
+
*
|
|
105
|
+
* @returns the buffered text; the buffer is left empty.
|
|
106
|
+
*/
|
|
107
|
+
take() {
|
|
108
|
+
if (this.parts.length === 0) return "";
|
|
109
|
+
const joined = new Uint8Array(this.bytes);
|
|
110
|
+
let at = 0;
|
|
111
|
+
for (const part of this.parts) {
|
|
112
|
+
joined.set(part, at);
|
|
113
|
+
at += part.byteLength;
|
|
114
|
+
}
|
|
115
|
+
this.parts = [];
|
|
116
|
+
this.bytes = 0;
|
|
117
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(
|
|
118
|
+
joined.subarray(charBoundary(joined, 0))
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
61
122
|
|
|
62
123
|
export {
|
|
63
124
|
decodeStrictText,
|
|
@@ -65,6 +126,7 @@ export {
|
|
|
65
126
|
detectsCrlf,
|
|
66
127
|
restoreLineEndings,
|
|
67
128
|
applyLiteralEdit,
|
|
68
|
-
tailCap
|
|
129
|
+
tailCap,
|
|
130
|
+
TailBuffer
|
|
69
131
|
};
|
|
70
|
-
//# sourceMappingURL=chunk-
|
|
132
|
+
//# sourceMappingURL=chunk-2F2PJ7D7.js.map
|
|
@@ -1 +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'\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 readFileText 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// Byte-accurate tail cap: keep the LAST maxBytes bytes of the encoded text,\n// re-aligned to a UTF-8 sequence boundary so the kept tail still decodes.\nexport function tailCap(text: string, maxBytes: number): { text: string; truncated: boolean } {\n const bytes = new TextEncoder().encode(text)\n if (bytes.byteLength <= maxBytes) return { text, truncated: false }\n
|
|
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'\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 readFileText 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// Byte-accurate tail cap: keep the LAST maxBytes bytes of the encoded text,\n// re-aligned to a UTF-8 sequence boundary so the kept tail still decodes.\nexport function tailCap(text: string, maxBytes: number): { text: string; truncated: boolean } {\n const bytes = new TextEncoder().encode(text)\n if (bytes.byteLength <= maxBytes) return { text, truncated: false }\n const start = charBoundary(bytes, bytes.byteLength - maxBytes)\n return {\n text: new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(start)),\n truncated: true,\n }\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 /**\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 = new Uint8Array(this.bytes)\n let at = 0\n for (const part of this.parts) {\n joined.set(part, at)\n at += part.byteLength\n }\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"],"mappings":";AAcA,SAAS,eAAe;AAExB,IAAM,sBAAsB;AAMrB,SAAS,iBAAiB,OAAmB,aAA6B;AAC/E,MAAI,MAAM,SAAS,GAAG,mBAAmB,EAAE,SAAS,CAAC,GAAG;AACtD,UAAM,IAAI,QAAQ,gBAAgB,WAAW,kBAAkB,aAAa;AAAA,EAC9E;AACA,MAAI;AACF,WAAO,IAAI,YAAY,SAAS,EAAE,OAAO,KAAK,CAAC,EAAE,OAAO,KAAK;AAAA,EAC/D,SAAS,OAAgB;AACvB,UAAM,IAAI,QAAQ,gBAAgB,WAAW,yBAAyB,eAAe;AAAA,MACnF,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACF;AAEO,SAAS,qBAAqB,OAAuB;AAC1D,SAAO,MAAM,WAAW,QAAQ,IAAI;AACtC;AAIO,SAAS,YAAY,OAAwB;AAClD,QAAM,SAAS,MAAM,MAAM,GAAG,IAAI;AAClC,QAAM,OAAO,OAAO,MAAM,MAAM,EAAE,SAAS;AAC3C,QAAM,KAAK,OAAO,MAAM,IAAI,EAAE,SAAS,IAAI;AAC3C,SAAO,OAAO;AAChB;AAEO,SAAS,mBAAmB,OAAe,MAAuB;AACvE,SAAO,OAAO,qBAAqB,KAAK,EAAE,WAAW,MAAM,MAAM,IAAI;AACvE;AAEA,SAAS,iBAAiB,UAAkB,QAAwB;AAClE,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,MAAI,QAAQ;AACZ,MAAI,QAAQ,SAAS,QAAQ,MAAM;AACnC,SAAO,UAAU,IAAI;AACnB;AACA,YAAQ,SAAS,QAAQ,QAAQ,QAAQ,OAAO,MAAM;AAAA,EACxD;AACA,SAAO;AACT;AAKO,SAAS,iBACd,SACA,WACA,WACA,YACA,aACQ;AACR,QAAM,UAAU,iBAAiB,SAAS,SAAS;AACnD,MAAI,YAAY,GAAG;AACjB,UAAM,IAAI,QAAQ,gBAAgB,WAAW,0BAA0B,mBAAmB;AAAA,EAC5F;AACA,MAAI,UAAU,KAAK,CAAC,YAAY;AAC9B,UAAM,IAAI;AAAA,MACR,gBAAgB,WAAW,wBAAwB,OAAO,OAAO,CAAC;AAAA,MAClE;AAAA,IACF;AAAA,EACF;AACA,SAAO,aACH,QAAQ,WAAW,WAAW,SAAS,IACvC,QAAQ,QAAQ,WAAW,SAAS;AAC1C;AAIA,SAAS,aAAa,OAAmB,MAAsB;AAC7D,MAAI,QAAQ;AACZ,SAAO,QAAQ,MAAM,gBAAgB,MAAM,KAAK,KAAK,KAAK,SAAU,IAAM;AAC1E,SAAO;AACT;AAIO,SAAS,QAAQ,MAAc,UAAwD;AAC5F,QAAM,QAAQ,IAAI,YAAY,EAAE,OAAO,IAAI;AAC3C,MAAI,MAAM,cAAc,SAAU,QAAO,EAAE,MAAM,WAAW,MAAM;AAClE,QAAM,QAAQ,aAAa,OAAO,MAAM,aAAa,QAAQ;AAC7D,SAAO;AAAA,IACL,MAAM,IAAI,YAAY,SAAS,EAAE,OAAO,MAAM,CAAC,EAAE,OAAO,MAAM,SAAS,KAAK,CAAC;AAAA,IAC7E,WAAW;AAAA,EACb;AACF;AAYO,IAAM,aAAN,MAAiB;AAAA,EACL;AAAA,EACT,QAAsB,CAAC;AAAA,EACvB,QAAQ;AAAA,EAEhB,YAAY,QAAgB;AAC1B,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,OAAO,MAA2B;AAChC,SAAK,MAAM,KAAK,IAAI;AACpB,SAAK,SAAS,KAAK;AACnB,QAAI,KAAK,SAAS,KAAK,OAAQ,QAAO;AACtC,QAAI,SAAS,KAAK,QAAQ,KAAK;AAC/B,WAAO,SAAS,GAAG;AACjB,YAAM,OAAO,KAAK,MAAM,CAAC;AACzB,UAAI,SAAS,OAAW;AACxB,UAAI,KAAK,cAAc,QAAQ;AAC7B,aAAK,MAAM,MAAM;AACjB,kBAAU,KAAK;AACf,aAAK,SAAS,KAAK;AAAA,MACrB,OAAO;AACL,aAAK,MAAM,CAAC,IAAI,KAAK,SAAS,MAAM;AACpC,aAAK,SAAS;AACd,iBAAS;AAAA,MACX;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,OAAe;AACb,QAAI,KAAK,MAAM,WAAW,EAAG,QAAO;AACpC,UAAM,SAAS,IAAI,WAAW,KAAK,KAAK;AACxC,QAAI,KAAK;AACT,eAAW,QAAQ,KAAK,OAAO;AAC7B,aAAO,IAAI,MAAM,EAAE;AACnB,YAAM,KAAK;AAAA,IACb;AACA,SAAK,QAAQ,CAAC;AACd,SAAK,QAAQ;AACb,WAAO,IAAI,YAAY,SAAS,EAAE,OAAO,MAAM,CAAC,EAAE;AAAA,MAChD,OAAO,SAAS,aAAa,QAAQ,CAAC,CAAC;AAAA,IACzC;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// src/approval.ts
|
|
2
|
+
import { Outcome, Scope } from "@struktoai/mirage-core/policy/types";
|
|
3
|
+
import { shellQuote } from "@struktoai/mirage-core/utils/quote";
|
|
4
|
+
var APPROVAL_TOOL_NAME = "bash";
|
|
5
|
+
function approverOf(ctx) {
|
|
6
|
+
const held = ctx.get("approval");
|
|
7
|
+
if (held === null || typeof held !== "object") return null;
|
|
8
|
+
const { request } = held;
|
|
9
|
+
return typeof request === "function" ? held : null;
|
|
10
|
+
}
|
|
11
|
+
function approvalReason(record) {
|
|
12
|
+
const line = [record.command, ...record.argv].map(shellQuote).join(" ");
|
|
13
|
+
return `${record.reason}: ${line}`;
|
|
14
|
+
}
|
|
15
|
+
function askThroughApproval(ctx) {
|
|
16
|
+
return async (record, signal) => {
|
|
17
|
+
const approver = approverOf(ctx);
|
|
18
|
+
if (approver === null) return null;
|
|
19
|
+
const outcome = await approver.request({
|
|
20
|
+
agent: void 0,
|
|
21
|
+
toolName: APPROVAL_TOOL_NAME,
|
|
22
|
+
callId: record.id,
|
|
23
|
+
reason: approvalReason(record),
|
|
24
|
+
...signal !== void 0 ? { signal } : {}
|
|
25
|
+
});
|
|
26
|
+
const verb = outcome === "allowed-once" ? Outcome.ALLOW : Outcome.DENY;
|
|
27
|
+
return { ...record, outcome: verb, scope: Scope.ONCE };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/service.ts
|
|
32
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
33
|
+
import { buildRuntime } from "@struktoai/mirage-core/runtime/table";
|
|
34
|
+
import { parseMountMode } from "@struktoai/mirage-core/types";
|
|
35
|
+
import { Workspace, buildResource, parseSessionProfile } from "@struktoai/mirage-node";
|
|
36
|
+
function isMountBlock(entry) {
|
|
37
|
+
if (Array.isArray(entry)) return false;
|
|
38
|
+
return typeof entry.resource === "string";
|
|
39
|
+
}
|
|
40
|
+
async function resolveMount(entry) {
|
|
41
|
+
const resource = await buildResource(entry.resource, entry.config ?? {});
|
|
42
|
+
if (entry.mode === void 0) return resource;
|
|
43
|
+
return [resource, parseMountMode(entry.mode)];
|
|
44
|
+
}
|
|
45
|
+
function parseProfiles(raw) {
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const [name, block] of Object.entries(raw)) {
|
|
48
|
+
out[name] = parseSessionProfile(block, `profile \`${name}\``);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function toRuntimeEntry(entry) {
|
|
53
|
+
if (typeof entry === "string") return entry;
|
|
54
|
+
return buildRuntime(entry.name, entry.captures === void 0 ? {} : { captures: entry.captures });
|
|
55
|
+
}
|
|
56
|
+
function builtEntry(entry) {
|
|
57
|
+
return typeof entry === "string" ? buildRuntime(entry) : entry;
|
|
58
|
+
}
|
|
59
|
+
var MirageService = class extends Service {
|
|
60
|
+
static provide = "mirage";
|
|
61
|
+
/** Resolves to the shared workspace once every mount is built. */
|
|
62
|
+
ready;
|
|
63
|
+
built = null;
|
|
64
|
+
plannedRuntimes;
|
|
65
|
+
constructor(ctx, config = {}) {
|
|
66
|
+
super(ctx, "mirage");
|
|
67
|
+
if (config.workspace !== void 0 && config.mounts !== void 0) {
|
|
68
|
+
throw new Error("mirage: pass either workspace or mounts, not both");
|
|
69
|
+
}
|
|
70
|
+
if (config.workspace !== void 0) {
|
|
71
|
+
if (config.runtimes !== void 0 || config.workspaceOptions !== void 0 || config.profiles !== void 0 || config.profile !== void 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"mirage: runtimes, profiles, profile and workspaceOptions configure a service-owned workspace, not an adopted workspace"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
this.built = config.workspace;
|
|
77
|
+
this.ready = Promise.resolve(config.workspace);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (config.mounts === void 0) {
|
|
81
|
+
throw new Error("mirage: one of workspace or mounts is required");
|
|
82
|
+
}
|
|
83
|
+
const options = { ...config.workspaceOptions ?? {} };
|
|
84
|
+
if (config.runtimes !== void 0) {
|
|
85
|
+
if (options.runtimes !== void 0) {
|
|
86
|
+
throw new Error("mirage: pass runtimes or workspaceOptions.runtimes, not both");
|
|
87
|
+
}
|
|
88
|
+
options.runtimes = config.runtimes.map(toRuntimeEntry);
|
|
89
|
+
}
|
|
90
|
+
if (config.profiles !== void 0) {
|
|
91
|
+
if (options.profiles !== void 0) {
|
|
92
|
+
throw new Error("mirage: pass profiles or workspaceOptions.profiles, not both");
|
|
93
|
+
}
|
|
94
|
+
options.profiles = parseProfiles(config.profiles);
|
|
95
|
+
}
|
|
96
|
+
if (config.profile !== void 0) {
|
|
97
|
+
if (options.profile !== void 0) {
|
|
98
|
+
throw new Error("mirage: pass profile or workspaceOptions.profile, not both");
|
|
99
|
+
}
|
|
100
|
+
options.profile = config.profile;
|
|
101
|
+
}
|
|
102
|
+
options.onAsk ??= askThroughApproval(ctx);
|
|
103
|
+
this.plannedRuntimes = options.runtimes?.map(builtEntry);
|
|
104
|
+
if (this.plannedRuntimes !== void 0) options.runtimes = [...this.plannedRuntimes];
|
|
105
|
+
const blocks = Object.values(config.mounts).some(isMountBlock);
|
|
106
|
+
if (blocks) {
|
|
107
|
+
this.ready = this.open(config.mounts, options);
|
|
108
|
+
void this.ready.catch(() => void 0);
|
|
109
|
+
} else {
|
|
110
|
+
const owned = new Workspace(config.mounts, options);
|
|
111
|
+
this.built = owned;
|
|
112
|
+
this.ready = Promise.resolve(owned);
|
|
113
|
+
}
|
|
114
|
+
ctx.effect(
|
|
115
|
+
() => async () => {
|
|
116
|
+
const ws = await this.ready.catch(() => null);
|
|
117
|
+
if (ws !== null) await ws.close();
|
|
118
|
+
},
|
|
119
|
+
"mirage workspace"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
/** The live workspace; throws until `ready` resolves. */
|
|
123
|
+
get workspace() {
|
|
124
|
+
if (this.built === null) {
|
|
125
|
+
throw new Error("mirage: workspace is not ready yet; await ctx.mirage.ready");
|
|
126
|
+
}
|
|
127
|
+
return this.built;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The decision ledger: every ask this world has raised and every
|
|
131
|
+
* answer given, questions still waiting included.
|
|
132
|
+
*
|
|
133
|
+
* The host's handle on the ask layer. A composition with an approval
|
|
134
|
+
* channel answers inline and rarely reads this; one without it finds
|
|
135
|
+
* its pending questions here (`pending()`) and answers them out of
|
|
136
|
+
* band (`answer(id, outcome, scope)`), which is what makes an `ask`
|
|
137
|
+
* rule usable with no human at the keyboard.
|
|
138
|
+
*/
|
|
139
|
+
get decisions() {
|
|
140
|
+
return this.workspace.decisions;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* What a line would do here, without doing it: one `Explanation` per
|
|
144
|
+
* command, carrying the outcome, the rule that spoke and the exact
|
|
145
|
+
* exit code and stderr the agent would get.
|
|
146
|
+
*
|
|
147
|
+
* A dry run through the same gate the dispatcher uses, so a host
|
|
148
|
+
* reading this and an agent typing the line cannot be told different
|
|
149
|
+
* things. It spends no ONCE answer, records no question and never
|
|
150
|
+
* reaches the approval channel, which is what makes it safe to call
|
|
151
|
+
* about a line nobody typed — a pre-execute check, or an operator
|
|
152
|
+
* asking why a command was refused.
|
|
153
|
+
*
|
|
154
|
+
* @param line the command line to judge.
|
|
155
|
+
* @param sessionId the session to judge it in; the default when empty.
|
|
156
|
+
* @returns one explanation per command of the line.
|
|
157
|
+
*/
|
|
158
|
+
async explain(line, sessionId = "") {
|
|
159
|
+
const ws = await this.ready;
|
|
160
|
+
return ws.explain(line, sessionId);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* True while every runtime in the world reaches only the vfs
|
|
164
|
+
* (`Runtime.reach`), meaning the workspace dispatch is the single
|
|
165
|
+
* gate for anything the agent can execute: every effect passes
|
|
166
|
+
* mount modes, session grants, and policy before touching the real
|
|
167
|
+
* backend behind a mount. The default world qualifies, and so do
|
|
168
|
+
* the bridged engines, while the host `local` python or a remote
|
|
169
|
+
* sandbox can act around the gate. Answers before `ready` from the
|
|
170
|
+
* constructor-resolved entries (mounts resolve asynchronously,
|
|
171
|
+
* runtimes never do) and from the live workspace afterwards, so a
|
|
172
|
+
* later `addRuntime` is seen.
|
|
173
|
+
*/
|
|
174
|
+
get vfsOnly() {
|
|
175
|
+
const entries = this.built === null ? this.plannedRuntimes ?? [] : this.built.runtimeEntries;
|
|
176
|
+
return entries.every((entry) => entry.reach === "vfs");
|
|
177
|
+
}
|
|
178
|
+
async open(mounts, options) {
|
|
179
|
+
const resolved = {};
|
|
180
|
+
for (const [prefix, entry] of Object.entries(mounts)) {
|
|
181
|
+
resolved[prefix] = isMountBlock(entry) ? await resolveMount(entry) : entry;
|
|
182
|
+
}
|
|
183
|
+
const ws = new Workspace(resolved, options);
|
|
184
|
+
this.built = ws;
|
|
185
|
+
return ws;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
APPROVAL_TOOL_NAME,
|
|
191
|
+
approverOf,
|
|
192
|
+
approvalReason,
|
|
193
|
+
askThroughApproval,
|
|
194
|
+
MirageService
|
|
195
|
+
};
|
|
196
|
+
//# sourceMappingURL=chunk-4CZV24HW.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 { Explanation } 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 type { MountSpec } from '@struktoai/mirage-core/workspace/workspace/workspace'\nimport { Workspace, buildResource, parseSessionProfile } from '@struktoai/mirage-node'\nimport type { Mount, 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 resource is named by its\n * registry type and built through `buildResource`, 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 `resource`: a string names a\n * registry entry, an object is the instance itself.\n */\nexport interface MirageMountBlock {\n /** Resource registry name (`ram`, `s3`, `slack`, `redis`, ...). */\n resource: string\n /** Mount mode: `read`, `write`, or `exec`. Omitted = the workspace default. */\n mode?: string\n /** Resource 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 captures. */\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}\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).resource === 'string'\n}\n\nasync function resolveMount(entry: MirageMountBlock): Promise<MountSpec> {\n const resource = await buildResource(entry.resource, entry.config ?? {})\n if (entry.mode === undefined) return resource\n return [resource, parseMountMode(entry.mode)]\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\nfunction toRuntimeEntry(entry: string | MirageRuntimeBlock): RuntimeEntry {\n if (typeof entry === 'string') return entry\n return buildRuntime(entry.name, entry.captures === undefined ? {} : { captures: entry.captures })\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 resources is synchronous; a declarative mount\n * block defers to `buildResource`, 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 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: one `Explanation` per\n * command, carrying the outcome, the rule that spoke and the exact\n * exit code and stderr the agent would get.\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 one explanation per command of the line.\n */\n async explain(line: string, sessionId = ''): Promise<Explanation[]> {\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` is seen.\n */\n get vfsOnly(): boolean {\n const entries = this.built === null ? (this.plannedRuntimes ?? []) : this.built.runtimeEntries\n return entries.every((entry) => entry.reach === 'vfs')\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":";AAiBA,SAAS,SAAS,aAAa;AAC/B,SAAS,kBAAkB;AAQpB,IAAM,qBAAqB;AAqD3B,SAAS,WAAW,KAA+B;AACxD,QAAM,OAAgB,IAAI,IAAI,UAAU;AACxC,MAAI,SAAS,QAAQ,OAAO,SAAS,SAAU,QAAO;AACtD,QAAM,EAAE,QAAQ,IAAI;AACpB,SAAO,OAAO,YAAY,aAAc,OAAoB;AAC9D;AAsBO,SAAS,eAAe,QAA0B;AACvD,QAAM,OAAO,CAAC,OAAO,SAAS,GAAG,OAAO,IAAI,EAAE,IAAI,UAAU,EAAE,KAAK,GAAG;AACtE,SAAO,GAAG,OAAO,MAAM,KAAK,IAAI;AAClC;AAkDO,SAAS,mBAAmB,KAA0B;AAC3D,SAAO,OAAO,QAAkB,WAAmD;AACjF,UAAM,WAAW,WAAW,GAAG;AAC/B,QAAI,aAAa,KAAM,QAAO;AAC9B,UAAM,UAAU,MAAM,SAAS,QAAQ;AAAA,MACrC,OAAO;AAAA,MACP,UAAU;AAAA,MACV,QAAQ,OAAO;AAAA,MACf,QAAQ,eAAe,MAAM;AAAA,MAC7B,GAAI,WAAW,SAAY,EAAE,OAAO,IAAI,CAAC;AAAA,IAC3C,CAAC;AACD,UAAM,OAAO,YAAY,iBAAiB,QAAQ,QAAQ,QAAQ;AAClE,WAAO,EAAE,GAAG,QAAQ,SAAS,MAAM,OAAO,MAAM,KAAK;AAAA,EACvD;AACF;;;AC/JA,SAAS,eAAe;AAIxB,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB;AAE/B,SAAS,WAAW,eAAe,2BAA2B;AA+E9D,SAAS,aAAa,OAA+C;AACnE,MAAI,MAAM,QAAQ,KAAK,EAAG,QAAO;AACjC,SAAO,OAAQ,MAA2B,aAAa;AACzD;AAEA,eAAe,aAAa,OAA6C;AACvE,QAAM,WAAW,MAAM,cAAc,MAAM,UAAU,MAAM,UAAU,CAAC,CAAC;AACvE,MAAI,MAAM,SAAS,OAAW,QAAO;AACrC,SAAO,CAAC,UAAU,eAAe,MAAM,IAAI,CAAC;AAC9C;AAUA,SAAS,cAAc,KAA8D;AACnF,QAAM,MAAsC,CAAC;AAC7C,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC/C,QAAI,IAAI,IAAI,oBAAoB,OAAO,aAAa,IAAI,IAAI;AAAA,EAC9D;AACA,SAAO;AACT;AAEA,SAAS,eAAe,OAAkD;AACxE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,SAAO,aAAa,MAAM,MAAM,MAAM,aAAa,SAAY,CAAC,IAAI,EAAE,UAAU,MAAM,SAAS,CAAC;AAClG;AAKA,SAAS,WAAW,OAA8B;AAChD,SAAO,OAAO,UAAU,WAAW,aAAa,KAAK,IAAI;AAC3D;AAcO,IAAM,gBAAN,cAA4B,QAAQ;AAAA,EACzC,OAAgB,UAAU;AAAA;AAAA,EAGjB;AAAA,EAED,QAA0B;AAAA,EACjB;AAAA,EAEjB,YAAY,KAAc,SAAuB,CAAC,GAAG;AACnD,UAAM,KAAK,QAAQ;AACnB,QAAI,OAAO,cAAc,UAAa,OAAO,WAAW,QAAW;AACjE,YAAM,IAAI,MAAM,mDAAmD;AAAA,IACrE;AACA,QAAI,OAAO,cAAc,QAAW;AASlC,UACE,OAAO,aAAa,UACpB,OAAO,qBAAqB,UAC5B,OAAO,aAAa,UACpB,OAAO,YAAY,QACnB;AACA,cAAM,IAAI;AAAA,UACR;AAAA,QACF;AAAA,MACF;AACA,WAAK,QAAQ,OAAO;AACpB,WAAK,QAAQ,QAAQ,QAAQ,OAAO,SAAS;AAC7C;AAAA,IACF;AACA,QAAI,OAAO,WAAW,QAAW;AAC/B,YAAM,IAAI,MAAM,gDAAgD;AAAA,IAClE;AACA,UAAM,UAAU,EAAE,GAAI,OAAO,oBAAoB,CAAC,EAAG;AACrD,QAAI,OAAO,aAAa,QAAW;AACjC,UAAI,QAAQ,aAAa,QAAW;AAClC,cAAM,IAAI,MAAM,8DAA8D;AAAA,MAChF;AACA,cAAQ,WAAW,OAAO,SAAS,IAAI,cAAc;AAAA,IACvD;AACA,QAAI,OAAO,aAAa,QAAW;AACjC,UAAI,QAAQ,aAAa,QAAW;AAClC,cAAM,IAAI,MAAM,8DAA8D;AAAA,MAChF;AACA,cAAQ,WAAW,cAAc,OAAO,QAAQ;AAAA,IAClD;AACA,QAAI,OAAO,YAAY,QAAW;AAChC,UAAI,QAAQ,YAAY,QAAW;AACjC,cAAM,IAAI,MAAM,4DAA4D;AAAA,MAC9E;AACA,cAAQ,UAAU,OAAO;AAAA,IAC3B;AAOA,YAAQ,UAAU,mBAAmB,GAAG;AACxC,SAAK,kBAAkB,QAAQ,UAAU,IAAI,UAAU;AACvD,QAAI,KAAK,oBAAoB,OAAW,SAAQ,WAAW,CAAC,GAAG,KAAK,eAAe;AACnF,UAAM,SAAS,OAAO,OAAO,OAAO,MAAM,EAAE,KAAK,YAAY;AAC7D,QAAI,QAAQ;AACV,WAAK,QAAQ,KAAK,KAAK,OAAO,QAAQ,OAAO;AAO7C,WAAK,KAAK,MAAM,MAAM,MAAM,MAAS;AAAA,IACvC,OAAO;AACL,YAAM,QAAQ,IAAI,UAAU,OAAO,QAA6C,OAAO;AACvF,WAAK,QAAQ;AACb,WAAK,QAAQ,QAAQ,QAAQ,KAAK;AAAA,IACpC;AACA,QAAI;AAAA,MACF,MAAM,YAAY;AAChB,cAAM,KAAK,MAAM,KAAK,MAAM,MAAM,MAAM,IAAI;AAC5C,YAAI,OAAO,KAAM,OAAM,GAAG,MAAM;AAAA,MAClC;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,YAAuB;AACzB,QAAI,KAAK,UAAU,MAAM;AACvB,YAAM,IAAI,MAAM,4DAA4D;AAAA,IAC9E;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,IAAI,YAAoC;AACtC,WAAO,KAAK,UAAU;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,MAAM,QAAQ,MAAc,YAAY,IAA4B;AAClE,UAAM,KAAK,MAAM,KAAK;AACtB,WAAO,GAAG,QAAQ,MAAM,SAAS;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,IAAI,UAAmB;AACrB,UAAM,UAAU,KAAK,UAAU,OAAQ,KAAK,mBAAmB,CAAC,IAAK,KAAK,MAAM;AAChF,WAAO,QAAQ,MAAM,CAAC,UAAU,MAAM,UAAU,KAAK;AAAA,EACvD;AAAA,EAEA,MAAc,KACZ,QACA,SACoB;AACpB,UAAM,WAA8C,CAAC;AACrD,eAAW,CAAC,QAAQ,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACpD,eAAS,MAAM,IAAI,aAAa,KAAK,IAAI,MAAM,aAAa,KAAK,IAAI;AAAA,IACvE;AACA,UAAM,KAAK,IAAI,UAAU,UAAU,OAAO;AAC1C,SAAK,QAAQ;AACb,WAAO;AAAA,EACT;AACF;","names":[]}
|