windows-development-sandbox 1.13.2 → 1.14.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/dist/app/features/storage/storage.run.js +1 -1
- package/dist/bin/agent-sandbox.js +2 -2
- package/dist/bin/{chunk-VENWSGX2.js → chunk-BQ23GBLC.js} +411 -58
- package/dist/bin/{chunk-K4SU3MHJ.js → chunk-F7IMME5G.js} +1 -1
- package/dist/bin/{chunk-IL7BXJF2.js → chunk-LQHEUN2A.js} +1 -1
- package/dist/bin/claude-host.js +2 -2
- package/dist/bin/claude-sbx.js +2 -2
- package/dist/bin/codex-sbx.js +2 -2
- package/dist/bin/gemini-sbx.js +2 -2
- package/dist/bin/sandbox.js +2 -2
- package/dist/bin/sbx-hide.js +2 -2
- package/dist/bin/sbx-show.js +2 -2
- package/dist/cli/commands/launch/launch.js +1 -1
- package/dist/cli/commands/repair/checks/contained.js +1 -1
- package/dist/guest/daemon/sessions.d.ts +7 -0
- package/dist/guest/daemon/sessions.js +23 -2
- package/dist/guest/entries/daemon.js +1 -0
- package/dist/guest/sessions/pty-host.d.ts +10 -0
- package/dist/guest/sessions/pty-host.js +48 -3
- package/dist/host/channel/channel.js +1 -1
- package/dist/host/install/install-record.d.ts +1 -0
- package/dist/host/install/install-record.js +2 -0
- package/dist/host/sandbox/contained-deps.d.ts +2 -1
- package/dist/host/sandbox/contained-deps.js +6 -4
- package/dist/host/sharing/lifecycle.js +9 -1
- package/dist/host/storage/contained/contained-storage.js +13 -0
- package/dist/host/storage/contained/disk-port.d.ts +5 -0
- package/dist/host/storage/contained/helper-port.d.ts +72 -0
- package/dist/host/storage/contained/helper-port.js +162 -0
- package/dist/host/storage/contained/index.d.ts +1 -0
- package/dist/host/storage/contained/index.js +1 -0
- package/dist/host/web/bridge.d.ts +5 -0
- package/dist/host/web/bridge.js +11 -1
- package/dist/host/web/server.d.ts +2 -0
- package/dist/host/web/server.js +11 -1
- package/dist/runtime/agent.js +9 -0
- package/dist/runtime/daemon.js +57 -3
- package/dist/runtime/db.js +9 -0
- package/dist/runtime/main.js +195 -12
- package/dist/runtime/netproxy.js +9 -0
- package/dist/runtime/view.js +195 -12
- package/dist/selftest/channel.test.js +72 -1
- package/dist/selftest/contained.test.js +1 -37
- package/dist/selftest/hcs.test.js +5 -2
- package/dist/selftest/index.js +2 -0
- package/dist/selftest/install.test.js +11 -0
- package/dist/selftest/live-daemon.test.js +51 -1
- package/dist/selftest/live-terminal.test.js +112 -4
- package/dist/selftest/storage-helper.test.d.ts +2 -0
- package/dist/selftest/storage-helper.test.js +493 -0
- package/dist/selftest/support/vhdx.d.ts +14 -0
- package/dist/selftest/support/vhdx.js +72 -0
- package/dist/selftest/web.test.js +14 -2
- package/dist/shared/channel/run-dir.d.ts +6 -0
- package/dist/shared/channel/run-dir.js +9 -0
- package/dist/shared/pty/index.d.ts +3 -1
- package/dist/shared/pty/index.js +3 -1
- package/dist/shared/pty/key-gate.d.ts +24 -0
- package/dist/shared/pty/key-gate.js +45 -0
- package/dist/shared/pty/mouse-modes.d.ts +27 -0
- package/dist/shared/pty/mouse-modes.js +155 -0
- package/dist/shared/pty/terminal.d.ts +0 -5
- package/dist/shared/pty/terminal.js +51 -20
- package/dist/sync/watcher.js +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/{main-NBQ3LF4E.js → main-6XNBTJLT.js} +33 -33
- package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
- package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
- package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
- package/native/csharp/StorageHelper/Client.cs +297 -0
- package/native/csharp/StorageHelper/Log.cs +177 -0
- package/native/csharp/StorageHelper/MountRecords.cs +160 -0
- package/native/csharp/StorageHelper/Operations.cs +596 -0
- package/native/csharp/StorageHelper/Pins.cs +270 -0
- package/native/csharp/StorageHelper/Privileges.cs +78 -0
- package/native/csharp/StorageHelper/Program.cs +349 -0
- package/native/csharp/StorageHelper/Protocol.cs +107 -0
- package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
- package/native/csharp/StorageHelper/Rules.cs +156 -0
- package/native/csharp/StorageHelper/Server.cs +656 -0
- package/native/csharp/StorageHelper/Settings.cs +297 -0
- package/native/csharp/StorageHelper/StorageService.cs +53 -0
- package/native/csharp/StorageHelper/UserSettings.cs +206 -0
- package/native/csharp/StorageHelper/Vhdx.cs +230 -0
- package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
- package/native/csharp/StorageHelper/Volumes.cs +379 -0
- package/native/csharp/StorageHelper/Watchdog.cs +128 -0
- package/native/csharp/StorageHelper/Win32.cs +250 -0
- package/native/scripts/set-storage-helper.ps1 +203 -0
- package/package.json +1 -1
- package/sandbox/csharp/PtyHost.cs +236 -1
package/dist/bin/claude-host.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/claude-host.ts
|
|
9
9
|
var args = process.argv.slice(2);
|
package/dist/bin/claude-sbx.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/claude-sbx.ts
|
|
9
9
|
run(process.argv.slice(2), { harness: "claude" });
|
package/dist/bin/codex-sbx.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/codex-sbx.ts
|
|
9
9
|
run(process.argv.slice(2), { harness: "codex" });
|
package/dist/bin/gemini-sbx.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/gemini-sbx.ts
|
|
9
9
|
run(process.argv.slice(2), { harness: "gemini" });
|
package/dist/bin/sandbox.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/sandbox.ts
|
|
9
9
|
run(process.argv.slice(2));
|
package/dist/bin/sbx-hide.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/sbx-hide.ts
|
|
9
9
|
run(process.argv.slice(2), { command: "hide" });
|
package/dist/bin/sbx-show.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
3
3
|
import {
|
|
4
4
|
run
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-
|
|
5
|
+
} from "./chunk-F7IMME5G.js";
|
|
6
|
+
import "./chunk-BQ23GBLC.js";
|
|
7
7
|
|
|
8
8
|
// src/bin/sbx-show.ts
|
|
9
9
|
run(process.argv.slice(2), { command: "show" });
|
|
@@ -130,7 +130,7 @@ class Launcher {
|
|
|
130
130
|
const engine = await openEngine(config);
|
|
131
131
|
const provider = providerFor(config, config.storage.contained === 'off'
|
|
132
132
|
? {}
|
|
133
|
-
: { contained: realContainedDeps(ctx.storageRoot, config.storage.containedMaxGb) });
|
|
133
|
+
: { contained: realContainedDeps(ctx.storageRoot, config.storage.containedMaxGb, Boolean(ctx.options.config)) });
|
|
134
134
|
if (!generateOnly)
|
|
135
135
|
assertCanLaunch(isSandboxRunning() ? runningOwner() : undefined);
|
|
136
136
|
// Contained storage: the image is mounted now (before anything reads or writes the data set), and let go of again
|
|
@@ -10,7 +10,7 @@ import { containedAvailability, ContainedStorage, containedPaths } from '../../.
|
|
|
10
10
|
import { ok } from './common.js';
|
|
11
11
|
function storageOf(env) {
|
|
12
12
|
const { config } = env.ctx.loaded;
|
|
13
|
-
return new ContainedStorage(realContainedDeps(env.storageRoot, config.storage.containedMaxGb), config.storage.dataName);
|
|
13
|
+
return new ContainedStorage(realContainedDeps(env.storageRoot, config.storage.containedMaxGb, Boolean(env.ctx.options.config)), config.storage.dataName);
|
|
14
14
|
}
|
|
15
15
|
/** Only when contained storage is on, or an image or a record of one exists. */
|
|
16
16
|
const inUse = (env) => {
|
|
@@ -22,6 +22,13 @@ export declare class SessionManager {
|
|
|
22
22
|
/** The sessions' ptyhosts, running or ended within ROOTS_KEPT_MS (audit events arrive after they happen). */
|
|
23
23
|
roots(): SessionRoot[];
|
|
24
24
|
private rootStarted;
|
|
25
|
+
/** What a session's ptyhost left running when it was itself ended (Task Manager, a crash) is ended now. */
|
|
26
|
+
private endLeftovers;
|
|
27
|
+
/**
|
|
28
|
+
* At the daemon's start: trees recorded by session hosts that are gone (a daemon that ended with them) are ended
|
|
29
|
+
* too. One whose session host still runs is left alone (ptyhost --end-tree checks).
|
|
30
|
+
*/
|
|
31
|
+
sweepLeftovers(): void;
|
|
25
32
|
private rootEnded;
|
|
26
33
|
/**
|
|
27
34
|
* `session` requests: an agent (or a plain shell) in its own ptyhost, in the launch folder's project or a registered
|
|
@@ -16,7 +16,7 @@ import { commandLine, shellLaunch } from '../sessions/cmdline.js';
|
|
|
16
16
|
import { pwshDir } from '../toolchains/tools/pwsh.js';
|
|
17
17
|
import { freePort } from '../runtime/ports.js';
|
|
18
18
|
import { primaryProject } from '../boot/project-status.js';
|
|
19
|
-
import { ensurePtyHost, ptyHostLogTail, startPtyHost, waitForPtyHost } from '../sessions/pty-host.js';
|
|
19
|
+
import { endLeftoverTree, ensurePtyHost, ptyHostLogTail, recordedTrees, startPtyHost, waitForPtyHost, } from '../sessions/pty-host.js';
|
|
20
20
|
import { agents } from '../agents/agent-installer.js';
|
|
21
21
|
import { saveClaudeMem } from '../setup/claude-mem.js';
|
|
22
22
|
import { saveLocalState } from '../agents/local-state.js';
|
|
@@ -224,7 +224,28 @@ export class SessionManager {
|
|
|
224
224
|
rootStarted(id, child, from) {
|
|
225
225
|
if (child.pid)
|
|
226
226
|
this.ptyHosts.set(id, { session: id, pid: child.pid, from });
|
|
227
|
-
child.once('exit', () =>
|
|
227
|
+
child.once('exit', () => {
|
|
228
|
+
this.rootEnded(id);
|
|
229
|
+
this.endLeftovers(id);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** What a session's ptyhost left running when it was itself ended (Task Manager, a crash) is ended now. */
|
|
233
|
+
endLeftovers(id) {
|
|
234
|
+
void endLeftoverTree(id)
|
|
235
|
+
.then((done) => {
|
|
236
|
+
if (done)
|
|
237
|
+
log('Session ' + id + ': its session host was ended from outside; ' + done + ' it left running');
|
|
238
|
+
})
|
|
239
|
+
.catch((e) => warn('Ending what session ' + id + ' left running failed: ' + errMsg(e)));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* At the daemon's start: trees recorded by session hosts that are gone (a daemon that ended with them) are ended
|
|
243
|
+
* too. One whose session host still runs is left alone (ptyhost --end-tree checks).
|
|
244
|
+
*/
|
|
245
|
+
sweepLeftovers() {
|
|
246
|
+
for (const id of recordedTrees())
|
|
247
|
+
if (!this.live.has(id))
|
|
248
|
+
this.endLeftovers(id);
|
|
228
249
|
}
|
|
229
250
|
rootEnded(id) {
|
|
230
251
|
const r = this.ptyHosts.get(id);
|
|
@@ -17,6 +17,7 @@ adoptBootSecrets();
|
|
|
17
17
|
log('Session daemon started (pid ' + process.pid + ').');
|
|
18
18
|
safely('port forwards', startRelays);
|
|
19
19
|
safely('audit', () => startProcessCollector(() => sessions.roots()));
|
|
20
|
+
safely('leftover processes', () => sessions.sweepLeftovers());
|
|
20
21
|
safely('memory governor', () => governor.start());
|
|
21
22
|
log('Memory governor: ' + governor.describe());
|
|
22
23
|
new Daemon().start();
|
|
@@ -20,3 +20,13 @@ export interface PtyHostOptions {
|
|
|
20
20
|
}
|
|
21
21
|
/** Starts ptyhost detached, so closing a terminal tab never kills the agent; only the agent exiting ends it. */
|
|
22
22
|
export declare function startPtyHost(o: PtyHostOptions): ChildProcess;
|
|
23
|
+
/**
|
|
24
|
+
* Ends what a session's ptyhost left running when it was itself ended (Task Manager, a crash): its program and what
|
|
25
|
+
* that started, from the tree it recorded in run/trees (PTYHOST_TREE_FILE). ptyhost deletes the record when it ends the
|
|
26
|
+
* tree itself, so a record left behind is the sign. `ptyhost --end-tree` checks every process by id and start time (a
|
|
27
|
+
* reused id is never ended), spares the shared ones (databases, sccache ...) and does nothing while the ptyhost that
|
|
28
|
+
* wrote it still runs. Resolves with what it did, or null when there was nothing to do.
|
|
29
|
+
*/
|
|
30
|
+
export declare function endLeftoverTree(id: string, exe?: string): Promise<string | null>;
|
|
31
|
+
/** Session ids with a process tree recorded (running sessions, and any whose ptyhost was ended). */
|
|
32
|
+
export declare function recordedTrees(): string[];
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Starts one ptyhost (the ConPTY bridge, sandbox/csharp/PtyHost.cs) per agent session and waits for it to listen.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { execFile, spawn } from 'node:child_process';
|
|
5
5
|
import { ensureCompiled } from '../runtime/csc.js';
|
|
6
6
|
import { portOpen } from '../runtime/ports.js';
|
|
7
|
-
import { logsDir, toolsDir } from '../runtime/paths.js';
|
|
7
|
+
import { logsDir, run, toolsDir } from '../runtime/paths.js';
|
|
8
8
|
import { session } from '../runtime/session.js';
|
|
9
9
|
export function ensurePtyHost() {
|
|
10
10
|
return ensureCompiled('ptyhost');
|
|
@@ -51,10 +51,55 @@ const conptyDll = () => {
|
|
|
51
51
|
};
|
|
52
52
|
/** Starts ptyhost detached, so closing a terminal tab never kills the agent; only the agent exiting ends it. */
|
|
53
53
|
export function startPtyHost(o) {
|
|
54
|
-
|
|
54
|
+
fs.mkdirSync(run.treesDir, { recursive: true });
|
|
55
|
+
const env = {
|
|
56
|
+
...o.env,
|
|
57
|
+
PTYHOST_BIND: o.remote ? 'any' : 'loopback',
|
|
58
|
+
PTYHOST_TREE_FILE: run.treeFile(o.id),
|
|
59
|
+
};
|
|
55
60
|
delete env.PTYHOST_CONPTY;
|
|
56
61
|
const dll = conptyDll();
|
|
57
62
|
if (dll)
|
|
58
63
|
env.PTYHOST_CONPTY = dll;
|
|
59
64
|
return spawn(o.exe, [String(o.port), o.token, String(o.cols), String(o.rows), ptyHostLog(o.id), o.command, o.restartCommand], { cwd: o.cwd, env, detached: true, stdio: 'ignore', windowsHide: true });
|
|
60
65
|
}
|
|
66
|
+
/** How long `ptyhost --end-tree` may take (a process snapshot and a few TerminateProcess calls). */
|
|
67
|
+
const END_TREE_MS = 30000;
|
|
68
|
+
/**
|
|
69
|
+
* Ends what a session's ptyhost left running when it was itself ended (Task Manager, a crash): its program and what
|
|
70
|
+
* that started, from the tree it recorded in run/trees (PTYHOST_TREE_FILE). ptyhost deletes the record when it ends the
|
|
71
|
+
* tree itself, so a record left behind is the sign. `ptyhost --end-tree` checks every process by id and start time (a
|
|
72
|
+
* reused id is never ended), spares the shared ones (databases, sccache ...) and does nothing while the ptyhost that
|
|
73
|
+
* wrote it still runs. Resolves with what it did, or null when there was nothing to do.
|
|
74
|
+
*/
|
|
75
|
+
export function endLeftoverTree(id, exe = ensurePtyHost()) {
|
|
76
|
+
const file = run.treeFile(id);
|
|
77
|
+
if (!fs.existsSync(file))
|
|
78
|
+
return Promise.resolve(null);
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
execFile(exe, ['--end-tree', file], { timeout: END_TREE_MS, windowsHide: true }, (error, stdout) => {
|
|
81
|
+
const code = error ? (typeof error.code === 'number' ? error.code : -1) : 0;
|
|
82
|
+
// 3: the ptyhost that wrote it still runs (its session goes on): the record stays.
|
|
83
|
+
if (code !== 3)
|
|
84
|
+
try {
|
|
85
|
+
fs.rmSync(file, { force: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* removed by the next sweep (or the host's reset at the next launch) */
|
|
89
|
+
}
|
|
90
|
+
resolve(code === 0 ? stdout.trim().slice(0, 200) : code === 3 ? null : 'could not end them (code ' + code + ')');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Session ids with a process tree recorded (running sessions, and any whose ptyhost was ended). */
|
|
95
|
+
export function recordedTrees() {
|
|
96
|
+
try {
|
|
97
|
+
return fs
|
|
98
|
+
.readdirSync(run.treesDir)
|
|
99
|
+
.filter((n) => /^[A-Za-z0-9_-]{1,64}\.json$/.test(n))
|
|
100
|
+
.map((n) => n.slice(0, -'.json'.length));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -231,7 +231,7 @@ export class SandboxChannel {
|
|
|
231
231
|
removeLink(p.root);
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
|
-
for (const target of [p.sandboxFile, p.requestsDir, p.responsesDir, p.sessionsDir, p.projectsDir]) {
|
|
234
|
+
for (const target of [p.sandboxFile, p.requestsDir, p.responsesDir, p.sessionsDir, p.projectsDir, p.treesDir]) {
|
|
235
235
|
safeRemove(target, this.dataPath);
|
|
236
236
|
}
|
|
237
237
|
}
|
|
@@ -6,6 +6,7 @@ export declare const InstallRecordSchema: z.ZodObject<{
|
|
|
6
6
|
}>;
|
|
7
7
|
groupSid: z.ZodNullable<z.ZodString>;
|
|
8
8
|
autoUpdate: z.ZodBoolean;
|
|
9
|
+
storageHelper: z.ZodOptional<z.ZodBoolean>;
|
|
9
10
|
installedAt: z.ZodString;
|
|
10
11
|
}, z.core.$strip>;
|
|
11
12
|
export type InstallRecord = z.infer<typeof InstallRecordSchema>;
|
|
@@ -7,6 +7,8 @@ export const InstallRecordSchema = z.object({
|
|
|
7
7
|
scope: z.enum(['user', 'machine']),
|
|
8
8
|
groupSid: z.string().nullable(),
|
|
9
9
|
autoUpdate: z.boolean(),
|
|
10
|
+
/** All-users installs: the storage helper service (AgentSandboxStorage) was installed. Older records have none. */
|
|
11
|
+
storageHelper: z.boolean().optional(),
|
|
10
12
|
installedAt: z.string(),
|
|
11
13
|
});
|
|
12
14
|
export class InstallRecordFile {
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { type ContainedDeps } from '../storage/index.js';
|
|
2
|
-
|
|
2
|
+
/** `configOverridden`: the command runs with --config (the helper reads only your usual settings: it is not asked). */
|
|
3
|
+
export declare function realContainedDeps(storageRoot: string, maxGb: number, configOverridden?: boolean): ContainedDeps;
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
// The real wiring of contained storage on this PC: the
|
|
1
|
+
// The real wiring of contained storage on this PC: the storage helper when an all-users install added it, else the
|
|
2
|
+
// permission-prompt scripts, protectPrivateFolder for new
|
|
2
3
|
// folders, your SID, and whether a sandbox runs. Lives here (not in host/storage) so storage never imports the
|
|
3
4
|
// sandbox or sharing folders, which import storage. The self-test never reaches a disk through it: the disk port
|
|
4
5
|
// refuses there.
|
|
5
|
-
import { ScriptDiskPort } from '../storage/index.js';
|
|
6
|
+
import { HelperDiskPort, helperApplies, ScriptDiskPort } from '../storage/index.js';
|
|
6
7
|
import { currentUserSid, protectPrivateFolder } from '../sharing/index.js';
|
|
7
8
|
import { isSandboxRunning } from './platform.js';
|
|
8
|
-
|
|
9
|
+
/** `configOverridden`: the command runs with --config (the helper reads only your usual settings: it is not asked). */
|
|
10
|
+
export function realContainedDeps(storageRoot, maxGb, configOverridden = false) {
|
|
9
11
|
return {
|
|
10
12
|
storageRoot,
|
|
11
13
|
maxGb,
|
|
12
14
|
protect: (dir) => protectPrivateFolder(dir),
|
|
13
15
|
userSid: currentUserSid,
|
|
14
|
-
port: new ScriptDiskPort(),
|
|
16
|
+
port: new HelperDiskPort(new ScriptDiskPort(), helperApplies(process.env, configOverridden)),
|
|
15
17
|
sandboxRunning: isSandboxRunning,
|
|
16
18
|
};
|
|
17
19
|
}
|
|
@@ -43,7 +43,15 @@ export function beginSharing(deps, start, now = new Date()) {
|
|
|
43
43
|
// The owner creates the run folder and every channel folder in it before others may write there: a folder another
|
|
44
44
|
// user created would stay theirs, and writable by them, after sharing is turned off.
|
|
45
45
|
const run = new RunDir(start.dataPath);
|
|
46
|
-
for (const dir of [
|
|
46
|
+
for (const dir of [
|
|
47
|
+
run.root,
|
|
48
|
+
run.requestsDir,
|
|
49
|
+
run.responsesDir,
|
|
50
|
+
run.sessionsDir,
|
|
51
|
+
run.projectsDir,
|
|
52
|
+
run.auditDir,
|
|
53
|
+
run.treesDir,
|
|
54
|
+
])
|
|
47
55
|
fs.mkdirSync(dir, { recursive: true });
|
|
48
56
|
const plan = sharingAclPlan({
|
|
49
57
|
storageUserDir: storageOf(start.dataPath),
|
|
@@ -76,6 +76,19 @@ export class ContainedStorage {
|
|
|
76
76
|
});
|
|
77
77
|
this.requireOk(outcome, 'mount');
|
|
78
78
|
}
|
|
79
|
+
else {
|
|
80
|
+
// Another holder vouches for the mount, but the folder could be a mount point of some other disk (one left after
|
|
81
|
+
// a restart, whose volume name an image of someone else's now has). A port that can check it without a prompt
|
|
82
|
+
// (the storage helper) is asked: it only says "mounted already" for a mount of this very image.
|
|
83
|
+
const confirmed = await this.deps.port.confirmMounted?.({
|
|
84
|
+
paths,
|
|
85
|
+
maxGb: this.deps.maxGb,
|
|
86
|
+
create: false,
|
|
87
|
+
userSid: this.deps.userSid(),
|
|
88
|
+
});
|
|
89
|
+
if (confirmed)
|
|
90
|
+
this.requireOk(confirmed, 'mount');
|
|
91
|
+
}
|
|
79
92
|
this.assertReallyMounted();
|
|
80
93
|
for (const name of CONTAINED_FOLDERS)
|
|
81
94
|
fs.mkdirSync(path.join(paths.mount, name), { recursive: true });
|
|
@@ -22,6 +22,11 @@ export interface DismountRequest {
|
|
|
22
22
|
}
|
|
23
23
|
export interface DiskPort {
|
|
24
24
|
mount(request: MountRequest): Promise<ScriptOutcome>;
|
|
25
|
+
/**
|
|
26
|
+
* A mount that should be there already, checked without a permission prompt: the outcome of a mount (which, for a
|
|
27
|
+
* mount of this image, changes nothing), or null when this port cannot check without a prompt.
|
|
28
|
+
*/
|
|
29
|
+
confirmMounted?(request: MountRequest): Promise<ScriptOutcome | null>;
|
|
25
30
|
dismount(request: DismountRequest): Promise<ScriptOutcome>;
|
|
26
31
|
reality(paths: ContainedPaths): Promise<MountReality>;
|
|
27
32
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContainedPaths } from './layout.js';
|
|
3
|
+
import type { DiskPort, DismountRequest, MountRequest, ScriptOutcome } from './disk-port.js';
|
|
4
|
+
import type { MountReality } from './state.js';
|
|
5
|
+
export declare const HELPER_PIPE = "\\\\.\\pipe\\AgentSandboxStorage";
|
|
6
|
+
export declare const HELPER_PROTOCOL = 1;
|
|
7
|
+
/** What goes to the service: exactly the verb and the data set (the service refuses anything else). */
|
|
8
|
+
export declare const HelperRequestSchema: z.ZodObject<{
|
|
9
|
+
verb: z.ZodEnum<{
|
|
10
|
+
mount: "mount";
|
|
11
|
+
dismount: "dismount";
|
|
12
|
+
}>;
|
|
13
|
+
set: z.ZodString;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
export type HelperRequest = z.infer<typeof HelperRequestSchema>;
|
|
16
|
+
/** The service's answer (Protocol.Answer in Protocol.cs). */
|
|
17
|
+
export declare const HelperAnswerSchema: z.ZodObject<{
|
|
18
|
+
protocol: z.ZodNumber;
|
|
19
|
+
ok: z.ZodBoolean;
|
|
20
|
+
message: z.ZodString;
|
|
21
|
+
created: z.ZodBoolean;
|
|
22
|
+
alreadyMounted: z.ZodBoolean;
|
|
23
|
+
fallback: z.ZodBoolean;
|
|
24
|
+
image: z.ZodString;
|
|
25
|
+
mount: z.ZodString;
|
|
26
|
+
notes: z.ZodArray<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type HelperAnswer = z.infer<typeof HelperAnswerSchema>;
|
|
29
|
+
/** One call: the text of the answer, or why there was none. `absent`: no helper here (or not for you). */
|
|
30
|
+
export type PipeResult = {
|
|
31
|
+
readonly kind: 'answer';
|
|
32
|
+
readonly text: string;
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: 'absent';
|
|
35
|
+
readonly reason: string;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'failed';
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
};
|
|
40
|
+
export type PipeConnector = (pipe: string, line: string, timeoutMs: number) => Promise<PipeResult>;
|
|
41
|
+
export declare const pipeConnector: PipeConnector;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the helper may be asked at all. It reads your settings from the usual place (your profile's
|
|
44
|
+
* .agent-sandbox\config.json and the machine file), so a run with other settings (--config, AGENT_SANDBOX_CONFIG,
|
|
45
|
+
* AGENT_SANDBOX_HOME) would get other folders: those use the permission prompt.
|
|
46
|
+
*/
|
|
47
|
+
export declare function helperApplies(env: NodeJS.ProcessEnv, configOverridden: boolean): boolean;
|
|
48
|
+
/** The answer's paths are ours (Windows paths: case does not matter, a trailing backslash neither). */
|
|
49
|
+
export declare function samePaths(answer: Pick<HelperAnswer, 'image' | 'mount'>, paths: ContainedPaths): boolean;
|
|
50
|
+
/** What one answer means: an outcome, or "use the permission prompt instead". */
|
|
51
|
+
export declare function readAnswer(result: PipeResult, paths: ContainedPaths): {
|
|
52
|
+
readonly use: 'outcome';
|
|
53
|
+
readonly outcome: ScriptOutcome;
|
|
54
|
+
} | {
|
|
55
|
+
readonly use: 'fallback';
|
|
56
|
+
readonly why: string;
|
|
57
|
+
};
|
|
58
|
+
/** The helper when it is there and applies, else (and whenever it says so) the permission-prompt scripts. */
|
|
59
|
+
export declare class HelperDiskPort implements DiskPort {
|
|
60
|
+
private readonly fallback;
|
|
61
|
+
private readonly applies;
|
|
62
|
+
private readonly connect;
|
|
63
|
+
private readonly pipe;
|
|
64
|
+
constructor(fallback: DiskPort, applies: boolean, connect?: PipeConnector, pipe?: string);
|
|
65
|
+
mount(request: MountRequest): Promise<ScriptOutcome>;
|
|
66
|
+
/** The helper checks a mount without a prompt (it is "mounted already" only for this image); null without it. */
|
|
67
|
+
confirmMounted(request: MountRequest): Promise<ScriptOutcome | null>;
|
|
68
|
+
dismount(request: DismountRequest): Promise<ScriptOutcome>;
|
|
69
|
+
reality(paths: ContainedPaths): Promise<MountReality>;
|
|
70
|
+
/** The helper's outcome, or null to use the permission prompt. */
|
|
71
|
+
private ask;
|
|
72
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The storage helper (native\csharp\StorageHelper, the Windows service AgentSandboxStorage that an all-users install
|
|
2
|
+
// can add): mounting and dismounting go to its pipe instead of a Windows permission prompt. The request names only the
|
|
3
|
+
// data set; the service works out the folders itself from your settings, so the answer's paths must equal ours. When
|
|
4
|
+
// the helper is not installed, cannot be reached, or says to (an image it leaves alone), the permission-prompt scripts
|
|
5
|
+
// run as before. Looking (reality) never needs either.
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { SET_NAME_RULE } from './layout.js';
|
|
9
|
+
import { refuseInSelfTest } from '../../test-guard.js';
|
|
10
|
+
export const HELPER_PIPE = '\\\\.\\pipe\\AgentSandboxStorage';
|
|
11
|
+
export const HELPER_PROTOCOL = 1;
|
|
12
|
+
const MOUNT_TIMEOUT_MS = 10 * 60_000;
|
|
13
|
+
const MAX_ANSWER_BYTES = 64 * 1024;
|
|
14
|
+
/** What goes to the service: exactly the verb and the data set (the service refuses anything else). */
|
|
15
|
+
export const HelperRequestSchema = z
|
|
16
|
+
.object({ verb: z.enum(['mount', 'dismount']), set: z.string().regex(SET_NAME_RULE) })
|
|
17
|
+
.strict();
|
|
18
|
+
/** The service's answer (Protocol.Answer in Protocol.cs). */
|
|
19
|
+
export const HelperAnswerSchema = z.object({
|
|
20
|
+
protocol: z.number().int(),
|
|
21
|
+
ok: z.boolean(),
|
|
22
|
+
message: z.string().max(4000),
|
|
23
|
+
created: z.boolean(),
|
|
24
|
+
alreadyMounted: z.boolean(),
|
|
25
|
+
fallback: z.boolean(),
|
|
26
|
+
image: z.string().max(1000),
|
|
27
|
+
mount: z.string().max(1000),
|
|
28
|
+
notes: z.array(z.string().max(1000)).max(50),
|
|
29
|
+
});
|
|
30
|
+
/** Errors that mean "no helper to talk to": not installed (or stopped), or this user may not open it. */
|
|
31
|
+
const ABSENT_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'ECONNREFUSED']);
|
|
32
|
+
export const pipeConnector = (pipe, line, timeoutMs) => new Promise((resolve) => {
|
|
33
|
+
let text = '';
|
|
34
|
+
let connected = false;
|
|
35
|
+
let settled = false;
|
|
36
|
+
const done = (result) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
socket.destroy();
|
|
42
|
+
resolve(result);
|
|
43
|
+
};
|
|
44
|
+
const socket = net.connect(pipe);
|
|
45
|
+
const timer = setTimeout(() => done({ kind: 'failed', reason: 'the storage helper did not answer in time' }), timeoutMs);
|
|
46
|
+
socket.setEncoding('utf8');
|
|
47
|
+
socket.on('connect', () => {
|
|
48
|
+
connected = true;
|
|
49
|
+
socket.write(line + '\n');
|
|
50
|
+
});
|
|
51
|
+
socket.on('data', (chunk) => {
|
|
52
|
+
text += chunk;
|
|
53
|
+
const end = text.indexOf('\n');
|
|
54
|
+
if (end >= 0)
|
|
55
|
+
done({ kind: 'answer', text: text.slice(0, end) });
|
|
56
|
+
else if (text.length > MAX_ANSWER_BYTES)
|
|
57
|
+
done({ kind: 'failed', reason: 'the storage helper answered too much' });
|
|
58
|
+
});
|
|
59
|
+
socket.on('error', (e) => {
|
|
60
|
+
if (!connected && ABSENT_CODES.has(e.code ?? ''))
|
|
61
|
+
done({ kind: 'absent', reason: e.code ?? 'no pipe' });
|
|
62
|
+
else
|
|
63
|
+
done({ kind: 'failed', reason: e.message });
|
|
64
|
+
});
|
|
65
|
+
socket.on('close', () => done({ kind: 'failed', reason: 'the storage helper closed the connection' }));
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Whether the helper may be asked at all. It reads your settings from the usual place (your profile's
|
|
69
|
+
* .agent-sandbox\config.json and the machine file), so a run with other settings (--config, AGENT_SANDBOX_CONFIG,
|
|
70
|
+
* AGENT_SANDBOX_HOME) would get other folders: those use the permission prompt.
|
|
71
|
+
*/
|
|
72
|
+
export function helperApplies(env, configOverridden) {
|
|
73
|
+
if (configOverridden)
|
|
74
|
+
return false;
|
|
75
|
+
return !env.AGENT_SANDBOX_CONFIG && !env.AGENT_SANDBOX_HOME;
|
|
76
|
+
}
|
|
77
|
+
/** The answer's paths are ours (Windows paths: case does not matter, a trailing backslash neither). */
|
|
78
|
+
export function samePaths(answer, paths) {
|
|
79
|
+
const norm = (p) => p.replace(/\\+$/, '').toLowerCase();
|
|
80
|
+
return norm(answer.image) === norm(paths.image) && norm(answer.mount) === norm(paths.mount);
|
|
81
|
+
}
|
|
82
|
+
/** What one answer means: an outcome, or "use the permission prompt instead". */
|
|
83
|
+
export function readAnswer(result, paths) {
|
|
84
|
+
if (result.kind === 'absent')
|
|
85
|
+
return { use: 'fallback', why: 'no storage helper (' + result.reason + ')' };
|
|
86
|
+
if (result.kind === 'failed')
|
|
87
|
+
return { use: 'outcome', outcome: failed('The storage helper did not finish: ' + result.reason + '.') };
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = JSON.parse(result.text);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { use: 'fallback', why: 'the storage helper gave an answer that is not JSON' };
|
|
94
|
+
}
|
|
95
|
+
const parsed = HelperAnswerSchema.safeParse(raw);
|
|
96
|
+
if (!parsed.success || parsed.data.protocol !== HELPER_PROTOCOL)
|
|
97
|
+
return { use: 'fallback', why: 'the storage helper speaks another version' };
|
|
98
|
+
const answer = parsed.data;
|
|
99
|
+
if (answer.fallback)
|
|
100
|
+
return { use: 'fallback', why: answer.message };
|
|
101
|
+
if (answer.ok && !samePaths(answer, paths))
|
|
102
|
+
return {
|
|
103
|
+
use: 'outcome',
|
|
104
|
+
outcome: failed('The storage helper used other folders (' +
|
|
105
|
+
answer.mount +
|
|
106
|
+
') than this command (' +
|
|
107
|
+
paths.mount +
|
|
108
|
+
'): your settings differ from what it reads. Run sandbox doctor.'),
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
use: 'outcome',
|
|
112
|
+
outcome: {
|
|
113
|
+
ok: answer.ok,
|
|
114
|
+
message: answer.message,
|
|
115
|
+
created: answer.created,
|
|
116
|
+
alreadyMounted: answer.alreadyMounted,
|
|
117
|
+
notes: answer.notes,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function failed(message) {
|
|
122
|
+
return { ok: false, message, created: false, alreadyMounted: false, notes: [] };
|
|
123
|
+
}
|
|
124
|
+
/** The helper when it is there and applies, else (and whenever it says so) the permission-prompt scripts. */
|
|
125
|
+
export class HelperDiskPort {
|
|
126
|
+
fallback;
|
|
127
|
+
applies;
|
|
128
|
+
connect;
|
|
129
|
+
pipe;
|
|
130
|
+
constructor(fallback, applies, connect = pipeConnector, pipe = HELPER_PIPE) {
|
|
131
|
+
this.fallback = fallback;
|
|
132
|
+
this.applies = applies;
|
|
133
|
+
this.connect = connect;
|
|
134
|
+
this.pipe = pipe;
|
|
135
|
+
}
|
|
136
|
+
async mount(request) {
|
|
137
|
+
const answer = await this.ask({ verb: 'mount', set: request.paths.set }, request.paths);
|
|
138
|
+
return answer ?? this.fallback.mount(request);
|
|
139
|
+
}
|
|
140
|
+
/** The helper checks a mount without a prompt (it is "mounted already" only for this image); null without it. */
|
|
141
|
+
confirmMounted(request) {
|
|
142
|
+
return this.ask({ verb: 'mount', set: request.paths.set }, request.paths);
|
|
143
|
+
}
|
|
144
|
+
async dismount(request) {
|
|
145
|
+
const answer = await this.ask({ verb: 'dismount', set: request.paths.set }, request.paths);
|
|
146
|
+
return answer ?? this.fallback.dismount(request);
|
|
147
|
+
}
|
|
148
|
+
reality(paths) {
|
|
149
|
+
return this.fallback.reality(paths);
|
|
150
|
+
}
|
|
151
|
+
/** The helper's outcome, or null to use the permission prompt. */
|
|
152
|
+
async ask(request, paths) {
|
|
153
|
+
if (!this.applies)
|
|
154
|
+
return null;
|
|
155
|
+
// A test talks to a helper of its own (another pipe name); the real one could mount a real disk.
|
|
156
|
+
if (this.pipe === HELPER_PIPE)
|
|
157
|
+
refuseInSelfTest('mount or dismount a disk image');
|
|
158
|
+
const line = JSON.stringify(HelperRequestSchema.parse(request));
|
|
159
|
+
const read = readAnswer(await this.connect(this.pipe, line, MOUNT_TIMEOUT_MS), paths);
|
|
160
|
+
return read.use === 'outcome' ? read.outcome : null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -2,6 +2,7 @@ export { CONTAINED_FOLDERS, containedMappings, containedPaths, setNameOf, type C
|
|
|
2
2
|
export type { ContainedPaths } from './layout.js';
|
|
3
3
|
export { ContainedStorage, type ContainedDeps, type HolderId, type ReleaseResult } from './contained-storage.js';
|
|
4
4
|
export { ScriptDiskPort, spawnRunner, type DiskPort, type MountRequest, type ScriptOutcome } from './disk-port.js';
|
|
5
|
+
export { HELPER_PIPE, HelperAnswerSchema, HelperDiskPort, helperApplies, HelperRequestSchema, pipeConnector, readAnswer, samePaths, type PipeConnector, type PipeResult, } from './helper-port.js';
|
|
5
6
|
export { containedAvailability, containedFacts, type ContainedFacts } from './check.js';
|
|
6
7
|
export { hostDataFolder, hostDataRoot } from './resolve.js';
|
|
7
8
|
export { writeContainedSetting } from './setting.js';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { CONTAINED_FOLDERS, containedMappings, containedPaths, setNameOf } from './layout.js';
|
|
2
2
|
export { ContainedStorage } from './contained-storage.js';
|
|
3
3
|
export { ScriptDiskPort, spawnRunner } from './disk-port.js';
|
|
4
|
+
export { HELPER_PIPE, HelperAnswerSchema, HelperDiskPort, helperApplies, HelperRequestSchema, pipeConnector, readAnswer, samePaths, } from './helper-port.js';
|
|
4
5
|
export { containedAvailability, containedFacts } from './check.js';
|
|
5
6
|
export { hostDataFolder, hostDataRoot } from './resolve.js';
|
|
6
7
|
export { writeContainedSetting } from './setting.js';
|