windows-development-sandbox 1.11.3 → 1.11.5

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 (36) hide show
  1. package/dist/app/features/accounts/accounts.def.js +0 -1
  2. package/dist/app/features/data/data.def.d.ts +1 -1
  3. package/dist/app/features/data/data.def.js +41 -48
  4. package/dist/app/features/defs.d.ts +7 -0
  5. package/dist/app/features/defs.js +7 -3
  6. package/dist/app/features/health/health.def.js +28 -9
  7. package/dist/app/features/sandbox/sandbox.def.js +4 -4
  8. package/dist/app/features/storage/storage.def.js +8 -13
  9. package/dist/app/features/updates/updates.def.js +2 -3
  10. package/dist/guest/toolchains/tools/cli.js +19 -0
  11. package/dist/host/life/host-memory.d.ts +18 -0
  12. package/dist/host/life/host-memory.js +66 -0
  13. package/dist/host/web/runner.d.ts +1 -1
  14. package/dist/host/web/server.d.ts +12 -3
  15. package/dist/host/web/server.js +20 -6
  16. package/dist/runtime/daemon.js +16 -1
  17. package/dist/runtime/main.js +16 -1
  18. package/dist/runtime/view.js +2 -0
  19. package/dist/selftest/app.test.js +21 -9
  20. package/dist/selftest/health.test.js +35 -0
  21. package/dist/selftest/live-terminal.test.js +30 -0
  22. package/dist/selftest/support/web-preview.js +3 -0
  23. package/dist/selftest/web.test.js +33 -0
  24. package/dist/shared/pty/client.d.ts +0 -1
  25. package/dist/shared/pty/client.js +5 -0
  26. package/dist/sync/watcher.d.ts +2 -0
  27. package/dist/sync/watcher.js +7 -2
  28. package/dist/web/index.html +2 -2
  29. package/dist/web/{main-Z645W45E.js → main-QL7KHCEE.js} +76 -76
  30. package/dist/web/{main-6JF3ASB5.css → main-RQFN4WBY.css} +1 -1
  31. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  32. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  33. package/native/csharp/HostTools/MemoryStatus.cs +58 -2
  34. package/native/csharp/HostTools/Program.cs +7 -1
  35. package/package.json +1 -1
  36. package/sandbox/csharp/PtyHost.cs +228 -2
@@ -209,7 +209,6 @@ export function agentSection(a) {
209
209
  id: a.name,
210
210
  title: a.name,
211
211
  summary: a.accounts.length ? plural(a.accounts.length, 'saved login') : 'no saved login yet',
212
- description: 'Saved ' + a.name + ' logins.',
213
212
  cards: true,
214
213
  items: [...saveItems(a), ...a.accounts.map((r) => loginItem(a, r))],
215
214
  };
@@ -113,7 +113,7 @@ export declare const pruneStorage: import("../../kernel/define.js").OperationDef
113
113
  message: z.ZodString;
114
114
  removed: z.ZodBoolean;
115
115
  }, z.core.$strip>, "data.prune">;
116
- /** The data page: disks, then one section per area, the total, and clean-up. */
116
+ /** The data page: the disks, then every kept folder in one table (its total as the summary, clean-up as its action). */
117
117
  export declare function dataPage(o: DataShow): Page;
118
118
  export declare const dataView: import("../../kernel/define.js").ViewDef<{
119
119
  data: import("../../kernel/define.js").QueryDef<z.ZodObject<{}, z.core.$strip>, z.ZodObject<{
@@ -103,7 +103,7 @@ export const pruneStorage = defineOperation({
103
103
  },
104
104
  });
105
105
  const DAYS_FIELD = fieldOf(new SchemaNode(PruneInputSchema).child('days'), ['days']);
106
- /** The data page: disks, then one section per area, the total, and clean-up. */
106
+ /** The data page: the disks, then every kept folder in one table (its total as the summary, clean-up as its action). */
107
107
  export function dataPage(o) {
108
108
  const disks = {
109
109
  id: 'disks',
@@ -132,44 +132,44 @@ export function dataPage(o) {
132
132
  })),
133
133
  notes: [{ text: 'Persistent data for the sandbox: ' + o.dataPath + ' (C:\\AgentData inside)' }],
134
134
  };
135
- const areas = DATA_AREAS.flatMap((area) => {
136
- const rows = o.entries.filter((e) => e.area === area);
137
- if (!rows.length)
138
- return [];
139
- return [
140
- {
141
- id: 'area:' + area,
142
- title: AREA_LABEL[area],
143
- summary: o.areaTotals[area] ?? '',
144
- columns: [
145
- { key: 'name', label: '' },
146
- { key: 'size', label: '' },
147
- { key: 'path', label: '' },
148
- ],
149
- columnsOn: 'cli',
150
- items: rows.map((e) => ({
151
- id: 'entry:' + e.name,
152
- label: e.name,
153
- hint: e.size,
154
- ...(e.target
155
- ? { keys: 'x deletes it.' }
156
- : { description: 'Kept on this PC on purpose; it is not deleted here.' }),
157
- details: ['Folder ' + e.path],
158
- cells: { name: ' ' + e.name, size: e.size, path: e.path },
159
- actions: e.target
160
- ? [action(cleanData, { targets: [e.target] }, { label: 'Delete', key: 'x', verb: 'delete' })]
161
- : [],
162
- })),
163
- },
164
- ];
165
- });
166
- const total = {
167
- id: 'total',
168
- facts: [{ id: 'total', label: 'Total', value: o.total }],
135
+ // Every folder in one table, grouped by kind (the order of DATA_AREAS), biggest first within a kind.
136
+ const kindRank = (area) => DATA_AREAS.indexOf(area);
137
+ const rows = [...o.entries].sort((a, b) => kindRank(a.area) - kindRank(b.area) || b.bytes - a.bytes);
138
+ const kept = {
139
+ id: 'kept',
140
+ title: 'What the sandbox keeps',
141
+ summary: o.total + ' in all',
142
+ columns: [
143
+ { key: 'kind', label: 'KIND' },
144
+ { key: 'name', label: 'NAME' },
145
+ { key: 'size', label: 'SIZE' },
146
+ { key: 'folder', label: 'FOLDER' },
147
+ ],
148
+ items: rows.map((e) => {
149
+ const del = e.target
150
+ ? action(cleanData, { targets: [e.target] }, { label: 'Delete', key: 'x', verb: 'delete' })
151
+ : undefined;
152
+ return {
153
+ id: 'entry:' + e.name,
154
+ label: e.name,
155
+ hint: e.size,
156
+ cells: { kind: AREA_LABEL[e.area], name: e.name, size: e.size, folder: e.path },
157
+ ...(del ? { primary: del } : {}),
158
+ };
159
+ }),
160
+ actions: [
161
+ action(pruneStorage, {}, {
162
+ label: 'Clean up…',
163
+ verb: 'choose what',
164
+ description: 'Delete caches, old sandboxes and other leftovers (the clean command, with its options).',
165
+ fields: [DAYS_FIELD],
166
+ }),
167
+ ],
169
168
  notes: [
170
169
  ...(o.entries.length
171
170
  ? []
172
171
  : [{ text: 'Nothing stored yet. Toolchains, databases and agent logins appear here after the first start.' }]),
172
+ ...keptNote(o),
173
173
  {
174
174
  text: 'Free space with: sandbox data clean <target...> (e.g. tools/go, databases/postgres-18, logs, launches)',
175
175
  tone: 'muted',
@@ -177,19 +177,12 @@ export function dataPage(o) {
177
177
  },
178
178
  ],
179
179
  };
180
- const clean = {
181
- id: 'clean',
182
- title: 'Clean up',
183
- actions: [
184
- action(pruneStorage, {}, {
185
- label: 'Clean up…',
186
- verb: 'choose what',
187
- description: 'Delete caches, old sandboxes and other leftovers (the clean command, with its options).',
188
- fields: [DAYS_FIELD],
189
- }),
190
- ],
191
- };
192
- return { title: 'Data', sections: [disks, ...areas, total, clean] };
180
+ return { title: 'Data', sections: [disks, kept] };
181
+ }
182
+ /** The folders kept on purpose (not deleted here), named in one note. */
183
+ function keptNote(o) {
184
+ const names = o.entries.filter((e) => !e.target).map((e) => e.name);
185
+ return names.length ? [{ text: 'Kept on this PC on purpose, not deleted here: ' + names.join(', ') + '.' }] : [];
193
186
  }
194
187
  export const dataView = defineView({
195
188
  id: 'data',
@@ -1262,9 +1262,16 @@ export interface NavSection {
1262
1262
  readonly elsewhere?: string;
1263
1263
  /** Entries under this one (Sandbox: Status, Projects). */
1264
1264
  readonly children?: readonly NavSection[];
1265
+ /** Views shown as more categories inside this entry's page (Settings: VPN, GitHub), not as entries of their own. */
1266
+ readonly embeds?: readonly {
1267
+ readonly label: string;
1268
+ readonly view: ViewId;
1269
+ }[];
1265
1270
  }
1266
1271
  /** Every entry of the navigation, children included, in order. */
1267
1272
  export declare function navSections(nav: readonly NavSection[]): NavSection[];
1268
1273
  /** Every view the web's navigation opens. */
1269
1274
  export declare function navViews(nav: readonly NavSection[]): ViewId[];
1275
+ /** The entry whose page shows this view as one of its categories (VPN: Settings), if any. */
1276
+ export declare function embeddingOf(nav: readonly NavSection[], view: string): NavSection | undefined;
1270
1277
  export declare const NAV: readonly NavSection[];
@@ -125,7 +125,11 @@ export function navSections(nav) {
125
125
  }
126
126
  /** Every view the web's navigation opens. */
127
127
  export function navViews(nav) {
128
- return navSections(nav).flatMap((n) => (n.view ? [n.view] : []));
128
+ return navSections(nav).flatMap((n) => [...(n.view ? [n.view] : []), ...(n.embeds ?? []).map((e) => e.view)]);
129
+ }
130
+ /** The entry whose page shows this view as one of its categories (VPN: Settings), if any. */
131
+ export function embeddingOf(nav, view) {
132
+ return navSections(nav).find((n) => n.embeds?.some((e) => e.view === view));
129
133
  }
130
134
  export const NAV = [
131
135
  { label: 'Agents', view: 'agents' },
@@ -140,8 +144,8 @@ export const NAV = [
140
144
  { label: 'Apps', view: 'apps' },
141
145
  {
142
146
  label: 'Settings',
143
- children: [
144
- { label: 'All settings', view: 'settings' },
147
+ view: 'settings',
148
+ embeds: [
145
149
  { label: 'VPN', view: 'vpn' },
146
150
  { label: 'GitHub', view: 'github' },
147
151
  ],
@@ -165,22 +165,35 @@ export const enableSandbox = defineOperation({
165
165
  },
166
166
  },
167
167
  });
168
+ /**
169
+ * A repair row: what it means is the description; what was found and the fix are label/value detail lines ("Found:",
170
+ * "Fix:"). An ok row has nothing to fix, so what it found is its description.
171
+ */
168
172
  function checkItems(last) {
169
173
  return last.checks.map((r, i) => {
170
- const lines = resultLines(r)
174
+ const found = r.detail + (r.timedOut ? ' (took too long)' : '');
175
+ const rest = resultLines(r)
171
176
  .slice(1)
172
- .map((l) => l.text.trim());
177
+ .map((l) => l.text.trim())
178
+ .filter((l) => !l.startsWith('What it means: '));
179
+ const details = r.status === 'ok' ? [] : ['Found: ' + found, ...rest];
173
180
  return {
174
181
  id: 'repair:' + i,
175
182
  label: r.name,
176
183
  hint: BADGE[r.status],
177
184
  ...(TONE[r.status] ? { tone: TONE[r.status] } : {}),
178
185
  badges: [{ text: BADGE[r.status], ...(TONE[r.status] ? { tone: TONE[r.status] } : {}) }],
179
- description: r.detail,
180
- ...(lines.length ? { details: lines } : {}),
186
+ description: r.means ?? (details.length ? undefined : found),
187
+ ...(details.length ? { details } : {}),
181
188
  };
182
189
  });
183
190
  }
191
+ /** A "Set up this PC" row: the real value is the hint; OK or action needed is the tone and the badge. */
192
+ const SETUP_BADGE = {
193
+ ok: 'OK',
194
+ warn: 'WARNING',
195
+ action: 'NEEDS YOU',
196
+ };
184
197
  /** The health page: before any check, the two things to do; after one, what it found. Pure. */
185
198
  export function healthPage(o) {
186
199
  const last = o.last;
@@ -223,8 +236,8 @@ export function healthPage(o) {
223
236
  const sections = [
224
237
  {
225
238
  id: 'status',
226
- summary: (last.mode === 'repair' ? 'Repaired ' : 'Checked ') + ago(last.at),
227
- facts: last.log ? [{ id: 'log', label: 'Log', value: last.log }] : [],
239
+ summary: (last.mode === 'repair' ? 'Last repair ' : 'Last check ') + ago(last.at),
240
+ ...(last.log ? { notes: [{ text: 'Log: ' + last.log, tone: 'muted' }] } : {}),
228
241
  },
229
242
  {
230
243
  id: 'doctor',
@@ -232,8 +245,9 @@ export function healthPage(o) {
232
245
  items: last.details.map(([name, detail], i) => ({
233
246
  id: 'doctor:' + i,
234
247
  label: name,
248
+ // The hint is the whole value; only a long one is cut, and then the description keeps all of it.
235
249
  hint: cut(detail, 50),
236
- details: [detail],
250
+ ...(detail.length > 50 ? { description: detail } : {}),
237
251
  })),
238
252
  },
239
253
  {
@@ -252,9 +266,14 @@ export function healthPage(o) {
252
266
  : setup.checks.map((c, i) => ({
253
267
  id: 'setup:' + i,
254
268
  label: c.name,
255
- hint: c.status === 'ok' ? 'OK' : c.status === 'warn' ? 'WARNING' : 'ACTION NEEDED',
269
+ hint: c.detail,
256
270
  tone: c.status === 'ok' ? 'ok' : c.status === 'warn' ? 'warn' : 'danger',
257
- description: c.detail,
271
+ badges: [
272
+ {
273
+ text: SETUP_BADGE[c.status],
274
+ tone: c.status === 'ok' ? 'ok' : c.status === 'warn' ? 'warn' : 'danger',
275
+ },
276
+ ],
258
277
  })),
259
278
  notes: canEnable
260
279
  ? [{ text: 'Turn Windows Sandbox on from the menu or with sandbox setup.', tone: 'muted', only: 'web' }]
@@ -130,12 +130,12 @@ function projectItem(p) {
130
130
  hint: p.host + ' -> ' + p.guest + ' (' + (MECHANISM_TEXT[p.mechanism] ?? 'wsb share') + ')',
131
131
  marked: p.here,
132
132
  details: [
133
- 'setup: ' + p.setup.phase + (p.setup.phase === 'ready' ? '' : ' - ' + p.setup.message),
133
+ 'Setup: ' + p.setup.phase + (p.setup.phase === 'ready' ? '' : ' - ' + p.setup.message),
134
134
  ...(p.databases.length
135
- ? ['databases: ' + p.databases.map((d) => d.database + ' :' + d.port + ' ' + d.state).join(', ')]
135
+ ? ['Databases: ' + p.databases.map((d) => d.database + ' :' + d.port + ' ' + d.state).join(', ')]
136
136
  : []),
137
- ...p.sessions.map((s) => 'session ' + s.id + ' ' + s.harness + ' ' + s.viewers + ' ' + ago(s.started)),
138
- ...p.repoGuard.map((f) => 'guard: ' + f),
137
+ ...p.sessions.map((s) => 'Session: ' + s.harness + ' ' + s.id + ', ' + s.viewers + ', started ' + ago(s.started)),
138
+ ...p.repoGuard.map((f) => 'Guard: ' + f),
139
139
  ],
140
140
  };
141
141
  }
@@ -160,22 +160,17 @@ export const containedOff = defineOperation({
160
160
  });
161
161
  /** The Storage page: the data folder (change, move, use another), then contained storage (beta). */
162
162
  export function storagePage(o, c) {
163
- const folder = {
164
- id: '@data-folder',
165
- label: 'Data folder',
166
- hint: o.root,
167
- description: 'Where your tools, logins, history and logs are kept between sandboxes. Press Enter to move them to another folder or drive, or to use a different folder.',
168
- details: [
169
- o.size,
170
- 'Default folder: ' + o.defaultRoot + (o.defaultInUse ? ' (in use)' : ''),
171
- ...o.otherFoldersWithData.map((f) => 'Another folder with data: ' + f),
172
- ],
173
- primary: action(changeDataFolder, {}, { label: 'Data folder', verb: 'change' }),
174
- };
163
+ // Move and use another folder are the two ways to change it (the same choice as the Data folder setting).
175
164
  const data = {
176
165
  id: 'folder',
177
166
  title: 'Data folder',
178
- items: [folder],
167
+ description: 'Where your tools, logins, history and logs are kept between sandboxes.',
168
+ facts: [
169
+ { id: 'folder', label: 'Folder', value: o.root },
170
+ { id: 'size', label: 'Size', value: o.size },
171
+ { id: 'default', label: 'Default folder', value: o.defaultRoot + (o.defaultInUse ? ' (in use)' : '') },
172
+ ...o.otherFoldersWithData.map((f, i) => ({ id: 'other:' + i, label: 'Other folder with data', value: f })),
173
+ ],
179
174
  actions: [action(moveStorage, {}), action(useStorage, {})],
180
175
  };
181
176
  const contained = {
@@ -159,7 +159,6 @@ export function updatesPage(s, latest) {
159
159
  hint: 'off',
160
160
  tone: 'warn',
161
161
  description: s.autoOffText,
162
- details: [s.autoOffText],
163
162
  }),
164
163
  ]
165
164
  : []),
@@ -178,8 +177,8 @@ export function updatesPage(s, latest) {
178
177
  row('failed', 'Last failed update', {
179
178
  hint: s.failed.version,
180
179
  tone: 'danger',
181
- description: s.failed.reason,
182
- details: [s.failed.reason, 'It is not tried again until a newer version is released.'],
180
+ description: 'It is not tried again until a newer version is released.',
181
+ details: [s.failed.reason],
183
182
  }),
184
183
  ]
185
184
  : []),
@@ -30,6 +30,22 @@ export function ghExePath() {
30
30
  const dir = tool('cli', 'gh');
31
31
  return path.join(entry?.subdir ? path.join(dir, entry.subdir) : dir, entry?.exe ?? 'gh.exe');
32
32
  }
33
+ /**
34
+ * gh keeps its token in Windows Credential Manager unless told otherwise, and that is wiped with the sandbox: only a
35
+ * login made with --insecure-storage (the token in GH_CONFIG_DIR, which persists) is still there next time.
36
+ */
37
+ const GH_NOTE = ' (log in once with: gh auth login --web --insecure-storage; it is kept between sandboxes)';
38
+ /** A saved gh account whose token was in Credential Manager (gone now): say how to log in so that it lasts. */
39
+ function warnKeyringLogin() {
40
+ try {
41
+ const hosts = fs.readFileSync(path.join(ghConfigDir(), 'hosts.yml'), 'utf8');
42
+ if (/^\s*user:/m.test(hosts) && !/oauth_token:/.test(hosts))
43
+ warn('gh: the GitHub login from an earlier sandbox was kept in Windows Credential Manager, which is wiped with each sandbox. Log in again with: gh auth login --web --insecure-storage (then it lasts).');
44
+ }
45
+ catch {
46
+ // No saved login: nothing to say.
47
+ }
48
+ }
33
49
  const toolVars = {
34
50
  gh: () => ({
35
51
  GH_CONFIG_DIR: exists(deliveredGhConfigDir()) ? deliveredGhConfigDir() : mkdirp(ghConfigDir()),
@@ -85,8 +101,11 @@ export async function setupCli(env, settings, gitAccess, refresh = false) {
85
101
  refresh,
86
102
  subdir: entry.subdir,
87
103
  size: entry.size,
104
+ ...(name === 'gh' ? { note: GH_NOTE } : {}),
88
105
  });
89
106
  Object.assign(env.vars, toolVars[name]?.() ?? {});
107
+ if (name === 'gh')
108
+ warnKeyringLogin();
90
109
  }
91
110
  catch (e) {
92
111
  // One tool failing never takes the others down.
@@ -17,6 +17,24 @@ export type HostMemory = z.output<typeof HostMemorySchema>;
17
17
  export declare function parseHostMemory(text: string): HostMemory | null;
18
18
  /** This PC's memory now, or null when it cannot be read (and always during the self-test: it never asks Windows). */
19
19
  export declare function readHostMemory(storageRoot: string): HostMemory | null;
20
+ /**
21
+ * One long-running `HostTools memory-status --serve` for the watcher: a reading every `seconds`, instead of starting a
22
+ * program (and building a disk counter) for each one. Without HostTools.exe it does nothing and `latest()` stays null.
23
+ */
24
+ export declare class HostMemoryStream {
25
+ private readonly storageRoot;
26
+ private readonly seconds;
27
+ private child;
28
+ private last;
29
+ private startedAt;
30
+ private stopped;
31
+ constructor(storageRoot: string, seconds: number);
32
+ start(): void;
33
+ /** The newest reading while it is fresh; starts the stream again when it ended (at most once a minute). */
34
+ latest(): HostMemory | null;
35
+ /** Ends it (closing its input ends HostTools). */
36
+ stop(): void;
37
+ }
20
38
  /** The same without blocking: for the watcher, which must never wait on it. */
21
39
  export declare function readHostMemoryAsync(storageRoot: string): Promise<HostMemory | null>;
22
40
  /** How low this PC's memory is: what the host watchdog acts on (pure). */
@@ -47,6 +47,72 @@ export function readHostMemory(storageRoot) {
47
47
  const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true, timeout: TIMEOUT_MS });
48
48
  return parseHostMemory(result.stdout || '');
49
49
  }
50
+ /** A reading older than this is not used (the stream stopped answering): the watcher asks once instead. */
51
+ const STREAM_FRESH_MS = 30000;
52
+ /** A stream that ended is started again at most this often. */
53
+ const STREAM_RESTART_MS = 60000;
54
+ /**
55
+ * One long-running `HostTools memory-status --serve` for the watcher: a reading every `seconds`, instead of starting a
56
+ * program (and building a disk counter) for each one. Without HostTools.exe it does nothing and `latest()` stays null.
57
+ */
58
+ export class HostMemoryStream {
59
+ storageRoot;
60
+ seconds;
61
+ child = null;
62
+ last = null;
63
+ startedAt = 0;
64
+ stopped = false;
65
+ constructor(storageRoot, seconds) {
66
+ this.storageRoot = storageRoot;
67
+ this.seconds = seconds;
68
+ }
69
+ start() {
70
+ if (!isWin || selfTestRunning() || this.stopped || this.child)
71
+ return;
72
+ const tools = hostToolsExe(this.storageRoot);
73
+ if (!tools)
74
+ return;
75
+ this.startedAt = Date.now();
76
+ try {
77
+ const child = spawn(tools, ['memory-status', '--serve', String(this.seconds)], {
78
+ windowsHide: true,
79
+ stdio: ['pipe', 'pipe', 'ignore'],
80
+ });
81
+ this.child = child;
82
+ let buffer = '';
83
+ child.stdout.setEncoding('utf8');
84
+ child.stdout.on('data', (chunk) => {
85
+ buffer = (buffer + chunk).slice(-16384);
86
+ let end;
87
+ while ((end = buffer.indexOf('\n')) >= 0) {
88
+ const memory = parseHostMemory(buffer.slice(0, end).trim());
89
+ buffer = buffer.slice(end + 1);
90
+ if (memory)
91
+ this.last = { at: Date.now(), memory };
92
+ }
93
+ });
94
+ child.stdin.on('error', () => undefined);
95
+ child.on('error', () => (this.child = null));
96
+ child.on('exit', () => (this.child = null));
97
+ }
98
+ catch {
99
+ // Could not start it: the watcher asks the old way.
100
+ this.child = null;
101
+ }
102
+ }
103
+ /** The newest reading while it is fresh; starts the stream again when it ended (at most once a minute). */
104
+ latest() {
105
+ if (!this.child && Date.now() - this.startedAt >= STREAM_RESTART_MS)
106
+ this.start();
107
+ return this.last && Date.now() - this.last.at < STREAM_FRESH_MS ? this.last.memory : null;
108
+ }
109
+ /** Ends it (closing its input ends HostTools). */
110
+ stop() {
111
+ this.stopped = true;
112
+ this.child?.stdin?.end();
113
+ this.child = null;
114
+ }
115
+ }
50
116
  /** The same without blocking: for the watcher, which must never wait on it. */
51
117
  export function readHostMemoryAsync(storageRoot) {
52
118
  if (!isWin || selfTestRunning())
@@ -8,7 +8,7 @@ export interface WebRunOptions {
8
8
  readonly staticRoot: string;
9
9
  /** This program's version, kept in the state file. */
10
10
  readonly version: string;
11
- /** End after this long unused while no sandbox runs (WebServerOptions.idleExitMs). */
11
+ /** End after this long with no browser (sooner with no sandbox): WebServerOptions.idleExitMs. */
12
12
  readonly idleExitMs?: number;
13
13
  }
14
14
  export declare class WebRunner {
@@ -4,8 +4,13 @@ import type { Endpoint } from '../../shared/pty/index.js';
4
4
  import type { WebBackend } from './backend.js';
5
5
  /** No inline script: the page and its script are files from this server. Inline styles are what xterm.js needs. */
6
6
  export declare const CONTENT_SECURITY_POLICY: string;
7
- /** The server ends after this long with no request, no socket and no sandbox running (it starts again with one). */
7
+ /**
8
+ * The server ends after this long with no browser connected and no request, even while a sandbox runs (sandbox web
9
+ * open and the next launch start it again), so it never sits in memory unused.
10
+ */
8
11
  export declare const IDLE_EXIT_MS: number;
12
+ /** ...and sooner when no sandbox runs either: there is nothing to look at. */
13
+ export declare const IDLE_NO_SANDBOX_MS: number;
9
14
  export declare const RPC_PATH = "/api/rpc";
10
15
  export interface WebServerOptions {
11
16
  /** 0 picks a free port (tests). */
@@ -20,8 +25,10 @@ export interface WebServerOptions {
20
25
  readonly dial?: (endpoint: Endpoint) => Promise<net.Socket>;
21
26
  /** The shared core's operations and queries the browser may run (none: /api/rpc is not served). */
22
27
  readonly rpc?: RpcHost;
23
- /** End by itself (onStop) after this long unused while no sandbox runs; 0 or missing: never (tests). */
28
+ /** End by itself (onStop) after this long with no browser, even while a sandbox runs; 0 or missing: never (tests). */
24
29
  readonly idleExitMs?: number;
30
+ /** ...or after this long when no sandbox runs either (default IDLE_NO_SANDBOX_MS; tests make it short). */
31
+ readonly idleNoSandboxMs?: number;
25
32
  /** Heartbeat period (tests make it short). */
26
33
  readonly heartbeatMs?: number;
27
34
  }
@@ -54,8 +61,10 @@ export declare class WebServer {
54
61
  * so they never hold a slot or a session connection.
55
62
  */
56
63
  private beat;
57
- /** Nothing has used the server for a while and no sandbox runs: it ends (the next launch starts it again). */
64
+ /** No browser has used the server for a while: it ends (sooner when no sandbox runs). */
58
65
  private checkIdle;
66
+ /** How long unused before it ends when no sandbox runs. */
67
+ private quietMs;
59
68
  /** Counts a new socket; at the limit, the oldest of its kind makes room (a new tab always works). */
60
69
  private track;
61
70
  stop(): Promise<void>;
@@ -19,8 +19,13 @@ const MAX_TERMINALS = 16;
19
19
  const MAX_RPC_SOCKETS = 8;
20
20
  /** Every socket is pinged this often; one that has not answered the last ping by the next is dropped. */
21
21
  const HEARTBEAT_MS = 20_000;
22
- /** The server ends after this long with no request, no socket and no sandbox running (it starts again with one). */
23
- export const IDLE_EXIT_MS = 30 * 60_000;
22
+ /**
23
+ * The server ends after this long with no browser connected and no request, even while a sandbox runs (sandbox web
24
+ * open and the next launch start it again), so it never sits in memory unused.
25
+ */
26
+ export const IDLE_EXIT_MS = 20 * 60_000;
27
+ /** ...and sooner when no sandbox runs either: there is nothing to look at. */
28
+ export const IDLE_NO_SANDBOX_MS = 5 * 60_000;
24
29
  const SESSION_PATH = /^\/api\/sessions\/([A-Za-z0-9_-]{1,64})\/terminal$/;
25
30
  export const RPC_PATH = '/api/rpc';
26
31
  class HttpFailure extends Error {
@@ -72,7 +77,7 @@ export class WebServer {
72
77
  this.heartbeat = setInterval(() => this.beat(), this.options.heartbeatMs ?? HEARTBEAT_MS);
73
78
  this.heartbeat.unref();
74
79
  if (this.options.idleExitMs) {
75
- this.idleCheck = setInterval(() => void this.checkIdle(), Math.min(60_000, this.options.idleExitMs));
80
+ this.idleCheck = setInterval(() => void this.checkIdle(), Math.min(60_000, this.quietMs()));
76
81
  this.idleCheck.unref();
77
82
  }
78
83
  resolve();
@@ -102,20 +107,29 @@ export class WebServer {
102
107
  }
103
108
  }
104
109
  }
105
- /** Nothing has used the server for a while and no sandbox runs: it ends (the next launch starts it again). */
110
+ /** No browser has used the server for a while: it ends (sooner when no sandbox runs). */
106
111
  async checkIdle() {
107
112
  const limit = this.options.idleExitMs ?? 0;
108
- if (!limit || this.tracked.size > 0 || Date.now() - this.lastUsed < limit)
113
+ if (!limit || this.tracked.size > 0)
114
+ return;
115
+ const idle = Date.now() - this.lastUsed;
116
+ if (idle >= limit)
117
+ return this.options.onStop?.();
118
+ if (idle < this.quietMs())
109
119
  return;
110
120
  try {
111
121
  if ((await this.options.backend.info()).sandbox === 'running')
112
122
  return;
113
123
  }
114
124
  catch {
115
- return; // cannot tell: keep running
125
+ return; // cannot tell: wait for the longer limit
116
126
  }
117
127
  this.options.onStop?.();
118
128
  }
129
+ /** How long unused before it ends when no sandbox runs. */
130
+ quietMs() {
131
+ return Math.min(this.options.idleExitMs ?? 0, this.options.idleNoSandboxMs ?? IDLE_NO_SANDBOX_MS);
132
+ }
119
133
  /** Counts a new socket; at the limit, the oldest of its kind makes room (a new tab always works). */
120
134
  track(ws, kind, limit) {
121
135
  const same = [...this.tracked.entries()]
@@ -29978,6 +29978,17 @@ function ghConfigDir() {
29978
29978
  function deliveredGhConfigDir() {
29979
29979
  return path45.join(localDir, "gh-token");
29980
29980
  }
29981
+ var GH_NOTE = " (log in once with: gh auth login --web --insecure-storage; it is kept between sandboxes)";
29982
+ function warnKeyringLogin() {
29983
+ try {
29984
+ const hosts = fs35.readFileSync(path45.join(ghConfigDir(), "hosts.yml"), "utf8");
29985
+ if (/^\s*user:/m.test(hosts) && !/oauth_token:/.test(hosts))
29986
+ warn(
29987
+ "gh: the GitHub login from an earlier sandbox was kept in Windows Credential Manager, which is wiped with each sandbox. Log in again with: gh auth login --web --insecure-storage (then it lasts)."
29988
+ );
29989
+ } catch {
29990
+ }
29991
+ }
29981
29992
  var toolVars = {
29982
29993
  gh: () => ({
29983
29994
  GH_CONFIG_DIR: exists(deliveredGhConfigDir()) ? deliveredGhConfigDir() : mkdirp(ghConfigDir()),
@@ -30018,9 +30029,11 @@ async function setupCli(env, settings, gitAccess, refresh = false) {
30018
30029
  await setupSingleExe(env, name, entry.repo, entry.asset(arch), entry.exe, tool("cli", name), {
30019
30030
  refresh,
30020
30031
  subdir: entry.subdir,
30021
- size: entry.size
30032
+ size: entry.size,
30033
+ ...name === "gh" ? { note: GH_NOTE } : {}
30022
30034
  });
30023
30035
  Object.assign(env.vars, toolVars[name]?.() ?? {});
30036
+ if (name === "gh") warnKeyringLogin();
30024
30037
  } catch (e) {
30025
30038
  warn(name + " could not be set up and is skipped; the other tools are not affected. Reason: " + errMsg(e));
30026
30039
  env.summary.push(entry.name + ": FAILED");
@@ -32050,6 +32063,7 @@ var connectOnce = (host, port, timeoutMs) => {
32050
32063
  socket.once("connect", () => {
32051
32064
  clearTimeout(timer);
32052
32065
  socket.setNoDelay(true);
32066
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
32053
32067
  resolve2(socket);
32054
32068
  });
32055
32069
  socket.once("error", (e) => {
@@ -32058,6 +32072,7 @@ var connectOnce = (host, port, timeoutMs) => {
32058
32072
  });
32059
32073
  });
32060
32074
  };
32075
+ var KEEPALIVE_IDLE_MS = 1e4;
32061
32076
  async function connect(endpoint, timeoutMs = 3e3) {
32062
32077
  const errors = [];
32063
32078
  for (const address of endpoint.addresses) {