authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:22-07:00
log07549200dcdc70dce57285276879004024beeaa7
treef3a5b3faa1460263bcb2be6cd72e2d14eeca8635
parentaf6c138a0de297f94ddef3133e2f47d017b46e94
signature Signed by PGP key B5690EEEBB952194

feat(desktop): run the WSL backend from the Linux CLI archive (#11511)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

8 files changed, 725 insertions(+), 844 deletions(-)

.github/workflows/release.yml+103-40
...@@ -354,21 +354,28 @@ jobs:...@@ -354,21 +354,28 @@ jobs:
354 # job — the Windows artifact then ships a ready WSL backend binary with no354 # job — the Windows artifact then ships a ready WSL backend binary with no
355 # cross-compiling and no first-launch compiler/node-gyp/network on the user's355 # cross-compiling and no first-launch compiler/node-gyp/network on the user's
356 # machine. node-pty is N-API, so one binary works across all WSL Node versions.356 # machine. node-pty is N-API, so one binary works across all WSL Node versions.
357 build_wsl_node_pty:357 # The Linux CLI archive is built ahead of the desktop matrix because two
358 name: Build WSL node-pty (linux-x64)358 # consumers need it: the Linux desktop entry attaches it to the release, and
359 # the Windows desktop entry embeds it as the WSL runtime. Building it once
360 # here means the WSL backend runs the exact bytes a Linux user downloads.
361 build_linux_cli:
362 name: Build CLI archive (linux-x64)
359 # Same gating as relay_public_config: only the release commit is needed, so363 # Same gating as relay_public_config: only the release commit is needed, so
360 # this runs alongside preflight. See the condition comment there.364 # this runs alongside preflight. See the condition comment there.
361 needs: [resolve_commit]365 needs: [resolve_commit, preflight, relay_public_config]
362 if: |366 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
363 needs.resolve_commit.result == 'success' &&367 runs-on: blacksmith-32vcpu-ubuntu-2404
364 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')368 timeout-minutes: 20
365 runs-on: blacksmith-8vcpu-ubuntu-2404369 env:
366 timeout-minutes: 15370 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }}
371 T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }}
372 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }}
373 T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }}
367 steps:374 steps:
368 - name: Checkout375 - name: Checkout
369 uses: actions/checkout@v6376 uses: actions/checkout@v6
370 with:377 with:
371 ref: ${{ needs.resolve_commit.outputs.ref }}378 ref: ${{ needs.preflight.outputs.ref }}
372 sparse-checkout: |379 sparse-checkout: |
373 /*380 /*
374 !/.repos/381 !/.repos/
...@@ -382,38 +389,92 @@ jobs:...@@ -382,38 +389,92 @@ jobs:
382 run-install: |389 run-install: |
383 args:390 args:
384 - --filter=t3...391 - --filter=t3...
392 - --filter=@t3tools/web...
393 - --filter=@t3tools/scripts...
394
395 - name: Cache resource monitor
396 id: resource_monitor_cache
397 uses: actions/cache@v6
398 with:
399 path: native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor
400 key: resource-monitor-x86_64-unknown-linux-gnu-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }}
401
402 - name: Setup Rust
403 if: steps.resource_monitor_cache.outputs.cache-hit != 'true'
404 uses: dtolnay/rust-toolchain@stable
405 with:
406 targets: x86_64-unknown-linux-gnu
407
408 - name: Build resource monitor
409 if: steps.resource_monitor_cache.outputs.cache-hit != 'true'
410 run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target x86_64-unknown-linux-gnu
411
412 - name: Download relay client tracing config
413 uses: actions/download-artifact@v8
414 with:
415 name: relay-client-tracing-config
416 path: ${{ runner.temp }}/relay-client-tracing
385417
386 - name: Build node-pty linux-x64 prebuild418 - name: Load relay client tracing config
387 shell: bash419 shell: bash
420 run: |
421 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
422 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
423 echo "::add-mask::$tracing_token"
424 cat "$config_path" >> "$GITHUB_ENV"
425
426 - name: Align package versions to release version
427 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
428
429 # The t3 build task depends on @t3tools/web#build, so the web client is
430 # built as part of this step.
431 - name: Build CLI package
432 run: vp run --filter t3 build
433
434 - name: Build CLI single-executable
435 env:
436 # The exact version, not a major: vp downloads it from nodejs.org/dist on
437 # the runner, and only exact versions have a dist directory. Keep in
438 # step with SEA_NODE_VERSION in apps/server/vite.config.ts.
439 VP_NODE_VERSION: "26.8.2"
440 run: node apps/server/scripts/cli.ts build-exe --verbose
441
442 - name: Stage resource monitor for the CLI archive
388 run: |443 run: |
389 set -euo pipefail444 set -euo pipefail
390 # Resolve node-pty from apps/server (where it's a dependency) and build445 target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64"
391 # its native binary from source for Linux. node-addon-api resolves from446 mkdir -p "$target_dir"
392 # node-pty's own dependency tree, so node-gyp has everything it needs.447 cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/"
393 pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")"448
394 pty_dir="$(dirname "$pty_pkg")"449 - name: Build CLI archive
395 ( cd "$pty_dir" && npx --yes node-gyp rebuild )450 run: |
396 mkdir -p wsl-prebuild451 node scripts/build-cli-archive.ts \
397 cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node452 --platform linux \
398 file wsl-prebuild/pty.node453 --arch x64 \
399454 --version "${{ needs.preflight.outputs.version }}" \
400 - name: Upload node-pty linux-x64 prebuild455 --resource-monitor-dir "$RUNNER_TEMP/cli-resource-monitor" \
456 --output-dir release-cli
457
458 - name: Smoke-test CLI archive
459 run: node scripts/smoke-cli-archive.ts --archive release-cli/* --expect-version "${{ needs.preflight.outputs.version }}"
460
461 - name: Upload CLI archive
401 uses: actions/upload-artifact@v7462 uses: actions/upload-artifact@v7
402 with:463 with:
403 name: wsl-node-pty-x64464 name: cli-linux-x64
404 path: wsl-prebuild/pty.node465 path: release-cli/*
405 if-no-files-found: error466 if-no-files-found: error
406467
407 build:468 build:
408 name: Build ${{ matrix.label }}469 name: Build ${{ matrix.label }}
409 # build_wsl_node_pty stays in `needs` so it runs first and its artifact is470 # build_linux_cli stays in `needs` so it runs first and its artifact is
410 # available to download, but only the Windows matrix entry consumes it. We471 # available to download, but only the Windows matrix entry consumes it (as
411 # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring472 # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring
412 # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux473 # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds.
413 # builds. `!cancelled()` (not `!failure()`) lets the job run even when474 # `!cancelled()` (not `!failure()`) lets the job run even when
414 # build_wsl_node_pty failed; the Windows-only download step below then fails475 # build_linux_cli failed; the Windows-only download step below then fails
415 # that single platform if the prebuild is missing.476 # that single platform if the archive is missing.
416 needs: [preflight, relay_public_config, build_wsl_node_pty]477 needs: [preflight, relay_public_config, build_linux_cli]
417 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}478 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
418 runs-on: ${{ matrix.runner }}479 runs-on: ${{ matrix.runner }}
419 timeout-minutes: 30480 timeout-minutes: 30
...@@ -448,6 +509,7 @@ jobs:...@@ -448,6 +509,7 @@ jobs:
448 rust_target: x86_64-apple-darwin509 rust_target: x86_64-apple-darwin
449 resource_key: darwin-x64510 resource_key: darwin-x64
450 cli_archive: false511 cli_archive: false
512 # The Linux CLI archive is produced by build_linux_cli, not here.
451 - label: Linux x64513 - label: Linux x64
452 runner: blacksmith-32vcpu-ubuntu-2404514 runner: blacksmith-32vcpu-ubuntu-2404
453 platform: linux515 platform: linux
...@@ -455,7 +517,7 @@ jobs:...@@ -455,7 +517,7 @@ jobs:
455 arch: x64517 arch: x64
456 rust_target: x86_64-unknown-linux-gnu518 rust_target: x86_64-unknown-linux-gnu
457 resource_key: linux-x64519 resource_key: linux-x64
458 cli_archive: true520 cli_archive: false
459 - label: Windows x64521 - label: Windows x64
460 runner: blacksmith-32vcpu-windows-2025522 runner: blacksmith-32vcpu-windows-2025
461 platform: win523 platform: win
...@@ -553,12 +615,14 @@ jobs:...@@ -553,12 +615,14 @@ jobs:
553 - name: Align package versions to release version615 - name: Align package versions to release version
554 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"616 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
555617
556 - name: Download WSL node-pty prebuild618 # The WSL backend runs the Linux CLI archive inside the distro, so the
619 # Windows desktop embeds the same archive the release attaches.
620 - name: Download Linux CLI archive for WSL
557 if: matrix.platform == 'win'621 if: matrix.platform == 'win'
558 uses: actions/download-artifact@v7622 uses: actions/download-artifact@v8
559 with:623 with:
560 name: wsl-node-pty-x64624 name: cli-linux-x64
561 path: wsl-prebuild625 path: wsl-runtime
562626
563 - name: Install Spectre-mitigated MSVC libs627 - name: Install Spectre-mitigated MSVC libs
564 if: matrix.platform == 'win'628 if: matrix.platform == 'win'
...@@ -720,10 +784,9 @@ jobs:...@@ -720,10 +784,9 @@ jobs:
720 echo "macOS signing disabled (missing one or more Apple signing secrets)."784 echo "macOS signing disabled (missing one or more Apple signing secrets)."
721 fi785 fi
722 elif [[ "${{ matrix.platform }}" == "win" ]]; then786 elif [[ "${{ matrix.platform }}" == "win" ]]; then
723 # Bundle the Linux node-pty binary built by the build_wsl_node_pty job787 # Embed the Linux CLI archive built by build_linux_cli as the WSL
724 # so the packaged WSL backend ships a ready binary (no first-launch788 # runtime. Required for a working WSL backend on Windows.
725 # compile). Required for a working WSL backend on Windows.789 args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz)
726 args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node")
727 if has_all \790 if has_all \
728 "$AZURE_TENANT_ID" \791 "$AZURE_TENANT_ID" \
729 "$AZURE_CLIENT_ID" \792 "$AZURE_CLIENT_ID" \
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+44-57
...@@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => {...@@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => {
374 runtimeId: string;374 runtimeId: string;
375 sha256: string;375 sha256: string;
376 }> = [];376 }> = [];
377 const observedNodePtyRoots: string[] = [];377 const observedProbeRoots: string[] = [];
378 let legacyCleanupCount = 0;378 let legacyCleanupCount = 0;
379 const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64";379 const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64";
380 const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin";
380381
381 return withPackagedWslHarness(382 return withPackagedWslHarness(
382 {383 {
...@@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => {...@@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => {
394 });395 });
395 return { ok: true, linuxAppRoot };396 return { ok: true, linuxAppRoot };
396 },397 },
397 ensureNodePty: (_distro, root) => {398 probeRuntime: (_distro, root) => {
398 observedNodePtyRoots.push(root);399 observedProbeRoots.push(root);
399 return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };400 return { ok: true, resolvedPath };
401 },
402 // The staged runtime carries its own Node, so the preflight must not
403 // go looking for one in the distro.
404 ensureNodePty: () => {
405 throw new Error("the staged runtime must not probe for Node");
400 },406 },
401 }),407 }),
402 },408 },
...@@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => {...@@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => {
413 sha256: archiveHash,419 sha256: archiveHash,
414 },420 },
415 ]);421 ]);
416 assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]);422 assert.deepEqual(observedProbeRoots, [linuxAppRoot]);
417 assert.equal(423 assert.equal(
418 config.entryPath,424 config.entryPath,
419 path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"),425 path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"),
420 );426 );
421 assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`);427 assert.deepEqual(config.args, [
428 "-d",
429 "Ubuntu",
430 "--exec",
431 "env",
432 `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`,
433 `${linuxAppRoot}/t3`,
434 "--bootstrap-fd",
435 "0",
436 ]);
437 assert.notInclude(config.args, "/usr/bin/node");
422 assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`);438 assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`);
423 assert.equal(legacyCleanupCount, 1);439 assert.equal(legacyCleanupCount, 1);
424 assert.isTrue(Option.isNone(config.preflightFailure));440 assert.isTrue(Option.isNone(config.preflightFailure));
...@@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => {...@@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => {
457473
458 assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]);474 assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]);
459 assert.equal(first.wslRuntimeId, observedRuntimeIds[0]);475 assert.equal(first.wslRuntimeId, observedRuntimeIds[0]);
476 assert.include(first.args, `/runtime/sha256-${firstHash}/t3`);
460 assert.equal(second.wslRuntimeId, observedRuntimeIds[1]);477 assert.equal(second.wslRuntimeId, observedRuntimeIds[1]);
478 assert.include(second.args, `/runtime/sha256-${secondHash}/t3`);
461 assert.isUndefined(invalidIdentity.wslRuntimeId);479 assert.isUndefined(invalidIdentity.wslRuntimeId);
480 assert.include(invalidIdentity.args, "/usr/bin/node");
462 assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);481 assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
463 }),482 }),
464 );483 );
...@@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => {...@@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => {
484503
485 assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);504 assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);
486 assert.equal(config.entryPath, mountedEntryPath);505 assert.equal(config.entryPath, mountedEntryPath);
506 assert.include(config.args, "/usr/bin/node");
487 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);507 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
488 assert.isUndefined(config.wslRuntimeId);508 assert.isUndefined(config.wslRuntimeId);
489 assert.isTrue(Option.isNone(config.preflightFailure));509 assert.isTrue(Option.isNone(config.preflightFailure));
...@@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => {...@@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => {
491 );511 );
492 });512 });
493513
494 it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => {514 it.effect("resolveWsl retires a staged runtime whose executable does not start", () => {
495 const archiveHash = "c".repeat(64);515 const archiveHash = "c".repeat(64);
496 const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`;516 const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`;
517 const observedProbeRoots: string[] = [];
497 const observedNodePtyRoots: string[] = [];518 const observedNodePtyRoots: string[] = [];
498 const invalidatedRuntimeIds: string[] = [];519 const invalidatedRuntimeIds: string[] = [];
499 return withPackagedWslHarness(520 return withPackagedWslHarness(
...@@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => {...@@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => {
505 Effect.sync(() => {526 Effect.sync(() => {
506 invalidatedRuntimeIds.push(runtimeId);527 invalidatedRuntimeIds.push(runtimeId);
507 }),528 }),
529 probeRuntime: (_distro, root) => {
530 observedProbeRoots.push(root);
531 return { ok: false, reason: `${root}/t3 --version failed (exit 127)` };
532 },
508 ensureNodePty: (_distro, root) => {533 ensureNodePty: (_distro, root) => {
509 observedNodePtyRoots.push(root);534 observedNodePtyRoots.push(root);
510 return root === stagedAppRoot535 return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
511 ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
512 : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
513 },536 },
514 }),537 }),
515 },538 },
...@@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => {...@@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => {
518 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;541 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
519 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });542 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
520543
521 assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]);544 assert.deepEqual(observedProbeRoots, [stagedAppRoot]);
545 assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);
546 assert.include(config.args, "/usr/bin/node");
522 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);547 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
548 assert.notInclude(config.args, `${stagedAppRoot}/t3`);
523 assert.equal(config.entryPath, mountedEntryPath);549 assert.equal(config.entryPath, mountedEntryPath);
524 assert.isUndefined(config.wslRuntimeId);550 assert.isUndefined(config.wslRuntimeId);
525 assert.isTrue(Option.isNone(config.preflightFailure));551 assert.isTrue(Option.isNone(config.preflightFailure));
...@@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => {...@@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => {
540 Effect.sync(() => {566 Effect.sync(() => {
541 invalidatedRuntimeIds.push(runtimeId);567 invalidatedRuntimeIds.push(runtimeId);
542 }),568 }),
543 ensureNodePty: (_distro, root) => ({569 probeRuntime: () => ({
544 ok: false,570 ok: false,
545 reason:571 reason: "unsupported CPU architecture or incompatible system libraries",
546 root === stagedAppRoot572 }),
547 ? "unsupported CPU architecture or incompatible system libraries"573 ensureNodePty: () => ({
548 : "mounted tree is broken in some other way",574 ok: false,
575 reason: "mounted tree is broken in some other way",
549 fatal: true,576 fatal: true,
550 }),577 }),
551 }),578 }),
...@@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => {...@@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => {
575 Effect.sync(() => {602 Effect.sync(() => {
576 invalidatedRuntimeIds.push(runtimeId);603 invalidatedRuntimeIds.push(runtimeId);
577 }),604 }),
578 ensureNodePty: (_distro, root) =>605 probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }),
579 root === stagedAppRoot
580 ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
581 : {
582 ok: false,
583 reason: "WSL backend preflight timed out while probing for Node.js.",
584 fatal: false,
585 },
586 }),
587 },
588 () =>
589 Effect.gen(function* () {
590 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
591 const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
592 const failure = Option.getOrThrow(config.preflightFailure);
593
594 assert.isFalse(failure.fatal);
595 assert.equal(failure.retryLimit, 12);
596 assert.include(failure.reason, "timed out");
597 assert.deepEqual(invalidatedRuntimeIds, []);
598 }),
599 );
600 });
601
602 it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => {
603 const invalidatedRuntimeIds: string[] = [];
604 return withPackagedWslHarness(
605 {
606 archiveHash: "e".repeat(64),
607 forbidFallback: "A transient probe failure must not extract the fallback",
608 forbidCleanup: "A transient probe failure must not clean the fallback tree",
609 wsl: () => ({
610 prepareRuntime: () => ({
611 ok: true,
612 linuxAppRoot: "/home/test/.t3/wsl-runtime/cache",
613 }),
614 invalidateRuntime: (_distro, runtimeId) =>
615 Effect.sync(() => {
616 invalidatedRuntimeIds.push(runtimeId);
617 }),
618 ensureNodePty: () => ({606 ensureNodePty: () => ({
619 ok: false,607 ok: false,
620 reason: "WSL backend preflight timed out while probing for Node.js.",608 reason: "WSL backend preflight timed out while probing for Node.js.",
621 fatal: false,609 fatal: false,
622 retryLimit: 12,
623 }),610 }),
624 }),611 }),
625 },612 },
apps/desktop/src/backend/DesktopBackendConfiguration.ts+75-50
...@@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const...@@ -95,6 +95,11 @@ const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const
9595
96const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";96const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
9797
98const nodeBinDirOf = (nodePath: string): string => {
99 const lastSlash = nodePath.lastIndexOf("/");
100 return lastSlash > 0 ? nodePath.slice(0, lastSlash) : "/usr/bin";
101};
102
98const backendChildEnvPatch = (): Record<string, string | undefined> =>103const backendChildEnvPatch = (): Record<string, string | undefined> =>
99 Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));104 Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));
100105
...@@ -210,18 +215,31 @@ interface SharedBootstrapInput {...@@ -210,18 +215,31 @@ interface SharedBootstrapInput {
210 readonly observabilitySettings: BackendObservabilitySettings;215 readonly observabilitySettings: BackendObservabilitySettings;
211}216}
212217
218// What the launch runs inside the distro. The staged runtime is the release's
219// self-contained `t3` executable (Node inside); the mounted server tree is a
220// script that needs the distro's own Node.
221type WslPreflightRuntime =
222 | {
223 readonly kind: "executable";
224 readonly entryPath: string;
225 }
226 | {
227 readonly kind: "node-script";
228 // Absolute path to the node binary the preflight validated after the
229 // shared remote resolver repaired PATH. The launch must use this exact
230 // path so it doesn't fall through to a different/old node than the one
231 // node-pty was probed with.
232 readonly nodePath: string;
233 readonly linuxEntryPath: string;
234 };
235
213interface WslPreflightSuccess {236interface WslPreflightSuccess {
214 readonly _tag: "Ready";237 readonly _tag: "Ready";
215 readonly runningDistro: string;238 readonly runningDistro: string;
216 readonly windowsEntryPath: string;239 readonly windowsEntryPath: string;
217 readonly linuxEntryPath: string;240 readonly runtime: WslPreflightRuntime;
218 // Absolute path to the node binary the preflight validated after the shared241 // PATH captured from the user's login shell. The launch forwards this value
219 // remote resolver repaired PATH. The launch must use this exact path so it242 // directly without a shell so the server can spawn provider CLIs by name.
220 // doesn't fall through to a different/old node than the one node-pty was
221 // built against.
222 readonly nodePath: string;
223 // PATH captured from the same login shell after the shared resolver loaded
224 // version managers. The launch forwards this value directly without a shell.
225 readonly resolvedPath: string;243 readonly resolvedPath: string;
226 // Identifies the distro-local runtime cache selected from the packaged archive.244 // Identifies the distro-local runtime cache selected from the packaged archive.
227 readonly runtimeId?: string;245 readonly runtimeId?: string;
...@@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f...@@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
355 // fatal verdict the cached reason is the more actionable one to report.373 // fatal verdict the cached reason is the more actionable one to report.
356 // A transient mounted failure is neither — it rules nothing out, so it stays374 // A transient mounted failure is neither — it rules nothing out, so it stays
357 // retryable and the staged verdict waits for an attempt that can answer.375 // retryable and the staged verdict waits for an attempt that can answer.
358 let stagedFailure:376 let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined;
359 | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult }377 const failedStaged = (failure: { readonly reason: string }) =>
360 | undefined;378 ({
379 _tag: "Failed",
380 reason: `WSL runtime unavailable: ${failure.reason}`,
381 fatal: true,
382 }) as const;
361383
362 if (input.runtimeArchive !== null) {384 if (input.runtimeArchive !== null) {
363 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);385 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
364 if (runtime.ok) {386 if (runtime.ok) {
365 const stagedNodePty = yield* wslEnv.ensureNodePty(387 // The staged runtime is self-contained, so the only question is whether
366 runningDistro,388 // it runs here; there is no Node to find or node-pty to load.
367 runtime.linuxAppRoot,389 const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot);
368 nodePtyOptions,390 if (stagedProbe.ok) {
369 );
370 if (stagedNodePty.ok) {
371 yield* wslServerTree.cleanupLegacy;391 yield* wslServerTree.cleanupLegacy;
372 return {392 return {
373 _tag: "Ready",393 _tag: "Ready",
374 runningDistro,394 runningDistro,
375 windowsEntryPath: environment.backendEntryPath,395 windowsEntryPath: environment.backendEntryPath,
376 linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`,396 runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` },
377 nodePath: stagedNodePty.nodePath,397 resolvedPath: stagedProbe.resolvedPath,
378 resolvedPath: stagedNodePty.resolvedPath,
379 runtimeId: input.runtimeArchive.runtimeId,398 runtimeId: input.runtimeArchive.runtimeId,
380 } as const;399 } as const;
381 }400 }
382 // A transport failure says nothing about the staged tree, so it is
383 // retried against the same cache rather than spending a second probe on
384 // the mounted tree and risking a needless reinstall.
385 if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty);
386 yield* Effect.logWarning(401 yield* Effect.logWarning(
387 "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.",402 "The staged WSL runtime did not start; retrying from the mounted server tree.",
388 { reason: stagedNodePty.reason },403 { reason: stagedProbe.reason },
389 );404 );
390 stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty };405 stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason };
391 } else {406 } else {
392 yield* Effect.logWarning(407 yield* Effect.logWarning(
393 "Could not stage the WSL runtime; launching from the mounted server tree instead.",408 "Could not stage the WSL runtime; launching from the mounted server tree instead.",
...@@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f...@@ -399,7 +414,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
399 const mounted = yield* resolveMountedAppRoot;414 const mounted = yield* resolveMountedAppRoot;
400 if (!mounted.ok) {415 if (!mounted.ok) {
401 return stagedFailure && mounted.fatal416 return stagedFailure && mounted.fatal
402 ? failedNodePty(stagedFailure.nodePty)417 ? failedStaged(stagedFailure)
403 : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);418 : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);
404 }419 }
405420
...@@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f...@@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
413 // turn a retryable failure into a fatal one, ending the WSL attempt (and,428 // turn a retryable failure into a fatal one, ending the WSL attempt (and,
414 // in wsl-only mode, persisting Windows) before the slow /mnt path had a429 // in wsl-only mode, persisting Windows) before the slow /mnt path had a
415 // chance to answer and clear the bad cache.430 // chance to answer and clear the bad cache.
416 return failedNodePty(431 return stagedFailure && nodePtyResult.fatal
417 stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult,432 ? failedStaged(stagedFailure)
418 );433 : failedNodePty(nodePtyResult);
419 }434 }
420435
421 // The mounted tree runs what the cache could not, so the cache is the broken436 // The mounted tree runs what the cache could not, so the cache is the broken
...@@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f...@@ -429,8 +444,11 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
429 _tag: "Ready",444 _tag: "Ready",
430 runningDistro,445 runningDistro,
431 windowsEntryPath: mounted.windowsEntryPath,446 windowsEntryPath: mounted.windowsEntryPath,
432 linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,447 runtime: {
433 nodePath: nodePtyResult.nodePath,448 kind: "node-script",
449 nodePath: nodePtyResult.nodePath,
450 linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
451 },
434 resolvedPath: nodePtyResult.resolvedPath,452 resolvedPath: nodePtyResult.resolvedPath,
435 } as const;453 } as const;
436});454});
...@@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
610 runtimeId: `sha256-${archiveHash}`,628 runtimeId: `sha256-${archiveHash}`,
611 sha256: archiveHash,629 sha256: archiveHash,
612 },630 },
613 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and631 // Packaged builds run the self-contained Linux runtime and, on fallback,
614 // attached to the Windows artifact — see build-desktop-artifact.ts), so the632 // whatever Linux node-pty the mounted tree carries, so the WSL backend never
615 // WSL backend never needs a compiler, node-gyp, or network on first launch.633 // needs a compiler, node-gyp, or network on first launch. Compiling from
616 // Compiling from source is a dev-only convenience: a checkout has no shipped634 // source is a dev-only convenience: a checkout has no Linux binary, and
617 // prebuilt, and developers have the toolchain. In packaged builds we instead635 // developers have the toolchain. In packaged builds we instead surface a
618 // surface a clear diagnostic if the prebuilt can't load (unsupported636 // clear diagnostic if the binary can't load (unsupported arch/distro),
619 // arch/distro), rather than silently dropping into a fragile runtime build.637 // rather than silently dropping into a fragile runtime build.
620 allowBuild: !environment.isPackaged,638 allowBuild: !environment.isPackaged,
621 });639 });
622640
...@@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
709727
710 // The WSL server spawns commands its providers reference by name — `npm`/`npx`728 // The WSL server spawns commands its providers reference by name — `npm`/`npx`
711 // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those729 // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those
712 // live in the resolved Node's bin dir, which `wsl.exe -- node` does NOT put on730 // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on
713 // the process PATH, so `npm install -g ...` fails with NotFound. Pass the731 // the process PATH, so `npm install -g ...` fails with NotFound. Pass the
714 // user PATH entries captured by the login-shell preflight. Every dynamic732 // user PATH entries captured by the preflight. Every dynamic value is a
715 // value is a separate argv entry under `wsl.exe --exec`; no shell command is733 // separate argv entry under `wsl.exe --exec`; no shell command is involved,
716 // involved, so Windows cannot mangle nested quotes and stdin remains reserved734 // so Windows cannot mangle nested quotes and stdin remains reserved for the
717 // for the bootstrap envelope.735 // bootstrap envelope. A node-script runtime additionally leads with the
718 const lastSlash = preflight.nodePath.lastIndexOf("/");736 // probed Node's bin dir so the server cannot pick up a different node than
719 const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin";737 // the one node-pty was probed with.
720 const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`;738 const runtime = preflight.runtime;
739 const launchPath =
740 runtime.kind === "executable"
741 ? `${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`
742 : `${nodeBinDirOf(runtime.nodePath)}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`;
743 const command =
744 runtime.kind === "executable"
745 ? [runtime.entryPath]
746 : [runtime.nodePath, runtime.linuxEntryPath];
721747
722 return {748 return {
723 ...baseConfig,749 ...baseConfig,
...@@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl...@@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
726 "--exec",752 "--exec",
727 "env",753 "env",
728 `PATH=${launchPath}`,754 `PATH=${launchPath}`,
729 preflight.nodePath,755 ...command,
730 preflight.linuxEntryPath,
731 "--bootstrap-fd",756 "--bootstrap-fd",
732 "0",757 "0",
733 ...devUrlArgs,758 ...devUrlArgs,
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+42-45
...@@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => {...@@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => {
74 return line.slice(field.length + 1).trim();74 return line.slice(field.length + 1).trim();
75};75};
7676
77const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");';77// Stands in for the release's self-contained `t3` executable: the install
78// script only asks it for `--version`.
79const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n';
7880
79const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>81const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
80 ChildProcessSpawner.make(() =>82 ChildProcessSpawner.make(() =>
...@@ -164,21 +166,22 @@ describe("WSL runtime cache", () => {...@@ -164,21 +166,22 @@ describe("WSL runtime cache", () => {
164166
165 expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"');167 expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"');
166 expect(script).toContain(' [ -f "$ready_marker" ] &&');168 expect(script).toContain(' [ -f "$ready_marker" ] &&');
167 expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&');169 expect(script).toContain(' runtime_entry_runs "$runtime_root" &&');
168 expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&');
169 expect(script).toContain(' node_pty_payload_present "$runtime_root"');
170 expect(script).toContain("if runtime_is_ready; then");170 expect(script).toContain("if runtime_is_ready; then");
171 expect(script).not.toContain("bin.mjs");
172 expect(script).not.toContain("node-pty");
171 expect(script).toContain("trap 'exit 1' HUP INT TERM");173 expect(script).toContain("trap 'exit 1' HUP INT TERM");
172 expect(script).toContain('exec 9> "$runtime_lock"');174 expect(script).toContain('exec 9> "$runtime_lock"');
173 expect(script).toContain("flock -x 9");175 expect(script).toContain("flock -x 9");
174 expect(script).not.toContain('rm -rf "$runtime_lock"');176 expect(script).not.toContain('rm -rf "$runtime_lock"');
175 expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"');177 expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"');
176 expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"');178 expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"');
179 // The release archive wraps everything in one `t3-<version>-linux-x64/`
180 // directory; stripping it puts the executable at `$runtime_root/t3`.
177 expect(script).toContain(181 expect(script).toContain(
178 "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"",182 "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1",
179 );183 );
180 expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"');184 expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then');
181 expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"');
182 expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"');185 expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"');
183 expect(script).not.toContain('rm -rf "$runtime_root"');186 expect(script).not.toContain('rm -rf "$runtime_root"');
184187
...@@ -248,46 +251,39 @@ describe("WSL runtime cache", () => {...@@ -248,46 +251,39 @@ describe("WSL runtime cache", () => {
248 expect(deleted).toBeGreaterThan(kept);251 expect(deleted).toBeGreaterThan(kept);
249 });252 });
250253
251 it("treats a runtime whose native payload went missing as a cache miss", () => {254 it("treats a runtime whose executable no longer runs as a cache miss", () => {
252 const script = buildWslRuntimeInstallScript(255 const script = buildWslRuntimeInstallScript(
253 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",256 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
254 "1.2.3-x64",257 "1.2.3-x64",
255 "b".repeat(64),258 "b".repeat(64),
256 );259 );
257260
258 // A glob, not a mapped `uname -m`: this is a presence check, and the later261 // The same proof the SSH runner and CLI installers use: executable, and
259 // native probe is what judges arch and loadability.262 // `--version` exits 0. That is what decides arch and loadability, so no
260 expect(script).toContain(263 // separate native probe is needed.
261 ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',264 expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1');
262 );
263 // The marker the probe reads must sit beside the binary, or the runtime is
264 // just as unusable as one missing pty.node outright.
265 expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue');
266265
267 // Readiness gates the short-circuit, so a cache missing the payload266 // Readiness gates the short-circuit, so a cache whose executable broke
268 // reinstalls from the archive instead of being reused forever.267 // reinstalls from the archive instead of being reused forever.
269 const payloadCheckDefined = script.indexOf("node_pty_payload_present() {");268 const entryCheckDefined = script.indexOf("runtime_entry_runs() {");
270 const readinessDefined = script.indexOf("runtime_is_ready() {");269 const readinessDefined = script.indexOf("runtime_is_ready() {");
271 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");270 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
272 expect(payloadCheckDefined).toBeGreaterThan(-1);271 expect(entryCheckDefined).toBeGreaterThan(-1);
273 expect(payloadCheckDefined).toBeLessThan(readinessDefined);272 expect(entryCheckDefined).toBeLessThan(readinessDefined);
274 expect(readinessDefined).toBeLessThan(readyShortCircuit);273 expect(readinessDefined).toBeLessThan(readyShortCircuit);
275 });274 });
276275
277 // A truncated or half-written bin.mjs passes every presence check the cache276 // A swapped or half-written `t3` can still exist and even still answer
278 // had: the file exists, node-pty still loads, and launch then picks a server277 // `--version`, and launch then runs something this install never verified.
279 // that exits before it becomes ready — forever, because nothing ever278 // The digest the install records is what turns that into a miss.
280 // reinstalls. The digest the install records is what turns that into a miss.279 it("re-hashes the executable against the digest the install recorded", () => {
281 it("re-hashes the server entry against the digest the install recorded", () => {
282 const script = buildWslRuntimeInstallScript(280 const script = buildWslRuntimeInstallScript(
283 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",281 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
284 "1.2.3-x64",282 "1.2.3-x64",
285 "b".repeat(64),283 "b".repeat(64),
286 );284 );
287285
288 expect(script).toContain(286 expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`);
289 ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,
290 );
291 expect(script).toContain(287 expect(script).toContain(
292 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',288 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
293 );289 );
...@@ -310,18 +306,18 @@ describe("WSL runtime cache", () => {...@@ -310,18 +306,18 @@ describe("WSL runtime cache", () => {
310 expect(promoted).toBeGreaterThan(markerWritten);306 expect(promoted).toBeGreaterThan(markerWritten);
311 });307 });
312308
313 it("refuses to mark an archive without a native payload as ready", () => {309 it("refuses to mark an archive whose executable does not run as ready", () => {
314 const script = buildWslRuntimeInstallScript(310 const script = buildWslRuntimeInstallScript(
315 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",311 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
316 "1.2.3-x64",312 "1.2.3-x64",
317 "b".repeat(64),313 "b".repeat(64),
318 );314 );
319315
320 expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then');316 expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then');
321317
322 // The extracted tree is rejected before the ready marker is written, so a318 // The extracted tree is rejected before the ready marker is written, so a
323 // defective archive falls back to the mounted tree instead of caching.319 // defective archive falls back to the mounted tree instead of caching.
324 const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"');320 const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"');
325 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');321 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
326 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');322 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
327 expect(payloadValidated).toBeGreaterThan(-1);323 expect(payloadValidated).toBeGreaterThan(-1);
...@@ -351,7 +347,7 @@ describe("WSL runtime cache", () => {...@@ -351,7 +347,7 @@ describe("WSL runtime cache", () => {
351 it("never deletes a runtime another backend is running from", () => {347 it("never deletes a runtime another backend is running from", () => {
352 const script = buildWslRuntimePruneScript("1.2.3/x64");348 const script = buildWslRuntimePruneScript("1.2.3/x64");
353349
354 // The running backend's argv holds `<runtime>/apps/server/dist/bin.mjs`, so350 // The running backend's argv holds `<runtime>/t3`, so
355 // the process itself is the lease and exiting releases it. Nothing has to be351 // the process itself is the lease and exiting releases it. Nothing has to be
356 // registered up front, which is what makes this cover backends already352 // registered up front, which is what makes this cover backends already
357 // running from an older version that knows nothing about pruning.353 // running from an older version that knows nothing about pruning.
...@@ -394,7 +390,7 @@ describe("WSL runtime cache", () => {...@@ -394,7 +390,7 @@ describe("WSL runtime cache", () => {
394});390});
395391
396// Reading the generated script proves what it says, not what it does. A cache392// Reading the generated script proves what it says, not what it does. A cache
397// whose bin.mjs was truncated satisfied every assertion above and still got393// whose entry was truncated satisfied every assertion above and still got
398// reused, so these run the real script against a real archive in a throwaway394// reused, so these run the real script against a real archive in a throwaway
399// HOME and check the outcome.395// HOME and check the outcome.
400describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => {396describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => {
...@@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
410 [406 [
411 "set -eu",407 "set -eu",
412 "work=$(mktemp -d)",408 "work=$(mktemp -d)",
413 'stage="$work/stage"',409 // Mirrors the release archive: one top-level versioned directory that
414 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"',410 // holds the executable and its native addons.
415 `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`,411 'stage="$work/stage/t3-0.0.0-linux-x64"',
416 `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`,412 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"',
417 `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`,413 `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`,
418 `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`,414 'chmod +x "$stage/t3"',
419 `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`,415 `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`,
416 `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`,
420 `printf 'work:%s\\n' "$work"`,417 `printf 'work:%s\\n' "$work"`,
421 `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`,418 `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`,
422 ].join("\n"),419 ].join("\n"),
...@@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
443 runtimeId,440 runtimeId,
444 runtimeParent: `${work}/home/.t3/wsl-runtime`,441 runtimeParent: `${work}/home/.t3/wsl-runtime`,
445 runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`,442 runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`,
446 serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`,443 serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`,
447 installScript,444 installScript,
448 install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)),445 install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)),
449 };446 };
...@@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
462 expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot);459 expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot);
463 });460 });
464461
465 it("reinstalls a cache whose server entry was truncated", () => {462 it("reinstalls a cache whose executable was truncated", () => {
466 const fixture = createFixture();463 const fixture = createFixture();
467 expect(fixture.install().status).toBe(0);464 expect(fixture.install().status).toBe(0);
468 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);465 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
...@@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
645 `runtime_root=${sh(fixture.runtimeRoot)}`,642 `runtime_root=${sh(fixture.runtimeRoot)}`,
646 `runtime_parent=${sh(fixture.runtimeParent)}`,643 `runtime_parent=${sh(fixture.runtimeParent)}`,
647 'rm "$runtime_root/.t3code-wsl-runtime-ready"',644 'rm "$runtime_root/.t3code-wsl-runtime-ready"',
648 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &',645 'sh -c "sleep 30" "$runtime_root/t3" >/dev/null 2>&1 &',
649 "active_pid=$!",646 "active_pid=$!",
650 "sleep 0.1",647 "sleep 0.1",
651 fixture.installScript(),648 fixture.installScript(),
...@@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
672 'home="$work/home"',669 'home="$work/home"',
673 'runtime_parent="$home/.t3/wsl-runtime"',670 'runtime_parent="$home/.t3/wsl-runtime"',
674 'mkdir -p "$runtime_parent"',671 'mkdir -p "$runtime_parent"',
675 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }',672 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }',
676 "make_ready sha256-current",673 "make_ready sha256-current",
677 "make_ready sha256-previous",674 "make_ready sha256-previous",
678 "make_ready sha256-active",675 "make_ready sha256-active",
...@@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed...@@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
683 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"',680 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"',
684 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"',681 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"',
685 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"',682 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"',
686 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &',683 'sh -c "sleep 30" "$runtime_parent/sha256-active/t3" >/dev/null 2>&1 &',
687 "active_pid=$!",684 "active_pid=$!",
688 "(",685 "(",
689 ' exec 9> "$runtime_parent/.sha256-locked.install.lock"',686 ' exec 9> "$runtime_parent/.sha256-locked.install.lock"',
apps/desktop/src/wsl/DesktopWslEnvironment.ts+125-71
...@@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult =...@@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult =
66 readonly retryLimit?: number;66 readonly retryLimit?: number;
67 };67 };
6868
69// Outcome of asking the staged self-contained runtime to prove itself. Any
70// failure sends the launch to the mounted server tree; the caller decides what
71// to do with the cache.
72export type ProbeWslRuntimeResult =
73 | {
74 readonly ok: true;
75 readonly resolvedPath: string;
76 }
77 | {
78 readonly ok: false;
79 readonly reason: string;
80 };
81
69export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()(82export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()(
70 "DesktopWslDistroListError",83 "DesktopWslDistroListError",
71 { reason: Schema.String },84 { reason: Schema.String },
...@@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service<...@@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service<
108 readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;121 readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
109 // Marks a staged runtime as unusable so the next launch reinstalls it.122 // Marks a staged runtime as unusable so the next launch reinstalls it.
110 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;123 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
124 // Proves a staged self-contained runtime can run (`<root>/t3 --version`)
125 // and captures the user's login-shell PATH for the launch. Needs no Node
126 // in the distro; the mounted server tree still goes through ensureNodePty.
127 readonly probeRuntime: (
128 distro: string | null,
129 linuxAppRoot: string,
130 ) => Effect.Effect<ProbeWslRuntimeResult>;
111 readonly ensureNodePty: (131 readonly ensureNodePty: (
112 distro: string | null,132 distro: string | null,
113 linuxAppRoot: string,133 linuxAppRoot: string,
...@@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = {...@@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = {
149169
150const formatWslShellTransportFailureReason = (170const formatWslShellTransportFailureReason = (
151 failure: ShellResult["transportFailure"],171 failure: ShellResult["transportFailure"],
172 subject = "Node.js",
152): string | null => {173): string | null => {
153 switch (failure) {174 switch (failure) {
154 case "timeout":175 case "timeout":
155 return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy.";176 return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`;
156 case "spawn":177 case "spawn":
157 return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible.";178 return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`;
158 case "process":179 case "process":
159 return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy.";180 return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`;
160 case null:181 case null:
161 return null;182 return null;
162 }183 }
...@@ -255,7 +276,7 @@ const runWslShell = (...@@ -255,7 +276,7 @@ const runWslShell = (
255276
256const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;277const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
257278
258// Holds the sha256 of the runtime's server entry, written when the install279// Holds the sha256 of the runtime's `t3` executable, written when the install
259// promotes a verified tree. Presence alone only says an install once finished280// promotes a verified tree. Presence alone only says an install once finished
260// here; the digest is what lets a later launch prove the entry still is what281// here; the digest is what lets a later launch prove the entry still is what
261// that install wrote.282// that install wrote.
...@@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = (...@@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = (
279 'runtime_parent="$HOME/.t3/wsl-runtime"',300 'runtime_parent="$HOME/.t3/wsl-runtime"',
280 `runtime_root="$runtime_parent/${safeRuntimeId}"`,301 `runtime_root="$runtime_parent/${safeRuntimeId}"`,
281 `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,302 `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,
282 // The native payload is the part of the tree the WSL backend actually303 // The runtime is a self-contained `t3` executable with Node inside, so the
283 // dlopens, and the only part a user can plausibly break by hand. Checking304 // readiness proof is the same one the SSH runner and the CLI installers
284 // node-pty's package.json alone let a runtime whose pty.node had gone305 // use: the file is executable and `t3 --version` exits 0. That covers the
285 // missing stay cache-ready forever: every launch reused it and then failed306 // truncated-binary and wrong-arch cases without a separate native probe.
286 // the native probe, with no reinstall and no fallback. Match on the glob307 "runtime_entry_runs() {",
287 // rather than a mapped `uname -m` so this stays a presence check; the probe308 ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1',
288 // is what decides whether the binary is the right arch and loadable.
289 "node_pty_payload_present() {",
290 ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',
291 ' [ -f "$candidate" ] || continue',
292 ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue',
293 " return 0",
294 " done",
295 " return 1",
296 "}",309 "}",
297 // Hashing the server entry is the only check that can tell a working cache310 // Hashing the entry is what tells a working cache from one whose `t3` was
298 // from one whose bin.mjs was truncated or half-written: the file is still311 // swapped or half-written after install: the file is still there and may
299 // there, the native probe still passes, and launch then picks a server that312 // even still run, and launch then picks an executable that is not what
300 // exits before it can become ready, on every restart. Hashing the ~7MB313 // this install verified. Hashing the executable measures in tens of
301 // entry measures in single-digit milliseconds inside the distro, once per314 // milliseconds inside the distro, once per launch, against a cold
302 // launch, against a cold reinstall of a few hundred megabytes.315 // reinstall of a few hundred megabytes.
303 "runtime_server_entry_digest() {",316 "runtime_server_entry_digest() {",
304 ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,317 ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`,
305 "}",318 "}",
306 "runtime_is_ready() {",319 "runtime_is_ready() {",
307 ' [ -f "$ready_marker" ] &&',320 ' [ -f "$ready_marker" ] &&',
308 ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&',321 ' runtime_entry_runs "$runtime_root" &&',
309 ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&',
310 ' node_pty_payload_present "$runtime_root" &&',
311 // An empty or unreadable marker is a miss, not a pass: that is what a322 // An empty or unreadable marker is a miss, not a pass: that is what a
312 // runtime installed before the marker carried a digest looks like, and one323 // runtime installed before the marker carried a digest looks like, and one
313 // reinstall is the cheapest way to make it verifiable from then on.324 // reinstall is the cheapest way to make it verifiable from then on.
...@@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = (...@@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = (
370 `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,381 `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
371 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',382 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',
372 "trap cleanup_runtime_install EXIT",383 "trap cleanup_runtime_install EXIT",
373 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`,384 // The release archive has one top-level `t3-<version>-linux-<arch>/`
374 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"',385 // directory; strip it so the executable lands at `$runtime_root/t3`.
375 'test -f "$runtime_tmp/node_modules/node-pty/package.json"',386 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`,
376387 // Never write the ready marker over a tree whose executable does not run.
377 // Never write the ready marker over a tree that is missing the native388 // Failing here drops out to the mounted-tree fallback, which is
378 // payload. Failing here drops out to the mounted-tree fallback, which is
379 // recoverable; promoting it would mark the defect ready and cache it.389 // recoverable; promoting it would mark the defect ready and cache it.
380 'if ! node_pty_payload_present "$runtime_tmp"; then',390 'if ! runtime_entry_runs "$runtime_tmp"; then',
381 " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2",391 " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2",
382 " exit 1",392 " exit 1",
383 "fi",393 "fi",
384 // The archive's bytes were verified against archiveSha256 above, so the394 // The archive's bytes were verified against archiveSha256 above, so the
...@@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => {...@@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => {
466};476};
467477
468// Drops the ready marker so the next launch reinstalls the runtime from the478// Drops the ready marker so the next launch reinstalls the runtime from the
469// archive. Readiness is a presence check by design, so a cached tree whose479// archive. Readiness is decided inside the install script, so a cached tree
470// native payload is present but unloadable (truncated pty.node, a distro whose480// that passes there but fails the launch-time probe (a distro whose glibc the
471// glibc the binary needs and the tree was copied from another machine) stays481// executable needs, a tree copied from another machine) would stay ready
472// ready forever and fails the probe on every launch. Only the probe can see482// forever and fail on every launch. Only the probe can see that, so the probe
473// that, so the probe is what revokes the marker. The tree itself is left in483// is what revokes the marker. The tree itself is left in place: the install
474// place: the install script moves an unready root aside before extracting.484// script moves an unready root aside before extracting.
475export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {485export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {
476 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);486 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
477 return [487 return [
...@@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => {...@@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => {
488 return runtimeRoot.startsWith("/") ? runtimeRoot : null;498 return runtimeRoot.startsWith("/") ? runtimeRoot : null;
489};499};
490500
491const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4;501// The mounted server tree carries no Linux pty.node unless the build put one
502// there. Distinct from a binary that is present but will not load, which is a
503// distro problem rather than a build problem.
504const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4;
492505
493const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>506const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
494 exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE507 exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE
495 ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support."508 ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support."
496 : null;509 : null;
497510
511// Captures the login-shell PATH as `resolvedPath:` so the launch can forward the
512// user's PATH; the server spawns provider CLIs (`codex`, `claude`) by name.
513const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`;
514
498const NODE_PTY_PROBE_SCRIPT = (515const NODE_PTY_PROBE_SCRIPT = (
499 linuxServerDir: string,516 linuxServerDir: string,
500) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"517) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"
501printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"518printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"
502printf 'resolvedPath:%s\\n' "$PATH"519${RESOLVED_PATH_LINE}
503cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1520cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1
504// The WSL Node can't read inside app.asar, so confirm what the server needs is521// The WSL Node can't read inside app.asar, so confirm what the server needs is
505// unpacked on the real filesystem before reporting the backend healthy. Exit 3522// unpacked on the real filesystem before reporting the backend healthy. Exit 3
506// marks this distinct from a node-pty prebuild problem so the caller can report523// marks this distinct from a node-pty binary problem so the caller can report
507// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at524// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at
508// launch (which, in wsl-only mode, would just fail to launch with no fallback).525// launch (which, in wsl-only mode, would just fail to launch with no fallback).
509//526//
...@@ -517,26 +534,27 @@ const fs = require("node:fs");...@@ -517,26 +534,27 @@ const fs = require("node:fs");
517const path = require("node:path");534const path = require("node:path");
518const pkgDir = path.dirname(require.resolve("node-pty/package.json"));535const pkgDir = path.dirname(require.resolve("node-pty/package.json"));
519// node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across536// node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across
520// Node versions — require() succeeding IS the real compatibility test. Compare537// Node versions — require() succeeding IS the real compatibility test. Look in
521// only arch and node-pty version (a stale binary from a different node-pty),538// the same places node-pty's own loader does.
522// NOT process.versions.modules: that would reject a perfectly loadable prebuilt539const candidates = [
523// whenever the user's WSL Node ABI differs from the build's, defeating the540 path.join(pkgDir, "build", "Release", "pty.node"),
524// whole point of shipping one prebuilt for all Node versions.541 path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"),
525const expected = {542];
526 arch: process.arch,543if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE});
527 nodePtyVersion: require("node-pty/package.json").version,
528};
529const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch);
530const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json");
531const binary = path.join(prebuildDir, "pty.node");
532if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE});
533require("node-pty");544require("node-pty");
534const actual = JSON.parse(fs.readFileSync(marker, "utf8"));
535for (const key of Object.keys(expected)) {
536 if (actual[key] !== expected[key]) process.exit(2);
537}
538NODE`;545NODE`;
539546
547// Readiness proof for a staged self-contained runtime: the executable runs and
548// reports its version, and the login shell's PATH is captured for the launch.
549// This runs under plain `sh` (no Node resolver preamble, since the runtime
550// needs no Node), so the login shell is entered explicitly for the PATH
551// capture; a distro without bash falls back to the PATH sh was started with.
552const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) =>
553 [
554 `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`,
555 `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`,
556 ].join("\n");
557
540const TOOLCHAIN_CHECK_SCRIPT = [558const TOOLCHAIN_CHECK_SCRIPT = [
541 "for tool in node make g++ python3; do",559 "for tool in node make g++ python3; do",
542 ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"',560 ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"',
...@@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) =>...@@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) =>
552 "set -e",570 "set -e",
553 `cd ${shellQuote(linuxServerDir)}`,571 `cd ${shellQuote(linuxServerDir)}`,
554 `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`,572 `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`,
555 `arch=$(node -p "process.arch")`,
556 `modules=$(node -p "process.versions.modules")`,
557 `node_pty_version=$(node -p "require('node-pty/package.json').version")`,
558 `cd "$pkg_dir"`,573 `cd "$pkg_dir"`,
559 "npx --yes node-gyp rebuild",574 "npx --yes node-gyp rebuild",
560 `prebuild_dir="prebuilds/linux-$arch"`,
561 `mkdir -p "$prebuild_dir"`,
562 `cp build/Release/pty.node "$prebuild_dir/pty.node"`,
563 `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`,
564 `node -e 'require("node-pty")'`,575 `node -e 'require("node-pty")'`,
565 ].join("\n");576 ].join("\n");
566577
...@@ -660,6 +671,38 @@ export const formatMissingToolsReason = (...@@ -660,6 +671,38 @@ export const formatMissingToolsReason = (
660 return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`;671 return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`;
661};672};
662673
674const probeWslRuntimeImpl = (
675 distro: string | null,
676 linuxAppRoot: string,
677): Effect.Effect<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> =>
678 Effect.gen(function* () {
679 const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, {
680 resolveNode: false,
681 });
682 const transportFailureReason = formatWslShellTransportFailureReason(
683 probe.transportFailure,
684 "the staged runtime",
685 );
686 if (transportFailureReason !== null) {
687 return { ok: false, reason: transportFailureReason } as const;
688 }
689 if (probe.exitCode !== 0) {
690 const trimmedTail = probe.stderr.trim().slice(-500);
691 return {
692 ok: false,
693 reason: `${linuxAppRoot}/t3 --version failed (exit ${probe.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`,
694 } as const;
695 }
696 const resolvedPath = parseResolvedPath(probe.stdout);
697 if (resolvedPath === null) {
698 return {
699 ok: false,
700 reason: "WSL login-shell PATH could not be resolved during backend preflight.",
701 } as const;
702 }
703 return { ok: true, resolvedPath } as const;
704 });
705
663const ensureNodePtyImpl = (706const ensureNodePtyImpl = (
664 distro: string | null,707 distro: string | null,
665 linuxRepoRoot: string,708 linuxRepoRoot: string,
...@@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub {...@@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub {
1133 ) => PrepareWslRuntimeResult;1176 ) => PrepareWslRuntimeResult;
1134 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;1177 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1135 readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;1178 readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1179 // Defaults to success with a plain PATH: a staged runtime that was prepared
1180 // is assumed to run unless the test says otherwise.
1181 readonly probeRuntime?: (distro: string | null, linuxAppRoot: string) => ProbeWslRuntimeResult;
1136 readonly ensureNodePty?: (1182 readonly ensureNodePty?: (
1137 distro: string | null,1183 distro: string | null,
1138 linuxAppRoot: string,1184 linuxAppRoot: string,
...@@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {...@@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
1165 pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void,1211 pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void,
1166 invalidateRuntime: (distro, runtimeId) =>1212 invalidateRuntime: (distro, runtimeId) =>
1167 stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void,1213 stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void,
1214 probeRuntime: (distro, linuxAppRoot) =>
1215 Effect.succeed(
1216 stub.probeRuntime?.(distro, linuxAppRoot) ?? { ok: true, resolvedPath: "/usr/bin:/bin" },
1217 ),
1168 ensureNodePty: (distro, linuxAppRoot, options) =>1218 ensureNodePty: (distro, linuxAppRoot, options) =>
1169 Effect.succeed(1219 Effect.succeed(
1170 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {1220 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
...@@ -1259,6 +1309,10 @@ export const layer = Layer.effect(...@@ -1259,6 +1309,10 @@ export const layer = Layer.effect(
1259 provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe(1309 provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe(
1260 Effect.withSpan("desktop.wsl.invalidateRuntime"),1310 Effect.withSpan("desktop.wsl.invalidateRuntime"),
1261 ),1311 ),
1312 probeRuntime: (distro, linuxAppRoot) =>
1313 provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe(
1314 Effect.withSpan("desktop.wsl.probeRuntime"),
1315 ),
1262 ensureNodePty: (distro, linuxAppRoot, options) =>1316 ensureNodePty: (distro, linuxAppRoot, options) =>
1263 provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe(1317 provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe(
1264 Effect.withSpan("desktop.wsl.ensureNodePty"),1318 Effect.withSpan("desktop.wsl.ensureNodePty"),
docs/operations/development.md+3-2
...@@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc...@@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc
191rustup target add aarch64-pc-windows-msvc191rustup target add aarch64-pc-windows-msvc
192```192```
193193
194NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild;194NSIS is downloaded by electron-builder. WSL support additionally needs the Linux CLI archive
195see the [release runbook](./release.md#windows-payload-topology-and-update-validation).195passed as `--wsl-runtime`; see the
196[release runbook](./release.md#windows-payload-topology-and-update-validation).
196197
197### Signing and passkeys198### Signing and passkeys
198199
scripts/build-desktop-artifact.test.ts+216-324
...@@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17import {17import {
18 BundleNotSelfContainedError,18 BundleNotSelfContainedError,
19 BuildCommandFailedError,19 BuildCommandFailedError,
20 buildWslRuntimeArchiveArgs,
21 parseWslRuntimeArchiveMembers,20 parseWslRuntimeArchiveMembers,
22 DesktopDmgBackgroundSourceMissingError,21 DesktopDmgBackgroundSourceMissingError,
23 createStageWorkspaceConfig,22 createStageWorkspaceConfig,
...@@ -88,32 +87,48 @@ import {...@@ -88,32 +87,48 @@ import {
88 WSL_RUNTIME_ARCHIVE_HASH_NAME,87 WSL_RUNTIME_ARCHIVE_HASH_NAME,
89 WSL_RUNTIME_ARCHIVE_NAME,88 WSL_RUNTIME_ARCHIVE_NAME,
90 WSL_RUNTIME_EXTRA_RESOURCES,89 WSL_RUNTIME_EXTRA_RESOURCES,
91 wslRuntimeArchiveTarTarget,90 WslRuntimeArchiveMissingError,
91 wslRuntimeArchiveStem,
92} from "./build-desktop-artifact.ts";92} from "./build-desktop-artifact.ts";
93import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";93import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
94import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";94import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
95import { symlinksSupported } from "@t3tools/shared/testing/symlinks";95import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9696
97// A minimal stand-in for the staged sidecar roots packed into the WSL archive.97// A minimal stand-in for the Linux CLI release archive: one top-level
98const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* (98// directory named after the archive stem holding the executable, the web
99 root: string,99// client, and the runtime externals with node-pty built from source.
100 serverSource: string,100const makeLinuxCliArchiveFixture = Effect.fn("test.makeLinuxCliArchiveFixture")(function* (input: {
101) {101 readonly root: string;
102 readonly stem: string;
103 readonly extraMembers?: ReadonlyArray<string>;
104 readonly omitMembers?: ReadonlyArray<string>;
105}) {
102 const fs = yield* FileSystem.FileSystem;106 const fs = yield* FileSystem.FileSystem;
103 const path = yield* Path.Path;107 const path = yield* Path.Path;
104 yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true });108 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
105 yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource);109 const contentRoot = path.join(input.root, "content");
106 yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), {110 const members = [
107 recursive: true,111 `${input.stem}/t3`,
108 });112 `${input.stem}/client/index.html`,
109 yield* fs.writeFileString(113 `${input.stem}/node_modules/node-pty/package.json`,
110 path.join(root, "node_modules/node-pty/package.json"),114 `${input.stem}/node_modules/node-pty/build/Release/pty.node`,
111 '{"name":"node-pty"}\n',115 ...(input.extraMembers ?? []),
112 );116 ].filter((member) => !(input.omitMembers ?? []).includes(member));
113 yield* fs.writeFileString(117 for (const member of members) {
114 path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"),118 const memberPath = path.join(contentRoot, member);
115 "pty",119 yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true });
120 yield* fs.writeFileString(memberPath, member);
121 }
122 const archivePath = path.join(input.root, `${input.stem}.tar.gz`);
123 const tar = yield* spawner.spawn(
124 ChildProcess.make("tar", ["-czf", archivePath, "-C", contentRoot, "."], {
125 stdin: "ignore",
126 stdout: "ignore",
127 stderr: "pipe",
128 }),
116 );129 );
130 assert.equal(Number(yield* tar.exitCode), 0);
131 return archivePath;
117});132});
118133
119function mockProcess(exitCode: number, stdout = "") {134function mockProcess(exitCode: number, stdout = "") {
...@@ -154,10 +169,12 @@ function iconResizeSpawnerLayer(...@@ -154,10 +169,12 @@ function iconResizeSpawnerLayer(
154 );169 );
155}170}
156171
172const WINDOWS_PAYLOAD_FIXTURE_VERSION = "1.2.3";
173
157const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {174const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {
158 readonly copyUnpackedNatives: boolean;175 readonly copyUnpackedNatives: boolean;
159 readonly serverEntrySource?: string;176 readonly serverEntrySource?: string;
160 readonly wslRuntime?: "valid" | "forbidden" | "bad-digest";177 readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest";
161}) {178}) {
162 const fs = yield* FileSystem.FileSystem;179 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;180 const path = yield* Path.Path;
...@@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu...@@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
195 yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");212 yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");
196213
197 if (input.wslRuntime !== undefined) {214 if (input.wslRuntime !== undefined) {
198 const wslSourceDir = path.join(tempDir, "wsl-source");215 const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64");
199 const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64");216 const sourceArchivePath =
200 yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true });217 input.wslRuntime === "loose-server-tree"
201 yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true });218 ? // The old hand-rolled runtime: apps/server/dist + node_modules at the
202 yield* fs.writeFileString(219 // archive root, no single stem directory, no `t3` executable.
203 path.join(wslSourceDir, "apps/server/dist/bin.mjs"),220 yield* makeLinuxCliArchiveFixture({
204 "console.log('wsl server');\n",221 root: path.join(tempDir, "wsl-runtime"),
205 );222 stem: "apps",
206 yield* fs.writeFileString(223 omitMembers: ["apps/t3", "apps/client/index.html"],
207 path.join(wslSourceDir, "node_modules/node-pty/package.json"),224 extraMembers: ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"],
208 '{"name":"node-pty"}',225 })
209 );226 : yield* makeLinuxCliArchiveFixture({
210 yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty");227 root: path.join(tempDir, "wsl-runtime"),
211 yield* fs.writeFileString(228 stem,
212 path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"),229 ...(input.wslRuntime === "missing-pty"
213 '{"arch":"x64"}',230 ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] }
214 );231 : {}),
215 if (input.wslRuntime === "forbidden") {232 });
216 const windowsPrebuildDir = path.join(
217 wslSourceDir,
218 "node_modules/node-pty/prebuilds/win32-x64",
219 );
220 yield* fs.makeDirectory(windowsPrebuildDir, { recursive: true });
221 yield* fs.writeFileString(path.join(windowsPrebuildDir, "pty.node"), "windows-pty");
222 }
223
224 const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME);233 const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME);
225 const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME);234 const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME);
226 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;235 yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath });
227 const tar = yield* spawner.spawn(236 if (input.wslRuntime === "bad-digest") {
228 ChildProcess.make(237 yield* fs.writeFileString(hashPath, `${"0".repeat(64)}\n`);
229 "tar",238 }
230 [
231 "-czf",
232 wslRuntimeArchiveTarTarget(path.relative(wslSourceDir, archivePath)),
233 "apps/server/dist",
234 "node_modules",
235 ],
236 { cwd: wslSourceDir, stdin: "ignore", stdout: "ignore", stderr: "pipe" },
237 ),
238 );
239 assert.equal(Number(yield* tar.exitCode), 0);
240 const archiveDigest = NodeCrypto.createHash("sha256");
241 yield* fs
242 .stream(archivePath)
243 .pipe(Stream.runForEach((chunk) => Effect.sync(() => archiveDigest.update(chunk))));
244 yield* fs.writeFileString(
245 hashPath,
246 input.wslRuntime === "bad-digest"
247 ? `${"0".repeat(64)}\n`
248 : `${archiveDigest.digest("hex")}\n`,
249 );
250 }239 }
251240
252 return {241 return {
...@@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
463 libc: ["glibc"],452 libc: ["glibc"],
464 },453 },
465 });454 });
466 // The Windows app stage only serves the desktop main process; the server455 // Windows stages only win32 natives; WSL runs the separately built Linux
467 // sidecar stage is the one that needs Linux natives (below).456 // CLI archive rather than anything installed here.
468 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {457 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {
469 supportedArchitectures: {458 supportedArchitectures: {
470 os: ["win32"],459 os: ["win32"],
471 cpu: ["x64"],460 cpu: ["x64"],
472 },461 },
473 });462 });
474 // The server sidecar stage bundles the same-architecture WSL (Linux,463 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {
475 // glibc) backend, so its install must fetch Linux native optional deps464 supportedArchitectures: {
476 // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar465 os: ["win32"],
477 // packing and runtime extraction without symlinks.466 cpu: ["arm64"],
478 assert.deepStrictEqual(
479 createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }),
480 {
481 supportedArchitectures: {
482 os: ["win32", "linux"],
483 cpu: ["x64"],
484 libc: ["glibc"],
485 },
486 nodeLinker: "hoisted",
487 },
488 );
489 assert.deepStrictEqual(
490 createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }),
491 {
492 supportedArchitectures: {
493 os: ["win32", "linux"],
494 cpu: ["arm64"],
495 libc: ["glibc"],
496 },
497 nodeLinker: "hoisted",
498 },467 },
499 );468 });
500 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {469 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
501 supportedArchitectures: {470 supportedArchitectures: {
502 os: ["darwin"],471 os: ["darwin"],
...@@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
635 undefined,604 undefined,
636 true,605 true,
637 );606 );
638 const winWithoutWslPrebuild = yield* createBuildConfig(607 const winWithoutWslRuntime = yield* createBuildConfig(
639 "win",608 "win",
640 "nsis",609 "nsis",
641 "1.2.3",610 "1.2.3",
...@@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
654 assert.notProperty(linux, "asarUnpack");623 assert.notProperty(linux, "asarUnpack");
655 assert.deepStrictEqual(win.asar, { smartUnpack: false });624 assert.deepStrictEqual(win.asar, { smartUnpack: false });
656 assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]);625 assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]);
657 assert.deepStrictEqual(winWithoutWslPrebuild.asar, win.asar);626 assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar);
658 assert.deepStrictEqual(winWithoutWslPrebuild.asarUnpack, win.asarUnpack);627 assert.deepStrictEqual(winWithoutWslRuntime.asarUnpack, win.asarUnpack);
659 assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES);628 assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES);
660 assert.deepStrictEqual(linux.extraResources, [629 assert.deepStrictEqual(linux.extraResources, [
661 ...DESKTOP_EXTRA_RESOURCES,630 ...DESKTOP_EXTRA_RESOURCES,
...@@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
670 ...WINDOWS_SERVER_EXTRA_RESOURCES,639 ...WINDOWS_SERVER_EXTRA_RESOURCES,
671 ...WSL_RUNTIME_EXTRA_RESOURCES,640 ...WSL_RUNTIME_EXTRA_RESOURCES,
672 ]);641 ]);
673 // No Linux prebuild means the sidecar staging never writes the archive,642 // No Linux CLI archive means staging never writes the runtime, so
674 // so listing it here would fail the build on a missing source file.643 // listing it here would fail the build on a missing source file.
675 assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [644 assert.deepStrictEqual(winWithoutWslRuntime.extraResources, [
676 {645 {
677 from: "apps/desktop/prod-resources/resource-monitor",646 from: "apps/desktop/prod-resources/resource-monitor",
678 to: "resource-monitor",647 to: "resource-monitor",
...@@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
707 assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]);676 assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]);
708 assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]);677 assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]);
709 assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS);678 assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS);
710 assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files);679 assert.deepStrictEqual(winWithoutWslRuntime.files, win.files);
711 assert.notProperty(mac.mac as Record<string, unknown>, "sign");680 assert.notProperty(mac.mac as Record<string, unknown>, "sign");
712 for (const config of [linux, win]) {681 for (const config of [linux, win]) {
713 assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES);682 assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES);
...@@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
819 ]);788 ]);
820 });789 });
821790
822 it.effect(791 it.effect("keeps target native files while excluding the other Windows architecture", () =>
823 "keeps target and WSL native files while excluding the other Windows architecture",792 Effect.scoped(
824 () =>793 Effect.gen(function* () {
825 Effect.scoped(794 const fs = yield* FileSystem.FileSystem;
826 Effect.gen(function* () {795 const path = yield* Path.Path;
827 const fs = yield* FileSystem.FileSystem;796 const tempDir = yield* fs.makeTempDirectoryScoped({
828 const path = yield* Path.Path;797 prefix: "t3-windows-architecture-test-",
829 const tempDir = yield* fs.makeTempDirectoryScoped({798 });
830 prefix: "t3-windows-architecture-test-",799 const sourceDir = path.join(tempDir, "server");
831 });800 const nativeFiles = [
832 const sourceDir = path.join(tempDir, "server");801 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",
833 const nativeFiles = [802 "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe",
834 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",803 "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe",
835 "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe",804 "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe",
836 "node_modules/node-pty/prebuilds/linux-x64/pty.node",805 ];
837 "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe",806
838 "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe",807 for (const nativeFile of nativeFiles) {
839 ];808 const nativePath = path.join(sourceDir, nativeFile);
840809 yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true });
841 for (const nativeFile of nativeFiles) {810 yield* fs.writeFileString(nativePath, "native");
842 const nativePath = path.join(sourceDir, nativeFile);811 }
843 yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true });
844 yield* fs.writeFileString(nativePath, "native");
845 }
846812
847 const asarPath = path.join(tempDir, "server.asar");813 const asarPath = path.join(tempDir, "server.asar");
848 yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" });814 yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" });
849 const unpackedRoot = `${asarPath}.unpacked`;815 const unpackedRoot = `${asarPath}.unpacked`;
850816
851 assert.isTrue(817 assert.isTrue(
852 yield* fs.exists(818 yield* fs.exists(
853 path.join(819 path.join(
854 unpackedRoot,820 unpackedRoot,
855 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",821 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",
856 ),
857 ),
858 );
859 assert.isTrue(
860 yield* fs.exists(
861 path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64/pty.node"),
862 ),
863 );
864 assert.isFalse(
865 yield* fs.exists(
866 path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64"),
867 ),
868 );
869 assert.isFalse(
870 yield* fs.exists(
871 path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"),
872 ),822 ),
873 );823 ),
874 }),824 );
875 ),825 assert.isFalse(
826 yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64")),
827 );
828 assert.isFalse(
829 yield* fs.exists(
830 path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"),
831 ),
832 );
833 }),
834 ),
876 );835 );
877836
878 it.effect("stages a cached resource monitor without invoking Cargo", () =>837 it.effect("stages a cached resource monitor without invoking Cargo", () =>
...@@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1205 stageDistDir: fixture.stageDistDir,1164 stageDistDir: fixture.stageDistDir,
1206 appExecutableName: fixture.appExecutableName,1165 appExecutableName: fixture.appExecutableName,
1207 targetArch: "x64",1166 targetArch: "x64",
1167 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1208 });1168 });
12091169
1210 const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar");1170 const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar");
...@@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1226 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),1186 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
1227 );1187 );
12281188
1229 it.effect("validates the emitted WSL archive and its SHA-256 sidecar", () =>1189 it.effect("accepts an embedded Linux CLI release archive with a matching digest", () =>
1230 Effect.scoped(1190 Effect.scoped(
1231 Effect.gen(function* () {1191 Effect.gen(function* () {
1232 const fixture = yield* makeWindowsPayloadFixture({1192 const fixture = yield* makeWindowsPayloadFixture({
...@@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1237 stageDistDir: fixture.stageDistDir,1197 stageDistDir: fixture.stageDistDir,
1238 appExecutableName: fixture.appExecutableName,1198 appExecutableName: fixture.appExecutableName,
1239 targetArch: "x64",1199 targetArch: "x64",
1200 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1240 expectWslRuntime: true,1201 expectWslRuntime: true,
1241 });1202 });
12421203
...@@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1245 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),1206 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
1246 );1207 );
12471208
1209 it.effect("rejects an embedded archive built for a different release version", () =>
1210 Effect.scoped(
1211 Effect.gen(function* () {
1212 const fixture = yield* makeWindowsPayloadFixture({
1213 copyUnpackedNatives: true,
1214 wslRuntime: "valid",
1215 });
1216 const error = yield* validateWindowsPackagedPayload({
1217 stageDistDir: fixture.stageDistDir,
1218 appExecutableName: fixture.appExecutableName,
1219 targetArch: "x64",
1220 appVersion: "9.9.9",
1221 expectWslRuntime: true,
1222 }).pipe(Effect.flip);
1223
1224 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
1225 assert.equal(error.reason, "wsl-runtime-invalid");
1226 }),
1227 ),
1228 );
1229
1248 it.effect("rejects a Windows package missing its expected WSL runtime", () =>1230 it.effect("rejects a Windows package missing its expected WSL runtime", () =>
1249 Effect.scoped(1231 Effect.scoped(
1250 Effect.gen(function* () {1232 Effect.gen(function* () {
...@@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1253 stageDistDir: fixture.stageDistDir,1235 stageDistDir: fixture.stageDistDir,
1254 appExecutableName: fixture.appExecutableName,1236 appExecutableName: fixture.appExecutableName,
1255 targetArch: "x64",1237 targetArch: "x64",
1238 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1256 expectWslRuntime: true,1239 expectWslRuntime: true,
1257 }).pipe(Effect.flip);1240 }).pipe(Effect.flip);
12581241
...@@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1262 ),1245 ),
1263 );1246 );
12641247
1265 it.effect("rejects forbidden native members in the emitted WSL archive", () =>1248 it.effect("rejects a loose server tree that is not a Linux CLI release archive", () =>
1249 Effect.scoped(
1250 Effect.gen(function* () {
1251 const fixture = yield* makeWindowsPayloadFixture({
1252 copyUnpackedNatives: true,
1253 wslRuntime: "loose-server-tree",
1254 });
1255 const error = yield* validateWindowsPackagedPayload({
1256 stageDistDir: fixture.stageDistDir,
1257 appExecutableName: fixture.appExecutableName,
1258 targetArch: "x64",
1259 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1260 expectWslRuntime: true,
1261 }).pipe(Effect.flip);
1262
1263 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
1264 assert.equal(error.reason, "wsl-runtime-invalid");
1265 }),
1266 ),
1267 );
1268
1269 it.effect("rejects an embedded archive without the Linux node-pty binary", () =>
1266 Effect.scoped(1270 Effect.scoped(
1267 Effect.gen(function* () {1271 Effect.gen(function* () {
1268 const fixture = yield* makeWindowsPayloadFixture({1272 const fixture = yield* makeWindowsPayloadFixture({
1269 copyUnpackedNatives: true,1273 copyUnpackedNatives: true,
1270 wslRuntime: "forbidden",1274 wslRuntime: "missing-pty",
1271 });1275 });
1272 const error = yield* validateWindowsPackagedPayload({1276 const error = yield* validateWindowsPackagedPayload({
1273 stageDistDir: fixture.stageDistDir,1277 stageDistDir: fixture.stageDistDir,
1274 appExecutableName: fixture.appExecutableName,1278 appExecutableName: fixture.appExecutableName,
1275 targetArch: "x64",1279 targetArch: "x64",
1280 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1276 expectWslRuntime: true,1281 expectWslRuntime: true,
1277 }).pipe(Effect.flip);1282 }).pipe(Effect.flip);
12781283
1279 assert.instanceOf(error, WindowsPackagedPayloadValidationError);1284 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
1280 assert.equal(error.reason, "wsl-runtime-invalid");1285 assert.equal(error.reason, "wsl-runtime-invalid");
1286 assert.deepStrictEqual(error.missingFiles, [
1287 `${wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64")}/node_modules/node-pty/build/Release/pty.node`,
1288 ]);
1281 }),1289 }),
1282 ),1290 ),
1283 );1291 );
...@@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1293 stageDistDir: fixture.stageDistDir,1301 stageDistDir: fixture.stageDistDir,
1294 appExecutableName: fixture.appExecutableName,1302 appExecutableName: fixture.appExecutableName,
1295 targetArch: "x64",1303 targetArch: "x64",
1304 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1296 expectWslRuntime: true,1305 expectWslRuntime: true,
1297 }).pipe(Effect.flip);1306 }).pipe(Effect.flip);
12981307
...@@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1327 stageDistDir: fixture.stageDistDir,1336 stageDistDir: fixture.stageDistDir,
1328 appExecutableName: fixture.appExecutableName,1337 appExecutableName: fixture.appExecutableName,
1329 targetArch: "x64",1338 targetArch: "x64",
1339 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1330 });1340 });
13311341
1332 const primaryProbe = commands.find(1342 const primaryProbe = commands.find(
...@@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1466 stageDistDir: fixture.stageDistDir,1476 stageDistDir: fixture.stageDistDir,
1467 appExecutableName: fixture.appExecutableName,1477 appExecutableName: fixture.appExecutableName,
1468 targetArch: "arm64",1478 targetArch: "arm64",
1479 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1469 });1480 });
14701481
1471 assert.isFalse(1482 assert.isFalse(
...@@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1502 stageDistDir: fixture.stageDistDir,1513 stageDistDir: fixture.stageDistDir,
1503 appExecutableName: fixture.appExecutableName,1514 appExecutableName: fixture.appExecutableName,
1504 targetArch: "arm64",1515 targetArch: "arm64",
1516 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1505 }).pipe(Effect.flip);1517 }).pipe(Effect.flip);
15061518
1507 assert.instanceOf(error, WindowsPrimaryNativeProbeError);1519 assert.instanceOf(error, WindowsPrimaryNativeProbeError);
...@@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1525 stageDistDir: fixture.stageDistDir,1537 stageDistDir: fixture.stageDistDir,
1526 appExecutableName: fixture.appExecutableName,1538 appExecutableName: fixture.appExecutableName,
1527 targetArch: "x64",1539 targetArch: "x64",
1540 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1528 }).pipe(Effect.flip);1541 }).pipe(Effect.flip);
15291542
1530 assert.instanceOf(error, WindowsPackagedPayloadValidationError);1543 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
...@@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1553 stageDistDir: fixture.stageDistDir,1566 stageDistDir: fixture.stageDistDir,
1554 appExecutableName: fixture.appExecutableName,1567 appExecutableName: fixture.appExecutableName,
1555 targetArch: "x64",1568 targetArch: "x64",
1569 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1556 }).pipe(Effect.flip);1570 }).pipe(Effect.flip);
1557 assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError);1571 assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError);
1558 assert.equal(nativeError.reason, "unpacked-native-missing");1572 assert.equal(nativeError.reason, "unpacked-native-missing");
...@@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1573 stageDistDir: fixture.stageDistDir,1587 stageDistDir: fixture.stageDistDir,
1574 appExecutableName: fixture.appExecutableName,1588 appExecutableName: fixture.appExecutableName,
1575 targetArch: "x64",1589 targetArch: "x64",
1590 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1576 }).pipe(Effect.flip);1591 }).pipe(Effect.flip);
1577 assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError);1592 assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError);
1578 assert.equal(resourceMonitorError.reason, "resource-monitor-missing");1593 assert.equal(resourceMonitorError.reason, "resource-monitor-missing");
...@@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1591 stageDistDir: fixture.stageDistDir,1606 stageDistDir: fixture.stageDistDir,
1592 appExecutableName: fixture.appExecutableName,1607 appExecutableName: fixture.appExecutableName,
1593 targetArch: "x64",1608 targetArch: "x64",
1609 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1594 fileLimit: 2,1610 fileLimit: 2,
1595 }).pipe(Effect.flip);1611 }).pipe(Effect.flip);
15961612
...@@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1612 stageDistDir: fixture.stageDistDir,1628 stageDistDir: fixture.stageDistDir,
1613 appExecutableName: fixture.appExecutableName,1629 appExecutableName: fixture.appExecutableName,
1614 targetArch: "x64",1630 targetArch: "x64",
1631 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
1615 }).pipe(Effect.flip);1632 }).pipe(Effect.flip);
16161633
1617 assert.instanceOf(error, BundleNotSelfContainedError);1634 assert.instanceOf(error, BundleNotSelfContainedError);
...@@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1923 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");1940 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");
1924 });1941 });
19251942
1926 it("packages the WSL server and production dependencies as one compressed runtime", () => {1943 it("ships the Linux CLI release archive as the WSL runtime", () => {
1927 assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz");1944 assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz");
1928 assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256");1945 assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256");
1929 assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, {1946 assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, {
...@@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1934 from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",1951 from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",
1935 to: "wsl-runtime.tar.gz.sha256",1952 to: "wsl-runtime.tar.gz.sha256",
1936 });1953 });
1937 // The archive is only usable alongside a Linux pty.node, so both the1954 // Both the staging and the packaging config hang off this one decision:
1938 // staging and the packaging config hang off this one decision.1955 // Windows only, and only when CI handed the build a Linux CLI archive.
1939 assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" }));1956 const runtimeArchivePath = "/tmp/t3-1.2.3-linux-x64.tar.gz";
1940 assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" }));1957 assert.isTrue(bundlesWslRuntime({ platform: "win", runtimeArchivePath }));
1941 assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined }));1958 assert.isFalse(bundlesWslRuntime({ platform: "win", runtimeArchivePath: undefined }));
1942 assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" }));1959 assert.isFalse(bundlesWslRuntime({ platform: "linux", runtimeArchivePath }));
19431960 assert.isFalse(bundlesWslRuntime({ platform: "mac", runtimeArchivePath }));
1944 assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [1961 assert.equal(wslRuntimeArchiveStem("1.2.3", "x64"), "t3-1.2.3-linux-x64");
1945 "-czf",
1946 "apps/desktop/prod-resources/wsl-runtime.tar.gz",
1947 "--exclude=node_modules/@anthropic-ai/claude-agent-sdk-*",
1948 "--exclude=node_modules/.bin*",
1949 "--exclude=node_modules/.pnpm*",
1950 "--exclude=node_modules/.modules.yaml*",
1951 "--exclude=node_modules/.pnpm-workspace-state-v1.json*",
1952 "--exclude=node_modules/node-pty/prebuilds/darwin-*",
1953 "--exclude=node_modules/node-pty/prebuilds/win32-*",
1954 "--exclude=node_modules/node-pty/build*",
1955 "--exclude=node_modules/node-pty/third_party/conpty*",
1956 "--exclude=node_modules/@ff-labs/fff-bin-win32-*",
1957 "--exclude=node_modules/@yuuang/ffi-rs-win32-*",
1958 "--exclude=node_modules/@msgpackr-extract/msgpackr-extract-win32-*",
1959 "apps/server/dist",
1960 "node_modules",
1961 ]);
1962 });1962 });
19631963
1964 it("parses Windows bsdtar member listings with CRLF line endings", () => {1964 it("parses Windows bsdtar member listings with CRLF line endings", () => {
1965 assert.deepStrictEqual(1965 assert.deepStrictEqual(
1966 parseWslRuntimeArchiveMembers(1966 parseWslRuntimeArchiveMembers("./t3-1.2.3-linux-x64/t3\r\nt3-1.2.3-linux-x64/client/\r\n"),
1967 "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n",1967 ["t3-1.2.3-linux-x64/t3", "t3-1.2.3-linux-x64/client"],
1968 ),
1969 ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"],
1970 );1968 );
1971 });1969 });
19721970
1973 it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => {1971 it.effect("stages the Linux CLI archive verbatim with its SHA-256 sidecar", () =>
1974 assert.equal(1972 Effect.scoped(
1975 wslRuntimeArchiveTarTarget("..\\app\\apps\\desktop\\prod-resources\\wsl-runtime.tar.gz"),
1976 "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz",
1977 );
1978 assert.equal(
1979 wslRuntimeArchiveTarTarget("../app/apps/desktop/prod-resources/wsl-runtime.tar.gz"),
1980 "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz",
1981 );
1982 });
1983
1984 // The staged source tree and the archive live in sibling stage directories,
1985 // so this covers the real call: on Windows the archive path is an absolute
1986 // C:\... path, and handing that to tar is what made Git's GNU tar try to
1987 // reach a host named "C".
1988 it.effect("spawns tar with an archive target relative to the staged source tree", () => {
1989 const commands: Array<{
1990 readonly command: string;
1991 readonly args: ReadonlyArray<string>;
1992 readonly options: { readonly cwd?: string };
1993 }> = [];
1994
1995 return Effect.scoped(
1996 Effect.gen(function* () {1973 Effect.gen(function* () {
1997 const fs = yield* FileSystem.FileSystem;1974 const fs = yield* FileSystem.FileSystem;
1998 const path = yield* Path.Path;1975 const path = yield* Path.Path;
1999 const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" });1976 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-stage-" });
2000 const sourceDir = path.join(stageRoot, "server");1977 const sourceArchivePath = yield* makeLinuxCliArchiveFixture({
2001 const stageAppDir = path.join(stageRoot, "app");1978 root,
1979 stem: "t3-1.2.3-linux-x64",
1980 });
1981 const stageAppDir = path.join(root, "app");
2002 const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from);1982 const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from);
2003 const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from);1983 const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from);
2004 yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n");
2005
2006 const spawnerLayer = Layer.succeed(
2007 ChildProcessSpawner.ChildProcessSpawner,
2008 ChildProcessSpawner.make((command) => {
2009 const childProcess = command as unknown as (typeof commands)[number];
2010 commands.push(childProcess);
2011 // Stand in for tar: write the archive by resolving the -f target
2012 // against the cwd tar was spawned in, exactly as tar would.
2013 const target = path.resolve(childProcess.options.cwd ?? "", childProcess.args[1] ?? "");
2014 return Effect.as(fs.writeFileString(target, "wsl-runtime-archive"), mockProcess(0));
2015 }),
2016 );
2017
2018 yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }).pipe(
2019 Effect.provide(spawnerLayer),
2020 );
20211984
2022 const tarCommand = commands.find((command) => command.command === "tar");1985 yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath });
2023 if (tarCommand === undefined) return assert.fail("tar was not spawned");
20241986
2025 const target = tarCommand.args[1] ?? "";1987 const [source, staged] = yield* Effect.all([
2026 assert.equal(tarCommand.options.cwd, sourceDir);1988 fs.readFile(sourceArchivePath),
2027 assert.notInclude(target, ":");1989 fs.readFile(archivePath),
2028 assert.isFalse(path.isAbsolute(target));1990 ]);
2029 // Relative or not, tar has to land the archive where the build expects it.1991 assert.deepStrictEqual(staged, source);
2030 assert.equal(path.resolve(sourceDir, target), archivePath);1992 // The digest both gates installation inside the distro and names the
2031 assert.isTrue(yield* fs.exists(archivePath));1993 // extracted runtime's cache directory.
2032
2033 // The archive digest both gates installation and names the cache.
2034 const hash = yield* fs.readFileString(hashPath);1994 const hash = yield* fs.readFileString(hashPath);
2035 assert.match(hash.trim(), /^[0-9a-f]{64}$/);1995 assert.equal(hash, `${NodeCrypto.createHash("sha256").update(source).digest("hex")}\n`);
2036 }),1996 }),
2037 );1997 ),
2038 });1998 );
20391999
2040 it.effect("ships only Linux runtime members in the WSL archive", () =>2000 it.effect("fails when the Linux CLI archive handed to the build does not exist", () =>
2041 Effect.scoped(2001 Effect.scoped(
2042 Effect.gen(function* () {2002 Effect.gen(function* () {
2043 const fs = yield* FileSystem.FileSystem;2003 const fs = yield* FileSystem.FileSystem;
2044 const path = yield* Path.Path;2004 const path = yield* Path.Path;
2045 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;2005 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-missing-" });
2046 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" });2006 const error = yield* stageWslRuntimeArchive({
2047 const sourceDir = path.join(root, "server");2007 sourceArchivePath: path.join(root, "t3-1.2.3-linux-x64.tar.gz"),
2048 const archivePath = path.join(root, "wsl-runtime.tar.gz");2008 archivePath: path.join(root, WSL_RUNTIME_ARCHIVE_NAME),
2049 const hashPath = `${archivePath}.sha256`;2009 hashPath: path.join(root, WSL_RUNTIME_ARCHIVE_HASH_NAME),
2050 yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n");2010 }).pipe(Effect.flip);
2051
2052 const members = [
2053 "node_modules/node-pty/prebuilds/darwin-x64/pty.node",
2054 "node_modules/node-pty/prebuilds/win32-x64/pty.node",
2055 "node_modules/node-pty/build/Release/pty.node",
2056 "node_modules/node-pty/third_party/conpty/win10-x64/conpty.dll",
2057 "node_modules/@ff-labs/fff-bin-win32-x64/fff.dll",
2058 "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so",
2059 "node_modules/@yuuang/ffi-rs-win32-x64-msvc/ffi.dll",
2060 "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so",
2061 "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/addon.node",
2062 "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node",
2063 "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/index.js",
2064 "node_modules/.bin/tool",
2065 "node_modules/.pnpm/lock.yaml",
2066 "node_modules/.modules.yaml",
2067 "node_modules/.pnpm-workspace-state-v1.json",
2068 ] as const;
2069 yield* Effect.forEach(
2070 members,
2071 (member) =>
2072 Effect.gen(function* () {
2073 const memberPath = path.join(sourceDir, member);
2074 yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true });
2075 yield* fs.writeFileString(memberPath, member);
2076 }),
2077 { discard: true },
2078 );
2079
2080 yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath });
2081 const process = yield* spawner.spawn(
2082 ChildProcess.make("tar", ["-tzf", archivePath], {
2083 stdin: "ignore",
2084 stdout: "pipe",
2085 stderr: "pipe",
2086 }),
2087 );
2088 const listing = yield* process.stdout.pipe(
2089 Stream.decodeText(),
2090 Stream.runFold(
2091 () => "",
2092 (output, chunk) => output + chunk,
2093 ),
2094 );
2095 assert.equal(Number(yield* process.exitCode), 0);
20962011
2097 assert.include(listing, "apps/server/dist/bin.mjs");2012 assert.instanceOf(error, WslRuntimeArchiveMissingError);
2098 assert.include(listing, "node_modules/node-pty/prebuilds/linux-x64/pty.node");
2099 assert.include(listing, "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so");
2100 assert.include(listing, "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so");
2101 assert.include(
2102 listing,
2103 "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node",
2104 );
2105 for (const excluded of [
2106 "prebuilds/darwin-",
2107 "prebuilds/win32-",
2108 "node-pty/build",
2109 "third_party/conpty",
2110 "fff-bin-win32-",
2111 "ffi-rs-win32-",
2112 "msgpackr-extract-win32-",
2113 "claude-agent-sdk-",
2114 "node_modules/.bin",
2115 "node_modules/.pnpm",
2116 "node_modules/.modules.yaml",
2117 "node_modules/.pnpm-workspace-state-v1.json",
2118 ]) {
2119 assert.notInclude(listing, excluded);
2120 }
2121 }),2013 }),
2122 ),2014 ),
2123 );2015 );
...@@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
2228 verbose: Option.none(),2120 verbose: Option.none(),
2229 mockUpdates: Option.none(),2121 mockUpdates: Option.none(),
2230 mockUpdateServerPort: Option.none(),2122 mockUpdateServerPort: Option.none(),
2231 wslPrebuild: Option.none(),2123 wslRuntime: Option.none(),
2232 }).pipe(2124 }).pipe(
2233 Effect.provide(2125 Effect.provide(
2234 Layer.mergeAll(2126 Layer.mergeAll(
...@@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
2268 verbose: Option.none(),2160 verbose: Option.none(),
2269 mockUpdates: Option.none(),2161 mockUpdates: Option.none(),
2270 mockUpdateServerPort: Option.none(),2162 mockUpdateServerPort: Option.none(),
2271 wslPrebuild: Option.none(),2163 wslRuntime: Option.none(),
2272 }),2164 }),
2273 );2165 );
22742166
...@@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
2292 verbose: Option.some(false),2184 verbose: Option.some(false),
2293 mockUpdates: Option.some(false),2185 mockUpdates: Option.some(false),
2294 mockUpdateServerPort: Option.none(),2186 mockUpdateServerPort: Option.none(),
2295 wslPrebuild: Option.none(),2187 wslRuntime: Option.none(),
2296 }).pipe(2188 }).pipe(
2297 Effect.provide(2189 Effect.provide(
2298 ConfigProvider.layer(2190 ConfigProvider.layer(
scripts/build-desktop-artifact.ts+117-255
...@@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe(...@@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe(
89);89);
90const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));90const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
91const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));91const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
92const decodeNodePtyManifest = Schema.decodeUnknownEffect(
93 Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
94);
95const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));92const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
9693
97const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {94const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
...@@ -164,7 +161,7 @@ interface BuildCliInput {...@@ -164,7 +161,7 @@ interface BuildCliInput {
164 readonly verbose: Option.Option<boolean>;161 readonly verbose: Option.Option<boolean>;
165 readonly mockUpdates: Option.Option<boolean>;162 readonly mockUpdates: Option.Option<boolean>;
166 readonly mockUpdateServerPort: Option.Option<number>;163 readonly mockUpdateServerPort: Option.Option<number>;
167 readonly wslPrebuild: Option.Option<string>;164 readonly wslRuntime: Option.Option<string>;
168}165}
169166
170function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {167function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
...@@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [...@@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [
399 id: "msvc",396 id: "msvc",
400 description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries",397 description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries",
401 },398 },
402 { id: "tar", description: "tar for the bundled WSL runtime" },399 { id: "tar", description: "tar to inspect the bundled WSL runtime archive" },
403] as const;400] as const;
404401
405export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()(402export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()(
...@@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<Des...@@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<Des
670 }667 }
671}668}
672669
673export class WslNodePtyPrebuildMissingError extends Schema.TaggedError<WslNodePtyPrebuildMissingError>()(670export class WslRuntimeArchiveMissingError extends Schema.TaggedError<WslRuntimeArchiveMissingError>()(
674 "WslNodePtyPrebuildMissingError",671 "WslRuntimeArchiveMissingError",
675 {672 {
676 prebuildPath: Schema.String,673 archivePath: Schema.String,
677 },674 },
678) {675) {
679 override get message(): string {676 override get message(): string {
680 return `WSL node-pty prebuild not found at ${this.prebuildPath}.`;677 return `WSL runtime archive not found at ${this.archivePath}.`;
681 }678 }
682}679}
683680
...@@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<Wi...@@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<Wi
754 }751 }
755}752}
756753
757export class WslNodePtyManifestReadError extends Schema.TaggedError<WslNodePtyManifestReadError>()(
758 "WslNodePtyManifestReadError",
759 {
760 manifestPath: Schema.String,
761 cause: Schema.Defect(),
762 },
763) {
764 override get message(): string {
765 return `Could not read node-pty version from ${this.manifestPath}.`;
766 }
767}
768
769export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()(754export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()(
770 "LinuxIconResizeError",755 "LinuxIconResizeError",
771 {756 {
...@@ -933,7 +918,7 @@ interface ResolvedBuildOptions {...@@ -933,7 +918,7 @@ interface ResolvedBuildOptions {
933 readonly verbose: boolean;918 readonly verbose: boolean;
934 readonly mockUpdates: boolean;919 readonly mockUpdates: boolean;
935 readonly mockUpdateServerPort: number | undefined;920 readonly mockUpdateServerPort: number | undefined;
936 readonly wslPrebuild: string | undefined;921 readonly wslRuntime: string | undefined;
937}922}
938923
939interface StagePackageJson {924interface StagePackageJson {
...@@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) {...@@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) {
1007// then extracts a handful of large archives instead of thousands of small992// then extracts a handful of large archives instead of thousands of small
1008// files, which dominates install (and update) time. The Windows primary runs993// files, which dominates install (and update) time. The Windows primary runs
1009// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE994// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE
1010// runtime. WSL normally uses the dedicated compressed Linux runtime below;995// runtime. WSL does not use this sidecar: it runs the Linux CLI archive
1011// DesktopWslServerTree can still materialize this sidecar as a fallback.996// embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME).
1012export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";997export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
1013// dlopen/spawn need real files, so native modules, shared libraries, and998// dlopen/spawn need real files, so native modules, shared libraries, and
1014// helper executables live in each archive's .unpacked sibling (the standard999// helper executables live in each archive's .unpacked sibling (the standard
...@@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = {...@@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = {
1064 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`,1049 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`,
1065 to: WSL_RUNTIME_ARCHIVE_HASH_NAME,1050 to: WSL_RUNTIME_ARCHIVE_HASH_NAME,
1066} as const;1051} as const;
1067export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const;1052
10681053// The WSL runtime is the Linux CLI release archive (t3-<version>-linux-<arch>
1069// The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar.1054// .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL
1070// Keep build/install metadata and target-native packages that cannot run in1055// runs the exact bytes a Linux user downloads. This one predicate decides both
1071// WSL out of the compressed archive.1056// whether the archive is staged and whether the packaging config ships it:
1072export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [1057// listing an extraResource whose source was never written fails electron-builder.
1073 "node_modules/@anthropic-ai/claude-agent-sdk-",
1074 "node_modules/.bin",
1075 "node_modules/.pnpm",
1076 "node_modules/.modules.yaml",
1077 "node_modules/.pnpm-workspace-state-v1.json",
1078 "node_modules/node-pty/prebuilds/darwin-",
1079 "node_modules/node-pty/prebuilds/win32-",
1080 "node_modules/node-pty/build",
1081 "node_modules/node-pty/third_party/conpty",
1082 "node_modules/@ff-labs/fff-bin-win32-",
1083 "node_modules/@yuuang/ffi-rs-win32-",
1084 "node_modules/@msgpackr-extract/msgpackr-extract-win32-",
1085] as const;
1086// WSL runs the same CPU arch as the Windows host; universal is mac-only.
1087export const resolveWslPrebuildArch = (arch: typeof BuildArch.Type): "x64" | "arm64" | undefined =>
1088 arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : undefined;
1089
1090// A packaged WSL runtime is only usable when a Linux pty.node is bundled with
1091// it, so this one predicate decides both whether the archive is built and
1092// whether the packaging config ships it. Without it the build would produce an
1093// archive that can never pass the install script's payload check, and every
1094// launch would extract a few hundred MB from /mnt/c only to throw it away.
1095export const bundlesWslRuntime = (input: {1058export const bundlesWslRuntime = (input: {
1096 readonly arch: typeof BuildArch.Type;1059 readonly platform: typeof BuildPlatform.Type;
1097 readonly prebuildPath: string | undefined;1060 readonly runtimeArchivePath: string | undefined;
1098}): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined;1061}): boolean => input.platform === "win" && input.runtimeArchivePath !== undefined;
10991062
1100export const WSL_RUNTIME_EXTRA_RESOURCES = [1063export const WSL_RUNTIME_EXTRA_RESOURCES = [
1101 WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE,1064 WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE,
...@@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: {...@@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: {
1515 readonly allowBuilds?: Record<string, boolean>;1478 readonly allowBuilds?: Record<string, boolean>;
1516 readonly patchedDependencies?: Record<string, string>;1479 readonly patchedDependencies?: Record<string, string>;
1517 readonly overrides?: Record<string, string>;1480 readonly overrides?: Record<string, string>;
1518 // The Windows server sidecar stage runs both the Windows primary and the
1519 // WSL Linux backend from one dependency tree, so it needs win32 + linux
1520 // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical,
1521 // symlink-free) node_modules: the tree gets packed into server.asar and
1522 // later extracted for WSL, and neither step can rely on pnpm's
1523 // symlink/junction layout surviving the trip.
1524 readonly linuxServerBackend?: boolean;
1525}): StageWorkspaceConfig {1481}): StageWorkspaceConfig {
1526 const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input;1482 const { platform, arch, allowBuilds, patchedDependencies, overrides } = input;
1527 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";1483 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
1528 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];1484 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];
1529 // Linux AppImages execute a Linux/glibc Node process that loads1485 // Linux AppImages execute a Linux/glibc Node process that loads
...@@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: {...@@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: {
1536 cpu: hostCpu,1492 cpu: hostCpu,
1537 libc: ["glibc"],1493 libc: ["glibc"],
1538 }1494 }
1539 : linuxServerBackend1495 : {
1540 ? {1496 os: [hostOs],
1541 os: Array.from(new Set([hostOs, "linux"])),1497 cpu: hostCpu,
1542 cpu: hostCpu,1498 };
1543 libc: ["glibc"],
1544 }
1545 : {
1546 os: [hostOs],
1547 cpu: hostCpu,
1548 };
15491499
1550 return {1500 return {
1551 supportedArchitectures,1501 supportedArchitectures,
...@@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: {...@@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: {
1554 ? { patchedDependencies }1504 ? { patchedDependencies }
1555 : {}),1505 : {}),
1556 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),1506 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1557 ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}),
1558 };1507 };
1559}1508}
15601509
...@@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({...@@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({
1600 verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)),1549 verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)),
1601 mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),1550 mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),
1602 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),1551 mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option),
1603 // Path to a prebuilt Linux node-pty binary (pty.node) for the target arch,1552 // Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) built
1604 // produced by the Linux CI job and handed to the Windows packaging job. Placed1553 // by the build_linux_cli CI job. The Windows build embeds it verbatim as the
1605 // into the staged node-pty so the WSL backend ships a ready binary and never1554 // WSL runtime.
1606 // compiles on the user's machine.1555 wslRuntime: Config.string("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option),
1607 wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option),
1608});1556});
16091557
1610const MockUpdateServerPortSchema = Schema.NumberFromString.check(1558const MockUpdateServerPortSchema = Schema.NumberFromString.check(
...@@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (...@@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
1696 ),1644 ),
1697 ));1645 ));
16981646
1699 const wslPrebuild =1647 const wslRuntime =
1700 Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild);1648 Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime);
17011649
1702 return {1650 return {
1703 platform,1651 platform,
...@@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (...@@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
1711 verbose,1659 verbose,
1712 mockUpdates,1660 mockUpdates,
1713 mockUpdateServerPort,1661 mockUpdateServerPort,
1714 wslPrebuild,1662 wslRuntime,
1715 } satisfies ResolvedBuildOptions;1663 } satisfies ResolvedBuildOptions;
1716});1664});
17171665
...@@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (...@@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
2683 readonly provisioningProfilePath: string;2631 readonly provisioningProfilePath: string;
2684 }2632 }
2685 | undefined,2633 | undefined,
2686 // Windows only, and false when no Linux node-pty prebuild was bundled: the2634 // Windows only, and false when no Linux CLI archive was handed to the build:
2687 // sidecar staging skips the archive in that case, and listing a resource2635 // staging skips the archive in that case, and listing a resource whose
2688 // whose source file was never written fails the electron-builder step.2636 // source file was never written fails the electron-builder step.
2689 wslRuntimeBundled = false,2637 wslRuntimeBundled = false,
2690 arch?: typeof BuildArch.Type,2638 arch?: typeof BuildArch.Type,
2691) {2639) {
...@@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f...@@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f
2850 }2798 }
2851});2799});
28522800
2853// Stage the prebuilt Linux node-pty binary into the packaged app so the WSL2801// Copy the Linux CLI release archive into the stage verbatim and record its
2854// backend never compiles on the user's machine. node-pty publishes no Linux2802// SHA-256 beside it. The desktop app verifies the digest inside the distro
2855// prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the2803// before extracting, and the digest also names the extracted runtime's cache
2856// Linux CI job builds pty.node and hands it here. We drop it into the staged2804// directory, so it must be computed from the exact bytes that ship.
2857// node-pty's prebuilds/linux-<arch>/ with a t3code marker the WSL preflight
2858// checks (arch + node-pty version; the binary is N-API, hence ABI-stable across
2859// Node versions). A missing prebuild is a warning, not an error, so local and
2860// non-Windows builds still succeed — they just won't ship a working WSL backend.
2861const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* (input: {
2862 readonly stageAppDir: string;
2863 readonly arch: typeof BuildArch.Type;
2864 readonly prebuildPath: string | undefined;
2865}) {
2866 const fs = yield* FileSystem.FileSystem;
2867 const path = yield* Path.Path;
2868
2869 if (input.prebuildPath === undefined) {
2870 yield* Effect.logWarning(
2871 "[desktop-artifact] No WSL node-pty prebuild provided (--wsl-prebuild / T3CODE_DESKTOP_WSL_PREBUILD); the packaged WSL backend will not start until a Linux pty.node is bundled.",
2872 );
2873 return;
2874 }
2875
2876 const linuxArch = resolveWslPrebuildArch(input.arch);
2877 if (linuxArch === undefined) {
2878 yield* Effect.logWarning(
2879 `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`,
2880 );
2881 return;
2882 }
2883
2884 const prebuildExists = yield* fs
2885 .exists(input.prebuildPath)
2886 .pipe(Effect.orElseSucceed(() => false));
2887 if (!prebuildExists) {
2888 return yield* new WslNodePtyPrebuildMissingError({
2889 prebuildPath: input.prebuildPath,
2890 });
2891 }
2892
2893 // Resolve through the (pnpm) symlink so we write into the stage's own node-pty
2894 // copy, never a shared content-addressable store.
2895 const nodePtyLink = path.join(input.stageAppDir, "node_modules", "node-pty");
2896 const nodePtyDir = yield* fs.realPath(nodePtyLink).pipe(Effect.orElseSucceed(() => nodePtyLink));
2897
2898 const manifestPath = path.join(nodePtyDir, "package.json");
2899 const pkgRaw = yield* fs.readFileString(manifestPath);
2900 const manifest = yield* decodeNodePtyManifest(pkgRaw).pipe(
2901 Effect.mapError(
2902 (cause) =>
2903 new WslNodePtyManifestReadError({
2904 manifestPath,
2905 cause,
2906 }),
2907 ),
2908 );
2909 const nodePtyVersion = manifest.version;
2910
2911 const prebuildDir = path.join(nodePtyDir, "prebuilds", `linux-${linuxArch}`);
2912 yield* fs.makeDirectory(prebuildDir, { recursive: true });
2913 yield* fs.copyFile(input.prebuildPath, path.join(prebuildDir, "pty.node"));
2914 const markerJson = yield* encodeJsonString({ arch: linuxArch, nodePtyVersion });
2915 yield* fs.writeFileString(path.join(prebuildDir, "t3code-wsl-node-pty.json"), `${markerJson}\n`);
2916
2917 yield* Effect.log(
2918 `[desktop-artifact] Staged WSL node-pty prebuild (linux-${linuxArch}, node-pty ${nodePtyVersion}).`,
2919 );
2920});
2921
2922// tar reads an `-f` target containing a colon as `host:path` and tries to reach
2923// it over rsh, so handing it a Windows drive path (C:\...\wsl-runtime.tar.gz)
2924// makes Git for Windows' GNU tar fail with "Cannot connect to C: resolve
2925// failed". The staged source tree and the archive both live under the build's
2926// stage root, so the target is always expressible relative to tar's cwd.
2927export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string =>
2928 relativeArchivePath.replaceAll("\\", "/");
2929
2930// `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget.
2931export const buildWslRuntimeArchiveArgs = (
2932 archivePath: string = WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from,
2933): ReadonlyArray<string> => [
2934 "-czf",
2935 archivePath,
2936 ...WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.map((prefix) => `--exclude=${prefix}*`),
2937 ...WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS,
2938];
2939
2940export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> =>
2941 listing
2942 .split(/\r?\n/)
2943 .map((member) => member.replace(/^\.\//, "").replace(/\/$/, ""))
2944 .filter((member) => member.length > 0);
2945
2946export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: {2805export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: {
2947 readonly sourceDir: string;2806 readonly sourceArchivePath: string;
2948 readonly archivePath: string;2807 readonly archivePath: string;
2949 readonly hashPath: string;2808 readonly hashPath: string;
2950}) {2809}) {
2951 const fs = yield* FileSystem.FileSystem;2810 const fs = yield* FileSystem.FileSystem;
2952 const path = yield* Path.Path;2811 const path = yield* Path.Path;
2812 const sourceExists = yield* fs
2813 .exists(input.sourceArchivePath)
2814 .pipe(Effect.orElseSucceed(() => false));
2815 if (!sourceExists) {
2816 return yield* new WslRuntimeArchiveMissingError({ archivePath: input.sourceArchivePath });
2817 }
2953 yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true });2818 yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true });
2954 const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath));2819 yield* fs.copyFile(input.sourceArchivePath, input.archivePath);
2955 yield* runCommand(
2956 ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), {
2957 cwd: input.sourceDir,
2958 }),
2959 { label: "tar WSL runtime", verbose: false },
2960 );
2961 const hash = NodeCrypto.createHash("sha256");2820 const hash = NodeCrypto.createHash("sha256");
2962 yield* fs2821 yield* fs
2963 .stream(input.archivePath)2822 .stream(input.archivePath)
...@@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi...@@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi
2965 const digest = hash.digest("hex");2824 const digest = hash.digest("hex");
2966 yield* fs.writeFileString(input.hashPath, `${digest}\n`);2825 yield* fs.writeFileString(input.hashPath, `${digest}\n`);
2967 yield* Effect.log(2826 yield* Effect.log(
2968 `[desktop-artifact] Staged compressed WSL runtime at ${input.archivePath} (${digest}).`,2827 `[desktop-artifact] Staged WSL runtime archive at ${input.archivePath} (${digest}).`,
2969 );2828 );
2970});2829});
29712830
2831// Mirrors cliArchiveStem in scripts/build-cli-archive.ts (which imports from
2832// this module, so it cannot be imported here). WSL runs the same CPU arch as
2833// the Windows host.
2834export const wslRuntimeArchiveStem = (version: string, arch: typeof BuildArch.Type): string =>
2835 `t3-${version}-linux-${arch}`;
2836
2837export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray<string> =>
2838 listing
2839 .split(/\r?\n/)
2840 .map((member) => member.replace(/^\.\//, "").replace(/\/$/, ""))
2841 .filter((member) => member.length > 0);
2842
2972// Stage and pack the Windows server sidecar: the bundled server plus a hoisted2843// Stage and pack the Windows server sidecar: the bundled server plus a hoisted
2973// install of only its runtime-external/native dependency closure for win32 and2844// install of only its runtime-external/native dependency closure for win32.
2974// WSL Linux. The Windows primary runs from the archive through the asar-aware2845// The Windows primary runs from the archive through the asar-aware
2975// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory.2846// ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of
2976// Shipping one packed archive instead of thousands of loose files is what2847// thousands of loose files is what makes the NSIS install/update fast.
2977// makes the NSIS install/update fast.
2978export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: {2848export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: {
2979 readonly sourceDir: string;2849 readonly sourceDir: string;
2980 readonly asarPath: string;2850 readonly asarPath: string;
...@@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(...@@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
3025 readonly allowBuilds: Record<string, boolean>;2895 readonly allowBuilds: Record<string, boolean>;
3026 readonly patchedDependencies: Record<string, string>;2896 readonly patchedDependencies: Record<string, string>;
3027 readonly overrides: Record<string, string>;2897 readonly overrides: Record<string, string>;
3028 readonly wslPrebuildPath: string | undefined;
3029 readonly asarPath: string;2898 readonly asarPath: string;
3030 readonly wslRuntimeArchivePath: string;
3031 readonly wslRuntimeArchiveHashPath: string;
3032 readonly verbose: boolean;2899 readonly verbose: boolean;
3033}) {2900}) {
3034 const fs = yield* FileSystem.FileSystem;2901 const fs = yield* FileSystem.FileSystem;
...@@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(...@@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
30402907
3041 const sidecarDependencies = {2908 const sidecarDependencies = {
3042 ...input.runtimeExternalDependencies,2909 ...input.runtimeExternalDependencies,
3043 // The sidecar serves two processes: the Windows primary loads win32
3044 // natives, and the WSL backend loads the matching Linux natives (fff via
3045 // ffi-rs) from the extracted copy of this same tree.
3046 ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion),2910 ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion),
3047 ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion),
3048 };2911 };
3049 const sidecarPatchedDependencies = createStagePatchedDependencies(2912 const sidecarPatchedDependencies = createStagePatchedDependencies(
3050 input.patchedDependencies,2913 input.patchedDependencies,
...@@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(...@@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
3062 path.join(serverStageDir, "package.json"),2925 path.join(serverStageDir, "package.json"),
3063 `${sidecarPackageJsonString}\n`,2926 `${sidecarPackageJsonString}\n`,
3064 );2927 );
3065 const sidecarWorkspaceConfig = createStageWorkspaceConfig({2928 const sidecarWorkspaceConfig = {
3066 platform: "win",2929 ...createStageWorkspaceConfig({
3067 arch: input.arch,2930 platform: "win",
3068 allowBuilds: input.allowBuilds,2931 arch: input.arch,
3069 patchedDependencies: sidecarPatchedDependencies,2932 allowBuilds: input.allowBuilds,
3070 overrides: input.overrides,2933 patchedDependencies: sidecarPatchedDependencies,
3071 linuxServerBackend: true,2934 overrides: input.overrides,
3072 });2935 }),
2936 // The tree gets packed into server.asar, which cannot carry pnpm's
2937 // symlink/junction layout, so install a physical, hoisted node_modules.
2938 nodeLinker: "hoisted" as const,
2939 };
3073 const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig);2940 const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig);
3074 yield* fs.writeFileString(2941 yield* fs.writeFileString(
3075 path.join(serverStageDir, "pnpm-workspace.yaml"),2942 path.join(serverStageDir, "pnpm-workspace.yaml"),
...@@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(...@@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
3089 { label: "vp install --prod (server sidecar)", verbose: input.verbose },2956 { label: "vp install --prod (server sidecar)", verbose: input.verbose },
3090 );2957 );
30912958
3092 yield* stageWslNodePtyPrebuild({
3093 stageAppDir: serverStageDir,
3094 arch: input.arch,
3095 prebuildPath: input.wslPrebuildPath,
3096 });
3097 // Skip the archive entirely rather than shipping one the install script must
3098 // extract and reject on every launch. The desktop app treats a missing
3099 // archive as "no WSL-local runtime" and goes straight to the mounted tree.
3100 if (bundlesWslRuntime({ arch: input.arch, prebuildPath: input.wslPrebuildPath })) {
3101 yield* stageWslRuntimeArchive({
3102 sourceDir: serverStageDir,
3103 archivePath: input.wslRuntimeArchivePath,
3104 hashPath: input.wslRuntimeArchiveHashPath,
3105 });
3106 }
3107
3108 yield* Effect.log("[desktop-artifact] Packing server.asar...");2959 yield* Effect.log("[desktop-artifact] Packing server.asar...");
3109 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });2960 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
3110 yield* packWindowsServerAsar({2961 yield* packWindowsServerAsar({
...@@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn(
3248 readonly stageDistDir: string;3099 readonly stageDistDir: string;
3249 readonly appExecutableName: string;3100 readonly appExecutableName: string;
3250 readonly targetArch: typeof BuildArch.Type;3101 readonly targetArch: typeof BuildArch.Type;
3102 // The version the embedded Linux CLI archive must carry; its top-level
3103 // directory is named t3-<version>-linux-<arch>.
3104 readonly appVersion: string;
3251 readonly expectWslRuntime?: boolean;3105 readonly expectWslRuntime?: boolean;
3252 readonly fileLimit?: number;3106 readonly fileLimit?: number;
3253 readonly verbose?: boolean;3107 readonly verbose?: boolean;
...@@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn(
3411 );3265 );
3412 }3266 }
3413 const members = parseWslRuntimeArchiveMembers(listing.stdout);3267 const members = parseWslRuntimeArchiveMembers(listing.stdout);
3414 const forbiddenMember = members.find((member) =>3268 // A release archive unpacks to one directory named after its stem; the
3415 WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)),3269 // desktop app's WSL install script relies on that layout to find `t3`.
3416 );3270 const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch);
3417 if (forbiddenMember !== undefined) {3271 const topLevel = new Set(members.map((member) => member.split("/")[0]));
3272 if (topLevel.size !== 1 || !topLevel.has(stem)) {
3418 return yield* invalidWslRuntime(3273 return yield* invalidWslRuntime(
3419 new Error(`WSL runtime archive contains forbidden member ${forbiddenMember}`),3274 new Error(
3275 `WSL runtime archive must contain a single top-level directory ${stem}, found ${[...topLevel].join(", ") || "nothing"}`,
3276 ),
3420 );3277 );
3421 }3278 }
3422 const wslArch = resolveWslPrebuildArch(input.targetArch);
3423 const requiredMembers = [3279 const requiredMembers = [
3424 "apps/server/dist/bin.mjs",3280 `${stem}/t3`,
3425 "node_modules/node-pty/package.json",3281 `${stem}/client`,
3426 ...(wslArch === undefined3282 `${stem}/node_modules`,
3427 ? []3283 `${stem}/node_modules/node-pty/build/Release/pty.node`,
3428 : [
3429 `node_modules/node-pty/prebuilds/linux-${wslArch}/pty.node`,
3430 `node_modules/node-pty/prebuilds/linux-${wslArch}/t3code-wsl-node-pty.json`,
3431 ]),
3432 ];3284 ];
3433 const missingMembers = requiredMembers.filter((member) => !members.includes(member));3285 const missingMembers = requiredMembers.filter((member) => !members.includes(member));
3434 if (missingMembers.length > 0) {3286 if (missingMembers.length > 0) {
...@@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn(...@@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn(
3436 reason: "wsl-runtime-invalid",3288 reason: "wsl-runtime-invalid",
3437 packagedAppDir,3289 packagedAppDir,
3438 missingFiles: missingMembers,3290 missingFiles: missingMembers,
3439 cause: new Error("WSL runtime archive is incomplete"),3291 cause: new Error("WSL runtime archive is not a Linux CLI release archive"),
3440 });3292 });
3441 }3293 }
3294 // The CLI archive runs the single-executable, never a loose server bundle.
3295 const bundleEntry = members.find((member) => member.endsWith("/bin.mjs"));
3296 if (bundleEntry !== undefined) {
3297 return yield* invalidWslRuntime(
3298 new Error(`WSL runtime archive contains a server bundle entry ${bundleEntry}`),
3299 );
3300 }
3442 }3301 }
34433302
3444 const fileCount = yield* countPayloadFiles(packagedAppDir);3303 const fileCount = yield* countPayloadFiles(packagedAppDir);
...@@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3487 yield* preflightWindowsDesktopBuild({3346 yield* preflightWindowsDesktopBuild({
3488 arch: options.arch,3347 arch: options.arch,
3489 bundlesWslRuntime: bundlesWslRuntime({3348 bundlesWslRuntime: bundlesWslRuntime({
3490 arch: options.arch,3349 platform: options.platform,
3491 prebuildPath: options.wslPrebuild,3350 runtimeArchivePath: options.wslRuntime,
3492 }),3351 }),
3493 });3352 });
3494 }3353 }
...@@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3807 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,3666 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
3808 }3667 }
3809 : undefined,3668 : undefined,
3810 bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }),3669 bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }),
3811 options.arch,3670 options.arch,
3812 ),3671 ),
3813 dependencies: stageDependencies,3672 dependencies: stageDependencies,
...@@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3847 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);3706 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
3848 yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch);3707 yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch);
38493708
3850 // WSL is Windows-only, so only the Windows artifact carries the server3709 // Only the Windows artifact carries the server sidecar and the WSL runtime;
3851 // sidecar (which embeds the Linux node-pty prebuild); other platforms3710 // other platforms ignore the --wsl-runtime input.
3852 // ignore the prebuild input.
3853 if (options.platform === "win" && windowsServerAsarPath) {3711 if (options.platform === "win" && windowsServerAsarPath) {
3854 yield* stageWindowsServerSidecar({3712 yield* stageWindowsServerSidecar({
3855 stageRoot,3713 stageRoot,
...@@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3862 allowBuilds: workspaceAllowBuilds,3720 allowBuilds: workspaceAllowBuilds,
3863 patchedDependencies: workspacePatchedDependencies,3721 patchedDependencies: workspacePatchedDependencies,
3864 overrides: resolvedOverrides,3722 overrides: resolvedOverrides,
3865 wslPrebuildPath: options.wslPrebuild,
3866 asarPath: windowsServerAsarPath,3723 asarPath: windowsServerAsarPath,
3867 wslRuntimeArchivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from),
3868 wslRuntimeArchiveHashPath: path.join(
3869 stageAppDir,
3870 WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from,
3871 ),
3872
3873 verbose: options.verbose,3724 verbose: options.verbose,
3874 });3725 });
3875 }3726 }
3727 if (
3728 options.wslRuntime !== undefined &&
3729 bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime })
3730 ) {
3731 yield* stageWslRuntimeArchive({
3732 sourceArchivePath: options.wslRuntime,
3733 archivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from),
3734 hashPath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from),
3735 });
3736 }
38763737
3877 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")3738 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")
3878 // as enabled, so copy the host env and scrub empty values instead of relying3739 // as enabled, so copy the host env and scrub empty values instead of relying
...@@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3968 stageDistDir,3829 stageDistDir,
3969 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,3830 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
3970 targetArch: options.arch,3831 targetArch: options.arch,
3832 appVersion,
3971 expectWslRuntime: bundlesWslRuntime({3833 expectWslRuntime: bundlesWslRuntime({
3972 arch: options.arch,3834 platform: options.platform,
3973 prebuildPath: options.wslPrebuild,3835 runtimeArchivePath: options.wslRuntime,
3974 }),3836 }),
3975 verbose: options.verbose,3837 verbose: options.verbose,
3976 });3838 });
...@@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {...@@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
4055 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),3917 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
4056 Flag.optional,3918 Flag.optional,
4057 ),3919 ),
4058 wslPrebuild: Flag.string("wsl-prebuild").pipe(3920 wslRuntime: Flag.string("wsl-runtime").pipe(
4059 Flag.withDescription(3921 Flag.withDescription(
4060 "Path to a prebuilt Linux node-pty (pty.node) for the target arch, staged for the WSL backend (env: T3CODE_DESKTOP_WSL_PREBUILD).",3922 "Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) to embed as the WSL runtime of a Windows build (env: T3CODE_DESKTOP_WSL_RUNTIME).",
4061 ),3923 ),
4062 Flag.optional,3924 Flag.optional,
4063 ),3925 ),