windows-development-sandbox 1.11.7 → 1.12.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 (121) hide show
  1. package/README.md +12 -6
  2. package/config.example.json +4 -3
  3. package/dist/app/features/apps/apps-rows.d.ts +4 -1
  4. package/dist/app/features/apps/apps-rows.js +7 -2
  5. package/dist/app/features/apps/apps.def.d.ts +19 -0
  6. package/dist/app/features/apps/apps.def.js +49 -12
  7. package/dist/app/features/apps/apps.run.d.ts +2 -0
  8. package/dist/app/features/apps/apps.run.js +1 -1
  9. package/dist/app/features/audit/audit.def.d.ts +341 -0
  10. package/dist/app/features/audit/audit.def.js +454 -0
  11. package/dist/app/features/audit/audit.run.d.ts +107 -0
  12. package/dist/app/features/audit/audit.run.js +133 -0
  13. package/dist/app/features/audit/index.d.ts +2 -0
  14. package/dist/app/features/audit/index.js +3 -0
  15. package/dist/app/features/defs.d.ts +194 -1
  16. package/dist/app/features/defs.js +4 -0
  17. package/dist/app/features/settings/settings.run.d.ts +3 -0
  18. package/dist/app/features/settings/settings.run.js +16 -2
  19. package/dist/app/kernel/define.d.ts +12 -1
  20. package/dist/app/registry.js +2 -0
  21. package/dist/cli/args.d.ts +0 -3
  22. package/dist/cli/args.js +3 -4
  23. package/dist/cli/commands/index.d.ts +1 -1
  24. package/dist/cli/commands/index.js +1 -1
  25. package/dist/cli/commands/launch/prepare.js +1 -1
  26. package/dist/cli/commands/maintenance/index.d.ts +0 -1
  27. package/dist/cli/commands/maintenance/index.js +0 -1
  28. package/dist/cli/commands/registry.js +1 -2
  29. package/dist/cli/manifest.d.ts +0 -41
  30. package/dist/cli/manifest.js +0 -14
  31. package/dist/cli/menu/pages/apps-page.js +2 -15
  32. package/dist/cli/menu/pages/maintenance-page.d.ts +4 -0
  33. package/dist/cli/menu/pages/maintenance-page.js +51 -8
  34. package/dist/cli/menu/views.js +2 -0
  35. package/dist/core/config/defaults.js +1 -1
  36. package/dist/core/schema/apps.d.ts +9 -0
  37. package/dist/core/schema/apps.js +9 -0
  38. package/dist/core/schema/config.d.ts +1 -0
  39. package/dist/core/schema/config.js +3 -3
  40. package/dist/core/schema/sandbox.d.ts +1 -0
  41. package/dist/core/schema/sandbox.js +11 -3
  42. package/dist/guest/apps/app-installer.js +50 -4
  43. package/dist/guest/apps/index.d.ts +1 -1
  44. package/dist/guest/apps/index.js +1 -1
  45. package/dist/guest/apps/sources.js +1 -1
  46. package/dist/guest/audit/parse.d.ts +32 -2
  47. package/dist/guest/audit/parse.js +43 -5
  48. package/dist/guest/audit/process-audit.d.ts +3 -1
  49. package/dist/guest/audit/process-audit.js +9 -6
  50. package/dist/guest/boot/boot.js +12 -3
  51. package/dist/guest/boot/guide.d.ts +1 -1
  52. package/dist/guest/boot/guide.js +27 -3
  53. package/dist/guest/daemon/sessions.d.ts +7 -0
  54. package/dist/guest/daemon/sessions.js +27 -2
  55. package/dist/guest/entries/daemon.js +2 -1
  56. package/dist/guest/setup/addon-mcp.d.ts +2 -0
  57. package/dist/guest/setup/addon-mcp.js +49 -0
  58. package/dist/guest/setup/agent-addons.d.ts +9 -0
  59. package/dist/guest/setup/agent-addons.js +22 -0
  60. package/dist/guest/setup/headroom.d.ts +7 -0
  61. package/dist/guest/setup/headroom.js +60 -0
  62. package/dist/guest/setup/managed-policy.d.ts +2 -1
  63. package/dist/guest/setup/managed-policy.js +15 -6
  64. package/dist/guest/toolchains/plan.js +8 -3
  65. package/dist/host/audit/attribute.d.ts +46 -0
  66. package/dist/host/audit/attribute.js +76 -0
  67. package/dist/host/audit/collect.d.ts +29 -3
  68. package/dist/host/audit/collect.js +256 -61
  69. package/dist/host/audit/git-commits.d.ts +29 -0
  70. package/dist/host/audit/git-commits.js +157 -0
  71. package/dist/host/audit/index.d.ts +5 -0
  72. package/dist/host/audit/index.js +6 -0
  73. package/dist/host/audit/log.d.ts +44 -7
  74. package/dist/host/audit/log.js +148 -45
  75. package/dist/host/audit/redact.d.ts +2 -0
  76. package/dist/host/audit/redact.js +67 -0
  77. package/dist/runtime/agent.js +110 -15
  78. package/dist/runtime/daemon.js +569 -316
  79. package/dist/runtime/db.js +110 -15
  80. package/dist/runtime/main.js +1011 -672
  81. package/dist/runtime/netproxy.js +110 -15
  82. package/dist/runtime/view.js +110 -15
  83. package/dist/selftest/app.test.js +25 -1
  84. package/dist/selftest/audit.test.js +354 -47
  85. package/dist/selftest/hcs.test.js +35 -0
  86. package/dist/selftest/menu-screens.js +1 -1
  87. package/dist/selftest/menu.test.js +3 -3
  88. package/dist/selftest/support/engine-golden.js +9 -0
  89. package/dist/selftest/support/fixtures.d.ts +1 -0
  90. package/dist/selftest/support/web-preview.js +94 -0
  91. package/dist/selftest/toolchains.test.js +147 -2
  92. package/dist/shared/apps/agent-addons.d.ts +64 -0
  93. package/dist/shared/apps/agent-addons.js +95 -0
  94. package/dist/shared/apps/catalogue.d.ts +33 -2
  95. package/dist/shared/apps/catalogue.js +102 -11
  96. package/dist/shared/apps/index.d.ts +3 -2
  97. package/dist/shared/apps/index.js +3 -2
  98. package/dist/shared/apps/selection.d.ts +2 -0
  99. package/dist/shared/apps/selection.js +7 -0
  100. package/dist/sync/watcher.js +9 -1
  101. package/dist/web/index.html +2 -2
  102. package/dist/web/{main-RQFN4WBY.css → main-UAQPI5XD.css} +1 -1
  103. package/dist/web/{main-K2JMCAZI.js → main-UD4QCXZR.js} +66 -66
  104. package/native/csharp/HcsHost/AssemblyInfo.cs +5 -3
  105. package/native/csharp/HcsHost/Hcs.cs +9 -0
  106. package/native/csharp/HcsHost/Program.cs +26 -1
  107. package/native/csharp/HcsHost/Share.cs +173 -0
  108. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  109. package/package.json +1 -1
  110. package/sandbox/addons/ccusage/package-lock.json +111 -0
  111. package/sandbox/addons/ccusage/package.json +7 -0
  112. package/sandbox/addons/context7/package-lock.json +1313 -0
  113. package/sandbox/addons/context7/package.json +7 -0
  114. package/sandbox/addons/headroom/requirements.in +1 -0
  115. package/sandbox/addons/headroom/requirements.txt +2922 -0
  116. package/sandbox/addons/playwright-mcp/package-lock.json +56 -0
  117. package/sandbox/addons/playwright-mcp/package.json +7 -0
  118. package/schemas/config.schema.json +13 -4
  119. package/schemas/sandbox-config.schema.json +2 -2
  120. package/dist/cli/commands/maintenance/audit.d.ts +0 -3
  121. package/dist/cli/commands/maintenance/audit.js +0 -58
@@ -1,12 +1,13 @@
1
1
  // audit.processes, sandbox side: Windows records every program started (Security log, event 4688, with its command
2
- // line); the daemon reads the new events every 15 s and leaves them in run\audit\ as small .jsonl files, which the
3
- // host takes into its own log (and deletes). Windows' own background programs are left out. The host treats all of it
4
- // as untrusted (it is checked line by line there).
2
+ // line and process ids); the daemon reads the new events every 15 s, links each to the agent session whose ptyhost
3
+ // started it (directly or further down), and leaves them in run\audit\ as small .jsonl files, which the host takes
4
+ // into its own log (and deletes). Windows' own background programs are left out. The host treats all of it as
5
+ // untrusted (it is checked line by line there, and a session it does not know is dropped).
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { spawnSync } from 'node:child_process';
8
9
  import { SYSTEM } from '../../shared/system.js';
9
- import { parseProcessEvents } from './parse.js';
10
+ import { Attributor, parseProcessEvents } from './parse.js';
10
11
  import { dryRun, isWin, run } from '../runtime/paths.js';
11
12
  import { log, warn } from '../runtime/log.js';
12
13
  import { mkdirp } from '../runtime/fs.js';
@@ -44,11 +45,13 @@ const newestRecord = () => {
44
45
  };
45
46
  /**
46
47
  * Reads new process events every 15 s for the daemon's life (from the moment it started: older ones are boot noise).
48
+ * `roots` gives the sessions' ptyhosts, so each program is linked to the session that started it (parse.ts).
47
49
  */
48
- export function startProcessCollector() {
50
+ export function startProcessCollector(roots = () => []) {
49
51
  if (!auditOn())
50
52
  return;
51
53
  let last = newestRecord();
54
+ const attributor = new Attributor();
52
55
  const pass = () => {
53
56
  try {
54
57
  const r = spawnSync(wevtutil(), [
@@ -58,7 +61,7 @@ export function startProcessCollector() {
58
61
  '/f:xml',
59
62
  '/c:' + MAX_PER_PASS,
60
63
  ], { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
61
- const { lines, last: newest } = parseProcessEvents(r.stdout ?? '');
64
+ const { lines, last: newest } = parseProcessEvents(r.stdout ?? '', { attributor, roots: roots() });
62
65
  if (newest)
63
66
  last = newest;
64
67
  if (lines.length)
@@ -16,6 +16,8 @@ import { runHooks } from '../setup/hooks.js';
16
16
  import { applyAllowOnly, proxyEnv } from '../network/allow-only.js';
17
17
  import { applyNetworkPolicy, networkProblems } from '../network/firewall.js';
18
18
  import { writeManagedSettings } from '../setup/managed-policy.js';
19
+ import { writeAddonMcpServers } from '../setup/addon-mcp.js';
20
+ import { startHeadroom } from '../setup/headroom.js';
19
21
  import { applyPersonalization } from '../setup/desktop.js';
20
22
  import { setupGitConfig } from '../setup/git-identity.js';
21
23
  import { loginGithub, prepareGithubFolder } from '../setup/github-token.js';
@@ -144,7 +146,7 @@ export class Boot {
144
146
  const { tools, env } = await this.developerTools();
145
147
  await this.vpn();
146
148
  await this.gitIdentity(env);
147
- await this.claude(tools);
149
+ await this.claude(tools, env);
148
150
  await this.agent(env);
149
151
  this.preAgentHooks(env);
150
152
  await this.start(env);
@@ -230,15 +232,22 @@ export class Boot {
230
232
  await soft('GitHub token', () => loginGithub(token, env));
231
233
  }
232
234
  }
233
- async claude(tools) {
235
+ async claude(tools, env) {
234
236
  step(6, 'Claude settings, history and claude-mem');
235
237
  if (session.harnessName === 'claude')
236
238
  await soft('Claude settings', () => syncClaudeProfile(this.secrets.claudeCredentials));
237
239
  await soft('claude-mem restore', restoreClaudeMem);
238
240
  await soft('Agent files of the last sandbox', resetMachineBound);
239
241
  await soft('Agent databases restore', restoreLocalState);
242
+ // Agent add-ons: MCP servers in the sandbox's Claude Code, and the Headroom proxy before the managed settings point
243
+ // Claude Code at it (the guide reads them back, so it comes last).
244
+ let headroom = null;
245
+ await soft('MCP servers from Apps', writeAddonMcpServers);
246
+ await soft('Headroom proxy', async () => {
247
+ headroom = await startHeadroom(env);
248
+ });
249
+ await soft('Claude managed settings', () => writeManagedSettings(this.gitAccess, headroom));
240
250
  await soft('Sandbox guide for the agent', () => writeGuides(tools.summary, this.lan));
241
- await soft('Claude managed settings', () => writeManagedSettings(this.gitAccess));
242
251
  }
243
252
  async agent(env) {
244
253
  step(7, 'Agent: ' + session.harnessName);
@@ -3,7 +3,7 @@ export interface GuideProject {
3
3
  guest: string;
4
4
  host: string;
5
5
  }
6
- export declare function sandboxGuide(toolSummary: string[], lan?: 'blocked' | 'open' | 'offline', projects?: GuideProject[]): string;
6
+ export declare function sandboxGuide(toolSummary: string[], lan?: 'blocked' | 'open' | 'offline', projects?: GuideProject[], addons?: string[]): string;
7
7
  /** Inserts or replaces the generated section, keeping whatever else the file contains (the host's CLAUDE.md). */
8
8
  export declare function upsertGuide(file: string, guide: string): void;
9
9
  /**
@@ -3,7 +3,9 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { claudeDir, session, stateDir, workspace } from '../runtime/session.js';
6
- import { dataDir, launchDir, toolsDir } from '../runtime/paths.js';
6
+ import { dataDir, launchDir, logsDir, toolsDir } from '../runtime/paths.js';
7
+ import { addonGuideLine, APPS, headroomGuideLine, RTK_GUIDE_LINE } from '../../shared/apps/index.js';
8
+ import { installedAddon, installedRtk } from '../setup/agent-addons.js';
7
9
  const BEGIN = '<!-- agent-sandbox:begin (generated every boot, do not edit) -->';
8
10
  const END = '<!-- agent-sandbox:end -->';
9
11
  /** Drives other than C: are subst drives onto C:\Mirror\<letter> (mapped folders shown at their PC paths). */
@@ -20,7 +22,7 @@ const mirrorLine = () => {
20
22
  '`; real-path tools may print that form).',
21
23
  ];
22
24
  };
23
- export function sandboxGuide(toolSummary, lan = 'blocked', projects = []) {
25
+ export function sandboxGuide(toolSummary, lan = 'blocked', projects = [], addons = []) {
24
26
  return [
25
27
  BEGIN,
26
28
  '## Sandbox manifest — you are in a DISPOSABLE Windows Sandbox',
@@ -45,6 +47,8 @@ export function sandboxGuide(toolSummary, lan = 'blocked', projects = []) {
45
47
  '- RO: `' + launchDir + "` (this boot's scripts) · lost on close: everything else, incl. `C:\\nvm` (Node symlink)",
46
48
  ...mirrorLine(),
47
49
  '- Tools: ' + toolSummary.join(' · '),
50
+ // Agent add-ons that change how the agent works (rtk, Headroom, MCP servers, ccusage).
51
+ ...addons,
48
52
  '- Persistent installs: `npm i -g` (per nvm version), `pip`, `cargo install`, `dotnet tool -g`, `uv tool`, `bun add -g`. Real installers must re-run each boot via a hook (ask the user).',
49
53
  '- Mapped folders (workspace, C:\\AgentData) allow NO symlinks or junctions: pnpm runs hoisted (set), npm workspaces / `file:` deps and deno node_modules dirs fail there (use a folder on C:\\ outside them), bun cannot save its lockfile there (`bun install --no-save`).',
50
54
  '- Network: ' +
@@ -75,8 +79,28 @@ export function upsertGuide(file, guide) {
75
79
  export function managedClaudeMd() {
76
80
  return path.join(process.env.AGENT_MANAGED_DIR ?? path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'ClaudeCode'), 'CLAUDE.md');
77
81
  }
82
+ /** Whether the managed settings point Claude Code at the Headroom proxy (written just before the guide). */
83
+ const headroomRuns = () => {
84
+ try {
85
+ const file = path.join(path.dirname(managedClaudeMd()), 'managed-settings.json');
86
+ const settings = JSON.parse(fs.readFileSync(file, 'utf8'));
87
+ return typeof settings.env?.ANTHROPIC_BASE_URL === 'string';
88
+ }
89
+ catch {
90
+ return false; // no managed settings: nothing points at the proxy
91
+ }
92
+ };
93
+ /** The lines for the agent add-ons that are on and installed (rtk, Headroom, MCP servers, ccusage). */
94
+ function addonLines() {
95
+ const mcp = APPS.flatMap((a) => (a.mcp && installedAddon(a.id) ? [a.mcp.name] : []));
96
+ return [
97
+ ...(installedRtk() ? [RTK_GUIDE_LINE] : []),
98
+ ...(installedAddon('headroom') && headroomRuns() ? [headroomGuideLine(path.join(logsDir, 'headroom.log'))] : []),
99
+ ...addonGuideLine(mcp, Boolean(installedAddon('ccusage'))),
100
+ ];
101
+ }
78
102
  export function writeGuides(toolSummary, lan, projects = []) {
79
- const guide = sandboxGuide(toolSummary, lan, projects);
103
+ const guide = sandboxGuide(toolSummary, lan, projects, addonLines());
80
104
  const managed = managedClaudeMd();
81
105
  fs.mkdirSync(path.dirname(managed), { recursive: true });
82
106
  fs.writeFileSync(managed, guide + '\n', 'utf8');
@@ -1,4 +1,5 @@
1
1
  import type { GuestRequest } from '../../core/types.js';
2
+ import type { SessionRoot } from '../audit/parse.js';
2
3
  type SessionRequest = Extract<GuestRequest, {
3
4
  kind: 'session';
4
5
  }>;
@@ -14,8 +15,14 @@ export declare class SessionManager {
14
15
  /** Sessions and projects being set up right now (a headless sandbox must not shut down under them). */
15
16
  private starting;
16
17
  private readonly idle;
18
+ /** Each session's ptyhost (process id and when it ran), for linking the programs it starts to it (audit). */
19
+ private readonly ptyHosts;
17
20
  /** How many sessions are running. */
18
21
  get count(): number;
22
+ /** The sessions' ptyhosts, running or ended within ROOTS_KEPT_MS (audit events arrive after they happen). */
23
+ roots(): SessionRoot[];
24
+ private rootStarted;
25
+ private rootEnded;
19
26
  /** `session` requests: an agent in its own ptyhost, in the launch folder's project or a registered one. */
20
27
  start(req: SessionRequest): Promise<void>;
21
28
  /**
@@ -31,6 +31,8 @@ import { errMsg } from '../../core/util/errors.js';
31
31
  import { provisioner } from './project-provisioner.js';
32
32
  import { IdleShutdown } from './idle-shutdown.js';
33
33
  import { insidePrimary, respond, safely, saveStatus } from './state.js';
34
+ /** An ended session's ptyhost is remembered this long: its last programs reach the audit up to 15 s later. */
35
+ const ROOTS_KEPT_MS = 10 * 60 * 1000;
34
36
  function terminalTab(id, title, cwd) {
35
37
  const view = [process.execPath, path.join(path.dirname(process.argv[1]), 'view.js'), id];
36
38
  const wt = path.join(toolsDir, 'terminal', 'wt.exe');
@@ -94,7 +96,7 @@ function writeBudget(cpus, memoryGb, jobs, env) {
94
96
  }
95
97
  }
96
98
  /** Starts ptyhost for the agent and waits until it listens; a failure carries the tail of ptyhost's own log. */
97
- const launchPtyHost = async (req, remote, env, say, sessions) => {
99
+ const launchPtyHost = async (req, remote, env, say, sessions, onSpawn) => {
98
100
  const harness = harnessFor(req);
99
101
  // Never refresh here: main already did, and replacing a running agent's executable would fail.
100
102
  say('Preparing ' + req.harness);
@@ -116,6 +118,7 @@ const launchPtyHost = async (req, remote, env, say, sessions) => {
116
118
  command: commandLine(launch.file, [...base, ...req.args]),
117
119
  restartCommand: commandLine(launch.file, [...base, ...(harness.resumeArguments ?? [])]),
118
120
  });
121
+ onSpawn(child);
119
122
  try {
120
123
  await waitForPtyHost(child, port, 15000);
121
124
  }
@@ -160,10 +163,30 @@ export class SessionManager {
160
163
  /** Sessions and projects being set up right now (a headless sandbox must not shut down under them). */
161
164
  starting = 0;
162
165
  idle = new IdleShutdown(() => this.live.size > 0 || this.starting > 0);
166
+ /** Each session's ptyhost (process id and when it ran), for linking the programs it starts to it (audit). */
167
+ ptyHosts = new Map();
163
168
  /** How many sessions are running. */
164
169
  get count() {
165
170
  return this.live.size;
166
171
  }
172
+ /** The sessions' ptyhosts, running or ended within ROOTS_KEPT_MS (audit events arrive after they happen). */
173
+ roots() {
174
+ const cutoff = Date.now() - ROOTS_KEPT_MS;
175
+ for (const [id, r] of this.ptyHosts)
176
+ if (r.to !== undefined && r.to < cutoff)
177
+ this.ptyHosts.delete(id);
178
+ return [...this.ptyHosts.values()];
179
+ }
180
+ rootStarted(id, child, from) {
181
+ if (child.pid)
182
+ this.ptyHosts.set(id, { session: id, pid: child.pid, from });
183
+ child.once('exit', () => this.rootEnded(id));
184
+ }
185
+ rootEnded(id) {
186
+ const r = this.ptyHosts.get(id);
187
+ if (r && r.to === undefined)
188
+ this.ptyHosts.set(id, { ...r, to: Date.now() });
189
+ }
167
190
  /** `session` requests: an agent in its own ptyhost, in the launch folder's project or a registered one. */
168
191
  async start(req) {
169
192
  let status = {
@@ -190,7 +213,8 @@ export class SessionManager {
190
213
  try {
191
214
  const place = await placeFor(req, say);
192
215
  const remote = session.networking && session.attach.enabled;
193
- const { child, port, token } = await launchPtyHost(req, remote, place.env, say, this.live.size + 1);
216
+ const from = Date.now();
217
+ const { child, port, token } = await launchPtyHost(req, remote, place.env, say, this.live.size + 1, (c) => this.rootStarted(req.id, c, from));
194
218
  const resumed = req.harness === 'claude' ? resumedConversation(req.args) : undefined;
195
219
  const running = {
196
220
  ...status,
@@ -218,6 +242,7 @@ export class SessionManager {
218
242
  ')');
219
243
  }
220
244
  catch (e) {
245
+ this.rootEnded(req.id);
221
246
  saveStatus({ ...status, phase: 'failed', message: errMsg(e) });
222
247
  warn('Session ' + req.id + ' failed: ' + errMsg(e));
223
248
  this.idle.check();
@@ -9,13 +9,14 @@ import { log, warn } from '../runtime/log.js';
9
9
  import { adoptBootSecrets } from '../runtime/secrets.js';
10
10
  import { errMsg } from '../../core/util/errors.js';
11
11
  import { Daemon } from '../daemon/daemon.js';
12
+ import { sessions } from '../daemon/sessions.js';
12
13
  import { governor } from '../governor/index.js';
13
14
  process.on('uncaughtException', (e) => warn('daemon: unexpected error (continuing): ' + (e.stack ?? errMsg(e))));
14
15
  process.on('unhandledRejection', (e) => warn('daemon: unexpected error (continuing): ' + errMsg(e)));
15
16
  adoptBootSecrets();
16
17
  log('Session daemon started (pid ' + process.pid + ').');
17
18
  safely('port forwards', startRelays);
18
- safely('audit', startProcessCollector);
19
+ safely('audit', () => startProcessCollector(() => sessions.roots()));
19
20
  safely('memory governor', () => governor.start());
20
21
  log('Memory governor: ' + governor.describe());
21
22
  new Daemon().start();
@@ -0,0 +1,2 @@
1
+ /** Sets the add-ons' MCP servers in the sandbox's .claude.json; the names that are on. */
2
+ export declare function writeAddonMcpServers(): string[];
@@ -0,0 +1,49 @@
1
+ // The agent add-ons' MCP servers (Playwright, Context7) in the sandbox's own Claude Code: user-scope servers named
2
+ // sbx-... in its .claude.json, set every boot while the add-on is on and removed again when it is off. Your servers
3
+ // (from this PC or added in the sandbox) are never touched, and nothing on this PC changes.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { addonMcpServer, APPS, withAddonMcpServers } from '../../shared/apps/index.js';
7
+ import { writeJsonAtomic } from '../../shared/io/json.js';
8
+ import { claudeDir } from '../runtime/session.js';
9
+ import { log, warn } from '../runtime/log.js';
10
+ import { installedMcpAddons } from './agent-addons.js';
11
+ /** The names of every MCP server an add-on can add (the ones the sandbox may remove). */
12
+ const ADDON_MCP_NAMES = APPS.flatMap((a) => (a.mcp ? [a.mcp.name] : []));
13
+ /** Sets the add-ons' MCP servers in the sandbox's .claude.json; the names that are on. */
14
+ export function writeAddonMcpServers() {
15
+ const servers = {};
16
+ for (const { id, folder } of installedMcpAddons()) {
17
+ const mcp = APPS.find((a) => a.id === id)?.mcp;
18
+ if (mcp)
19
+ servers[mcp.name] = addonMcpServer(folder, mcp);
20
+ }
21
+ const file = path.join(claudeDir, '.claude.json');
22
+ let state = {};
23
+ if (fs.existsSync(file)) {
24
+ try {
25
+ const text = fs.readFileSync(file, 'utf8');
26
+ state = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
27
+ }
28
+ catch {
29
+ // Never rewrite a file Claude Code could not read either: its login and state are in it.
30
+ warn('Claude Code MCP servers from Apps: ' + file + ' is not valid JSON, left as it is');
31
+ return [];
32
+ }
33
+ }
34
+ else if (!Object.keys(servers).length)
35
+ return [];
36
+ if (typeof state !== 'object' || state === null || Array.isArray(state))
37
+ return [];
38
+ const current = state.mcpServers;
39
+ const before = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
40
+ const after = withAddonMcpServers(before, servers, ADDON_MCP_NAMES);
41
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
42
+ fs.mkdirSync(claudeDir, { recursive: true });
43
+ writeJsonAtomic(file, { ...state, mcpServers: after });
44
+ }
45
+ const names = Object.keys(servers);
46
+ if (names.length)
47
+ log('Claude Code MCP servers from Apps: ' + names.join(', '));
48
+ return names;
49
+ }
@@ -0,0 +1,9 @@
1
+ /** The add-on's program when it is chosen and installed, else null. */
2
+ export declare function installedAddon(id: string): string | null;
3
+ /** rtk.exe when rtk is chosen and installed, else null. */
4
+ export declare function installedRtk(): string | null;
5
+ /** The add-ons with an MCP server that are chosen and installed: their folder and server. */
6
+ export declare function installedMcpAddons(): {
7
+ id: string;
8
+ folder: string;
9
+ }[];
@@ -0,0 +1,22 @@
1
+ // Agent add-ons in this sandbox: an add-on is plugged into the agents only while it is chosen (apps.install) AND its
2
+ // program is really there (a failed download adds no hook, proxy or MCP server that would fail on every use).
3
+ import { APPS, appById } from '../../shared/apps/index.js';
4
+ import { findExe } from '../apps/find-exe.js';
5
+ import { dryRun } from '../runtime/paths.js';
6
+ import { session } from '../runtime/session.js';
7
+ import { tool } from '../toolchains/tool-env.js';
8
+ /** The add-on's program when it is chosen and installed, else null. */
9
+ export function installedAddon(id) {
10
+ const entry = appById(id);
11
+ if (dryRun || !entry || !(session.apps?.install ?? []).includes(id))
12
+ return null;
13
+ return findExe(tool('apps', id), entry.exe);
14
+ }
15
+ /** rtk.exe when rtk is chosen and installed, else null. */
16
+ export function installedRtk() {
17
+ return installedAddon('rtk');
18
+ }
19
+ /** The add-ons with an MCP server that are chosen and installed: their folder and server. */
20
+ export function installedMcpAddons() {
21
+ return APPS.filter((a) => a.mcp && installedAddon(a.id)).map((a) => ({ id: a.id, folder: tool('apps', a.id) }));
22
+ }
@@ -0,0 +1,7 @@
1
+ /** The proxy's log (it redacts keys and tokens in what it logs). */
2
+ export declare const HEADROOM_LOG: string;
3
+ /**
4
+ * Starts the proxy when Headroom is chosen and installed; its base URL once it answers, else null (Claude Code then
5
+ * talks to the API directly).
6
+ */
7
+ export declare function startHeadroom(env: NodeJS.ProcessEnv): Promise<string | null>;
@@ -0,0 +1,60 @@
1
+ // The Headroom agent add-on: its proxy runs detached on 127.0.0.1 for the sandbox's life, and Claude Code is pointed at
2
+ // it (managed-policy.ts) only once it answers as Headroom. It forwards only to Anthropic's API, starts without the
3
+ // agents' keys in its environment and with HEADROOM_OFFLINE (no telemetry, update checks or model downloads).
4
+ import fs from 'node:fs';
5
+ import http from 'node:http';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { HEADROOM_PORT, headroomArgs, headroomEnv, headroomUrl } from '../../shared/apps/index.js';
9
+ import { logsDir } from '../runtime/paths.js';
10
+ import { portOpen, waitForPort } from '../runtime/ports.js';
11
+ import { log } from '../runtime/log.js';
12
+ import { installedAddon } from './agent-addons.js';
13
+ /** The proxy's log (it redacts keys and tokens in what it logs). */
14
+ export const HEADROOM_LOG = path.join(logsDir, 'headroom.log');
15
+ /** Whether Headroom's own health check answers on the port (not some other program that took it). */
16
+ function answersAsHeadroom(port) {
17
+ return new Promise((resolve) => {
18
+ const req = http.get({ host: '127.0.0.1', port, path: '/livez', timeout: 3000 }, (res) => {
19
+ let body = '';
20
+ res.setEncoding('utf8');
21
+ res.on('data', (chunk) => {
22
+ if (body.length < 4096)
23
+ body += chunk;
24
+ });
25
+ res.on('end', () => resolve(res.statusCode === 200 && /"service"\s*:\s*"headroom-proxy"/.test(body)));
26
+ });
27
+ req.on('timeout', () => req.destroy());
28
+ req.on('error', () => resolve(false));
29
+ });
30
+ }
31
+ /**
32
+ * Starts the proxy when Headroom is chosen and installed; its base URL once it answers, else null (Claude Code then
33
+ * talks to the API directly).
34
+ */
35
+ export async function startHeadroom(env) {
36
+ const exe = installedAddon('headroom');
37
+ if (!exe)
38
+ return null;
39
+ if (env.ANTHROPIC_BASE_URL) {
40
+ log('Headroom: not used, ANTHROPIC_BASE_URL is set in the agent environment (' + env.ANTHROPIC_BASE_URL + ')');
41
+ return null;
42
+ }
43
+ const port = HEADROOM_PORT;
44
+ if (!(await portOpen(port))) {
45
+ const out = fs.openSync(HEADROOM_LOG, 'a');
46
+ spawn(exe, headroomArgs(port), {
47
+ detached: true,
48
+ stdio: ['ignore', out, out],
49
+ windowsHide: true,
50
+ env: headroomEnv(env),
51
+ }).unref();
52
+ fs.closeSync(out);
53
+ if (!(await waitForPort(port, 90)))
54
+ throw new Error('the Headroom proxy did not start on port ' + port + '; see ' + HEADROOM_LOG);
55
+ }
56
+ if (!(await answersAsHeadroom(port)))
57
+ throw new Error('port ' + port + ' is taken by another program; Claude Code talks to the API directly');
58
+ log('Headroom: Claude Code reaches the API through ' + headroomUrl(port) + ' (log: ' + HEADROOM_LOG + ')');
59
+ return headroomUrl(port);
60
+ }
@@ -1,3 +1,4 @@
1
1
  import type { GitAccess } from '../../core/types.js';
2
2
  export declare function managedSettingsFile(): string;
3
- export declare function writeManagedSettings(access: GitAccess): void;
3
+ /** `headroom`: the proxy's base URL when it answered at this boot (null: Claude Code talks to the API directly). */
4
+ export declare function writeManagedSettings(access: GitAccess, headroom?: string | null): void;
@@ -1,20 +1,29 @@
1
1
  // Claude Code managed settings for the sandbox (C:\Program Files\ClaudeCode\managed-settings.json). Managed settings
2
- // have the highest precedence and cannot be overridden by user or project settings, so they carry the hard rules:
3
- // today, the git access mode. Rewritten every boot; removed when there is nothing to enforce.
2
+ // have the highest precedence and cannot be overridden by user or project settings, so they carry the hard rules (the
3
+ // git access mode), the sandbox's own hooks (rtk, when it is installed) and the Headroom proxy's address while it runs
4
+ // (env.ANTHROPIC_BASE_URL). Rewritten every boot; removed when there is nothing to write. Your Claude settings from
5
+ // this PC are never changed for this.
4
6
  import fs from 'node:fs';
5
7
  import path from 'node:path';
8
+ import { claudeManagedSettings } from '../../shared/apps/index.js';
6
9
  import { gitDenyRules } from './git-rules.js';
7
10
  import { managedClaudeMd } from '../boot/guide.js';
11
+ import { installedRtk } from './agent-addons.js';
12
+ import { log } from '../runtime/log.js';
8
13
  export function managedSettingsFile() {
9
14
  return path.join(path.dirname(managedClaudeMd()), 'managed-settings.json');
10
15
  }
11
- export function writeManagedSettings(access) {
16
+ /** `headroom`: the proxy's base URL when it answered at this boot (null: Claude Code talks to the API directly). */
17
+ export function writeManagedSettings(access, headroom = null) {
12
18
  const file = managedSettingsFile();
13
- const deny = gitDenyRules(access);
14
- if (deny.length === 0) {
19
+ const rtk = installedRtk();
20
+ const settings = claudeManagedSettings(gitDenyRules(access), rtk, headroom);
21
+ if (!settings) {
15
22
  fs.rmSync(file, { force: true });
16
23
  return;
17
24
  }
18
25
  fs.mkdirSync(path.dirname(file), { recursive: true });
19
- fs.writeFileSync(file, JSON.stringify({ permissions: { deny } }, null, 2) + '\n', 'utf8');
26
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
27
+ if (rtk)
28
+ log("rtk: Claude Code's Bash commands go through it (hook in " + file + ')');
20
29
  }
@@ -1,4 +1,4 @@
1
- import { appsNeedMesa, setupApps } from '../apps/index.js';
1
+ import { appsNeedMesa, appsNeedUv, setupApps } from '../apps/index.js';
2
2
  import { dryRun } from '../runtime/paths.js';
3
3
  import { wanted } from './detect.js';
4
4
  import { DATABASE_NAMES, setupDatabase } from '../databases/index.js';
@@ -85,7 +85,12 @@ export function plan(config, versions, o) {
85
85
  },
86
86
  { name: 'Deno', enabled: wanted(config.deno.enabled, 'deno'), install: (env) => setupDeno(env) },
87
87
  { name: 'Bun', enabled: wanted(config.bun.enabled, 'bun'), install: (env) => setupBun(env) },
88
- { name: 'uv', enabled: wanted(config.uv.enabled, 'uv'), install: (env) => setupUv(env) },
88
+ // Headroom (an agent add-on) installs with uv: the uv step runs for it too, before the apps.
89
+ {
90
+ name: 'uv',
91
+ enabled: wanted(config.uv.enabled, 'uv') || appsNeedUv(o.apps),
92
+ install: (env) => setupUv(env),
93
+ },
89
94
  { name: 'PowerShell 7', enabled: wanted(config.pwsh.enabled, 'pwsh'), install: (env) => setupPwsh(env, o.refresh) },
90
95
  // The openssl tool comes from Git's install: after Git.
91
96
  {
@@ -111,7 +116,7 @@ export function plan(config, versions, o) {
111
116
  name: 'Apps',
112
117
  enabled: apps,
113
118
  shared: true,
114
- after: ['Mesa 3D', 'Visual C++ runtime'],
119
+ after: ['Mesa 3D', 'Visual C++ runtime', 'uv'],
115
120
  install: (env) => setupApps(env, o.apps, { gitAccess: o.gitAccess, refresh: o.refresh }),
116
121
  },
117
122
  {
@@ -0,0 +1,46 @@
1
+ /** A session as the audit remembers it (its run record goes with the sandbox). */
2
+ export interface KnownSession {
3
+ readonly id: string;
4
+ readonly agent: string;
5
+ /** The project it runs in (hub name). */
6
+ readonly project: string;
7
+ /** When it started and ended (ms). */
8
+ readonly from: number;
9
+ readonly to?: number;
10
+ }
11
+ /** A git command a session ran that can make a commit (from the programs the sandbox reported). */
12
+ export interface GitRun {
13
+ readonly t: number;
14
+ readonly session: string;
15
+ }
16
+ /** A link to a session: its id and how it was made (p: process tree / git command; t: only session running). */
17
+ export interface Link {
18
+ readonly s: string;
19
+ readonly sv: 'p' | 't';
20
+ }
21
+ /** git commit, merge, cherry-pick, revert, rebase, am, pull: the commands that can add a commit. */
22
+ export declare function makesCommits(program: string, commandLine: string): boolean;
23
+ /** The sessions of this sandbox seen so far, with when each ran. */
24
+ export declare class SessionBook {
25
+ private readonly sessions;
26
+ /** Takes the sessions as they are now: new ones are added, ended ones get their end time. */
27
+ update(list: readonly {
28
+ readonly id: string;
29
+ readonly harness: string;
30
+ readonly project?: string;
31
+ readonly phase: string;
32
+ readonly startedAt: string;
33
+ readonly updatedAt: string;
34
+ }[], fallbackProject: string, now?: number): void;
35
+ get(id: string | undefined): KnownSession | undefined;
36
+ /** The one session running in this project at time t, or undefined when none or several were. */
37
+ onlyRunning(project: string, t: number): KnownSession | undefined;
38
+ /** A file change in a project at time t: linked only when one session ran there then. */
39
+ linkFile(project: string, t: number): Link | undefined;
40
+ /**
41
+ * A commit in a project at time t: the session whose git command made it when that project's sessions ran git
42
+ * commands that make commits around then and all were the same session's; else, when `likely` is allowed (programs
43
+ * are not recorded, so no git command could be seen), the one session running there.
44
+ */
45
+ linkCommit(project: string, t: number, gitRuns: readonly GitRun[], likely?: boolean): Link | undefined;
46
+ }
@@ -0,0 +1,76 @@
1
+ // Which agent session an event belongs to, on the PC side. Programs come linked from the sandbox (by the process tree,
2
+ // guest/audit/parse.ts); the host only keeps a link to a session it knows. Files and commits are seen from this PC,
3
+ // where the writer cannot be told: they are linked only when exactly one session was running in that project at the
4
+ // time ("likely", marked as such), or, for a commit, when that project's sessions ran exactly one git command that
5
+ // makes commits around then. Anything else stays "no session": nothing is guessed. Pure, so the self-test checks it.
6
+ import path from 'node:path';
7
+ /** How long before and after a commit a git command counts as the one that made it. */
8
+ const GIT_BEFORE_MS = 3 * 60 * 1000;
9
+ const GIT_AFTER_MS = 30 * 1000;
10
+ /** Sessions remembered at most. */
11
+ const MAX_SESSIONS = 512;
12
+ /** git commit, merge, cherry-pick, revert, rebase, am, pull: the commands that can add a commit. */
13
+ export function makesCommits(program, commandLine) {
14
+ return (/^git(\.exe)?$/i.test(path.win32.basename(program)) &&
15
+ /\s(commit|merge|cherry-pick|revert|rebase|am|pull)(\s|$)/i.test(commandLine));
16
+ }
17
+ /** The sessions of this sandbox seen so far, with when each ran. */
18
+ export class SessionBook {
19
+ sessions = new Map();
20
+ /** Takes the sessions as they are now: new ones are added, ended ones get their end time. */
21
+ update(list, fallbackProject, now = Date.now()) {
22
+ for (const s of list) {
23
+ const known = this.sessions.get(s.id);
24
+ const ended = s.phase === 'exited' || s.phase === 'failed';
25
+ if (known) {
26
+ if (ended && known.to === undefined)
27
+ this.sessions.set(s.id, { ...known, to: Math.min(now, Date.parse(s.updatedAt) || now) });
28
+ continue;
29
+ }
30
+ if (this.sessions.size >= MAX_SESSIONS)
31
+ continue;
32
+ const from = Date.parse(s.startedAt) || now;
33
+ this.sessions.set(s.id, {
34
+ id: s.id,
35
+ agent: s.harness,
36
+ project: s.project ?? fallbackProject,
37
+ from,
38
+ ...(ended ? { to: Math.max(from, Date.parse(s.updatedAt) || now) } : {}),
39
+ });
40
+ }
41
+ // A session whose record is gone is not running any more: it ended by now at the latest.
42
+ const listed = new Set(list.map((s) => s.id));
43
+ for (const [id, s] of this.sessions)
44
+ if (!listed.has(id) && s.to === undefined)
45
+ this.sessions.set(id, { ...s, to: now });
46
+ }
47
+ get(id) {
48
+ return id === undefined ? undefined : this.sessions.get(id);
49
+ }
50
+ /** The one session running in this project at time t, or undefined when none or several were. */
51
+ onlyRunning(project, t) {
52
+ const running = [...this.sessions.values()].filter((s) => s.project.toLowerCase() === project.toLowerCase() && s.from <= t && (s.to === undefined || t <= s.to));
53
+ return running.length === 1 ? running[0] : undefined;
54
+ }
55
+ /** A file change in a project at time t: linked only when one session ran there then. */
56
+ linkFile(project, t) {
57
+ const only = this.onlyRunning(project, t);
58
+ return only ? { s: only.id, sv: 't' } : undefined;
59
+ }
60
+ /**
61
+ * A commit in a project at time t: the session whose git command made it when that project's sessions ran git
62
+ * commands that make commits around then and all were the same session's; else, when `likely` is allowed (programs
63
+ * are not recorded, so no git command could be seen), the one session running there.
64
+ */
65
+ linkCommit(project, t, gitRuns, likely = true) {
66
+ const near = new Set(gitRuns
67
+ .filter((g) => g.t >= t - GIT_BEFORE_MS && g.t <= t + GIT_AFTER_MS)
68
+ .map((g) => g.session)
69
+ .filter((id) => this.get(id)?.project.toLowerCase() === project.toLowerCase()));
70
+ if (near.size === 1)
71
+ return { s: [...near][0], sv: 'p' };
72
+ if (near.size > 1)
73
+ return undefined;
74
+ return likely ? this.linkFile(project, t) : undefined;
75
+ }
76
+ }