dsh-plugin-wsl-env 0.7.5 → 0.9.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Auto-preset: bind an agent to the WSL preset when its workspace directory
3
+ * lives inside a distro.
4
+ *
5
+ * Why an event hook and not configuration: the environment is chosen in
6
+ * `dsh-api-session-controller`'s `composeAgent(presetId)`, whose signature has no
7
+ * working directory — the cwd only exists one frame up in
8
+ * `ensureSession(sessionId, cwd, ...)`. So the path-aware decision has to be made
9
+ * where the path IS available. `agent/created` carries it:
10
+ *
11
+ * ctx.serial(carrier, "agent/created", { agent, source, signal })
12
+ *
13
+ * and `agent.session.header.cwd` is the session's workspace directory.
14
+ *
15
+ * Two details are load-bearing, both established by experiment:
16
+ *
17
+ * 1. **Respect an explicit choice.** By the time the event fires, the resolved
18
+ * preset is already mounted (`composeAgent` mounts request-or-default in
19
+ * `setup`). We cannot tell which of the two it was — so we only ever rewrite
20
+ * when the mounted preset equals the registry default, which means the client
21
+ * omitted it. An operator's pick is never overridden.
22
+ *
23
+ * 2. **Switch after creation, not during.** Switching inside the listener
24
+ * collides with the previous generation's tools:
25
+ * Error: tool "subagent" is already registered in this scope
26
+ * Deferring past the creating call fixes it — the session is still blank
27
+ * (no turn has started), so `select()` remains legal. A short retry loop
28
+ * absorbs the remaining ordering slack instead of relying on one timer.
29
+ *
30
+ * This is a TypeScript source built to `lib/auto-preset.js`; edit THIS file and
31
+ * run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
32
+ * fails when it drifts.
33
+ *
34
+ * @module dsh-plugin-wsl/auto-preset
35
+ */
36
+ import z from "@deepseek-ai/schemastery";
37
+ import type { Context } from "@deepseek-ai/cordis";
38
+ export declare const name = "auto-preset";
39
+ export declare const inject: string[];
40
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
41
+ /** Preset to bind for a WSL workspace. */
42
+ preset: z<string, string, "defined">;
43
+ /** Attempts before giving up; each waits `retryMs`. */
44
+ attempts: z<number, number, "defined">;
45
+ /** Gap between attempts, in milliseconds. */
46
+ retryMs: z<number, number, "defined">;
47
+ }>>, Schemastery.ObjectT<NoInfer<{
48
+ /** Preset to bind for a WSL workspace. */
49
+ preset: z<string, string, "defined">;
50
+ /** Attempts before giving up; each waits `retryMs`. */
51
+ attempts: z<number, number, "defined">;
52
+ /** Gap between attempts, in milliseconds. */
53
+ retryMs: z<number, number, "defined">;
54
+ }>>, "plain">;
55
+ /** The config schema's validated output. */
56
+ export interface AutoPresetConfig {
57
+ /** Preset to bind for a WSL workspace. */
58
+ preset?: string;
59
+ /** Attempts before giving up; each waits `retryMs`. */
60
+ attempts?: number;
61
+ /** Gap between attempts, in milliseconds. */
62
+ retryMs?: number;
63
+ }
64
+ /**
65
+ * The slice of a created agent this plugin inspects; the harness's agent
66
+ * object is structural here.
67
+ */
68
+ export interface CreatedAgentView {
69
+ /** The agent's id. */
70
+ id: string;
71
+ /** The session stub, when mounted. */
72
+ session?: {
73
+ header?: {
74
+ cwd?: string | undefined;
75
+ };
76
+ } | undefined;
77
+ /** The agent's own context. */
78
+ ctx: Context;
79
+ }
80
+ /**
81
+ * The cordis context extended with the agent-service slices this plugin
82
+ * consumes. `agents` and `agentPresets` come from the harness's agent
83
+ * packages, whose declarations are not visible to this plugin, so they are
84
+ * spelled structurally on top of the framework's {@link Context}.
85
+ */
86
+ export type AutoPresetContext = Context & {
87
+ agents: {
88
+ list: () => CreatedAgentView[];
89
+ };
90
+ agentPresets: {
91
+ composedPreset: (ctx: Context) => string | undefined;
92
+ defaultId: string | undefined;
93
+ select: (agent: CreatedAgentView, presetId: string) => Promise<string>;
94
+ };
95
+ on: (event: "agent/created", listener: (payload: {
96
+ agent?: CreatedAgentView;
97
+ } | undefined) => void) => () => boolean;
98
+ sessionController?: {
99
+ agents?: {
100
+ ensureSession: (this: unknown, sessionId: string, cwd: string | undefined, checkPersistedIdentity: boolean, presetId: string | undefined) => Promise<unknown>;
101
+ };
102
+ };
103
+ };
104
+ /**
105
+ * Mount the auto-preset plugin.
106
+ *
107
+ * @param ctx - the cordis context.
108
+ * @param config - the schema-validated plugin config.
109
+ */
110
+ export declare function apply(ctx: AutoPresetContext, config?: AutoPresetConfig): Promise<void>;
@@ -27,14 +27,16 @@
27
27
  * (no turn has started), so `select()` remains legal. A short retry loop
28
28
  * absorbs the remaining ordering slack instead of relying on one timer.
29
29
  *
30
+ * This is a TypeScript source built to `lib/auto-preset.js`; edit THIS file and
31
+ * run `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
32
+ * fails when it drifts.
33
+ *
30
34
  * @module dsh-plugin-wsl/auto-preset
31
35
  */
32
-
33
36
  import { appendFileSync } from "node:fs";
34
37
  import z from "@deepseek-ai/schemastery";
35
38
  import { isWslUnc } from "./paths.js";
36
39
  import { shouldAdoptWslPreset } from "./preset-choice.js";
37
-
38
40
  /**
39
41
  * Set `DSH_WSL_TRACE` to a file path to record every decision this plugin makes.
40
42
  *
@@ -43,156 +45,170 @@ import { shouldAdoptWslPreset } from "./preset-choice.js";
43
45
  * bug in this plugin stayed invisible once already.
44
46
  */
45
47
  const TRACE = process.env.DSH_WSL_TRACE;
48
+ /**
49
+ * Append one decision line to the trace file, when tracing is on.
50
+ *
51
+ * @param message - the decision line to append.
52
+ */
46
53
  function trace(message) {
47
- if (TRACE === void 0 || TRACE === "") return;
48
- try {
49
- appendFileSync(TRACE, `${new Date().toISOString()} ${message}\n`);
50
- } catch {
51
- /* a trace is never worth failing a session over */
52
- }
54
+ if (TRACE === void 0 || TRACE === "")
55
+ return;
56
+ try {
57
+ appendFileSync(TRACE, `${new Date().toISOString()} ${message}\n`);
58
+ }
59
+ catch {
60
+ /* a trace is never worth failing a session over */
61
+ }
53
62
  }
54
-
55
63
  export const name = "auto-preset";
56
-
57
64
  export const inject = ["agents", "agentPresets"];
58
-
59
65
  export const Config = z.object({
60
- /** Preset to bind for a WSL workspace. */
61
- preset: z.string().default("wsl"),
62
- /** Attempts before giving up; each waits `retryMs`. */
63
- attempts: z.natural().min(1).default(6),
64
- /** Gap between attempts, in milliseconds. */
65
- retryMs: z.natural().default(150),
66
+ /** Preset to bind for a WSL workspace. */
67
+ preset: z.string().default("wsl"),
68
+ /** Attempts before giving up; each waits `retryMs`. */
69
+ attempts: z.natural().min(1).default(6),
70
+ /** Gap between attempts, in milliseconds. */
71
+ retryMs: z.natural().default(150),
66
72
  });
67
-
73
+ /**
74
+ * Mount the auto-preset plugin.
75
+ *
76
+ * @param ctx - the cordis context.
77
+ * @param config - the schema-validated plugin config.
78
+ */
68
79
  export async function apply(ctx, config = {}) {
69
- const target = config.preset ?? "wsl";
70
- const attempts = config.attempts ?? 6;
71
- const retryMs = config.retryMs ?? 150;
72
- const warn = (message) => {
73
- trace(`WARN ${message}`);
74
- // `logger` is not injectable here, so an unavailable or throwing logger must
75
- // not turn this into a silent no-op: fall back to stderr.
76
- let logged = false;
77
- try {
78
- ctx.logger?.warn?.(`auto-preset: ${message}`);
79
- logged = ctx.logger !== void 0;
80
- } catch {
81
- logged = false;
82
- }
83
- if (!logged) {
84
- try {
85
- process.stderr.write(`auto-preset: ${message}\n`);
86
- } catch {
87
- /* logging is never worth failing a session over */
88
- }
89
- }
90
- };
91
-
92
- const consider = (agent) => {
93
- if (agent === void 0) return;
94
-
95
- let cwd;
96
- let current;
97
- try {
98
- cwd = agent.session?.header?.cwd;
99
- current = ctx.agentPresets.composedPreset(agent.ctx);
100
- } catch (error) {
101
- warn(`cannot inspect the new agent: ${error?.message ?? error}`);
102
- return;
103
- }
104
- trace(`consider agent=${agent.id} cwd=${JSON.stringify(cwd)} current=${String(current)}`);
105
-
106
- // Only a workspace inside a distro, and only when the preset is still the
107
- // registry default (i.e. the operator did not ask for one). `undefined` means
108
- // the creating caller mounted nothing at all — the headless runner does this
109
- // — which is likewise "no explicit choice".
110
- let fallback;
111
- try {
112
- fallback = ctx.agentPresets.defaultId;
113
- } catch {
114
- fallback = void 0;
115
- }
116
- if (!shouldAdoptWslPreset({ cwd, current, fallback })) return;
117
- trace(` -> acting: fallback=${String(fallback)} isWsl=${isWslUnc(cwd)}`);
118
-
119
- // Deferred on purpose — see the module comment.
120
- void (async () => {
121
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
80
+ const target = config.preset ?? "wsl";
81
+ const attempts = config.attempts ?? 6;
82
+ const retryMs = config.retryMs ?? 150;
83
+ const warn = (message) => {
84
+ trace(`WARN ${message}`);
85
+ // `logger` is not injectable here, so an unavailable or throwing logger must
86
+ // not turn this into a silent no-op: fall back to stderr.
87
+ let logged = false;
122
88
  try {
123
- const chosen = await ctx.agentPresets.select(agent, target);
124
- ctx.logger?.debug?.(`auto-preset: "${cwd}" -> preset "${chosen}"`);
125
- return;
126
- } catch (error) {
127
- const code = error?.code;
128
- // The session already started a turn: its preset is fixed, and that is
129
- // a legitimate outcome (the operator drove it before we got here).
130
- if (code === "agent-preset/locked") {
131
- warn(`"${cwd}" already began its first turn; leaving the preset as "${String(current)}"`);
132
- return;
133
- }
134
- if (code === "agent-preset/not-found") {
135
- warn(`preset "${target}" is not composed in this profile; skipping "${cwd}"`);
89
+ ctx.logger?.warn?.(`auto-preset: ${message}`);
90
+ logged = ctx.logger !== void 0;
91
+ }
92
+ catch {
93
+ logged = false;
94
+ }
95
+ if (!logged) {
96
+ try {
97
+ process.stderr.write(`auto-preset: ${message}\n`);
98
+ }
99
+ catch {
100
+ /* logging is never worth failing a session over */
101
+ }
102
+ }
103
+ };
104
+ const consider = (agent) => {
105
+ if (agent === void 0)
136
106
  return;
137
- }
138
- if (attempt === attempts) {
139
- warn(`could not bind preset "${target}" for "${cwd}": ${code ?? error?.name}: ${error?.message}`);
107
+ let cwd;
108
+ let current;
109
+ try {
110
+ cwd = agent.session?.header?.cwd;
111
+ current = ctx.agentPresets.composedPreset(agent.ctx);
112
+ }
113
+ catch (error) {
114
+ warn(`cannot inspect the new agent: ${error?.message ?? error}`);
140
115
  return;
141
- }
142
- await new Promise((resolve) => setTimeout(resolve, retryMs));
143
116
  }
144
- }
145
- })();
146
- };
147
-
148
- ctx.on("agent/created", (payload) => consider(payload?.agent));
149
-
150
- // ── Make the INITIAL preset correct, instead of correcting it afterwards ────
151
- //
152
- // A WSL workspace mounted on the HOST preset puts the Windows ACL sandbox in
153
- // front of a `\\wsl.localhost` path, which cannot carry security descriptors:
154
- // the shell tool then dies in sandbox preparation (`GetNamedSecurityInfoW
155
- // failed (Win32 1)`) and the workspace is unusable — not merely mis-labelled.
156
- // Correcting the preset after creation loses whenever the first turn does not
157
- // wait, so the choice is made where the cwd still exists: the controller
158
- // resolves the preset in `composeAgent(presetId)`, which has no cwd, but it
159
- // calls `agents.ensureSession(sessionId, cwd, …, presetId)` — and that frame
160
- // holds both. Filling the preset there makes the very first mount already
161
- // correct, so the host sandbox never meets the distro path at all.
162
- ctx.inject(["sessionController"], (controllerCtx) => {
163
- const facade = controllerCtx.sessionController?.agents;
164
- if (facade === void 0 || typeof facade.ensureSession !== "function") {
165
- warn("sessionController exposes no agents.ensureSession(); the creation-time preset fix is inactive");
166
- return;
167
- }
168
- const original = facade.ensureSession;
169
- facade.ensureSession = async function (sessionId, cwd, checkPersistedIdentity, presetId) {
170
- // An explicit choice always wins, and only a distro workspace is affected.
171
- if (presetId !== void 0 || !isWslUnc(cwd)) {
172
- return original.call(this, sessionId, cwd, checkPersistedIdentity, presetId);
173
- }
174
- try {
175
- return await original.call(this, sessionId, cwd, checkPersistedIdentity, target);
176
- } catch (error) {
177
- // A session already stored under another preset rejects a different one.
178
- // Fall back rather than turn adoption into a new failure mode.
179
- if (!String(error?.message ?? "").includes("agent preset")) throw error;
180
- trace(`creation-time preset "${target}" refused for ${sessionId}; falling back to the recorded one`);
181
- return original.call(this, sessionId, cwd, checkPersistedIdentity, presetId);
182
- }
117
+ trace(`consider agent=${agent.id} cwd=${JSON.stringify(cwd)} current=${String(current)}`);
118
+ // Only a workspace inside a distro, and only when the preset is still the
119
+ // registry default (i.e. the operator did not ask for one). `undefined` means
120
+ // the creating caller mounted nothing at all — the headless runner does this
121
+ // — which is likewise "no explicit choice".
122
+ let fallback;
123
+ try {
124
+ fallback = ctx.agentPresets.defaultId;
125
+ }
126
+ catch {
127
+ fallback = void 0;
128
+ }
129
+ if (!shouldAdoptWslPreset({ cwd, current, fallback }))
130
+ return;
131
+ trace(` -> acting: fallback=${String(fallback)} isWsl=${isWslUnc(cwd)}`);
132
+ // Deferred on purpose — see the module comment.
133
+ void (async () => {
134
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
135
+ try {
136
+ const chosen = await ctx.agentPresets.select(agent, target);
137
+ ctx.logger?.debug?.(`auto-preset: "${cwd}" -> preset "${chosen}"`);
138
+ return;
139
+ }
140
+ catch (error) {
141
+ const code = error?.code;
142
+ // The session already started a turn: its preset is fixed, and that is
143
+ // a legitimate outcome (the operator drove it before we got here).
144
+ if (code === "agent-preset/locked") {
145
+ warn(`"${cwd}" already began its first turn; leaving the preset as "${String(current)}"`);
146
+ return;
147
+ }
148
+ if (code === "agent-preset/not-found") {
149
+ warn(`preset "${target}" is not composed in this profile; skipping "${cwd}"`);
150
+ return;
151
+ }
152
+ if (attempt === attempts) {
153
+ warn(`could not bind preset "${target}" for "${cwd}": ${code ?? error?.name}: ${error?.message}`);
154
+ return;
155
+ }
156
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
157
+ }
158
+ }
159
+ })();
183
160
  };
184
- trace("creation-time preset fix installed");
185
- controllerCtx.effect(() => () => {
186
- facade.ensureSession = original;
161
+ ctx.on("agent/created", (payload) => consider(payload?.agent));
162
+ // ── Make the INITIAL preset correct, instead of correcting it afterwards ────
163
+ //
164
+ // A WSL workspace mounted on the HOST preset puts the Windows ACL sandbox in
165
+ // front of a `\\wsl.localhost` path, which cannot carry security descriptors:
166
+ // the shell tool then dies in sandbox preparation (`GetNamedSecurityInfoW
167
+ // failed (Win32 1)`) and the workspace is unusable — not merely mis-labelled.
168
+ // Correcting the preset after creation loses whenever the first turn does not
169
+ // wait, so the choice is made where the cwd still exists: the controller
170
+ // resolves the preset in `composeAgent(presetId)`, which has no cwd, but it
171
+ // calls `agents.ensureSession(sessionId, cwd, …, presetId)` — and that frame
172
+ // holds both. Filling the preset there makes the very first mount already
173
+ // correct, so the host sandbox never meets the distro path at all.
174
+ ctx.inject(["sessionController"], (controllerCtx) => {
175
+ const facade = controllerCtx.sessionController?.agents;
176
+ if (facade === void 0 || typeof facade.ensureSession !== "function") {
177
+ warn("sessionController exposes no agents.ensureSession(); the creation-time preset fix is inactive");
178
+ return;
179
+ }
180
+ const original = facade.ensureSession;
181
+ facade.ensureSession = async function (sessionId, cwd, checkPersistedIdentity, presetId) {
182
+ // An explicit choice always wins, and only a distro workspace is affected.
183
+ if (presetId !== void 0 || cwd === undefined || !isWslUnc(cwd)) {
184
+ return original.call(this, sessionId, cwd, checkPersistedIdentity, presetId);
185
+ }
186
+ try {
187
+ return await original.call(this, sessionId, cwd, checkPersistedIdentity, target);
188
+ }
189
+ catch (error) {
190
+ // A session already stored under another preset rejects a different one.
191
+ // Fall back rather than turn adoption into a new failure mode.
192
+ if (!String(error?.message ?? "").includes("agent preset"))
193
+ throw error;
194
+ trace(`creation-time preset "${target}" refused for ${sessionId}; falling back to the recorded one`);
195
+ return original.call(this, sessionId, cwd, checkPersistedIdentity, presetId);
196
+ }
197
+ };
198
+ trace("creation-time preset fix installed");
199
+ controllerCtx.effect(() => () => {
200
+ facade.ensureSession = original;
201
+ });
187
202
  });
188
- });
189
- // Mount order is not a guarantee: a runner that creates its session during
190
- // startup can finish before this plugin mounts and miss the event entirely
191
- // (dsh-headless does exactly that). Sweep the agents already live so the
192
- // decision never depends on who mounted first.
193
- try {
194
- for (const agent of ctx.agents.list()) consider(agent);
195
- } catch (error) {
196
- warn(`could not sweep already-live agents: ${error?.message ?? error}`);
197
- }
203
+ // Mount order is not a guarantee: a runner that creates its session during
204
+ // startup can finish before this plugin mounts and miss the event entirely
205
+ // (dsh-headless does exactly that). Sweep the agents already live so the
206
+ // decision never depends on who mounted first.
207
+ try {
208
+ for (const agent of ctx.agents.list())
209
+ consider(agent);
210
+ }
211
+ catch (error) {
212
+ warn(`could not sweep already-live agents: ${error?.message ?? error}`);
213
+ }
198
214
  }
package/lib/bwrap.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The distro-side `bwrap` command line, shared by every confinement site.
3
+ *
4
+ * Two callers compose a confined command and they must agree on it exactly:
5
+ * `lib/sandbox.js` wraps a *single* command (`confine()`, and the usability
6
+ * probe), and `lib/agent-confined.js` wraps a *resident* whose whole lifetime
7
+ * runs inside the profile — it hands the prefix to the transport, which inserts
8
+ * it between `wsl.exe --exec` and the `sh <script>` pair.
9
+ *
10
+ * That split is why this module exists. The two sites used to assemble the argv
11
+ * themselves, and only one of them prepended the program: the confined resident's
12
+ * prefix began at `--ro-bind`, so `wsl.exe --exec` tried to run a `--ro-bind`
13
+ * executable, the agent exited 1 during its handshake, and every confined
14
+ * mutation failed with `FS_IO_ERROR: … agent exited during handshake`. The
15
+ * verified probe could not see it because it inlined its own copy of the argv.
16
+ *
17
+ * This module imports nothing, so a bare checkout can assert the argv both sites
18
+ * actually produce.
19
+ *
20
+ * This is a TypeScript source built to `lib/bwrap.js`; edit THIS file and run
21
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
22
+ * fails when it drifts.
23
+ *
24
+ * @module dsh-plugin-wsl/bwrap
25
+ */
26
+ /** Profile options shared by the two confinement sites. */
27
+ export interface BwrapProfileOptions {
28
+ /** Shadow `/mnt` with an empty tmpfs. */
29
+ maskWindowsDrive?: boolean;
30
+ /**
31
+ * Workspace-write binds the real `/tmp` read-write instead of mounting an
32
+ * ephemeral tmpfs over it.
33
+ */
34
+ realTmp?: boolean;
35
+ }
36
+ /**
37
+ * Build the bwrap profile arguments for one file-effect policy, exactly as
38
+ * upstream's `bwrapProfileArgs` does.
39
+ *
40
+ * `--ro-bind / /` makes the whole distro read-only, `--dev /dev` supplies the
41
+ * `/dev/null` sink a shell needs, `--unshare-pid` keeps the sandbox's process
42
+ * view its own, and `--proc /proc` gives it a matching `/proc`. Under
43
+ * `workspace-write` the workspace is bound read-write and `/tmp` becomes an
44
+ * ephemeral tmpfs — the temp area the mode promises, without exposing the
45
+ * distro's real `/tmp`.
46
+ *
47
+ * `options.maskWindowsDrive` mounts an empty tmpfs over `/mnt` AFTER the
48
+ * read-only root, shadowing the Windows drive: the drive's files disappear
49
+ * from the command's view, which severs interop as a side effect — a Windows
50
+ * executable on the drive can no longer be reached to launch, and the drive's
51
+ * data cannot be read or exfiltrated. It is a narrowing, not a closure: a
52
+ * command may still WRITE an executable into the workspace and run it
53
+ * (binfmt interop dispatches on file content, not location), so `enforcement`
54
+ * stays `partial` — see `lib/sandbox.js`.
55
+ *
56
+ * `options.realTmp` changes what workspace-write does at `/tmp`. The default
57
+ * (`--tmpfs /tmp`) gives the sandbox an ephemeral, empty temp area — the right
58
+ * shape for a COMMAND: fresh per run, cannot touch the distro's real `/tmp`,
59
+ * cannot be poisoned by a previous run. `realTmp: true` binds the REAL `/tmp`
60
+ * read-write instead — the right shape for the FILE-WRITES resident: the
61
+ * fence grants `/tmp` as a writable root, so a write the fence approved must
62
+ * land where every reader (the plain agent, the user's own shell) reads — not
63
+ * inside a private tmpfs that reads cannot see and that dies with the
64
+ * resident. Read-only mode mounts no `/tmp` at all, so the option is inert
65
+ * there.
66
+ *
67
+ * @param policy - the policy to express as bwrap mounts.
68
+ * @param options - profile options.
69
+ * @returns profile arguments, before the `--` separator and the command argv.
70
+ */
71
+ export declare function bwrapProfileArgs(policy: {
72
+ mode: string;
73
+ workspaceRoot: string;
74
+ }, options?: BwrapProfileOptions): string[];
75
+ /**
76
+ * The argv prefix that runs one command (or one resident) inside the profile.
77
+ *
78
+ * The prefix is a complete command from the distro's point of view: it names the
79
+ * program (`bwrap`), the profile, and the `--` separator that ends bwrap's own
80
+ * options. `workspaceRoot` must already be spelled as a Linux path inside the
81
+ * distro; the callers own that translation.
82
+ *
83
+ * @param policy - the resolved policy: `mode`, plus the Linux `workspaceRoot`.
84
+ * @param options - profile options, passed through to {@link bwrapProfileArgs}.
85
+ * @returns argv to insert before the command, as the transport receives it.
86
+ */
87
+ export declare function bwrapArgvPrefix(policy: {
88
+ mode: string;
89
+ workspaceRoot: string;
90
+ }, options?: BwrapProfileOptions): string[];
package/lib/bwrap.js CHANGED
@@ -17,9 +17,12 @@
17
17
  * This module imports nothing, so a bare checkout can assert the argv both sites
18
18
  * actually produce.
19
19
  *
20
+ * This is a TypeScript source built to `lib/bwrap.js`; edit THIS file and run
21
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
22
+ * fails when it drifts.
23
+ *
20
24
  * @module dsh-plugin-wsl/bwrap
21
25
  */
22
-
23
26
  /**
24
27
  * Build the bwrap profile arguments for one file-effect policy, exactly as
25
28
  * upstream's `bwrapProfileArgs` does.
@@ -53,27 +56,24 @@
53
56
  *
54
57
  * @param policy - the policy to express as bwrap mounts.
55
58
  * @param options - profile options.
56
- * @param options.maskWindowsDrive - shadow `/mnt` with an empty tmpfs.
57
- * @param options.realTmp - workspace-write binds the real `/tmp` read-write
58
- * instead of mounting an ephemeral tmpfs over it.
59
59
  * @returns profile arguments, before the `--` separator and the command argv.
60
60
  */
61
61
  export function bwrapProfileArgs(policy, options = {}) {
62
- const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
63
- if (policy.mode === "workspace-write") {
64
- if (options.realTmp) {
65
- args.push("--bind", "/tmp", "/tmp");
66
- } else {
67
- args.push("--tmpfs", "/tmp");
62
+ const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-pid", "--proc", "/proc", "--die-with-parent"];
63
+ if (policy.mode === "workspace-write") {
64
+ if (options.realTmp) {
65
+ args.push("--bind", "/tmp", "/tmp");
66
+ }
67
+ else {
68
+ args.push("--tmpfs", "/tmp");
69
+ }
70
+ args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
71
+ }
72
+ if (options.maskWindowsDrive) {
73
+ args.push("--tmpfs", "/mnt");
68
74
  }
69
- args.push("--bind", policy.workspaceRoot, policy.workspaceRoot);
70
- }
71
- if (options.maskWindowsDrive) {
72
- args.push("--tmpfs", "/mnt");
73
- }
74
- return args;
75
+ return args;
75
76
  }
76
-
77
77
  /**
78
78
  * The argv prefix that runs one command (or one resident) inside the profile.
79
79
  *
@@ -87,5 +87,5 @@ export function bwrapProfileArgs(policy, options = {}) {
87
87
  * @returns argv to insert before the command, as the transport receives it.
88
88
  */
89
89
  export function bwrapArgvPrefix(policy, options = {}) {
90
- return ["bwrap", ...bwrapProfileArgs(policy, options), "--"];
90
+ return ["bwrap", ...bwrapProfileArgs(policy, options), "--"];
91
91
  }