windows-development-sandbox 1.12.0 → 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 (160) hide show
  1. package/README.md +1 -0
  2. package/config.example.json +11 -1
  3. package/dist/app/features/access/access.def.d.ts +190 -0
  4. package/dist/app/features/access/access.def.js +273 -0
  5. package/dist/app/features/access/access.run.d.ts +79 -0
  6. package/dist/app/features/access/access.run.js +129 -0
  7. package/dist/app/features/access/index.d.ts +2 -0
  8. package/dist/app/features/access/index.js +3 -0
  9. package/dist/app/features/agents/agent-entry.js +5 -0
  10. package/dist/app/features/agents/agents.def.js +1 -0
  11. package/dist/app/features/apps/apps.def.js +1 -0
  12. package/dist/app/features/audit/audit.def.d.ts +11 -2
  13. package/dist/app/features/audit/audit.def.js +7 -4
  14. package/dist/app/features/audit/audit.run.d.ts +3 -0
  15. package/dist/app/features/defs.d.ts +155 -3
  16. package/dist/app/features/defs.js +12 -1
  17. package/dist/app/features/files/files.def.js +1 -0
  18. package/dist/app/features/health/health.def.js +1 -0
  19. package/dist/app/features/sandbox/sandbox.def.js +3 -0
  20. package/dist/app/features/sessions/sessions.def.d.ts +36 -0
  21. package/dist/app/features/sessions/sessions.def.js +77 -9
  22. package/dist/app/features/sessions/sessions.run.d.ts +25 -2
  23. package/dist/app/features/sessions/sessions.run.js +71 -3
  24. package/dist/app/features/settings/settings.def.d.ts +8 -0
  25. package/dist/app/features/settings/settings.def.js +17 -0
  26. package/dist/app/features/web/web.def.d.ts +9 -0
  27. package/dist/app/features/web/web.def.js +26 -1
  28. package/dist/app/features/web/web.run.d.ts +3 -0
  29. package/dist/app/features/web/web.run.js +11 -3
  30. package/dist/app/kernel/access.d.ts +33 -0
  31. package/dist/app/kernel/access.js +44 -0
  32. package/dist/app/kernel/define.d.ts +14 -1
  33. package/dist/app/kernel/index.d.ts +1 -0
  34. package/dist/app/kernel/index.js +1 -0
  35. package/dist/app/kernel/rpc.d.ts +5 -2
  36. package/dist/app/registry.d.ts +4 -1
  37. package/dist/app/registry.js +24 -4
  38. package/dist/cli/commands/registry.js +2 -0
  39. package/dist/cli/commands/remote.d.ts +2 -0
  40. package/dist/cli/commands/remote.js +49 -0
  41. package/dist/cli/commands/sessions/reopen.js +3 -0
  42. package/dist/cli/commands/web.js +66 -0
  43. package/dist/cli/manifest.d.ts +63 -4
  44. package/dist/cli/manifest.js +33 -4
  45. package/dist/cli/menu/io-menu.d.ts +5 -3
  46. package/dist/cli/menu/io-menu.js +12 -5
  47. package/dist/cli/menu/remote-menu.d.ts +19 -0
  48. package/dist/cli/menu/remote-menu.js +108 -0
  49. package/dist/cli/menu/views.js +2 -0
  50. package/dist/cli/operations/handler.js +15 -1
  51. package/dist/cli/services/remote-client.d.ts +16 -0
  52. package/dist/cli/services/remote-client.js +141 -0
  53. package/dist/cli/services/sessions-service.d.ts +5 -0
  54. package/dist/cli/services/sessions-service.js +17 -1
  55. package/dist/core/config/defaults.js +11 -1
  56. package/dist/core/local-config/file.js +16 -2
  57. package/dist/core/local-config/overrides.js +7 -0
  58. package/dist/core/run/versions.js +9 -8
  59. package/dist/core/schema/config.d.ts +22 -3
  60. package/dist/core/schema/config.js +39 -2
  61. package/dist/core/schema/web.d.ts +51 -0
  62. package/dist/core/schema/web.js +46 -1
  63. package/dist/core/util/fs.d.ts +5 -0
  64. package/dist/core/util/fs.js +23 -0
  65. package/dist/core/util/index.d.ts +1 -1
  66. package/dist/core/util/index.js +1 -1
  67. package/dist/guest/daemon/daemon.js +2 -0
  68. package/dist/guest/daemon/idle-shutdown.js +2 -1
  69. package/dist/guest/daemon/sessions.d.ts +16 -2
  70. package/dist/guest/daemon/sessions.js +126 -20
  71. package/dist/guest/git/decision.js +7 -0
  72. package/dist/guest/sessions/cmdline.d.ts +14 -0
  73. package/dist/guest/sessions/cmdline.js +10 -0
  74. package/dist/guest/toolchains/setup.js +3 -0
  75. package/dist/host/audit/log.d.ts +3 -2
  76. package/dist/host/audit/log.js +3 -3
  77. package/dist/host/channel/channel.d.ts +5 -1
  78. package/dist/host/channel/channel.js +12 -0
  79. package/dist/host/guard/agent-files.js +7 -1
  80. package/dist/host/guard/commands.js +13 -1
  81. package/dist/host/guard/git-config.js +27 -7
  82. package/dist/host/guard/git-dirs.js +78 -3
  83. package/dist/host/guard/mark-of-the-web.js +3 -1
  84. package/dist/host/guard/nested-repos.js +7 -2
  85. package/dist/host/guard/store.js +1 -0
  86. package/dist/host/guard/types.d.ts +2 -0
  87. package/dist/host/launch/mappings.js +11 -1
  88. package/dist/host/launch/project-scan.js +2 -21
  89. package/dist/host/launch/stage.js +4 -0
  90. package/dist/host/life/session-ledger.js +2 -1
  91. package/dist/host/remote/access-store.d.ts +156 -0
  92. package/dist/host/remote/access-store.js +308 -0
  93. package/dist/host/remote/cert.d.ts +20 -0
  94. package/dist/host/remote/cert.js +103 -0
  95. package/dist/host/remote/door-client.d.ts +13 -0
  96. package/dist/host/remote/door-client.js +54 -0
  97. package/dist/host/remote/firewall.d.ts +44 -0
  98. package/dist/host/remote/firewall.js +118 -0
  99. package/dist/host/remote/gate.d.ts +125 -0
  100. package/dist/host/remote/gate.js +391 -0
  101. package/dist/host/remote/index.d.ts +8 -0
  102. package/dist/host/remote/index.js +10 -0
  103. package/dist/host/remote/lan.d.ts +24 -0
  104. package/dist/host/remote/lan.js +88 -0
  105. package/dist/host/remote/tls-store.d.ts +22 -0
  106. package/dist/host/remote/tls-store.js +63 -0
  107. package/dist/host/remote/windows-helper.d.ts +67 -0
  108. package/dist/host/remote/windows-helper.js +129 -0
  109. package/dist/host/web/bridge.d.ts +5 -1
  110. package/dist/host/web/bridge.js +6 -2
  111. package/dist/host/web/browser.js +1 -1
  112. package/dist/host/web/control.d.ts +4 -1
  113. package/dist/host/web/control.js +28 -4
  114. package/dist/host/web/rpc.d.ts +16 -2
  115. package/dist/host/web/rpc.js +37 -4
  116. package/dist/host/web/runner.d.ts +4 -0
  117. package/dist/host/web/runner.js +12 -0
  118. package/dist/host/web/server.d.ts +18 -2
  119. package/dist/host/web/server.js +97 -24
  120. package/dist/host/web/state.d.ts +5 -0
  121. package/dist/host/web/state.js +8 -0
  122. package/dist/runtime/agent.js +74 -3
  123. package/dist/runtime/daemon.js +205 -24
  124. package/dist/runtime/db.js +74 -3
  125. package/dist/runtime/gitguard.js +6 -0
  126. package/dist/runtime/main.js +77 -3
  127. package/dist/runtime/netproxy.js +74 -3
  128. package/dist/runtime/view.js +76 -3
  129. package/dist/selftest/app.test.js +41 -6
  130. package/dist/selftest/channel.test.js +16 -0
  131. package/dist/selftest/cmdline.test.js +16 -0
  132. package/dist/selftest/git-access.test.js +8 -0
  133. package/dist/selftest/hostile-input.test.js +12 -1
  134. package/dist/selftest/index.js +2 -0
  135. package/dist/selftest/live-daemon.test.js +25 -0
  136. package/dist/selftest/local-config.test.js +30 -0
  137. package/dist/selftest/remote.test.d.ts +2 -0
  138. package/dist/selftest/remote.test.js +536 -0
  139. package/dist/selftest/repo-guard.test.js +56 -2
  140. package/dist/selftest/resume.test.js +2 -1
  141. package/dist/selftest/sharing.test.js +9 -2
  142. package/dist/selftest/support/fixtures.d.ts +13 -0
  143. package/dist/selftest/support/web-preview.js +63 -0
  144. package/dist/selftest/web.test.js +2 -0
  145. package/dist/shared/channel/schema.d.ts +23 -0
  146. package/dist/shared/channel/schema.js +8 -0
  147. package/dist/shared/pty/protocol.d.ts +2 -0
  148. package/dist/shared/pty/protocol.js +2 -0
  149. package/dist/web/index.html +1 -1
  150. package/dist/web/main-Z2WYAE2Z.js +221 -0
  151. package/native/csharp/HostTools/Program.cs +17 -1
  152. package/native/csharp/HostTools/WebHelper.cs +499 -0
  153. package/native/scripts/get-network-profile.ps1 +19 -0
  154. package/native/scripts/set-folder-access.ps1 +11 -0
  155. package/native/scripts/set-web-firewall.ps1 +103 -0
  156. package/package.json +1 -1
  157. package/sandbox/csharp/PtyHost.cs +20 -0
  158. package/schemas/config.schema.json +65 -0
  159. package/schemas/sandbox-config.schema.json +0 -18
  160. package/dist/web/main-UD4QCXZR.js +0 -219
@@ -0,0 +1,308 @@
1
+ // Who the owner let in (docs/design/remote-access.md): paired devices, Windows sign-ins and the one pending device
2
+ // code, in <storage>\web\access.json (the web state's private folder). Only SHA-256 hashes of codes and tokens stay,
3
+ // and the whole list is sealed with SecretStore (DPAPI). Credentials end at a fixed time (never renewed by use); revoke
4
+ // one, or all (which also moves the epoch and voids the pending code). The web server and the CLI share this file: the
5
+ // server reads it again when it changes.
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { z } from 'zod';
10
+ import { JsonFile } from '../../shared/io/index.js';
11
+ const DAY_MS = 86_400_000;
12
+ /** A device code is good for this long. */
13
+ export const CODE_LIFETIME_MS = 5 * 60_000;
14
+ /** A code that has seen this many wrong tries is void (the PC says so). */
15
+ export const CODE_MAX_WRONG = 10;
16
+ /** Most devices and Windows sign-ins kept (the oldest go first). */
17
+ export const MAX_DEVICES = 50;
18
+ export const MAX_WINDOWS_GRANTS = 100;
19
+ /** Crockford base32 without I, L, O, U: what a person reads off a screen and types on a phone. */
20
+ const CODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
21
+ const CODE_LENGTH = 8;
22
+ const Hex64 = z.string().regex(/^[0-9a-f]{64}$/);
23
+ const IdSchema = z.string().regex(/^[0-9a-f]{16}$/);
24
+ const IsoSchema = z.string().max(40);
25
+ const RoleSchema = z.enum(['view', 'control']);
26
+ export const DeviceSchema = z.strictObject({
27
+ id: IdSchema,
28
+ name: z.string().max(40),
29
+ role: RoleSchema,
30
+ /** The address it was allowed from. */
31
+ address: z.string().max(45),
32
+ createdAt: IsoSchema,
33
+ expiresAt: IsoSchema,
34
+ lastSeen: IsoSchema.optional(),
35
+ tokenHash: Hex64,
36
+ });
37
+ export const WindowsGrantSchema = z.strictObject({
38
+ id: IdSchema,
39
+ sid: z.string().regex(/^S-1-[0-9-]{1,180}$/),
40
+ user: z.string().max(140),
41
+ createdAt: IsoSchema,
42
+ expiresAt: IsoSchema,
43
+ tokenHash: Hex64,
44
+ });
45
+ export const PendingCodeSchema = z.strictObject({
46
+ hash: Hex64,
47
+ role: RoleSchema,
48
+ createdAt: IsoSchema,
49
+ expiresAt: IsoSchema,
50
+ wrong: z.number().int().min(0).max(1000),
51
+ });
52
+ export const AccessDataSchema = z.strictObject({
53
+ /** Moves on "revoke all": nothing issued before comes back. */
54
+ epoch: z.number().int().min(0),
55
+ devices: z.array(DeviceSchema).max(MAX_DEVICES),
56
+ windows: z.array(WindowsGrantSchema).max(MAX_WINDOWS_GRANTS),
57
+ code: PendingCodeSchema.nullable(),
58
+ /** The network LAN mode was turned on for (its name from Windows), or null. */
59
+ network: z.string().max(256).nullable(),
60
+ /** The pending code was voided by wrong tries (the PC says so until a new code is made). */
61
+ codeVoided: z.boolean(),
62
+ });
63
+ const AccessFileSchema = z.strictObject({ version: z.literal(1), sealed: z.string().max(2_000_000) });
64
+ export const EMPTY_ACCESS = {
65
+ epoch: 0,
66
+ devices: [],
67
+ windows: [],
68
+ code: null,
69
+ network: null,
70
+ codeVoided: false,
71
+ };
72
+ /** SHA-256 of a code or token, hex. */
73
+ export function secretHash(value) {
74
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
75
+ }
76
+ /** Two hex hashes, compared without leaking where they differ. */
77
+ function sameHash(a, b) {
78
+ return a.length === b.length && crypto.timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
79
+ }
80
+ /** A new code: 8 characters (40 bits), shown as ABCD-EFGH. */
81
+ export function newCode(random = crypto.randomBytes) {
82
+ const bytes = random(CODE_LENGTH);
83
+ let code = '';
84
+ for (let i = 0; i < CODE_LENGTH; i++)
85
+ code += CODE_ALPHABET[bytes[i] & 31];
86
+ return code;
87
+ }
88
+ /** ABCDEFGH -> ABCD-EFGH. */
89
+ export function showCode(code) {
90
+ return code.slice(0, 4) + '-' + code.slice(4);
91
+ }
92
+ /** What a person typed, as a code: upper case, no dashes or spaces, O as 0, I or L as 1; null when it is not one. */
93
+ export function normalizeCode(typed) {
94
+ if (typeof typed !== 'string' || typed.length > 32)
95
+ return null;
96
+ const code = typed.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
97
+ return code.length === CODE_LENGTH && [...code].every((c) => CODE_ALPHABET.includes(c)) ? code : null;
98
+ }
99
+ /** A credential as the cookie carries it: `<id>.<token>`. */
100
+ export function credentialValue(id, token) {
101
+ return id + '.' + token;
102
+ }
103
+ /** `<16 hex>.<64 hex>`, or null. */
104
+ export function parseCredential(value) {
105
+ const match = /^([0-9a-f]{16})\.([0-9a-f]{64})$/.exec(value ?? '');
106
+ return match ? { id: match[1], token: match[2] } : null;
107
+ }
108
+ export class AccessStore {
109
+ dir;
110
+ secrets;
111
+ options;
112
+ file;
113
+ cache;
114
+ now;
115
+ random;
116
+ constructor(dir, secrets, options = {}) {
117
+ this.dir = dir;
118
+ this.secrets = secrets;
119
+ this.options = options;
120
+ this.file = new JsonFile(AccessFileSchema, path.join(dir, 'access.json'));
121
+ this.now = options.now ?? Date.now;
122
+ this.random = options.random ?? crypto.randomBytes;
123
+ }
124
+ /** <storage>\web. */
125
+ static at(storageRoot, secrets, options = {}) {
126
+ return new AccessStore(path.join(storageRoot, 'web'), secrets, options);
127
+ }
128
+ stamp() {
129
+ try {
130
+ const st = fs.statSync(this.file.file);
131
+ return st.mtimeMs + ':' + st.size;
132
+ }
133
+ catch {
134
+ return 'none';
135
+ }
136
+ }
137
+ /** The list as it is now (read again only when the file changed). A damaged or foreign file counts as empty. */
138
+ read() {
139
+ const stamp = this.stamp();
140
+ if (this.cache?.stamp === stamp)
141
+ return this.cache.data;
142
+ let data = EMPTY_ACCESS;
143
+ try {
144
+ const raw = this.file.read();
145
+ if (raw) {
146
+ const parsed = AccessDataSchema.safeParse(this.secrets.openJson(raw.sealed));
147
+ if (parsed.success)
148
+ data = parsed.data;
149
+ }
150
+ }
151
+ catch {
152
+ // A file sealed by another user or system cannot be opened: nobody is let in by it.
153
+ }
154
+ this.cache = { stamp, data };
155
+ return data;
156
+ }
157
+ write(data) {
158
+ const checked = AccessDataSchema.parse(data);
159
+ fs.mkdirSync(this.dir, { recursive: true });
160
+ this.options.protect?.(this.dir);
161
+ this.file.write({ version: 1, sealed: this.secrets.sealJson(checked) });
162
+ this.cache = { stamp: this.stamp(), data: checked };
163
+ return checked;
164
+ }
165
+ /** Changes the list (expired credentials are dropped on the way). */
166
+ update(change) {
167
+ return this.write(change(this.withoutExpired(this.read())));
168
+ }
169
+ iso(ms) {
170
+ return new Date(ms).toISOString();
171
+ }
172
+ alive(expiresAt) {
173
+ return Date.parse(expiresAt) > this.now();
174
+ }
175
+ withoutExpired(data) {
176
+ return {
177
+ ...data,
178
+ devices: data.devices.filter((d) => this.alive(d.expiresAt)),
179
+ windows: data.windows.filter((w) => this.alive(w.expiresAt)),
180
+ code: data.code && this.alive(data.code.expiresAt) ? data.code : null,
181
+ };
182
+ }
183
+ newId() {
184
+ return this.random(8).toString('hex');
185
+ }
186
+ newToken() {
187
+ return this.random(32).toString('hex');
188
+ }
189
+ /** A new device code for this role (replaces a pending one). Returns the code; only its hash is kept. */
190
+ newDeviceCode(role) {
191
+ const code = newCode(this.random);
192
+ const t = this.now();
193
+ const expiresAt = this.iso(t + CODE_LIFETIME_MS);
194
+ this.update((d) => ({
195
+ ...d,
196
+ code: { hash: secretHash(code), role, createdAt: this.iso(t), expiresAt, wrong: 0 },
197
+ codeVoided: false,
198
+ }));
199
+ return { code, expiresAt };
200
+ }
201
+ /** Whether a code is pending (attempts with none pending are not counted anywhere). */
202
+ codePending() {
203
+ const code = this.read().code;
204
+ return code !== null && this.alive(code.expiresAt);
205
+ }
206
+ /** Trades the pending code for a new device credential (one use); counts a wrong code. */
207
+ redeem(typed, name, address, rememberDays) {
208
+ const data = this.read();
209
+ const pending = data.code;
210
+ if (!pending)
211
+ return { ok: false, reason: 'none' };
212
+ if (!this.alive(pending.expiresAt)) {
213
+ this.update((d) => ({ ...d, code: null }));
214
+ return { ok: false, reason: 'expired' };
215
+ }
216
+ const code = normalizeCode(typed);
217
+ if (!code || !sameHash(secretHash(code), pending.hash)) {
218
+ const wrong = pending.wrong + 1;
219
+ const voided = wrong >= CODE_MAX_WRONG;
220
+ this.update((d) => ({ ...d, code: voided ? null : { ...pending, wrong }, codeVoided: voided || d.codeVoided }));
221
+ return { ok: false, reason: voided ? 'void' : 'wrong' };
222
+ }
223
+ const t = this.now();
224
+ const token = this.newToken();
225
+ const device = {
226
+ id: this.newId(),
227
+ name: name.slice(0, 40),
228
+ role: pending.role,
229
+ address: address.slice(0, 45),
230
+ createdAt: this.iso(t),
231
+ expiresAt: this.iso(t + rememberDays * DAY_MS),
232
+ tokenHash: secretHash(token),
233
+ };
234
+ this.update((d) => ({ ...d, code: null, devices: [...d.devices, device].slice(-MAX_DEVICES) }));
235
+ return { ok: true, device, credential: credentialValue(device.id, token) };
236
+ }
237
+ /** The device a cookie value stands for: known id, same token, not expired. */
238
+ device(value) {
239
+ const parsed = parseCredential(value);
240
+ if (!parsed)
241
+ return null;
242
+ const device = this.read().devices.find((d) => d.id === parsed.id);
243
+ if (!device || !this.alive(device.expiresAt))
244
+ return null;
245
+ return sameHash(secretHash(parsed.token), device.tokenHash) ? device : null;
246
+ }
247
+ /** Notes when a device was last seen (at most every 10 minutes per device: each write seals the list again). */
248
+ seen(id) {
249
+ const device = this.read().devices.find((d) => d.id === id);
250
+ const t = this.now();
251
+ if (!device || (device.lastSeen && t - Date.parse(device.lastSeen) < 10 * 60_000))
252
+ return;
253
+ this.update((d) => ({
254
+ ...d,
255
+ devices: d.devices.map((x) => (x.id === id ? { ...x, lastSeen: this.iso(t) } : x)),
256
+ }));
257
+ }
258
+ /** A credential for a Windows user (after the pipe sign-in): the cookie value. */
259
+ issueWindows(sid, user, rememberDays) {
260
+ const t = this.now();
261
+ const token = this.newToken();
262
+ const grant = {
263
+ id: this.newId(),
264
+ sid,
265
+ user: user.slice(0, 140),
266
+ createdAt: this.iso(t),
267
+ expiresAt: this.iso(t + rememberDays * DAY_MS),
268
+ tokenHash: secretHash(token),
269
+ };
270
+ this.update((d) => ({ ...d, windows: [...d.windows, grant].slice(-MAX_WINDOWS_GRANTS) }));
271
+ return credentialValue(grant.id, token);
272
+ }
273
+ windowsGrant(value) {
274
+ const parsed = parseCredential(value);
275
+ if (!parsed)
276
+ return null;
277
+ const grant = this.read().windows.find((w) => w.id === parsed.id);
278
+ if (!grant || !this.alive(grant.expiresAt))
279
+ return null;
280
+ return sameHash(secretHash(parsed.token), grant.tokenHash) ? grant : null;
281
+ }
282
+ /** Ends one device or Windows sign-in; false when there is no such id. */
283
+ revoke(id) {
284
+ const data = this.read();
285
+ if (!data.devices.some((d) => d.id === id) && !data.windows.some((w) => w.id === id))
286
+ return false;
287
+ this.update((d) => ({
288
+ ...d,
289
+ devices: d.devices.filter((x) => x.id !== id),
290
+ windows: d.windows.filter((x) => x.id !== id),
291
+ }));
292
+ return true;
293
+ }
294
+ /** Ends every device (and, unless only devices are asked for, every Windows sign-in); voids the pending code. */
295
+ revokeAll(what = 'everything') {
296
+ this.update((d) => ({
297
+ ...d,
298
+ epoch: d.epoch + 1,
299
+ devices: [],
300
+ windows: what === 'everything' ? [] : d.windows,
301
+ code: null,
302
+ }));
303
+ }
304
+ /** Remembers which network LAN mode was turned on for (null: forget it). */
305
+ setNetwork(network) {
306
+ this.update((d) => ({ ...d, network }));
307
+ }
308
+ }
@@ -0,0 +1,20 @@
1
+ /** Phones (Apple's rule) refuse certificates valid longer than this. */
2
+ export declare const CERT_DAYS = 397;
3
+ export interface MadeCertificate {
4
+ /** PEM certificate and PKCS#8 PEM private key. */
5
+ readonly cert: string;
6
+ readonly key: string;
7
+ /** SHA-256 of the certificate, AA:BB:... (what browsers show). */
8
+ readonly fingerprint: string;
9
+ readonly notAfter: string;
10
+ }
11
+ export interface CertificateRequest {
12
+ readonly addresses: readonly string[];
13
+ readonly dnsNames?: readonly string[];
14
+ readonly now?: number;
15
+ readonly days?: number;
16
+ }
17
+ /** A new key and its self-signed certificate. */
18
+ export declare function makeCertificate(r: CertificateRequest): MadeCertificate;
19
+ /** SHA-256 of DER bytes as AA:BB:... */
20
+ export declare function fingerprintOf(der: Buffer): string;
@@ -0,0 +1,103 @@
1
+ // The LAN listener's certificate: self-signed, EC P-256, 397 days (the most phones accept), for this PC's private
2
+ // addresses (subject alternative names, IP entries) and its name, server authentication only and not a CA (a CA key on
3
+ // this PC that a phone trusted could vouch for any site). Node 22 has no certificate builder, so this writes the X.509
4
+ // DER itself (a few fixed structures) and node:crypto makes the key and signs. The self-test parses the result with
5
+ // crypto.X509Certificate and serves HTTPS with it.
6
+ import crypto from 'node:crypto';
7
+ import net from 'node:net';
8
+ /** Phones (Apple's rule) refuse certificates valid longer than this. */
9
+ export const CERT_DAYS = 397;
10
+ const DAY_MS = 86_400_000;
11
+ function length(n) {
12
+ if (n < 0x80)
13
+ return Buffer.from([n]);
14
+ const bytes = [];
15
+ for (let v = n; v > 0; v = Math.floor(v / 256))
16
+ bytes.unshift(v & 0xff);
17
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
18
+ }
19
+ /** One DER element: tag, length, content. */
20
+ function tlv(tag, content) {
21
+ return Buffer.concat([Buffer.from([tag]), length(content.length), content]);
22
+ }
23
+ const seq = (...items) => tlv(0x30, Buffer.concat(items));
24
+ const set = (...items) => tlv(0x31, Buffer.concat(items));
25
+ const octets = (b) => tlv(0x04, b);
26
+ const bits = (b) => tlv(0x03, Buffer.concat([Buffer.from([0]), b]));
27
+ const explicit = (n, inner) => tlv(0xa0 + n, inner);
28
+ function oid(dotted) {
29
+ const arcs = dotted.split('.').map(Number);
30
+ const out = [arcs[0] * 40 + arcs[1]];
31
+ for (const arc of arcs.slice(2)) {
32
+ const chunk = [arc & 0x7f];
33
+ for (let v = Math.floor(arc / 128); v > 0; v = Math.floor(v / 128))
34
+ chunk.unshift(0x80 | (v & 0x7f));
35
+ out.push(...chunk);
36
+ }
37
+ return tlv(0x06, Buffer.from(out));
38
+ }
39
+ /** A positive INTEGER from big-endian bytes. */
40
+ function integer(bytes) {
41
+ let b = bytes;
42
+ while (b.length > 1 && b[0] === 0 && b[1] < 0x80)
43
+ b = b.subarray(1);
44
+ return tlv(0x02, b[0] >= 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b);
45
+ }
46
+ /** UTCTime (YYMMDDHHMMSSZ), what certificates use before 2050. */
47
+ function utcTime(ms) {
48
+ const iso = new Date(ms).toISOString();
49
+ const text = iso.slice(2, 4) +
50
+ iso.slice(5, 7) +
51
+ iso.slice(8, 10) +
52
+ iso.slice(11, 13) +
53
+ iso.slice(14, 16) +
54
+ iso.slice(17, 19) +
55
+ 'Z';
56
+ return tlv(0x17, Buffer.from(text, 'ascii'));
57
+ }
58
+ function name(commonName) {
59
+ return seq(set(seq(oid('2.5.4.3'), tlv(0x0c, Buffer.from(commonName, 'utf8')))));
60
+ }
61
+ function extension(id, critical, value) {
62
+ return seq(oid(id), ...(critical ? [tlv(0x01, Buffer.from([0xff]))] : []), octets(value));
63
+ }
64
+ /** The subject alternative names: IP entries for the addresses, a DNS entry for the PC's name. */
65
+ function altNames(addresses, dnsNames) {
66
+ const entries = [
67
+ ...addresses.filter((a) => net.isIPv4(a)).map((a) => tlv(0x87, Buffer.from(a.split('.').map(Number)))),
68
+ ...dnsNames.filter((d) => /^[A-Za-z0-9-]{1,63}$/.test(d)).map((d) => tlv(0x82, Buffer.from(d, 'ascii'))),
69
+ ];
70
+ return seq(...entries);
71
+ }
72
+ /** A new key and its self-signed certificate. */
73
+ export function makeCertificate(r) {
74
+ if (!r.addresses.length || !r.addresses.every((a) => net.isIPv4(a)))
75
+ throw new Error('A certificate needs at least one IPv4 address.');
76
+ const now = r.now ?? Date.now();
77
+ const days = r.days ?? CERT_DAYS;
78
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
79
+ const spki = publicKey.export({ type: 'spki', format: 'der' });
80
+ const serial = crypto.randomBytes(16);
81
+ serial[0] &= 0x7f;
82
+ const algorithm = seq(oid('1.2.840.10045.4.3.2'));
83
+ const subject = name('Agent Sandbox web UI');
84
+ const notBefore = now - 60 * 60_000;
85
+ const notAfter = now + days * DAY_MS;
86
+ const extensions = seq(extension('2.5.29.19', true, seq()), extension('2.5.29.15', true, bits(Buffer.from([0x80]))), extension('2.5.29.37', false, seq(oid('1.3.6.1.5.5.7.3.1'))), extension('2.5.29.17', false, altNames(r.addresses, r.dnsNames ?? [])));
87
+ const tbs = seq(explicit(0, integer(Buffer.from([2]))), integer(serial), algorithm, subject, seq(utcTime(notBefore), utcTime(notAfter)), subject, spki, explicit(3, extensions));
88
+ const signature = crypto.sign('sha256', tbs, { key: privateKey, dsaEncoding: 'der' });
89
+ const der = seq(tbs, algorithm, bits(signature));
90
+ const cert = '-----BEGIN CERTIFICATE-----\n' +
91
+ (der.toString('base64').match(/.{1,64}/g) ?? []).join('\n') +
92
+ '\n-----END CERTIFICATE-----\n';
93
+ return {
94
+ cert,
95
+ key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
96
+ fingerprint: fingerprintOf(der),
97
+ notAfter: new Date(notAfter).toISOString(),
98
+ };
99
+ }
100
+ /** SHA-256 of DER bytes as AA:BB:... */
101
+ export function fingerprintOf(der) {
102
+ return (crypto.createHash('sha256').update(der).digest('hex').toUpperCase().match(/.{2}/g) ?? []).join(':');
103
+ }
@@ -0,0 +1,13 @@
1
+ export type DoorAnswer = {
2
+ readonly ok: true;
3
+ readonly token: string;
4
+ readonly port: number;
5
+ readonly role: 'view' | 'control';
6
+ } | {
7
+ readonly ok: false;
8
+ readonly reason: string;
9
+ };
10
+ /** What the helper printed (and its exit code), as an answer. */
11
+ export declare function parseDoorAnswer(text: string, exitCode: number | null, owner: string): DoorAnswer;
12
+ /** Knocks on `owner`'s web UI (a name or a SID) as this Windows user. */
13
+ export declare function knockOnDoor(storageRoot: string, owner: string): Promise<DoorAnswer>;
@@ -0,0 +1,54 @@
1
+ // Another Windows user's side of Windows sign-in (docs/design/remote-access.md): their own HostTools connects to the
2
+ // owner's pipe, checks that its server runs in the owner's session (so nobody else can squat the name), and gets a
3
+ // one-time token for the owner's web UI, or the reason it was refused. Nothing secret is sent: Windows says who
4
+ // connected.
5
+ import { spawn } from 'node:child_process';
6
+ import { fail } from '../../core/util/index.js';
7
+ import { hostToolsExe } from '../native.js';
8
+ const NOT_OWNER = 3;
9
+ const NO_PIPE = 4;
10
+ const NO_USER = 5;
11
+ /** What the helper printed (and its exit code), as an answer. */
12
+ export function parseDoorAnswer(text, exitCode, owner) {
13
+ if (exitCode === NOT_OWNER)
14
+ return {
15
+ ok: false,
16
+ reason: 'The sign-in point for ' + owner + ' is not theirs (another user made it). Nothing was sent.',
17
+ };
18
+ if (exitCode === NO_PIPE)
19
+ return { ok: false, reason: owner + "'s web UI is not running, or it is from a version without Windows sign-in." };
20
+ if (exitCode === NO_USER)
21
+ return { ok: false, reason: 'There is no Windows user ' + owner + ' on this PC.' };
22
+ if (exitCode !== 0)
23
+ return { ok: false, reason: 'Windows sign-in did not work (exit ' + exitCode + ').' };
24
+ const ok = /^ok ([0-9a-f]{64}) (\d{4,5}) (view|control)$/.exec(text.trim());
25
+ if (ok) {
26
+ const port = Number(ok[2]);
27
+ if (port >= 1024 && port <= 65535)
28
+ return { ok: true, token: ok[1], port, role: ok[3] };
29
+ }
30
+ const refused = /^refused (.{1,200})$/.exec(text.trim());
31
+ return { ok: false, reason: refused ? 'Refused: ' + refused[1] + '.' : 'The web UI gave an answer that is not one.' };
32
+ }
33
+ /** Knocks on `owner`'s web UI (a name or a SID) as this Windows user. */
34
+ export async function knockOnDoor(storageRoot, owner) {
35
+ if (!/^[^\0\r\n"]{1,140}$/.test(owner))
36
+ fail('That is not a Windows user name.');
37
+ const exe = hostToolsExe(storageRoot);
38
+ if (!exe)
39
+ fail('Windows sign-in needs HostTools, which could not be built on this PC.', 'Run sandbox doctor.');
40
+ return new Promise((resolve) => {
41
+ const child = spawn(exe, ['door-client', '--owner', owner], { windowsHide: true });
42
+ let out = '';
43
+ child.stdout.on('data', (d) => {
44
+ if (out.length < 1024)
45
+ out += d.toString('utf8');
46
+ });
47
+ const timer = setTimeout(() => child.kill(), 20_000);
48
+ child.on('error', () => resolve({ ok: false, reason: 'HostTools could not be started.' }));
49
+ child.on('close', (code) => {
50
+ clearTimeout(timer);
51
+ resolve(parseDoorAnswer(out, code, owner));
52
+ });
53
+ });
54
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ export declare const FIREWALL_RULE_NAME = "Agent Sandbox web UI (LAN)";
3
+ export type FirewallRequest = {
4
+ readonly action: 'add';
5
+ readonly ruleName: string;
6
+ readonly port: number;
7
+ readonly localAddress: string;
8
+ readonly program: string;
9
+ } | {
10
+ readonly action: 'remove';
11
+ readonly ruleName: string;
12
+ };
13
+ /** The request for adding the rule (checked here first; the script checks it again). */
14
+ export declare function addRuleRequest(port: number, localAddress: string, program?: string): FirewallRequest;
15
+ export declare function removeRuleRequest(): FirewallRequest;
16
+ export interface ScriptRun {
17
+ readonly status: number | null;
18
+ readonly stdout: string;
19
+ readonly stderr: string;
20
+ }
21
+ export type Runner = (exe: string, args: readonly string[], timeoutMs: number) => Promise<ScriptRun>;
22
+ /** The real runner: a process, its output collected. */
23
+ export declare const spawnRunner: Runner;
24
+ declare const OutcomeSchema: z.ZodObject<{
25
+ ok: z.ZodBoolean;
26
+ message: z.ZodString;
27
+ commands: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
+ }, z.core.$strip>;
29
+ export type FirewallOutcome = z.output<typeof OutcomeSchema>;
30
+ /** Adds or removes the rule on this PC (one Windows permission prompt). Never in the self-test with the real runner. */
31
+ export declare function applyFirewall(request: FirewallRequest, run?: Runner): Promise<FirewallOutcome>;
32
+ /** Checks a request with the script itself and returns what it would run; nothing changes (no elevation). */
33
+ export declare function checkFirewall(request: unknown, run?: Runner): Promise<FirewallOutcome>;
34
+ declare const ProfileSchema: z.ZodObject<{
35
+ found: z.ZodBoolean;
36
+ category: z.ZodString;
37
+ name: z.ZodString;
38
+ }, z.core.$strip>;
39
+ export type NetworkProfile = z.output<typeof ProfileSchema>;
40
+ /** The network the address is on (category and name), from Windows. */
41
+ export declare function networkProfile(address: string, run?: Runner): Promise<NetworkProfile>;
42
+ /** Why LAN mode may not start on this network, or null. `expected`: the network it was turned on for. */
43
+ export declare function networkProblem(p: NetworkProfile, expected: string | null): string | null;
44
+ export {};
@@ -0,0 +1,118 @@
1
+ // The Windows Firewall rule of LAN mode and the network it runs on (docs/design/remote-access.md). The rule is added or
2
+ // removed by native\scripts\set-web-firewall.ps1, elevated (one Windows permission prompt), from a request file whose
3
+ // SHA-256 it checks; it checks every value again. get-network-profile.ps1 says whether the address is on a Private
4
+ // network. A test hands in a fake runner, or runs the script with -DryRun (no elevation, nothing changed).
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { z } from 'zod';
11
+ import { fail } from '../../core/util/index.js';
12
+ import { readValid } from '../../shared/io/index.js';
13
+ import { SYSTEM } from '../../shared/system.js';
14
+ import { nativeFile, nativeScript } from '../native.js';
15
+ import { refuseInSelfTest } from '../test-guard.js';
16
+ import { isPrivateIPv4 } from './lan.js';
17
+ export const FIREWALL_RULE_NAME = 'Agent Sandbox web UI (LAN)';
18
+ const SCRIPT_TIMEOUT_MS = 5 * 60_000;
19
+ /** The request for adding the rule (checked here first; the script checks it again). */
20
+ export function addRuleRequest(port, localAddress, program = process.execPath) {
21
+ if (!Number.isInteger(port) || port < 1024 || port > 65535)
22
+ fail('The LAN port must be from 1024 to 65535.');
23
+ if (!isPrivateIPv4(localAddress))
24
+ fail(localAddress + ' is not a private address.', 'LAN mode is for your own network only.');
25
+ if (path.basename(program).toLowerCase() !== 'node.exe' || !path.isAbsolute(program))
26
+ fail('The web UI runs on node.exe, which the firewall rule names: ' + program + ' is not it.');
27
+ return { action: 'add', ruleName: FIREWALL_RULE_NAME, port, localAddress, program };
28
+ }
29
+ export function removeRuleRequest() {
30
+ return { action: 'remove', ruleName: FIREWALL_RULE_NAME };
31
+ }
32
+ /** The real runner: a process, its output collected. */
33
+ export const spawnRunner = (exe, args, timeoutMs) => new Promise((resolve) => {
34
+ const child = spawn(exe, [...args], { windowsHide: true });
35
+ let stdout = '';
36
+ let stderr = '';
37
+ const timer = setTimeout(() => child.kill(), timeoutMs);
38
+ child.stdout.on('data', (d) => (stdout += d.toString('utf8')));
39
+ child.stderr.on('data', (d) => (stderr += d.toString('utf8')));
40
+ child.on('error', (e) => resolve({ status: null, stdout, stderr: stderr + e.message }));
41
+ child.on('close', (status) => {
42
+ clearTimeout(timer);
43
+ resolve({ status, stdout, stderr });
44
+ });
45
+ });
46
+ const OutcomeSchema = z.object({
47
+ ok: z.boolean(),
48
+ message: z.string().max(2000),
49
+ commands: z.array(z.string().max(1000)).max(4).optional(),
50
+ });
51
+ /** Writes the request in a private temp folder and runs the script on it (elevated, or -DryRun). */
52
+ async function runScript(request, dryRun, run) {
53
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-sandbox-firewall-'));
54
+ try {
55
+ const file = path.join(dir, 'request.json');
56
+ fs.writeFileSync(file, JSON.stringify(request, null, 2), 'utf8');
57
+ const sha = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
58
+ const script = nativeFile('scripts', 'set-web-firewall.ps1');
59
+ const args = dryRun
60
+ ? nativeScript('set-web-firewall.ps1', ['-Json', file, '-Sha256', sha, '-DryRun'])
61
+ : nativeScript('run-elevated.ps1', ['-Script', script, '-Json', file, '-Sha256', sha]);
62
+ const out = await run(SYSTEM.powershell(), args, SCRIPT_TIMEOUT_MS);
63
+ if (dryRun) {
64
+ const line = out.stdout.trim().split(/\r?\n/).pop() ?? '';
65
+ const parsed = OutcomeSchema.safeParse(safeJson(line));
66
+ return parsed.success ? parsed.data : { ok: false, message: (out.stderr || out.stdout || 'no answer').trim() };
67
+ }
68
+ const answer = readValid(OutcomeSchema, path.join(dir, 'result.json'));
69
+ if (!answer)
70
+ return fail(out.status === 1
71
+ ? 'Windows did not give permission (the prompt was declined or closed).'
72
+ : 'The firewall script gave no answer' + (out.stderr.trim() ? ': ' + out.stderr.trim() : '.'), 'Run the command again and choose Yes in the Windows prompt.');
73
+ return answer;
74
+ }
75
+ finally {
76
+ fs.rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ }
79
+ /** Adds or removes the rule on this PC (one Windows permission prompt). Never in the self-test with the real runner. */
80
+ export async function applyFirewall(request, run = spawnRunner) {
81
+ if (run === spawnRunner)
82
+ refuseInSelfTest('change the Windows Firewall');
83
+ const outcome = await runScript(request, false, run);
84
+ if (!outcome.ok)
85
+ fail('The firewall rule was not changed: ' + outcome.message);
86
+ return outcome;
87
+ }
88
+ /** Checks a request with the script itself and returns what it would run; nothing changes (no elevation). */
89
+ export function checkFirewall(request, run = spawnRunner) {
90
+ return runScript(request, true, run);
91
+ }
92
+ const ProfileSchema = z.object({ found: z.boolean(), category: z.string().max(64), name: z.string().max(256) });
93
+ /** The network the address is on (category and name), from Windows. */
94
+ export async function networkProfile(address, run = spawnRunner) {
95
+ if (!isPrivateIPv4(address))
96
+ return { found: false, category: '', name: '' };
97
+ const out = await run(SYSTEM.powershell(), nativeScript('get-network-profile.ps1', ['-Address', address]), 30_000);
98
+ const parsed = ProfileSchema.safeParse(safeJson(out.stdout.trim().split(/\r?\n/).pop() ?? ''));
99
+ return parsed.success ? parsed.data : { found: false, category: '', name: '' };
100
+ }
101
+ /** Why LAN mode may not start on this network, or null. `expected`: the network it was turned on for. */
102
+ export function networkProblem(p, expected) {
103
+ if (!p.found)
104
+ return 'Windows does not know the network of that address.';
105
+ if (p.category !== 'Private')
106
+ return ('The network "' + p.name + '" is marked ' + (p.category || 'unknown') + ', not Private, so LAN mode stays off.');
107
+ if (expected !== null && p.name !== expected)
108
+ return 'LAN mode was turned on for the network "' + expected + '", not "' + p.name + '".';
109
+ return null;
110
+ }
111
+ function safeJson(text) {
112
+ try {
113
+ return JSON.parse(text);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }