windows-development-sandbox 1.12.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +1 -0
  2. package/config.example.json +11 -1
  3. package/dist/app/features/access/access.def.d.ts +190 -0
  4. package/dist/app/features/access/access.def.js +273 -0
  5. package/dist/app/features/access/access.run.d.ts +79 -0
  6. package/dist/app/features/access/access.run.js +129 -0
  7. package/dist/app/features/access/index.d.ts +2 -0
  8. package/dist/app/features/access/index.js +3 -0
  9. package/dist/app/features/accounts/accounts.def.d.ts +1 -0
  10. package/dist/app/features/accounts/accounts.def.js +12 -3
  11. package/dist/app/features/accounts/accounts.run.d.ts +1 -0
  12. package/dist/app/features/accounts/accounts.run.js +20 -11
  13. package/dist/app/features/agents/agent-entry.js +5 -0
  14. package/dist/app/features/agents/agents.def.js +1 -0
  15. package/dist/app/features/apps/apps.def.js +1 -0
  16. package/dist/app/features/audit/audit.def.d.ts +11 -2
  17. package/dist/app/features/audit/audit.def.js +7 -4
  18. package/dist/app/features/audit/audit.run.d.ts +3 -0
  19. package/dist/app/features/defs.d.ts +156 -3
  20. package/dist/app/features/defs.js +12 -1
  21. package/dist/app/features/files/files.def.js +1 -0
  22. package/dist/app/features/health/health.def.js +1 -0
  23. package/dist/app/features/sandbox/sandbox.def.js +3 -0
  24. package/dist/app/features/sessions/sessions.def.d.ts +36 -0
  25. package/dist/app/features/sessions/sessions.def.js +77 -9
  26. package/dist/app/features/sessions/sessions.run.d.ts +25 -2
  27. package/dist/app/features/sessions/sessions.run.js +71 -3
  28. package/dist/app/features/settings/settings-file.d.ts +7 -0
  29. package/dist/app/features/settings/settings-file.js +19 -2
  30. package/dist/app/features/settings/settings-rows.js +9 -1
  31. package/dist/app/features/settings/settings.def.d.ts +8 -0
  32. package/dist/app/features/settings/settings.def.js +17 -0
  33. package/dist/app/features/web/web.def.d.ts +9 -0
  34. package/dist/app/features/web/web.def.js +26 -1
  35. package/dist/app/features/web/web.run.d.ts +3 -0
  36. package/dist/app/features/web/web.run.js +11 -3
  37. package/dist/app/kernel/access.d.ts +33 -0
  38. package/dist/app/kernel/access.js +44 -0
  39. package/dist/app/kernel/define.d.ts +14 -1
  40. package/dist/app/kernel/index.d.ts +1 -0
  41. package/dist/app/kernel/index.js +1 -0
  42. package/dist/app/kernel/rpc.d.ts +5 -2
  43. package/dist/app/registry.d.ts +4 -1
  44. package/dist/app/registry.js +24 -4
  45. package/dist/bin/agent-sandbox.js +7 -1
  46. package/dist/bin/chunk-IL7BXJF2.js +7 -0
  47. package/dist/bin/chunk-K4SU3MHJ.js +10432 -0
  48. package/dist/bin/chunk-VENWSGX2.js +78437 -0
  49. package/dist/bin/claude-host.js +10 -4
  50. package/dist/bin/claude-sbx.js +8 -2
  51. package/dist/bin/codex-sbx.js +8 -2
  52. package/dist/bin/gemini-sbx.js +8 -2
  53. package/dist/bin/sandbox.js +7 -2
  54. package/dist/bin/sbx-hide.js +8 -2
  55. package/dist/bin/sbx-show.js +8 -2
  56. package/dist/cli/commands/launch/prepare.js +24 -7
  57. package/dist/cli/commands/launch/running.js +4 -0
  58. package/dist/cli/commands/registry.js +2 -0
  59. package/dist/cli/commands/remote.d.ts +2 -0
  60. package/dist/cli/commands/remote.js +49 -0
  61. package/dist/cli/commands/sessions/reopen.js +3 -0
  62. package/dist/cli/commands/storage/offer.js +1 -1
  63. package/dist/cli/commands/web.js +66 -0
  64. package/dist/cli/manifest.d.ts +63 -4
  65. package/dist/cli/manifest.js +33 -4
  66. package/dist/cli/menu/io-menu.d.ts +5 -3
  67. package/dist/cli/menu/io-menu.js +12 -5
  68. package/dist/cli/menu/live.d.ts +29 -0
  69. package/dist/cli/menu/live.js +92 -21
  70. package/dist/cli/menu/menu.d.ts +2 -1
  71. package/dist/cli/menu/menu.js +16 -2
  72. package/dist/cli/menu/pages/list-page.d.ts +5 -0
  73. package/dist/cli/menu/pages/list-page.js +19 -0
  74. package/dist/cli/menu/remote-menu.d.ts +19 -0
  75. package/dist/cli/menu/remote-menu.js +108 -0
  76. package/dist/cli/menu/shell.d.ts +2 -0
  77. package/dist/cli/menu/shell.js +1 -1
  78. package/dist/cli/menu/views.js +2 -0
  79. package/dist/cli/operations/handler.js +15 -1
  80. package/dist/cli/services/accounts-service.d.ts +7 -0
  81. package/dist/cli/services/accounts-service.js +20 -0
  82. package/dist/cli/services/remote-client.d.ts +16 -0
  83. package/dist/cli/services/remote-client.js +141 -0
  84. package/dist/cli/services/sessions-service.d.ts +5 -0
  85. package/dist/cli/services/sessions-service.js +17 -1
  86. package/dist/cli/tui/app.d.ts +4 -0
  87. package/dist/cli/tui/app.js +5 -0
  88. package/dist/cli/tui/terminal.d.ts +6 -0
  89. package/dist/cli/tui/terminal.js +8 -1
  90. package/dist/cli/validate.js +8 -4
  91. package/dist/core/config/defaults.js +11 -1
  92. package/dist/core/local-config/file.js +16 -2
  93. package/dist/core/local-config/overrides.js +7 -0
  94. package/dist/core/package-info.js +1 -1
  95. package/dist/core/run/versions.js +9 -8
  96. package/dist/core/schema/config.d.ts +22 -3
  97. package/dist/core/schema/config.js +39 -2
  98. package/dist/core/schema/web.d.ts +51 -0
  99. package/dist/core/schema/web.js +46 -1
  100. package/dist/core/util/fs.d.ts +5 -0
  101. package/dist/core/util/fs.js +23 -0
  102. package/dist/core/util/index.d.ts +1 -1
  103. package/dist/core/util/index.js +1 -1
  104. package/dist/guest/daemon/daemon.js +2 -0
  105. package/dist/guest/daemon/idle-shutdown.js +2 -1
  106. package/dist/guest/daemon/sessions.d.ts +16 -2
  107. package/dist/guest/daemon/sessions.js +126 -20
  108. package/dist/guest/git/decision.js +7 -0
  109. package/dist/guest/sessions/cmdline.d.ts +14 -0
  110. package/dist/guest/sessions/cmdline.js +10 -0
  111. package/dist/guest/toolchains/setup.js +3 -0
  112. package/dist/host/audit/log.d.ts +3 -2
  113. package/dist/host/audit/log.js +3 -3
  114. package/dist/host/channel/channel.d.ts +5 -1
  115. package/dist/host/channel/channel.js +12 -0
  116. package/dist/host/guard/agent-files.js +7 -1
  117. package/dist/host/guard/commands.js +13 -1
  118. package/dist/host/guard/git-config.js +27 -7
  119. package/dist/host/guard/git-dirs.js +78 -3
  120. package/dist/host/guard/mark-of-the-web.js +3 -1
  121. package/dist/host/guard/nested-repos.js +7 -2
  122. package/dist/host/guard/store.js +1 -0
  123. package/dist/host/guard/types.d.ts +2 -0
  124. package/dist/host/launch/mappings.js +11 -1
  125. package/dist/host/launch/project-scan.js +2 -21
  126. package/dist/host/launch/stage.js +4 -0
  127. package/dist/host/life/session-ledger.js +2 -1
  128. package/dist/host/remote/access-store.d.ts +156 -0
  129. package/dist/host/remote/access-store.js +308 -0
  130. package/dist/host/remote/cert.d.ts +20 -0
  131. package/dist/host/remote/cert.js +103 -0
  132. package/dist/host/remote/door-client.d.ts +13 -0
  133. package/dist/host/remote/door-client.js +54 -0
  134. package/dist/host/remote/firewall.d.ts +44 -0
  135. package/dist/host/remote/firewall.js +118 -0
  136. package/dist/host/remote/gate.d.ts +125 -0
  137. package/dist/host/remote/gate.js +391 -0
  138. package/dist/host/remote/index.d.ts +8 -0
  139. package/dist/host/remote/index.js +10 -0
  140. package/dist/host/remote/lan.d.ts +24 -0
  141. package/dist/host/remote/lan.js +88 -0
  142. package/dist/host/remote/tls-store.d.ts +22 -0
  143. package/dist/host/remote/tls-store.js +63 -0
  144. package/dist/host/remote/windows-helper.d.ts +67 -0
  145. package/dist/host/remote/windows-helper.js +129 -0
  146. package/dist/host/sandbox/platform.d.ts +2 -0
  147. package/dist/host/sandbox/platform.js +19 -4
  148. package/dist/host/storage/contained/state.d.ts +3 -2
  149. package/dist/host/storage/contained/state.js +5 -2
  150. package/dist/host/web/bridge.d.ts +5 -1
  151. package/dist/host/web/bridge.js +6 -2
  152. package/dist/host/web/browser.js +1 -1
  153. package/dist/host/web/control.d.ts +4 -1
  154. package/dist/host/web/control.js +28 -4
  155. package/dist/host/web/rpc.d.ts +16 -2
  156. package/dist/host/web/rpc.js +37 -4
  157. package/dist/host/web/runner.d.ts +4 -0
  158. package/dist/host/web/runner.js +12 -0
  159. package/dist/host/web/server.d.ts +18 -2
  160. package/dist/host/web/server.js +97 -24
  161. package/dist/host/web/state.d.ts +5 -0
  162. package/dist/host/web/state.js +8 -0
  163. package/dist/runtime/agent.js +74 -3
  164. package/dist/runtime/daemon.js +205 -24
  165. package/dist/runtime/db.js +74 -3
  166. package/dist/runtime/gitguard.js +6 -0
  167. package/dist/runtime/main.js +77 -3
  168. package/dist/runtime/netproxy.js +74 -3
  169. package/dist/runtime/view.js +76 -3
  170. package/dist/selftest/app.test.js +41 -6
  171. package/dist/selftest/channel.test.js +16 -0
  172. package/dist/selftest/cmdline.test.js +16 -0
  173. package/dist/selftest/git-access.test.js +8 -0
  174. package/dist/selftest/hostile-input.test.js +12 -1
  175. package/dist/selftest/index.js +2 -0
  176. package/dist/selftest/layout.test.js +10 -1
  177. package/dist/selftest/live-daemon.test.js +25 -0
  178. package/dist/selftest/local-config.test.js +30 -0
  179. package/dist/selftest/menu-perf.test.js +198 -12
  180. package/dist/selftest/menu.test.js +4 -0
  181. package/dist/selftest/remote.test.d.ts +2 -0
  182. package/dist/selftest/remote.test.js +536 -0
  183. package/dist/selftest/repo-guard.test.js +56 -2
  184. package/dist/selftest/resume.test.js +2 -1
  185. package/dist/selftest/sharing.test.js +9 -2
  186. package/dist/selftest/support/fixtures.d.ts +13 -0
  187. package/dist/selftest/support/web-preview.js +63 -0
  188. package/dist/selftest/web.test.js +2 -0
  189. package/dist/shared/channel/schema.d.ts +23 -0
  190. package/dist/shared/channel/schema.js +8 -0
  191. package/dist/shared/pty/protocol.d.ts +2 -0
  192. package/dist/shared/pty/protocol.js +2 -0
  193. package/dist/web/index.html +1 -1
  194. package/dist/web/main-NBQ3LF4E.js +221 -0
  195. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  196. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  197. package/native/csharp/HostTools/Program.cs +17 -1
  198. package/native/csharp/HostTools/WebHelper.cs +499 -0
  199. package/native/scripts/get-network-profile.ps1 +19 -0
  200. package/native/scripts/set-folder-access.ps1 +11 -0
  201. package/native/scripts/set-web-firewall.ps1 +103 -0
  202. package/package.json +1 -1
  203. package/sandbox/csharp/PtyHost.cs +20 -0
  204. package/schemas/config.schema.json +65 -0
  205. package/schemas/sandbox-config.schema.json +0 -18
  206. package/dist/web/main-UD4QCXZR.js +0 -219
@@ -1,6 +1,7 @@
1
1
  // The architecture rules, checked on the compiled output (dist/**/*.js): which areas may import which, the guest's
2
2
  // portable core, the pure guest files the host and tests may load without the guest runtime's side effects, and no
3
- // import cycles. The bundled dist/runtime is left out (it has no imports).
3
+ // import cycles. The bundled dist/runtime is left out (it has no imports); the bundled commands (dist/bin) must import
4
+ // only their own chunks.
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  const STATIC = /^\s*(?:import|export)\b[^;]*?\bfrom\s*(['"])([^'"\n]+)\1;?\s*$|^\s*import\s*(['"])([^'"\n]+)\3;?\s*$/gm;
@@ -149,5 +150,13 @@ export const layoutSuite = {
149
150
  .filter((d) => area(d) === 'app' && !(area(f) === 'host' && d.startsWith('app/kernel/')))
150
151
  .map((d) => f + ' -> ' + d));
151
152
  t.check(!intoApp.length, 'below the CLI only host code uses the shared core, and only its kernel', list(intoApp));
153
+ t.section('the commands');
154
+ // Loading the CLI as hundreds of modules took most of a second before anything was shown (scripts/bundle.mjs).
155
+ const bins = files.filter((f) => area(f) === 'bin');
156
+ const unbundled = bins.flatMap((f) => modules
157
+ .get(f)
158
+ .statics.filter((d) => area(d) !== 'bin')
159
+ .map((d) => f + ' -> ' + d));
160
+ t.check(bins.includes('bin/sandbox.js') && bins.some((f) => f.startsWith('bin/chunk-')) && !unbundled.length, 'the commands (dist/bin) are bundled: they load no compiled module one by one', list(unbundled));
152
161
  },
153
162
  };
@@ -19,6 +19,8 @@ export const liveDaemonSuite = {
19
19
  Object.assign(session.harness, {
20
20
  arguments: ['/d', '/k', 'echo LIVE-AGENT-READY'],
21
21
  resumeArguments: ['/d', '/k', 'echo LIVE-AGENT-RESUMED'],
22
+ // The agent's own environment (where its keys live): a shell session must not get it.
23
+ environment: { LIVE_AGENT_KEY: 'sk-live-agent' },
22
24
  });
23
25
  t.writeJson(gen.sessionPath, session);
24
26
  const daemon = startDaemon(t, f, { launchPath: gen.launchPath, data, workspace });
@@ -82,6 +84,29 @@ export const liveDaemonSuite = {
82
84
  t.check(Boolean(ping?.ok) &&
83
85
  !fs.existsSync(path.join(daemon.run, 'responses', 'evil.json')) &&
84
86
  !fs.existsSync(path.join(daemon.run, 'requests', 'evil.json')), 'ping is answered; an unknown request kind gets no answer and is deleted', JSON.stringify(ping));
87
+ t.section('live: a plain shell session');
88
+ daemon.request({ kind: 'session', id: 'shell1', harness: 'shell', args: [], cwd: workspace, shell: 'cmd' });
89
+ const shell = await daemon.started('shell1');
90
+ t.check(shell?.phase === 'running' && shell.harness === 'cmd' && shell.kind === 'shell' && Boolean(shell.attach), 'a shell session runs through ptyhost and is listed by its shell, marked as a shell', JSON.stringify(shell) + '\n' + daemon.diagnostics());
91
+ if (shell?.attach) {
92
+ const shellView = await openViewer(shell.attach);
93
+ viewers.push(shellView);
94
+ // Typing before cmd has drawn its prompt can lose the keystrokes: wait for the prompt first.
95
+ await sees(t, shellView, '>');
96
+ shellView.type('echo SHELL-[%LIVE_AGENT_KEY%]-%USERNAME:~0,0%OK');
97
+ const sawShell = await sees(t, shellView, 'SHELL-[%LIVE_AGENT_KEY%]-OK');
98
+ t.check(Boolean(sawShell), "keystrokes reach the shell, and it has none of the agent's own environment", 'screen so far: ' + tail(shellView.output()) + '\n' + daemon.diagnostics());
99
+ daemon.request({ kind: 'close', id: 'close1', session: 'shell1' });
100
+ const closed = await daemon.response('close1');
101
+ const shellEnded = await t.waitFor(() => {
102
+ const s = daemon.status('shell1');
103
+ return s?.phase === 'exited' ? s : null;
104
+ }, WAIT.exit);
105
+ t.check(Boolean(closed?.ok) && shellEnded !== null && shellView.exitCode() !== null, 'close ends the shell: the sandbox answers, the status says exited and the attached client gets the exit', JSON.stringify(closed) + ' ' + JSON.stringify(daemon.status('shell1')));
106
+ daemon.request({ kind: 'close', id: 'close2', session: 'shell1' });
107
+ const again = await daemon.response('close2');
108
+ t.check(again?.ok === false, 'closing a session that has ended says it is not running', JSON.stringify(again));
109
+ }
85
110
  t.section('live: restart and exit');
86
111
  daemon.request({ kind: 'restart', id: 'restart1', harness: 'claude' });
87
112
  const sawResumed = await sees(t, viewer, 'LIVE-AGENT-RESUMED');
@@ -61,6 +61,28 @@ export const localConfigSuite = {
61
61
  await rejectedBecause({ claudeProfile: { hostDir: 'C:\\x' } }, 'claudeProfile.hostDir is not allowed', 'a project file cannot point at the Claude profile folder');
62
62
  await rejectedBecause({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed', 'a project file cannot point at host tool folders');
63
63
  await rejectedBecause({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:', 'values are type-checked (memory too small)');
64
+ // Security review 2026-10: what a repo could reach through fields that looked harmless.
65
+ const secretFile = path.join(f.shared, 'id_ed25519');
66
+ fs.writeFileSync(secretFile, 'PRIVATE KEY');
67
+ await rejectedBecause({ scripts: [secretFile] }, 'only files inside the project', 'a project file cannot name a script outside the project (it would copy that PC file into the launch folder)');
68
+ await rejectedBecause({ scripts: [path.relative(dirC, secretFile)] }, 'only files inside the project', 'a relative ..\\ script path outside the project is refused too');
69
+ fs.symlinkSync(f.shared, path.join(dirC, 'linked'), 'junction');
70
+ await rejectedBecause({ scripts: ['./linked/id_ed25519'] }, 'only files inside the project', 'a script reached through a link to a folder outside the project is refused');
71
+ fs.rmSync(path.join(dirC, 'linked'));
72
+ for (const name of [
73
+ 'AGENT_GIT_ACCESS',
74
+ 'GIT_CONFIG_COUNT',
75
+ 'ANTHROPIC_BASE_URL',
76
+ 'NODE_OPTIONS',
77
+ 'Path',
78
+ 'https_proxy',
79
+ ])
80
+ await rejectedBecause({ toolchains: { environment: { [name]: 'x' } } }, 'cannot be set by a project', 'a project file cannot set ' + name + ' in toolchains.environment');
81
+ await rejectedBecause({ sandbox: { reserveHostMb: 0 } }, 'sandbox.reserveHostMb is not allowed', 'a project file cannot lower the RAM kept for this PC');
82
+ f.writeLocal(dirC, { toolchains: { environment: { NPM_CONFIG_REGISTRY: 'https://registry.example' } } });
83
+ t.equal((await f.run({ cwd: dirC, trustConfig: true })).config.toolchains.environment.NPM_CONFIG_REGISTRY, 'https://registry.example', 'a registry mirror in toolchains.environment is still fine');
84
+ f.writeLocal(dirC, { provisioning: { copyFromHost: true } });
85
+ t.equal((await f.run({ cwd: dirC, trustConfig: true }, { ...f.baseConfig, provisioning: { ...f.baseConfig.provisioning, copyFromHost: false } })).config.provisioning.copyFromHost, false, 'a project file cannot turn on copying Node, Git and the agent from this PC when you turned it off');
64
86
  await rejectedBecause('{\n // a comment\n "readOnly": tru\n}', 'line 3', 'broken JSON is reported with its line');
65
87
  await rejectedBecause({ _comment: 'old style' }, '_comment is not allowed', 'unknown keys are rejected');
66
88
  f.writeLocal(dirC, '// comments and trailing commas are fine\n{\n "readOnly": true, /* inline */\n}\n');
@@ -109,6 +131,14 @@ export const localConfigSuite = {
109
131
  t.check(viaVariable.includes('outside the project') &&
110
132
  viaLink.includes('outside the project') &&
111
133
  (await f.run({ cwd: dirD, trustConfig: true }, withOutside(false))).config.mappings.some((m) => m.host === path.join(dirD, 'assets')), 'outside means where the folder really is (%VAR% expanded, links followed); a folder inside needs no permission');
134
+ // A second mapping of part of the project must not undo readOnly or the workspace filter.
135
+ fs.mkdirSync(path.join(dirD, 'assets'), { recursive: true });
136
+ f.writeLocal(dirD, { mappings: [{ host: '.', guest: 'C:\\Data', readOnly: false }] });
137
+ const readOnlyUser = { ...f.baseConfig, readOnly: true };
138
+ const remapped = (await f.launch({ cwd: dirD, trustConfig: true }, { config: readOnlyUser })).mappings.find((m) => m.guest.toLowerCase() === 'c:\\data');
139
+ const filtered = await t.thrownMessage(() => f.launch({ cwd: dirD, trustConfig: true }, { config: { ...f.baseConfig, workspaceFilter: { ...f.baseConfig.workspaceFilter, enabled: true } } }));
140
+ t.check(remapped?.readOnly === true && filtered.includes('workspaceFilter.enabled'), 'a project folder mapped again is read-only when the project is, and refused while the workspace filter is on', JSON.stringify(remapped) + ' / ' + filtered);
141
+ fs.rmSync(path.join(dirD, '.sandbox-config.json'));
112
142
  t.section('a project can only tighten protections');
113
143
  // Settings that would loosen a protection are listed before approval, then ignored.
114
144
  f.writeLocal(dirC, {
@@ -1,16 +1,21 @@
1
- // The menu stays quick: no key or draw waits for a child process (tasklist is faked and counted), a slow tab load shows
2
- // "Loading…" instead of holding up the keys, a held key is drawn once, and the terminal writes only the rows that
3
- // changed (the screen after the diffs is the same as a full draw, also after a resize).
1
+ // The menu stays quick: no key or draw waits for a child process (tasklist is faked and counted), the first screen does
2
+ // not wait for the process list, a slow tab load shows "Loading…" instead of holding up the keys, a section seen before
3
+ // shows at once, a held key is drawn once, Esc is a key at once, an idle menu writes nothing, and the terminal writes
4
+ // only the rows that changed (the screen after the diffs is the same as a full draw, also after a resize). The process
5
+ // list is fetched only when it may have changed, and the settings file is read once for a whole list.
6
+ import fs from 'node:fs';
4
7
  import { EventEmitter } from 'node:events';
5
8
  import { PassThrough } from 'node:stream';
9
+ import { ConfigFile } from '../app/features/settings/index.js';
6
10
  import { DEFAULTS } from '../core/config/index.js';
7
11
  import { sleep } from '../core/util/index.js';
8
12
  import { Menu } from '../cli/menu/menu.js';
9
- import { LiveStatus } from '../cli/menu/live.js';
13
+ import { LiveStatus, listGap } from '../cli/menu/live.js';
10
14
  import { ListPage } from '../cli/menu/pages/list-page.js';
11
15
  import { App, Text, Terminal } from '../cli/tui/index.js';
12
- import { supportsSyncUpdate } from '../cli/tui/terminal.js';
13
- import { isSandboxRunning, useTasklistCache, useTasklistRunners } from '../host/sandbox/index.js';
16
+ import { ESCAPE_TIMEOUT_MS, supportsSyncUpdate } from '../cli/tui/terminal.js';
17
+ import { isSandboxRunning, readTasklist, sandboxPids, useTasklistCache, useTasklistRunners, } from '../host/sandbox/index.js';
18
+ import { staleMountNote } from '../host/storage/index.js';
14
19
  const TASKLIST = [
15
20
  '"System Idle Process","0","Services","0","8 K"',
16
21
  '"WindowsSandboxClient.exe","4242","Console","1","9,000 K"',
@@ -124,6 +129,26 @@ export const menuPerfSuite = {
124
129
  app.close();
125
130
  t.check(result === 'bye' && handled === 31 && renders < 10, 'keys that are already waiting are handled first and drawn once, not once each', 'handled ' + handled + ', drawn ' + renders);
126
131
  }
132
+ t.section('Esc is a key at once');
133
+ {
134
+ const tty = new FakeTty();
135
+ const term = tty.terminal();
136
+ term.start();
137
+ const times = [];
138
+ for (let i = 0; i < 3; i++) {
139
+ const at = Date.now();
140
+ tty.type('\x1b');
141
+ const key = await term.key();
142
+ times.push(Date.now() - at);
143
+ if (key.name !== 'escape')
144
+ times.push(9999);
145
+ }
146
+ tty.type('\x1b[B');
147
+ const arrow = await term.key();
148
+ term.stop();
149
+ t.check(Math.max(...times) < 250 && ESCAPE_TIMEOUT_MS <= 50, 'Esc arrives in ' + times.join(', ') + ' ms (Node waits 500 ms for the rest of a sequence by default)', times.join(', '));
150
+ t.check(arrow.name === 'down', 'an arrow key is still one key, not Esc and letters');
151
+ }
127
152
  t.section('a slow load does not hold up the keys');
128
153
  {
129
154
  const tty = new FakeTty();
@@ -131,9 +156,10 @@ export const menuPerfSuite = {
131
156
  class Slow extends ListPage {
132
157
  label = 'Slow';
133
158
  rows = [];
159
+ next = ['alpha', 'beta'];
134
160
  async load() {
135
161
  await sleep(500);
136
- this.rows = ['alpha', 'beta'];
162
+ this.rows = this.next;
137
163
  }
138
164
  options() {
139
165
  return { path: [], items: this.rows.map((r) => ({ value: r, label: r })) };
@@ -150,6 +176,14 @@ export const menuPerfSuite = {
150
176
  t.check(waited < 400 && page.loading && text().includes('Loading'), 'the tab opens at once and says Loading… while the data comes', 'waited ' + waited + ' ms');
151
177
  const arrived = await t.waitFor(() => (text().includes('beta') ? true : null), 5000, 20);
152
178
  t.check(arrived === true && !page.loading, 'the list is built when the data arrives');
179
+ // Back on the section later: what it had shows at once, the fresh data replaces it when it is there.
180
+ page.next = ['alpha', 'gamma'];
181
+ const again = Date.now();
182
+ await page.show();
183
+ const shownIn = Date.now() - again;
184
+ t.check(shownIn < 100 && text().includes('beta'), 'a section seen before shows its last data at once (in ' + shownIn + ' ms), not Loading…', text());
185
+ const fresh = await t.waitFor(() => (text().includes('gamma') ? true : null), 5000, 20);
186
+ t.check(fresh === true && !text().includes('beta'), 'and its fresh data when it has loaded');
153
187
  }
154
188
  t.section('no child process on a key or a draw');
155
189
  const saved = { hostOnly: process.env.AGENT_SANDBOX_HOST_ONLY };
@@ -177,24 +211,34 @@ export const menuPerfSuite = {
177
211
  return TASKLIST;
178
212
  },
179
213
  refreshMs: 100,
214
+ // The sandbox's process is gone at every look: the list is fetched again on every tick.
215
+ processAlive: () => false,
180
216
  });
217
+ const startedAt = Date.now();
181
218
  const done = menu.start();
182
- const shown = await t.waitFor(() => tty.writes.length > 0, 20000, 20);
219
+ const shown = await t.waitFor(() => tty.writes.length > 0, 20000, 5);
220
+ const firstPaint = Date.now() - startedAt;
183
221
  t.check(shown === true, 'the menu draws its first screen');
222
+ t.check(firstPaint < 1000, 'the first screen does not wait for a 1.5 second process list (drawn after ' + firstPaint + ' ms)');
184
223
  const slowest = { ms: 0, key: '' };
224
+ const took = [];
185
225
  const press = async (name, bytes) => {
186
226
  const before = tty.writes.length;
187
227
  const at = Date.now();
188
228
  tty.type(bytes);
189
229
  // Not every key changes the screen: give the ones that do a chance, and measure those.
190
- await t.waitFor(() => tty.writes.length > before, 400, 5);
191
- const took = Date.now() - at;
192
- if (tty.writes.length > before && took > slowest.ms) {
193
- slowest.ms = took;
230
+ await t.waitFor(() => tty.writes.length > before, 400, 2);
231
+ const ms = Date.now() - at;
232
+ if (tty.writes.length > before)
233
+ took.push(ms);
234
+ if (tty.writes.length > before && ms > slowest.ms) {
235
+ slowest.ms = ms;
194
236
  slowest.key = name;
195
237
  }
196
238
  await sleep(15);
197
239
  };
240
+ // The pages have their data (the first process list came after 1.5 s).
241
+ await sleep(1700);
198
242
  const syncBefore = calls.sync;
199
243
  for (const section of ['2', '3', '4', '5', '6', '7', '1'])
200
244
  await press('section ' + section, section);
@@ -209,6 +253,21 @@ export const menuPerfSuite = {
209
253
  ' ' +
210
254
  slowest.ms +
211
255
  ' ms)');
256
+ const median = [...took].sort((a, b) => a - b)[Math.floor(took.length / 2)] ?? 9999;
257
+ t.check(median < 100, 'a key is usually drawn at once (median ' + median + ' ms over ' + took.length + ' keys)');
258
+ // Left alone, the menu writes nothing: the process list keeps coming (every tick here), but nothing on the
259
+ // screen changes, so nothing is drawn.
260
+ await sleep(300);
261
+ const idleFrom = tty.writes.length;
262
+ const fetchesFrom = calls.async;
263
+ await sleep(3000);
264
+ const idleWrites = tty.writes.slice(idleFrom);
265
+ const idleBytes = idleWrites.join('').length;
266
+ t.check(idleWrites.length <= 1 && idleBytes < 4000 && calls.async > fetchesFrom, 'an idle menu writes nothing for 3 s (' +
267
+ idleWrites.length +
268
+ ' writes, ' +
269
+ idleBytes +
270
+ ' bytes) while the process list is looked at in the background', Text.strip(idleWrites.join('')).slice(0, 300));
212
271
  tty.type(KEYS.ctrlC);
213
272
  const code = await Promise.race([done, sleep(5000).then(() => -1)]);
214
273
  t.check(code === 130, 'Ctrl+C leaves the menu');
@@ -244,5 +303,132 @@ export const menuPerfSuite = {
244
303
  await sleep(80);
245
304
  t.check(live.line() === 'status b' && changes === 2, 'a stopped status looks at nothing');
246
305
  }
306
+ t.section('the process list is fetched only when it may have changed');
307
+ {
308
+ t.equal([0, 1, 2, 3, 4, 10, -1].map((n) => listGap(n, 2500, 30000)), [2500, 5000, 10000, 20000, 30000, 30000, 2500], 'while nothing changes, the gap between two lists doubles up to the longest');
309
+ t.equal(sandboxPids(TASKLIST + '\r\n"WindowsSandbox.exe","77","Console","1","5 K"'), [4242, 77], 'the ids of the sandbox programs are read from the list');
310
+ // A sandbox runs: its programs are checked by id, and the list is fetched again only when one has ended.
311
+ let fetches = 0;
312
+ let derived = 0;
313
+ let alive = true;
314
+ const running = new LiveStatus({
315
+ fetch: () => {
316
+ fetches++;
317
+ return Promise.resolve(TASKLIST);
318
+ },
319
+ derive: () => String(++derived),
320
+ onChange: () => undefined,
321
+ intervalMs: 20,
322
+ alive: () => alive,
323
+ needsList: () => true,
324
+ });
325
+ await running.start();
326
+ await sleep(400);
327
+ t.check(fetches === 1 && derived > 5, 'while the sandbox runs, its programs are checked by id: no new list (' +
328
+ fetches +
329
+ ' fetched), the line is still derived (' +
330
+ derived +
331
+ ' times)');
332
+ alive = false;
333
+ const refetched = await t.waitFor(() => fetches > 1, 3000, 10);
334
+ running.stop();
335
+ t.check(refetched === true, 'when one of them has ended, the list is fetched again');
336
+ // None runs: fewer and fewer lists, one at once after a key press or when the data folder changes.
337
+ fetches = 0;
338
+ let signal = 'a';
339
+ const idle = new LiveStatus({
340
+ fetch: () => {
341
+ fetches++;
342
+ return Promise.resolve('"System Idle Process","0","Services","0","8 K"');
343
+ },
344
+ derive: () => 'none',
345
+ onChange: () => undefined,
346
+ intervalMs: 20,
347
+ idleMaxMs: 100000,
348
+ needsList: () => true,
349
+ signal: () => signal,
350
+ });
351
+ await idle.start();
352
+ await sleep(1000);
353
+ const quiet = fetches;
354
+ t.check(quiet >= 2 && quiet <= 8, 'with nothing running, a second of 20 ms ticks fetched the list ' + quiet + ' times (not 50)');
355
+ signal = 'b';
356
+ const onSignal = await t.waitFor(() => fetches > quiet, 1000, 5);
357
+ t.check(onSignal === true, 'a change in the data folder fetches the list at once');
358
+ await sleep(100);
359
+ const beforeKey = fetches;
360
+ idle.touch();
361
+ const onKey = await t.waitFor(() => fetches > beforeKey, 1000, 5);
362
+ idle.stop();
363
+ t.check(onKey === true, 'so does a key press (someone is looking)');
364
+ // Inside a sandbox the list is never needed: nothing runs tasklist, not even a question asked meanwhile.
365
+ fetches = 0;
366
+ let syncRuns = 0;
367
+ useTasklistRunners({
368
+ sync: () => {
369
+ syncRuns++;
370
+ return '';
371
+ },
372
+ async: () => Promise.resolve(''),
373
+ });
374
+ try {
375
+ const inside = new LiveStatus({
376
+ fetch: () => {
377
+ fetches++;
378
+ return Promise.resolve('');
379
+ },
380
+ derive: () => 'inside',
381
+ onChange: () => undefined,
382
+ intervalMs: 20,
383
+ needsList: () => false,
384
+ });
385
+ await inside.start();
386
+ await sleep(200);
387
+ const list = readTasklist();
388
+ inside.stop();
389
+ t.check(fetches === 0 && syncRuns === 0 && list === '', 'when the list is not needed, it is never fetched and nothing asks tasklist meanwhile');
390
+ }
391
+ finally {
392
+ useTasklistRunners();
393
+ }
394
+ }
395
+ t.section('nothing before the menu waits for tasklist');
396
+ {
397
+ let asked = 0;
398
+ const note = staleMountNote(t.dir('no-mounts'), 'main', () => {
399
+ asked++;
400
+ return false;
401
+ });
402
+ t.check(note === null && asked === 0, 'with no image mounted, "is a sandbox running?" is not even asked');
403
+ }
404
+ t.section('the settings file is read once for a whole list');
405
+ {
406
+ const file = t.write(t.at('settings', 'config.json'), JSON.stringify({ defaultHarness: 'codex', workspaceFilter: { exclude: ['a', 'b'] } }));
407
+ const config = new ConfigFile('user', file);
408
+ const reads = { count: 0 };
409
+ const original = fs.readFileSync;
410
+ const counting = ((...args) => {
411
+ if (String(args[0]) === file)
412
+ reads.count++;
413
+ return original(...args);
414
+ });
415
+ fs.readFileSync = counting;
416
+ let first;
417
+ try {
418
+ first = Array.from({ length: 200 }, () => config.valueInFile(['defaultHarness']));
419
+ }
420
+ finally {
421
+ fs.readFileSync = original;
422
+ }
423
+ t.check(reads.count === 1 && first.every((v) => v === 'codex'), '200 values in one run read the file once (' + reads.count + ' reads)');
424
+ const exclude = ['workspaceFilter', 'exclude'];
425
+ config.valueInFile(exclude).push('c');
426
+ t.equal(config.valueInFile(exclude), ['a', 'b'], 'a value handed out is a copy (changing it changes nothing)');
427
+ fs.writeFileSync(file, JSON.stringify({ defaultHarness: 'gemini' }));
428
+ await Promise.resolve();
429
+ t.equal(config.valueInFile(['defaultHarness']), 'gemini', 'the next run reads the file again (a change outside)');
430
+ const result = config.set(['defaultHarness'], 'opencode');
431
+ t.equal([result, config.valueInFile(['defaultHarness'])], ['saved', 'opencode'], 'a save is seen at once');
432
+ }
247
433
  },
248
434
  };
@@ -725,6 +725,10 @@ export const menuSuite = {
725
725
  ['main', false, false],
726
726
  ['second', true, true],
727
727
  ], 'rename keeps the default and active marks');
728
+ const ensured = [service.ensure('codex', 'main'), service.activeName('codex'), fs.readFileSync(codexAuth, 'utf8')];
729
+ t.equal(ensured, ['main', 'main', '{"token":"A"}'], 'a launch with --account switches the sandbox to that login');
730
+ t.equal([service.ensure('codex', null), service.activeName('codex'), fs.readFileSync(codexAuth, 'utf8')], ['second', 'second', '{"token":"B2"}'], 'a launch without --account switches the sandbox back to the default login');
731
+ t.equal([service.ensure('claude', null), service.ensure('nobody', 'x')], [null, null], 'an agent with no saved login, or one that keeps no login: nothing to switch');
728
732
  await t.throws(() => service.save('x', false, 'nobody'), 'Unknown agent', 'an unknown agent: refused');
729
733
  await t.throws(() => service.save('bad name', false, 'codex'), 'Invalid account name', 'bad names are refused');
730
734
  t.section('every screen');
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './support/suite.js';
2
+ export declare const remoteSuite: Suite;