windows-development-sandbox 1.11.7 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +13 -6
  2. package/config.example.json +15 -4
  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/agents/agent-entry.js +5 -0
  10. package/dist/app/features/agents/agents.def.js +1 -0
  11. package/dist/app/features/apps/apps-rows.d.ts +4 -1
  12. package/dist/app/features/apps/apps-rows.js +7 -2
  13. package/dist/app/features/apps/apps.def.d.ts +19 -0
  14. package/dist/app/features/apps/apps.def.js +50 -12
  15. package/dist/app/features/apps/apps.run.d.ts +2 -0
  16. package/dist/app/features/apps/apps.run.js +1 -1
  17. package/dist/app/features/audit/audit.def.d.ts +350 -0
  18. package/dist/app/features/audit/audit.def.js +457 -0
  19. package/dist/app/features/audit/audit.run.d.ts +110 -0
  20. package/dist/app/features/audit/audit.run.js +133 -0
  21. package/dist/app/features/audit/index.d.ts +2 -0
  22. package/dist/app/features/audit/index.js +3 -0
  23. package/dist/app/features/defs.d.ts +349 -4
  24. package/dist/app/features/defs.js +16 -1
  25. package/dist/app/features/files/files.def.js +1 -0
  26. package/dist/app/features/health/health.def.js +1 -0
  27. package/dist/app/features/sandbox/sandbox.def.js +3 -0
  28. package/dist/app/features/sessions/sessions.def.d.ts +36 -0
  29. package/dist/app/features/sessions/sessions.def.js +77 -9
  30. package/dist/app/features/sessions/sessions.run.d.ts +25 -2
  31. package/dist/app/features/sessions/sessions.run.js +71 -3
  32. package/dist/app/features/settings/settings.def.d.ts +8 -0
  33. package/dist/app/features/settings/settings.def.js +17 -0
  34. package/dist/app/features/settings/settings.run.d.ts +3 -0
  35. package/dist/app/features/settings/settings.run.js +16 -2
  36. package/dist/app/features/web/web.def.d.ts +9 -0
  37. package/dist/app/features/web/web.def.js +26 -1
  38. package/dist/app/features/web/web.run.d.ts +3 -0
  39. package/dist/app/features/web/web.run.js +11 -3
  40. package/dist/app/kernel/access.d.ts +33 -0
  41. package/dist/app/kernel/access.js +44 -0
  42. package/dist/app/kernel/define.d.ts +26 -2
  43. package/dist/app/kernel/index.d.ts +1 -0
  44. package/dist/app/kernel/index.js +1 -0
  45. package/dist/app/kernel/rpc.d.ts +5 -2
  46. package/dist/app/registry.d.ts +4 -1
  47. package/dist/app/registry.js +26 -4
  48. package/dist/cli/args.d.ts +0 -3
  49. package/dist/cli/args.js +3 -4
  50. package/dist/cli/commands/index.d.ts +1 -1
  51. package/dist/cli/commands/index.js +1 -1
  52. package/dist/cli/commands/launch/prepare.js +1 -1
  53. package/dist/cli/commands/maintenance/index.d.ts +0 -1
  54. package/dist/cli/commands/maintenance/index.js +0 -1
  55. package/dist/cli/commands/registry.js +3 -2
  56. package/dist/cli/commands/remote.d.ts +2 -0
  57. package/dist/cli/commands/remote.js +49 -0
  58. package/dist/cli/commands/sessions/reopen.js +3 -0
  59. package/dist/cli/commands/web.js +66 -0
  60. package/dist/cli/manifest.d.ts +63 -45
  61. package/dist/cli/manifest.js +33 -18
  62. package/dist/cli/menu/io-menu.d.ts +5 -3
  63. package/dist/cli/menu/io-menu.js +12 -5
  64. package/dist/cli/menu/pages/apps-page.js +2 -15
  65. package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
  66. package/dist/cli/menu/pages/maintenance-page.js +51 -8
  67. package/dist/cli/menu/remote-menu.d.ts +19 -0
  68. package/dist/cli/menu/remote-menu.js +108 -0
  69. package/dist/cli/menu/views.js +4 -0
  70. package/dist/cli/operations/handler.js +15 -1
  71. package/dist/cli/services/remote-client.d.ts +16 -0
  72. package/dist/cli/services/remote-client.js +141 -0
  73. package/dist/cli/services/sessions-service.d.ts +5 -0
  74. package/dist/cli/services/sessions-service.js +17 -1
  75. package/dist/core/config/defaults.js +12 -2
  76. package/dist/core/local-config/file.js +16 -2
  77. package/dist/core/local-config/overrides.js +7 -0
  78. package/dist/core/run/versions.js +9 -8
  79. package/dist/core/schema/apps.d.ts +9 -0
  80. package/dist/core/schema/apps.js +9 -0
  81. package/dist/core/schema/config.d.ts +23 -3
  82. package/dist/core/schema/config.js +42 -5
  83. package/dist/core/schema/sandbox.d.ts +1 -0
  84. package/dist/core/schema/sandbox.js +11 -3
  85. package/dist/core/schema/web.d.ts +51 -0
  86. package/dist/core/schema/web.js +46 -1
  87. package/dist/core/util/fs.d.ts +5 -0
  88. package/dist/core/util/fs.js +23 -0
  89. package/dist/core/util/index.d.ts +1 -1
  90. package/dist/core/util/index.js +1 -1
  91. package/dist/guest/apps/app-installer.js +50 -4
  92. package/dist/guest/apps/index.d.ts +1 -1
  93. package/dist/guest/apps/index.js +1 -1
  94. package/dist/guest/apps/sources.js +1 -1
  95. package/dist/guest/audit/parse.d.ts +32 -2
  96. package/dist/guest/audit/parse.js +43 -5
  97. package/dist/guest/audit/process-audit.d.ts +3 -1
  98. package/dist/guest/audit/process-audit.js +9 -6
  99. package/dist/guest/boot/boot.js +12 -3
  100. package/dist/guest/boot/guide.d.ts +1 -1
  101. package/dist/guest/boot/guide.js +27 -3
  102. package/dist/guest/daemon/daemon.js +2 -0
  103. package/dist/guest/daemon/idle-shutdown.js +2 -1
  104. package/dist/guest/daemon/sessions.d.ts +23 -2
  105. package/dist/guest/daemon/sessions.js +151 -20
  106. package/dist/guest/entries/daemon.js +2 -1
  107. package/dist/guest/git/decision.js +7 -0
  108. package/dist/guest/sessions/cmdline.d.ts +14 -0
  109. package/dist/guest/sessions/cmdline.js +10 -0
  110. package/dist/guest/setup/addon-mcp.d.ts +2 -0
  111. package/dist/guest/setup/addon-mcp.js +49 -0
  112. package/dist/guest/setup/agent-addons.d.ts +9 -0
  113. package/dist/guest/setup/agent-addons.js +22 -0
  114. package/dist/guest/setup/headroom.d.ts +7 -0
  115. package/dist/guest/setup/headroom.js +60 -0
  116. package/dist/guest/setup/managed-policy.d.ts +2 -1
  117. package/dist/guest/setup/managed-policy.js +15 -6
  118. package/dist/guest/toolchains/plan.js +8 -3
  119. package/dist/guest/toolchains/setup.js +3 -0
  120. package/dist/host/audit/attribute.d.ts +46 -0
  121. package/dist/host/audit/attribute.js +76 -0
  122. package/dist/host/audit/collect.d.ts +29 -3
  123. package/dist/host/audit/collect.js +256 -61
  124. package/dist/host/audit/git-commits.d.ts +29 -0
  125. package/dist/host/audit/git-commits.js +157 -0
  126. package/dist/host/audit/index.d.ts +5 -0
  127. package/dist/host/audit/index.js +6 -0
  128. package/dist/host/audit/log.d.ts +45 -7
  129. package/dist/host/audit/log.js +148 -45
  130. package/dist/host/audit/redact.d.ts +2 -0
  131. package/dist/host/audit/redact.js +67 -0
  132. package/dist/host/channel/channel.d.ts +5 -1
  133. package/dist/host/channel/channel.js +12 -0
  134. package/dist/host/guard/agent-files.js +7 -1
  135. package/dist/host/guard/commands.js +13 -1
  136. package/dist/host/guard/git-config.js +27 -7
  137. package/dist/host/guard/git-dirs.js +78 -3
  138. package/dist/host/guard/mark-of-the-web.js +3 -1
  139. package/dist/host/guard/nested-repos.js +7 -2
  140. package/dist/host/guard/store.js +1 -0
  141. package/dist/host/guard/types.d.ts +2 -0
  142. package/dist/host/launch/mappings.js +11 -1
  143. package/dist/host/launch/project-scan.js +2 -21
  144. package/dist/host/launch/stage.js +4 -0
  145. package/dist/host/life/session-ledger.js +2 -1
  146. package/dist/host/remote/access-store.d.ts +156 -0
  147. package/dist/host/remote/access-store.js +308 -0
  148. package/dist/host/remote/cert.d.ts +20 -0
  149. package/dist/host/remote/cert.js +103 -0
  150. package/dist/host/remote/door-client.d.ts +13 -0
  151. package/dist/host/remote/door-client.js +54 -0
  152. package/dist/host/remote/firewall.d.ts +44 -0
  153. package/dist/host/remote/firewall.js +118 -0
  154. package/dist/host/remote/gate.d.ts +125 -0
  155. package/dist/host/remote/gate.js +391 -0
  156. package/dist/host/remote/index.d.ts +8 -0
  157. package/dist/host/remote/index.js +10 -0
  158. package/dist/host/remote/lan.d.ts +24 -0
  159. package/dist/host/remote/lan.js +88 -0
  160. package/dist/host/remote/tls-store.d.ts +22 -0
  161. package/dist/host/remote/tls-store.js +63 -0
  162. package/dist/host/remote/windows-helper.d.ts +67 -0
  163. package/dist/host/remote/windows-helper.js +129 -0
  164. package/dist/host/web/bridge.d.ts +5 -1
  165. package/dist/host/web/bridge.js +6 -2
  166. package/dist/host/web/browser.js +1 -1
  167. package/dist/host/web/control.d.ts +4 -1
  168. package/dist/host/web/control.js +28 -4
  169. package/dist/host/web/rpc.d.ts +16 -2
  170. package/dist/host/web/rpc.js +37 -4
  171. package/dist/host/web/runner.d.ts +4 -0
  172. package/dist/host/web/runner.js +12 -0
  173. package/dist/host/web/server.d.ts +18 -2
  174. package/dist/host/web/server.js +97 -24
  175. package/dist/host/web/state.d.ts +5 -0
  176. package/dist/host/web/state.js +8 -0
  177. package/dist/runtime/agent.js +184 -18
  178. package/dist/runtime/daemon.js +772 -338
  179. package/dist/runtime/db.js +184 -18
  180. package/dist/runtime/gitguard.js +6 -0
  181. package/dist/runtime/main.js +966 -553
  182. package/dist/runtime/netproxy.js +184 -18
  183. package/dist/runtime/view.js +186 -18
  184. package/dist/selftest/app.test.js +66 -7
  185. package/dist/selftest/audit.test.js +354 -47
  186. package/dist/selftest/channel.test.js +16 -0
  187. package/dist/selftest/cmdline.test.js +16 -0
  188. package/dist/selftest/git-access.test.js +8 -0
  189. package/dist/selftest/hcs.test.js +35 -0
  190. package/dist/selftest/hostile-input.test.js +12 -1
  191. package/dist/selftest/index.js +2 -0
  192. package/dist/selftest/live-daemon.test.js +25 -0
  193. package/dist/selftest/local-config.test.js +30 -0
  194. package/dist/selftest/menu-screens.js +1 -1
  195. package/dist/selftest/menu.test.js +3 -3
  196. package/dist/selftest/remote.test.d.ts +2 -0
  197. package/dist/selftest/remote.test.js +536 -0
  198. package/dist/selftest/repo-guard.test.js +56 -2
  199. package/dist/selftest/resume.test.js +2 -1
  200. package/dist/selftest/sharing.test.js +9 -2
  201. package/dist/selftest/support/engine-golden.js +9 -0
  202. package/dist/selftest/support/fixtures.d.ts +14 -0
  203. package/dist/selftest/support/web-preview.js +157 -0
  204. package/dist/selftest/toolchains.test.js +147 -2
  205. package/dist/selftest/web.test.js +2 -0
  206. package/dist/shared/apps/agent-addons.d.ts +64 -0
  207. package/dist/shared/apps/agent-addons.js +95 -0
  208. package/dist/shared/apps/catalogue.d.ts +33 -2
  209. package/dist/shared/apps/catalogue.js +102 -11
  210. package/dist/shared/apps/index.d.ts +3 -2
  211. package/dist/shared/apps/index.js +3 -2
  212. package/dist/shared/apps/selection.d.ts +2 -0
  213. package/dist/shared/apps/selection.js +7 -0
  214. package/dist/shared/channel/schema.d.ts +23 -0
  215. package/dist/shared/channel/schema.js +8 -0
  216. package/dist/shared/pty/protocol.d.ts +2 -0
  217. package/dist/shared/pty/protocol.js +2 -0
  218. package/dist/sync/watcher.js +9 -1
  219. package/dist/web/index.html +2 -2
  220. package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
  221. package/dist/web/main-Z2WYAE2Z.js +221 -0
  222. package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
  223. package/native/csharp/HcsHost/Hcs.cs +9 -0
  224. package/native/csharp/HcsHost/Program.cs +26 -1
  225. package/native/csharp/HcsHost/Share.cs +173 -0
  226. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  227. package/native/csharp/HostTools/Program.cs +17 -1
  228. package/native/csharp/HostTools/WebHelper.cs +499 -0
  229. package/native/scripts/get-network-profile.ps1 +19 -0
  230. package/native/scripts/set-folder-access.ps1 +11 -0
  231. package/native/scripts/set-web-firewall.ps1 +103 -0
  232. package/package.json +1 -1
  233. package/sandbox/addons/ccusage/package-lock.json +111 -0
  234. package/sandbox/addons/ccusage/package.json +7 -0
  235. package/sandbox/addons/context7/package-lock.json +1313 -0
  236. package/sandbox/addons/context7/package.json +7 -0
  237. package/sandbox/addons/headroom/requirements.in +1 -0
  238. package/sandbox/addons/headroom/requirements.txt +2922 -0
  239. package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
  240. package/sandbox/addons/playwright-mcp/package.json +7 -0
  241. package/sandbox/csharp/PtyHost.cs +20 -0
  242. package/schemas/config.schema.json +78 -4
  243. package/schemas/sandbox-config.schema.json +2 -20
  244. package/dist/cli/commands/maintenance/audit.d.ts +0 -3
  245. package/dist/cli/commands/maintenance/audit.js +0 -58
  246. package/dist/web/main-K2JMCAZI.js +0 -219
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { z } from 'zod';
8
- import { agentsPage, agentsView } from '../app/features/agents/index.js';
8
+ import { agentNameProblem, agentsPage, agentsView } from '../app/features/agents/index.js';
9
9
  import { DEFINITIONS, NAV, navSections, navViews, QUERIES, VIEWS, viewById } from '../app/features/defs.js';
10
10
  import { accountsPage, agentPage, listAccounts } from '../app/features/accounts/index.js';
11
11
  import { addProject, projectRoots, projectsPage } from '../app/features/projects/index.js';
@@ -15,13 +15,13 @@ import { insideSandbox, isSandboxRunning } from '../host/sandbox/index.js';
15
15
  import { CRASH, listLogs, logsPage, tailLog } from '../app/features/logs/index.js';
16
16
  import { DEFAULTS } from '../core/config/index.js';
17
17
  import { sandboxPage } from '../app/features/sandbox/index.js';
18
- import { sessionsPage } from '../app/features/sessions/index.js';
18
+ import { closeSession, sessionsPage, startShell, } from '../app/features/sessions/index.js';
19
19
  import { APP_GROUPS, appsPage, listApps } from '../app/features/apps/index.js';
20
- import { settingsPage } from '../app/features/settings/index.js';
20
+ import { keepShownSecrets, settingsPage } from '../app/features/settings/index.js';
21
21
  import { checkEngine, checkText, enginePage } from '../app/features/engine/index.js';
22
22
  import { runUpdate, updatesPage } from '../app/features/updates/index.js';
23
23
  import { MenuOperations } from '../cli/menu/io-menu.js';
24
- import { ago, bytesText, Cancelled, ClientMessageSchema, defineOperation, exemption, fakeIO, fieldOf, fieldProblem, FieldSchema, fieldsOf, fromWireError, humanize, implementer, invoke, pageActions, pageHasContent, ServerMessageSchema, SURFACE_NAMES, toWireError, valueText, } from '../app/kernel/index.js';
24
+ import { accessNeeded, ago, allows, bytesText, Cancelled, ClientMessageSchema, defineOperation, exemption, fakeIO, fieldOf, fieldProblem, FieldSchema, fieldsOf, fromWireError, humanize, implementer, invoke, pageActions, pageHasContent, ServerMessageSchema, SURFACE_NAMES, toWireError, valueText, OWNER, } from '../app/kernel/index.js';
25
25
  import { createRpcHost, IMPLEMENTATIONS, readQuery, runDefinition } from '../app/registry.js';
26
26
  import { webPage } from '../app/features/web/index.js';
27
27
  import { parseCli } from '../cli/args.js';
@@ -326,11 +326,43 @@ export const appSuite = {
326
326
  const menu = menuView(sessions);
327
327
  const chosen = menu.resolve({ kind: 'pick', value: 'new' });
328
328
  t.check(menu.items.length === 2 &&
329
- menu.actions.map((a) => a.key).join() === 'n,r,o,l' &&
329
+ menu.actions.map((a) => a.key).join() === 'n,s,r,o,x,l' &&
330
330
  menu.enterLabel(undefined, 'new') === 'attach' &&
331
331
  chosen?.kind === 'action' &&
332
332
  chosen.action.kind === 'link' &&
333
- menu.resolve({ kind: 'action', key: 'l', value: undefined })?.kind === 'refresh', 'sessions in the menu: rows, the n/r/o/l keys, Enter attaches');
333
+ menu.resolve({ kind: 'action', key: 'l', value: undefined })?.kind === 'refresh', 'sessions in the menu: rows, the n/s/r/o/x/l keys, Enter attaches');
334
+ t.section('plain shell sessions');
335
+ const shellRow = { ...running('sh1', true), harness: 'pwsh', kind: 'shell' };
336
+ const shellPage = sessionsPage({ ...list, sessions: [shellRow] });
337
+ const shellItem = shellPage.sections[0].items?.[0];
338
+ const closeAction = shellItem?.actions?.[0];
339
+ t.check(shellItem?.label === 'pwsh (shell) api' &&
340
+ closeAction?.kind === 'operation' &&
341
+ closeAction.op === 'sessions.close' &&
342
+ closeAction.key === 'x' &&
343
+ closeAction.danger === true &&
344
+ JSON.stringify(closeAction.input) === '{"session":"sh1"}', 'a shell is listed by its name, marked as a shell, and can be closed (x)', JSON.stringify(shellItem));
345
+ const newShell = shellPage.actions?.find((a) => a.kind === 'operation' && a.op === 'sessions.shell');
346
+ t.check(newShell?.kind === 'operation' && newShell.key === 's' && JSON.stringify(newShell.input) === '{"shell":"auto"}', 'the sessions page offers New shell (s) next to New session');
347
+ t.check(!sessionsPage({ ...list, sandboxRunning: false, sessions: [] }).actions?.some((a) => a.kind === 'operation' && a.op === 'sessions.shell'), 'no New shell without a running sandbox');
348
+ t.equal(startShell.opens?.({ session: 'sh1', shell: 'pwsh', message: '' }), { kind: 'terminal', session: 'sh1' }, 'a new shell opens its terminal on every surface');
349
+ const controlCaller = { kind: 'windows', user: 'bob', role: 'control' };
350
+ const viewCaller = { kind: 'device', id: 'd', name: 'phone', role: 'view' };
351
+ t.check(accessNeeded(startShell, {}) === 'owner' &&
352
+ !allows(controlCaller, accessNeeded(startShell, {})) &&
353
+ allows(OWNER, accessNeeded(startShell, {})) &&
354
+ accessNeeded(closeSession, { session: 'x' }) === 'control' &&
355
+ allows(controlCaller, accessNeeded(closeSession, { session: 'x' })) &&
356
+ !allows(viewCaller, accessNeeded(closeSession, { session: 'x' })), 'only the owner opens a shell; control may close a session, view may not');
357
+ t.equal([
358
+ cliInput(definitionFor('shell', []), parseCli(['shell', 'api', '--shell', 'cmd'])),
359
+ cliInput(definitionFor('close', []), parseCli(['close', 'sh1'])),
360
+ ], [{ project: 'api', shell: 'cmd' }, { session: 'sh1' }], 'sandbox shell [project] --shell and sandbox close <id> become the input');
361
+ t.check(agentNameProblem('pwsh', []) !== true && agentNameProblem('Shell', []) !== true, 'an agent cannot be named after a shell or the shell command');
362
+ if (insideSandbox())
363
+ await t.throws(() => runDefinition('sessions.shell', {}, {}, fakeIO([]).io), 'has nothing to control', 'sandbox shell refuses inside the sandbox');
364
+ else
365
+ t.skip('sandbox shell refuses inside the sandbox (this PC is not a sandbox)');
334
366
  const menuStatus = menuView(statusPage, { category: 'status', prefix: 'status:' });
335
367
  t.check(menuStatus.items.every((i) => i.category === 'status' && i.value.startsWith('status:')) &&
336
368
  menuStatus.actions.some((a) => a.key === 'x' && a.label === 'stop'), 'a view inside one category of a page: rows in it, prefixed, with the keys of its actions');
@@ -384,6 +416,8 @@ export const appSuite = {
384
416
  edit.op === 'settings.set' &&
385
417
  edit.fields?.[0]?.name === 'value' &&
386
418
  page.sections.some((s) => s.items?.some((i) => i.id === '@data-folder')), 'the settings page: a section per category, each setting edited through its Field');
419
+ t.section('a secret shown as *** is never saved as ***');
420
+ t.equal(keepShownSecrets({ API_KEY: 'sk-real', LOG: 'debug' }, { API_KEY: '***', LOG: 'info', NEW_KEY: '***' }), { API_KEY: 'sk-real', LOG: 'info', NEW_KEY: '***' }, 'a map edited in the web keeps the saved secret where *** came back, and takes the rest as typed');
387
421
  t.section('settings export and import, run against a config file');
388
422
  const exportFile = t.at('app-settings', 'out.json');
389
423
  const exported = (await runDefinition('settings.export', ctx, { settingsFile: exportFile }, fakeIO().io));
@@ -428,6 +462,28 @@ export const appSuite = {
428
462
  const offText = fs.readFileSync(appsConfig, 'utf8');
429
463
  t.check(onText.includes('"lazygit"') && !offText.includes('"lazygit"'), 'apps: turning an app on writes apps.install, off removes it');
430
464
  await t.throws(() => runDefinition('apps.toggle', appsCtx, { scope: 'project', id: 'vscode', on: false }, quiet), 'is not an app this scope can change', 'apps: a project cannot change VS Code');
465
+ await t.throws(() => runDefinition('apps.toggle', appsCtx, { scope: 'project', id: 'rtk', on: true }, quiet), 'is not an app this scope can change', 'apps: a project cannot turn rtk on (it hooks every agent command)');
466
+ for (const id of ['headroom', 'ccusage', 'playwright-mcp', 'context7'])
467
+ await t.throws(() => runDefinition('apps.toggle', appsCtx, { scope: 'project', id, on: true }, quiet), 'is not an app this scope can change', 'apps: a project cannot turn ' + id + ' on (an agent add-on: your own settings only)');
468
+ const projectApps = (await readQuery(listApps, appsCtx, { scope: 'project' }));
469
+ const userApps = (await readQuery(listApps, appsCtx, { scope: 'user' }));
470
+ const mem = userApps.apps.find((a) => a.id === 'claude-mem');
471
+ const addons = appsPage(userApps).sections.find((s) => s.id === 'agents');
472
+ t.check(!projectApps.apps.some((a) => a.id === 'rtk' || a.id === 'claude-mem') &&
473
+ userApps.apps.find((a) => a.id === 'rtk')?.group === 'agents' &&
474
+ mem?.group === 'agents' &&
475
+ mem.setting === 'claudeProfile.syncClaudeMem' &&
476
+ mem.settingField?.kind === 'boolean' &&
477
+ mem.on === true &&
478
+ addons?.title === 'Agent add-ons' &&
479
+ (addons.items ?? []).some((i) => i.id === 'rtk') &&
480
+ (addons.items ?? []).some((i) => i.id === 'claude-mem' && !(i.details ?? []).some((d) => d.startsWith('Download'))), 'apps: an Agent add-ons section with rtk and claude-mem (its own setting, nothing to download); not in a project');
481
+ const headroomItem = (addons?.items ?? []).find((i) => i.id === 'headroom');
482
+ t.check(['headroom', 'ccusage', 'playwright-mcp', 'context7'].every((id) => (addons?.items ?? []).some((i) => i.id === id) &&
483
+ !projectApps.apps.some((a) => a.id === id) &&
484
+ userApps.apps.find((a) => a.id === id)?.on === false) &&
485
+ (headroomItem?.details ?? []).some((d) => d.includes('no shortcut, not on PATH')) &&
486
+ (headroomItem?.details ?? []).some((d) => d.startsWith('Plugs in')), 'apps: Headroom, ccusage, Playwright MCP and Context7 are agent add-ons, off by default, not in a project');
431
487
  const dataDir = t.dir('app-apps', 'data');
432
488
  fs.mkdirSync(path.join(dataDir, 'tools', 'apps', 'lazygit'), { recursive: true });
433
489
  fs.writeFileSync(path.join(dataDir, 'tools', 'apps', 'lazygit', '.agent-sandbox-app'), 'lazygit');
@@ -939,6 +995,9 @@ export const appSuite = {
939
995
  t.check(definitionFor('web', ['open'])?.id === 'web.open' &&
940
996
  definitionFor('web', ['status'])?.id === 'web.status' &&
941
997
  definitionFor('web', []) === undefined &&
942
- commandSpec('web')?.args[0]?.choices?.join() === 'start,stop,status,open', 'the web actions are actions of the web command; sandbox web with no action is not one of them');
998
+ commandSpec('web')?.args[0]?.choices?.join() ===
999
+ 'start,stop,status,open,allow-device,revoke,revoke-all,lan-on,lan-off,devices' &&
1000
+ definitionFor('web', ['allow-device'])?.id === 'access.allowDevice' &&
1001
+ definitionFor('web', ['revoke', '0123456789abcdef'])?.id === 'access.revoke', 'the web actions (and the remote access ones) are actions of the web command; sandbox web alone is not one');
943
1002
  },
944
1003
  };
@@ -1,13 +1,19 @@
1
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.
2
+ // filtered, secrets scrubbed from command lines, every program linked to the session that started it (and nothing
3
+ // guessed), commits read from a project's reflog, what the sandbox reports checked like everything else from it, and
4
+ // the Activity page on every surface.
3
5
  import crypto from 'node:crypto';
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
6
8
  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';
9
+ import { auditArgv, auditPage, auditView, listAudit, NO_SESSION } from '../app/features/audit/index.js';
10
+ import { auditListing } from '../app/features/audit/audit.run.js';
11
+ import { pageLines } from '../cli/output/index.js';
12
+ import { menuView } from '../cli/menu/render-view.js';
13
+ import { AuditCollector, AuditLog, CommitWatcher, fold, makesCommits, parseReflogLine, redactCommandLine, SessionBook, } from '../host/audit/index.js';
14
+ import { Attributor, parseProcessEvents } from '../guest/audit/parse.js';
10
15
  import { RunDir } from '../shared/channel/index.js';
16
+ const DAY = 86400000;
11
17
  const ev = (t, extra = {}) => ({
12
18
  t,
13
19
  k: 'proc',
@@ -15,9 +21,39 @@ const ev = (t, extra = {}) => ({
15
21
  c: 'cargo build',
16
22
  ...extra,
17
23
  });
24
+ /** YYYY-MM-DD, `n` days before today (UTC, like the day files). */
25
+ const day = (n) => new Date(Date.now() - n * DAY).toISOString().slice(0, 10);
26
+ const iso = (msAgo) => new Date(Date.now() - msAgo).toISOString();
27
+ const SHA = (c) => c.repeat(40);
28
+ const settings = (extra = {}) => ({
29
+ enabled: true,
30
+ days: 7,
31
+ maxMb: 50,
32
+ processes: true,
33
+ files: false,
34
+ commits: true,
35
+ projects: [],
36
+ ...extra,
37
+ });
38
+ /** A 4688 event as wevtutil prints it. */
39
+ function procEvent(id, pid, ppid, program, cmd = program, t = iso(1000)) {
40
+ return ("<Event><System><TimeCreated SystemTime='" +
41
+ t +
42
+ "'/><EventRecordID>" +
43
+ id +
44
+ "</EventRecordID></System><EventData><Data Name='NewProcessId'>0x" +
45
+ pid.toString(16) +
46
+ "</Data><Data Name='NewProcessName'>" +
47
+ program +
48
+ "</Data><Data Name='CommandLine'>" +
49
+ cmd +
50
+ "</Data><Data Name='ProcessId'>0x" +
51
+ ppid.toString(16) +
52
+ "</Data><Data Name='ParentProcessName'>C:\\parent.exe</Data></EventData></Event>");
53
+ }
18
54
  export const auditSuite = {
19
55
  name: 'audit',
20
- title: 'audit log (folding, compression, limits, untrusted reports)',
56
+ title: 'audit log (folding, retention, secrets, sessions, commits, untrusted reports, the Activity page)',
21
57
  run: (t) => {
22
58
  t.section('small');
23
59
  const folded = fold([
@@ -31,60 +67,331 @@ export const auditSuite = {
31
67
  ['cargo test', 1],
32
68
  ['cargo build', 1],
33
69
  ], 'repeats within a minute become one line with a count');
34
- const flood = fold(Array.from({ length: 50 }, (_, i) => ev('2026-01-01T10:00:00Z', { c: 'step ' + i })), 10);
35
- 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');
70
+ t.equal(fold([ev('2026-01-01T10:00:01Z', { s: 'a' }), ev('2026-01-01T10:00:02Z', { s: 'b' })]).length, 2, "the same command in two sessions stays two lines (each session's own)");
71
+ const flood = fold([
72
+ ...Array.from({ length: 50 }, (_, i) => ev('2026-01-01T10:00:00Z', { c: 'step ' + i })),
73
+ ev('2026-01-01T10:00:00Z', { k: 'commit', h: SHA('a'), p: 'a commit' }),
74
+ ], 10);
75
+ t.check(flood.length === 12 &&
76
+ flood.some((e) => e.k === 'commit') &&
77
+ flood.at(-1).k === 'note' &&
78
+ flood.at(-1).p.startsWith('40 more'), 'past the per-minute cap the rest is one note; a commit is never dropped');
79
+ t.section('retention: days and size');
36
80
  const dir = t.at('audit-log');
37
81
  const log = new AuditLog(dir, { days: 3, maxMb: 5 });
38
82
  log.append([
39
- ev('2026-01-01T10:00:00Z'),
40
- ev('2026-01-02T10:00:00Z'),
41
- ev('2026-01-04T10:00:00Z', { k: 'file', p: 'src/a.ts', a: 'change', w: 'C:\\app' }),
83
+ ev(day(4) + 'T10:00:00Z'),
84
+ ev(day(1) + 'T10:00:00Z'),
85
+ ev(day(0) + 'T00:00:01Z', { k: 'file', p: 'src/a.ts', a: 'change', w: 'C:\\app' }),
42
86
  ]);
43
- log.maintain('2026-01-04');
44
- 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');
45
- t.includes(zlib.gunzipSync(fs.readFileSync(path.join(dir, '2026-01-02.jsonl.gz'))).toString(), 'cargo build', 'a compressed day still holds its events');
46
- const big = new AuditLog(t.at('audit-big'), { days: 30, maxMb: 5 });
87
+ log.maintain(day(0));
88
+ t.equal(fs.readdirSync(dir).sort(), [day(1) + '.jsonl.gz', day(0) + '.jsonl'], 'finished days are compressed and days past audit.days are deleted');
89
+ t.includes(zlib.gunzipSync(fs.readFileSync(path.join(dir, day(1) + '.jsonl.gz'))).toString(), 'cargo build', 'a compressed day still holds its events');
90
+ // Written while the limit was higher, then kept to 5 MB.
91
+ const roomy = new AuditLog(t.at('audit-big'), { days: 30, maxMb: 50 });
47
92
  // Random text: compression must not make the test data fit after all.
48
- const line = (day) => ev(day + 'T00:00:00Z', { c: crypto.randomBytes(900).toString('hex') });
49
- for (const day of ['2026-02-01', '2026-02-02', '2026-02-03'])
50
- big.append(Array.from({ length: 1400 }, () => line(day)));
51
- big.maintain('2026-02-03');
93
+ const line = (d) => ev(d + 'T00:00:00Z', { c: crypto.randomBytes(900).toString('hex') });
94
+ for (const d of [day(2), day(1), day(0)])
95
+ roomy.append(Array.from({ length: 1400 }, () => line(d)));
96
+ const big = new AuditLog(roomy.dir, { days: 30, maxMb: 5 });
97
+ big.maintain(day(0));
52
98
  t.check(big.bytes() <= 5 * 1024 * 1024 && fs.readdirSync(big.dir).length < 3, 'past audit.maxMb the oldest days go', String(big.bytes()));
99
+ const capped = new AuditLog(t.at('audit-capped'), { days: 30, maxMb: 5 });
100
+ for (let i = 0; i < 3; i++)
101
+ capped.append(Array.from({ length: 1400 }, () => line(day(0))));
102
+ const cappedEvents = capped.read({ days: 1 });
103
+ t.check(capped.bytes() <= 5 * 1024 * 1024 + 4096 &&
104
+ cappedEvents.filter((e) => e.k === 'note' && e.p.includes('audit.maxMb')).length === 1, 'past audit.maxMb nothing more is written while the sandbox runs, but one note saying so', String(capped.bytes()));
105
+ const late = new AuditLog(t.at('audit-late'), { days: 7, maxMb: 5 });
106
+ late.append([ev(day(1) + 'T23:59:58Z', { c: 'before midnight' })]);
107
+ late.maintain(day(0));
108
+ late.append([ev(day(1) + 'T23:59:59Z', { c: 'reported late' })]);
109
+ late.maintain(day(0));
110
+ t.equal(late.read({ days: 3 }).map((e) => e.c), ['before midnight', 'reported late'], "a line that arrives after its day was compressed is added to it: the day's log is never written over");
111
+ const clipped = new AuditLog(t.at('audit-clip'), { days: 7, maxMb: 5 });
112
+ clipped.append([ev(day(0) + 'T00:00:01Z', { k: 'session', w: 'C:\\' + 'x'.repeat(3000) })]);
113
+ t.equal(clipped.read({ days: 1 }).map((e) => e.w?.length), [1024], 'a field longer than its limit is cut, not left to make the line unreadable later');
114
+ const kept = new AuditLog(t.at('audit-kept'), { days: 2, maxMb: 5 });
115
+ kept.append([ev(day(5) + 'T10:00:00Z', { c: 'old' }), ev(day(0) + 'T00:00:01Z', { c: 'new' })]);
116
+ t.equal(kept.read({ days: 30 }).map((e) => e.c), ['new'], 'reading never goes back past audit.days, even before the old day is deleted');
117
+ t.write(path.join(kept.dir, day(1) + '.jsonl.gz'), 'not gzip at all');
118
+ t.equal(kept.read({ days: 2 }).length, 1, 'a damaged day is left out, the rest is read');
53
119
  t.section('reading');
54
- t.equal(log.read({ days: 10000, kinds: ['file'] }).map((e) => e.p), ['src/a.ts'], 'events can be filtered by kind');
55
- t.equal(log.read({ days: 10000, grep: 'SRC/A.TS' }).length, 1, '... and by text (any case)');
120
+ t.equal(log.read({ days: 3, kinds: ['file'] }).map((e) => e.p), ['src/a.ts'], 'events can be filtered by kind');
121
+ t.equal(log.read({ days: 3, grep: 'SRC/A.TS' }).length, 1, '... and by text (any case)');
122
+ t.section('secrets never reach the log');
123
+ const cases = [
124
+ ['curl -H "Authorization: Bearer abcdefghijklmnopqrstuvwxyz" https://x', 'abcdefghijklmnop', 'a header'],
125
+ ['git push https://bob:hunter22@github.com/a/b.git', 'hunter22', 'a password in a URL'],
126
+ ['npm publish --token npm_abcdefghijklmnopqrstuvwxyz0123456789', 'npm_abcdefgh', 'a token flag'],
127
+ ['mytool --password=s3cretvalue --verbose', 's3cretvalue', '--password=value'],
128
+ ['set API_KEY=zzzyyyxxx123 && run', 'zzzyyyxxx123', 'NAME=value'],
129
+ ['mysql -u root -pHunter2pass db', 'Hunter2pass', 'mysql -p<password>'],
130
+ ['echo ghp_abcdefghijklmnopqrstuvwxyz0123456789AB', 'ghp_abcdefghij', 'a token by its shape'],
131
+ ["$env:GITHUB_TOKEN = 'abcdef123456'", 'abcdef123456', 'a PowerShell variable'],
132
+ ['set PGPASSWORD=pgsecret1 && psql', 'pgsecret1', 'a name with the word inside (PGPASSWORD)'],
133
+ ['tool --accessToken abc123def456', 'abc123def456', 'a camelCase flag'],
134
+ ['tool --apiToken=abc123def456', 'abc123def456', 'a camelCase flag with ='],
135
+ ['docker login -u me -p dockerpw1 registry.io', 'dockerpw1', 'docker login -p'],
136
+ ['sshpass -p sshpw123 ssh host', 'sshpw123', 'sshpass -p'],
137
+ ['curl --user=me:curlpw12 https://x', 'curlpw12', 'curl --user=name:password'],
138
+ ['git clone https://abcdef0123456789@github.com/a/b', 'abcdef0123456789', 'a token as the user name of a URL'],
139
+ ['$p = ConvertTo-SecureString "pspw1234" -AsPlainText', 'pspw1234', 'ConvertTo-SecureString'],
140
+ ];
141
+ for (const [cmd, secret, what] of cases) {
142
+ const out = redactCommandLine(cmd);
143
+ t.check(!out.includes(secret) && out.includes('***'), 'hidden: ' + what, out);
144
+ }
145
+ t.equal(redactCommandLine('git commit -m "fix the login page" && cargo build --release'), 'git commit -m "fix the login page" && cargo build --release', 'ordinary command lines stay as they are');
146
+ t.section('which session a program belongs to');
147
+ const attributor = new Attributor();
148
+ const now = Date.now();
149
+ const roots = [
150
+ { session: 's-one', pid: 100, from: now - 60000 },
151
+ { session: 's-old', pid: 300, from: now - 600000, to: now - 300000 },
152
+ ];
153
+ const who = (pid, ppid, at = now) => attributor.attribute({ pid, ppid, t: at }, roots);
154
+ t.equal([who(100, 50), who(101, 100), who(102, 101), who(103, 102)], [undefined, 's-one', 's-one', 's-one'], "the ptyhost itself is no session's; what it starts, and what those start, are its session's");
155
+ t.equal(who(200, 77), undefined, 'a program whose parent was never seen is no session: nothing is guessed');
156
+ t.equal(who(102, 999), undefined, 'a reused process id does not keep the session of the process it had before');
157
+ t.equal(who(104, 102), undefined, '... and neither do the programs it starts');
158
+ t.equal(who(301, 300), undefined, "an ended session's ptyhost id, reused later, is not that session's");
159
+ const xml = procEvent(41, 0x200, 0x100, 'C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force &quot;origin&quot;') +
160
+ procEvent(42, 0x201, 0x200, 'C:\\Windows\\System32\\conhost.exe') +
161
+ procEvent(43, 0x202, 0x201, 'C:\\x\\node.exe', 'node a.js');
162
+ const parsed = parseProcessEvents(xml, {
163
+ attributor: new Attributor(),
164
+ roots: [{ session: 's-two', pid: 0x100, from: now - 60000 }],
165
+ });
166
+ t.equal({
167
+ last: parsed.last,
168
+ lines: parsed.lines.map((l) => JSON.parse(l)).map((e) => [e.p, e.c, e.s]),
169
+ }, {
170
+ last: 43,
171
+ lines: [
172
+ ['C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force "origin"', 's-two'],
173
+ ['C:\\x\\node.exe', 'node a.js', 's-two'],
174
+ ],
175
+ }, "Windows process events become audit lines linked to their session; Windows' own programs are left out but still carry the link");
176
+ t.section('commits, read from the reflog on this PC');
177
+ const repo = t.dir('audit-repo');
178
+ const reflog = t.write(path.join(repo, '.git', 'logs', 'HEAD'), '');
179
+ const reflogLine = (sha, msg, secondsAgo = 5) => SHA('0') +
180
+ ' ' +
181
+ sha +
182
+ ' Ada Lovelace <ada@example.com> ' +
183
+ Math.floor((Date.now() - secondsAgo * 1000) / 1000) +
184
+ ' +0100\t' +
185
+ msg +
186
+ '\n';
187
+ fs.appendFileSync(reflog, reflogLine(SHA('1'), 'commit (initial): before the sandbox'));
188
+ const watcher = new CommitWatcher();
189
+ watcher.prime([repo]);
190
+ fs.appendFileSync(reflog, reflogLine(SHA('2'), 'commit: add the parser') +
191
+ reflogLine(SHA('3'), 'checkout: moving from main to dev') +
192
+ reflogLine(SHA('4'), 'commit (amend): add the parser, fixed') +
193
+ 'garbage line\n');
194
+ const seen = watcher.commits([repo]);
195
+ t.equal(seen.map((c) => [c.sha.slice(0, 1), c.action, c.message, c.author]), [
196
+ ['2', 'commit', 'add the parser', 'Ada Lovelace <ada@example.com>'],
197
+ ['4', 'amend', 'add the parser, fixed', 'Ada Lovelace <ada@example.com>'],
198
+ ], 'only commits made since the first look are reported (checkouts and junk are not commits)');
199
+ t.equal(watcher.commits([repo]), [], 'nothing new, nothing reported');
200
+ fs.writeFileSync(reflog, reflogLine(SHA('5'), 'commit: rewritten'));
201
+ t.equal(watcher.commits([repo]), [], 'a reflog that shrank is not read again from the start');
202
+ fs.mkdirSync(path.join(repo, '.git', 'worktrees', 'wt', 'logs'), { recursive: true });
203
+ const wt = t.write(path.join(repo, '.git', 'worktrees', 'wt', 'logs', 'HEAD'), '');
204
+ watcher.commits([repo]);
205
+ fs.appendFileSync(wt, reflogLine(SHA('6'), 'commit: in a worktree'));
206
+ fs.appendFileSync(reflog, reflogLine(SHA('7'), 'merge feature: Merge made by the ort strategy.'));
207
+ t.equal(watcher
208
+ .commits([repo])
209
+ .map((c) => c.action + ' ' + c.sha.slice(0, 1))
210
+ .sort(), ['commit 6', 'merge 7'], "a worktree's commits are seen too, and merges");
211
+ t.equal(parseReflogLine(SHA('0') + ' ' + SHA('0') + ' x <y> 1 +0000\tcommit: deleted', repo), null, 'a line that does not record a commit (all-zero id) is not one');
212
+ t.check(makesCommits('C:\\git\\cmd\\git.exe', 'git commit -m x') &&
213
+ makesCommits('git', 'git -C app merge dev') &&
214
+ !makesCommits('C:\\git\\cmd\\git.exe', 'git status') &&
215
+ !makesCommits('C:\\x\\node.exe', 'node commit.js'), 'the git commands that can make a commit are told apart from the rest');
216
+ t.section('files and commits: linked only when it can be known');
217
+ const book = new SessionBook();
218
+ const base = Date.now() - 3600000;
219
+ const at = (minutes) => new Date(base + minutes * 60000).toISOString();
220
+ book.update([
221
+ { id: 'a', harness: 'claude', project: 'app', phase: 'exited', startedAt: at(0), updatedAt: at(30) },
222
+ { id: 'b', harness: 'codex', project: 'app', phase: 'running', startedAt: at(20), updatedAt: at(20) },
223
+ { id: 'c', harness: 'claude', project: 'web', phase: 'running', startedAt: at(0), updatedAt: at(0) },
224
+ ], 'app');
225
+ const ms = (minutes) => base + minutes * 60000;
226
+ t.equal([
227
+ book.linkFile('app', ms(10)),
228
+ book.linkFile('app', ms(25)),
229
+ book.linkFile('app', ms(40)),
230
+ book.linkFile('other', ms(10)),
231
+ ], [{ s: 'a', sv: 't' }, undefined, { s: 'b', sv: 't' }, undefined], 'a file change is linked (as likely) only while exactly one session ran in its project');
232
+ t.equal([
233
+ book.linkCommit('app', ms(25), [{ t: ms(24), session: 'b' }]),
234
+ book.linkCommit('app', ms(25), [
235
+ { t: ms(24), session: 'a' },
236
+ { t: ms(24.5), session: 'b' },
237
+ ]),
238
+ book.linkCommit('app', ms(25), [{ t: ms(24), session: 'c' }]),
239
+ ], [{ s: 'b', sv: 'p' }, undefined, undefined], "a commit goes to the session whose git command made it; two candidates or another project's session: none");
240
+ t.equal([book.linkCommit('app', ms(40), [], true), book.linkCommit('app', ms(40), [], false)], [{ s: 'b', sv: 't' }, undefined], 'with no git command seen, a commit is "likely" the only running session only when programs are not recorded');
241
+ book.update([], 'app', ms(50));
242
+ t.equal(book.linkFile('app', ms(55)), undefined, 'a session whose record is gone counts as ended');
56
243
  t.section('what the sandbox reports');
57
- 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 &quot;origin&quot;</Data><Data Name='ParentProcessName'>C:\\nvm\\nodejs\\node.exe</Data></EventData></Event>" +
58
- "<Event><System><EventRecordID>42</EventRecordID></System><EventData><Data Name='NewProcessName'>C:\\Windows\\System32\\conhost.exe</Data></EventData></Event>";
59
- const parsed = parseProcessEvents(xml);
60
- t.equal({ last: parsed.last, lines: parsed.lines.map((l) => JSON.parse(l)).map((e) => [e.p, e.c, e.pp]) }, {
61
- last: 42,
62
- lines: [['C:\\AgentData\\tools\\git\\cmd\\git.exe', 'git push --force "origin"', 'C:\\nvm\\nodejs\\node.exe']],
63
- }, "Windows process events become audit lines; Windows' own background programs are left out");
64
244
  const data = t.at('audit-data', 'data', 'd');
65
245
  const storage = t.at('audit-data');
66
- const reports = new RunDir(data).auditDir;
67
- t.write(path.join(reports, '1-1.jsonl'), [
68
- JSON.stringify({ t: '2026-01-05T09:00:00Z', k: 'guard', p: 'fake: all clear' }),
69
- JSON.stringify({ t: '2026-01-05T09:00:01Z', k: 'proc', p: 'C:\\x.exe', c: 'x --y', w: 'C:\\Users\\me' }),
246
+ const run = new RunDir(data);
247
+ t.writeJson(run.sessionFile('known-1'), {
248
+ id: 'known-1',
249
+ launchId: 'l1',
250
+ harness: 'claude',
251
+ cwd: 'C:\\Projects\\app',
252
+ project: 'app',
253
+ phase: 'running',
254
+ message: 'claude is running',
255
+ updatedAt: iso(60000),
256
+ startedAt: iso(120000),
257
+ });
258
+ t.write(path.join(run.auditDir, '1-1.jsonl'), [
259
+ JSON.stringify({ t: iso(5000), k: 'guard', p: 'fake: all clear', s: 'known-1' }),
260
+ JSON.stringify({
261
+ t: iso(4000),
262
+ k: 'proc',
263
+ p: 'C:\\x.exe',
264
+ c: 'x --token abcdefgh12345678',
265
+ w: 'C:\\Users\\me',
266
+ s: 'known-1',
267
+ ag: 'not-claude',
268
+ }),
269
+ JSON.stringify({ t: '2001-01-01T00:00:00Z', k: 'proc', p: 'C:\\y.exe', s: 'made-up-session' }),
70
270
  'not json',
71
- JSON.stringify({ t: '2026-01-05T09:00:02Z', k: 'proc', p: 'x'.repeat(5000) }),
271
+ JSON.stringify({ t: iso(3000), k: 'proc', p: 'x'.repeat(5000) }),
72
272
  ].join('\n'));
73
- t.write(path.join(reports, 'not-a-report.txt'), 'ignored');
74
- const collector = new AuditCollector(data, storage, {
75
- enabled: true,
76
- days: 14,
77
- maxMb: 50,
78
- processes: true,
79
- files: false,
80
- projects: [],
81
- });
273
+ t.write(path.join(run.auditDir, 'not-a-report.txt'), 'ignored');
274
+ // A folder with a report's name stops nothing.
275
+ fs.mkdirSync(path.join(run.auditDir, '0-0.jsonl'), { recursive: true });
276
+ const project = t.dir('audit-data', 'app');
277
+ const collector = new AuditCollector(data, storage, settings(), () => [{ name: 'app', host: project }]);
82
278
  collector.flush();
83
- const got = new AuditLog(AuditLog.dirFor(storage, data), { days: 14, maxMb: 50 }).read({ days: 100000 });
84
- t.equal(got.map((e) => [e.k, e.p, e.w]), [
85
- ['proc', 'fake: all clear', undefined],
86
- ['proc', 'C:\\x.exe', undefined],
87
- ], 'sandbox reports can only be programs: other kinds are recorded as such, fields it may not set are dropped, oversized lines are refused');
88
- t.check(!fs.existsSync(path.join(reports, '1-1.jsonl')), 'reports are deleted once taken in');
279
+ const got = new AuditLog(AuditLog.dirFor(storage, data), { days: 7, maxMb: 50 }).read({ days: 7 });
280
+ const procs = got.filter((e) => e.k === 'proc');
281
+ t.equal(procs.map((e) => [e.p, e.w, e.s, e.ag, e.pj, e.sv]), [
282
+ ['fake: all clear', undefined, 'known-1', 'claude', 'app', 'p'],
283
+ ['C:\\x.exe', undefined, 'known-1', 'claude', 'app', 'p'],
284
+ ['C:\\y.exe', undefined, undefined, undefined, undefined, undefined],
285
+ ], "sandbox reports can only be programs: other kinds are recorded as such, fields it may not set are dropped, the agent comes from this PC's record, a session this sandbox does not have is dropped, oversized lines are refused");
286
+ t.check(procs[1].c === 'x --token ***' && Math.abs(Date.parse(procs[2].t) - Date.now()) < 60000, 'a reported command line loses its secrets, and a time far from now is taken as now', JSON.stringify(procs));
287
+ t.check(got.some((e) => e.k === 'session' && e.a === 'start' && e.s === 'known-1' && e.ag === 'claude'), 'a session starting is recorded with its id and agent');
288
+ t.check(!fs.existsSync(path.join(run.auditDir, '1-1.jsonl')), 'reports are deleted once taken in');
289
+ // A commit in the project: held until the git command that made it could be reported, then linked to it.
290
+ fs.mkdirSync(path.join(project, '.git', 'logs'), { recursive: true });
291
+ const projectLog = path.join(project, '.git', 'logs', 'HEAD');
292
+ fs.writeFileSync(projectLog, '');
293
+ const second = new AuditCollector(data, storage, settings(), () => [{ name: 'app', host: project }]);
294
+ second.start();
295
+ fs.appendFileSync(projectLog, reflogLine(SHA('9'), 'commit: use the token from --password hunter2x'));
296
+ t.write(path.join(run.auditDir, '2-1.jsonl'), JSON.stringify({ t: iso(2000), k: 'proc', p: 'C:\\git\\cmd\\git.exe', c: 'git commit -m x', s: 'known-1' }));
297
+ second.flush(true);
298
+ const commit = new AuditLog(AuditLog.dirFor(storage, data), { days: 7, maxMb: 50 })
299
+ .read({ days: 7, kinds: ['commit'] })
300
+ .at(-1);
301
+ t.check(commit !== undefined &&
302
+ commit.h === SHA('9') &&
303
+ commit.s === 'known-1' &&
304
+ commit.sv === 'p' &&
305
+ commit.ag === 'claude' &&
306
+ commit.au === 'Ada Lovelace <ada@example.com>' &&
307
+ !commit.p.includes('hunter2x'), 'a commit is recorded with its id, author and message (secrets hidden), linked to the session whose git command made it', JSON.stringify(commit));
308
+ t.section('the Activity page');
309
+ const pageLog = new AuditLog(t.at('audit-page'), { days: 7, maxMb: 100 });
310
+ pageLog.append([
311
+ { t: iso(9000), k: 'session', a: 'start', p: 'claude', s: 'sess-claude-1', ag: 'claude', pj: 'app' },
312
+ { t: iso(8000), k: 'proc', p: 'C:\\git.exe', c: 'git status', s: 'sess-claude-1', ag: 'claude', pj: 'app' },
313
+ {
314
+ t: iso(7000),
315
+ k: 'file',
316
+ a: 'change',
317
+ p: 'src/a.ts',
318
+ w: 'F:\\app',
319
+ pj: 'app',
320
+ s: 'sess-claude-1',
321
+ ag: 'claude',
322
+ sv: 't',
323
+ },
324
+ {
325
+ t: iso(6000),
326
+ k: 'commit',
327
+ a: 'commit',
328
+ p: 'add a',
329
+ h: SHA('a'),
330
+ pj: 'app',
331
+ s: 'sess-claude-1',
332
+ ag: 'claude',
333
+ sv: 'p',
334
+ },
335
+ { t: iso(5000), k: 'proc', p: 'C:\\npm.cmd', c: 'npm test', s: 'sess-codex-22', ag: 'codex', pj: 'web', n: 3 },
336
+ { t: iso(4000), k: 'proc', p: 'C:\\setup.exe', c: 'setup /quiet' },
337
+ { t: iso(3000), k: 'guard', p: 'hook: a pre-commit hook was neutralised' },
338
+ ]);
339
+ const listing = (input = {}) => auditListing(pageLog, { enabled: true, days: 7, maxMb: 100 }, listAudit.input.parse(input), 'C:\\audit');
340
+ const all = listing();
341
+ t.equal([all.totals, all.unlinked, all.sessions.map((s) => [s.id, s.agent, s.commands, s.files, s.commits])], [
342
+ { events: 7, commands: 5, files: 1, commits: 1, sessions: 2 },
343
+ { commands: 1, files: 0, commits: 0 },
344
+ [
345
+ ['sess-codex-22', 'codex', 3, 0, 0],
346
+ ['sess-claude-1', 'claude', 1, 1, 1],
347
+ ],
348
+ ], 'the totals, what no session can be linked to, and each session summed up');
349
+ t.equal([
350
+ listing({ session: 'sess-claude' }).events.length,
351
+ listing({ session: NO_SESSION }).events.map((e) => e.kind),
352
+ listing({ agent: 'CODEX' }).events.map((e) => e.what),
353
+ listing({ kind: 'commit' }).events.map((e) => e.sha),
354
+ listing({ grep: 'npm' }).matched,
355
+ listing({ limit: 2 }).events.map((e) => e.kind),
356
+ listing({ project: 'web' }).totals.commands,
357
+ ], [4, ['guard', 'proc'], ['C:\\npm.cmd'], [SHA('a')], 1, ['guard', 'proc'], 3], 'filters: a session (or the start of its id, or none), an agent, a kind, a text, a limit (the newest), a project');
358
+ const page = auditPage(all);
359
+ const sessionsSection = page.sections.find((s) => s.id === 'sessions');
360
+ const card = sessionsSection?.items?.find((i) => i.id === 'session:sess-claude-1');
361
+ t.check(page.sections.map((s) => s.id).join(',') === 'summary,agents,sessions,events' &&
362
+ sessionsSection?.cards === true &&
363
+ card?.primary?.kind === 'link' &&
364
+ card.primary.target.kind === 'view' &&
365
+ card.primary.target.params?.session === 'sess-claude-1' &&
366
+ sessionsSection.items.some((i) => i.id === 'session:' + NO_SESSION) &&
367
+ page.sections.find((s) => s.id === 'summary').facts.some((f) => f.label === 'Commits' && f.value === '1'), 'the page: totals, per agent, each session as a card that opens its own events, then the events', JSON.stringify(page.sections.map((s) => [s.id, s.items?.length, s.facts?.length])));
368
+ const lines = pageLines(page);
369
+ t.check(lines.some((l) => /^Commits\s+1$/.test(l)) &&
370
+ lines.some((l) => /^\s*SESSION\s+AGENT\s+PROJECT\s+STARTED\s+COMMANDS\s+FILES\s+COMMITS$/.test(l)) &&
371
+ lines.some((l) => /^\s*TIME\s+AGENT\s+KIND\s+WHAT$/.test(l)) &&
372
+ lines.some((l) => /claude\?\s+file\s+changed src\/a\.ts/.test(l)) &&
373
+ lines.some((l) => /codex\s+command\s+npm test {2}×3/.test(l)) &&
374
+ lines.some((l) => l.startsWith('? = the only session')), 'on the command line: the totals, a table of sessions and one of events ("?" marks a likely link)', lines.join('\n'));
375
+ const one = auditPage(listing({ session: 'sess-claude-1' }));
376
+ t.check(one.sections[0].id === 'filter' &&
377
+ one.sections[0].summary.includes('session sess-cla') &&
378
+ !one.sections.some((s) => s.id === 'sessions'), "one session's page says so and offers to show everything instead of the session cards");
379
+ t.equal(pageLines(auditPage({ ...listing({ grep: 'nothing-like-this' }), enabled: false })).at(-1), 'The audit log is off. Turn it on in the menu (Settings > Security & privacy > Audit log) or with: sandbox config set audit.enabled true', 'an empty page says how to turn the log on');
380
+ const off = auditPage({
381
+ ...listing({ grep: 'nothing-like-this' }),
382
+ filter: { days: 1, kind: 'all' },
383
+ enabled: false,
384
+ });
385
+ t.check(off.empty?.action?.kind === 'operation' &&
386
+ off.empty.action.op === 'settings.set' &&
387
+ off.empty.action.input.key === 'audit.enabled', 'with the log off, the empty page offers to turn it on (the settings operation, on every surface)');
388
+ t.equal([auditArgv({ days: '7', kind: 'all', session: 'abc' }), auditArgv({ kind: 'commit' })], [
389
+ ['audit', '--days', '7', '--session', 'abc'],
390
+ ['audit', '--kind', 'commit'],
391
+ ], "the menu shows a session's events with the same command line");
392
+ const menu = menuView(page, { category: 'activity', prefix: 'audit:' });
393
+ const chosen = menu.resolve({ kind: 'pick', value: 'audit:session:sess-claude-1' });
394
+ t.check(chosen?.kind === 'action' && chosen.action.kind === 'link' && chosen.action.target.kind === 'view', "in the menu, Enter on a session's row opens its events");
395
+ t.equal(auditView.inputs({ days: '14', kind: 'bogus', session: 'x' }), { audit: { days: 14, kind: 'all', session: 'x' } }, "the web page's address becomes the query's input (values it does not know are left at their default)");
89
396
  },
90
397
  };
@@ -35,6 +35,22 @@ export const channelSuite = {
35
35
  t.check(validSession(goodSession) &&
36
36
  !validSession({ ...goodSession, attach: { ...goodSession.attach, port: 70000 } }) &&
37
37
  !validSession({ ...goodSession, phase: 'pwned' }), 'session status: a well-formed one passes; an impossible port or unknown phase is rejected');
38
+ const shellRequest = {
39
+ kind: 'session',
40
+ id: 'r1',
41
+ harness: 'shell',
42
+ args: [],
43
+ cwd: 'C:\\Workspace',
44
+ shell: 'pwsh',
45
+ createdAt: 'x',
46
+ };
47
+ t.check(validRequest(shellRequest) &&
48
+ validRequest({ ...shellRequest, shell: 'auto' }) &&
49
+ !validRequest({ ...shellRequest, shell: 'bash -c evil' }) &&
50
+ validRequest({ kind: 'close', id: 'c1', session: 's1', createdAt: 'x' }) &&
51
+ !validRequest({ kind: 'close', id: 'c1', session: '..\\s1', createdAt: 'x' }) &&
52
+ validSession({ ...goodSession, harness: 'pwsh', kind: 'shell' }) &&
53
+ !validSession({ ...goodSession, kind: 'daemon' }), 'shells: a session request names one of the known shells; close names a safe session id; a status says shell');
38
54
  t.section('boot progress');
39
55
  const goodStatus = {
40
56
  launchId: 'l1',
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import path from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { isWin } from '../core/util/index.js';
7
+ import { commandLine, shellLaunch } from '../guest/sessions/cmdline.js';
7
8
  import { runNode } from './support/fixtures.js';
8
9
  // Odd quote counts flip cmd's quote state for every later argument: those come after them on purpose.
9
10
  const TRICKY_ARGUMENTS = [
@@ -71,5 +72,20 @@ export const cmdlineSuite = {
71
72
  }
72
73
  else
73
74
  t.skip('end-to-end cmd.exe quoting run (not Windows, or the temp path has spaces)');
75
+ t.section('plain shell sessions');
76
+ const paths = { pwsh: 'C:/T/pwsh/pwsh.exe', powershell: 'C:/W/powershell.exe', cmd: 'C:/W/cmd.exe' };
77
+ const noPwsh = { ...paths, pwsh: null };
78
+ t.equal([
79
+ shellLaunch('auto', paths),
80
+ shellLaunch('auto', noPwsh),
81
+ shellLaunch('powershell', paths),
82
+ shellLaunch('cmd', paths),
83
+ ].map((l) => l.name + ' ' + commandLine(l.file, l.args)), [
84
+ 'pwsh C:/T/pwsh/pwsh.exe -NoLogo',
85
+ 'powershell C:/W/powershell.exe -NoLogo',
86
+ 'powershell C:/W/powershell.exe -NoLogo',
87
+ 'cmd C:/W/cmd.exe',
88
+ ], 'a shell runs by its absolute path: auto is pwsh when present, else Windows PowerShell; cmd takes no arguments');
89
+ await t.throws(() => shellLaunch('pwsh', noPwsh), 'PowerShell 7 is not installed', 'asking for pwsh where it is missing says so');
74
90
  },
75
91
  };