diff --git a/apps/server/src/cli/sshHelper.ts b/apps/server/src/cli/sshHelper.ts index 09a729ef4489..5b365ed8924e 100644 --- a/apps/server/src/cli/sshHelper.ts +++ b/apps/server/src/cli/sshHelper.ts @@ -6,10 +6,61 @@ import * as NodeHttp from "node:http"; import * as NodeNet from "node:net"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { Argument, Command } from "effect/cli"; +import { bootServiceBaseDirOf, bootServiceUnitPath } from "../cloud/bootService.ts"; import * as DesktopUpdateMarker from "../desktopUpdate/DesktopUpdateMarker.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import * as ProcessRunner from "../processRunner.ts"; + +/** Resolve once at launch; pairing must use that same home even in a non-login shell. */ +export const resolveSshServerHome = Effect.gen(function* () { + const environment = yield* HostProcess.Environment; + if (environment.T3CODE_HOME?.trim()) return yield* resolveBaseDir(environment.T3CODE_HOME); + const fs = yield* FileSystem.FileSystem; + const uid = yield* HostProcess.UserId; + const processRunner = yield* ProcessRunner.ProcessRunner; + // A non-login SSH session may lack this, so `busctl --user` cannot find the user bus. + const userManagerEnv = + (yield* HostProcess.Platform) === "linux" && + !environment.XDG_RUNTIME_DIR && + uid !== undefined && + !(uid === 0 && environment.SUDO_USER) + ? { XDG_RUNTIME_DIR: `/run/user/${uid}` } + : undefined; + const serviceFile = yield* bootServiceUnitPath().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (request) => + processRunner.run({ ...request, env: { ...userManagerEnv, ...request.env } }), + }), + ); + const service = + serviceFile === undefined + ? "" + : yield* fs.readFileString(serviceFile).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(""), + ), + ); + return yield* resolveBaseDir(bootServiceBaseDirOf(service)); +}); + +const homeDir = Command.make("home-dir").pipe( + Command.withHandler(() => + resolveSshServerHome.pipe( + Effect.provide(ProcessRunner.layer), + Effect.tap((home) => + Effect.sync(() => { + process.stdout.write(home); + }), + ), + ), + ), +); const canBind = (port: number) => new Promise((resolve) => { @@ -144,5 +195,5 @@ const updateRemaining = Command.make("update-remaining", { export const sshHelperCommand = Command.make("__ssh-helper").pipe( Command.unlisted, - Command.withSubcommands([pickPort, waitReady, runtimePort, updateRemaining]), + Command.withSubcommands([pickPort, waitReady, runtimePort, updateRemaining, homeDir]), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4d89805bb19d..166241bf168c 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -2,7 +2,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Clock from "effect/Clock"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -20,6 +20,7 @@ import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import { serviceLauncherLockPath } from "../serviceLauncher.ts"; import * as BootService from "./bootService.ts"; +import { resolveSshServerHome } from "../cli/sshHelper.ts"; import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; import { disabledServiceTask, @@ -1664,3 +1665,90 @@ process.disconnect(); }), ); }); + +it.effect.each(["linux", "darwin"] as const)( + "resolves the %s service home and gives an explicit environment home precedence", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-helper-test-" }); + const serviceHome = path.join(directory, 'T3 "Data" & 100%'); + const servicePath = + platform === "darwin" + ? path.join(directory, "Library/LaunchAgents/com.t3tools.t3code.service.plist") + : path.join(directory, ".config/systemd/user/t3code.service"); + // No reachable systemd manager, so Linux falls back to the XDG config directory. + const runner = ProcessRunner.ProcessRunner.of({ + run: () => + Effect.succeed({ + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + const resolve = (environment: NodeJS.ProcessEnv = {}) => + resolveSshServerHome.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provideService(HostProcess.HomeDirectory, directory), + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(HostProcess.Environment, environment), + ); + assert.strictEqual(yield* resolve(), path.join(directory, ".t3")); + const plan = { + baseDir: serviceHome, + program: ["/fake/t3"], + logPath: "/fake/log", + unitPath: servicePath, + }; + yield* fs.makeDirectory(path.dirname(servicePath), { recursive: true }); + yield* fs.writeFileString( + servicePath, + platform === "darwin" + ? BootService.renderBootServicePlist(plan, { homeDir: directory, environmentPath: "" }) + : BootService.renderBootServiceUnit(plan), + ); + assert.strictEqual(yield* resolve(), serviceHome); + assert.strictEqual( + yield* resolve({ T3CODE_HOME: "~/explicit home" }), + path.join(directory, "explicit home"), + ); + assert.strictEqual(yield* resolve({ T3CODE_HOME: " " }), serviceHome); + if (platform === "linux") { + // A non-login SSH shell has no XDG_RUNTIME_DIR; the user manager is still found. + const managerDirectory = path.join(directory, "relocated config/systemd/user"); + yield* fs.makeDirectory(managerDirectory, { recursive: true }); + yield* fs.rename(servicePath, path.join(managerDirectory, "t3code.service")); + const managerRunner = ProcessRunner.ProcessRunner.of({ + run: (request) => + Effect.succeed({ + stdout: JSON.stringify({ data: [managerDirectory, managerDirectory + ".control"] }), + stderr: "", + code: ChildProcessSpawner.ExitCode( + request.env?.XDG_RUNTIME_DIR === "/run/user/1234" ? 0 : 1, + ), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + assert.strictEqual( + yield* resolveSshServerHome.pipe( + Effect.provideService(ProcessRunner.ProcessRunner, managerRunner), + Effect.provideService(HostProcess.HomeDirectory, directory), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.UserId, 1234), + Effect.provideService(HostProcess.Environment, {}), + ), + serviceHome, + ); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/packages/ssh/src/launcherTestHarness.ts b/packages/ssh/src/launcherTestHarness.ts index fb307fffd2f8..85bbf50a64ab 100644 --- a/packages/ssh/src/launcherTestHarness.ts +++ b/packages/ssh/src/launcherTestHarness.ts @@ -102,6 +102,7 @@ Date.now = () => Number(fs.readFileSync(process.env.HOME + '/clock', 'utf8')); options.archive ? "test-node" : "node", ` case "\${2:-}" in + __ssh-helper) [ "$3" = home-dir ] && printf '%s' "$HOME/.t3" ;; */server-runtime.json) # A started server publishes its runtime record, which the launch waits for. # The fake nohup runs in the background, so wait for it instead of racing it. @@ -191,6 +192,7 @@ set -eu if [ "$1" = --version ]; then printf 1.2.3; exit; fi [ "$1" = __ssh-helper ] case "$2" in + home-dir) printf '%s' "$HOME/.t3" ;; update-remaining) printf 'marker %s\\n' "$(cat "$HOME/clock")" >> "$HOME/events"; exec "$TEST_REAL_NODE" --require "$HOME/clock.cjs" "$HOME/helper.ts" "$2" "$3" ;; runtime-port | pick-port | wait-ready) exec "$HOME/bin/test-node" - "$3" ;; *) exit 1 ;; diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 6a901f97875f..bbb617c4eb1a 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -106,6 +106,10 @@ const fakeT3Serve = (publishDelayMs: number | null) => `const fs = require("node const http = require("node:http"); const path = require("node:path"); const args = process.argv.slice(2); +if (args.join(" ") === "__ssh-helper home-dir") { + process.stdout.write(path.join(process.env.HOME, ".t3")); + process.exit(0); +} const port = Number(args[args.indexOf("--port") + 1]); const userdata = path.join(args[args.indexOf("--base-dir") + 1], "userdata"); const servers = path.join(process.env.HOME, "servers"); @@ -171,6 +175,214 @@ const localRemoteShellLayer = (home: string) => ); describe("ssh tunnel scripts", () => { + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "pairs against the remote custom home instead of creating credentials in ~/.t3", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-home-test-" }); + const customHome = path.join(directory, "Custom T3 home"); + const cli = path.join(directory, "pairing-fixture.cjs"); + yield* fs.writeFileString( + cli, + `const args = process.argv.slice(2); +process.stdout.write(args[0] === "__ssh-helper" + ? process.env.T3CODE_HOME + : JSON.stringify({ credential: args[args.indexOf("--base-dir") + 1] }));`, + ); + const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const localSsh = ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + if (command._tag !== "StandardCommand") return realSpawner.spawn(command); + return realSpawner.spawn( + ChildProcess.make("/bin/sh", ["-s"], { + ...command.options, + env: { + HOME: directory, + PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin`, + T3CODE_HOME: customHome, + }, + extendEnv: false, + }), + ); + }); + const paired = yield* SshTunnel.issueRemotePairingToken( + { alias: "fixture", hostname: "fixture", username: null, port: null }, + undefined, + { nodeScriptPath: cli }, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, localSsh)); + assert.strictEqual(paired.credential, customHome); + const stateKey = yield* remoteStateKey({ + alias: "fixture", + hostname: "fixture", + username: null, + port: null, + }); + const launchedHome = path.join(directory, "Home selected by the login shell"); + yield* fs.writeFileString( + path.join(directory, ".t3/ssh-launch", stateKey, "server-home"), + launchedHome, + ); + const pairedAgain = yield* SshTunnel.issueRemotePairingToken( + { alias: "fixture", hostname: "fixture", username: null, port: null }, + undefined, + { nodeScriptPath: cli }, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, localSsh)); + assert.strictEqual(pairedAgain.credential, launchedHome); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "adopts the custom-home service and leaves a live old-home managed server untouched", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-launch-test-" }); + const customHome = path.join(directory, "custom home"); + const target = { alias: "fixture", hostname: "fixture", username: null, port: null }; + const stateKey = yield* remoteStateKey(target); + const stateDir = path.join(directory, ".t3/ssh-launch", stateKey); + const archiveDir = path.join(directory, ".t3/runtime/versions", ARCHIVE.archiveVersion); + yield* fs.makeDirectory(archiveDir, { recursive: true }); + yield* fs.makeDirectory(path.join(customHome, "userdata"), { recursive: true }); + const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // This fixture owns its PID. Even a broken ownership guard cannot signal + // the test runner or a real T3 environment. + const external = yield* realSpawner.spawn( + ChildProcess.make(process.execPath, [ + "-e", + "process.stdin.resume()", + "--", + "serve", + "--host", + "127.0.0.1", + "--port", + "41773", + ]), + ); + yield* fs.writeFileString( + path.join(customHome, "userdata/server-runtime.json"), + JSON.stringify({ pid: external.pid, port: 41773 }), + ); + yield* fs.writeFileString( + path.join(archiveDir, ".install-complete"), + ARCHIVE.archiveVersion, + ); + yield* fs.writeFileString( + path.join(archiveDir, "t3"), + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (args[0] === "serve" || args[0] === "--version") process.exit(0); +if (args[0] !== "__ssh-helper") process.exit(21); +switch (args[1]) { + case "home-dir": process.stdout.write(process.env.T3CODE_HOME); break; + case "runtime-port": { + const runtime = JSON.parse(fs.readFileSync(args[2], "utf8")); + process.stdout.write(runtime.pid + " " + runtime.port); break; + } + case "pick-port": process.stdout.write("41774"); break; + case "wait-ready": { + if (args[2] === "41774" && fs.readFileSync(process.env.T3_FIXTURE_STATE_DIR + "/server-home", "utf8").trim() !== process.env.T3CODE_HOME) process.exit(24); + process.exit(args[2] === process.env.T3_FIXTURE_READY_PORT ? 0 : 22); break; + } + default: process.exit(23); +}`, + { mode: 0o700 }, + ); + yield* fs.writeFileString( + path.join(directory, "ps"), + '#!/bin/sh\nfor arg do pid="$arg"; done\nif [ -n "$T3_FIXTURE_ZOMBIE_PID" ] && [ "$pid" = "$T3_FIXTURE_ZOMBIE_PID" ]; then printf Z; else exec /bin/ps "$@"; fi\n', + { mode: 0o700 }, + ); + let zombiePid = ""; + let readyPort = "41773"; + const localSsh = ChildProcessSpawner.make((command) => { + assert.strictEqual(command._tag, "StandardCommand"); + if (command._tag !== "StandardCommand") return realSpawner.spawn(command); + return realSpawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", stateKey], { + ...command.options, + env: { + HOME: directory, + PATH: `${directory}:/usr/bin:/bin`, + T3_FIXTURE_ZOMBIE_PID: zombiePid, + T3CODE_HOME: customHome, + T3_FIXTURE_READY_PORT: readyPort, + T3_FIXTURE_STATE_DIR: stateDir, + }, + extendEnv: false, + }), + ); + }); + const launch = SshTunnel.launchOrReuseRemoteServer(target, undefined, ARCHIVE).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, localSsh), + ); + assert.deepEqual(yield* launch, { remotePort: 41773, remoteServerKind: "external" }); + assert.strictEqual( + (yield* fs.readFileString(path.join(stateDir, "server-home"))).trim(), + customHome, + ); + assert.isTrue(yield* external.isRunning); + + // A responsive port remembered for the old home cannot stand in for + // an unresponsive service in the newly selected home. + readyPort = "41666"; + yield* fs.remove(path.join(stateDir, "server-home")); + yield* fs.writeFileString(path.join(stateDir, "port"), readyPort); + assert.isTrue(Result.isFailure(yield* Effect.result(launch))); + assert.isFalse(yield* fs.exists(path.join(stateDir, "server-home"))); + assert.isTrue(yield* external.isRunning); + + // Legacy ownership has no server-home file and belongs to ~/.t3. + yield* fs.writeFileString(path.join(stateDir, "managed"), "managed"); + yield* fs.writeFileString(path.join(stateDir, "pid"), String(external.pid)); + yield* fs.writeFileString(path.join(stateDir, "run-t3.sh"), "old runner stays intact"); + const refused = yield* Effect.result(launch); + assert.isTrue(Result.isFailure(refused)); + if (Result.isFailure(refused)) + assert.include(refused.failure.message, "Stop it before changing the T3 home"); + assert.isTrue(yield* external.isRunning); + assert.strictEqual( + yield* fs.readFileString(path.join(stateDir, "run-t3.sh")), + "old runner stays intact", + ); + zombiePid = String(external.pid); + readyPort = "41774"; + assert.deepEqual(yield* launch, { remotePort: 41774, remoteServerKind: "managed" }); + assert.strictEqual( + (yield* fs.readFileString(path.join(stateDir, "server-home"))).trim(), + customHome, + ); + assert.isTrue(yield* external.isRunning); + + // A rejected runtime PID must not hide a live sibling on the selected home. + const sibling = yield* realSpawner.spawn( + ChildProcess.make(process.execPath, [ + "-e", + "process.stdin.resume()", + "--", + "serve", + "--host", + "127.0.0.1", + "--port", + "41775", + ]), + ); + const siblingDir = path.join(directory, ".t3/ssh-launch", "sibling"); + yield* fs.makeDirectory(siblingDir, { recursive: true }); + yield* fs.writeFileString(path.join(siblingDir, "managed"), "managed"); + yield* fs.writeFileString(path.join(siblingDir, "pid"), String(sibling.pid)); + yield* fs.writeFileString(path.join(siblingDir, "port"), "41775"); + yield* fs.writeFileString(path.join(siblingDir, "server-home"), customHome); + readyPort = "41775"; + assert.deepEqual(yield* launch, { remotePort: 41775, remoteServerKind: "external" }); + assert.isTrue(yield* sibling.isRunning); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it.live("distinguishes an incompatible remote Node from a missing executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -1113,6 +1325,14 @@ describe.runIf(HostProcess.Platform.defaultValue() === "linux")("stale managed p const http = require("node:http"); const path = require("node:path"); const args = process.argv.slice(2); +if (args.join(" ") === "__ssh-helper home-dir") { + process.stdout.write(path.join(require("node:os").homedir(), ".t3")); + process.exit(0); +} +if (args[0] === "--version") { + process.stdout.write("1.0.0"); + process.exit(0); +} const flag = (name) => args[args.indexOf(name) + 1]; const server = http.createServer((_request, response) => response.end("ok")); server.listen(Number(flag("--port") ?? 0), "127.0.0.1", () => { @@ -1222,6 +1442,7 @@ server.listen(Number(flag("--port") ?? 0), "127.0.0.1", () => { process.kill(firstPid, "SIGKILL"); yield* fs.remove(`${home}/.t3/userdata/server-runtime.json`); yield* fs.writeFileString(`${stateDir}/pid`, `${staleId}\n`); + yield* fs.writeFileString(`${stateDir}/server-home`, `${home}/old-home`); yield* fs.writeFileString(`${stateDir}/port`, `${holderPort}\n`); const relaunched = yield* launch; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 4a3352ee0507..8efb2759ee4b 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -584,15 +584,14 @@ const REMOTE_LAUNCH_SCRIPT = `set -eu STATE_KEY="$1" LAUNCHER_ID="\${2:-}" STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" -DEFAULT_SERVER_HOME="$HOME/.t3" -DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json" +SERVER_HOME_FILE="$STATE_DIR/server-home" PORT_FILE="$STATE_DIR/port" PID_FILE="$STATE_DIR/pid" MANAGED_FILE="$STATE_DIR/managed" LOG_FILE="$STATE_DIR/server.log" RUNNER_FILE="$STATE_DIR/run-t3.sh" RUNNER_NEXT="$STATE_DIR/run-t3.next.$$" -LAUNCH_LOCK="$DEFAULT_SERVER_HOME/ssh-launch/.launch.lock" +LAUNCH_LOCK="$HOME/.t3/ssh-launch/.launch.lock" LAUNCH_BREAK_LOCK="$LAUNCH_LOCK.break" mkdir -p "$STATE_DIR" cleanup_launch() { @@ -665,6 +664,33 @@ while ! (set -C; printf '%s\\n' "$$" >"$LAUNCH_LOCK") 2>/dev/null; do sleep 1 LAUNCH_LOCK_WAITED=$((LAUNCH_LOCK_WAITED + 1)) done +# Resolve with the new runner before replacing the old one: a failed home +# change must not consume the runner-change signal used for future updates. +DEFAULT_SERVER_HOME="$("$RUNNER_NEXT" __ssh-helper home-dir)" || DEFAULT_SERVER_HOME="" +if [ -z "$DEFAULT_SERVER_HOME" ]; then + printf 'Could not resolve the remote T3 home.\\n' >&2 + exit 1 +fi +# kill -0 also succeeds for a server that exited but was not reaped yet, so +# a zombie does not count as running. Without ps to tell, trust kill -0. +server_running() { + [ -n "$1" ] && kill -0 "$1" 2>/dev/null || return 1 + case "$(ps -o stat= -p "$1" 2>/dev/null || true)" in + *Z*) return 1 ;; + esac +} +@@T3_MANAGED_SERVER_RUNNING_SCRIPT@@ +PREVIOUS_SERVER_HOME="$(cat "$SERVER_HOME_FILE" 2>/dev/null || printf '%s' "$HOME/.t3")" +# A home change must not redirect pairing to another environment while the +# previously managed server is still running. Leave it for an explicit stop. +if [ "$PREVIOUS_SERVER_HOME" != "$DEFAULT_SERVER_HOME" ] && [ "$(cat "$MANAGED_FILE" 2>/dev/null || true)" = "managed" ]; then + PREVIOUS_PID="$(cat "$PID_FILE" 2>/dev/null || true)" + if managed_server_running "$PREVIOUS_PID"; then + printf 'The SSH-managed server still uses %s. Stop it before changing the T3 home to %s.\\n' "$PREVIOUS_SERVER_HOME" "$DEFAULT_SERVER_HOME" >&2 + exit 1 + fi +fi +DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json" RUNNER_CHANGED=0 if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then RUNNER_CHANGED=1 @@ -699,15 +725,6 @@ wait_for_pid_exit() { sleep 0.1 done } -# kill -0 also succeeds for a server that exited but was not reaped yet, so -# a zombie does not count as running. Without ps to tell, trust kill -0. -server_running() { - [ -n "$1" ] && kill -0 "$1" 2>/dev/null || return 1 - case "$(ps -o stat= -p "$1" 2>/dev/null || true)" in - *Z*) return 1 ;; - esac -} -@@T3_MANAGED_SERVER_RUNNING_SCRIPT@@ resolve_default_runtime_port() { if [ "$T3_ARCHIVE_MODE" = "1" ]; then "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE" @@ -756,6 +773,13 @@ NODE REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" +if [ "$PREVIOUS_SERVER_HOME" != "$DEFAULT_SERVER_HOME" ]; then + # A remembered port may belong to an external server using the old home. + # Rediscover against the selected home without stopping that server. + REMOTE_PID="" + REMOTE_PORT="" + REMOTE_MANAGED="" +fi adopt_default_server() { DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" DEFAULT_RUNTIME_PID="" @@ -764,6 +788,11 @@ ADOPTED_PID="" if [ -n "$DEFAULT_RUNTIME_INFO" ]; then DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" + if ! server_running "$DEFAULT_RUNTIME_PID"; then + DEFAULT_RUNTIME_INFO="" + DEFAULT_RUNTIME_PID="" + DEFAULT_REMOTE_PORT="" + fi fi # The managed server runs on the default home, so it writes the default # runtime file too. When that file names the PID this launcher recorded as @@ -795,6 +824,10 @@ elif [ -n "$DEFAULT_REMOTE_PORT" ]; then fi return 0 else + if [ "$PREVIOUS_SERVER_HOME" != "$DEFAULT_SERVER_HOME" ]; then + printf 'The server in %s did not become ready. Retry the connection; the server was left running.\\n' "$DEFAULT_SERVER_HOME" >&2 + exit 1 + fi REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" @@ -808,7 +841,7 @@ return 1 # Another desktop's server stays external. Only this first adoption runs # before the replace decision, which the previous key's runner settles. if adopt_default_server && [ -n "$ADOPTED_PID" ] && [ -n "$LAUNCHER_ID" ]; then - for SIBLING_DIR in "$DEFAULT_SERVER_HOME"/ssh-launch/*; do + for SIBLING_DIR in "$HOME/.t3"/ssh-launch/*; do [ "$SIBLING_DIR" != "$STATE_DIR" ] && [ "$(cat "$SIBLING_DIR/managed" 2>/dev/null || true)" = "managed" ] || continue [ "$(cat "$SIBLING_DIR/pid" 2>/dev/null || true)" = "$ADOPTED_PID" ] && managed_server_running "$ADOPTED_PID" || continue [ "$(cat "$SIBLING_DIR/launcher" 2>/dev/null || true)" = "$LAUNCHER_ID" ] || break @@ -857,8 +890,9 @@ fi # publish one; another alias's launch record still names it. Only one server # owns the home, so the first live one decides. if [ -z "$REMOTE_PORT" ] && [ -z "$DEFAULT_RUNTIME_INFO" ]; then - for SIBLING_DIR in "$DEFAULT_SERVER_HOME"/ssh-launch/*; do + for SIBLING_DIR in "$HOME/.t3"/ssh-launch/*; do [ "$SIBLING_DIR" != "$STATE_DIR" ] && [ "$(cat "$SIBLING_DIR/managed" 2>/dev/null || true)" = "managed" ] || continue + [ "$(cat "$SIBLING_DIR/server-home" 2>/dev/null || printf '%s' "$HOME/.t3")" = "$DEFAULT_SERVER_HOME" ] || continue managed_server_running "$(cat "$SIBLING_DIR/pid" 2>/dev/null || true)" || continue REMOTE_PORT="$(cat "$SIBLING_DIR/port" 2>/dev/null || true)" if [ -n "$REMOTE_PORT" ] && wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then @@ -910,6 +944,7 @@ if [ -z "$REMOTE_PORT" ]; then REMOTE_PID="$!" printf '%s\\n' "$REMOTE_PID" >"$PID_FILE" printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" + printf '%s\\n' "$DEFAULT_SERVER_HOME" >"$SERVER_HOME_FILE" printf 'managed\\n' >"$MANAGED_FILE" # A stop keeps the launcher file; it must not name this server's owner. if [ -z "$LAUNCHER_ID" ]; then @@ -936,19 +971,27 @@ if [ -z "$REMOTE_PORT" ]; then sleep 0.1 done fi +printf '%s\\n' "$DEFAULT_SERVER_HOME" >"$SERVER_HOME_FILE" printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}" `; const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" -DEFAULT_SERVER_HOME="$HOME/.t3" RUNNER_FILE="$STATE_DIR/run-t3.sh" mkdir -p "$STATE_DIR" cat >"$RUNNER_FILE" <<'SH' @@T3_RUNNER_SCRIPT@@ SH chmod 700 "$RUNNER_FILE" -PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" +if [ -f "$STATE_DIR/server-home" ]; then + PAIRING_BASE_DIR="$(cat "$STATE_DIR/server-home")" +else + PAIRING_BASE_DIR="$("$RUNNER_FILE" __ssh-helper home-dir)" || PAIRING_BASE_DIR="" +fi +if [ -z "$PAIRING_BASE_DIR" ]; then + printf 'Could not resolve the remote T3 home for pairing.\\n' >&2 + exit 1 +fi "$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json `;