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:
354354 # job — the Windows artifact then ships a ready WSL backend binary with no
355355 # cross-compiling and no first-launch compiler/node-gyp/network on the user's
356356 # machine. node-pty is N-API, so one binary works across all WSL Node versions.
357 build_wsl_node_pty:
358 name: Build WSL node-pty (linux-x64)
357 # The Linux CLI archive is built ahead of the desktop matrix because two
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)
359363 # Same gating as relay_public_config: only the release commit is needed, so
360364 # this runs alongside preflight. See the condition comment there.
361 needs: [resolve_commit]
362 if: |
363 needs.resolve_commit.result == 'success' &&
364 (github.event_name != 'schedule' || needs.resolve_commit.outputs.has_changes == 'true')
365 runs-on: blacksmith-8vcpu-ubuntu-2404
366 timeout-minutes: 15
365 needs: [resolve_commit, preflight, relay_public_config]
366 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
367 runs-on: blacksmith-32vcpu-ubuntu-2404
368 timeout-minutes: 20
369 env:
370 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 }}
367374 steps:
368375 - name: Checkout
369376 uses: actions/checkout@v6
370377 with:
371 ref: ${{ needs.resolve_commit.outputs.ref }}
378 ref: ${{ needs.preflight.outputs.ref }}
372379 sparse-checkout: |
373380 /*
374381 !/.repos/
......@@ -382,38 +389,92 @@ jobs:
382389 run-install: |
383390 args:
384391 - --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 prebuild
418 - name: Load relay client tracing config
387419 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
388443 run: |
389444 set -euo pipefail
390 # Resolve node-pty from apps/server (where it's a dependency) and build
391 # its native binary from source for Linux. node-addon-api resolves from
392 # node-pty's own dependency tree, so node-gyp has everything it needs.
393 pty_pkg="$(node -e "console.log(require.resolve('node-pty/package.json', { paths: ['$GITHUB_WORKSPACE/apps/server'] }))")"
394 pty_dir="$(dirname "$pty_pkg")"
395 ( cd "$pty_dir" && npx --yes node-gyp rebuild )
396 mkdir -p wsl-prebuild
397 cp "$pty_dir/build/Release/pty.node" wsl-prebuild/pty.node
398 file wsl-prebuild/pty.node
399
400 - name: Upload node-pty linux-x64 prebuild
445 target_dir="$RUNNER_TEMP/cli-resource-monitor/linux-x64"
446 mkdir -p "$target_dir"
447 cp native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor "$target_dir/"
448
449 - name: Build CLI archive
450 run: |
451 node scripts/build-cli-archive.ts \
452 --platform linux \
453 --arch x64 \
454 --version "${{ needs.preflight.outputs.version }}" \
455 --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
401462 uses: actions/upload-artifact@v7
402463 with:
403 name: wsl-node-pty-x64
404 path: wsl-prebuild/pty.node
464 name: cli-linux-x64
465 path: release-cli/*
405466 if-no-files-found: error
406467
407468 build:
408469 name: Build ${{ matrix.label }}
409 # build_wsl_node_pty stays in `needs` so it runs first and its artifact is
410 # available to download, but only the Windows matrix entry consumes it. We
411 # therefore gate the job on preflight + relay (must succeed) WITHOUT requiring
412 # build_wsl_node_pty, so a failed Linux prebuild doesn't skip the macOS/Linux
413 # builds. `!cancelled()` (not `!failure()`) lets the job run even when
414 # build_wsl_node_pty failed; the Windows-only download step below then fails
415 # that single platform if the prebuild is missing.
416 needs: [preflight, relay_public_config, build_wsl_node_pty]
470 # build_linux_cli stays in `needs` so it runs first and its artifact is
471 # available to download, but only the Windows matrix entry consumes it (as
472 # the WSL runtime). The job is gated on preflight + relay WITHOUT requiring
473 # build_linux_cli, so a failed Linux archive doesn't skip the macOS builds.
474 # `!cancelled()` (not `!failure()`) lets the job run even when
475 # build_linux_cli failed; the Windows-only download step below then fails
476 # that single platform if the archive is missing.
477 needs: [preflight, relay_public_config, build_linux_cli]
417478 if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }}
418479 runs-on: ${{ matrix.runner }}
419480 timeout-minutes: 30
......@@ -448,6 +509,7 @@ jobs:
448509 rust_target: x86_64-apple-darwin
449510 resource_key: darwin-x64
450511 cli_archive: false
512 # The Linux CLI archive is produced by build_linux_cli, not here.
451513 - label: Linux x64
452514 runner: blacksmith-32vcpu-ubuntu-2404
453515 platform: linux
......@@ -455,7 +517,7 @@ jobs:
455517 arch: x64
456518 rust_target: x86_64-unknown-linux-gnu
457519 resource_key: linux-x64
458 cli_archive: true
520 cli_archive: false
459521 - label: Windows x64
460522 runner: blacksmith-32vcpu-windows-2025
461523 platform: win
......@@ -553,12 +615,14 @@ jobs:
553615 - name: Align package versions to release version
554616 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
555617
556 - name: Download WSL node-pty prebuild
618 # 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
557621 if: matrix.platform == 'win'
558 uses: actions/download-artifact@v7
622 uses: actions/download-artifact@v8
559623 with:
560 name: wsl-node-pty-x64
561 path: wsl-prebuild
624 name: cli-linux-x64
625 path: wsl-runtime
562626
563627 - name: Install Spectre-mitigated MSVC libs
564628 if: matrix.platform == 'win'
......@@ -720,10 +784,9 @@ jobs:
720784 echo "macOS signing disabled (missing one or more Apple signing secrets)."
721785 fi
722786 elif [[ "${{ matrix.platform }}" == "win" ]]; then
723 # Bundle the Linux node-pty binary built by the build_wsl_node_pty job
724 # so the packaged WSL backend ships a ready binary (no first-launch
725 # compile). Required for a working WSL backend on Windows.
726 args+=(--wsl-prebuild "$GITHUB_WORKSPACE/wsl-prebuild/pty.node")
787 # Embed the Linux CLI archive built by build_linux_cli as the WSL
788 # runtime. Required for a working WSL backend on Windows.
789 args+=(--wsl-runtime "$GITHUB_WORKSPACE"/wsl-runtime/t3-*-linux-x64.tar.gz)
727790 if has_all \
728791 "$AZURE_TENANT_ID" \
729792 "$AZURE_CLIENT_ID" \
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+44-57
......@@ -374,9 +374,10 @@ describe("DesktopBackendConfiguration", () => {
374374 runtimeId: string;
375375 sha256: string;
376376 }> = [];
377 const observedNodePtyRoots: string[] = [];
377 const observedProbeRoots: string[] = [];
378378 let legacyCleanupCount = 0;
379379 const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64";
380 const resolvedPath = "/home/test/.local/bin:/usr/bin:/bin";
380381
381382 return withPackagedWslHarness(
382383 {
......@@ -394,9 +395,14 @@ describe("DesktopBackendConfiguration", () => {
394395 });
395396 return { ok: true, linuxAppRoot };
396397 },
397 ensureNodePty: (_distro, root) => {
398 observedNodePtyRoots.push(root);
399 return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
398 probeRuntime: (_distro, root) => {
399 observedProbeRoots.push(root);
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");
400406 },
401407 }),
402408 },
......@@ -413,12 +419,22 @@ describe("DesktopBackendConfiguration", () => {
413419 sha256: archiveHash,
414420 },
415421 ]);
416 assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]);
422 assert.deepEqual(observedProbeRoots, [linuxAppRoot]);
417423 assert.equal(
418424 config.entryPath,
419425 path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"),
420426 );
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");
422438 assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`);
423439 assert.equal(legacyCleanupCount, 1);
424440 assert.isTrue(Option.isNone(config.preflightFailure));
......@@ -457,8 +473,11 @@ describe("DesktopBackendConfiguration", () => {
457473
458474 assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]);
459475 assert.equal(first.wslRuntimeId, observedRuntimeIds[0]);
476 assert.include(first.args, `/runtime/sha256-${firstHash}/t3`);
460477 assert.equal(second.wslRuntimeId, observedRuntimeIds[1]);
478 assert.include(second.args, `/runtime/sha256-${secondHash}/t3`);
461479 assert.isUndefined(invalidIdentity.wslRuntimeId);
480 assert.include(invalidIdentity.args, "/usr/bin/node");
462481 assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
463482 }),
464483 );
......@@ -484,6 +503,7 @@ describe("DesktopBackendConfiguration", () => {
484503
485504 assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);
486505 assert.equal(config.entryPath, mountedEntryPath);
506 assert.include(config.args, "/usr/bin/node");
487507 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
488508 assert.isUndefined(config.wslRuntimeId);
489509 assert.isTrue(Option.isNone(config.preflightFailure));
......@@ -491,9 +511,10 @@ describe("DesktopBackendConfiguration", () => {
491511 );
492512 });
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", () => {
495515 const archiveHash = "c".repeat(64);
496516 const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`;
517 const observedProbeRoots: string[] = [];
497518 const observedNodePtyRoots: string[] = [];
498519 const invalidatedRuntimeIds: string[] = [];
499520 return withPackagedWslHarness(
......@@ -505,11 +526,13 @@ describe("DesktopBackendConfiguration", () => {
505526 Effect.sync(() => {
506527 invalidatedRuntimeIds.push(runtimeId);
507528 }),
529 probeRuntime: (_distro, root) => {
530 observedProbeRoots.push(root);
531 return { ok: false, reason: `${root}/t3 --version failed (exit 127)` };
532 },
508533 ensureNodePty: (_distro, root) => {
509534 observedNodePtyRoots.push(root);
510 return root === stagedAppRoot
511 ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
512 : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
535 return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
513536 },
514537 }),
515538 },
......@@ -518,8 +541,11 @@ describe("DesktopBackendConfiguration", () => {
518541 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
519542 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");
522547 assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
548 assert.notInclude(config.args, `${stagedAppRoot}/t3`);
523549 assert.equal(config.entryPath, mountedEntryPath);
524550 assert.isUndefined(config.wslRuntimeId);
525551 assert.isTrue(Option.isNone(config.preflightFailure));
......@@ -540,12 +566,13 @@ describe("DesktopBackendConfiguration", () => {
540566 Effect.sync(() => {
541567 invalidatedRuntimeIds.push(runtimeId);
542568 }),
543 ensureNodePty: (_distro, root) => ({
569 probeRuntime: () => ({
544570 ok: false,
545 reason:
546 root === stagedAppRoot
547 ? "unsupported CPU architecture or incompatible system libraries"
548 : "mounted tree is broken in some other way",
571 reason: "unsupported CPU architecture or incompatible system libraries",
572 }),
573 ensureNodePty: () => ({
574 ok: false,
575 reason: "mounted tree is broken in some other way",
549576 fatal: true,
550577 }),
551578 }),
......@@ -575,51 +602,11 @@ describe("DesktopBackendConfiguration", () => {
575602 Effect.sync(() => {
576603 invalidatedRuntimeIds.push(runtimeId);
577604 }),
578 ensureNodePty: (_distro, root) =>
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 }),
605 probeRuntime: () => ({ ok: false, reason: "t3 --version failed (exit 1)" }),
618606 ensureNodePty: () => ({
619607 ok: false,
620608 reason: "WSL backend preflight timed out while probing for Node.js.",
621609 fatal: false,
622 retryLimit: 12,
623610 }),
624611 }),
625612 },
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
9595
9696const 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
98103const backendChildEnvPatch = (): Record<string, string | undefined> =>
99104 Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));
100105
......@@ -210,18 +215,31 @@ interface SharedBootstrapInput {
210215 readonly observabilitySettings: BackendObservabilitySettings;
211216}
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
213236interface WslPreflightSuccess {
214237 readonly _tag: "Ready";
215238 readonly runningDistro: string;
216239 readonly windowsEntryPath: string;
217 readonly linuxEntryPath: string;
218 // Absolute path to the node binary the preflight validated after the shared
219 // remote resolver repaired PATH. The launch must use this exact path so it
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.
240 readonly runtime: WslPreflightRuntime;
241 // PATH captured from the user's login shell. The launch forwards this value
242 // directly without a shell so the server can spawn provider CLIs by name.
225243 readonly resolvedPath: string;
226244 // Identifies the distro-local runtime cache selected from the packaged archive.
227245 readonly runtimeId?: string;
......@@ -355,39 +373,36 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
355373 // fatal verdict the cached reason is the more actionable one to report.
356374 // A transient mounted failure is neither — it rules nothing out, so it stays
357375 // retryable and the staged verdict waits for an attempt that can answer.
358 let stagedFailure:
359 | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult }
360 | undefined;
376 let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined;
377 const failedStaged = (failure: { readonly reason: string }) =>
378 ({
379 _tag: "Failed",
380 reason: `WSL runtime unavailable: ${failure.reason}`,
381 fatal: true,
382 }) as const;
361383
362384 if (input.runtimeArchive !== null) {
363385 const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
364386 if (runtime.ok) {
365 const stagedNodePty = yield* wslEnv.ensureNodePty(
366 runningDistro,
367 runtime.linuxAppRoot,
368 nodePtyOptions,
369 );
370 if (stagedNodePty.ok) {
387 // The staged runtime is self-contained, so the only question is whether
388 // it runs here; there is no Node to find or node-pty to load.
389 const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot);
390 if (stagedProbe.ok) {
371391 yield* wslServerTree.cleanupLegacy;
372392 return {
373393 _tag: "Ready",
374394 runningDistro,
375395 windowsEntryPath: environment.backendEntryPath,
376 linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`,
377 nodePath: stagedNodePty.nodePath,
378 resolvedPath: stagedNodePty.resolvedPath,
396 runtime: { kind: "executable", entryPath: `${runtime.linuxAppRoot}/t3` },
397 resolvedPath: stagedProbe.resolvedPath,
379398 runtimeId: input.runtimeArchive.runtimeId,
380399 } as const;
381400 }
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);
386401 yield* Effect.logWarning(
387 "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.",
388 { reason: stagedNodePty.reason },
402 "The staged WSL runtime did not start; retrying from the mounted server tree.",
403 { reason: stagedProbe.reason },
389404 );
390 stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty };
405 stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, reason: stagedProbe.reason };
391406 } else {
392407 yield* Effect.logWarning(
393408 "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
399414 const mounted = yield* resolveMountedAppRoot;
400415 if (!mounted.ok) {
401416 return stagedFailure && mounted.fatal
402 ? failedNodePty(stagedFailure.nodePty)
417 ? failedStaged(stagedFailure)
403418 : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);
404419 }
405420
......@@ -413,9 +428,9 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
413428 // turn a retryable failure into a fatal one, ending the WSL attempt (and,
414429 // in wsl-only mode, persisting Windows) before the slow /mnt path had a
415430 // chance to answer and clear the bad cache.
416 return failedNodePty(
417 stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult,
418 );
431 return stagedFailure && nodePtyResult.fatal
432 ? failedStaged(stagedFailure)
433 : failedNodePty(nodePtyResult);
419434 }
420435
421436 // 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
429444 _tag: "Ready",
430445 runningDistro,
431446 windowsEntryPath: mounted.windowsEntryPath,
432 linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
433 nodePath: nodePtyResult.nodePath,
447 runtime: {
448 kind: "node-script",
449 nodePath: nodePtyResult.nodePath,
450 linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
451 },
434452 resolvedPath: nodePtyResult.resolvedPath,
435453 } as const;
436454});
......@@ -610,13 +628,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
610628 runtimeId: `sha256-${archiveHash}`,
611629 sha256: archiveHash,
612630 },
613 // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
614 // attached to the Windows artifact — see build-desktop-artifact.ts), so the
615 // WSL backend never needs a compiler, node-gyp, or network on first launch.
616 // Compiling from source is a dev-only convenience: a checkout has no shipped
617 // prebuilt, and developers have the toolchain. In packaged builds we instead
618 // surface a clear diagnostic if the prebuilt can't load (unsupported
619 // arch/distro), rather than silently dropping into a fragile runtime build.
631 // Packaged builds run the self-contained Linux runtime and, on fallback,
632 // whatever Linux node-pty the mounted tree carries, so the WSL backend never
633 // needs a compiler, node-gyp, or network on first launch. Compiling from
634 // source is a dev-only convenience: a checkout has no Linux binary, and
635 // developers have the toolchain. In packaged builds we instead surface a
636 // clear diagnostic if the binary can't load (unsupported arch/distro),
637 // rather than silently dropping into a fragile runtime build.
620638 allowBuild: !environment.isPackaged,
621639 });
622640
......@@ -709,15 +727,23 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
709727
710728 // The WSL server spawns commands its providers reference by name — `npm`/`npx`
711729 // 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 on
730 // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on
713731 // the process PATH, so `npm install -g ...` fails with NotFound. Pass the
714 // user PATH entries captured by the login-shell preflight. Every dynamic
715 // value is a separate argv entry under `wsl.exe --exec`; no shell command is
716 // involved, so Windows cannot mangle nested quotes and stdin remains reserved
717 // for the bootstrap envelope.
718 const lastSlash = preflight.nodePath.lastIndexOf("/");
719 const nodeBinDir = lastSlash > 0 ? preflight.nodePath.slice(0, lastSlash) : "/usr/bin";
720 const launchPath = `${nodeBinDir}:${WSL_SERVER_SYSTEM_PATH}:${preflight.resolvedPath}`;
732 // user PATH entries captured by the preflight. Every dynamic value is a
733 // separate argv entry under `wsl.exe --exec`; no shell command is involved,
734 // so Windows cannot mangle nested quotes and stdin remains reserved for the
735 // bootstrap envelope. A node-script runtime additionally leads with the
736 // probed Node's bin dir so the server cannot pick up a different node than
737 // the one node-pty was probed with.
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
722748 return {
723749 ...baseConfig,
......@@ -726,8 +752,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
726752 "--exec",
727753 "env",
728754 `PATH=${launchPath}`,
729 preflight.nodePath,
730 preflight.linuxEntryPath,
755 ...command,
731756 "--bootstrap-fd",
732757 "0",
733758 ...devUrlArgs,
apps/desktop/src/wsl/DesktopWslEnvironment.test.ts+42-45
......@@ -74,7 +74,9 @@ const readField = (stdout: string, field: string) => {
7474 return line.slice(field.length + 1).trim();
7575};
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
7981const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
8082 ChildProcessSpawner.make(() =>
......@@ -164,21 +166,22 @@ describe("WSL runtime cache", () => {
164166
165167 expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"');
166168 expect(script).toContain(' [ -f "$ready_marker" ] &&');
167 expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&');
168 expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&');
169 expect(script).toContain(' node_pty_payload_present "$runtime_root"');
169 expect(script).toContain(' runtime_entry_runs "$runtime_root" &&');
170170 expect(script).toContain("if runtime_is_ready; then");
171 expect(script).not.toContain("bin.mjs");
172 expect(script).not.toContain("node-pty");
171173 expect(script).toContain("trap 'exit 1' HUP INT TERM");
172174 expect(script).toContain('exec 9> "$runtime_lock"');
173175 expect(script).toContain("flock -x 9");
174176 expect(script).not.toContain('rm -rf "$runtime_lock"');
175177 expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"');
176178 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`.
177181 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",
179183 );
180 expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"');
181 expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"');
184 expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then');
182185 expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"');
183186 expect(script).not.toContain('rm -rf "$runtime_root"');
184187
......@@ -248,46 +251,39 @@ describe("WSL runtime cache", () => {
248251 expect(deleted).toBeGreaterThan(kept);
249252 });
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", () => {
252255 const script = buildWslRuntimeInstallScript(
253256 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
254257 "1.2.3-x64",
255258 "b".repeat(64),
256259 );
257260
258 // A glob, not a mapped `uname -m`: this is a presence check, and the later
259 // native probe is what judges arch and loadability.
260 expect(script).toContain(
261 ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',
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');
261 // The same proof the SSH runner and CLI installers use: executable, and
262 // `--version` exits 0. That is what decides arch and loadability, so no
263 // separate native probe is needed.
264 expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1');
266265
267 // Readiness gates the short-circuit, so a cache missing the payload
266 // Readiness gates the short-circuit, so a cache whose executable broke
268267 // 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() {");
270269 const readinessDefined = script.indexOf("runtime_is_ready() {");
271270 const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
272 expect(payloadCheckDefined).toBeGreaterThan(-1);
273 expect(payloadCheckDefined).toBeLessThan(readinessDefined);
271 expect(entryCheckDefined).toBeGreaterThan(-1);
272 expect(entryCheckDefined).toBeLessThan(readinessDefined);
274273 expect(readinessDefined).toBeLessThan(readyShortCircuit);
275274 });
276275
277 // A truncated or half-written bin.mjs passes every presence check the cache
278 // had: the file exists, node-pty still loads, and launch then picks a server
279 // that exits before it becomes ready — forever, because nothing ever
280 // reinstalls. The digest the install records is what turns that into a miss.
281 it("re-hashes the server entry against the digest the install recorded", () => {
276 // A swapped or half-written `t3` can still exist and even still answer
277 // `--version`, and launch then runs something this install never verified.
278 // The digest the install records is what turns that into a miss.
279 it("re-hashes the executable against the digest the install recorded", () => {
282280 const script = buildWslRuntimeInstallScript(
283281 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
284282 "1.2.3-x64",
285283 "b".repeat(64),
286284 );
287285
288 expect(script).toContain(
289 ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,
290 );
286 expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`);
291287 expect(script).toContain(
292288 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
293289 );
......@@ -310,18 +306,18 @@ describe("WSL runtime cache", () => {
310306 expect(promoted).toBeGreaterThan(markerWritten);
311307 });
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", () => {
314310 const script = buildWslRuntimeInstallScript(
315311 "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
316312 "1.2.3-x64",
317313 "b".repeat(64),
318314 );
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
322318 // The extracted tree is rejected before the ready marker is written, so a
323319 // 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"');
325321 const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
326322 const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
327323 expect(payloadValidated).toBeGreaterThan(-1);
......@@ -351,7 +347,7 @@ describe("WSL runtime cache", () => {
351347 it("never deletes a runtime another backend is running from", () => {
352348 const script = buildWslRuntimePruneScript("1.2.3/x64");
353349
354 // The running backend's argv holds `<runtime>/apps/server/dist/bin.mjs`, so
350 // The running backend's argv holds `<runtime>/t3`, so
355351 // the process itself is the lease and exiting releases it. Nothing has to be
356352 // registered up front, which is what makes this cover backends already
357353 // running from an older version that knows nothing about pruning.
......@@ -394,7 +390,7 @@ describe("WSL runtime cache", () => {
394390});
395391
396392// 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 got
393// whose entry was truncated satisfied every assertion above and still got
398394// reused, so these run the real script against a real archive in a throwaway
399395// HOME and check the outcome.
400396describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => {
......@@ -410,13 +406,14 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
410406 [
411407 "set -eu",
412408 "work=$(mktemp -d)",
413 'stage="$work/stage"',
414 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"',
415 `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`,
416 `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`,
417 `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`,
418 `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`,
419 `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`,
409 // Mirrors the release archive: one top-level versioned directory that
410 // holds the executable and its native addons.
411 'stage="$work/stage/t3-0.0.0-linux-x64"',
412 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"',
413 `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`,
414 'chmod +x "$stage/t3"',
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`,
420417 `printf 'work:%s\\n' "$work"`,
421418 `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`,
422419 ].join("\n"),
......@@ -443,7 +440,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
443440 runtimeId,
444441 runtimeParent: `${work}/home/.t3/wsl-runtime`,
445442 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`,
447444 installScript,
448445 install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)),
449446 };
......@@ -462,7 +459,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
462459 expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot);
463460 });
464461
465 it("reinstalls a cache whose server entry was truncated", () => {
462 it("reinstalls a cache whose executable was truncated", () => {
466463 const fixture = createFixture();
467464 expect(fixture.install().status).toBe(0);
468465 expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
......@@ -645,7 +642,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
645642 `runtime_root=${sh(fixture.runtimeRoot)}`,
646643 `runtime_parent=${sh(fixture.runtimeParent)}`,
647644 '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 &',
649646 "active_pid=$!",
650647 "sleep 0.1",
651648 fixture.installScript(),
......@@ -672,7 +669,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
672669 'home="$work/home"',
673670 'runtime_parent="$home/.t3/wsl-runtime"',
674671 '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"; }',
676673 "make_ready sha256-current",
677674 "make_ready sha256-previous",
678675 "make_ready sha256-active",
......@@ -683,7 +680,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed
683680 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"',
684681 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"',
685682 '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 &',
687684 "active_pid=$!",
688685 "(",
689686 ' exec 9> "$runtime_parent/.sha256-locked.install.lock"',
apps/desktop/src/wsl/DesktopWslEnvironment.ts+125-71
......@@ -66,6 +66,19 @@ export type EnsureWslNodePtyResult =
6666 readonly retryLimit?: number;
6767 };
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
6982export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()(
7083 "DesktopWslDistroListError",
7184 { reason: Schema.String },
......@@ -108,6 +121,13 @@ export class DesktopWslEnvironment extends Context.Service<
108121 readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
109122 // Marks a staged runtime as unusable so the next launch reinstalls it.
110123 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>;
111131 readonly ensureNodePty: (
112132 distro: string | null,
113133 linuxAppRoot: string,
......@@ -149,14 +169,15 @@ const TIMEOUT_RESULT: ShellResult = {
149169
150170const formatWslShellTransportFailureReason = (
151171 failure: ShellResult["transportFailure"],
172 subject = "Node.js",
152173): string | null => {
153174 switch (failure) {
154175 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.`;
156177 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.`;
158179 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.`;
160181 case null:
161182 return null;
162183 }
......@@ -255,7 +276,7 @@ const runWslShell = (
255276
256277const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
257278
258// Holds the sha256 of the runtime's server entry, written when the install
279// Holds the sha256 of the runtime's `t3` executable, written when the install
259280// promotes a verified tree. Presence alone only says an install once finished
260281// here; the digest is what lets a later launch prove the entry still is what
261282// that install wrote.
......@@ -279,35 +300,25 @@ export const buildWslRuntimeInstallScript = (
279300 'runtime_parent="$HOME/.t3/wsl-runtime"',
280301 `runtime_root="$runtime_parent/${safeRuntimeId}"`,
281302 `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,
282 // The native payload is the part of the tree the WSL backend actually
283 // dlopens, and the only part a user can plausibly break by hand. Checking
284 // node-pty's package.json alone let a runtime whose pty.node had gone
285 // missing stay cache-ready forever: every launch reused it and then failed
286 // the native probe, with no reinstall and no fallback. Match on the glob
287 // rather than a mapped `uname -m` so this stays a presence check; the probe
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",
303 // The runtime is a self-contained `t3` executable with Node inside, so the
304 // readiness proof is the same one the SSH runner and the CLI installers
305 // use: the file is executable and `t3 --version` exits 0. That covers the
306 // truncated-binary and wrong-arch cases without a separate native probe.
307 "runtime_entry_runs() {",
308 ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1',
296309 "}",
297 // Hashing the server entry is the only check that can tell a working cache
298 // from one whose bin.mjs was truncated or half-written: the file is still
299 // there, the native probe still passes, and launch then picks a server that
300 // exits before it can become ready, on every restart. Hashing the ~7MB
301 // entry measures in single-digit milliseconds inside the distro, once per
302 // launch, against a cold reinstall of a few hundred megabytes.
310 // Hashing the entry is what tells a working cache from one whose `t3` was
311 // swapped or half-written after install: the file is still there and may
312 // even still run, and launch then picks an executable that is not what
313 // this install verified. Hashing the executable measures in tens of
314 // milliseconds inside the distro, once per launch, against a cold
315 // reinstall of a few hundred megabytes.
303316 "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`,
305318 "}",
306319 "runtime_is_ready() {",
307320 ' [ -f "$ready_marker" ] &&',
308 ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&',
309 ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&',
310 ' node_pty_payload_present "$runtime_root" &&',
321 ' runtime_entry_runs "$runtime_root" &&',
311322 // An empty or unreadable marker is a miss, not a pass: that is what a
312323 // runtime installed before the marker carried a digest looks like, and one
313324 // reinstall is the cheapest way to make it verifiable from then on.
......@@ -370,15 +381,14 @@ export const buildWslRuntimeInstallScript = (
370381 `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
371382 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',
372383 "trap cleanup_runtime_install EXIT",
373 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`,
374 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"',
375 'test -f "$runtime_tmp/node_modules/node-pty/package.json"',
376
377 // Never write the ready marker over a tree that is missing the native
378 // payload. Failing here drops out to the mounted-tree fallback, which is
384 // The release archive has one top-level `t3-<version>-linux-<arch>/`
385 // directory; strip it so the executable lands at `$runtime_root/t3`.
386 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`,
387 // Never write the ready marker over a tree whose executable does not run.
388 // Failing here drops out to the mounted-tree fallback, which is
379389 // recoverable; promoting it would mark the defect ready and cache it.
380 'if ! node_pty_payload_present "$runtime_tmp"; then',
381 " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2",
390 'if ! runtime_entry_runs "$runtime_tmp"; then',
391 " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2",
382392 " exit 1",
383393 "fi",
384394 // The archive's bytes were verified against archiveSha256 above, so the
......@@ -466,12 +476,12 @@ export const buildWslRuntimePruneScript = (runtimeId: string): string => {
466476};
467477
468478// 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 whose
470// native payload is present but unloadable (truncated pty.node, a distro whose
471// glibc the binary needs and the tree was copied from another machine) stays
472// ready forever and fails the probe on every launch. Only the probe can see
473// that, so the probe is what revokes the marker. The tree itself is left in
474// place: the install script moves an unready root aside before extracting.
479// archive. Readiness is decided inside the install script, so a cached tree
480// that passes there but fails the launch-time probe (a distro whose glibc the
481// executable needs, a tree copied from another machine) would stay ready
482// forever and fail on every launch. Only the probe can see that, so the probe
483// is what revokes the marker. The tree itself is left in place: the install
484// script moves an unready root aside before extracting.
475485export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {
476486 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
477487 return [
......@@ -488,22 +498,29 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => {
488498 return runtimeRoot.startsWith("/") ? runtimeRoot : null;
489499};
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
493506const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
494 exitCode === NODE_PTY_PREBUILD_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."
507 exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE
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."
496509 : 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
498515const NODE_PTY_PROBE_SCRIPT = (
499516 linuxServerDir: string,
500517) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"
501518printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"
502printf 'resolvedPath:%s\\n' "$PATH"
519${RESOLVED_PATH_LINE}
503520cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1
504521// The WSL Node can't read inside app.asar, so confirm what the server needs is
505522// 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 report
523// marks this distinct from a node-pty binary problem so the caller can report
507524// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at
508525// launch (which, in wsl-only mode, would just fail to launch with no fallback).
509526//
......@@ -517,26 +534,27 @@ const fs = require("node:fs");
517534const path = require("node:path");
518535const pkgDir = path.dirname(require.resolve("node-pty/package.json"));
519536// 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. Compare
521// only arch and node-pty version (a stale binary from a different node-pty),
522// NOT process.versions.modules: that would reject a perfectly loadable prebuilt
523// whenever the user's WSL Node ABI differs from the build's, defeating the
524// whole point of shipping one prebuilt for all Node versions.
525const expected = {
526 arch: process.arch,
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});
537// Node versions — require() succeeding IS the real compatibility test. Look in
538// the same places node-pty's own loader does.
539const candidates = [
540 path.join(pkgDir, "build", "Release", "pty.node"),
541 path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"),
542];
543if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE});
533544require("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}
538545NODE`;
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
540558const TOOLCHAIN_CHECK_SCRIPT = [
541559 "for tool in node make g++ python3; do",
542560 ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"',
......@@ -552,15 +570,8 @@ const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) =>
552570 "set -e",
553571 `cd ${shellQuote(linuxServerDir)}`,
554572 `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")`,
558573 `cd "$pkg_dir"`,
559574 "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"`,
564575 `node -e 'require("node-pty")'`,
565576 ].join("\n");
566577
......@@ -660,6 +671,38 @@ export const formatMissingToolsReason = (
660671 return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`;
661672};
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
663706const ensureNodePtyImpl = (
664707 distro: string | null,
665708 linuxRepoRoot: string,
......@@ -1133,6 +1176,9 @@ export interface DesktopWslEnvironmentTestStub {
11331176 ) => PrepareWslRuntimeResult;
11341177 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
11351178 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;
11361182 readonly ensureNodePty?: (
11371183 distro: string | null,
11381184 linuxAppRoot: string,
......@@ -1165,6 +1211,10 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
11651211 pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void,
11661212 invalidateRuntime: (distro, runtimeId) =>
11671213 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 ),
11681218 ensureNodePty: (distro, linuxAppRoot, options) =>
11691219 Effect.succeed(
11701220 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
......@@ -1259,6 +1309,10 @@ export const layer = Layer.effect(
12591309 provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe(
12601310 Effect.withSpan("desktop.wsl.invalidateRuntime"),
12611311 ),
1312 probeRuntime: (distro, linuxAppRoot) =>
1313 provideSpawner(probeWslRuntimeImpl(distro, linuxAppRoot)).pipe(
1314 Effect.withSpan("desktop.wsl.probeRuntime"),
1315 ),
12621316 ensureNodePty: (distro, linuxAppRoot, options) =>
12631317 provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe(
12641318 Effect.withSpan("desktop.wsl.ensureNodePty"),
docs/operations/development.md+3-2
......@@ -191,8 +191,9 @@ rustup target add x86_64-pc-windows-msvc
191191rustup target add aarch64-pc-windows-msvc
192192```
193193
194NSIS is downloaded by electron-builder. WSL support additionally needs a Linux node-pty prebuild;
195see the [release runbook](./release.md#windows-payload-topology-and-update-validation).
194NSIS is downloaded by electron-builder. WSL support additionally needs the Linux CLI archive
195passed as `--wsl-runtime`; see the
196[release runbook](./release.md#windows-payload-topology-and-update-validation).
196197
197198### Signing and passkeys
198199
scripts/build-desktop-artifact.test.ts+216-324
......@@ -17,7 +17,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1717import {
1818 BundleNotSelfContainedError,
1919 BuildCommandFailedError,
20 buildWslRuntimeArchiveArgs,
2120 parseWslRuntimeArchiveMembers,
2221 DesktopDmgBackgroundSourceMissingError,
2322 createStageWorkspaceConfig,
......@@ -88,32 +87,48 @@ import {
8887 WSL_RUNTIME_ARCHIVE_HASH_NAME,
8988 WSL_RUNTIME_ARCHIVE_NAME,
9089 WSL_RUNTIME_EXTRA_RESOURCES,
91 wslRuntimeArchiveTarTarget,
90 WslRuntimeArchiveMissingError,
91 wslRuntimeArchiveStem,
9292} from "./build-desktop-artifact.ts";
9393import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
9494import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
9595import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9696
97// A minimal stand-in for the staged sidecar roots packed into the WSL archive.
98const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* (
99 root: string,
100 serverSource: string,
101) {
97// A minimal stand-in for the Linux CLI release archive: one top-level
98// directory named after the archive stem holding the executable, the web
99// client, and the runtime externals with node-pty built from source.
100const makeLinuxCliArchiveFixture = Effect.fn("test.makeLinuxCliArchiveFixture")(function* (input: {
101 readonly root: string;
102 readonly stem: string;
103 readonly extraMembers?: ReadonlyArray<string>;
104 readonly omitMembers?: ReadonlyArray<string>;
105}) {
102106 const fs = yield* FileSystem.FileSystem;
103107 const path = yield* Path.Path;
104 yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true });
105 yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource);
106 yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), {
107 recursive: true,
108 });
109 yield* fs.writeFileString(
110 path.join(root, "node_modules/node-pty/package.json"),
111 '{"name":"node-pty"}\n',
112 );
113 yield* fs.writeFileString(
114 path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"),
115 "pty",
108 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
109 const contentRoot = path.join(input.root, "content");
110 const members = [
111 `${input.stem}/t3`,
112 `${input.stem}/client/index.html`,
113 `${input.stem}/node_modules/node-pty/package.json`,
114 `${input.stem}/node_modules/node-pty/build/Release/pty.node`,
115 ...(input.extraMembers ?? []),
116 ].filter((member) => !(input.omitMembers ?? []).includes(member));
117 for (const member of members) {
118 const memberPath = path.join(contentRoot, member);
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 }),
116129 );
130 assert.equal(Number(yield* tar.exitCode), 0);
131 return archivePath;
117132});
118133
119134function mockProcess(exitCode: number, stdout = "") {
......@@ -154,10 +169,12 @@ function iconResizeSpawnerLayer(
154169 );
155170}
156171
172const WINDOWS_PAYLOAD_FIXTURE_VERSION = "1.2.3";
173
157174const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: {
158175 readonly copyUnpackedNatives: boolean;
159176 readonly serverEntrySource?: string;
160 readonly wslRuntime?: "valid" | "forbidden" | "bad-digest";
177 readonly wslRuntime?: "valid" | "loose-server-tree" | "missing-pty" | "bad-digest";
161178}) {
162179 const fs = yield* FileSystem.FileSystem;
163180 const path = yield* Path.Path;
......@@ -195,58 +212,30 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu
195212 yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad");
196213
197214 if (input.wslRuntime !== undefined) {
198 const wslSourceDir = path.join(tempDir, "wsl-source");
199 const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64");
200 yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true });
201 yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true });
202 yield* fs.writeFileString(
203 path.join(wslSourceDir, "apps/server/dist/bin.mjs"),
204 "console.log('wsl server');\n",
205 );
206 yield* fs.writeFileString(
207 path.join(wslSourceDir, "node_modules/node-pty/package.json"),
208 '{"name":"node-pty"}',
209 );
210 yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty");
211 yield* fs.writeFileString(
212 path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"),
213 '{"arch":"x64"}',
214 );
215 if (input.wslRuntime === "forbidden") {
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
215 const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, "x64");
216 const sourceArchivePath =
217 input.wslRuntime === "loose-server-tree"
218 ? // The old hand-rolled runtime: apps/server/dist + node_modules at the
219 // archive root, no single stem directory, no `t3` executable.
220 yield* makeLinuxCliArchiveFixture({
221 root: path.join(tempDir, "wsl-runtime"),
222 stem: "apps",
223 omitMembers: ["apps/t3", "apps/client/index.html"],
224 extraMembers: ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"],
225 })
226 : yield* makeLinuxCliArchiveFixture({
227 root: path.join(tempDir, "wsl-runtime"),
228 stem,
229 ...(input.wslRuntime === "missing-pty"
230 ? { omitMembers: [`${stem}/node_modules/node-pty/build/Release/pty.node`] }
231 : {}),
232 });
224233 const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME);
225234 const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME);
226 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
227 const tar = yield* spawner.spawn(
228 ChildProcess.make(
229 "tar",
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 );
235 yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath });
236 if (input.wslRuntime === "bad-digest") {
237 yield* fs.writeFileString(hashPath, `${"0".repeat(64)}\n`);
238 }
250239 }
251240
252241 return {
......@@ -463,40 +452,20 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
463452 libc: ["glibc"],
464453 },
465454 });
466 // The Windows app stage only serves the desktop main process; the server
467 // sidecar stage is the one that needs Linux natives (below).
455 // Windows stages only win32 natives; WSL runs the separately built Linux
456 // CLI archive rather than anything installed here.
468457 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), {
469458 supportedArchitectures: {
470459 os: ["win32"],
471460 cpu: ["x64"],
472461 },
473462 });
474 // The server sidecar stage bundles the same-architecture WSL (Linux,
475 // glibc) backend, so its install must fetch Linux native optional deps
476 // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar
477 // packing and runtime extraction without symlinks.
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",
463 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {
464 supportedArchitectures: {
465 os: ["win32"],
466 cpu: ["arm64"],
498467 },
499 );
468 });
500469 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
501470 supportedArchitectures: {
502471 os: ["darwin"],
......@@ -635,7 +604,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
635604 undefined,
636605 true,
637606 );
638 const winWithoutWslPrebuild = yield* createBuildConfig(
607 const winWithoutWslRuntime = yield* createBuildConfig(
639608 "win",
640609 "nsis",
641610 "1.2.3",
......@@ -654,8 +623,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
654623 assert.notProperty(linux, "asarUnpack");
655624 assert.deepStrictEqual(win.asar, { smartUnpack: false });
656625 assert.deepStrictEqual(win.asarUnpack, [WINDOWS_NATIVE_ASAR_UNPACK_GLOB]);
657 assert.deepStrictEqual(winWithoutWslPrebuild.asar, win.asar);
658 assert.deepStrictEqual(winWithoutWslPrebuild.asarUnpack, win.asarUnpack);
626 assert.deepStrictEqual(winWithoutWslRuntime.asar, win.asar);
627 assert.deepStrictEqual(winWithoutWslRuntime.asarUnpack, win.asarUnpack);
659628 assert.deepStrictEqual(mac.extraResources, DESKTOP_EXTRA_RESOURCES);
660629 assert.deepStrictEqual(linux.extraResources, [
661630 ...DESKTOP_EXTRA_RESOURCES,
......@@ -670,9 +639,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
670639 ...WINDOWS_SERVER_EXTRA_RESOURCES,
671640 ...WSL_RUNTIME_EXTRA_RESOURCES,
672641 ]);
673 // No Linux prebuild means the sidecar staging never writes the archive,
674 // so listing it here would fail the build on a missing source file.
675 assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [
642 // No Linux CLI archive means staging never writes the runtime, so
643 // listing it here would fail the build on a missing source file.
644 assert.deepStrictEqual(winWithoutWslRuntime.extraResources, [
676645 {
677646 from: "apps/desktop/prod-resources/resource-monitor",
678647 to: "resource-monitor",
......@@ -707,7 +676,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
707676 assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]);
708677 assert.deepStrictEqual(linux.files, [...DESKTOP_FILE_EXCLUSIONS, ...LINUX_FILE_EXCLUSIONS]);
709678 assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS);
710 assert.deepStrictEqual(winWithoutWslPrebuild.files, win.files);
679 assert.deepStrictEqual(winWithoutWslRuntime.files, win.files);
711680 assert.notProperty(mac.mac as Record<string, unknown>, "sign");
712681 for (const config of [linux, win]) {
713682 assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES);
......@@ -819,60 +788,50 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
819788 ]);
820789 });
821790
822 it.effect(
823 "keeps target and WSL native files while excluding the other Windows architecture",
824 () =>
825 Effect.scoped(
826 Effect.gen(function* () {
827 const fs = yield* FileSystem.FileSystem;
828 const path = yield* Path.Path;
829 const tempDir = yield* fs.makeTempDirectoryScoped({
830 prefix: "t3-windows-architecture-test-",
831 });
832 const sourceDir = path.join(tempDir, "server");
833 const nativeFiles = [
834 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",
835 "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe",
836 "node_modules/node-pty/prebuilds/linux-x64/pty.node",
837 "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe",
838 "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe",
839 ];
840
841 for (const nativeFile of nativeFiles) {
842 const nativePath = path.join(sourceDir, nativeFile);
843 yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true });
844 yield* fs.writeFileString(nativePath, "native");
845 }
791 it.effect("keeps target native files while excluding the other Windows architecture", () =>
792 Effect.scoped(
793 Effect.gen(function* () {
794 const fs = yield* FileSystem.FileSystem;
795 const path = yield* Path.Path;
796 const tempDir = yield* fs.makeTempDirectoryScoped({
797 prefix: "t3-windows-architecture-test-",
798 });
799 const sourceDir = path.join(tempDir, "server");
800 const nativeFiles = [
801 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",
802 "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe",
803 "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe",
804 "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe",
805 ];
806
807 for (const nativeFile of nativeFiles) {
808 const nativePath = path.join(sourceDir, nativeFile);
809 yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true });
810 yield* fs.writeFileString(nativePath, "native");
811 }
846812
847 const asarPath = path.join(tempDir, "server.asar");
848 yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" });
849 const unpackedRoot = `${asarPath}.unpacked`;
813 const asarPath = path.join(tempDir, "server.asar");
814 yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" });
815 const unpackedRoot = `${asarPath}.unpacked`;
850816
851 assert.isTrue(
852 yield* fs.exists(
853 path.join(
854 unpackedRoot,
855 "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"),
817 assert.isTrue(
818 yield* fs.exists(
819 path.join(
820 unpackedRoot,
821 "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe",
872822 ),
873 );
874 }),
875 ),
823 ),
824 );
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 ),
876835 );
877836
878837 it.effect("stages a cached resource monitor without invoking Cargo", () =>
......@@ -1205,6 +1164,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12051164 stageDistDir: fixture.stageDistDir,
12061165 appExecutableName: fixture.appExecutableName,
12071166 targetArch: "x64",
1167 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
12081168 });
12091169
12101170 const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar");
......@@ -1226,7 +1186,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12261186 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
12271187 );
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", () =>
12301190 Effect.scoped(
12311191 Effect.gen(function* () {
12321192 const fixture = yield* makeWindowsPayloadFixture({
......@@ -1237,6 +1197,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12371197 stageDistDir: fixture.stageDistDir,
12381198 appExecutableName: fixture.appExecutableName,
12391199 targetArch: "x64",
1200 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
12401201 expectWslRuntime: true,
12411202 });
12421203
......@@ -1245,6 +1206,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12451206 ).pipe(Effect.provideService(HostProcessPlatform, "linux")),
12461207 );
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
12481230 it.effect("rejects a Windows package missing its expected WSL runtime", () =>
12491231 Effect.scoped(
12501232 Effect.gen(function* () {
......@@ -1253,6 +1235,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12531235 stageDistDir: fixture.stageDistDir,
12541236 appExecutableName: fixture.appExecutableName,
12551237 targetArch: "x64",
1238 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
12561239 expectWslRuntime: true,
12571240 }).pipe(Effect.flip);
12581241
......@@ -1262,22 +1245,47 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12621245 ),
12631246 );
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", () =>
12661270 Effect.scoped(
12671271 Effect.gen(function* () {
12681272 const fixture = yield* makeWindowsPayloadFixture({
12691273 copyUnpackedNatives: true,
1270 wslRuntime: "forbidden",
1274 wslRuntime: "missing-pty",
12711275 });
12721276 const error = yield* validateWindowsPackagedPayload({
12731277 stageDistDir: fixture.stageDistDir,
12741278 appExecutableName: fixture.appExecutableName,
12751279 targetArch: "x64",
1280 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
12761281 expectWslRuntime: true,
12771282 }).pipe(Effect.flip);
12781283
12791284 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
12801285 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 ]);
12811289 }),
12821290 ),
12831291 );
......@@ -1293,6 +1301,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
12931301 stageDistDir: fixture.stageDistDir,
12941302 appExecutableName: fixture.appExecutableName,
12951303 targetArch: "x64",
1304 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
12961305 expectWslRuntime: true,
12971306 }).pipe(Effect.flip);
12981307
......@@ -1327,6 +1336,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
13271336 stageDistDir: fixture.stageDistDir,
13281337 appExecutableName: fixture.appExecutableName,
13291338 targetArch: "x64",
1339 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
13301340 });
13311341
13321342 const primaryProbe = commands.find(
......@@ -1466,6 +1476,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
14661476 stageDistDir: fixture.stageDistDir,
14671477 appExecutableName: fixture.appExecutableName,
14681478 targetArch: "arm64",
1479 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
14691480 });
14701481
14711482 assert.isFalse(
......@@ -1502,6 +1513,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15021513 stageDistDir: fixture.stageDistDir,
15031514 appExecutableName: fixture.appExecutableName,
15041515 targetArch: "arm64",
1516 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
15051517 }).pipe(Effect.flip);
15061518
15071519 assert.instanceOf(error, WindowsPrimaryNativeProbeError);
......@@ -1525,6 +1537,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15251537 stageDistDir: fixture.stageDistDir,
15261538 appExecutableName: fixture.appExecutableName,
15271539 targetArch: "x64",
1540 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
15281541 }).pipe(Effect.flip);
15291542
15301543 assert.instanceOf(error, WindowsPackagedPayloadValidationError);
......@@ -1553,6 +1566,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15531566 stageDistDir: fixture.stageDistDir,
15541567 appExecutableName: fixture.appExecutableName,
15551568 targetArch: "x64",
1569 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
15561570 }).pipe(Effect.flip);
15571571 assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError);
15581572 assert.equal(nativeError.reason, "unpacked-native-missing");
......@@ -1573,6 +1587,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15731587 stageDistDir: fixture.stageDistDir,
15741588 appExecutableName: fixture.appExecutableName,
15751589 targetArch: "x64",
1590 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
15761591 }).pipe(Effect.flip);
15771592 assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError);
15781593 assert.equal(resourceMonitorError.reason, "resource-monitor-missing");
......@@ -1591,6 +1606,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
15911606 stageDistDir: fixture.stageDistDir,
15921607 appExecutableName: fixture.appExecutableName,
15931608 targetArch: "x64",
1609 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
15941610 fileLimit: 2,
15951611 }).pipe(Effect.flip);
15961612
......@@ -1612,6 +1628,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
16121628 stageDistDir: fixture.stageDistDir,
16131629 appExecutableName: fixture.appExecutableName,
16141630 targetArch: "x64",
1631 appVersion: WINDOWS_PAYLOAD_FIXTURE_VERSION,
16151632 }).pipe(Effect.flip);
16161633
16171634 assert.instanceOf(error, BundleNotSelfContainedError);
......@@ -1923,7 +1940,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
19231940 assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe");
19241941 });
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", () => {
19271944 assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz");
19281945 assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256");
19291946 assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, {
......@@ -1934,190 +1951,65 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
19341951 from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256",
19351952 to: "wsl-runtime.tar.gz.sha256",
19361953 });
1937 // The archive is only usable alongside a Linux pty.node, so both the
1938 // staging and the packaging config hang off this one decision.
1939 assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" }));
1940 assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" }));
1941 assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined }));
1942 assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" }));
1943
1944 assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [
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 ]);
1954 // Both the staging and the packaging config hang off this one decision:
1955 // Windows only, and only when CI handed the build a Linux CLI archive.
1956 const runtimeArchivePath = "/tmp/t3-1.2.3-linux-x64.tar.gz";
1957 assert.isTrue(bundlesWslRuntime({ platform: "win", runtimeArchivePath }));
1958 assert.isFalse(bundlesWslRuntime({ platform: "win", runtimeArchivePath: undefined }));
1959 assert.isFalse(bundlesWslRuntime({ platform: "linux", runtimeArchivePath }));
1960 assert.isFalse(bundlesWslRuntime({ platform: "mac", runtimeArchivePath }));
1961 assert.equal(wslRuntimeArchiveStem("1.2.3", "x64"), "t3-1.2.3-linux-x64");
19621962 });
19631963
19641964 it("parses Windows bsdtar member listings with CRLF line endings", () => {
19651965 assert.deepStrictEqual(
1966 parseWslRuntimeArchiveMembers(
1967 "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n",
1968 ),
1969 ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"],
1966 parseWslRuntimeArchiveMembers("./t3-1.2.3-linux-x64/t3\r\nt3-1.2.3-linux-x64/client/\r\n"),
1967 ["t3-1.2.3-linux-x64/t3", "t3-1.2.3-linux-x64/client"],
19701968 );
19711969 });
19721970
1973 it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => {
1974 assert.equal(
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(
1971 it.effect("stages the Linux CLI archive verbatim with its SHA-256 sidecar", () =>
1972 Effect.scoped(
19961973 Effect.gen(function* () {
19971974 const fs = yield* FileSystem.FileSystem;
19981975 const path = yield* Path.Path;
1999 const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" });
2000 const sourceDir = path.join(stageRoot, "server");
2001 const stageAppDir = path.join(stageRoot, "app");
1976 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-stage-" });
1977 const sourceArchivePath = yield* makeLinuxCliArchiveFixture({
1978 root,
1979 stem: "t3-1.2.3-linux-x64",
1980 });
1981 const stageAppDir = path.join(root, "app");
20021982 const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from);
20031983 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");
2023 if (tarCommand === undefined) return assert.fail("tar was not spawned");
1985 yield* stageWslRuntimeArchive({ sourceArchivePath, archivePath, hashPath });
20241986
2025 const target = tarCommand.args[1] ?? "";
2026 assert.equal(tarCommand.options.cwd, sourceDir);
2027 assert.notInclude(target, ":");
2028 assert.isFalse(path.isAbsolute(target));
2029 // Relative or not, tar has to land the archive where the build expects it.
2030 assert.equal(path.resolve(sourceDir, target), archivePath);
2031 assert.isTrue(yield* fs.exists(archivePath));
2032
2033 // The archive digest both gates installation and names the cache.
1987 const [source, staged] = yield* Effect.all([
1988 fs.readFile(sourceArchivePath),
1989 fs.readFile(archivePath),
1990 ]);
1991 assert.deepStrictEqual(staged, source);
1992 // The digest both gates installation inside the distro and names the
1993 // extracted runtime's cache directory.
20341994 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`);
20361996 }),
2037 );
2038 });
1997 ),
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", () =>
20412001 Effect.scoped(
20422002 Effect.gen(function* () {
20432003 const fs = yield* FileSystem.FileSystem;
20442004 const path = yield* Path.Path;
2045 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2046 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" });
2047 const sourceDir = path.join(root, "server");
2048 const archivePath = path.join(root, "wsl-runtime.tar.gz");
2049 const hashPath = `${archivePath}.sha256`;
2050 yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n");
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);
2005 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-missing-" });
2006 const error = yield* stageWslRuntimeArchive({
2007 sourceArchivePath: path.join(root, "t3-1.2.3-linux-x64.tar.gz"),
2008 archivePath: path.join(root, WSL_RUNTIME_ARCHIVE_NAME),
2009 hashPath: path.join(root, WSL_RUNTIME_ARCHIVE_HASH_NAME),
2010 }).pipe(Effect.flip);
20962011
2097 assert.include(listing, "apps/server/dist/bin.mjs");
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 }
2012 assert.instanceOf(error, WslRuntimeArchiveMissingError);
21212013 }),
21222014 ),
21232015 );
......@@ -2228,7 +2120,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
22282120 verbose: Option.none(),
22292121 mockUpdates: Option.none(),
22302122 mockUpdateServerPort: Option.none(),
2231 wslPrebuild: Option.none(),
2123 wslRuntime: Option.none(),
22322124 }).pipe(
22332125 Effect.provide(
22342126 Layer.mergeAll(
......@@ -2268,7 +2160,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
22682160 verbose: Option.none(),
22692161 mockUpdates: Option.none(),
22702162 mockUpdateServerPort: Option.none(),
2271 wslPrebuild: Option.none(),
2163 wslRuntime: Option.none(),
22722164 }),
22732165 );
22742166
......@@ -2292,7 +2184,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
22922184 verbose: Option.some(false),
22932185 mockUpdates: Option.some(false),
22942186 mockUpdateServerPort: Option.none(),
2295 wslPrebuild: Option.none(),
2187 wslRuntime: Option.none(),
22962188 }).pipe(
22972189 Effect.provide(
22982190 ConfigProvider.layer(
scripts/build-desktop-artifact.ts+117-255
......@@ -89,9 +89,6 @@ const RepoRoot = Effect.service(Path.Path).pipe(
8989);
9090const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
9191const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
92const decodeNodePtyManifest = Schema.decodeUnknownEffect(
93 Schema.fromJsonString(Schema.Struct({ version: Schema.String })),
94);
9592const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig));
9693
9794const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
......@@ -164,7 +161,7 @@ interface BuildCliInput {
164161 readonly verbose: Option.Option<boolean>;
165162 readonly mockUpdates: Option.Option<boolean>;
166163 readonly mockUpdateServerPort: Option.Option<number>;
167 readonly wslPrebuild: Option.Option<string>;
164 readonly wslRuntime: Option.Option<string>;
168165}
169166
170167function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined {
......@@ -399,7 +396,7 @@ const WINDOWS_DESKTOP_BUILD_PREREQUISITES = [
399396 id: "msvc",
400397 description: "Visual Studio Build Tools with C++, Windows SDK, and Spectre libraries",
401398 },
402 { id: "tar", description: "tar for the bundled WSL runtime" },
399 { id: "tar", description: "tar to inspect the bundled WSL runtime archive" },
403400] as const;
404401
405402export class WindowsDesktopBuildPrerequisitesMissingError extends Schema.TaggedError<WindowsDesktopBuildPrerequisitesMissingError>()(
......@@ -670,14 +667,14 @@ export class DesktopBuildNoArtifactsProducedError extends Schema.TaggedError<Des
670667 }
671668}
672669
673export class WslNodePtyPrebuildMissingError extends Schema.TaggedError<WslNodePtyPrebuildMissingError>()(
674 "WslNodePtyPrebuildMissingError",
670export class WslRuntimeArchiveMissingError extends Schema.TaggedError<WslRuntimeArchiveMissingError>()(
671 "WslRuntimeArchiveMissingError",
675672 {
676 prebuildPath: Schema.String,
673 archivePath: Schema.String,
677674 },
678675) {
679676 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}.`;
681678 }
682679}
683680
......@@ -754,18 +751,6 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedError<Wi
754751 }
755752}
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
769754export class LinuxIconResizeError extends Schema.TaggedError<LinuxIconResizeError>()(
770755 "LinuxIconResizeError",
771756 {
......@@ -933,7 +918,7 @@ interface ResolvedBuildOptions {
933918 readonly verbose: boolean;
934919 readonly mockUpdates: boolean;
935920 readonly mockUpdateServerPort: number | undefined;
936 readonly wslPrebuild: string | undefined;
921 readonly wslRuntime: string | undefined;
937922}
938923
939924interface StagePackageJson {
......@@ -1007,8 +992,8 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) {
1007992// then extracts a handful of large archives instead of thousands of small
1008993// files, which dominates install (and update) time. The Windows primary runs
1009994// 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;
1011// DesktopWslServerTree can still materialize this sidecar as a fallback.
995// runtime. WSL does not use this sidecar: it runs the Linux CLI archive
996// embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME).
1012997export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar";
1013998// dlopen/spawn need real files, so native modules, shared libraries, and
1014999// helper executables live in each archive's .unpacked sibling (the standard
......@@ -1064,38 +1049,16 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = {
10641049 from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`,
10651050 to: WSL_RUNTIME_ARCHIVE_HASH_NAME,
10661051} as const;
1067export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const;
1068
1069// The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar.
1070// Keep build/install metadata and target-native packages that cannot run in
1071// WSL out of the compressed archive.
1072export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [
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.
1052
1053// The WSL runtime is the Linux CLI release archive (t3-<version>-linux-<arch>
1054// .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL
1055// runs the exact bytes a Linux user downloads. This one predicate decides both
1056// whether the archive is staged and whether the packaging config ships it:
1057// listing an extraResource whose source was never written fails electron-builder.
10951058export const bundlesWslRuntime = (input: {
1096 readonly arch: typeof BuildArch.Type;
1097 readonly prebuildPath: string | undefined;
1098}): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined;
1059 readonly platform: typeof BuildPlatform.Type;
1060 readonly runtimeArchivePath: string | undefined;
1061}): boolean => input.platform === "win" && input.runtimeArchivePath !== undefined;
10991062
11001063export const WSL_RUNTIME_EXTRA_RESOURCES = [
11011064 WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE,
......@@ -1515,15 +1478,8 @@ export function createStageWorkspaceConfig(input: {
15151478 readonly allowBuilds?: Record<string, boolean>;
15161479 readonly patchedDependencies?: Record<string, string>;
15171480 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;
15251481}): StageWorkspaceConfig {
1526 const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input;
1482 const { platform, arch, allowBuilds, patchedDependencies, overrides } = input;
15271483 const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux";
15281484 const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch];
15291485 // Linux AppImages execute a Linux/glibc Node process that loads
......@@ -1536,16 +1492,10 @@ export function createStageWorkspaceConfig(input: {
15361492 cpu: hostCpu,
15371493 libc: ["glibc"],
15381494 }
1539 : linuxServerBackend
1540 ? {
1541 os: Array.from(new Set([hostOs, "linux"])),
1542 cpu: hostCpu,
1543 libc: ["glibc"],
1544 }
1545 : {
1546 os: [hostOs],
1547 cpu: hostCpu,
1548 };
1495 : {
1496 os: [hostOs],
1497 cpu: hostCpu,
1498 };
15491499
15501500 return {
15511501 supportedArchitectures,
......@@ -1554,7 +1504,6 @@ export function createStageWorkspaceConfig(input: {
15541504 ? { patchedDependencies }
15551505 : {}),
15561506 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1557 ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}),
15581507 };
15591508}
15601509
......@@ -1600,11 +1549,10 @@ const BuildEnvConfig = Config.all({
16001549 verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)),
16011550 mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)),
16021551 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,
1604 // produced by the Linux CI job and handed to the Windows packaging job. Placed
1605 // into the staged node-pty so the WSL backend ships a ready binary and never
1606 // compiles on the user's machine.
1607 wslPrebuild: Config.string("T3CODE_DESKTOP_WSL_PREBUILD").pipe(Config.option),
1552 // Path to the Linux CLI release archive (t3-<version>-linux-x64.tar.gz) built
1553 // by the build_linux_cli CI job. The Windows build embeds it verbatim as the
1554 // WSL runtime.
1555 wslRuntime: Config.string("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option),
16081556});
16091557
16101558const MockUpdateServerPortSchema = Schema.NumberFromString.check(
......@@ -1696,8 +1644,8 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
16961644 ),
16971645 ));
16981646
1699 const wslPrebuild =
1700 Option.getOrUndefined(input.wslPrebuild) ?? Option.getOrUndefined(env.wslPrebuild);
1647 const wslRuntime =
1648 Option.getOrUndefined(input.wslRuntime) ?? Option.getOrUndefined(env.wslRuntime);
17011649
17021650 return {
17031651 platform,
......@@ -1711,7 +1659,7 @@ export const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (
17111659 verbose,
17121660 mockUpdates,
17131661 mockUpdateServerPort,
1714 wslPrebuild,
1662 wslRuntime,
17151663 } satisfies ResolvedBuildOptions;
17161664});
17171665
......@@ -2683,9 +2631,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
26832631 readonly provisioningProfilePath: string;
26842632 }
26852633 | undefined,
2686 // Windows only, and false when no Linux node-pty prebuild was bundled: the
2687 // sidecar staging skips the archive in that case, and listing a resource
2688 // whose source file was never written fails the electron-builder step.
2634 // Windows only, and false when no Linux CLI archive was handed to the build:
2635 // staging skips the archive in that case, and listing a resource whose
2636 // source file was never written fails the electron-builder step.
26892637 wslRuntimeBundled = false,
26902638 arch?: typeof BuildArch.Type,
26912639) {
......@@ -2850,114 +2798,25 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f
28502798 }
28512799});
28522800
2853// Stage the prebuilt Linux node-pty binary into the packaged app so the WSL
2854// backend never compiles on the user's machine. node-pty publishes no Linux
2855// prebuilt and the WSL Linux Node can't load the Windows/Electron binary, so the
2856// Linux CI job builds pty.node and hands it here. We drop it into the staged
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
2801// Copy the Linux CLI release archive into the stage verbatim and record its
2802// SHA-256 beside it. The desktop app verifies the digest inside the distro
2803// before extracting, and the digest also names the extracted runtime's cache
2804// directory, so it must be computed from the exact bytes that ship.
29462805export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: {
2947 readonly sourceDir: string;
2806 readonly sourceArchivePath: string;
29482807 readonly archivePath: string;
29492808 readonly hashPath: string;
29502809}) {
29512810 const fs = yield* FileSystem.FileSystem;
29522811 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 }
29532818 yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true });
2954 const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath));
2955 yield* runCommand(
2956 ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), {
2957 cwd: input.sourceDir,
2958 }),
2959 { label: "tar WSL runtime", verbose: false },
2960 );
2819 yield* fs.copyFile(input.sourceArchivePath, input.archivePath);
29612820 const hash = NodeCrypto.createHash("sha256");
29622821 yield* fs
29632822 .stream(input.archivePath)
......@@ -2965,16 +2824,27 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi
29652824 const digest = hash.digest("hex");
29662825 yield* fs.writeFileString(input.hashPath, `${digest}\n`);
29672826 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}).`,
29692828 );
29702829});
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
29722843// 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 and
2974// WSL Linux. 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.
2976// Shipping one packed archive instead of thousands of loose files is what
2977// makes the NSIS install/update fast.
2844// install of only its runtime-external/native dependency closure for win32.
2845// The Windows primary runs from the archive through the asar-aware
2846// ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of
2847// thousands of loose files is what makes the NSIS install/update fast.
29782848export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: {
29792849 readonly sourceDir: string;
29802850 readonly asarPath: string;
......@@ -3025,10 +2895,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
30252895 readonly allowBuilds: Record<string, boolean>;
30262896 readonly patchedDependencies: Record<string, string>;
30272897 readonly overrides: Record<string, string>;
3028 readonly wslPrebuildPath: string | undefined;
30292898 readonly asarPath: string;
3030 readonly wslRuntimeArchivePath: string;
3031 readonly wslRuntimeArchiveHashPath: string;
30322899 readonly verbose: boolean;
30332900}) {
30342901 const fs = yield* FileSystem.FileSystem;
......@@ -3040,11 +2907,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
30402907
30412908 const sidecarDependencies = {
30422909 ...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.
30462910 ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion),
3047 ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion),
30482911 };
30492912 const sidecarPatchedDependencies = createStagePatchedDependencies(
30502913 input.patchedDependencies,
......@@ -3062,14 +2925,18 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
30622925 path.join(serverStageDir, "package.json"),
30632926 `${sidecarPackageJsonString}\n`,
30642927 );
3065 const sidecarWorkspaceConfig = createStageWorkspaceConfig({
3066 platform: "win",
3067 arch: input.arch,
3068 allowBuilds: input.allowBuilds,
3069 patchedDependencies: sidecarPatchedDependencies,
3070 overrides: input.overrides,
3071 linuxServerBackend: true,
3072 });
2928 const sidecarWorkspaceConfig = {
2929 ...createStageWorkspaceConfig({
2930 platform: "win",
2931 arch: input.arch,
2932 allowBuilds: input.allowBuilds,
2933 patchedDependencies: sidecarPatchedDependencies,
2934 overrides: input.overrides,
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 };
30732940 const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig);
30742941 yield* fs.writeFileString(
30752942 path.join(serverStageDir, "pnpm-workspace.yaml"),
......@@ -3089,22 +2956,6 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(
30892956 { label: "vp install --prod (server sidecar)", verbose: input.verbose },
30902957 );
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
31082959 yield* Effect.log("[desktop-artifact] Packing server.asar...");
31092960 yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true });
31102961 yield* packWindowsServerAsar({
......@@ -3248,6 +3099,9 @@ export const validateWindowsPackagedPayload = Effect.fn(
32483099 readonly stageDistDir: string;
32493100 readonly appExecutableName: string;
32503101 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;
32513105 readonly expectWslRuntime?: boolean;
32523106 readonly fileLimit?: number;
32533107 readonly verbose?: boolean;
......@@ -3411,24 +3265,22 @@ export const validateWindowsPackagedPayload = Effect.fn(
34113265 );
34123266 }
34133267 const members = parseWslRuntimeArchiveMembers(listing.stdout);
3414 const forbiddenMember = members.find((member) =>
3415 WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)),
3416 );
3417 if (forbiddenMember !== undefined) {
3268 // A release archive unpacks to one directory named after its stem; the
3269 // desktop app's WSL install script relies on that layout to find `t3`.
3270 const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch);
3271 const topLevel = new Set(members.map((member) => member.split("/")[0]));
3272 if (topLevel.size !== 1 || !topLevel.has(stem)) {
34183273 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 ),
34203277 );
34213278 }
3422 const wslArch = resolveWslPrebuildArch(input.targetArch);
34233279 const requiredMembers = [
3424 "apps/server/dist/bin.mjs",
3425 "node_modules/node-pty/package.json",
3426 ...(wslArch === undefined
3427 ? []
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 ]),
3280 `${stem}/t3`,
3281 `${stem}/client`,
3282 `${stem}/node_modules`,
3283 `${stem}/node_modules/node-pty/build/Release/pty.node`,
34323284 ];
34333285 const missingMembers = requiredMembers.filter((member) => !members.includes(member));
34343286 if (missingMembers.length > 0) {
......@@ -3436,9 +3288,16 @@ export const validateWindowsPackagedPayload = Effect.fn(
34363288 reason: "wsl-runtime-invalid",
34373289 packagedAppDir,
34383290 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"),
34403292 });
34413293 }
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 }
34423301 }
34433302
34443303 const fileCount = yield* countPayloadFiles(packagedAppDir);
......@@ -3487,8 +3346,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
34873346 yield* preflightWindowsDesktopBuild({
34883347 arch: options.arch,
34893348 bundlesWslRuntime: bundlesWslRuntime({
3490 arch: options.arch,
3491 prebuildPath: options.wslPrebuild,
3349 platform: options.platform,
3350 runtimeArchivePath: options.wslRuntime,
34923351 }),
34933352 });
34943353 }
......@@ -3807,7 +3666,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
38073666 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
38083667 }
38093668 : undefined,
3810 bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }),
3669 bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }),
38113670 options.arch,
38123671 ),
38133672 dependencies: stageDependencies,
......@@ -3847,9 +3706,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
38473706 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
38483707 yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch);
38493708
3850 // WSL is Windows-only, so only the Windows artifact carries the server
3851 // sidecar (which embeds the Linux node-pty prebuild); other platforms
3852 // ignore the prebuild input.
3709 // Only the Windows artifact carries the server sidecar and the WSL runtime;
3710 // other platforms ignore the --wsl-runtime input.
38533711 if (options.platform === "win" && windowsServerAsarPath) {
38543712 yield* stageWindowsServerSidecar({
38553713 stageRoot,
......@@ -3862,17 +3720,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
38623720 allowBuilds: workspaceAllowBuilds,
38633721 patchedDependencies: workspacePatchedDependencies,
38643722 overrides: resolvedOverrides,
3865 wslPrebuildPath: options.wslPrebuild,
38663723 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
38733724 verbose: options.verbose,
38743725 });
38753726 }
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
38773738 // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="")
38783739 // as enabled, so copy the host env and scrub empty values instead of relying
......@@ -3968,9 +3829,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
39683829 stageDistDir,
39693830 appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`,
39703831 targetArch: options.arch,
3832 appVersion,
39713833 expectWslRuntime: bundlesWslRuntime({
3972 arch: options.arch,
3973 prebuildPath: options.wslPrebuild,
3834 platform: options.platform,
3835 runtimeArchivePath: options.wslRuntime,
39743836 }),
39753837 verbose: options.verbose,
39763838 });
......@@ -4055,9 +3917,9 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", {
40553917 Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."),
40563918 Flag.optional,
40573919 ),
4058 wslPrebuild: Flag.string("wsl-prebuild").pipe(
3920 wslRuntime: Flag.string("wsl-runtime").pipe(
40593921 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).",
40613923 ),
40623924 Flag.optional,
40633925 ),