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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The audit log: kept small (repeats folded, a cap per minute, old days compressed, limits on days and size), read back
|
|
2
|
+
// filtered, and what the sandbox reports is checked like everything else that comes from it.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import zlib from 'node:zlib';
|
|
7
|
+
import { AuditCollector } from '../host/audit/collect.js';
|
|
8
|
+
import { AuditLog, fold } from '../host/audit/log.js';
|
|
9
|
+
import { parseProcessEvents } from '../guest/audit-parse.js';
|
|
10
|
+
import { runPaths } from '../shared/rundir.js';
|
|
11
|
+
const ev = (t, extra = {}) => ({ t, k: 'proc', p: 'C:\\x\\cargo.exe', c: 'cargo build', ...extra });
|
|
12
|
+
export const auditSuite = {
|
|
13
|
+
name: 'audit',
|
|
14
|
+
title: 'audit log (folding, compression, limits, untrusted reports)',
|
|
15
|
+
run: (t) => {
|
|
16
|
+
t.section('small');
|
|
17
|
+
const folded = fold([ev('2026-01-01T10:00:01Z'), ev('2026-01-01T10:00:02Z'), ev('2026-01-01T10:00:03Z', { c: 'cargo test' }), ev('2026-01-01T10:01:00Z')]);
|
|
18
|
+
t.equal(folded.map((e) => [e.c, e.n ?? 1]), [['cargo build', 2], ['cargo test', 1], ['cargo build', 1]], 'repeats within a minute become one line with a count');
|
|
19
|
+
const flood = fold(Array.from({ length: 50 }, (_, i) => ev('2026-01-01T10:00:00Z', { c: 'step ' + i })), 10);
|
|
20
|
+
t.check(flood.length === 11 && flood[10].k === 'note' && flood[10].p.startsWith('40 more'), 'past the per-minute cap the rest is one note');
|
|
21
|
+
const dir = t.at('audit-log');
|
|
22
|
+
const log = new AuditLog(dir, { days: 3, maxMb: 5 });
|
|
23
|
+
log.append([ev('2026-01-01T10:00:00Z'), ev('2026-01-02T10:00:00Z'), ev('2026-01-04T10:00:00Z', { k: 'file', p: 'src/a.ts', a: 'change', w: 'C:\\app' })]);
|
|
24
|
+
log.maintain('2026-01-04');
|
|
25
|
+
t.equal(fs.readdirSync(dir).sort(), ['2026-01-02.jsonl.gz', '2026-01-04.jsonl'], 'finished days are compressed and days past audit.days are deleted');
|
|
26
|
+
t.includes(zlib.gunzipSync(fs.readFileSync(path.join(dir, '2026-01-02.jsonl.gz'))).toString(), 'cargo build', 'a compressed day still holds its events');
|
|
27
|
+
const big = new AuditLog(t.at('audit-big'), { days: 30, maxMb: 5 });
|
|
28
|
+
// Random text: compression must not make the test data fit after all.
|
|
29
|
+
const line = (day) => ev(day + 'T00:00:00Z', { c: crypto.randomBytes(900).toString('hex') });
|
|
30
|
+
for (const day of ['2026-02-01', '2026-02-02', '2026-02-03'])
|
|
31
|
+
big.append(Array.from({ length: 1400 }, () => line(day)));
|
|
32
|
+
big.maintain('2026-02-03');
|
|
33
|
+
t.check(big.bytes() <= 5 * 1024 * 1024 && fs.readdirSync(big.dir).length < 3, 'past audit.maxMb the oldest days go', String(big.bytes()));
|
|
34
|
+
t.section('reading');
|
|
35
|
+
t.equal(log.read({ days: 10000, kinds: ['file'] }).map((e) => e.p), ['src/a.ts'], 'events can be filtered by kind');
|
|
36
|
+
t.equal(log.read({ days: 10000, grep: 'SRC/A.TS' }).length, 1, '... and by text (any case)');
|
|
37
|
+
t.section('what the sandbox reports');
|
|
38
|
+
const xml = "<Event><System><TimeCreated SystemTime='2026-01-05T09:00:00.000Z'/><EventRecordID>41</EventRecordID></System><EventData><Data Name='NewProcessName'>C:\\AgentData\\tools\\git\\cmd\\git.exe</Data><Data Name='CommandLine'>git push --force "origin"</Data><Data Name='ParentProcessName'>C:\\nvm\\nodejs\\node.exe</Data></EventData></Event>"
|
|
39
|
+
+ "<Event><System><EventRecordID>42</EventRecordID></System><EventData><Data Name='NewProcessName'>C:\\Windows\\System32\\conhost.exe</Data></EventData></Event>";
|
|
40
|
+
const parsed = parseProcessEvents(xml);
|
|
41
|
+
t.equal({ last: parsed.last, lines: parsed.lines.map((l) => JSON.parse(l)).map((e) => [e.p, e.c, e.pp]) }, { last: 42, lines: [['C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force "origin"', 'C:\\nvm\\nodejs\\node.exe']] }, 'Windows process events become audit lines; Windows\' own background programs are left out');
|
|
42
|
+
const data = t.at('audit-data', 'data', 'd');
|
|
43
|
+
const storage = t.at('audit-data');
|
|
44
|
+
const reports = runPaths(data).audit;
|
|
45
|
+
t.write(path.join(reports, '1-1.jsonl'), [
|
|
46
|
+
JSON.stringify({ t: '2026-01-05T09:00:00Z', k: 'guard', p: 'fake: all clear' }),
|
|
47
|
+
JSON.stringify({ t: '2026-01-05T09:00:01Z', k: 'proc', p: 'C:\\x.exe', c: 'x --y', w: 'C:\\Users\\me' }),
|
|
48
|
+
'not json', JSON.stringify({ t: '2026-01-05T09:00:02Z', k: 'proc', p: 'x'.repeat(5000) }),
|
|
49
|
+
].join('\n'));
|
|
50
|
+
t.write(path.join(reports, 'not-a-report.txt'), 'ignored');
|
|
51
|
+
const collector = new AuditCollector(data, storage, { enabled: true, days: 14, maxMb: 50, processes: true, files: false, projects: [] });
|
|
52
|
+
collector.flush();
|
|
53
|
+
const got = new AuditLog(AuditLog.dirFor(storage, data), { days: 14, maxMb: 50 }).read({ days: 100000 });
|
|
54
|
+
t.equal(got.map((e) => [e.k, e.p, e.w]), [['proc', 'fake: all clear', undefined], ['proc', 'C:\\x.exe', undefined]], 'sandbox reports can only be programs: other kinds are recorded as such, fields it may not set are dropped, oversized lines are refused');
|
|
55
|
+
t.check(!fs.existsSync(path.join(reports, '1-1.jsonl')), 'reports are deleted once taken in');
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The host <-> sandbox channel and the terminal protocol: everything arriving from the sandbox is validated, sized and
|
|
2
2
|
// filtered before this PC acts on it.
|
|
3
3
|
import { safeId } from '../shared/rundir.js';
|
|
4
|
-
import { GuestRequest, SessionStatus } from '../shared/schema.js';
|
|
5
|
-
import {
|
|
4
|
+
import { GuestRequest, SandboxStatus, SessionStatus } from '../shared/schema.js';
|
|
5
|
+
import { bootLine, firstStartNotice, pendingDownloads, readyIn, stillWorking } from '../cli/boot-progress.js';
|
|
6
|
+
import { encodeFrame, FrameReader, FrameType, fixWindowsKeys, helloFrame, InputJoiner, MAX_FRAME_BYTES, MOUSE_OFF, OutputFilter } from '../shared/pty-client.js';
|
|
6
7
|
export const channelSuite = {
|
|
7
8
|
name: 'channel',
|
|
8
9
|
title: 'request/status validation and the terminal protocol (frames, OSC 52, keys)',
|
|
@@ -19,6 +20,32 @@ export const channelSuite = {
|
|
|
19
20
|
t.check(validSession(goodSession), 'a well-formed session status is accepted');
|
|
20
21
|
t.check(!validSession({ ...goodSession, attach: { ...goodSession.attach, port: 70000 } }), 'a session status with an impossible port is rejected');
|
|
21
22
|
t.check(!validSession({ ...goodSession, phase: 'pwned' }), 'a session status with an unknown phase is rejected');
|
|
23
|
+
t.section('boot progress');
|
|
24
|
+
const goodStatus = { launchId: 'l1', phase: 'provisioning', message: '[3/8] Developer tools', updatedAt: 'x', workspaceHost: 'C:\\p', guestWorkspace: 'C:\\Workspace', headless: false };
|
|
25
|
+
const progress = { step: 3, steps: 8, title: 'Developer tools', active: [{ name: 'Rust', percent: 40 }, { name: 'Go' }], done: 2, total: 6 };
|
|
26
|
+
const parsed = (s) => SandboxStatus.safeParse(s);
|
|
27
|
+
t.equal(parsed({ ...goodStatus, progress }).data?.progress, progress, 'a well-formed boot progress is accepted');
|
|
28
|
+
t.check(parsed(goodStatus).success && parsed(goodStatus).data?.progress === undefined, 'a status without progress (an older sandbox) is accepted');
|
|
29
|
+
const hostile = [
|
|
30
|
+
{ ...progress, title: 'x'.repeat(5000) }, { ...progress, active: Array.from({ length: 100 }, () => ({ name: 'a' })) },
|
|
31
|
+
{ ...progress, active: [{ name: 'a', percent: 500 }] }, { ...progress, step: -1 }, { ...progress, done: 1e9 }, { ...progress, extra: 1 }, 'pwned',
|
|
32
|
+
];
|
|
33
|
+
t.equal(hostile.map((p) => { const r = parsed({ ...goodStatus, progress: p }); return r.success && r.data.progress === undefined; }), hostile.map(() => true), 'an oversized or malformed progress is dropped, the status itself still counts');
|
|
34
|
+
t.check(!parsed({ ...goodStatus, phase: 'pwned', progress }).success, 'a status with an unknown phase is still rejected');
|
|
35
|
+
const line = (p) => bootLine(parsed({ ...goodStatus, progress: p }).data ?? null).line;
|
|
36
|
+
t.equal(line(progress), '[3/8] Developer tools: downloading Rust 40%; setting up Go (2 of 6 done)', 'the line shows the step, the download and what is set up');
|
|
37
|
+
t.equal(line({ ...progress, title: 'Agent: claude', step: 6, active: [], done: 0, total: 0 }), '[6/8] Agent: claude', 'a step without tools is just its title');
|
|
38
|
+
t.equal(line({ ...progress, active: [{ name: '\x1b]52;c;ZXZpbA==\x07Ru\x1b[2Jst\r\n', percent: 99 }] }), '[3/8] Developer tools: downloading Rust 99% (2 of 6 done)', 'escape sequences and control characters from the sandbox never reach the terminal');
|
|
39
|
+
t.equal(bootLine(null).line, 'Starting Windows Sandbox', 'before the sandbox writes anything');
|
|
40
|
+
t.equal(bootLine(parsed({ ...goodStatus, message: 'Installing Go (1 of 3 done)' }).data).line, 'Installing Go (1 of 3 done)', 'an older sandbox: its message');
|
|
41
|
+
t.equal(bootLine(parsed({ ...goodStatus, progress: { ...progress, active: [{ name: 'Rust', percent: 50 }] } }).data).key, bootLine(parsed({ ...goodStatus, progress: { ...progress, active: [{ name: 'Rust', percent: 60 }] } }).data).key, 'a download moving on is no new plain line (only a new step or tool is)');
|
|
42
|
+
t.equal(stillWorking('Rust', 3 * 60 * 1000), 'Still working on Rust (3 min). Full log: sandbox logs -f', 'the notice after a long quiet spell');
|
|
43
|
+
t.equal(readyIn(432_000), 'Ready in 7 min 12 s.', 'the time a first start took');
|
|
44
|
+
const pending = pendingDownloads(['node', 'rust', 'cpp', 'postgres', 'node', 'constructor'], (dir) => dir === 'nvm');
|
|
45
|
+
t.equal(pending, { names: ['rust', 'C/C++', 'PostgreSQL'], mb: 900 }, 'only what is not downloaded yet counts, once each');
|
|
46
|
+
t.equal(firstStartNotice(false, { names: [], mb: 0 }), null, 'a later start with everything downloaded says nothing');
|
|
47
|
+
t.check(/^First start: .*\(about 900 MB: rust, C\/C\+\+, PostgreSQL\)\. This can take 5-20 minutes/.test(firstStartNotice(true, pending) ?? ''), 'the first start says what it downloads, roughly how much, and how long it can take');
|
|
48
|
+
t.check(/^New tools for this project: .*about 1\.8 GB: MSVC, rust/.test(firstStartNotice(false, pendingDownloads(['msvc', 'rust'], () => false)) ?? ''), 'a new toolchain later on gets its own heads-up');
|
|
22
49
|
t.section('terminal frames');
|
|
23
50
|
const filter = new OutputFilter();
|
|
24
51
|
const filtered = Buffer.concat([
|
|
@@ -27,11 +54,27 @@ export const channelSuite = {
|
|
|
27
54
|
filter.push(Buffer.from('2;c;cGF5\x1b\\d')),
|
|
28
55
|
]).toString('latin1');
|
|
29
56
|
t.equal(filtered, 'a' + 'b\x1b]0;title\x07' + 'c' + 'd', 'clipboard writes (OSC 52) from the sandbox are stripped, even split across chunks; titles pass');
|
|
57
|
+
const through = (...chunks) => { const f = new OutputFilter(); return Buffer.concat(chunks.map((s) => f.push(Buffer.from(s, 'latin1')))).toString('latin1'); };
|
|
58
|
+
t.equal(through('x\x1b]52;c;QUFB\x1b', '\\y'), 'xy', 'an OSC 52 whose terminator is split between chunks is still stripped');
|
|
59
|
+
t.equal(through('x\x1b]052;c;QUFB\x07y'), 'xy', 'OSC 052 (leading zero) is stripped too');
|
|
60
|
+
t.equal(through('x\x1b]52;c;' + 'A'.repeat(70000), 'B'.repeat(100), '\x07y'), 'xy', 'a huge OSC 52 stays stripped across chunks');
|
|
61
|
+
t.equal(through('x\xc2\x9d52;c;QUFB\xc2\x9cy\x1bP1$qm\x1b\\z'), 'xyz', 'C1 (UTF-8) OSC and DCS strings are stripped');
|
|
62
|
+
t.equal(through('\x1b]8;;https://a.b\x1b\\link\x1b]8;;\x1b\\ \x1b[31mred\x1b[0m é\xc2\xa0'), '\x1b]8;;https://a.b\x1b\\link\x1b]8;;\x1b\\ \x1b[31mred\x1b[0m é\xc2\xa0', 'links, colours and ordinary UTF-8 (incl. U+00A0) pass unchanged');
|
|
63
|
+
t.equal(through('\x1b]9;9;macro\x07\x1b]9;4;1;50\x07'), '\x1b]9;4;1;50\x07', 'only the progress form of OSC 9 passes (ConEmu macros do not)');
|
|
30
64
|
const oversizedHeader = Buffer.concat([Buffer.from([16]), Buffer.from([0, 0, 0, 0x7f])]);
|
|
31
65
|
await t.throws(() => new FrameReader().push(oversizedHeader), 'frame too large', 'a frame announcing too much data is rejected instead of buffered');
|
|
32
66
|
t.equal(new FrameReader().push(encodeFrame(16, Buffer.alloc(MAX_FRAME_BYTES))).length, 1, 'a frame of exactly the maximum size is accepted');
|
|
33
67
|
const hello = new FrameReader().push(helloFrame('tok', 100, 30, 2))[0];
|
|
34
68
|
t.equal({ type: hello.type, cols: hello.payload.readInt16LE(0), rows: hello.payload.readInt16LE(2), flags: hello.payload[4], token: hello.payload.subarray(5).toString() }, { type: FrameType.Hello, cols: 100, rows: 30, flags: 2, token: 'tok' }, 'a hello frame carries size, flags and token');
|
|
35
69
|
t.equal(fixWindowsKeys(Buffer.from([0x61, 0x08, 0x7f, 0x1b])), Buffer.from([0x61, 0x7f, 0x08, 0x1b]), 'Windows console Backspace (^H) reaches the agent as DEL, so it deletes one character, not a word');
|
|
70
|
+
t.section('typed input keeps escape sequences whole');
|
|
71
|
+
const joined = (...chunks) => { const j = new InputJoiner(); const out = chunks.map((c) => j.push(Buffer.from(c, 'latin1')).toString('latin1')); out.push(j.flush().toString('latin1')); return out.filter(Boolean); };
|
|
72
|
+
t.equal(joined('ab\x1b[', 'C'), ['ab', '\x1b[C'], 'a Right arrow split after ESC[ is sent whole (ConPTY would read Alt+[ and a typed "C")');
|
|
73
|
+
t.equal(joined('\x1b', '[<0;10;', '5M', 'x'), ['\x1b[<0;10;5M', 'x'], 'a mouse report split three ways is sent as one');
|
|
74
|
+
t.equal(joined('\x1bO', 'A'), ['\x1bOA'], 'an SS3 key split after ESC O is sent whole');
|
|
75
|
+
t.equal(joined('a\x1b'), ['a', '\x1b'], 'a lone Esc key is held, then sent by the flush');
|
|
76
|
+
t.equal(joined('\x1b[A\x1bx\x7f'), ['\x1b[A\x1bx\x7f'], 'complete keys (arrows, Alt+x, Backspace) pass at once');
|
|
77
|
+
t.equal(joined('\x1b[' + '1;'.repeat(40)), ['\x1b[' + '1;'.repeat(40)], 'an endless "sequence" is not held back');
|
|
78
|
+
t.check(MOUSE_OFF.includes('\x1b[?1000l') && MOUSE_OFF.includes('\x1b[?1003l') && MOUSE_OFF.includes('\x1b[?1006l'), 'leaving a session turns every mouse tracking mode off in your terminal');
|
|
36
79
|
},
|
|
37
80
|
};
|
|
@@ -5,13 +5,14 @@ import path from 'node:path';
|
|
|
5
5
|
import { buildProgram, parseCli } from '../cli/args.js';
|
|
6
6
|
import { COMMANDS } from '../cli/manifest.js';
|
|
7
7
|
import { commandLine, readHistory, recordHistory } from '../cli/history.js';
|
|
8
|
-
import {
|
|
8
|
+
import { splitArgs } from '../cli/args.js';
|
|
9
|
+
import { optionProblem } from '../cli/menu/index.js';
|
|
9
10
|
import { cleanTargetOf } from '../cli/commands/data/collect.js';
|
|
10
11
|
import { ago, colorEnabled, shorten, shortenMiddle } from '../cli/colors.js';
|
|
11
12
|
import { hoistConfig, withoutConfig } from '../cli/index.js';
|
|
12
13
|
import { closest, validNodeSpec, validPythonSpec } from '../cli/validate.js';
|
|
13
|
-
import { editConfigText,
|
|
14
|
-
import {
|
|
14
|
+
import { CATEGORIES, editConfigText, nameOf, SchemaNode } from '../cli/menu/settings/index.js';
|
|
15
|
+
import { Frame, ListScreen, Text, TextScreen } from '../cli/tui/index.js';
|
|
15
16
|
import { Config, LocalOverrides } from '../core/schema.js';
|
|
16
17
|
export const cliSuite = {
|
|
17
18
|
name: 'cli',
|
|
@@ -99,20 +100,49 @@ export const cliSuite = {
|
|
|
99
100
|
const projectBad = editConfigText('{}', ['storage', 'root'], 'C:\\x', 'project');
|
|
100
101
|
t.check('text' in projectEdit && projectEdit.text.includes('requireChecksum'), 'settings screen: a project file accepts project settings');
|
|
101
102
|
t.check('problems' in projectBad, 'settings screen: a project file is validated against the project schema (storage cannot be set there)');
|
|
102
|
-
// Every setting must be reachable in the settings screen
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
103
|
+
// Every setting must be reachable in the settings screen, in a category, with an editor that fits it.
|
|
104
|
+
const userSchema = new SchemaNode(Config);
|
|
105
|
+
const projectSchema = new SchemaNode(LocalOverrides);
|
|
106
|
+
const userLeaves = userSchema.leaves();
|
|
107
|
+
const projectLeaves = projectSchema.leaves();
|
|
107
108
|
t.check(userLeaves.length > 100 && projectLeaves.length > 50, 'settings screen: every setting is a leaf (' + userLeaves.length + ' settings, ' + projectLeaves.length + ' in a project file)');
|
|
108
|
-
const
|
|
109
|
-
t.equal(
|
|
110
|
-
|
|
111
|
-
t.
|
|
112
|
-
|
|
113
|
-
t.
|
|
114
|
-
const
|
|
115
|
-
t.check(
|
|
116
|
-
t.
|
|
109
|
+
const uncategorised = [...userSchema.children(), ...projectSchema.children()].map(([name]) => name).filter((name) => !CATEGORIES.some((c) => c.keys.includes(name)));
|
|
110
|
+
t.equal(uncategorised, [], 'settings screen: every top-level setting belongs to a category');
|
|
111
|
+
const kinds = new Set(userLeaves.map((l) => l.node.editor));
|
|
112
|
+
t.equal(['boolean', 'choice', 'union', 'number', 'string', 'string-list', 'choice-list', 'object-list', 'string-map', 'object-map'].filter((k) => !kinds.has(k)), [], 'settings screen: every kind of editor is used');
|
|
113
|
+
t.equal(userLeaves.filter((l) => l.node.editor === 'json').map((l) => l.key.join('.')), [], 'settings screen: no setting falls back to typing JSON');
|
|
114
|
+
t.check(userLeaves.some((l) => l.key.join('.') === 'downloads.requireChecksum') && projectLeaves.some((l) => l.key.join('.') === 'downloads.requireChecksum'), 'settings screen: downloads.requireChecksum is reachable in both files');
|
|
115
|
+
const harness = userSchema.child('harnesses').element;
|
|
116
|
+
t.includes(String(harness.check(harness.initial())), 'npmPackage', 'settings screen: a new agent says what it still needs (a package to install)');
|
|
117
|
+
t.equal(harness.check({ ...harness.initial(), npmPackage: 'my-cli', npmBin: 'my-cli' }), true, '... and is valid once it has one');
|
|
118
|
+
const mapping = userSchema.child('mappings').element;
|
|
119
|
+
t.check(mapping.check(mapping.initial()) !== true, 'settings screen: a new mapped folder needs its folders filled in before it is valid');
|
|
120
|
+
t.equal([Text.humanize('memoryMb'), Text.humanize('vGpu'), nameOf('node')], ['Memory MB', 'V GPU', 'Node.js'], 'settings screen: readable names');
|
|
121
|
+
t.section('screens');
|
|
122
|
+
const fit = Text.fit('\x1b[31mred text\x1b[0m', 5);
|
|
123
|
+
t.equal([Text.width(fit), Text.strip(fit)], [5, 'red …'], 'a coloured label is cut to the width without breaking its colour');
|
|
124
|
+
t.equal(Text.width('日本'), 4, 'wide characters take two columns');
|
|
125
|
+
const screen = new ListScreen({ path: ['T'], items: [{ value: 'a', label: 'Alpha', category: 'x' }, { value: 'b', label: 'Beta', category: 'y', description: 'second' }], categories: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }] });
|
|
126
|
+
const key = (name, text = '') => ({ name, text, ctrl: false, meta: false, shift: false });
|
|
127
|
+
screen.handle(key('left'));
|
|
128
|
+
screen.handle(key('down'));
|
|
129
|
+
screen.handle(key('right'));
|
|
130
|
+
t.equal(screen.handle(key('return')), { done: { kind: 'pick', value: 'b' } }, 'list screen: arrows switch category, Enter picks');
|
|
131
|
+
const search = new ListScreen({ path: ['T'], items: [{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }] });
|
|
132
|
+
for (const k of [key('', '/'), key('b', 'b'), key('e', 'e')])
|
|
133
|
+
search.handle(k);
|
|
134
|
+
t.equal(search.handle(key('return')), { done: { kind: 'pick', value: 'b' } }, 'list screen: / searches');
|
|
135
|
+
const lines = Frame.render(screen.render({ width: 60, height: Frame.bodyHeight(20) }), 60, 20);
|
|
136
|
+
t.equal(lines.length, 20, 'a frame fills the terminal height exactly');
|
|
137
|
+
const multi = new ListScreen({ path: ['T'], multi: true, items: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B', checked: true }] });
|
|
138
|
+
multi.handle(key('space'));
|
|
139
|
+
t.equal(multi.handle(key('return')), { done: { kind: 'multi', values: ['a', 'b'] } }, 'list screen: space ticks several');
|
|
140
|
+
const typed = new TextScreen({ path: ['T'], label: 'Port', validate: (v) => (Number(v) > 0 ? true : 'a number') });
|
|
141
|
+
for (const ch of 'x')
|
|
142
|
+
typed.handle(key(ch, ch));
|
|
143
|
+
t.equal(typed.handle(key('return')), undefined, 'text field: an invalid value is not accepted');
|
|
144
|
+
typed.handle(key('backspace'));
|
|
145
|
+
typed.handle(key('8', '8'));
|
|
146
|
+
t.equal(typed.handle(key('return')), { done: '8' }, 'text field: a valid value is');
|
|
117
147
|
},
|
|
118
148
|
};
|
|
@@ -31,10 +31,22 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
31
31
|
defaultHarness: string;
|
|
32
32
|
guestWorkspace: string;
|
|
33
33
|
readOnly: boolean;
|
|
34
|
+
workspaceFilter: {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
exclude: string[];
|
|
37
|
+
include: string[];
|
|
38
|
+
};
|
|
39
|
+
audit: {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
days: number;
|
|
42
|
+
maxMb: number;
|
|
43
|
+
processes: boolean;
|
|
44
|
+
files: boolean;
|
|
45
|
+
};
|
|
34
46
|
mappings: {
|
|
35
47
|
host: string;
|
|
36
|
-
guest: string;
|
|
37
48
|
readOnly: boolean;
|
|
49
|
+
guest?: string | undefined;
|
|
38
50
|
optional?: boolean | undefined;
|
|
39
51
|
}[];
|
|
40
52
|
sandbox: {
|
|
@@ -42,6 +54,7 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
42
54
|
maxMemoryMb: number;
|
|
43
55
|
maxMemoryPercent: number;
|
|
44
56
|
cpuLimit: number | "none";
|
|
57
|
+
pageFileMb: number;
|
|
45
58
|
reserveHostMb: number;
|
|
46
59
|
vGpu: boolean;
|
|
47
60
|
networking: boolean;
|
|
@@ -51,14 +64,25 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
51
64
|
printerRedirection: boolean;
|
|
52
65
|
protectedClient: boolean;
|
|
53
66
|
headless: boolean;
|
|
67
|
+
keepAwake: boolean;
|
|
54
68
|
};
|
|
55
69
|
storage: {
|
|
56
70
|
root: string;
|
|
57
71
|
persistAgentState: boolean;
|
|
58
72
|
dataName: string;
|
|
73
|
+
markSandboxFiles: boolean;
|
|
74
|
+
warnDataGb: number;
|
|
59
75
|
};
|
|
60
76
|
network: {
|
|
61
77
|
blockPrivateRanges: boolean;
|
|
78
|
+
allowOnly: string[];
|
|
79
|
+
blockHosts: string[];
|
|
80
|
+
forwardPorts: {
|
|
81
|
+
pc: number;
|
|
82
|
+
sandbox: number;
|
|
83
|
+
}[];
|
|
84
|
+
dns: string[];
|
|
85
|
+
proxy: string;
|
|
62
86
|
};
|
|
63
87
|
provisioning: {
|
|
64
88
|
copyFromHost: boolean;
|
|
@@ -71,6 +95,9 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
71
95
|
terminal: {
|
|
72
96
|
enabled: boolean;
|
|
73
97
|
};
|
|
98
|
+
conpty: {
|
|
99
|
+
enabled: boolean;
|
|
100
|
+
};
|
|
74
101
|
};
|
|
75
102
|
downloads: {
|
|
76
103
|
requireChecksum: boolean;
|
|
@@ -164,6 +191,9 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
164
191
|
};
|
|
165
192
|
vscode: {
|
|
166
193
|
enabled: boolean;
|
|
194
|
+
setup: boolean;
|
|
195
|
+
recommended: boolean;
|
|
196
|
+
extensions: string[];
|
|
167
197
|
};
|
|
168
198
|
environment: Record<string, string>;
|
|
169
199
|
};
|
|
@@ -216,6 +246,19 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
216
246
|
personalization: {
|
|
217
247
|
enabled: boolean;
|
|
218
248
|
};
|
|
249
|
+
apps: {
|
|
250
|
+
install: string[];
|
|
251
|
+
custom: Record<string, {
|
|
252
|
+
name: string;
|
|
253
|
+
exe: string;
|
|
254
|
+
github?: string | undefined;
|
|
255
|
+
asset?: string | undefined;
|
|
256
|
+
url?: string | undefined;
|
|
257
|
+
sha256?: string | undefined;
|
|
258
|
+
unpack?: "file" | "archive" | "nsis" | "msi" | undefined;
|
|
259
|
+
cli?: boolean | undefined;
|
|
260
|
+
}>;
|
|
261
|
+
};
|
|
219
262
|
claudeProfile: {
|
|
220
263
|
enabled: boolean;
|
|
221
264
|
hostDir: string;
|
|
@@ -224,7 +267,9 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
224
267
|
copyCredentials: boolean;
|
|
225
268
|
overwrite: boolean;
|
|
226
269
|
syncHistory: boolean;
|
|
270
|
+
historyScope: "project" | "all";
|
|
227
271
|
syncClaudeMem: boolean;
|
|
272
|
+
syncClaudeMemBack: boolean;
|
|
228
273
|
};
|
|
229
274
|
harnesses: Record<string, {
|
|
230
275
|
arguments: string[];
|
|
@@ -246,6 +291,7 @@ export declare const createFixtures: (t: Harness) => {
|
|
|
246
291
|
roots: string[];
|
|
247
292
|
askToAddRoot: boolean;
|
|
248
293
|
declinedRoots: string[];
|
|
294
|
+
allowOutsideMappings: boolean | "ask";
|
|
249
295
|
wsbShare: boolean;
|
|
250
296
|
};
|
|
251
297
|
$schema?: string | undefined;
|
|
@@ -62,7 +62,9 @@ export const createFixtures = (t) => {
|
|
|
62
62
|
const probe = { nodeDir: hostNode, harnessExe: { dir: hostClaude, file: 'claude.exe' }, gitDir: null, nvmDir: null, hostNodeVersion: HOST_NODE_VERSION };
|
|
63
63
|
/** Resolves a run for `workA` (or whatever `over` says). */
|
|
64
64
|
const run = (over = {}, config = baseConfig) => resolveRun({ packageRoot: install, loaded: { config, configPath: null, base: t.temp }, cwd: workA, noAgent: false, noLocalConfig: false, trustConfig: false, scripts: [], ...over }, HOST_NODE_VERSION);
|
|
65
|
-
const generate = (r, args = [], p = probe) =>
|
|
65
|
+
const generate = (r, args = [], p = probe) =>
|
|
66
|
+
// A fixed "this PC has" list, so "auto" toolchains decide the same on every machine the tests run on.
|
|
67
|
+
generateSandbox({ packageRoot: t.packageRoot, run: r, harnessArgs: args, probe: p, pcLanguages: ['python', 'rust', 'cpp', 'dotnet'] });
|
|
66
68
|
/** Resolves a run and generates its sandbox (.wsb + launch folder + session.json) in one step. */
|
|
67
69
|
const launch = async (over = {}, options = {}) => generate(await run(over, options.config ?? baseConfig), options.args, options.probe);
|
|
68
70
|
/** Writes a project's `.sandbox-config.json` (objects are serialised; strings are written as they are). */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { LocalOverrides } from '../core/schema.js';
|
|
6
|
+
import { logonEnv } from '../host/generate.js';
|
|
6
7
|
import { gitIdentity } from '../host/launch/stage.js';
|
|
7
8
|
import { buildWsb, resolveMemoryMb } from '../host/launch/wsb.js';
|
|
8
9
|
export const generateSuite = {
|
|
@@ -21,14 +22,17 @@ export const generateSuite = {
|
|
|
21
22
|
t.includes(wsb, '<vGPU>Disable</vGPU>', 'wsb: vGPU is off');
|
|
22
23
|
t.includes(buildWsb(f.baseConfig, 4096, [{ host: 'C:\\a&b', guest: 'C:\\X', readOnly: true }]), 'C:\\a&b', 'wsb values are XML-escaped');
|
|
23
24
|
t.section('the launch folder');
|
|
24
|
-
const expectedFiles = ['package.json', 'runtime/main.js', 'runtime/daemon.js', 'runtime/view.js', 'runtime/gitguard.js', 'sandbox/bootstrap.ps1', 'sandbox/logon.cmd', 'sandbox/trust.cmd', 'sandbox/ptyhost.cs', 'sandbox/gitshim.cs'];
|
|
25
|
+
const expectedFiles = ['package.json', 'runtime/main.js', 'runtime/daemon.js', 'runtime/view.js', 'runtime/gitguard.js', 'sandbox/bootstrap.ps1', 'sandbox/logon.cmd', 'sandbox/trust.cmd', 'sandbox/ptyhost.cs', 'sandbox/gitshim.cs', 'sandbox/cpucap.ps1', 'sandbox/cpucap.cs', 'sandbox/pagefile.ps1', 'sandbox/pagefile.cs', 'sandbox/memlog.ps1'];
|
|
25
26
|
const missing = expectedFiles.filter((file) => !fs.existsSync(path.join(gen.launchPath, file)));
|
|
26
27
|
t.equal(missing, [], 'the launch folder has the runtime, the C# sources and the boot scripts');
|
|
27
|
-
t.equal(fs.readdirSync(path.join(gen.launchPath, 'runtime')).length,
|
|
28
|
+
t.equal(fs.readdirSync(path.join(gen.launchPath, 'runtime')).length, 6, 'the runtime is 6 self-contained files');
|
|
28
29
|
const logon = fs.readFileSync(path.join(gen.launchPath, 'sandbox', 'logon.cmd'), 'utf8');
|
|
29
30
|
t.check(logon.indexOf('trust.cmd') < logon.indexOf('wt.exe'), 'logon.cmd applies the sandbox trust settings before opening Windows Terminal');
|
|
30
31
|
t.includes(logon, 'tools\\terminal\\wt.exe', 'logon.cmd opens Windows Terminal from the tools folder');
|
|
31
32
|
t.includes(logon, '-NoExit', 'logon.cmd falls back to a PowerShell window');
|
|
33
|
+
t.check(logon.indexOf('env.cmd') > 0 && logon.indexOf('env.cmd') < logon.indexOf('wt.exe'), 'logon.cmd sets AGENT_WORKSPACE (env.cmd) before Windows Terminal starts, so new tabs can open in it');
|
|
34
|
+
t.includes(fs.readFileSync(path.join(gen.launchPath, 'env.cmd'), 'utf8'), 'AGENT_WORKSPACE=', 'env.cmd names the workspace');
|
|
35
|
+
t.equal(logonEnv('C:\\Workspace\\100%\\x'), '@set "AGENT_WORKSPACE=C:\\Workspace\\100%%\\x"\r\n', 'a % in the workspace path is escaped for cmd');
|
|
32
36
|
t.section('session.json');
|
|
33
37
|
const session = f.readJson(gen.sessionPath);
|
|
34
38
|
t.equal({
|
|
@@ -43,6 +47,7 @@ export const generateSuite = {
|
|
|
43
47
|
}, { launchId: true, workspace: 'C:\\Workspace\\work-a', workspaceHost: f.workA, firstArgument: '--version', gitFromHost: true, nvmFromHost: true, nodeSpec: '22.3.0', gitAccess: 'full' }, 'session.json carries the id, paths, agent arguments, host tools, versions and git access');
|
|
44
48
|
t.matches(session.hostTools.nodeVersion ?? '', /^\d+\.\d+\.\d+$/, 'session.json: the host Node version is a plain version');
|
|
45
49
|
t.equal(session.cpuLimitPercent, 80, 'session.json carries the CPU cap for cpucap.ps1 (default 80 %)');
|
|
50
|
+
t.equal(session.pageFileMb, 16384, 'session.json carries the paging file limit for pagefile.ps1 (default 16 GB)');
|
|
46
51
|
t.check(!LocalOverrides.safeParse({ sandbox: { cpuLimit: 'none' } }).success, "a project's .sandbox-config.json cannot lift your CPU cap (sandbox.cpuLimit is yours only)");
|
|
47
52
|
t.section('what is copied from this PC');
|
|
48
53
|
const staged = path.join(gen.launchPath, 'claude-profile');
|
package/dist/selftest/index.js
CHANGED
|
@@ -40,6 +40,10 @@ import { syncSuite } from './sync.test.js';
|
|
|
40
40
|
import { testGuardSuite } from './test-guard.test.js';
|
|
41
41
|
import { toolchainsSuite } from './toolchains.test.js';
|
|
42
42
|
import { workspaceSuite } from './workspace.test.js';
|
|
43
|
+
import { workspaceViewSuite } from './workspace-view.test.js';
|
|
44
|
+
import { liveNetworkSuite } from './live-network.test.js';
|
|
45
|
+
import { liveTerminalSuite } from './live-terminal.test.js';
|
|
46
|
+
import { auditSuite } from './audit.test.js';
|
|
43
47
|
/** Every suite, in the order they run. The live ones come last: they are the slowest and need Windows. */
|
|
44
48
|
export const SUITES = [
|
|
45
49
|
// settings and project files
|
|
@@ -47,7 +51,7 @@ export const SUITES = [
|
|
|
47
51
|
// the generated sandbox and the guest that boots it
|
|
48
52
|
generateSuite, guestBootSuite, mirrorSuite, projectsSuite,
|
|
49
53
|
// syncing with this PC
|
|
50
|
-
syncSuite, accountsSuite,
|
|
54
|
+
syncSuite, accountsSuite, workspaceViewSuite, auditSuite,
|
|
51
55
|
// an untrusted sandbox: what the host refuses to do with what it finds
|
|
52
56
|
linksSuite, channelSuite, gitAccessSuite, repoGuardSuite, secretsSuite, hostileInputSuite, testGuardSuite,
|
|
53
57
|
// pure helpers and the command line
|
|
@@ -55,7 +59,7 @@ export const SUITES = [
|
|
|
55
59
|
// every command as its own process
|
|
56
60
|
hostSimSuite,
|
|
57
61
|
// real processes
|
|
58
|
-
liveDaemonSuite, liveProjectsSuite, liveGitSuite,
|
|
62
|
+
liveDaemonSuite, liveProjectsSuite, liveGitSuite, liveNetworkSuite, liveTerminalSuite,
|
|
59
63
|
];
|
|
60
64
|
/** Windows Sandbox (its user is always WDAGUtilityAccount) or a GitHub Actions runner: never someone's PC. */
|
|
61
65
|
export const onThrowawayMachine = () => os.userInfo().username.toLowerCase() === 'wdagutilityaccount' || process.env.GITHUB_ACTIONS === 'true';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Live: the sandbox's allowOnly proxy (the real bundled runtime\netproxy.js) and the PC-side port forwarder, on
|
|
2
|
+
// loopback only; and that the cargo shim compiles with the compiler that ships with Windows.
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { startPortForwards } from '../host/port-forward.js';
|
|
7
|
+
import { PROXY_PORT, relayPort } from '../shared/netpolicy.js';
|
|
8
|
+
import { CSC } from './live.js';
|
|
9
|
+
/** A TCP server that answers every line with "echo:<line>". */
|
|
10
|
+
const echoServer = (port = 0) => new Promise((resolve) => {
|
|
11
|
+
const server = net.createServer((s) => s.on('data', (d) => s.write('echo:' + d.toString())));
|
|
12
|
+
server.listen(port, '127.0.0.1', () => resolve(server));
|
|
13
|
+
});
|
|
14
|
+
const portOf = (server) => server.address().port;
|
|
15
|
+
/** Sends `text` after `preamble` (a CONNECT) and resolves with everything received within a short while. */
|
|
16
|
+
const talk = (port, preamble, text) => new Promise((resolve) => {
|
|
17
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
18
|
+
let got = '';
|
|
19
|
+
socket.on('data', (d) => { got += d.toString(); if (preamble && got.includes('\r\n\r\n') && got.startsWith('HTTP/1.1 200') && !got.includes('echo:'))
|
|
20
|
+
socket.write(text); });
|
|
21
|
+
socket.on('error', () => resolve(got));
|
|
22
|
+
socket.on('connect', () => socket.write(preamble || text));
|
|
23
|
+
setTimeout(() => { socket.destroy(); resolve(got); }, 1500);
|
|
24
|
+
});
|
|
25
|
+
export const liveNetworkSuite = {
|
|
26
|
+
name: 'live-network',
|
|
27
|
+
title: 'live: allowOnly proxy, port forwarding, cargo shim',
|
|
28
|
+
live: true,
|
|
29
|
+
run: async (t, f) => {
|
|
30
|
+
t.section('allowOnly proxy');
|
|
31
|
+
const gen = await f.launch({}, { config: { ...f.baseConfig, network: { ...f.baseConfig.network, allowOnly: ['localhost'] } } });
|
|
32
|
+
const echo = await echoServer();
|
|
33
|
+
const proxy = spawn(process.execPath, [path.join(gen.launchPath, 'runtime', 'netproxy.js')], { env: { ...process.env, AGENT_LAUNCH_DIR: gen.launchPath, AGENT_DATA_DIR: t.dir('netproxy-data') }, stdio: 'ignore' });
|
|
34
|
+
try {
|
|
35
|
+
const up = await t.waitFor(() => new Promise((resolve) => { const s = net.connect(PROXY_PORT, '127.0.0.1', () => { s.destroy(); resolve(true); }); s.on('error', () => resolve(false)); }), 10000, 200);
|
|
36
|
+
t.check(Boolean(up), 'the proxy listens on localhost');
|
|
37
|
+
const allowed = await talk(PROXY_PORT, 'CONNECT localhost:' + portOf(echo) + ' HTTP/1.1\r\nHost: localhost\r\n\r\n', 'hi');
|
|
38
|
+
t.check(allowed.startsWith('HTTP/1.1 200') && allowed.includes('echo:hi'), 'an allowed host is tunnelled through', allowed);
|
|
39
|
+
const blocked = await talk(PROXY_PORT, 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com\r\n\r\n', '');
|
|
40
|
+
t.check(blocked.startsWith('HTTP/1.1 403'), 'any other host is refused (403)', blocked);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
proxy.kill();
|
|
44
|
+
echo.close();
|
|
45
|
+
}
|
|
46
|
+
t.section('port forwarding');
|
|
47
|
+
const relay = await echoServer(relayPort(0));
|
|
48
|
+
const free = await echoServer();
|
|
49
|
+
const pcPort = portOf(free);
|
|
50
|
+
free.close();
|
|
51
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
52
|
+
const stop = startPortForwards([{ pc: pcPort, sandbox: 3000 }], () => '127.0.0.1');
|
|
53
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
54
|
+
const forwarded = await talk(pcPort, '', 'ping');
|
|
55
|
+
stop();
|
|
56
|
+
relay.close();
|
|
57
|
+
t.equal(forwarded, 'echo:ping', 'localhost:<pc> on this PC reaches the sandbox\'s relay');
|
|
58
|
+
t.section('cargo shim');
|
|
59
|
+
const out = path.join(t.dir('cargoshim'), 'cargo.exe');
|
|
60
|
+
const compiled = spawnSync(CSC, ['/nologo', '/optimize', '/out:' + out, path.join(gen.launchPath, 'sandbox', 'cargoshim.cs')], { encoding: 'utf8' });
|
|
61
|
+
t.check(compiled.status === 0, 'cargoshim.cs compiles with the .NET Framework compiler (C# 5)', compiled.stdout + compiled.stderr);
|
|
62
|
+
},
|
|
63
|
+
};
|