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
@@ -0,0 +1,22 @@
1
+ import type { SecretStore } from '../secrets/index.js';
2
+ export interface TlsMaterial {
3
+ readonly cert: string;
4
+ readonly key: string;
5
+ readonly fingerprint: string;
6
+ readonly notAfter: string;
7
+ }
8
+ export declare class TlsStore {
9
+ private readonly secrets;
10
+ private readonly now;
11
+ private readonly file;
12
+ constructor(dir: string, secrets: SecretStore, now?: () => number);
13
+ /** The kept certificate when there is a usable one (no key opened), else null. */
14
+ info(): {
15
+ fingerprint: string;
16
+ notAfter: string;
17
+ } | null;
18
+ /** The certificate to serve: the kept one, or a new one for these addresses when there is none or it ends soon. */
19
+ material(addresses: readonly string[]): TlsMaterial;
20
+ /** A new certificate (the old one is gone). */
21
+ renew(addresses: readonly string[]): TlsMaterial;
22
+ }
@@ -0,0 +1,63 @@
1
+ // The LAN listener's certificate, kept in <storage>\web\tls.json with its private key sealed by SecretStore (DPAPI).
2
+ // Made the first time LAN mode starts and then kept: a new certificate means a new warning on every phone, which
3
+ // teaches people to click through, so it is replaced only when it has expired or on request
4
+ // (sandbox web lan new-certificate, which also signs out every device).
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { z } from 'zod';
8
+ import { JsonFile } from '../../shared/io/index.js';
9
+ import { makeCertificate } from './cert.js';
10
+ const TlsFileSchema = z.strictObject({
11
+ cert: z.string().max(8000),
12
+ key: z.string().max(8000),
13
+ fingerprint: z.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
14
+ notAfter: z.string().max(40),
15
+ addresses: z.array(z.string().max(15)).max(16),
16
+ });
17
+ /** Renewed this long before it ends. */
18
+ const RENEW_BEFORE_MS = 7 * 86_400_000;
19
+ export class TlsStore {
20
+ secrets;
21
+ now;
22
+ file;
23
+ constructor(dir, secrets, now = Date.now) {
24
+ this.secrets = secrets;
25
+ this.now = now;
26
+ this.file = new JsonFile(TlsFileSchema, path.join(dir, 'tls.json'));
27
+ }
28
+ /** The kept certificate when there is a usable one (no key opened), else null. */
29
+ info() {
30
+ const saved = this.file.read();
31
+ return saved ? { fingerprint: saved.fingerprint, notAfter: saved.notAfter } : null;
32
+ }
33
+ /** The certificate to serve: the kept one, or a new one for these addresses when there is none or it ends soon. */
34
+ material(addresses) {
35
+ const saved = this.file.read();
36
+ if (saved && Date.parse(saved.notAfter) - this.now() > RENEW_BEFORE_MS) {
37
+ try {
38
+ return {
39
+ cert: saved.cert,
40
+ key: this.secrets.open(saved.key),
41
+ fingerprint: saved.fingerprint,
42
+ notAfter: saved.notAfter,
43
+ };
44
+ }
45
+ catch {
46
+ // Sealed by another user or system: a new one is made.
47
+ }
48
+ }
49
+ return this.renew(addresses);
50
+ }
51
+ /** A new certificate (the old one is gone). */
52
+ renew(addresses) {
53
+ const made = makeCertificate({ addresses, dnsNames: [os.hostname()], now: this.now() });
54
+ this.file.write({
55
+ cert: made.cert,
56
+ key: this.secrets.seal(made.key),
57
+ fingerprint: made.fingerprint,
58
+ notAfter: made.notAfter,
59
+ addresses: [...addresses],
60
+ });
61
+ return made;
62
+ }
63
+ }
@@ -0,0 +1,67 @@
1
+ /** A Windows account as the helper names it. */
2
+ export interface WindowsAccount {
3
+ readonly sid: string;
4
+ /** DOMAIN\name. */
5
+ readonly user: string;
6
+ }
7
+ /** Someone connected to the pipe: who their token says they are. */
8
+ export interface Knock extends WindowsAccount {
9
+ /** The token's integrity RID (0x2000 medium, 0x3000 high). */
10
+ readonly integrity: number;
11
+ readonly appContainer: boolean;
12
+ }
13
+ /** The two ends of a loopback TCP connection, our server's end being `local`. */
14
+ export interface Connection {
15
+ readonly localAddress: string;
16
+ readonly localPort: number;
17
+ readonly remoteAddress: string;
18
+ readonly remotePort: number;
19
+ }
20
+ export interface WindowsHelper {
21
+ /** The Windows user of the process at the other end, or null (unknown, a service, it changed while checking). */
22
+ peer(c: Connection): Promise<WindowsAccount | null>;
23
+ lookup(name: string): Promise<WindowsAccount | null>;
24
+ /** Who answers knocks on the pipe: the text written back ("ok <token> <port>" or "refused <reason>"). */
25
+ onKnock(answer: (k: Knock) => Promise<string>): void;
26
+ /** Problems worth a log line (the pipe could not be made). */
27
+ onProblem(listener: (text: string) => void): void;
28
+ stop(): void;
29
+ }
30
+ export type HelperLine = {
31
+ readonly kind: 'peer' | 'lookup';
32
+ readonly id: string;
33
+ readonly account: WindowsAccount | null;
34
+ } | {
35
+ readonly kind: 'door';
36
+ readonly number: number;
37
+ readonly knock: Knock;
38
+ } | {
39
+ readonly kind: 'problem';
40
+ readonly text: string;
41
+ };
42
+ /** One line from the helper, or null when it is not one of its lines. */
43
+ export declare function parseHelperLine(line: string): HelperLine | null;
44
+ /** Why a knock is refused before the settings are asked (low integrity, a sandboxed app, a service), or null. */
45
+ export declare function knockProblem(k: Knock, ownerSid: string): string | null;
46
+ /** The pipe's name for an owner: agent-sandbox-web-<SID> (WebHelper.cs makes the same name on the client side). */
47
+ export declare function doorPipeName(ownerSid: string): string;
48
+ /** The real helper process. */
49
+ export declare class HostToolsHelper implements WindowsHelper {
50
+ private readonly child;
51
+ private readonly waiting;
52
+ private next;
53
+ private answer;
54
+ private problem;
55
+ private constructor();
56
+ /** Starts it (null when HostTools cannot be built here). `pipe` null: no Windows sign-in, only peer checks. */
57
+ static start(storageRoot: string, pipe: string | null): HostToolsHelper | null;
58
+ private failAll;
59
+ private receive;
60
+ private send;
61
+ private ask;
62
+ peer(c: Connection): Promise<WindowsAccount | null>;
63
+ lookup(name: string): Promise<WindowsAccount | null>;
64
+ onKnock(answer: (k: Knock) => Promise<string>): void;
65
+ onProblem(listener: (text: string) => void): void;
66
+ stop(): void;
67
+ }
@@ -0,0 +1,129 @@
1
+ // The web server's line to Windows for sign-in (docs/design/remote-access.md): one long-running `HostTools web-helper`
2
+ // that serves the named pipe other Windows users sign in through, tells who is at the other end of a loopback TCP
3
+ // connection, and turns account names into SIDs. Lines are tab-separated (WebHelper.cs); everything it prints is
4
+ // checked here. Tests use a fake WindowsHelper.
5
+ import { spawn } from 'node:child_process';
6
+ import readline from 'node:readline';
7
+ import { hostToolsExe } from '../native.js';
8
+ const SID = /^S-1-[0-9-]{1,180}$/;
9
+ const MEDIUM_INTEGRITY = 0x2000;
10
+ /** Most requests waiting for the helper at once; more are answered "unknown" (a flood cannot pile up). */
11
+ const MAX_WAITING = 64;
12
+ const REQUEST_TIMEOUT_MS = 5000;
13
+ const clip = (text) => [...text]
14
+ .filter((c) => c >= ' ' && c !== '\u007f')
15
+ .join('')
16
+ .slice(0, 200);
17
+ /** One line from the helper, or null when it is not one of its lines. */
18
+ export function parseHelperLine(line) {
19
+ const f = line.split('\t');
20
+ if ((f[0] === 'peer' || f[0] === 'lookup') && /^\d{1,9}$/.test(f[1] ?? '')) {
21
+ const ok = f[2] === 'ok' && SID.test(f[3] ?? '') && (f[4] ?? '').length > 0;
22
+ return { kind: f[0], id: f[1], account: ok ? { sid: f[3], user: clip(f[4]) } : null };
23
+ }
24
+ if (f[0] === 'door' && f.length === 6 && /^\d{1,9}$/.test(f[1]) && SID.test(f[2]) && /^\d{1,6}$/.test(f[3]))
25
+ return {
26
+ kind: 'door',
27
+ number: Number(f[1]),
28
+ knock: { sid: f[2], integrity: Number(f[3]), appContainer: f[4] !== '0', user: clip(f[5]) },
29
+ };
30
+ if (f[0] === 'door-failed' || f[0] === 'error')
31
+ return { kind: 'problem', text: clip(f.slice(1).join(' ')) };
32
+ return null;
33
+ }
34
+ /** Why a knock is refused before the settings are asked (low integrity, a sandboxed app, a service), or null. */
35
+ export function knockProblem(k, ownerSid) {
36
+ if (k.appContainer)
37
+ return 'a sandboxed app (AppContainer)';
38
+ if (k.integrity < MEDIUM_INTEGRITY)
39
+ return 'a low-integrity process';
40
+ if (/^S-1-5-(18|19|20)$/.test(k.sid))
41
+ return 'a Windows service account';
42
+ if (k.sid.toUpperCase() === ownerSid.toUpperCase())
43
+ return 'the owner (use sandbox web open)';
44
+ return null;
45
+ }
46
+ /** The pipe's name for an owner: agent-sandbox-web-<SID> (WebHelper.cs makes the same name on the client side). */
47
+ export function doorPipeName(ownerSid) {
48
+ return 'agent-sandbox-web-' + ownerSid;
49
+ }
50
+ /** The real helper process. */
51
+ export class HostToolsHelper {
52
+ child;
53
+ waiting = new Map();
54
+ next = 1;
55
+ answer = async () => 'refused Windows sign-in is off.';
56
+ problem = () => undefined;
57
+ constructor(exe, pipe) {
58
+ this.child = spawn(exe, ['web-helper', '--pipe', pipe ?? '', '--server-pid', String(process.pid)], {
59
+ windowsHide: true,
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ });
62
+ this.child.stderr.resume();
63
+ this.child.on('error', () => this.failAll());
64
+ this.child.on('exit', () => this.failAll());
65
+ readline.createInterface({ input: this.child.stdout }).on('line', (line) => this.receive(line));
66
+ }
67
+ /** Starts it (null when HostTools cannot be built here). `pipe` null: no Windows sign-in, only peer checks. */
68
+ static start(storageRoot, pipe) {
69
+ const exe = hostToolsExe(storageRoot);
70
+ return exe ? new HostToolsHelper(exe, pipe) : null;
71
+ }
72
+ failAll() {
73
+ for (const resolve of this.waiting.values())
74
+ resolve(null);
75
+ this.waiting.clear();
76
+ }
77
+ receive(line) {
78
+ const parsed = parseHelperLine(line);
79
+ if (!parsed)
80
+ return;
81
+ if (parsed.kind === 'problem')
82
+ return this.problem(parsed.text);
83
+ if (parsed.kind === 'door') {
84
+ void this.answer(parsed.knock)
85
+ .catch(() => 'refused Something went wrong.')
86
+ .then((text) => this.send(['answer', String(parsed.number), text.replace(/[\t\r\n]/g, ' ')]));
87
+ return;
88
+ }
89
+ const resolve = this.waiting.get(parsed.id);
90
+ this.waiting.delete(parsed.id);
91
+ resolve?.(parsed.account);
92
+ }
93
+ send(fields) {
94
+ if (this.child.stdin.writable)
95
+ this.child.stdin.write(fields.join('\t') + '\n');
96
+ }
97
+ ask(fields) {
98
+ if (this.waiting.size >= MAX_WAITING || fields.some((f) => /[\t\r\n]/.test(f)))
99
+ return Promise.resolve(null);
100
+ const id = String(this.next++);
101
+ return new Promise((resolve) => {
102
+ const timer = setTimeout(() => {
103
+ this.waiting.delete(id);
104
+ resolve(null);
105
+ }, REQUEST_TIMEOUT_MS);
106
+ this.waiting.set(id, (account) => {
107
+ clearTimeout(timer);
108
+ resolve(account);
109
+ });
110
+ this.send([fields[0], id, ...fields.slice(1)]);
111
+ });
112
+ }
113
+ peer(c) {
114
+ return this.ask(['peer', c.localAddress, String(c.localPort), c.remoteAddress, String(c.remotePort)]);
115
+ }
116
+ lookup(name) {
117
+ return this.ask(['lookup', name]);
118
+ }
119
+ onKnock(answer) {
120
+ this.answer = answer;
121
+ }
122
+ onProblem(listener) {
123
+ this.problem = listener;
124
+ }
125
+ stop() {
126
+ this.child.stdin.end();
127
+ this.failAll();
128
+ }
129
+ }
@@ -7,6 +7,8 @@ export declare class TerminalBridge {
7
7
  private readonly cols;
8
8
  private readonly rows;
9
9
  private readonly dial;
10
+ /** View only: keystrokes and resizes from the browser are dropped here, whatever the page sends. */
11
+ private readonly readOnly;
10
12
  private socket;
11
13
  private pinger;
12
14
  private drain;
@@ -14,7 +16,9 @@ export declare class TerminalBridge {
14
16
  private done;
15
17
  private readonly filter;
16
18
  private readonly reader;
17
- constructor(ws: WebSocket, endpoint: Endpoint, cols: number, rows: number, dial?: (endpoint: Endpoint) => Promise<net.Socket>);
19
+ constructor(ws: WebSocket, endpoint: Endpoint, cols: number, rows: number, dial?: (endpoint: Endpoint) => Promise<net.Socket>,
20
+ /** View only: keystrokes and resizes from the browser are dropped here, whatever the page sends. */
21
+ readOnly?: boolean);
18
22
  /** Connects to the session and starts passing data both ways. */
19
23
  start(): Promise<void>;
20
24
  private fromBrowser;
@@ -12,6 +12,7 @@ export class TerminalBridge {
12
12
  cols;
13
13
  rows;
14
14
  dial;
15
+ readOnly;
15
16
  socket = null;
16
17
  pinger = null;
17
18
  drain = null;
@@ -19,12 +20,15 @@ export class TerminalBridge {
19
20
  done = false;
20
21
  filter = new OutputFilter();
21
22
  reader = new FrameReader();
22
- constructor(ws, endpoint, cols, rows, dial = connect) {
23
+ constructor(ws, endpoint, cols, rows, dial = connect,
24
+ /** View only: keystrokes and resizes from the browser are dropped here, whatever the page sends. */
25
+ readOnly = false) {
23
26
  this.ws = ws;
24
27
  this.endpoint = endpoint;
25
28
  this.cols = cols;
26
29
  this.rows = rows;
27
30
  this.dial = dial;
31
+ this.readOnly = readOnly;
28
32
  }
29
33
  /** Connects to the session and starts passing data both ways. */
30
34
  async start() {
@@ -54,7 +58,7 @@ export class TerminalBridge {
54
58
  this.tell({ t: 'ready' });
55
59
  }
56
60
  fromBrowser(data, isBinary) {
57
- if (!this.socket || this.done)
61
+ if (!this.socket || this.done || this.readOnly)
58
62
  return;
59
63
  const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
60
64
  if (isBinary) {
@@ -4,7 +4,7 @@ import { SYSTEM, system32 } from '../../shared/system.js';
4
4
  import { refuseInSelfTest } from '../test-guard.js';
5
5
  export function openBrowser(url) {
6
6
  refuseInSelfTest('open a browser');
7
- if (!/^http:\/\/127\.0\.0\.1:\d{1,5}\/(#k=[0-9a-f]{64})?$/.test(url))
7
+ if (!/^http:\/\/127\.0\.0\.1:\d{1,5}\/(#[kw]=[0-9a-f]{64})?$/.test(url))
8
8
  throw new Error('Not a web UI address.');
9
9
  spawn(SYSTEM.cmd(), ['/c', 'start', '', url], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
10
10
  }
@@ -6,7 +6,10 @@ export interface WebStatus {
6
6
  export declare class WebControl {
7
7
  private readonly store;
8
8
  constructor(store: WebStore);
9
- /** Running means the state file exists and its port answers as our server. */
9
+ /**
10
+ * Running means the state file exists and its port answers as OUR server: it proves it holds the token (an HMAC of
11
+ * a fresh challenge), so the token link is never handed to another program that took the port.
12
+ */
10
13
  status(): Promise<WebStatus>;
11
14
  /** http://127.0.0.1:<port>/#k=<token>: the fragment never goes to the server. */
12
15
  url(state: WebState): string;
@@ -1,5 +1,6 @@
1
1
  // Talking to a running web server from the CLI: is it up, its address (with the token fragment), and stopping it. The
2
2
  // state file says where it is and holds the token, so only you (and SYSTEM / Administrators) can do any of this.
3
+ import crypto from 'node:crypto';
3
4
  import http from 'node:http';
4
5
  import { CSRF_HEADER, CSRF_VALUE } from '../../shared/web/index.js';
5
6
  function request(state, method, path, body, cookie) {
@@ -18,8 +19,13 @@ function request(state, method, path, body, cookie) {
18
19
  ...(cookie ? { Cookie: cookie } : {}),
19
20
  },
20
21
  }, (res) => {
21
- res.resume();
22
- resolve(res);
22
+ let text = '';
23
+ res.setEncoding('utf8');
24
+ res.on('data', (chunk) => {
25
+ if (text.length < 4096)
26
+ text += chunk;
27
+ });
28
+ res.on('end', () => resolve(Object.assign(res, { text })));
23
29
  });
24
30
  req.on('timeout', () => req.destroy(new Error('timed out')));
25
31
  req.on('error', reject);
@@ -31,13 +37,23 @@ export class WebControl {
31
37
  constructor(store) {
32
38
  this.store = store;
33
39
  }
34
- /** Running means the state file exists and its port answers as our server. */
40
+ /**
41
+ * Running means the state file exists and its port answers as OUR server: it proves it holds the token (an HMAC of
42
+ * a fresh challenge), so the token link is never handed to another program that took the port.
43
+ */
35
44
  async status() {
36
45
  const state = this.store.read();
37
46
  if (!state)
38
47
  return { running: false, state: null };
39
48
  try {
40
- return { running: (await request(state, 'GET', '/api/health')).statusCode === 200, state };
49
+ const challenge = crypto.randomBytes(16).toString('hex');
50
+ const res = await request(state, 'GET', '/api/health?challenge=' + challenge);
51
+ const expected = crypto.createHmac('sha256', state.token).update(challenge).digest('hex');
52
+ const proof = safeJson(res.text)?.proof;
53
+ const proven = typeof proof === 'string' &&
54
+ proof.length === expected.length &&
55
+ crypto.timingSafeEqual(Buffer.from(proof), Buffer.from(expected));
56
+ return { running: res.statusCode === 200 && proven, state };
41
57
  }
42
58
  catch {
43
59
  return { running: false, state };
@@ -65,3 +81,11 @@ export class WebControl {
65
81
  this.store.clear();
66
82
  }
67
83
  }
84
+ function safeJson(text) {
85
+ try {
86
+ return JSON.parse(text);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
@@ -1,17 +1,31 @@
1
1
  import type { WebSocket } from 'ws';
2
- import { type RpcHost } from '../../app/kernel/index.js';
2
+ import { type Caller, type RpcHost } from '../../app/kernel/index.js';
3
3
  import { LiveHub } from './live.js';
4
+ /** One call as this socket saw it (the audit log of remote actions). */
5
+ export interface RpcCallRecord {
6
+ readonly caller: Caller;
7
+ readonly op: string;
8
+ readonly kind: 'operation' | 'query';
9
+ readonly outcome: 'ok' | 'refused' | 'failed';
10
+ }
4
11
  export declare class RpcSession {
5
12
  private readonly ws;
6
13
  private readonly host;
7
14
  /** Shared by every socket of the server (one poll per query); a socket of its own by default. */
8
15
  private readonly live;
16
+ /** Who uses this socket, as it is now (null: their access ended); checked before every call and subscription. */
17
+ private readonly caller;
18
+ private readonly onCall;
9
19
  private readonly calls;
10
20
  private readonly subscriptions;
11
21
  private closed;
12
22
  constructor(ws: WebSocket, host: RpcHost,
13
23
  /** Shared by every socket of the server (one poll per query); a socket of its own by default. */
14
- live?: LiveHub);
24
+ live?: LiveHub,
25
+ /** Who uses this socket, as it is now (null: their access ended); checked before every call and subscription. */
26
+ caller?: () => Caller | null, onCall?: (record: RpcCallRecord) => void);
27
+ /** The caller may run `id` with `input` now; else the refusal is sent (and the socket closed when access ended). */
28
+ private permitted;
15
29
  start(): void;
16
30
  private send;
17
31
  /** A browser that sends something that is not one of our messages is closed. */
@@ -1,4 +1,4 @@
1
- import { ANSWER_SCHEMAS, ClientMessageSchema, MAX_OPEN_PER_SOCKET, ioFromAsker, toWireError, } from '../../app/kernel/index.js';
1
+ import { allows, ANSWER_SCHEMAS, OWNER, refusalText, ClientMessageSchema, MAX_OPEN_PER_SOCKET, ioFromAsker, toWireError, } from '../../app/kernel/index.js';
2
2
  import { LiveHub } from './live.js';
3
3
  /** What a question gets when the browser goes away before answering: no. */
4
4
  function declined(q) {
@@ -8,15 +8,36 @@ export class RpcSession {
8
8
  ws;
9
9
  host;
10
10
  live;
11
+ caller;
12
+ onCall;
11
13
  calls = new Map();
12
14
  subscriptions = new Map();
13
15
  closed = false;
14
16
  constructor(ws, host,
15
17
  /** Shared by every socket of the server (one poll per query); a socket of its own by default. */
16
- live = new LiveHub(host)) {
18
+ live = new LiveHub(host),
19
+ /** Who uses this socket, as it is now (null: their access ended); checked before every call and subscription. */
20
+ caller = () => OWNER, onCall = () => undefined) {
17
21
  this.ws = ws;
18
22
  this.host = host;
19
23
  this.live = live;
24
+ this.caller = caller;
25
+ this.onCall = onCall;
26
+ }
27
+ /** The caller may run `id` with `input` now; else the refusal is sent (and the socket closed when access ended). */
28
+ permitted(rpcId, id, input, kind) {
29
+ const caller = this.caller();
30
+ if (!caller) {
31
+ this.ws.close(4001, 'signed out');
32
+ this.close();
33
+ return null;
34
+ }
35
+ const need = this.host.need(id, input) ?? 'owner';
36
+ if (allows(caller, need))
37
+ return caller;
38
+ this.send({ t: 'error', id: rpcId, error: { kind: 'user', message: refusalText(caller, need) } });
39
+ this.onCall({ caller, op: id, kind, outcome: 'refused' });
40
+ return null;
20
41
  }
21
42
  start() {
22
43
  this.ws.on('message', (data, binary) => this.receive(data, binary));
@@ -78,6 +99,9 @@ export class RpcSession {
78
99
  this.send({ t: 'error', id, error: { kind: 'invalid', message: 'There is no ' + op + ' here.' } });
79
100
  return;
80
101
  }
102
+ const caller = this.permitted(id, op, input, this.host.kind(op) ?? 'operation');
103
+ if (!caller)
104
+ return;
81
105
  this.calls.set(id, null);
82
106
  const io = ioFromAsker((question) => new Promise((resolve) => {
83
107
  if (this.closed)
@@ -88,9 +112,16 @@ export class RpcSession {
88
112
  progress: (text) => this.send({ t: 'progress', id, text }),
89
113
  log: (text, tone) => this.send({ t: 'log', id, text, ...(tone ? { tone } : {}) }),
90
114
  });
115
+ const kind = this.host.kind(op) ?? 'operation';
91
116
  this.host
92
- .run(op, input, io)
93
- .then((output) => this.send({ t: 'result', id, output }), (e) => this.send({ t: 'error', id, error: toWireError(e) }))
117
+ .run(op, input, io, caller)
118
+ .then((output) => {
119
+ this.send({ t: 'result', id, output });
120
+ this.onCall({ caller, op, kind, outcome: 'ok' });
121
+ }, (e) => {
122
+ this.send({ t: 'error', id, error: toWireError(e) });
123
+ this.onCall({ caller, op, kind, outcome: 'failed' });
124
+ })
94
125
  .finally(() => {
95
126
  this.calls.delete(id);
96
127
  // An operation may have changed what the live queries show: they read again now.
@@ -113,6 +144,8 @@ export class RpcSession {
113
144
  this.send({ t: 'error', id, error: { kind: 'invalid', message: 'There is no ' + query + ' here.' } });
114
145
  return;
115
146
  }
147
+ if (!this.permitted(id, query, input, 'query'))
148
+ return;
116
149
  const stop = this.live.watch(query, input, (event) => {
117
150
  if (this.subscriptions.get(id) !== stop)
118
151
  return;
@@ -1,4 +1,5 @@
1
1
  import type { RpcHost } from '../../app/kernel/index.js';
2
+ import type { RemoteGate } from '../remote/index.js';
2
3
  import type { WebBackend } from './backend.js';
3
4
  export interface WebRunOptions {
4
5
  readonly storageRoot: string;
@@ -10,11 +11,14 @@ export interface WebRunOptions {
10
11
  readonly version: string;
11
12
  /** End after this long with no browser (sooner with no sandbox): WebServerOptions.idleExitMs. */
12
13
  readonly idleExitMs?: number;
14
+ /** Other Windows users and devices (docs/design/remote-access.md); none: the owner only. */
15
+ readonly gate?: RemoteGate;
13
16
  }
14
17
  export declare class WebRunner {
15
18
  private readonly options;
16
19
  private readonly store;
17
20
  constructor(options: WebRunOptions);
21
+ private lanOf;
18
22
  /** Runs until stopped (from the browser, the CLI, or a signal). */
19
23
  run(): Promise<void>;
20
24
  }
@@ -10,12 +10,18 @@ export class WebRunner {
10
10
  this.options = options;
11
11
  this.store = new WebStore(options.storageRoot);
12
12
  }
13
+ lanOf(gate) {
14
+ const lan = gate.lanState();
15
+ return { url: lan.url, fingerprint: lan.fingerprint, problem: lan.problem };
16
+ }
13
17
  /** Runs until stopped (from the browser, the CLI, or a signal). */
14
18
  async run() {
15
19
  const token = newToken();
16
20
  let stop = () => undefined;
17
21
  const stopped = new Promise((resolve) => (stop = resolve));
22
+ const gate = this.options.gate;
18
23
  const server = new WebServer({
24
+ gate,
19
25
  port: this.options.port,
20
26
  token,
21
27
  backend: this.options.backend,
@@ -38,6 +44,12 @@ export class WebRunner {
38
44
  token,
39
45
  startedAt: new Date().toISOString(),
40
46
  version: this.options.version,
47
+ ...(gate ? { lan: this.lanOf(gate) } : {}),
48
+ });
49
+ gate?.onLanChange(() => {
50
+ const now = this.store.read();
51
+ if (now && now.pid === process.pid)
52
+ this.store.write({ ...now, lan: this.lanOf(gate) });
41
53
  });
42
54
  process.once('SIGINT', stop);
43
55
  process.once('SIGTERM', stop);
@@ -1,7 +1,8 @@
1
1
  import type net from 'node:net';
2
- import type { RpcHost } from '../../app/kernel/index.js';
2
+ import { type RpcHost } from '../../app/kernel/index.js';
3
3
  import type { Endpoint } from '../../shared/pty/index.js';
4
4
  import type { WebBackend } from './backend.js';
5
+ import type { RemoteGate } from '../remote/index.js';
5
6
  /** No inline script: the page and its script are files from this server. Inline styles are what xterm.js needs. */
6
7
  export declare const CONTENT_SECURITY_POLICY: string;
7
8
  /**
@@ -31,6 +32,11 @@ export interface WebServerOptions {
31
32
  readonly idleNoSandboxMs?: number;
32
33
  /** Heartbeat period (tests make it short). */
33
34
  readonly heartbeatMs?: number;
35
+ /**
36
+ * Other Windows users and paired devices (docs/design/remote-access.md); none: the owner's token only, as before.
37
+ * The gate also serves the LAN listener and closes sockets whose access ended.
38
+ */
39
+ readonly gate?: RemoteGate;
34
40
  }
35
41
  /** A browser's socket: which kind, whether it answered the last ping, and when it was opened. */
36
42
  interface Tracked {
@@ -71,6 +77,11 @@ export declare class WebServer {
71
77
  private headers;
72
78
  private json;
73
79
  private handle;
80
+ /** The LAN listener's own address:port, or loopback with our port. */
81
+ private hostOk;
82
+ private originOk;
83
+ /** Who sent the request: the owner's token cookie (loopback only), or whoever the gate lets in. Null: nobody. */
84
+ private callerOf;
74
85
  private serveFile;
75
86
  /** A call that changes something: the custom header, and an Origin that is this page. */
76
87
  private requireSameSite;
@@ -78,7 +89,12 @@ export declare class WebServer {
78
89
  /** A small JSON body (application/json only). */
79
90
  private body;
80
91
  private upgrade;
81
- /** The RPC socket (after the same Origin and cookie checks as a terminal). */
92
+ /** The RPC socket (after the same Origin and caller checks as a terminal). */
82
93
  private upgradeRpc;
94
+ /**
95
+ * GET /api/health: alive, and with ?challenge=<hex> on loopback the proof that this server holds the token (an
96
+ * HMAC of the challenge), so the CLI never hands the token link to another program on the port.
97
+ */
98
+ private health;
83
99
  }
84
100
  export {};