@ycodium-ai/plugin-browser-workbench 0.2.2700 → 0.2.2729

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.
package/dist/index.js CHANGED
@@ -87,10 +87,376 @@ import * as NodeFSP5 from "node:fs/promises";
87
87
 
88
88
  // src/attach.ts
89
89
  import * as NodeNet from "node:net";
90
+
91
+ // src/paths.ts
92
+ import * as NodeCrypto from "node:crypto";
93
+ import * as NodeFSP from "node:fs/promises";
94
+ import * as NodePath from "node:path";
95
+
96
+ // src/platform.ts
97
+ import * as NodeProcess from "node:process";
98
+ var CLI_PLATFORMS = [
99
+ "linux-x64",
100
+ "linux-arm64",
101
+ "darwin-x64",
102
+ "darwin-arm64",
103
+ "win32-x64"
104
+ ];
105
+ var CLI_DOWNLOAD_BASE_URL = "https://update.code.visualstudio.com";
106
+ var cliRelativePath = (platform3 = NodeProcess.platform) => platform3 === "win32" ? "bin/code.exe" : "bin/code";
107
+ var cliProgramSelector = (platform3 = NodeProcess.platform) => `data:${cliRelativePath(platform3)}`;
108
+ var detectCliPlatform = (platform3 = NodeProcess.platform, arch3 = NodeProcess.arch) => {
109
+ if (platform3 === "linux" && arch3 === "x64") return "linux-x64";
110
+ if (platform3 === "linux" && arch3 === "arm64") return "linux-arm64";
111
+ if (platform3 === "darwin" && arch3 === "x64") return "darwin-x64";
112
+ if (platform3 === "darwin" && arch3 === "arm64") return "darwin-arm64";
113
+ if (platform3 === "win32" && arch3 === "x64") return "win32-x64";
114
+ return null;
115
+ };
116
+ var cliDownloadUrl = (cliPlatform, version) => `${CLI_DOWNLOAD_BASE_URL}/${version}/cli-${cliPlatform}/stable`;
117
+ var cliArchiveKind = (cliPlatform) => cliPlatform.startsWith("linux") ? "tar.gz" : "zip";
118
+
119
+ // src/paths.ts
120
+ var workbenchPaths = (dataDir) => ({
121
+ dataDir,
122
+ binDir: NodePath.join(dataDir, "bin"),
123
+ cliPath: NodePath.join(dataDir, cliRelativePath()),
124
+ cliVerifiedPath: NodePath.join(dataDir, "bin", "cli-verified.json"),
125
+ cliDataDir: NodePath.join(dataDir, "cli-data"),
126
+ serverDataDir: NodePath.join(dataDir, "server-data"),
127
+ tokenPath: NodePath.join(dataDir, "serve-web-token"),
128
+ instancePath: NodePath.join(dataDir, "instance.json"),
129
+ licensePath: NodePath.join(dataDir, "license-accepted.json"),
130
+ configPath: NodePath.join(dataDir, "config.json")
131
+ });
132
+ var writeAtomic = async (filePath, contents, mode) => {
133
+ await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true });
134
+ const tmp = `${filePath}.${NodeCrypto.randomBytes(8).toString("hex")}.tmp`;
135
+ const payload = typeof contents === "string" ? contents : Buffer.from(contents);
136
+ try {
137
+ await NodeFSP.writeFile(tmp, payload, mode === void 0 ? {} : { mode });
138
+ await NodeFSP.rename(tmp, filePath);
139
+ if (mode !== void 0) await NodeFSP.chmod(filePath, mode);
140
+ } catch (error) {
141
+ await NodeFSP.unlink(tmp).catch(() => void 0);
142
+ throw error;
143
+ }
144
+ };
145
+ var fileExists = async (filePath) => {
146
+ try {
147
+ await NodeFSP.access(filePath);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ };
153
+ var readJsonFile = async (filePath) => {
154
+ try {
155
+ const text = await NodeFSP.readFile(filePath, "utf8");
156
+ return JSON.parse(text);
157
+ } catch {
158
+ return null;
159
+ }
160
+ };
161
+ var writeJsonFile = async (filePath, value, mode) => {
162
+ await writeAtomic(filePath, `${JSON.stringify(value)}
163
+ `, mode);
164
+ };
165
+ var removeFile = async (filePath) => {
166
+ await NodeFSP.unlink(filePath).catch(() => void 0);
167
+ };
168
+ var readInstance = (paths) => readJsonFile(paths.instancePath);
169
+ var writeInstance = (paths, record) => writeJsonFile(paths.instancePath, record);
170
+ var readConfigFile = (paths) => readJsonFile(paths.configPath);
171
+ var writeConfigFile = (paths, value) => writeJsonFile(paths.configPath, value);
172
+ var writeLicenseAccepted = async (paths) => {
173
+ await writeJsonFile(paths.licensePath, { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() });
174
+ };
175
+ var TOKEN_BYTES = 32;
176
+ var readServeWebToken = async (paths) => {
177
+ try {
178
+ const text = (await NodeFSP.readFile(paths.tokenPath, "utf8")).trim();
179
+ return text.length > 0 ? text : null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ };
184
+ var ensureServeWebToken = async (paths) => {
185
+ const existing = await readServeWebToken(paths);
186
+ if (existing !== null) return existing;
187
+ const token = NodeCrypto.randomBytes(TOKEN_BYTES).toString("base64url");
188
+ await writeAtomic(paths.tokenPath, `${token}
189
+ `, 384);
190
+ return token;
191
+ };
192
+ var ensureDataDirs = async (paths) => {
193
+ await NodeFSP.mkdir(paths.binDir, { recursive: true });
194
+ await NodeFSP.mkdir(paths.cliDataDir, { recursive: true });
195
+ await NodeFSP.mkdir(paths.serverDataDir, { recursive: true });
196
+ };
197
+ var directorySize = async (dir) => {
198
+ let total = 0;
199
+ const walk = async (current) => {
200
+ let entries;
201
+ try {
202
+ entries = await NodeFSP.readdir(current, { withFileTypes: true });
203
+ } catch {
204
+ return;
205
+ }
206
+ for (const entry of entries) {
207
+ const child = NodePath.join(current, entry.name);
208
+ if (entry.isDirectory()) {
209
+ await walk(child);
210
+ continue;
211
+ }
212
+ try {
213
+ total += (await NodeFSP.stat(child)).size;
214
+ } catch {
215
+ }
216
+ }
217
+ };
218
+ await walk(dir);
219
+ return total;
220
+ };
221
+
222
+ // src/types.ts
223
+ var WORKBENCH_PROGRESS_STAGES = [
224
+ "license",
225
+ "check-cli",
226
+ "download-cli",
227
+ "start-server",
228
+ "download-server",
229
+ "register-port"
230
+ ];
231
+ var WORKBENCH_PROGRESS_STATUSES = [
232
+ "started",
233
+ "progress",
234
+ "output",
235
+ "succeeded",
236
+ "skipped",
237
+ "failed"
238
+ ];
239
+ var WORKBENCH_FAILURE_KINDS = [
240
+ "license-required",
241
+ "download-failed",
242
+ "disk-full",
243
+ "launch-failed",
244
+ "port-in-use",
245
+ "server-download-failed",
246
+ "grant-expired",
247
+ "host-key-mismatch",
248
+ "channel-failed",
249
+ "unknown"
250
+ ];
251
+ var WorkbenchFailure = class extends Error {
252
+ code;
253
+ constructor(code, message) {
254
+ super(message);
255
+ this.name = "WorkbenchFailure";
256
+ this.code = code;
257
+ }
258
+ };
259
+
260
+ // src/util.ts
261
+ var messageOf = (error) => error instanceof Error ? error.message : String(error);
262
+ var isDiskFull = (error) => {
263
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
264
+ return error.code === "ENOSPC";
265
+ };
266
+ var parsePort = (value) => {
267
+ const asNumber = (input) => Number.isInteger(input) && input >= 1 && input <= 65535 ? input : null;
268
+ if (typeof value === "number") return asNumber(value);
269
+ if (typeof value === "string" && value.trim() !== "") {
270
+ const parsed = Number(value);
271
+ return Number.isFinite(parsed) ? asNumber(parsed) : null;
272
+ }
273
+ return null;
274
+ };
275
+ var redactWorkbenchSecrets = (line) => line.replaceAll(/([?&]tkn=)[^&\s]+/gi, "$1<redacted>");
276
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
277
+ if (signal?.aborted) {
278
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
279
+ return;
280
+ }
281
+ const timer = setTimeout(() => {
282
+ signal?.removeEventListener("abort", onAbort);
283
+ resolve();
284
+ }, ms);
285
+ const onAbort = () => {
286
+ clearTimeout(timer);
287
+ reject(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
288
+ };
289
+ signal?.addEventListener("abort", onAbort, { once: true });
290
+ });
291
+
292
+ // src/serveWeb.ts
293
+ var START_SERVER_TIMEOUT_MS = 3e4;
294
+ var DOWNLOAD_SERVER_TIMEOUT_MS = 10 * 6e4;
295
+ var DOWNLOAD_SERVER_POLL_MS = 1e3;
296
+ var WEB_UI_AVAILABLE_PATTERN = /Web UI available at http:\/\/127\.0\.0\.1:(\d+)/;
297
+ var SERVER_DOWNLOADING_PATTERN = /Downloading server\b/;
298
+ var SERVER_STARTING_PATTERN = /Starting server\b/;
299
+ var parseWebUiPort = (line) => {
300
+ const match = WEB_UI_AVAILABLE_PATTERN.exec(line);
301
+ if (match === null) return null;
302
+ const port = Number(match[1]);
303
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
304
+ };
305
+ var isServerLogLine = (line) => SERVER_DOWNLOADING_PATTERN.test(line) || SERVER_STARTING_PATTERN.test(line);
306
+ var serveWebArgs = (paths) => [
307
+ "serve-web",
308
+ "--host",
309
+ "127.0.0.1",
310
+ "--port",
311
+ "0",
312
+ "--connection-token-file",
313
+ paths.tokenPath,
314
+ "--accept-server-license-terms",
315
+ "--server-data-dir",
316
+ paths.serverDataDir,
317
+ "--cli-data-dir",
318
+ paths.cliDataDir,
319
+ "--disable-telemetry"
320
+ ];
321
+ var splitLines = (text) => {
322
+ const parts = text.split(/\r?\n/);
323
+ const rest = parts.pop() ?? "";
324
+ return { complete: parts, rest };
325
+ };
326
+ var consumeProcessLines = (stream, onLine) => {
327
+ const decoder = new TextDecoder();
328
+ let pending = "";
329
+ const run = async () => {
330
+ for await (const chunk of stream) {
331
+ pending += decoder.decode(chunk, { stream: true });
332
+ const split = splitLines(pending);
333
+ pending = split.rest;
334
+ for (const line of split.complete) onLine(redactWorkbenchSecrets(line));
335
+ }
336
+ pending += decoder.decode();
337
+ if (pending.length > 0) onLine(redactWorkbenchSecrets(pending));
338
+ };
339
+ return run();
340
+ };
341
+ var waitForServeWebPort = (input) => {
342
+ const timeoutMs = input.timeoutMs ?? START_SERVER_TIMEOUT_MS;
343
+ const stderrChunks = [];
344
+ let settled = false;
345
+ let resolvePort = () => void 0;
346
+ let rejectPort = () => void 0;
347
+ const portPromise = new Promise((resolve, reject) => {
348
+ resolvePort = resolve;
349
+ rejectPort = reject;
350
+ });
351
+ const finishError = (error) => {
352
+ if (settled) return;
353
+ settled = true;
354
+ rejectPort(error);
355
+ };
356
+ const finishPort = (port) => {
357
+ if (settled) return;
358
+ settled = true;
359
+ resolvePort(port);
360
+ };
361
+ const timer = setTimeout(() => {
362
+ finishError(
363
+ new WorkbenchFailure(
364
+ "launch-failed",
365
+ "The VS Code CLI started but did not publish a local address in time."
366
+ )
367
+ );
368
+ }, timeoutMs);
369
+ void consumeProcessLines(input.handle.stdout, (line) => {
370
+ input.onStdoutLine(line);
371
+ const port = parseWebUiPort(line);
372
+ if (port !== null) finishPort(port);
373
+ }).catch((error) => {
374
+ finishError(error instanceof Error ? error : new Error(String(error)));
375
+ });
376
+ void consumeProcessLines(input.handle.stderr, (line) => {
377
+ stderrChunks.push(line);
378
+ input.onStderrLine(line);
379
+ }).catch(() => void 0);
380
+ void input.handle.exited.then((code) => {
381
+ if (settled) return;
382
+ const tail = stderrChunks.slice(-20).join("\n");
383
+ finishError(
384
+ new WorkbenchFailure(
385
+ "launch-failed",
386
+ tail.length > 0 ? `The VS Code CLI exited before it published a local address.
387
+ ${tail}` : `The VS Code CLI exited before it published a local address (code ${String(code)}).`
388
+ )
389
+ );
390
+ });
391
+ return portPromise.then(
392
+ (port) => {
393
+ clearTimeout(timer);
394
+ return { port, stderr: stderrChunks.join("\n") };
395
+ },
396
+ (error) => {
397
+ clearTimeout(timer);
398
+ throw error;
399
+ }
400
+ );
401
+ };
402
+ var serverReadyStatus = (status) => {
403
+ if (status === 202) return "downloading";
404
+ if (status === 200 || status === 301 || status === 302 || status === 307 || status === 308) {
405
+ return "ready";
406
+ }
407
+ if (status === 403) return "forbidden";
408
+ return "other";
409
+ };
410
+ var waitForServerDownload = async (input) => {
411
+ const timeoutMs = input.timeoutMs ?? DOWNLOAD_SERVER_TIMEOUT_MS;
412
+ const pollMs = input.pollMs ?? DOWNLOAD_SERVER_POLL_MS;
413
+ const startedAtMs = Date.now();
414
+ const deadline = startedAtMs + timeoutMs;
415
+ const url = `http://127.0.0.1:${String(input.port)}/?tkn=${input.token}`;
416
+ const serveWebDir = `${input.cliDataDir}/serve-web`;
417
+ while (Date.now() < deadline) {
418
+ if (input.signal?.aborted) {
419
+ throw new WorkbenchFailure("server-download-failed", "The server download was cancelled.");
420
+ }
421
+ try {
422
+ const response = await input.http(
423
+ url,
424
+ input.signal === void 0 ? { redirect: "manual" } : { redirect: "manual", signal: input.signal }
425
+ );
426
+ const kind = serverReadyStatus(response.status);
427
+ if (kind === "ready") return;
428
+ if (kind === "forbidden") {
429
+ throw new WorkbenchFailure(
430
+ "server-download-failed",
431
+ "The VS Code server rejected the connection token."
432
+ );
433
+ }
434
+ } catch (error) {
435
+ if (error instanceof WorkbenchFailure) throw error;
436
+ }
437
+ const transferredBytes = await directorySize(serveWebDir);
438
+ input.onTransfer(
439
+ computeTransfer({
440
+ transferredBytes,
441
+ totalBytes: null,
442
+ startedAtMs,
443
+ nowMs: Date.now()
444
+ })
445
+ );
446
+ await sleep(pollMs, input.signal);
447
+ }
448
+ throw new WorkbenchFailure(
449
+ "server-download-failed",
450
+ "The VS Code server did not become ready in time."
451
+ );
452
+ };
453
+
454
+ // src/attach.ts
90
455
  var ATTACH_PROBE_TIMEOUT_MS = 1e3;
91
456
  var probeLoopback = (port, timeoutMs = ATTACH_PROBE_TIMEOUT_MS) => new Promise((resolve) => {
92
457
  const socket = NodeNet.connect({ host: "127.0.0.1", port });
93
458
  const finish = (ok) => {
459
+ clearTimeout(timer);
94
460
  socket.removeAllListeners();
95
461
  socket.destroy();
96
462
  resolve(ok);
@@ -105,6 +471,31 @@ var probeLoopback = (port, timeoutMs = ATTACH_PROBE_TIMEOUT_MS) => new Promise((
105
471
  finish(false);
106
472
  });
107
473
  });
474
+ var OWNERSHIP_PROBE_TIMEOUT_MS = 1e3;
475
+ var probeServeWebOwnership = async (input) => {
476
+ const http = input.http ?? fetch;
477
+ const timeoutMs = input.timeoutMs ?? OWNERSHIP_PROBE_TIMEOUT_MS;
478
+ const ask = async (tkn) => {
479
+ try {
480
+ const response = await http(`http://127.0.0.1:${String(input.port)}/?tkn=${tkn}`, {
481
+ redirect: "manual",
482
+ signal: AbortSignal.timeout(timeoutMs)
483
+ });
484
+ return serverReadyStatus(response.status);
485
+ } catch {
486
+ return "down";
487
+ }
488
+ };
489
+ const [withToken, withWrong] = await Promise.all([ask(input.token), ask("not-the-token")]);
490
+ if (withWrong === "forbidden" && (withToken === "ready" || withToken === "downloading")) {
491
+ return "ours";
492
+ }
493
+ if (withWrong === "downloading" && (withToken === "downloading" || withToken === "ready")) {
494
+ return "pending";
495
+ }
496
+ if (withWrong === "ready") return "foreign";
497
+ return "unknown";
498
+ };
108
499
 
109
500
  // src/catalog.ts
110
501
  var catalog = {
@@ -125,12 +516,16 @@ var catalog = {
125
516
  },
126
517
  running: {
127
518
  stopFirst: "Stop the workbench before updating the CLI."
519
+ },
520
+ adopt: {
521
+ notOurs: "The workbench server found from the last run does not accept this machine's token, so Ycodium let go of it. Open again to start a fresh one."
128
522
  }
129
523
  };
130
524
  var failureMessage = (kind) => catalog.failure[kind];
131
525
  var attachNoListenerMessage = (port) => catalog.attach.noListener.replace("{{port}}", String(port));
132
526
 
133
527
  // src/cli.ts
528
+ import * as NodeCrypto2 from "node:crypto";
134
529
  import * as NodeFS from "node:fs";
135
530
  import * as NodeFSP2 from "node:fs/promises";
136
531
  import * as NodeProcess2 from "node:process";
@@ -139,46 +534,6 @@ import * as NodeStreamPromises from "node:stream/promises";
139
534
 
140
535
  // src/archive.ts
141
536
  import * as NodeZlib from "node:zlib";
142
-
143
- // src/types.ts
144
- var WORKBENCH_PROGRESS_STAGES = [
145
- "license",
146
- "check-cli",
147
- "download-cli",
148
- "start-server",
149
- "download-server",
150
- "register-port"
151
- ];
152
- var WORKBENCH_PROGRESS_STATUSES = [
153
- "started",
154
- "progress",
155
- "output",
156
- "succeeded",
157
- "skipped",
158
- "failed"
159
- ];
160
- var WORKBENCH_FAILURE_KINDS = [
161
- "license-required",
162
- "download-failed",
163
- "disk-full",
164
- "launch-failed",
165
- "port-in-use",
166
- "server-download-failed",
167
- "grant-expired",
168
- "host-key-mismatch",
169
- "channel-failed",
170
- "unknown"
171
- ];
172
- var WorkbenchFailure = class extends Error {
173
- code;
174
- constructor(code, message) {
175
- super(message);
176
- this.name = "WorkbenchFailure";
177
- this.code = code;
178
- }
179
- };
180
-
181
- // src/archive.ts
182
537
  var TAR_BLOCK = 512;
183
538
  var ZIP_LOCAL = 67324752;
184
539
  var ZIP_CENTRAL = 33639248;
@@ -299,166 +654,15 @@ var extractCliBinary = async (archive, kind) => {
299
654
  return pickCliBinary(await extractZip(archive));
300
655
  };
301
656
 
302
- // src/platform.ts
303
- import * as NodeProcess from "node:process";
304
- var CLI_DOWNLOAD_BASE_URL = "https://update.code.visualstudio.com/latest";
305
- var cliRelativePath = (platform3 = NodeProcess.platform) => platform3 === "win32" ? "bin/code.exe" : "bin/code";
306
- var cliProgramSelector = (platform3 = NodeProcess.platform) => `data:${cliRelativePath(platform3)}`;
307
- var detectCliPlatform = (platform3 = NodeProcess.platform, arch3 = NodeProcess.arch) => {
308
- if (platform3 === "linux" && arch3 === "x64") return "linux-x64";
309
- if (platform3 === "linux" && arch3 === "arm64") return "linux-arm64";
310
- if (platform3 === "darwin" && arch3 === "x64") return "darwin-x64";
311
- if (platform3 === "darwin" && arch3 === "arm64") return "darwin-arm64";
312
- if (platform3 === "win32" && arch3 === "x64") return "win32-x64";
313
- return null;
314
- };
315
- var cliDownloadUrl = (cliPlatform) => `${CLI_DOWNLOAD_BASE_URL}/cli-${cliPlatform}/stable`;
316
- var cliArchiveKind = (cliPlatform) => cliPlatform.startsWith("win32") ? "zip" : "tar.gz";
317
-
318
- // src/paths.ts
319
- import * as NodeCrypto from "node:crypto";
320
- import * as NodeFSP from "node:fs/promises";
321
- import * as NodePath from "node:path";
322
- var workbenchPaths = (dataDir) => ({
323
- dataDir,
324
- binDir: NodePath.join(dataDir, "bin"),
325
- cliPath: NodePath.join(dataDir, cliRelativePath()),
326
- cliDataDir: NodePath.join(dataDir, "cli-data"),
327
- serverDataDir: NodePath.join(dataDir, "server-data"),
328
- tokenPath: NodePath.join(dataDir, "serve-web-token"),
329
- instancePath: NodePath.join(dataDir, "instance.json"),
330
- licensePath: NodePath.join(dataDir, "license-accepted.json"),
331
- configPath: NodePath.join(dataDir, "config.json")
332
- });
333
- var writeAtomic = async (filePath, contents, mode) => {
334
- await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true });
335
- const tmp = `${filePath}.${NodeCrypto.randomBytes(8).toString("hex")}.tmp`;
336
- const payload = typeof contents === "string" ? contents : Buffer.from(contents);
337
- try {
338
- await NodeFSP.writeFile(tmp, payload, mode === void 0 ? {} : { mode });
339
- await NodeFSP.rename(tmp, filePath);
340
- if (mode !== void 0) await NodeFSP.chmod(filePath, mode);
341
- } catch (error) {
342
- await NodeFSP.unlink(tmp).catch(() => void 0);
343
- throw error;
344
- }
345
- };
346
- var fileExists = async (filePath) => {
347
- try {
348
- await NodeFSP.access(filePath);
349
- return true;
350
- } catch {
351
- return false;
352
- }
353
- };
354
- var readJsonFile = async (filePath) => {
355
- try {
356
- const text = await NodeFSP.readFile(filePath, "utf8");
357
- return JSON.parse(text);
358
- } catch {
359
- return null;
360
- }
361
- };
362
- var writeJsonFile = async (filePath, value, mode) => {
363
- await writeAtomic(filePath, `${JSON.stringify(value)}
364
- `, mode);
365
- };
366
- var removeFile = async (filePath) => {
367
- await NodeFSP.unlink(filePath).catch(() => void 0);
368
- };
369
- var readInstance = (paths) => readJsonFile(paths.instancePath);
370
- var writeInstance = (paths, record) => writeJsonFile(paths.instancePath, record);
371
- var readConfigFile = (paths) => readJsonFile(paths.configPath);
372
- var writeConfigFile = (paths, value) => writeJsonFile(paths.configPath, value);
373
- var writeLicenseAccepted = async (paths) => {
374
- await writeJsonFile(paths.licensePath, { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() });
375
- };
376
- var TOKEN_BYTES = 32;
377
- var readServeWebToken = async (paths) => {
378
- try {
379
- const text = (await NodeFSP.readFile(paths.tokenPath, "utf8")).trim();
380
- return text.length > 0 ? text : null;
381
- } catch {
382
- return null;
383
- }
384
- };
385
- var ensureServeWebToken = async (paths) => {
386
- const existing = await readServeWebToken(paths);
387
- if (existing !== null) return existing;
388
- const token = NodeCrypto.randomBytes(TOKEN_BYTES).toString("base64url");
389
- await writeAtomic(paths.tokenPath, `${token}
390
- `, 384);
391
- return token;
392
- };
393
- var ensureDataDirs = async (paths) => {
394
- await NodeFSP.mkdir(paths.binDir, { recursive: true });
395
- await NodeFSP.mkdir(paths.cliDataDir, { recursive: true });
396
- await NodeFSP.mkdir(paths.serverDataDir, { recursive: true });
397
- };
398
- var directorySize = async (dir) => {
399
- let total = 0;
400
- const walk = async (current) => {
401
- let entries;
402
- try {
403
- entries = await NodeFSP.readdir(current, { withFileTypes: true });
404
- } catch {
405
- return;
406
- }
407
- for (const entry of entries) {
408
- const child = NodePath.join(current, entry.name);
409
- if (entry.isDirectory()) {
410
- await walk(child);
411
- continue;
412
- }
413
- try {
414
- total += (await NodeFSP.stat(child)).size;
415
- } catch {
416
- }
417
- }
418
- };
419
- await walk(dir);
420
- return total;
421
- };
422
-
423
- // src/util.ts
424
- var messageOf = (error) => error instanceof Error ? error.message : String(error);
425
- var isDiskFull = (error) => {
426
- if (typeof error !== "object" || error === null || !("code" in error)) return false;
427
- return error.code === "ENOSPC";
428
- };
429
- var parsePort = (value) => {
430
- const asNumber = (input) => Number.isInteger(input) && input >= 1 && input <= 65535 ? input : null;
431
- if (typeof value === "number") return asNumber(value);
432
- if (typeof value === "string" && value.trim() !== "") {
433
- const parsed = Number(value);
434
- return Number.isFinite(parsed) ? asNumber(parsed) : null;
435
- }
436
- return null;
437
- };
438
- var redactWorkbenchSecrets = (line) => line.replaceAll(/([?&]tkn=)[^&\s]+/gi, "$1<redacted>");
439
- var isProcessAlive = (pid) => {
440
- try {
441
- process.kill(pid, 0);
442
- return true;
443
- } catch {
444
- return false;
445
- }
657
+ // src/cliRelease.ts
658
+ var PINNED_CLI_VERSION = "1.139.1";
659
+ var PINNED_CLI_SHA256 = {
660
+ "linux-x64": "e079ddb70377cca515ab0cf5ca1ee4095a14e1f510af4b10a75b01c6018f9f02",
661
+ "linux-arm64": "5bc80c3622a628a8aee4be15967bdc7a039235d867c3d1bedf1b373d2d731dc7",
662
+ "darwin-x64": "7b8f895cf37909814aa3f1b71aa01fa58a468a71d1267098c1346bae3c8ac25e",
663
+ "darwin-arm64": "43ec2f4506beafde0783134de0b4b5fb94fd0e017d47a7b4ee6559b8bc363dea",
664
+ "win32-x64": "046b75c3e8ddad7692e05e20c0f8cab87ecfa66ec2fc92d55cc4e32044b522bf"
446
665
  };
447
- var sleep = (ms, signal) => new Promise((resolve, reject) => {
448
- if (signal?.aborted) {
449
- reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
450
- return;
451
- }
452
- const timer = setTimeout(() => {
453
- signal?.removeEventListener("abort", onAbort);
454
- resolve();
455
- }, ms);
456
- const onAbort = () => {
457
- clearTimeout(timer);
458
- reject(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
459
- };
460
- signal?.addEventListener("abort", onAbort, { once: true });
461
- });
462
666
 
463
667
  // src/cli.ts
464
668
  var CLI_DOWNLOAD_SUFFIX = ".download";
@@ -468,7 +672,6 @@ var parseCliVersion = (stdout) => {
468
672
  const version = match?.[1];
469
673
  return version === void 0 || version.length === 0 ? null : version;
470
674
  };
471
- var cliIsInstalled = (paths) => fileExists(paths.cliPath);
472
675
  var readInstalledCliVersion = async (processApi) => {
473
676
  const result = await processApi.run({
474
677
  program: cliProgramSelector(),
@@ -480,24 +683,65 @@ var readInstalledCliVersion = async (processApi) => {
480
683
  }
481
684
  return parseCliVersion(result.stdout) ?? parseCliVersion(result.stderr) ?? "unknown";
482
685
  };
686
+ var sha256Hex = (bytes) => NodeCrypto2.createHash("sha256").update(bytes).digest("hex");
687
+ var fileSha256 = async (filePath) => {
688
+ const hash = NodeCrypto2.createHash("sha256");
689
+ await NodeStreamPromises.pipeline(NodeFS.createReadStream(filePath), hash);
690
+ return hash.digest("hex");
691
+ };
692
+ var isVerifiedCliInstall = (value) => {
693
+ if (typeof value !== "object" || value === null) return false;
694
+ const record = value;
695
+ return typeof record["version"] === "string" && typeof record["archiveSha256"] === "string" && typeof record["binarySha256"] === "string" && CLI_PLATFORMS.some((platform3) => platform3 === record["platform"]);
696
+ };
697
+ var readVerifiedCli = async (paths, options) => {
698
+ const platform3 = options.platform === void 0 ? detectCliPlatform() : options.platform;
699
+ const record = await readJsonFile(paths.cliVerifiedPath);
700
+ if (!isVerifiedCliInstall(record)) return null;
701
+ if (record.version !== PINNED_CLI_VERSION || record.platform !== platform3) return null;
702
+ if (!await fileExists(paths.cliPath)) return null;
703
+ if (!options.checkBinary) return record;
704
+ const actual = await fileSha256(paths.cliPath).catch(() => null);
705
+ return actual === record.binarySha256 ? record : null;
706
+ };
707
+ var writeVerifiedCli = (paths, install) => writeJsonFile(paths.cliVerifiedPath, install);
708
+ var clearVerifiedCli = (paths) => removeFile(paths.cliVerifiedPath);
483
709
  var contentLengthOf = (response) => {
484
710
  const raw = response.headers.get("content-length");
485
711
  if (raw === null || raw.trim() === "") return null;
486
712
  const parsed = Number(raw);
487
713
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
488
714
  };
715
+ var hostOf = (url) => {
716
+ try {
717
+ return new URL(url).host;
718
+ } catch {
719
+ return url;
720
+ }
721
+ };
722
+ var describeNetworkError = (error) => {
723
+ const message = messageOf(error);
724
+ if (typeof error !== "object" || error === null || !("cause" in error)) return message;
725
+ const cause = error.cause;
726
+ if (typeof cause !== "object" || cause === null) return message;
727
+ const code = cause.code;
728
+ const causeMessage = messageOf(cause);
729
+ return `${message}: ${typeof code === "string" && !causeMessage.includes(code) ? `${code} ` : ""}${causeMessage}`;
730
+ };
489
731
  var streamResponseToFile = async (input) => {
490
732
  if (input.response.body === null) {
491
733
  throw new WorkbenchFailure("download-failed", "The CLI download had no body.");
492
734
  }
493
735
  const totalBytes = contentLengthOf(input.response);
494
736
  const startedAtMs = Date.now();
737
+ const hash = NodeCrypto2.createHash("sha256");
495
738
  let transferredBytes = 0;
496
739
  const readable = NodeStream.Readable.fromWeb(
497
740
  input.response.body
498
741
  );
499
742
  readable.on("data", (chunk) => {
500
743
  transferredBytes += chunk.length;
744
+ hash.update(chunk);
501
745
  input.onTransfer(
502
746
  computeTransfer({
503
747
  transferredBytes,
@@ -514,8 +758,13 @@ var streamResponseToFile = async (input) => {
514
758
  if (isDiskFull(error)) {
515
759
  throw new WorkbenchFailure("disk-full", messageOf(error));
516
760
  }
517
- throw error;
761
+ const of = totalBytes === null ? "" : ` of ${String(totalBytes)}`;
762
+ throw new WorkbenchFailure(
763
+ "download-failed",
764
+ `The VS Code CLI download stopped after ${String(transferredBytes)}${of} bytes (${describeNetworkError(error)}).`
765
+ );
518
766
  }
767
+ return hash.digest("hex");
519
768
  };
520
769
  var downloadCliBinary = async (input) => {
521
770
  const platform3 = input.platform === void 0 ? detectCliPlatform() : input.platform;
@@ -525,31 +774,44 @@ var downloadCliBinary = async (input) => {
525
774
  `This machine's platform (${NodeProcess2.platform}/${NodeProcess2.arch}) has no VS Code CLI build.`
526
775
  );
527
776
  }
528
- const url = input.url ?? cliDownloadUrl(platform3);
777
+ const version = PINNED_CLI_VERSION;
778
+ const expectedSha256 = input.expectedSha256 ?? PINNED_CLI_SHA256[platform3];
779
+ const url = input.url ?? cliDownloadUrl(platform3, version);
529
780
  let response;
530
781
  try {
531
782
  response = await input.http(url, input.signal === void 0 ? {} : { signal: input.signal });
532
783
  } catch (error) {
533
784
  if (isDiskFull(error)) throw new WorkbenchFailure("disk-full", messageOf(error));
534
- throw new WorkbenchFailure("download-failed", messageOf(error));
785
+ throw new WorkbenchFailure(
786
+ "download-failed",
787
+ `Could not reach ${hostOf(url)} to download VS Code CLI ${version} (${describeNetworkError(error)}). Check this machine's internet access and proxy settings.`
788
+ );
535
789
  }
536
790
  if (!response.ok) {
791
+ const host = hostOf(response.url === "" ? url : response.url);
537
792
  throw new WorkbenchFailure(
538
793
  "download-failed",
539
- `The CLI download returned HTTP ${String(response.status)}.`
794
+ `${host} answered HTTP ${String(response.status)} for VS Code CLI ${version}. If this machine reaches the internet through a proxy or firewall, check that it allows ${host}.`
540
795
  );
541
796
  }
542
797
  await NodeFSP2.mkdir(input.paths.binDir, { recursive: true });
543
798
  const archivePath = `${input.destPath}${CLI_DOWNLOAD_SUFFIX}`;
544
799
  try {
545
- await streamResponseToFile({
800
+ const archiveSha256 = await streamResponseToFile({
546
801
  response,
547
802
  filePath: archivePath,
548
803
  onTransfer: input.onTransfer
549
804
  });
805
+ if (archiveSha256 !== expectedSha256) {
806
+ throw new WorkbenchFailure(
807
+ "download-failed",
808
+ `The downloaded VS Code CLI ${version} (${platform3}) did not match its published SHA-256 (expected ${expectedSha256.slice(0, 12)}\u2026, got ${archiveSha256.slice(0, 12)}\u2026) and was discarded. If this machine uses a proxy, make sure it does not alter downloads.`
809
+ );
810
+ }
550
811
  const archive = await NodeFSP2.readFile(archivePath);
551
812
  const binary = await extractCliBinary(archive, cliArchiveKind(platform3));
552
813
  await writeAtomic(input.destPath, binary, 493);
814
+ return { version, platform: platform3, archiveSha256, binarySha256: sha256Hex(binary) };
553
815
  } catch (error) {
554
816
  if (error instanceof WorkbenchFailure) throw error;
555
817
  if (isDiskFull(error)) throw new WorkbenchFailure("disk-full", messageOf(error));
@@ -559,180 +821,31 @@ var downloadCliBinary = async (input) => {
559
821
  }
560
822
  };
561
823
 
562
- // src/serveWeb.ts
563
- var START_SERVER_TIMEOUT_MS = 3e4;
564
- var DOWNLOAD_SERVER_TIMEOUT_MS = 10 * 6e4;
565
- var DOWNLOAD_SERVER_POLL_MS = 1e3;
566
- var WEB_UI_AVAILABLE_PATTERN = /Web UI available at http:\/\/127\.0\.0\.1:(\d+)/;
567
- var SERVER_DOWNLOADING_PATTERN = /Downloading server\b/;
568
- var SERVER_STARTING_PATTERN = /Starting server\b/;
569
- var parseWebUiPort = (line) => {
570
- const match = WEB_UI_AVAILABLE_PATTERN.exec(line);
571
- if (match === null) return null;
572
- const port = Number(match[1]);
573
- return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
574
- };
575
- var isServerLogLine = (line) => SERVER_DOWNLOADING_PATTERN.test(line) || SERVER_STARTING_PATTERN.test(line);
576
- var serveWebArgs = (paths) => [
577
- "serve-web",
578
- "--host",
579
- "127.0.0.1",
580
- "--port",
581
- "0",
582
- "--connection-token-file",
583
- paths.tokenPath,
584
- "--accept-server-license-terms",
585
- "--server-data-dir",
586
- paths.serverDataDir,
587
- "--cli-data-dir",
588
- paths.cliDataDir,
589
- "--disable-telemetry"
590
- ];
591
- var splitLines = (text) => {
592
- const parts = text.split(/\r?\n/);
593
- const rest = parts.pop() ?? "";
594
- return { complete: parts, rest };
595
- };
596
- var consumeProcessLines = (stream, onLine) => {
597
- const decoder = new TextDecoder();
598
- let pending = "";
599
- const run = async () => {
600
- for await (const chunk of stream) {
601
- pending += decoder.decode(chunk, { stream: true });
602
- const split = splitLines(pending);
603
- pending = split.rest;
604
- for (const line of split.complete) onLine(redactWorkbenchSecrets(line));
605
- }
606
- pending += decoder.decode();
607
- if (pending.length > 0) onLine(redactWorkbenchSecrets(pending));
608
- };
609
- return run();
610
- };
611
- var waitForServeWebPort = (input) => {
612
- const timeoutMs = input.timeoutMs ?? START_SERVER_TIMEOUT_MS;
613
- const stderrChunks = [];
614
- let settled = false;
615
- let resolvePort = () => void 0;
616
- let rejectPort = () => void 0;
617
- const portPromise = new Promise((resolve, reject) => {
618
- resolvePort = resolve;
619
- rejectPort = reject;
620
- });
621
- const finishError = (error) => {
622
- if (settled) return;
623
- settled = true;
624
- rejectPort(error);
625
- };
626
- const finishPort = (port) => {
627
- if (settled) return;
628
- settled = true;
629
- resolvePort(port);
630
- };
631
- const timer = setTimeout(() => {
632
- finishError(
633
- new WorkbenchFailure(
634
- "launch-failed",
635
- "The VS Code CLI started but did not publish a local address in time."
636
- )
637
- );
638
- }, timeoutMs);
639
- void consumeProcessLines(input.handle.stdout, (line) => {
640
- input.onStdoutLine(line);
641
- const port = parseWebUiPort(line);
642
- if (port !== null) finishPort(port);
643
- }).catch((error) => {
644
- finishError(error instanceof Error ? error : new Error(String(error)));
645
- });
646
- void consumeProcessLines(input.handle.stderr, (line) => {
647
- stderrChunks.push(line);
648
- input.onStderrLine(line);
649
- }).catch(() => void 0);
650
- void input.handle.exited.then((code) => {
651
- if (settled) return;
652
- const tail = stderrChunks.slice(-20).join("\n");
653
- finishError(
654
- new WorkbenchFailure(
655
- "launch-failed",
656
- tail.length > 0 ? `The VS Code CLI exited before it published a local address.
657
- ${tail}` : `The VS Code CLI exited before it published a local address (code ${String(code)}).`
658
- )
659
- );
660
- });
661
- return portPromise.then(
662
- (port) => {
663
- clearTimeout(timer);
664
- return { port, stderr: stderrChunks.join("\n") };
665
- },
666
- (error) => {
667
- clearTimeout(timer);
668
- throw error;
669
- }
670
- );
671
- };
672
- var serverReadyStatus = (status) => {
673
- if (status === 202) return "downloading";
674
- if (status === 200 || status === 301 || status === 302 || status === 307 || status === 308) {
675
- return "ready";
676
- }
677
- if (status === 403) return "forbidden";
678
- return "other";
679
- };
680
- var waitForServerDownload = async (input) => {
681
- const timeoutMs = input.timeoutMs ?? DOWNLOAD_SERVER_TIMEOUT_MS;
682
- const pollMs = input.pollMs ?? DOWNLOAD_SERVER_POLL_MS;
683
- const startedAtMs = Date.now();
684
- const deadline = startedAtMs + timeoutMs;
685
- const url = `http://127.0.0.1:${String(input.port)}/?tkn=${input.token}`;
686
- const serveWebDir = `${input.cliDataDir}/serve-web`;
687
- while (Date.now() < deadline) {
688
- if (input.signal?.aborted) {
689
- throw new WorkbenchFailure("server-download-failed", "The server download was cancelled.");
690
- }
691
- try {
692
- const response = await input.http(
693
- url,
694
- input.signal === void 0 ? { redirect: "manual" } : { redirect: "manual", signal: input.signal }
695
- );
696
- const kind = serverReadyStatus(response.status);
697
- if (kind === "ready") return;
698
- if (kind === "forbidden") {
699
- throw new WorkbenchFailure(
700
- "server-download-failed",
701
- "The VS Code server rejected the connection token."
702
- );
703
- }
704
- } catch (error) {
705
- if (error instanceof WorkbenchFailure) throw error;
706
- }
707
- const transferredBytes = await directorySize(serveWebDir);
708
- input.onTransfer(
709
- computeTransfer({
710
- transferredBytes,
711
- totalBytes: null,
712
- startedAtMs,
713
- nowMs: Date.now()
714
- })
715
- );
716
- await sleep(pollMs, input.signal);
717
- }
718
- throw new WorkbenchFailure(
719
- "server-download-failed",
720
- "The VS Code server did not become ready in time."
721
- );
722
- };
723
-
724
824
  // src/instance.ts
725
- var instanceIsLive = async (record) => isProcessAlive(record.pid) && await probeLoopback(record.port);
726
- var reapOrphanInstance = async (paths) => {
825
+ var classifyRecord = async (record, paths) => {
826
+ const token = await readServeWebToken(paths);
827
+ if (token === null) return "stale";
828
+ const ownership = await probeServeWebOwnership({ port: record.port, token });
829
+ if (ownership !== "unknown") return ownership;
830
+ return await probeLoopback(record.port) ? "unresponsive" : "stale";
831
+ };
832
+ var reconcileInstanceRecord = async (paths, log) => {
727
833
  const record = await readInstance(paths);
728
834
  if (record === null) return;
729
- if (isProcessAlive(record.pid)) {
730
- try {
731
- process.kill(record.pid, "SIGTERM");
732
- } catch {
733
- }
835
+ const state = await classifyRecord(record, paths);
836
+ if (state === "foreign" || state === "stale") {
837
+ log?.info("dropping a recorded workbench instance that is not ours or not listening", {
838
+ port: record.port,
839
+ state
840
+ });
841
+ await removeFile(paths.instancePath);
842
+ return;
843
+ }
844
+ if (state === "unresponsive") {
845
+ log?.info("recorded workbench port is listening but does not answer yet; keeping its record", {
846
+ port: record.port
847
+ });
734
848
  }
735
- await removeFile(paths.instancePath);
736
849
  };
737
850
  var startServeWeb = async (input) => {
738
851
  await ensureDataDirs(input.paths);
@@ -759,26 +872,23 @@ var startServeWeb = async (input) => {
759
872
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
760
873
  };
761
874
  await writeInstance(input.paths, record);
762
- return { handle, record };
875
+ return { owned: true, handle, record };
763
876
  } catch (error) {
764
877
  await handle.kill("SIGTERM").catch(() => void 0);
765
878
  throw error;
766
879
  }
767
880
  };
768
881
  var STOP_GRACE_MS = 5e3;
769
- var stopManagedProcess = async (managed, paths) => {
770
- if (managed !== null) {
771
- await managed.handle.kill("SIGTERM").catch(() => void 0);
772
- const timedOut = await Promise.race([
773
- managed.handle.exited.then(() => false).catch(() => false),
774
- sleep(STOP_GRACE_MS).then(() => true)
775
- ]);
776
- if (timedOut) {
777
- await managed.handle.kill("SIGKILL").catch(() => void 0);
778
- await managed.handle.exited.catch(() => void 0);
779
- }
882
+ var stopOwnedHandle = async (handle) => {
883
+ await handle.kill("SIGTERM").catch(() => void 0);
884
+ const timedOut = await Promise.race([
885
+ handle.exited.then(() => false).catch(() => false),
886
+ sleep(STOP_GRACE_MS).then(() => true)
887
+ ]);
888
+ if (timedOut) {
889
+ await handle.kill("SIGKILL").catch(() => void 0);
890
+ await handle.exited.catch(() => void 0);
780
891
  }
781
- await removeFile(paths.instancePath);
782
892
  };
783
893
 
784
894
  // src/productPatch.ts
@@ -900,6 +1010,8 @@ var windowTitleTemplate = (machineLabel) => {
900
1010
  // src/runtime.ts
901
1011
  var MANAGED_LABEL = "VS Code";
902
1012
  var EXTERNAL_LABEL = "code-server";
1013
+ var UNRESPONSIVE_GRACE_MS = START_SERVER_TIMEOUT_MS;
1014
+ var UNRESPONSIVE_POLL_MS = 1e3;
903
1015
  var WorkbenchRuntime = class {
904
1016
  bus = new ProgressBus();
905
1017
  ctx;
@@ -910,13 +1022,15 @@ var WorkbenchRuntime = class {
910
1022
  externalPort = null;
911
1023
  externalListenPort = null;
912
1024
  outputStage = "start-server";
913
- constructor(ctx) {
1025
+ unresponsiveGraceMs;
1026
+ constructor(ctx, options = {}) {
914
1027
  this.ctx = ctx;
1028
+ this.unresponsiveGraceMs = options.unresponsiveGraceMs ?? UNRESPONSIVE_GRACE_MS;
915
1029
  this.paths = workbenchPaths(ctx.paths.dataDir);
916
1030
  }
917
- /** 激活时收孤儿、按设置接外接端口。不能让 activate 失败。 */
1031
+ /** 激活时收拾实例记录(不对任何进程发信号)、按设置接外接端口。不能让 activate 失败。 */
918
1032
  async start() {
919
- await reapOrphanInstance(this.paths);
1033
+ await reconcileInstanceRecord(this.paths, this.ctx.log);
920
1034
  const desired = await this.desiredExternalPort();
921
1035
  if (desired === null) return;
922
1036
  try {
@@ -930,7 +1044,7 @@ var WorkbenchRuntime = class {
930
1044
  }
931
1045
  async dispose() {
932
1046
  await this.enqueue(async () => {
933
- await this.stopManaged();
1047
+ await this.stopManaged("keep");
934
1048
  await this.clearExternal();
935
1049
  });
936
1050
  }
@@ -941,10 +1055,7 @@ var WorkbenchRuntime = class {
941
1055
  return this.enqueue(() => this.inRun(() => this.runOpen(payload)));
942
1056
  }
943
1057
  stop() {
944
- return this.enqueue(async () => {
945
- await this.stopManaged();
946
- return {};
947
- });
1058
+ return this.enqueue(() => this.stopManaged("stop"));
948
1059
  }
949
1060
  update() {
950
1061
  return this.enqueue(() => this.inRun(() => this.runUpdate()));
@@ -1000,7 +1111,8 @@ var WorkbenchRuntime = class {
1000
1111
  return parsePort(file?.attachPort);
1001
1112
  }
1002
1113
  async readStatus() {
1003
- const installed = await cliIsInstalled(this.paths);
1114
+ const verified = await readVerifiedCli(this.paths, { checkBinary: false });
1115
+ const installed = verified !== null;
1004
1116
  const live = await this.managedIsLive();
1005
1117
  const external = this.externalPort !== null && this.externalListenPort !== null ? { port: this.externalListenPort, portId: this.externalPort.portId } : void 0;
1006
1118
  if (live && this.managed !== null) {
@@ -1022,30 +1134,27 @@ var WorkbenchRuntime = class {
1022
1134
  external
1023
1135
  };
1024
1136
  }
1025
- let cliVersion;
1026
- if (installed) {
1027
- try {
1028
- cliVersion = await readInstalledCliVersion(this.ctx.process);
1029
- } catch {
1030
- cliVersion = void 0;
1031
- }
1032
- }
1033
1137
  return {
1034
1138
  mode: "none",
1035
1139
  installed,
1036
1140
  running: false,
1037
- ...cliVersion === void 0 ? {} : { cliVersion }
1141
+ ...verified === null ? {} : { cliVersion: verified.version }
1038
1142
  };
1039
1143
  }
1144
+ /**
1145
+ * 工作台算在跑:端口上的服务只收我们的令牌(ours),或还在下载、令牌对错都回 202(pending)。
1146
+ * 没有句柄时只按 ours 领养,pending 不足以证明是我们的,留给 Open 等下载完再复判。
1147
+ * 这里不改任何状态:一轮 503/超时、或下载阶段的 202,都不能抹掉本次 spawn 的句柄。
1148
+ */
1040
1149
  async managedIsLive() {
1041
1150
  if (this.managed !== null) {
1042
- if (await instanceIsLive(this.managed.record)) return true;
1043
- this.managed = null;
1044
- return false;
1151
+ const state = await classifyRecord(this.managed.record, this.paths);
1152
+ return state === "ours" || state === "pending";
1045
1153
  }
1046
1154
  const record = await readInstance(this.paths);
1047
- if (record === null) return false;
1048
- return instanceIsLive(record);
1155
+ if (record === null || await classifyRecord(record, this.paths) !== "ours") return false;
1156
+ this.managed = { owned: false, record };
1157
+ return true;
1049
1158
  }
1050
1159
  async runOpen(payload) {
1051
1160
  const desiredExternal = await this.desiredExternalPort();
@@ -1110,7 +1219,7 @@ var WorkbenchRuntime = class {
1110
1219
  }
1111
1220
  if (!patched) return;
1112
1221
  this.ctx.log.info("restarting the workbench server to apply the patched product.json");
1113
- await this.stopManaged();
1222
+ await this.stopManaged("keep");
1114
1223
  await this.stepStartServer(cliVersion);
1115
1224
  await this.stepDownloadServer(token);
1116
1225
  }
@@ -1124,31 +1233,44 @@ var WorkbenchRuntime = class {
1124
1233
  await writeLicenseAccepted(this.paths);
1125
1234
  this.stage("license", "succeeded");
1126
1235
  }
1236
+ /**
1237
+ * 只复用校验记录与二进制都对得上钉死版本的 CLI。旧版插件装的(没有记录)、版本不同的、
1238
+ * 装好后被换掉的,一律重下钉死的版本,不运行它。
1239
+ */
1127
1240
  async stepCheckAndDownloadCli() {
1128
1241
  this.stage("check-cli", "started");
1129
- if (await fileExists(this.paths.cliPath)) {
1130
- try {
1131
- const version = await readInstalledCliVersion(this.ctx.process);
1132
- this.stage("check-cli", "succeeded", { detail: version });
1133
- this.stage("download-cli", "skipped", { skipReason: "already-installed" });
1134
- return version;
1135
- } catch (error) {
1136
- this.fail("check-cli", "unknown", messageOf(error));
1137
- }
1242
+ const verified = await readVerifiedCli(this.paths, { checkBinary: true });
1243
+ if (verified !== null) {
1244
+ this.stage("check-cli", "succeeded", { detail: verified.version });
1245
+ this.stage("download-cli", "skipped", { skipReason: "already-installed" });
1246
+ return verified.version;
1138
1247
  }
1139
- this.stage("check-cli", "succeeded");
1248
+ this.stage(
1249
+ "check-cli",
1250
+ "succeeded",
1251
+ await fileExists(this.paths.cliPath) ? {
1252
+ detail: `The VS Code CLI on this machine is not the verified ${PINNED_CLI_VERSION} build; downloading that build to replace it.`
1253
+ } : {}
1254
+ );
1140
1255
  return this.stepDownloadCli(this.paths.cliPath);
1141
1256
  }
1142
- async stepDownloadCli(destPath) {
1257
+ /** 下载、校验并装上钉死的版本:`stagingPath` 与 `cliPath` 不同时校验通过后才改名换上。 */
1258
+ async stepDownloadCli(stagingPath) {
1143
1259
  this.stage("download-cli", "started");
1144
1260
  try {
1145
- await downloadCliBinary({
1261
+ await clearVerifiedCli(this.paths);
1262
+ const install = await downloadCliBinary({
1146
1263
  http: this.ctx.http,
1147
1264
  paths: this.paths,
1148
- destPath,
1265
+ destPath: stagingPath,
1149
1266
  onTransfer: (transfer) => this.stage("download-cli", "progress", { transfer })
1150
1267
  });
1268
+ if (stagingPath !== this.paths.cliPath) await NodeFSP5.rename(stagingPath, this.paths.cliPath);
1269
+ await writeVerifiedCli(this.paths, install);
1151
1270
  } catch (error) {
1271
+ if (stagingPath !== this.paths.cliPath) {
1272
+ await NodeFSP5.unlink(stagingPath).catch(() => void 0);
1273
+ }
1152
1274
  this.rethrowAs(error, "download-cli");
1153
1275
  }
1154
1276
  try {
@@ -1162,15 +1284,14 @@ var WorkbenchRuntime = class {
1162
1284
  async stepStartServer(cliVersion) {
1163
1285
  this.stage("start-server", "started");
1164
1286
  this.outputStage = "start-server";
1165
- if (this.managed !== null && await instanceIsLive(this.managed.record)) {
1166
- this.stage("start-server", "skipped", { skipReason: "already-running" });
1167
- return;
1168
- }
1169
- const record = await readInstance(this.paths);
1170
- if (record !== null && await instanceIsLive(record)) {
1171
- this.managed = { handle: orphanHandle(record.pid), record };
1172
- this.stage("start-server", "skipped", { skipReason: "already-running" });
1173
- return;
1287
+ const record = this.managed?.record ?? await readInstance(this.paths);
1288
+ if (record !== null) {
1289
+ const state = await this.settleRecord(record);
1290
+ if (state !== "forgotten") {
1291
+ if (this.managed === null) this.managed = { owned: false, record };
1292
+ this.stage("start-server", "skipped", { skipReason: "already-running" });
1293
+ return;
1294
+ }
1174
1295
  }
1175
1296
  try {
1176
1297
  this.managed = await startServeWeb({
@@ -1189,6 +1310,39 @@ var WorkbenchRuntime = class {
1189
1310
  detail: `127.0.0.1:${String(started.record.port)}`
1190
1311
  });
1191
1312
  }
1313
+ /**
1314
+ * 判定一条记录能不能接管。ours / pending 原样返回;foreign / stale 与等满时限仍答不出的
1315
+ * 记录一律忘掉(删记录;若是本次自己 spawn 的实例,先停它自己的句柄),返回 "forgotten"。
1316
+ * 不依赖记录里的 pid,也不对任何别的进程发信号。
1317
+ */
1318
+ async settleRecord(record) {
1319
+ let state = await classifyRecord(record, this.paths);
1320
+ if (state === "unresponsive") {
1321
+ this.stage("start-server", "output", {
1322
+ detail: "Waiting for the previous workbench server to answer..."
1323
+ });
1324
+ const deadline = Date.now() + this.unresponsiveGraceMs;
1325
+ const pollMs = Math.min(UNRESPONSIVE_POLL_MS, Math.max(1, this.unresponsiveGraceMs / 4));
1326
+ while (state === "unresponsive" && Date.now() < deadline) {
1327
+ await sleep(pollMs);
1328
+ state = await classifyRecord(record, this.paths);
1329
+ }
1330
+ }
1331
+ if (state === "ours" || state === "pending") return state;
1332
+ await this.forgetRecord(record, state);
1333
+ return "forgotten";
1334
+ }
1335
+ async forgetRecord(record, state) {
1336
+ this.ctx.log.warn("forgetting a recorded workbench instance", { port: record.port, state });
1337
+ if (this.managedPort !== null) {
1338
+ await this.managedPort.unregister().catch(() => void 0);
1339
+ this.managedPort = null;
1340
+ }
1341
+ const managed = this.managed;
1342
+ this.managed = null;
1343
+ if (managed?.owned === true) await stopOwnedHandle(managed.handle);
1344
+ await removeFile(this.paths.instancePath);
1345
+ }
1192
1346
  onProcessLine(line) {
1193
1347
  if (line.trim().length === 0) return;
1194
1348
  const stage = isServerLogLine(line) ? "download-server" : this.outputStage;
@@ -1208,8 +1362,19 @@ var WorkbenchRuntime = class {
1208
1362
  onTransfer: (transfer) => this.stage("download-server", "progress", { transfer })
1209
1363
  });
1210
1364
  } catch (error) {
1365
+ if (!managed.owned) {
1366
+ const state = await classifyRecord(managed.record, this.paths);
1367
+ if (state !== "ours" && state !== "pending") await this.forgetRecord(managed.record, state);
1368
+ }
1211
1369
  this.rethrowAs(error, "download-server", "server-download-failed");
1212
1370
  }
1371
+ if (!managed.owned) {
1372
+ const state = await classifyRecord(managed.record, this.paths);
1373
+ if (state !== "ours") {
1374
+ await this.forgetRecord(managed.record, state);
1375
+ this.fail("download-server", "unknown", catalog.adopt.notOurs);
1376
+ }
1377
+ }
1213
1378
  this.stage("download-server", "succeeded");
1214
1379
  }
1215
1380
  async stepRegisterManaged() {
@@ -1234,27 +1399,20 @@ var WorkbenchRuntime = class {
1234
1399
  this.stage("register-port", "succeeded");
1235
1400
  return this.managedPort.portId;
1236
1401
  }
1402
+ /**
1403
+ * 「更新」装的是插件钉死的版本,不是官方最新版:已装且校验通过就什么都不下;
1404
+ * 否则下到 `bin/code.new`,校验通过后才换上,失败时原来的二进制不动。
1405
+ */
1237
1406
  async runUpdate() {
1238
1407
  if (await this.managedIsLive()) {
1239
1408
  throw new WorkbenchFailure("unknown", catalog.running.stopFirst);
1240
1409
  }
1241
- this.stage("download-cli", "started");
1242
- const destPath = `${this.paths.cliPath}${CLI_NEW_SUFFIX}`;
1243
- try {
1244
- await downloadCliBinary({
1245
- http: this.ctx.http,
1246
- paths: this.paths,
1247
- destPath,
1248
- onTransfer: (transfer) => this.stage("download-cli", "progress", { transfer })
1249
- });
1250
- await NodeFSP5.rename(destPath, this.paths.cliPath);
1251
- } catch (error) {
1252
- await NodeFSP5.unlink(destPath).catch(() => void 0);
1253
- this.rethrowAs(error, "download-cli");
1410
+ const verified = await readVerifiedCli(this.paths, { checkBinary: true });
1411
+ if (verified !== null) {
1412
+ this.stage("download-cli", "skipped", { skipReason: "already-installed" });
1413
+ return { cliVersion: verified.version };
1254
1414
  }
1255
- this.stage("download-cli", "succeeded");
1256
- const version = await readInstalledCliVersion(this.ctx.process);
1257
- return { cliVersion: version };
1415
+ return { cliVersion: await this.stepDownloadCli(`${this.paths.cliPath}${CLI_NEW_SUFFIX}`) };
1258
1416
  }
1259
1417
  async attachPort(port) {
1260
1418
  if (!await probeLoopback(port)) {
@@ -1278,21 +1436,32 @@ var WorkbenchRuntime = class {
1278
1436
  this.externalPort = null;
1279
1437
  this.externalListenPort = null;
1280
1438
  }
1281
- async stopManaged() {
1439
+ /**
1440
+ * 停托管实例。只停本次运行自己 spawn 的句柄;记录里的 pid 不对它发信号:跨过一次重启
1441
+ * 它可能已被系统重用,令牌验的是端口上的服务,不是那个 pid。
1442
+ * - "keep"(插件停用、补丁后的内部重启):保留仍在服务的记录,下次激活或紧接着的
1443
+ * start-server 可以接上;
1444
+ * - "stop"(用户 Stop):句柄停完后记录仍在,说明端口上有个无法证明能安全停的服务
1445
+ * (领养来的,或孙进程脱离了句柄)。放弃接管:删记录、状态不再显示在跑,把端口报给
1446
+ * 调用方。否则 Stop 停不掉、Update 又要求先 Stop,形成单向门。
1447
+ */
1448
+ async stopManaged(mode) {
1282
1449
  if (this.managedPort !== null) {
1283
1450
  await this.managedPort.unregister().catch(() => void 0);
1284
1451
  this.managedPort = null;
1285
1452
  }
1286
- await stopManagedProcess(this.managed, this.paths);
1453
+ const managed = this.managed;
1287
1454
  this.managed = null;
1288
- const leftover = await readInstance(this.paths);
1289
- if (leftover !== null && isProcessAlive(leftover.pid)) {
1290
- try {
1291
- process.kill(leftover.pid, "SIGTERM");
1292
- } catch {
1293
- }
1294
- }
1455
+ if (managed?.owned === true) await stopOwnedHandle(managed.handle);
1456
+ await reconcileInstanceRecord(this.paths, this.ctx.log);
1457
+ if (mode === "keep") return {};
1458
+ const record = await readInstance(this.paths);
1459
+ if (record === null) return {};
1295
1460
  await removeFile(this.paths.instancePath);
1461
+ this.ctx.log.warn("released a workbench server it cannot stop; it may still be running", {
1462
+ port: record.port
1463
+ });
1464
+ return { released: { port: record.port } };
1296
1465
  }
1297
1466
  rethrowAs(error, stage, fallback = "download-failed") {
1298
1467
  if (error instanceof WorkbenchFailure) {
@@ -1306,19 +1475,6 @@ var capabilityReason = (error) => {
1306
1475
  const reason = error.reason;
1307
1476
  return typeof reason === "string" ? reason : null;
1308
1477
  };
1309
- var orphanHandle = (pid) => ({
1310
- pid,
1311
- stdout: emptyBytes(),
1312
- stderr: emptyBytes(),
1313
- writeStdin: async () => void 0,
1314
- closeStdin: async () => void 0,
1315
- kill: async (signal) => {
1316
- if (isProcessAlive(pid)) process.kill(pid, signal ?? "SIGTERM");
1317
- },
1318
- exited: Promise.resolve(null)
1319
- });
1320
- async function* emptyBytes() {
1321
- }
1322
1478
 
1323
1479
  // src/schemas.ts
1324
1480
  var emptyObject = {
@@ -1389,6 +1545,20 @@ var statusResultSchema = {
1389
1545
  }
1390
1546
  }
1391
1547
  };
1548
+ var stopResultSchema = {
1549
+ type: "object",
1550
+ additionalProperties: false,
1551
+ properties: {
1552
+ released: {
1553
+ type: "object",
1554
+ additionalProperties: false,
1555
+ required: ["port"],
1556
+ properties: {
1557
+ port: { type: "integer", minimum: 1, maximum: 65535 }
1558
+ }
1559
+ }
1560
+ }
1561
+ };
1392
1562
  var updateResultSchema = {
1393
1563
  type: "object",
1394
1564
  additionalProperties: false,
@@ -1433,7 +1603,7 @@ var progressItemSchema = {
1433
1603
  // src/index.ts
1434
1604
  var index_default = definePlugin({
1435
1605
  id: "browser-workbench",
1436
- apiVersion: 2,
1606
+ apiVersion: 3,
1437
1607
  activate: (ctx) => {
1438
1608
  const runtime = new WorkbenchRuntime(ctx);
1439
1609
  void runtime.start().catch((error) => {
@@ -1489,10 +1659,10 @@ var index_default = definePlugin({
1489
1659
  });
1490
1660
  ctx.command({
1491
1661
  name: "stop",
1492
- description: "Stop the managed VS Code CLI and unregister its port.",
1662
+ description: "Stop the managed VS Code CLI and unregister its port. A server this run did not start is released, not killed; the result then carries its port.",
1493
1663
  scope: "operate",
1494
1664
  payloadSchema: emptyPayloadSchema,
1495
- resultSchema: emptyResultSchema,
1665
+ resultSchema: stopResultSchema,
1496
1666
  handle: () => runtime.stop()
1497
1667
  });
1498
1668
  ctx.command({