@lone-wolf-akela/dsh-sandboxie-redirect 0.3.0

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/lib/boxes.mjs ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Per-workspace box lifecycle.
3
+ *
4
+ * One box per (workspace, mode) pair, created lazily and idempotently:
5
+ * workspace-write / copy-on-write -> `dsh_<adjective>_<noun>` (`OpenFilePath`)
6
+ * read-only -> `dshr_<adjective>_<noun>` (`ReadFilePath`)
7
+ *
8
+ * Two boxes rather than one because Sandboxie expresses "the workspace is
9
+ * writable" and "the workspace is read-only" as *different settings on the box*;
10
+ * there is no per-launch override.
11
+ *
12
+ * The name itself is a deterministic word pair derived from the workspace
13
+ * (see `naming.mjs`), so no state file is needed to find a workspace's box. The
14
+ * rare case of two workspaces hashing to the same pair is resolved by walking
15
+ * the next candidates: a box is only adopted when its workspace access rule
16
+ * names THIS workspace, so a foreign box is stepped over rather than hijacked.
17
+ *
18
+ * Everything here goes through `SbieIni.exe`, which writes `Sandboxie.ini`
19
+ * through the Sandboxie service. That matters: the config file itself is
20
+ * admin-only, but the service route works from an ordinary user process.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { boxNameCandidates, boxNameFor, boxRoot, canonicalWorkspace, isManagedBoxName, resolveBoxRoot, sbieSandboxRoot } from "./naming.mjs";
26
+ import { boxExists, listBoxNames, queryBoxSetting, reloadConfig, sbieExe, sbieIni, setBoxSetting } from "./sbie.mjs";
27
+
28
+ export { boxNameFor, boxNameCandidates, boxRoot, canonicalWorkspace, isManagedBoxName, sbieSandboxRoot };
29
+
30
+ /**
31
+ * Sandboxie's configured `FileRootPath`, per box, memoized for this process:
32
+ * asking costs an `SbieIni.exe` spawn, and the answer only changes when the user
33
+ * edits Sandboxie's settings.
34
+ *
35
+ * An EMPTY answer counts as "not declared". Sandboxie reports an unset key as a
36
+ * successful query with no output, and a plain `??` would then stop at the box
37
+ * and never look at `[GlobalSettings]` — so a global setting would be ignored on
38
+ * exactly the machines that rely on it.
39
+ */
40
+ const rootTemplates = new Map();
41
+ function rootTemplateFor(box) {
42
+ if (rootTemplates.has(box)) return rootTemplates.get(box);
43
+ const read = (section) => {
44
+ try {
45
+ const value = queryBoxSetting(section, "FileRootPath");
46
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ };
51
+ const template = read(box) ?? read("GlobalSettings");
52
+ rootTemplates.set(box, template);
53
+ return template;
54
+ }
55
+
56
+ /** Drop the memoized `FileRootPath` values (after a settings change, or in tests). */
57
+ export function invalidateRootTemplates() {
58
+ rootTemplates.clear();
59
+ }
60
+
61
+ /**
62
+ * Where this box's copy actually lives: Sandboxie's configured `FileRootPath`
63
+ * when it declares one, else the default layout (or `DSH_SBIE_ROOT`).
64
+ */
65
+ export function rootFor(box) {
66
+ return resolveBoxRoot(box, { template: rootTemplateFor(box) });
67
+ }
68
+
69
+ /** How many name candidates to try before giving up on a collision. */
70
+ const CANDIDATE_LIMIT = 12;
71
+
72
+ /**
73
+ * The settings a managed box carries. Anything not listed keeps Sandboxie's
74
+ * default, which is "writes are redirected into the box" — the whole point.
75
+ */
76
+ export function boxSettings(workspace, mode) {
77
+ const root = canonicalWorkspace(workspace);
78
+ return [
79
+ ["Enabled", "y"],
80
+ ["ConfigLevel", "10"],
81
+ [mode === "read-only" ? "ReadFilePath" : "OpenFilePath", `${root}\\*`],
82
+ // Deletions become recorded tombstones in the copy instead of real unlinks.
83
+ ["UseFileDeleteV2", "y"],
84
+ ["UseRegDeleteV2", "y"],
85
+ // Nothing is auto-recovered: the copy stays put until a human or --manage decides.
86
+ ["AutoRecover", "n"],
87
+ // Headless must never raise a modal "large file" prompt.
88
+ ["CopyLimitKb", "131072"],
89
+ ["CopyLimitSilent", "y"]
90
+ ];
91
+ }
92
+
93
+ function accessKeyFor(mode) {
94
+ return mode === "read-only" ? "ReadFilePath" : "OpenFilePath";
95
+ }
96
+
97
+ /** Whether an existing box's access rule names this workspace. */
98
+ export function boxOwnedBy(box, workspace, mode) {
99
+ const current = queryBoxSetting(box, accessKeyFor(mode));
100
+ if (current === undefined) return false;
101
+ return current.toLowerCase().includes(`${canonicalWorkspace(workspace)}\\*`.toLowerCase());
102
+ }
103
+
104
+ /**
105
+ * The box name this workspace uses: the first candidate that is either free or
106
+ * already ours.
107
+ */
108
+ export function resolveBoxName(workspace, mode, options = {}) {
109
+ const log = options.logger ?? (() => {});
110
+ const candidates = boxNameCandidates(workspace, mode, CANDIDATE_LIMIT);
111
+ for (const [index, candidate] of candidates.entries()) {
112
+ if (!boxExists(candidate)) return candidate;
113
+ if (boxOwnedBy(candidate, workspace, mode)) return candidate;
114
+ log(`box ${candidate} belongs to a different workspace; trying the next name`);
115
+ if (index === candidates.length - 1) {
116
+ throw new Error(
117
+ `every generated box name for ${canonicalWorkspace(workspace)} (${mode}) is taken by another workspace: ${candidates.join(", ")}`
118
+ );
119
+ }
120
+ }
121
+ throw new Error(`could not resolve a box name for ${canonicalWorkspace(workspace)}`);
122
+ }
123
+
124
+ /**
125
+ * Create the box if needed and make sure its workspace access rule is intact.
126
+ *
127
+ * @param {{workspace: string, mode: "workspace-write"|"read-only", box?: string, logger?: (msg: string) => void}} request
128
+ * @returns {{box: string, root: string, created: boolean, workspace: string, mode: string}}
129
+ */
130
+ export function ensureBox(request) {
131
+ const workspace = canonicalWorkspace(request.workspace);
132
+ const mode = request.mode === "read-only" ? "read-only" : "workspace-write";
133
+ const log = request.logger ?? (() => {});
134
+ const box = request.box ?? resolveBoxName(workspace, mode, { logger: log });
135
+ const root = rootFor(box);
136
+ const accessKey = accessKeyFor(mode);
137
+ const accessRule = `${workspace}\\*`;
138
+
139
+ if (boxExists(box)) {
140
+ if (!boxOwnedBy(box, workspace, mode)) {
141
+ log(`box ${box}: workspace access rule missing or stale, rewriting`);
142
+ setBoxSetting(box, accessKey, accessRule);
143
+ reloadConfig();
144
+ }
145
+ return { box, root, created: false, workspace, mode };
146
+ }
147
+
148
+ log(`box ${box}: creating for ${workspace} (${mode})`);
149
+ for (const [key, value] of boxSettings(workspace, mode)) {
150
+ if (!setBoxSetting(box, key, value)) throw new Error(`SbieIni.exe set ${box} ${key} failed`);
151
+ }
152
+ reloadConfig();
153
+ if (!boxExists(box)) {
154
+ throw new Error(`box ${box} was written but Sandboxie does not report it (check C:\\Windows\\Sandboxie.ini)`);
155
+ }
156
+ return { box, root, created: true, workspace, mode };
157
+ }
158
+
159
+ /** Managed boxes (`dsh_*` / `dshr_*`, plus legacy `Dsh_<hex>`) and the workspace each shadows. */
160
+ export function listManagedBoxes() {
161
+ const boxes = [];
162
+ for (const name of listBoxNames()) {
163
+ if (!isManagedBoxName(name)) continue;
164
+ const mode = /^dshr/i.test(name) ? "read-only" : "workspace-write";
165
+ const access = queryBoxSetting(name, "OpenFilePath") ?? queryBoxSetting(name, "ReadFilePath") ?? "";
166
+ const root = rootFor(name);
167
+ boxes.push({
168
+ box: name,
169
+ mode,
170
+ workspace: access.replace(/\\\*$/, ""),
171
+ root,
172
+ exists: fs.existsSync(root),
173
+ legacy: /_[0-9a-f]{8}$/.test(name)
174
+ });
175
+ }
176
+ return boxes;
177
+ }
178
+
179
+ /**
180
+ * Every existing managed box bound to one workspace, in both access modes —
181
+ * both the current word-named candidates and any legacy hex-named box.
182
+ */
183
+ export function managedBoxesFor(workspace) {
184
+ const target = canonicalWorkspace(workspace).toLowerCase();
185
+ const found = new Map();
186
+ for (const mode of ["workspace-write", "read-only"]) {
187
+ for (const candidate of boxNameCandidates(workspace, mode, CANDIDATE_LIMIT)) {
188
+ if (!boxExists(candidate)) continue;
189
+ if (!boxOwnedBy(candidate, workspace, mode)) continue;
190
+ found.set(candidate, { box: candidate, mode });
191
+ }
192
+ }
193
+ for (const entry of listManagedBoxes()) {
194
+ if (entry.workspace.toLowerCase() === target) found.set(entry.box, { box: entry.box, mode: entry.mode });
195
+ }
196
+ return [...found.values()];
197
+ }
198
+
199
+ /** Stop every process running inside a box. */
200
+ export function terminateBox(box, options = {}) {
201
+ const exe = options.startExe ?? sbieExe("Start.exe");
202
+ const result = spawnSync(exe, [`/box:${box}`, "/silent", "/terminate"], { windowsHide: true, timeout: 30000 });
203
+ return result.status === 0;
204
+ }
205
+
206
+ /** Remove a managed box: stop its processes, drop its copy tree, delete its config section. */
207
+ export function removeBox(box, options = {}) {
208
+ const log = options.logger ?? (() => {});
209
+ terminateBox(box, options);
210
+ const root = rootFor(box);
211
+ if (fs.existsSync(root)) {
212
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
213
+ log(`removed ${root}`);
214
+ }
215
+ // Sandboxie's documented whole-section delete: `set <box> * ""` removes
216
+ // every line of that section. (`delete <box>` only removes a value line.)
217
+ const del = sbieIni(["set", box, "*", ""]);
218
+ if (!del.ok) log(`note: could not drop the [${box}] section (SbieIni exit ${del.status}); it stays in Sandboxie.ini but is disabled`);
219
+ reloadConfig();
220
+ }
package/lib/cli.mjs ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `dsh-sbie-run --manage ...` — scripted box administration.
3
+ *
4
+ * The same boxes are visible in SandMan (it reads the same `Sandboxie.ini`),
5
+ * so this is a convenience for scripted inspection and cleanup, not the only
6
+ * route: a human can browse, recover, or delete a box from the GUI.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { boxNameCandidates, canonicalWorkspace, listManagedBoxes, managedBoxesFor, removeBox, rootFor } from "./boxes.mjs";
11
+ import { boxExists, reloadConfig, sbieDir, sbieIni } from "./sbie.mjs";
12
+
13
+ function print(line) {
14
+ process.stdout.write(`${line}\n`);
15
+ }
16
+
17
+ /**
18
+ * Sandboxie deliberately does not expose the box list to a process that is
19
+ * itself inside a box (`SbieApi_EnumBoxes` comes back empty), so management
20
+ * commands silently look like "no boxes exist" when run from a confined shell.
21
+ * Say so instead.
22
+ */
23
+ function warnIfConfined() {
24
+ if (process.env.DSH_SANDBOX_ROOT === undefined) return;
25
+ print("note: DSH_SANDBOX_ROOT is set, so this ran INSIDE a sandbox box.");
26
+ print(" Sandboxie hides the box list from boxed processes; run --manage from");
27
+ print(" a normal console, or from a session whose policy is danger-full-access.");
28
+ }
29
+
30
+ function humanBytes(bytes) {
31
+ if (!Number.isFinite(bytes)) return "-";
32
+ const units = ["B", "KiB", "MiB", "GiB"];
33
+ let value = bytes;
34
+ let unit = 0;
35
+ while (value >= 1024 && unit < units.length - 1) {
36
+ value /= 1024;
37
+ unit += 1;
38
+ }
39
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
40
+ }
41
+
42
+ function directorySize(dir) {
43
+ let total = 0;
44
+ const queue = [dir];
45
+ while (queue.length > 0) {
46
+ const current = queue.pop();
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(current, { withFileTypes: true });
50
+ } catch {
51
+ continue;
52
+ }
53
+ for (const entry of entries) {
54
+ const full = `${current}\\${entry.name}`;
55
+ try {
56
+ if (entry.isDirectory()) queue.push(full);
57
+ else total += fs.statSync(full).size;
58
+ } catch {}
59
+ }
60
+ }
61
+ return total;
62
+ }
63
+
64
+ function list() {
65
+ if (sbieDir() === undefined) {
66
+ print("Sandboxie-Plus is not installed.");
67
+ return 1;
68
+ }
69
+ const boxes = listManagedBoxes();
70
+ if (boxes.length === 0) {
71
+ print("No managed boxes (Dsh_* / Dshr_*) exist yet.");
72
+ return 0;
73
+ }
74
+ // Derived from the boxes themselves, not from the default layout: Sandboxie
75
+ // lets the user relocate the sandbox folder, and a box may itself override it.
76
+ const roots = [...new Set(boxes.map((entry) => path.dirname(entry.root)))];
77
+ print(`${roots.length === 1 ? "sandbox root" : "sandbox roots"}: ${roots.join(", ")}`);
78
+ print(["BOX", "MODE", "COPY", "SIZE", "WORKSPACE"].join("\t"));
79
+ for (const entry of boxes) {
80
+ print([entry.box, entry.mode, entry.exists ? "yes" : "no", entry.exists ? humanBytes(directorySize(entry.root)) : "-", entry.workspace || "?"].join("\t"));
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ function clean(workspace) {
86
+ if (workspace === undefined) {
87
+ print("usage: dsh-sbie-run --manage clean <workspace>");
88
+ return 2;
89
+ }
90
+ const root = canonicalWorkspace(workspace);
91
+ const targets = managedBoxesFor(root);
92
+ if (targets.length === 0) {
93
+ print(`no managed box is bound to ${root}`);
94
+ print(`(candidate names would be: ${[...boxNameCandidates(root, "workspace-write", 2), ...boxNameCandidates(root, "read-only", 2)].join(", ")})`);
95
+ return 1;
96
+ }
97
+ for (const entry of targets) {
98
+ removeBox(entry.box, { logger: print });
99
+ print(`${entry.box}: removed (${entry.mode})`);
100
+ }
101
+ return 0;
102
+ }
103
+
104
+ function deleteBox(box) {
105
+ if (box === undefined) {
106
+ print("usage: dsh-sbie-run --manage delete-box <box>");
107
+ return 2;
108
+ }
109
+ if (!boxExists(box)) {
110
+ print(`${box}: not present`);
111
+ return 1;
112
+ }
113
+ removeBox(box, { logger: print });
114
+ print(`${box}: removed`);
115
+ return 0;
116
+ }
117
+
118
+ function showBox(box) {
119
+ if (box === undefined) {
120
+ print("usage: dsh-sbie-run --manage show <box>");
121
+ return 2;
122
+ }
123
+ if (!boxExists(box)) {
124
+ print(`${box}: not present`);
125
+ return 1;
126
+ }
127
+ const root = rootFor(box);
128
+ print(`box: ${box}`);
129
+ print(`root: ${root}${fs.existsSync(root) ? "" : " (no copy yet)"}`);
130
+ for (const key of ["Enabled", "OpenFilePath", "ReadFilePath", "AutoRecover", "UseFileDeleteV2"]) {
131
+ const value = sbieIni(["query", box, key]);
132
+ print(`${key}=${value.ok ? value.stdout || "(empty)" : "(unset)"}`);
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ function reload() {
138
+ reloadConfig();
139
+ print("Sandboxie configuration reloaded.");
140
+ return 0;
141
+ }
142
+
143
+ /** @param {string[]} argv - arguments after `--manage`. */
144
+ export function manage(argv) {
145
+ const [subcommand, argument] = argv;
146
+ warnIfConfined();
147
+ switch (subcommand) {
148
+ case undefined:
149
+ case "list":
150
+ return list();
151
+ case "clean":
152
+ return clean(argument);
153
+ case "delete-box":
154
+ return deleteBox(argument);
155
+ case "show":
156
+ return showBox(argument);
157
+ case "reload":
158
+ return reload();
159
+ default:
160
+ print(`unknown --manage subcommand: ${subcommand}`);
161
+ print("usage: dsh-sbie-run --manage list|show <box>|clean <workspace>|delete-box <box>|reload");
162
+ return 2;
163
+ }
164
+ }
package/lib/client.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Browser half of dsh-sandboxie-redirect.
3
+ *
4
+ * 1. A chip in the conversation header naming the Sandboxie box this workspace
5
+ * is bound to.
6
+ * 2. A glyph for the `copy-on-write` row of the permission dropdown.
7
+ *
8
+ * Loading contract (copied from the shipped client halves, not guessed): a
9
+ * client half is NOT an ES module. It is evaluated as
10
+ * `window.__ModuleLoader__.load({ id, factory: (require) => … })` and receives a
11
+ * CJS-style `require` for the shell's shared modules. `id` must equal the
12
+ * package name, and the shell discovers this file through the package.json
13
+ * `dsh.client` declaration plus `exports["./client"]`.
14
+ *
15
+ * ABOUT SURFACE 2 — read this before changing it.
16
+ * The dropdown takes its icons from a CLOSED design-set map (`permissionGlyphs`
17
+ * in the permission-presets client half — a plain literal, with no registration
18
+ * point), so a fourth preset gets none, and the row carries no id: its label is
19
+ * the only handle. Three earlier revisions found that row by its SHAPE and each
20
+ * held in one selection state and not the other, because the shipped menu
21
+ * appends a selection check to the selected row. So this revision keys nothing
22
+ * on the shape: it flags the LABEL SPAN (structurally identical selected or
23
+ * not) and draws a real 16px SVG at metrics MEASURED from the product's own
24
+ * icons, so the glyph lands in the icon column like its neighbours.
25
+ *
26
+ * The ladder is deliberately fail-safe, per row, in this order:
27
+ * 1. the preset's configured name carries a glyph (`❐ 写时复制`), so an icon
28
+ * exists even if every line below fails;
29
+ * 2. an attribute on the label span makes the stylesheet draw the real SVG;
30
+ * 3. only once `getComputedStyle` CONFIRMS that pseudo-element exists is the
31
+ * text glyph stripped from the label, so a failure at step 2 leaves the
32
+ * configured glyph in place instead of removing it.
33
+ * Nothing is ever inserted into React's tree — adopt that as a rule here: the
34
+ * shipped menu renders a positional child array, so an extra foreign child can
35
+ * make React patch the wrong slot. Attribute + generated stylesheet only.
36
+ */
37
+ window.__ModuleLoader__.load({
38
+ // The module-loader id must equal the package name exactly, scope included:
39
+ // the shell keys its client-plugin registry by this string.
40
+ id: "@lone-wolf-akela/dsh-sandboxie-redirect",
41
+ factory: (require) => {
42
+ var module = { exports: {} };
43
+ var exports = module.exports;
44
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
45
+
46
+ const React = require("react");
47
+
48
+ /**
49
+ * The header row that already holds 「创造模式」 (agent-preset, order -10)
50
+ * and 「n 个后台任务」 (job-list, order 20). Sitting after both puts this
51
+ * chip beside them instead of in the far-right icon group.
52
+ */
53
+ const HEADER_SLOT = "conversation.session.header.actions";
54
+ const HEADER_CELL = "sandbox-box";
55
+ const HEADER_ORDER = 30;
56
+
57
+ /** Machine value of the preset that binds a conversation to a box. */
58
+ const PRESET_ID = "copy-on-write";
59
+
60
+ // ---------------------------------------------------------------------
61
+ // Surface 1: the workspace's sandbox name in the conversation header
62
+ // ---------------------------------------------------------------------
63
+
64
+ const chipStyle = {
65
+ // Match the neighbouring chips: the shell's secondary label colour at the
66
+ // same 13px/500 the header utilities use.
67
+ color: "var(--dsw-alias-label-secondary)",
68
+ fontSize: "13px",
69
+ fontWeight: 500,
70
+ lineHeight: "20px",
71
+ display: "inline-flex",
72
+ alignItems: "center",
73
+ gap: "5px",
74
+ minWidth: 0,
75
+ maxWidth: "220px",
76
+ overflow: "hidden",
77
+ whiteSpace: "nowrap",
78
+ userSelect: "text"
79
+ };
80
+ const dotStyle = {
81
+ flex: "none",
82
+ width: "6px",
83
+ height: "6px",
84
+ borderRadius: "50%",
85
+ background: "var(--dsw-alias-state-business-primary, var(--dsw-alias-label-tertiary))"
86
+ };
87
+ const textStyle = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0 };
88
+
89
+ /**
90
+ * One header chip: the box name, with the copy root in the tooltip.
91
+ *
92
+ * It appears only when BOTH facts hold, which together mean "this
93
+ * conversation is redirecting into this box right now":
94
+ *
95
+ * - the session's permission preset is copy-on-write (`permissions`), and
96
+ * - the host reports a box this workspace actually OWNS (`sandboxBox`).
97
+ *
98
+ * The host deliberately reports nothing until the box exists, because a
99
+ * collision can only be resolved by looking at Sandboxie's own state. So
100
+ * the chip shows up right after the first copy-on-write command — not on
101
+ * merely selecting the preset — and disappears again when the preset leaves
102
+ * copy-on-write or the box is deleted, which is what makes it readable as
103
+ * "a sandbox is in play, and it is this one".
104
+ */
105
+ function SandboxBoxChip(props) {
106
+ const permissions = props.useProjection("permissions");
107
+ const sandbox = props.useProjection("sandboxBox");
108
+ const preset = permissions === undefined || permissions === null ? undefined : permissions.currentValue;
109
+ if (preset !== PRESET_ID) return null;
110
+ const box = sandbox === undefined || sandbox === null ? undefined : sandbox.box;
111
+ if (box === undefined || box === null || box === "") return null;
112
+ const root = sandbox.root === undefined || sandbox.root === null ? box : sandbox.root;
113
+ return React.createElement(
114
+ "span",
115
+ {
116
+ style: chipStyle,
117
+ title: `Sandboxie box: ${box}\nWorkspace writes pass through; everything else lands in this box's copy.\nCopy root: ${root}`,
118
+ "aria-label": `Sandboxie box ${box}`
119
+ },
120
+ React.createElement("span", { style: dotStyle }),
121
+ React.createElement("span", { style: textStyle }, box)
122
+ );
123
+ }
124
+
125
+ // ---------------------------------------------------------------------
126
+ // Surface 2: the glyph the shipped design set cannot supply
127
+ // ---------------------------------------------------------------------
128
+
129
+ /** Marks the label span; the stylesheet below is keyed on this attribute. */
130
+ const LABEL_FLAG = "data-dsh-sbie-cow";
131
+ /** Substring of the preset's configured display name, glyph present or not. */
132
+ const PRESET_TEXT = "写时复制";
133
+ /** The leading glyph in the configured name, which the SVG replaces once live. */
134
+ const NAME_GLYPH = /^\s*\u2750\s+/u;
135
+ /**
136
+ * The two surfaces that render this preset's name, addressed by ARIA rather
137
+ * than by hashed CSS-module class names:
138
+ * - the dropdown list (`role="menu"`),
139
+ * - the composer trigger, whose `aria-label` is built from the preset name.
140
+ * Keeping the query scoped to these two is also what keeps the per-mutation
141
+ * cost bounded — this observer runs while the transcript streams.
142
+ */
143
+ const SURFACE_SELECTOR = `[role="menu"], button[aria-label*="${PRESET_TEXT}"]`;
144
+
145
+ const COPY_GLYPH =
146
+ "data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='5.6' y='5.6' width='8.4' height='8.4' rx='2'/%3E%3Cpath d='M10.4 3.6A2 2 0 0 0 8.4 1.9H3.9a2 2 0 0 0-2 2v4.5a2 2 0 0 0 1.7 1.97'/%3E%3C/svg%3E";
147
+
148
+ const STYLE_TAG_ID = "dsh-sandboxie-redirect/permission-icon.css";
149
+ let injectedMetrics = "";
150
+
151
+ /**
152
+ * Measure the product's own icons rather than guessing the metrics. A 16px
153
+ * glyph with a hand-picked margin is exactly why the text-character version
154
+ * looked off, so take the size from the product's SVG and the spacing from
155
+ * its slot and row.
156
+ */
157
+ function measureIcon() {
158
+ if (typeof document === "undefined" || document.body === null) return { size: 16, gap: 8 };
159
+ const probe = document.querySelector('[role="menuitem"] > span > svg');
160
+ if (probe === null) return { size: 16, gap: 8 };
161
+ const svg = getComputedStyle(probe);
162
+ const slot = probe.parentElement === null ? null : getComputedStyle(probe.parentElement);
163
+ const row = probe.closest('[role="menuitem"]');
164
+ const size = Math.round(Number.parseFloat(svg.width) || 16);
165
+ const gap =
166
+ Math.round(
167
+ (Number.parseFloat(slot === null ? "" : slot.marginRight) || 0) +
168
+ (Number.parseFloat(row === null ? "" : getComputedStyle(row).columnGap) || 0)
169
+ ) || 8;
170
+ return { size, gap };
171
+ }
172
+
173
+ function installStyles() {
174
+ if (typeof document === "undefined" || document.head === null) return;
175
+ if (typeof getComputedStyle !== "function") return;
176
+ const { size, gap } = measureIcon();
177
+ const metrics = `${size}/${gap}`;
178
+ const existing = document.querySelector(`style[data-plugin-css="${STYLE_TAG_ID}"]`);
179
+ if (existing !== null && injectedMetrics === metrics) return;
180
+ const css = [
181
+ `span[${LABEL_FLAG}]::before {`,
182
+ ' content: "";',
183
+ " display: inline-block;",
184
+ " vertical-align: -2px;",
185
+ ` width: ${size}px;`,
186
+ ` height: ${size}px;`,
187
+ ` margin-right: ${gap}px;`,
188
+ " background-color: currentColor;",
189
+ ` -webkit-mask-image: url("${COPY_GLYPH}");`,
190
+ ` mask-image: url("${COPY_GLYPH}");`,
191
+ " -webkit-mask-repeat: no-repeat;",
192
+ " mask-repeat: no-repeat;",
193
+ " -webkit-mask-position: center;",
194
+ " mask-position: center;",
195
+ " -webkit-mask-size: contain;",
196
+ " mask-size: contain;",
197
+ "}"
198
+ ].join("\n");
199
+ const tag = existing ?? document.createElement("style");
200
+ tag.dataset.plugin = "dsh-sandboxie-redirect";
201
+ tag.dataset.pluginCss = STYLE_TAG_ID;
202
+ tag.textContent = css;
203
+ if (existing === null) document.head.appendChild(tag);
204
+ injectedMetrics = metrics;
205
+ }
206
+
207
+ /**
208
+ * Flag the label span wherever this preset's name is rendered — the dropdown
209
+ * row and the composer trigger — then drop the configured text glyph, but
210
+ * only once the SVG is PROVEN to render, so this can never leave a surface
211
+ * with no icon at all.
212
+ *
213
+ * Keyed on the label TEXT inside an ARIA-addressed surface, never on a row's
214
+ * shape: the shipped menu appends a selection check to the selected row,
215
+ * which is what made shape-based matching behave differently in the two
216
+ * states.
217
+ */
218
+ function decoratePresetGlyphs(root) {
219
+ // SWEEP FIRST: a span still flagged whose text is no longer this preset
220
+ // must lose the flag. React reuses the label span across a preset change
221
+ // and never removes an attribute it did not set, so without this the
222
+ // generated glyph survives the switch — and the control then draws the
223
+ // product's icon AND ours, side by side. (Observed exactly that after
224
+ // selecting copy-on-write and then switching to another mode.)
225
+ for (const stale of root.querySelectorAll(`[${LABEL_FLAG}]`)) {
226
+ if (!(stale.textContent ?? "").includes(PRESET_TEXT)) stale.removeAttribute(LABEL_FLAG);
227
+ }
228
+ for (const surface of root.querySelectorAll(SURFACE_SELECTOR)) {
229
+ for (const label of surface.querySelectorAll("span")) {
230
+ if (!(label.textContent ?? "").includes(PRESET_TEXT)) continue;
231
+ // Only the innermost span: a wrapper that merely contains the label
232
+ // must not be flagged, or the glyph would be drawn twice.
233
+ if (label.querySelector("span") !== null) continue;
234
+ // A glyph the product drew sits BEFORE the label. Testing the
235
+ // preceding siblings (not all children) matters on the trigger, whose
236
+ // trailing chevron is also a span holding an SVG: counting it would
237
+ // wrongly conclude that the product already drew this preset's icon.
238
+ let leading = false;
239
+ for (let node = label.previousElementSibling; node !== null; node = node.previousElementSibling) {
240
+ if (node.querySelector("svg") !== null) {
241
+ leading = true;
242
+ break;
243
+ }
244
+ }
245
+ if (leading) continue;
246
+ if (!label.hasAttribute(LABEL_FLAG)) label.setAttribute(LABEL_FLAG, "1");
247
+ if (getComputedStyle(label, "::before").content === "none") continue;
248
+ const first = label.firstChild;
249
+ if (first === null || first.nodeType !== 3) continue;
250
+ const stripped = first.nodeValue.replace(NAME_GLYPH, "");
251
+ // React restores the configured text on re-render, so this re-runs and
252
+ // strips again; the two never fight, because React only ever writes the
253
+ // value it was given.
254
+ if (stripped !== first.nodeValue) first.nodeValue = stripped;
255
+ }
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------
260
+
261
+ const inject = ["slots"];
262
+
263
+ function apply(ctx) {
264
+ const slots = ctx.get("slots");
265
+ if (slots !== undefined) {
266
+ // `inject` waits for the slot's declaration lifetime, so this half may
267
+ // load before the conversation UI declares its header row.
268
+ ctx.slots.inject(HEADER_SLOT, () =>
269
+ ctx.slots.register({ name: HEADER_SLOT, id: HEADER_CELL, order: HEADER_ORDER }, SandboxBoxChip)
270
+ );
271
+ }
272
+
273
+ // The glyph shim is cosmetic and touches foreign DOM, so it must never be
274
+ // able to take the header chip (registered above) down with it.
275
+ try {
276
+ if (typeof MutationObserver !== "undefined" && typeof document !== "undefined" && document.body !== null) {
277
+ installStyles();
278
+ decoratePresetGlyphs(document);
279
+ const observer = new MutationObserver(() => {
280
+ try {
281
+ installStyles();
282
+ decoratePresetGlyphs(document);
283
+ } catch {
284
+ /* never let a DOM surprise reach the shell */
285
+ }
286
+ });
287
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
288
+ ctx.effect(() => () => observer.disconnect(), "dsh-sandboxie-redirect: preset glyph observer");
289
+ }
290
+ } catch {
291
+ /* the chip stays; only the optional glyph is lost */
292
+ }
293
+ }
294
+
295
+ exports.apply = apply;
296
+ exports.inject = inject;
297
+ return module.exports;
298
+ }
299
+ });