windows-development-sandbox 1.1.0 → 1.4.1
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/.sandbox-config.example.json +2 -1
- package/README.md +3 -2
- package/config.example.json +39 -5
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.js +3 -1
- package/dist/cli/boot-progress.d.ts +22 -0
- package/dist/cli/boot-progress.js +59 -0
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/agent-commands.js +1 -1
- package/dist/cli/commands/context.js +2 -1
- package/dist/cli/commands/data/clean.js +6 -1
- package/dist/cli/commands/data/collect.js +3 -3
- package/dist/cli/commands/index.d.ts +1 -1
- package/dist/cli/commands/index.js +1 -1
- package/dist/cli/commands/launch.d.ts +5 -2
- package/dist/cli/commands/launch.js +113 -25
- package/dist/cli/commands/maintenance.d.ts +2 -0
- package/dist/cli/commands/maintenance.js +70 -4
- package/dist/cli/commands/project.d.ts +10 -6
- package/dist/cli/commands/project.js +42 -3
- package/dist/cli/commands/restart.js +17 -7
- package/dist/cli/commands/sessions.js +5 -4
- package/dist/cli/commands/user-config.js +1 -1
- package/dist/cli/index.js +6 -4
- package/dist/cli/manifest.js +6 -1
- package/dist/cli/menu/apps.d.ts +21 -0
- package/dist/cli/menu/apps.js +122 -0
- package/dist/cli/menu/command-form.d.ts +28 -0
- package/dist/cli/menu/command-form.js +219 -0
- package/dist/cli/menu/index.d.ts +26 -0
- package/dist/cli/menu/index.js +180 -0
- package/dist/cli/menu/settings/categories.d.ts +9 -0
- package/dist/cli/menu/settings/categories.js +28 -0
- package/dist/cli/menu/settings/config-file.d.ts +40 -0
- package/dist/cli/menu/settings/config-file.js +71 -0
- package/dist/cli/menu/settings/editors.d.ts +30 -0
- package/dist/cli/menu/settings/editors.js +305 -0
- package/dist/cli/menu/settings/index.d.ts +32 -0
- package/dist/cli/menu/settings/index.js +136 -0
- package/dist/cli/menu/settings/schema-node.d.ts +62 -0
- package/dist/cli/menu/settings/schema-node.js +104 -0
- package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
- package/dist/cli/menu/several.js +102 -0
- package/dist/cli/progress.d.ts +15 -0
- package/dist/cli/progress.js +52 -0
- package/dist/cli/tui/app.d.ts +39 -0
- package/dist/cli/tui/app.js +52 -0
- package/dist/cli/tui/dialogs.d.ts +57 -0
- package/dist/cli/tui/dialogs.js +162 -0
- package/dist/cli/tui/frame.d.ts +18 -0
- package/dist/cli/tui/frame.js +27 -0
- package/dist/cli/tui/index.d.ts +7 -0
- package/dist/cli/tui/index.js +8 -0
- package/dist/cli/tui/list-screen.d.ts +101 -0
- package/dist/cli/tui/list-screen.js +299 -0
- package/dist/cli/tui/terminal.d.ts +37 -0
- package/dist/cli/tui/terminal.js +89 -0
- package/dist/cli/tui/text.d.ts +20 -0
- package/dist/cli/tui/text.js +105 -0
- package/dist/cli/tui/theme.d.ts +45 -0
- package/dist/cli/tui/theme.js +30 -0
- package/dist/core/config.js +10 -7
- package/dist/core/local-config.d.ts +31 -1
- package/dist/core/local-config.js +72 -7
- package/dist/core/project-policy.d.ts +50 -3
- package/dist/core/project-policy.js +90 -4
- package/dist/core/schema/apps.d.ts +40 -0
- package/dist/core/schema/apps.js +27 -0
- package/dist/core/schema/config.d.ts +69 -3
- package/dist/core/schema/config.js +18 -6
- package/dist/core/schema/profile.d.ts +5 -0
- package/dist/core/schema/profile.js +4 -2
- package/dist/core/schema/projects.d.ts +1 -0
- package/dist/core/schema/projects.js +1 -0
- package/dist/core/schema/sandbox.d.ts +29 -1
- package/dist/core/schema/sandbox.js +29 -1
- package/dist/core/schema/toolchains.d.ts +3 -0
- package/dist/core/schema/toolchains.js +6 -1
- package/dist/core/types.d.ts +24 -1
- package/dist/core/util.d.ts +2 -0
- package/dist/core/util.js +2 -0
- package/dist/core/workspace.d.ts +21 -4
- package/dist/core/workspace.js +99 -21
- package/dist/guest/apps/index.d.ts +10 -0
- package/dist/guest/apps/index.js +206 -0
- package/dist/guest/apps/resolvers.d.ts +8 -0
- package/dist/guest/apps/resolvers.js +90 -0
- package/dist/guest/audit-parse.d.ts +7 -0
- package/dist/guest/audit-parse.js +24 -0
- package/dist/guest/audit.d.ts +4 -0
- package/dist/guest/audit.js +47 -0
- package/dist/guest/daemon/relays.d.ts +1 -0
- package/dist/guest/daemon/relays.js +18 -0
- package/dist/guest/daemon.js +4 -0
- package/dist/guest/env.d.ts +7 -2
- package/dist/guest/env.js +17 -3
- package/dist/guest/helpers.d.ts +1 -1
- package/dist/guest/main.js +20 -3
- package/dist/guest/netproxy.d.ts +1 -0
- package/dist/guest/netproxy.js +100 -0
- package/dist/guest/ptyhost.js +14 -4
- package/dist/guest/setup/claude.d.ts +0 -4
- package/dist/guest/setup/claude.js +48 -2
- package/dist/guest/setup/network.d.ts +9 -0
- package/dist/guest/setup/network.js +97 -4
- package/dist/guest/setup/user.js +4 -0
- package/dist/guest/toolchains/index.js +29 -3
- package/dist/guest/toolchains/native/mesa.js +13 -3
- package/dist/guest/toolchains/plan.d.ts +3 -1
- package/dist/guest/toolchains/plan.js +53 -9
- package/dist/guest/toolchains/python.js +26 -2
- package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
- package/dist/guest/toolchains/vscode-profile.js +69 -0
- package/dist/guest/toolchains/vscode.d.ts +17 -0
- package/dist/guest/toolchains/vscode.js +87 -1
- package/dist/host/attach.js +2 -1
- package/dist/host/audit/collect.d.ts +34 -0
- package/dist/host/audit/collect.js +126 -0
- package/dist/host/audit/log.d.ts +46 -0
- package/dist/host/audit/log.js +143 -0
- package/dist/host/channel.d.ts +2 -2
- package/dist/host/channel.js +3 -2
- package/dist/host/generate.d.ts +7 -1
- package/dist/host/generate.js +23 -6
- package/dist/host/join.d.ts +7 -0
- package/dist/host/join.js +23 -9
- package/dist/host/launch/mappings.d.ts +7 -2
- package/dist/host/launch/mappings.js +68 -18
- package/dist/host/launch/projects.d.ts +2 -0
- package/dist/host/launch/projects.js +11 -5
- package/dist/host/launch/stage.js +1 -1
- package/dist/host/motw.d.ts +17 -0
- package/dist/host/motw.js +74 -0
- package/dist/host/platform.js +3 -0
- package/dist/host/port-forward.d.ts +8 -0
- package/dist/host/port-forward.js +37 -0
- package/dist/host/projects.d.ts +25 -2
- package/dist/host/projects.js +54 -5
- package/dist/host/repo-guard.d.ts +10 -3
- package/dist/host/repo-guard.js +142 -14
- package/dist/host/stops.d.ts +67 -0
- package/dist/host/stops.js +126 -0
- package/dist/host/terminal.d.ts +8 -0
- package/dist/host/terminal.js +45 -1
- package/dist/host/workspace-view/index.d.ts +20 -0
- package/dist/host/workspace-view/index.js +53 -0
- package/dist/host/workspace-view/rules.d.ts +26 -0
- package/dist/host/workspace-view/rules.js +93 -0
- package/dist/host/workspace-view/view.d.ts +50 -0
- package/dist/host/workspace-view/view.js +281 -0
- package/dist/runtime/daemon.js +1387 -558
- package/dist/runtime/db.js +199 -14
- package/dist/runtime/main.js +3082 -631
- package/dist/runtime/netproxy.js +25948 -0
- package/dist/runtime/view.js +531 -148
- package/dist/selftest/audit.test.d.ts +2 -0
- package/dist/selftest/audit.test.js +57 -0
- package/dist/selftest/channel.test.js +45 -2
- package/dist/selftest/cli.test.js +47 -17
- package/dist/selftest/fixtures.d.ts +47 -1
- package/dist/selftest/fixtures.js +3 -1
- package/dist/selftest/generate.test.js +7 -2
- package/dist/selftest/index.js +6 -2
- package/dist/selftest/live-network.test.d.ts +2 -0
- package/dist/selftest/live-network.test.js +63 -0
- package/dist/selftest/live-terminal.test.d.ts +2 -0
- package/dist/selftest/live-terminal.test.js +169 -0
- package/dist/selftest/local-config.test.js +42 -4
- package/dist/selftest/mirror.test.js +36 -7
- package/dist/selftest/network.test.js +12 -0
- package/dist/selftest/projects.test.js +77 -8
- package/dist/selftest/repo-guard.test.js +59 -1
- package/dist/selftest/sync.test.js +50 -10
- package/dist/selftest/toolchains.test.js +30 -0
- package/dist/selftest/workspace-view.test.d.ts +2 -0
- package/dist/selftest/workspace-view.test.js +89 -0
- package/dist/shared/apps.d.ts +49 -0
- package/dist/shared/apps.js +114 -0
- package/dist/shared/console-input.d.ts +11 -0
- package/dist/shared/console-input.js +61 -0
- package/dist/shared/download.d.ts +3 -0
- package/dist/shared/download.js +25 -2
- package/dist/shared/guest-paths.d.ts +27 -0
- package/dist/shared/guest-paths.js +68 -0
- package/dist/shared/netpolicy.d.ts +27 -0
- package/dist/shared/netpolicy.js +51 -0
- package/dist/shared/projects.d.ts +27 -4
- package/dist/shared/projects.js +72 -13
- package/dist/shared/pty-client.d.ts +26 -4
- package/dist/shared/pty-client.js +204 -25
- package/dist/shared/rundir.d.ts +2 -0
- package/dist/shared/rundir.js +2 -0
- package/dist/shared/schema.d.ts +34 -0
- package/dist/shared/schema.js +14 -0
- package/dist/shared/system.d.ts +1 -0
- package/dist/shared/system.js +1 -0
- package/dist/sync/claude-mem.js +3 -2
- package/dist/sync/history.d.ts +6 -0
- package/dist/sync/history.js +26 -11
- package/dist/sync/watcher.js +78 -6
- package/package.json +2 -1
- package/sandbox/cargoshim.cs +56 -0
- package/sandbox/cpucap.cs +33 -0
- package/sandbox/cpucap.ps1 +4 -35
- package/sandbox/logon.cmd +6 -0
- package/sandbox/memlog.ps1 +34 -0
- package/sandbox/pagefile.cs +31 -0
- package/sandbox/pagefile.ps1 +21 -0
- package/sandbox/ptyhost.cs +162 -7
- package/schemas/config.schema.json +318 -3
- package/schemas/sandbox-config.schema.json +129 -14
- package/dist/cli/menu-multi.js +0 -82
- package/dist/cli/menu.d.ts +0 -14
- package/dist/cli/menu.js +0 -290
- package/dist/cli/settings/edit.d.ts +0 -13
- package/dist/cli/settings/edit.js +0 -19
- package/dist/cli/settings/index.d.ts +0 -8
- package/dist/cli/settings/index.js +0 -131
- package/dist/cli/settings/prompts.d.ts +0 -7
- package/dist/cli/settings/prompts.js +0 -48
- package/dist/cli/settings/schema.d.ts +0 -33
- package/dist/cli/settings/schema.js +0 -29
package/dist/sync/watcher.js
CHANGED
|
@@ -9,6 +9,12 @@ import { assertNoLinks, errMsg, sleep } from '../core/util.js';
|
|
|
9
9
|
import { hostClaudeDir } from '../host/claude-profile.js';
|
|
10
10
|
import { checkRepo } from '../host/repo-guard.js';
|
|
11
11
|
import { isSandboxRunning } from '../host/platform.js';
|
|
12
|
+
import { readActiveViews, WorkspaceView } from '../host/workspace-view/index.js';
|
|
13
|
+
import { readForwards, sandboxAddress, startPortForwards } from '../host/port-forward.js';
|
|
14
|
+
import { AuditCollector, readAuditSettings } from '../host/audit/collect.js';
|
|
15
|
+
import { readMarkedProjects, saveDataSize, watchAndMark } from '../host/motw.js';
|
|
16
|
+
import { sizeOf } from '../core/disk.js';
|
|
17
|
+
import { beatLife, causeSince, endLife, keepAwake, readLife } from '../host/stops.js';
|
|
12
18
|
import { hostClaudeMemDir, syncClaudeMem } from './claude-mem.js';
|
|
13
19
|
import { readSyncState, syncHistoryIn, syncHistoryOut, writeSyncState } from './history.js';
|
|
14
20
|
const WATCH_INTERVAL_MS = 60 * 1000;
|
|
@@ -32,7 +38,7 @@ export const syncProjectIn = (dataPath, project) => {
|
|
|
32
38
|
const next = { ...state, projects };
|
|
33
39
|
writeSyncState(dataPath, next);
|
|
34
40
|
// Prompt history is left alone: sessions are appending to the sandbox's copy right now.
|
|
35
|
-
return state.syncHistory ? syncHistoryIn(toPaths(next, dataPath), { promptHistory: false }) : 0;
|
|
41
|
+
return state.syncHistory ? syncHistoryIn(toPaths(next, dataPath), { promptHistory: false, scope: state.historyScope }) : 0;
|
|
36
42
|
};
|
|
37
43
|
const memSync = (dataPath, state, direction) => {
|
|
38
44
|
assertNoLinks(dataPath, guestMem(dataPath));
|
|
@@ -47,11 +53,11 @@ export const syncBeforeLaunch = (config, dataPath, hostWorkspace) => {
|
|
|
47
53
|
const previous = readSyncState(dataPath);
|
|
48
54
|
if (previous?.syncHistory)
|
|
49
55
|
syncHistoryOut(toPaths(previous, dataPath));
|
|
50
|
-
const state = { ...previous, hostClaudeDir: hostClaudeDir(profile), hostWorkspace, guestWorkspace: config.guestWorkspace, syncHistory: profile.syncHistory, syncClaudeMem: profile.syncClaudeMem, projects: [] };
|
|
56
|
+
const state = { ...previous, hostClaudeDir: hostClaudeDir(profile), hostWorkspace, guestWorkspace: config.guestWorkspace, syncHistory: profile.syncHistory, historyScope: profile.historyScope, syncClaudeMem: profile.syncClaudeMem, syncClaudeMemBack: profile.syncClaudeMemBack, projects: [] };
|
|
51
57
|
if (profile.syncHistory)
|
|
52
|
-
summary.history = syncHistoryIn(toPaths(state, dataPath));
|
|
58
|
+
summary.history = syncHistoryIn(toPaths(state, dataPath), { scope: profile.historyScope });
|
|
53
59
|
if (profile.syncClaudeMem) {
|
|
54
|
-
if (previous?.syncClaudeMem)
|
|
60
|
+
if (previous?.syncClaudeMem && previous.syncClaudeMemBack)
|
|
55
61
|
memSync(dataPath, state, 'out');
|
|
56
62
|
summary.claudeMem = memSync(dataPath, state, 'in');
|
|
57
63
|
}
|
|
@@ -66,7 +72,7 @@ export const syncBack = (dataPath, final) => {
|
|
|
66
72
|
return summary;
|
|
67
73
|
if (state.syncHistory)
|
|
68
74
|
summary.history = syncHistoryOut(toPaths(state, dataPath));
|
|
69
|
-
if (final && state.syncClaudeMem)
|
|
75
|
+
if (final && state.syncClaudeMem && state.syncClaudeMemBack)
|
|
70
76
|
summary.claudeMem = memSync(dataPath, state, 'out');
|
|
71
77
|
writeSyncState(dataPath, state);
|
|
72
78
|
return summary;
|
|
@@ -105,8 +111,10 @@ export const watch = async (dataPath) => {
|
|
|
105
111
|
catch { /* ignore */ } };
|
|
106
112
|
const sync = (final) => {
|
|
107
113
|
try {
|
|
108
|
-
for (const finding of checkRepo(dataPath))
|
|
114
|
+
for (const finding of checkRepo(dataPath)) {
|
|
109
115
|
note('SECURITY: neutralised a git ' + finding.kind + ' added inside the sandbox: ' + finding.detail);
|
|
116
|
+
audit?.guard(finding.kind + ': ' + finding.detail);
|
|
117
|
+
}
|
|
110
118
|
}
|
|
111
119
|
catch (e) {
|
|
112
120
|
note('repo guard failed: ' + errMsg(e));
|
|
@@ -120,16 +128,80 @@ export const watch = async (dataPath) => {
|
|
|
120
128
|
note('sync failed: ' + errMsg(e));
|
|
121
129
|
}
|
|
122
130
|
};
|
|
131
|
+
// workspaceFilter: each filtered project is kept in step with its view from now until the sandbox closes.
|
|
132
|
+
// audit.enabled: programs, files, sessions and guard findings into the host-only audit log.
|
|
133
|
+
const auditSettings = readAuditSettings(storageOf(dataPath), dataPath);
|
|
134
|
+
const audit = auditSettings?.enabled ? new AuditCollector(dataPath, storageOf(dataPath), auditSettings) : null;
|
|
135
|
+
audit?.start();
|
|
136
|
+
const auditTimer = audit ? setInterval(() => { try {
|
|
137
|
+
audit.flush();
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
note('audit: ' + errMsg(e));
|
|
141
|
+
} }, 10000) : null;
|
|
142
|
+
// storage.markSandboxFiles: programs and scripts that appear in the projects get the "from the internet" mark.
|
|
143
|
+
const stopMarking = watchAndMark(readMarkedProjects(storageOf(dataPath), dataPath), (m) => note(m));
|
|
144
|
+
// network.forwardPorts: localhost on this PC reaches servers in the sandbox while it runs.
|
|
145
|
+
const stopForwards = startPortForwards(readForwards(storageOf(dataPath), dataPath), () => sandboxAddress(dataPath), (m) => note('port forward: ' + m));
|
|
146
|
+
const views = readActiveViews(storageOf(dataPath), dataPath);
|
|
147
|
+
const stops = views.flatMap((v) => {
|
|
148
|
+
try {
|
|
149
|
+
return [new WorkspaceView({ ...v, note: (m) => note('workspace ' + v.host + ': ' + m) }).watch()];
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
note('workspace ' + v.host + ': could not watch: ' + errMsg(e));
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
// sandbox.keepAwake, and the life record that tells the next launch how this run ended (host/stops.ts).
|
|
157
|
+
const awake = readLife(storageOf(dataPath), dataPath)?.keepAwake ? keepAwake(process.pid) : null;
|
|
123
158
|
try {
|
|
124
159
|
await sleep(30000);
|
|
125
160
|
while (isSandboxRunning()) {
|
|
161
|
+
beatLife(storageOf(dataPath), dataPath);
|
|
126
162
|
sync(false);
|
|
127
163
|
await sleep(WATCH_INTERVAL_MS);
|
|
128
164
|
}
|
|
165
|
+
try {
|
|
166
|
+
const life = readLife(storageOf(dataPath), dataPath);
|
|
167
|
+
const cause = life ? causeSince(life.lastSeen) : null;
|
|
168
|
+
endLife(storageOf(dataPath), dataPath, cause);
|
|
169
|
+
if (cause)
|
|
170
|
+
note('sandbox ended: ' + cause.text);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
note('could not record how the sandbox ended: ' + errMsg(e));
|
|
174
|
+
}
|
|
129
175
|
await sleep(3000);
|
|
130
176
|
sync(true);
|
|
131
177
|
}
|
|
132
178
|
finally {
|
|
179
|
+
awake?.kill();
|
|
180
|
+
stops.forEach((stop) => stop());
|
|
181
|
+
stopForwards();
|
|
182
|
+
stopMarking();
|
|
183
|
+
if (auditTimer)
|
|
184
|
+
clearInterval(auditTimer);
|
|
185
|
+
try {
|
|
186
|
+
audit?.stop();
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
note('audit: ' + errMsg(e));
|
|
190
|
+
}
|
|
191
|
+
for (const v of views) {
|
|
192
|
+
try {
|
|
193
|
+
const s = new WorkspaceView(v).syncAll();
|
|
194
|
+
note('workspace ' + v.host + ': final sync ' + s.toHost + ' out, ' + s.toView + ' in');
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
note('workspace ' + v.host + ': final sync failed: ' + errMsg(e));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// For the next launch's storage.warnDataGb (measuring then would be slow).
|
|
201
|
+
try {
|
|
202
|
+
saveDataSize(storageOf(dataPath), dataPath, sizeOf(dataPath));
|
|
203
|
+
}
|
|
204
|
+
catch { /* best effort */ }
|
|
133
205
|
fs.rmSync(watcherPidFile(dataPath), { force: true });
|
|
134
206
|
}
|
|
135
207
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windows-development-sandbox",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "A disposable Windows Sandbox for development, with or without an AI agent (Claude Code, Codex, Gemini, opencode, ...): the folder you run it from is the workspace, toolchains and databases for your project install themselves, and nothing else on your PC is reachable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"dependencies": {
|
|
73
73
|
"@inquirer/prompts": "^7.10.1",
|
|
74
74
|
"commander": "^14.0.3",
|
|
75
|
+
"ignore": "^7.0.12",
|
|
75
76
|
"jsonc-parser": "^3.3.1",
|
|
76
77
|
"lodash": "^4.18.1",
|
|
77
78
|
"zod": "^4.6.5"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// cargo.exe stand-in for toolchains.rust.targetDir = "separate": every checkout builds into ITS OWN target\sandbox.
|
|
2
|
+
// One CARGO_TARGET_DIR for the whole sandbox made agents working in different git worktrees overwrite each other's
|
|
3
|
+
// builds (and the Claude Code Bash tool does not keep a per-call variable). The checkout is the nearest folder with a
|
|
4
|
+
// .git (a folder, or the file a worktree has); without one, the nearest folder with a Cargo.toml. A CARGO_TARGET_DIR
|
|
5
|
+
// you set yourself always wins. Compiled inside the sandbox with the .NET Framework csc.exe (C# 5).
|
|
6
|
+
using System;
|
|
7
|
+
using System.Diagnostics;
|
|
8
|
+
using System.IO;
|
|
9
|
+
|
|
10
|
+
static class CargoShim
|
|
11
|
+
{
|
|
12
|
+
static int Main()
|
|
13
|
+
{
|
|
14
|
+
string real = Environment.GetEnvironmentVariable("AGENT_REAL_CARGO");
|
|
15
|
+
if (string.IsNullOrEmpty(real) || !File.Exists(real))
|
|
16
|
+
{
|
|
17
|
+
Console.Error.WriteLine("error: the real cargo was not found (AGENT_REAL_CARGO).");
|
|
18
|
+
return 101;
|
|
19
|
+
}
|
|
20
|
+
var start = new ProcessStartInfo(real, ArgumentsOf(Environment.CommandLine)) { UseShellExecute = false };
|
|
21
|
+
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CARGO_TARGET_DIR")))
|
|
22
|
+
{
|
|
23
|
+
string root = CheckoutOf(Environment.CurrentDirectory);
|
|
24
|
+
if (root != null) start.EnvironmentVariables["CARGO_TARGET_DIR"] = Path.Combine(root, "target", "sandbox");
|
|
25
|
+
}
|
|
26
|
+
// Ctrl+C reaches cargo too (same console); the shim only waits for it to finish.
|
|
27
|
+
Console.CancelKeyPress += delegate(object sender, ConsoleCancelEventArgs e) { e.Cancel = true; };
|
|
28
|
+
using (Process p = Process.Start(start))
|
|
29
|
+
{
|
|
30
|
+
p.WaitForExit();
|
|
31
|
+
return p.ExitCode;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static string CheckoutOf(string dir)
|
|
36
|
+
{
|
|
37
|
+
string cargoRoot = null;
|
|
38
|
+
for (var d = new DirectoryInfo(dir); d != null; d = d.Parent)
|
|
39
|
+
{
|
|
40
|
+
string git = Path.Combine(d.FullName, ".git");
|
|
41
|
+
if (Directory.Exists(git) || File.Exists(git)) return d.FullName;
|
|
42
|
+
if (cargoRoot == null && File.Exists(Path.Combine(d.FullName, "Cargo.toml"))) cargoRoot = d.FullName;
|
|
43
|
+
}
|
|
44
|
+
return cargoRoot;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Everything after the program name, untouched, so quoting survives exactly as the caller wrote it.
|
|
48
|
+
static string ArgumentsOf(string commandLine)
|
|
49
|
+
{
|
|
50
|
+
string s = commandLine.TrimStart();
|
|
51
|
+
int end;
|
|
52
|
+
if (s.StartsWith("\"")) { end = s.IndexOf('"', 1); end = end < 0 ? s.Length : end + 1; }
|
|
53
|
+
else { end = s.IndexOf(' '); if (end < 0) end = s.Length; }
|
|
54
|
+
return s.Substring(end).TrimStart();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Caps a process and everything it starts at a share of the PC's CPU. Loaded by cpucap.ps1 (Add-Type). C# 5.
|
|
2
|
+
using System;
|
|
3
|
+
using System.ComponentModel;
|
|
4
|
+
using System.Runtime.InteropServices;
|
|
5
|
+
|
|
6
|
+
public static class SandboxCpuCap {
|
|
7
|
+
// JOBOBJECT_CPU_RATE_CONTROL_INFORMATION.
|
|
8
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
9
|
+
private struct CpuRate { public uint ControlFlags; public uint Rate; }
|
|
10
|
+
|
|
11
|
+
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr CreateJobObject(IntPtr attributes, string name);
|
|
12
|
+
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref CpuRate info, int length);
|
|
13
|
+
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
14
|
+
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
15
|
+
[DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
|
|
16
|
+
|
|
17
|
+
private const int JobObjectCpuRateControlInformation = 15;
|
|
18
|
+
private const uint CpuRateEnable = 0x1, CpuRateHardCap = 0x4;
|
|
19
|
+
private const uint ProcessTerminate = 0x0001, ProcessSetQuota = 0x0100; // what AssignProcessToJobObject needs
|
|
20
|
+
|
|
21
|
+
/// <summary>Puts process pid in a new job with a hard cap of percent % of all processors. Throws on failure.</summary>
|
|
22
|
+
public static void Apply(int pid, int percent) {
|
|
23
|
+
IntPtr job = CreateJobObject(IntPtr.Zero, null);
|
|
24
|
+
if (job == IntPtr.Zero) throw new Win32Exception();
|
|
25
|
+
// The rate is in 1/100 % of all processors.
|
|
26
|
+
CpuRate rate = new CpuRate { ControlFlags = CpuRateEnable | CpuRateHardCap, Rate = (uint)(percent * 100) };
|
|
27
|
+
if (!SetInformationJobObject(job, JobObjectCpuRateControlInformation, ref rate, Marshal.SizeOf(typeof(CpuRate)))) throw new Win32Exception();
|
|
28
|
+
IntPtr process = OpenProcess(ProcessSetQuota | ProcessTerminate, false, pid);
|
|
29
|
+
if (process == IntPtr.Zero) throw new Win32Exception();
|
|
30
|
+
try { if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); } finally { CloseHandle(process); }
|
|
31
|
+
// The job's handle is left to close with this process: the job lives on while any process in it runs.
|
|
32
|
+
}
|
|
33
|
+
}
|
package/sandbox/cpucap.ps1
CHANGED
|
@@ -3,46 +3,15 @@
|
|
|
3
3
|
# every processor, so heavy builds inside can take the whole PC. This puts logon.cmd (the parent of this script),
|
|
4
4
|
# before it starts anything, into a Windows job object with a hard CPU-rate cap; everything it starts inherits it:
|
|
5
5
|
# Windows Terminal and its tabs, the boot, the agents, installs and builds. Programs started from the sandbox's Start
|
|
6
|
-
# menu are not covered. Never stops the boot:
|
|
7
|
-
param([int]$ProcessId = 0, [int]$Percent = 0)
|
|
6
|
+
# menu are not covered. Never stops the boot: it logs one line either way and the sandbox starts uncapped on failure.
|
|
7
|
+
param([int]$ProcessId = 0, [int]$Percent = 0) # 0 = the parent process / cpuLimitPercent from session.json
|
|
8
8
|
$ErrorActionPreference = 'Stop'
|
|
9
9
|
$log = 'C:\AgentData\logs\logon-hit.txt'
|
|
10
10
|
try {
|
|
11
|
-
if ($Percent -eq 0) {
|
|
12
|
-
$session = Get-Content -LiteralPath 'C:\AgentLaunch\session.json' -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
13
|
-
if ($null -eq $session.cpuLimitPercent) { exit 0 }
|
|
14
|
-
$Percent = [int]$session.cpuLimitPercent
|
|
15
|
-
}
|
|
11
|
+
if ($Percent -eq 0) { $Percent = [int](Get-Content -LiteralPath 'C:\AgentLaunch\session.json' -Raw -Encoding UTF8 | ConvertFrom-Json).cpuLimitPercent }
|
|
16
12
|
if ($Percent -lt 1 -or $Percent -ge 100) { exit 0 }
|
|
17
13
|
if ($ProcessId -eq 0) { $ProcessId = [int](Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId }
|
|
18
|
-
Add-Type -
|
|
19
|
-
using System;
|
|
20
|
-
using System.ComponentModel;
|
|
21
|
-
using System.Runtime.InteropServices;
|
|
22
|
-
public static class SandboxCpuCap {
|
|
23
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
24
|
-
private struct CpuRate { public uint ControlFlags; public uint Rate; }
|
|
25
|
-
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr CreateJobObject(IntPtr attributes, string name);
|
|
26
|
-
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref CpuRate info, int length);
|
|
27
|
-
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
28
|
-
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
|
|
29
|
-
[DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle);
|
|
30
|
-
// JobObjectCpuRateControlInformation; ENABLE | HARD_CAP; the rate is in 1/100 % of all processors.
|
|
31
|
-
public static void Apply(int pid, int percent) {
|
|
32
|
-
IntPtr job = CreateJobObject(IntPtr.Zero, null);
|
|
33
|
-
if (job == IntPtr.Zero) throw new Win32Exception();
|
|
34
|
-
CpuRate rate = new CpuRate();
|
|
35
|
-
rate.ControlFlags = 0x1 | 0x4;
|
|
36
|
-
rate.Rate = (uint)(percent * 100);
|
|
37
|
-
if (!SetInformationJobObject(job, 15, ref rate, Marshal.SizeOf(typeof(CpuRate)))) throw new Win32Exception();
|
|
38
|
-
// PROCESS_SET_QUOTA | PROCESS_TERMINATE: what AssignProcessToJobObject needs.
|
|
39
|
-
IntPtr process = OpenProcess(0x0100 | 0x0001, false, pid);
|
|
40
|
-
if (process == IntPtr.Zero) throw new Win32Exception();
|
|
41
|
-
try { if (!AssignProcessToJobObject(job, process)) throw new Win32Exception(); } finally { CloseHandle(process); }
|
|
42
|
-
// The job's handle may close: it lives on while any process in it runs.
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
'@
|
|
14
|
+
Add-Type -Path (Join-Path $PSScriptRoot 'cpucap.cs')
|
|
46
15
|
[SandboxCpuCap]::Apply($ProcessId, $Percent)
|
|
47
16
|
"CPU capped at $Percent% of the PC (sandbox.cpuLimit)." | Add-Content -LiteralPath $log
|
|
48
17
|
} catch {
|
package/sandbox/logon.cmd
CHANGED
|
@@ -4,9 +4,15 @@ rem (downloaded once by the host into persistent storage), or a plain PowerShell
|
|
|
4
4
|
if not exist C:\AgentData\logs mkdir C:\AgentData\logs
|
|
5
5
|
echo logon.cmd ran at %date% %time% > C:\AgentData\logs\logon-hit.txt
|
|
6
6
|
call C:\AgentLaunch\sandbox\trust.cmd
|
|
7
|
+
rem New Terminal tabs start in %AGENT_WORKSPACE%: Terminal must have it before the runtime sets it machine-wide.
|
|
8
|
+
if exist C:\AgentLaunch\env.cmd call C:\AgentLaunch\env.cmd
|
|
7
9
|
set "PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
|
8
10
|
rem sandbox.cpuLimit: this window joins a CPU-capped job first, so everything started below inherits the cap.
|
|
9
11
|
"%PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\AgentLaunch\sandbox\cpucap.ps1
|
|
12
|
+
rem sandbox.pageFileMb: room for many parallel builds (the sandbox starts with about 1 GB of paging file).
|
|
13
|
+
"%PS%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File C:\AgentLaunch\sandbox\pagefile.ps1
|
|
14
|
+
rem The memory state every 30 s into the persistent logs, so a crash leaves evidence.
|
|
15
|
+
start "" /min "%PS%" -NoLogo -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File C:\AgentLaunch\sandbox\memlog.ps1
|
|
10
16
|
set "BOOT=%PS% -NoLogo -NoProfile -ExecutionPolicy Bypass -NoExit -File C:\AgentLaunch\sandbox\bootstrap.ps1"
|
|
11
17
|
if not exist C:\AgentData\tools\terminal\wt.exe goto plain
|
|
12
18
|
start "" C:\AgentData\tools\terminal\wt.exe --maximized new-tab --title "Agent Sandbox" -d C:\AgentLaunch %BOOT%
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#Requires -Version 5.1
|
|
2
|
+
# Writes the sandbox's memory state to C:\AgentData\logs\memory-<time>.log every 30 seconds, while it runs. That folder
|
|
3
|
+
# outlives the sandbox, so if Windows inside it ever crashes again, the last lines show what was using memory just
|
|
4
|
+
# before. Bounded: a file is closed at 1 MB (about two days) and a new one started; only the newest 10 are kept, so
|
|
5
|
+
# the logs never take more than about 10 MB. Started hidden by logon.cmd. Never stops the boot.
|
|
6
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
7
|
+
$logFolder = 'C:\AgentData\logs'
|
|
8
|
+
$maxFileBytes = 1MB
|
|
9
|
+
$keepFiles = 10
|
|
10
|
+
|
|
11
|
+
# A new log file, and the oldest ones beyond $keepFiles removed. Returns its path.
|
|
12
|
+
function New-MemoryLog {
|
|
13
|
+
$path = Join-Path $logFolder ('memory-{0:yyyyMMdd-HHmmss}.log' -f (Get-Date))
|
|
14
|
+
'time | RAM used/total MB | commit used/limit MB | biggest processes (private MB)' | Set-Content -LiteralPath $path
|
|
15
|
+
Get-ChildItem -LiteralPath $logFolder -Filter 'memory-*.log' | Sort-Object LastWriteTime -Descending |
|
|
16
|
+
Select-Object -Skip $keepFiles | Remove-Item -Force
|
|
17
|
+
return $path
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
$logPath = New-MemoryLog
|
|
21
|
+
while ($true) {
|
|
22
|
+
$os = Get-CimInstance Win32_OperatingSystem
|
|
23
|
+
$ramTotalMb = [math]::Round($os.TotalVisibleMemorySize / 1KB)
|
|
24
|
+
$ramUsedMb = $ramTotalMb - [math]::Round($os.FreePhysicalMemory / 1KB)
|
|
25
|
+
$commitLimitMb = [math]::Round($os.TotalVirtualMemorySize / 1KB)
|
|
26
|
+
$commitUsedMb = $commitLimitMb - [math]::Round($os.FreeVirtualMemory / 1KB)
|
|
27
|
+
$processes = @(Get-Process)
|
|
28
|
+
$biggest = $processes | Sort-Object PrivateMemorySize64 -Descending | Select-Object -First 5 |
|
|
29
|
+
ForEach-Object { '{0} {1}' -f $_.ProcessName, [math]::Round($_.PrivateMemorySize64 / 1MB) }
|
|
30
|
+
'{0:HH:mm:ss} | {1}/{2} | {3}/{4} | {5} processes; {6}' -f (Get-Date), $ramUsedMb, $ramTotalMb, $commitUsedMb, $commitLimitMb, $processes.Count, ($biggest -join ', ') |
|
|
31
|
+
Add-Content -LiteralPath $logPath
|
|
32
|
+
if ((Get-Item -LiteralPath $logPath).Length -ge $maxFileBytes) { $logPath = New-MemoryLog }
|
|
33
|
+
Start-Sleep -Seconds 30
|
|
34
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Sets the size of the sandbox's paging file at once, with no restart. Loaded by pagefile.ps1 (Add-Type). C# 5.
|
|
2
|
+
using System;
|
|
3
|
+
using System.ComponentModel;
|
|
4
|
+
using System.Runtime.InteropServices;
|
|
5
|
+
|
|
6
|
+
public static class SandboxPageFile {
|
|
7
|
+
// UNICODE_STRING. The marshaller copies Buffer to native memory for the length of the call.
|
|
8
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
9
|
+
private struct NtString { public ushort Length; public ushort MaximumLength; public string Buffer; }
|
|
10
|
+
|
|
11
|
+
[DllImport("ntdll.dll")] private static extern int RtlAdjustPrivilege(int privilege, bool enable, bool currentThread, out bool wasEnabled);
|
|
12
|
+
[DllImport("ntdll.dll")] private static extern int NtCreatePagingFile(ref NtString path, ref long minimumBytes, ref long maximumBytes, uint priority);
|
|
13
|
+
[DllImport("ntdll.dll")] private static extern int RtlNtStatusToDosError(int status);
|
|
14
|
+
|
|
15
|
+
private const int SeCreatePagefilePrivilege = 15;
|
|
16
|
+
|
|
17
|
+
/// <summary>Makes C:\pagefile.sys sizeMb, fixed (it never grows). Throws with Windows' reason on failure.</summary>
|
|
18
|
+
public static void Set(int sizeMb) {
|
|
19
|
+
bool wasEnabled;
|
|
20
|
+
Check(RtlAdjustPrivilege(SeCreatePagefilePrivilege, true, false, out wasEnabled));
|
|
21
|
+
// The same call Windows makes at startup; for the existing file it changes the size in place.
|
|
22
|
+
const string path = @"\??\C:\pagefile.sys";
|
|
23
|
+
NtString name = new NtString { Length = (ushort)(path.Length * 2), MaximumLength = (ushort)(path.Length * 2 + 2), Buffer = path };
|
|
24
|
+
long minimum = sizeMb * 1048576L, maximum = minimum;
|
|
25
|
+
Check(NtCreatePagingFile(ref name, ref minimum, ref maximum, 0));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private static void Check(int status) {
|
|
29
|
+
if (status != 0) throw new Win32Exception(RtlNtStatusToDosError(status));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#Requires -Version 5.1
|
|
2
|
+
# Gives the sandbox a paging file of sandbox.pageFileMb, created at full size now, before anything runs. Windows Sandbox
|
|
3
|
+
# starts with about 1 GB, so parallel builds run out of memory ("The paging file is too small", os error 1455), and
|
|
4
|
+
# then Windows grows the file while they run: both crashes of Windows inside the sandbox (13 Oct 2026, stop code 0x1A
|
|
5
|
+
# MEMORY_MANAGEMENT) happened under that load. A fixed size never grows. Never stops the boot: it logs one line.
|
|
6
|
+
param([int]$SizeMb = 0) # 0 = read sandbox.pageFileMb from session.json
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
$log = 'C:\AgentData\logs\logon-hit.txt'
|
|
9
|
+
try {
|
|
10
|
+
if ($SizeMb -eq 0) { $SizeMb = [int](Get-Content -LiteralPath 'C:\AgentLaunch\session.json' -Raw -Encoding UTF8 | ConvertFrom-Json).pageFileMb }
|
|
11
|
+
if ($SizeMb -le 0) { exit 0 }
|
|
12
|
+
# Keep 8 GB of the sandbox's disk free for everything else.
|
|
13
|
+
$SizeMb = [math]::Min($SizeMb, [math]::Floor((Get-PSDrive -Name C).Free / 1MB) - 8192)
|
|
14
|
+
if ($SizeMb -lt 1024) { throw 'too little free disk.' }
|
|
15
|
+
Add-Type -Path (Join-Path $PSScriptRoot 'pagefile.cs')
|
|
16
|
+
[SandboxPageFile]::Set($SizeMb)
|
|
17
|
+
"Paging file set to $SizeMb MB, fixed (sandbox.pageFileMb)." | Add-Content -LiteralPath $log
|
|
18
|
+
} catch {
|
|
19
|
+
"Paging file left as it is: $($_.Exception.Message)" | Add-Content -LiteralPath $log
|
|
20
|
+
}
|
|
21
|
+
exit 0
|
package/sandbox/ptyhost.cs
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// 18 ERROR utf8 message
|
|
17
17
|
// 19 RESTARTING
|
|
18
18
|
// 20 STATUS [viewers:2][remoteViewers:2] viewers heard from recently (remote = not loopback, i.e. the PC)
|
|
19
|
+
// 21 CONSOLE utf8 "conpty.dll" or "windows", sent after HELLO. With conpty.dll the output is passed
|
|
20
|
+
// through (real scrollback, no repaint on resize): a viewer that joins later is sent the
|
|
21
|
+
// terminal modes in force (mouse tracking ...) and the output since the last screen clear
|
|
22
|
+
// instead, and must not clear its screen on a resize.
|
|
19
23
|
using System;
|
|
20
24
|
using System.Collections.Concurrent;
|
|
21
25
|
using System.Collections.Generic;
|
|
@@ -75,6 +79,7 @@ sealed class SessionHost
|
|
|
75
79
|
using (var console = PseudoConsole.Start(command, hub.Cols, hub.Rows, hub.Broadcast))
|
|
76
80
|
{
|
|
77
81
|
hub.Attach(console);
|
|
82
|
+
log.Write("pseudo console: " + Native.ConsoleKind);
|
|
78
83
|
int exitCode = console.WaitForExit();
|
|
79
84
|
if (!restartRequested)
|
|
80
85
|
{
|
|
@@ -92,6 +97,7 @@ sealed class SessionHost
|
|
|
92
97
|
public void Restart()
|
|
93
98
|
{
|
|
94
99
|
restartRequested = true;
|
|
100
|
+
hub.ResetReplay();
|
|
95
101
|
hub.Notify(Frame.Restarting);
|
|
96
102
|
hub.KillProgram();
|
|
97
103
|
}
|
|
@@ -225,6 +231,9 @@ sealed class ClientHub
|
|
|
225
231
|
readonly byte[] token;
|
|
226
232
|
readonly byte[] tail = new byte[8192];
|
|
227
233
|
int tailLength;
|
|
234
|
+
readonly object replayGate = new object();
|
|
235
|
+
readonly MemoryStream replay = new MemoryStream();
|
|
236
|
+
const int MaxReplay = 4 << 20;
|
|
228
237
|
PseudoConsole console;
|
|
229
238
|
Client sizeOwner;
|
|
230
239
|
short cols, rows;
|
|
@@ -251,8 +260,23 @@ sealed class ClientHub
|
|
|
251
260
|
client.ControlOnly = (hello[4] & 3) != 0;
|
|
252
261
|
client.Query = (hello[4] & 2) != 0;
|
|
253
262
|
if (client.Query) return true;
|
|
254
|
-
|
|
255
|
-
|
|
263
|
+
if (!client.ControlOnly)
|
|
264
|
+
{
|
|
265
|
+
Send(client, Frame.Console, Encoding.UTF8.GetBytes(Native.ConsoleKind.StartsWith("conpty") ? "conpty.dll" : "windows"));
|
|
266
|
+
// Taken and queued under the replay lock, so no output falls between the replay and the live stream.
|
|
267
|
+
lock (replayGate)
|
|
268
|
+
{
|
|
269
|
+
if (Native.ConsoleKind.StartsWith("conpty"))
|
|
270
|
+
{
|
|
271
|
+
byte[] prefix = ModePrefix();
|
|
272
|
+
if (prefix.Length > 0) Send(client, Frame.Output, prefix);
|
|
273
|
+
if (replay.Length > 0) Send(client, Frame.Output, replay.ToArray());
|
|
274
|
+
}
|
|
275
|
+
lock (gate) clients.Add(client);
|
|
276
|
+
}
|
|
277
|
+
TakeOwnership(client);
|
|
278
|
+
}
|
|
279
|
+
else lock (gate) clients.Add(client);
|
|
256
280
|
return true;
|
|
257
281
|
}
|
|
258
282
|
|
|
@@ -287,7 +311,92 @@ sealed class ClientHub
|
|
|
287
311
|
public void Broadcast(byte[] output)
|
|
288
312
|
{
|
|
289
313
|
Remember(output);
|
|
290
|
-
|
|
314
|
+
lock (replayGate)
|
|
315
|
+
{
|
|
316
|
+
Record(output);
|
|
317
|
+
foreach (Client c in Snapshot()) if (!c.ControlOnly) Send(c, Frame.Output, output);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The output a viewer joining now needs to see the screen (conpty.dll does not repaint for it): everything since the
|
|
323
|
+
* last "clear screen", capped. Cut at a line start when capped, so the replay never begins inside an escape sequence.
|
|
324
|
+
*/
|
|
325
|
+
void Record(byte[] output)
|
|
326
|
+
{
|
|
327
|
+
if (!Native.ConsoleKind.StartsWith("conpty")) return;
|
|
328
|
+
TrackModes(output);
|
|
329
|
+
int clear = LastClear(output);
|
|
330
|
+
if (clear >= 0) { replay.SetLength(0); replay.Write(output, clear, output.Length - clear); }
|
|
331
|
+
else replay.Write(output, 0, output.Length);
|
|
332
|
+
if (replay.Length <= MaxReplay) return;
|
|
333
|
+
byte[] all = replay.ToArray();
|
|
334
|
+
int from = all.Length - MaxReplay / 2;
|
|
335
|
+
while (from < all.Length && all[from] != (byte)'\n') from++;
|
|
336
|
+
replay.SetLength(0);
|
|
337
|
+
if (from + 1 < all.Length) replay.Write(all, from + 1, all.Length - from - 1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Terminal modes the program (or conpty.dll) turned on that a late viewer's terminal must have too, or its clicks and
|
|
342
|
+
* wheel never reach the program: mouse tracking (1000, 1002, 1003, 1005, 1006, 1015), focus events (1004), bracketed
|
|
343
|
+
* paste (2004), win32 input (9001), application cursor keys (1), cursor visibility (25), the alternate screen (1049).
|
|
344
|
+
* The replay is cut at the last screen clear, which may come after the program turned them on.
|
|
345
|
+
*/
|
|
346
|
+
static readonly int[] ReplayedModes = { 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1049, 2004, 9001 };
|
|
347
|
+
readonly Dictionary<int, bool> modes = new Dictionary<int, bool>();
|
|
348
|
+
/** DECSET / DECRST parser, kept across chunks: 0 text, 1 after ESC, 2 after ESC[, 3 in ESC[? parameters. */
|
|
349
|
+
int modeState;
|
|
350
|
+
readonly StringBuilder modeParams = new StringBuilder();
|
|
351
|
+
|
|
352
|
+
void TrackModes(byte[] output)
|
|
353
|
+
{
|
|
354
|
+
foreach (byte b in output)
|
|
355
|
+
{
|
|
356
|
+
if (b == 0x1b) { modeState = 1; continue; }
|
|
357
|
+
switch (modeState)
|
|
358
|
+
{
|
|
359
|
+
case 1: modeState = b == (byte)'[' ? 2 : 0; break;
|
|
360
|
+
case 2: modeState = b == (byte)'?' ? 3 : 0; modeParams.Length = 0; break;
|
|
361
|
+
case 3:
|
|
362
|
+
if ((b >= (byte)'0' && b <= (byte)'9') || b == (byte)';') { if (modeParams.Length < 64) modeParams.Append((char)b); }
|
|
363
|
+
else
|
|
364
|
+
{
|
|
365
|
+
if (b == (byte)'h' || b == (byte)'l')
|
|
366
|
+
foreach (string p in modeParams.ToString().Split(';'))
|
|
367
|
+
{
|
|
368
|
+
int mode;
|
|
369
|
+
if (int.TryParse(p, out mode) && Array.IndexOf(ReplayedModes, mode) >= 0) modes[mode] = b == (byte)'h';
|
|
370
|
+
}
|
|
371
|
+
modeState = 0;
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The modes to set before the replay, e.g. ESC[?1000h ESC[?1006h. */
|
|
379
|
+
byte[] ModePrefix()
|
|
380
|
+
{
|
|
381
|
+
var text = new StringBuilder();
|
|
382
|
+
foreach (int mode in ReplayedModes)
|
|
383
|
+
{
|
|
384
|
+
bool on;
|
|
385
|
+
if (modes.TryGetValue(mode, out on)) text.Append("\x1b[?" + mode + (on ? "h" : "l"));
|
|
386
|
+
}
|
|
387
|
+
return Encoding.ASCII.GetBytes(text.ToString());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Where the last ESC[2J (clear screen) or ESC c (reset) starts in `data`, or -1. */
|
|
391
|
+
static int LastClear(byte[] data)
|
|
392
|
+
{
|
|
393
|
+
for (int i = data.Length - 2; i >= 0; i--)
|
|
394
|
+
{
|
|
395
|
+
if (data[i] != 0x1b) continue;
|
|
396
|
+
if (data[i + 1] == (byte)'c') return i;
|
|
397
|
+
if (i + 3 < data.Length && data[i + 1] == (byte)'[' && data[i + 2] == (byte)'2' && data[i + 3] == (byte)'J') return i;
|
|
398
|
+
}
|
|
399
|
+
return -1;
|
|
291
400
|
}
|
|
292
401
|
|
|
293
402
|
/** The program's last output as plain text (escape sequences removed), for the log when it exits. */
|
|
@@ -311,6 +420,9 @@ sealed class ClientHub
|
|
|
311
420
|
}
|
|
312
421
|
}
|
|
313
422
|
|
|
423
|
+
/** A restarted program starts on a clean screen (viewers clear theirs on RESTARTING). */
|
|
424
|
+
public void ResetReplay() { lock (replayGate) { replay.SetLength(0); modes.Clear(); modeState = 0; } }
|
|
425
|
+
|
|
314
426
|
public void Notify(byte type)
|
|
315
427
|
{
|
|
316
428
|
foreach (Client c in Snapshot()) if (!c.ControlOnly) Send(c, type, new byte[0]);
|
|
@@ -503,7 +615,7 @@ sealed class PseudoConsole : IDisposable
|
|
|
503
615
|
static class Frame
|
|
504
616
|
{
|
|
505
617
|
public const byte Hello = 1, Input = 2, Resize = 3, Restart = 4, Ping = 5;
|
|
506
|
-
public const byte Output = 16, Exit = 17, Error = 18, Restarting = 19, Status = 20;
|
|
618
|
+
public const byte Output = 16, Exit = 17, Error = 18, Restarting = 19, Status = 20, Console = 21;
|
|
507
619
|
const int MaxPayload = 1 << 20;
|
|
508
620
|
|
|
509
621
|
public static bool Read(Stream stream, out byte type, out byte[] payload)
|
|
@@ -598,9 +710,52 @@ static class Native
|
|
|
598
710
|
}
|
|
599
711
|
}
|
|
600
712
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
713
|
+
// The pseudo console: the newer conpty.dll (+ OpenConsole.exe next to it) when PTYHOST_CONPTY names one, else the one
|
|
714
|
+
// built into Windows. Windows 10/11 before 24H2 ship a ConPTY that re-renders the screen instead of passing the
|
|
715
|
+
// program's output through, which garbles wide characters after a resize and loses mouse input and scrollback.
|
|
716
|
+
[UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate int CreateFn(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC);
|
|
717
|
+
[UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate int ResizeFn(IntPtr hPC, Coord size);
|
|
718
|
+
[UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate void CloseFn(IntPtr hPC);
|
|
719
|
+
static CreateFn create;
|
|
720
|
+
static ResizeFn resize;
|
|
721
|
+
static CloseFn close;
|
|
722
|
+
/** "conpty.dll" or "windows": which pseudo console is in use (logged, and told to viewers). */
|
|
723
|
+
public static string ConsoleKind = "windows";
|
|
724
|
+
|
|
725
|
+
static Native()
|
|
726
|
+
{
|
|
727
|
+
string dll = Environment.GetEnvironmentVariable("PTYHOST_CONPTY");
|
|
728
|
+
if (string.IsNullOrEmpty(dll) || !File.Exists(dll) || !File.Exists(Path.Combine(Path.GetDirectoryName(dll), "OpenConsole.exe"))) return;
|
|
729
|
+
IntPtr module = LoadLibraryW(dll);
|
|
730
|
+
if (module == IntPtr.Zero) return;
|
|
731
|
+
IntPtr c = GetProcAddress(module, "ConptyCreatePseudoConsole"), r = GetProcAddress(module, "ConptyResizePseudoConsole"), x = GetProcAddress(module, "ConptyClosePseudoConsole");
|
|
732
|
+
if (c == IntPtr.Zero || r == IntPtr.Zero || x == IntPtr.Zero) return;
|
|
733
|
+
create = (CreateFn)Marshal.GetDelegateForFunctionPointer(c, typeof(CreateFn));
|
|
734
|
+
resize = (ResizeFn)Marshal.GetDelegateForFunctionPointer(r, typeof(ResizeFn));
|
|
735
|
+
close = (CloseFn)Marshal.GetDelegateForFunctionPointer(x, typeof(CloseFn));
|
|
736
|
+
ConsoleKind = "conpty.dll";
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** conpty.dll first; if it cannot start one (OpenConsole.exe blocked or broken), the built-in one for good. */
|
|
740
|
+
public static int CreatePseudoConsole(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC)
|
|
741
|
+
{
|
|
742
|
+
if (create != null)
|
|
743
|
+
{
|
|
744
|
+
int hr = create(size, hInput, hOutput, dwFlags, out phPC);
|
|
745
|
+
if (hr == 0) return 0;
|
|
746
|
+
create = null; resize = null; close = null;
|
|
747
|
+
ConsoleKind = "windows (conpty.dll failed: 0x" + hr.ToString("X8") + ")";
|
|
748
|
+
}
|
|
749
|
+
return KernelCreatePseudoConsole(size, hInput, hOutput, dwFlags, out phPC);
|
|
750
|
+
}
|
|
751
|
+
public static int ResizePseudoConsole(IntPtr hPC, Coord size) { return resize != null ? resize(hPC, size) : KernelResizePseudoConsole(hPC, size); }
|
|
752
|
+
public static void ClosePseudoConsole(IntPtr hPC) { if (close != null) close(hPC); else KernelClosePseudoConsole(hPC); }
|
|
753
|
+
|
|
754
|
+
[DllImport("kernel32.dll", EntryPoint = "CreatePseudoConsole")] static extern int KernelCreatePseudoConsole(Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC);
|
|
755
|
+
[DllImport("kernel32.dll", EntryPoint = "ResizePseudoConsole")] static extern int KernelResizePseudoConsole(IntPtr hPC, Coord size);
|
|
756
|
+
[DllImport("kernel32.dll", EntryPoint = "ClosePseudoConsole")] static extern void KernelClosePseudoConsole(IntPtr hPC);
|
|
757
|
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern IntPtr LoadLibraryW(string path);
|
|
758
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Ansi, BestFitMapping = false)] static extern IntPtr GetProcAddress(IntPtr module, string name);
|
|
604
759
|
[DllImport("kernel32.dll", SetLastError = true)] public static extern bool CreatePipe(out SafeFileHandle hReadPipe, out SafeFileHandle hWritePipe, IntPtr lpPipeAttributes, int nSize);
|
|
605
760
|
[DllImport("kernel32.dll", SetLastError = true)] static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref IntPtr lpSize);
|
|
606
761
|
[DllImport("kernel32.dll", SetLastError = true)] static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize);
|