Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9f7f09f
fix(ssh): 使用远端自定义目录启动和配对环境
nateEc Oct 23, 2026
f379cbe
Merge remote-tracking branch 'github/main' into takeover-24100
maria-rcks Oct 26, 2026
e275384
test(ssh): keep service home coverage in the existing service tests
maria-rcks Oct 26, 2026
ca82cea
fix(ssh): ignore exited managed processes when changing home
maria-rcks Oct 26, 2026
354671b
chore: merge main into ssh home fix
maria-rcks Nov 1, 2026
7ec6e48
fix(ssh): preserve home during managed launch recovery
maria-rcks Nov 1, 2026
d33e7cc
Merge remote-tracking branch 'github/main' into takeover-24100
maria-rcks Nov 10, 2026
079bdfc
test(ssh): provide Digest to the custom-home tunnel tests
maria-rcks Nov 10, 2026
ce8decb
Merge remote-tracking branch 'github/main' into pr-24100
maria-rcks Nov 10, 2026
984c585
fix(ssh): find the installed service unit where the installer writes it
maria-rcks Nov 10, 2026
669e161
test(ssh): teach main's launch fixtures the home-dir and version calls
maria-rcks Nov 10, 2026
7832302
fix(ssh): report a failed home lookup instead of exiting silently
maria-rcks Nov 10, 2026
762bcbd
chore: merge main into ssh custom home fix
maria-rcks Nov 11, 2026
20841d4
test(ssh): support home discovery in stale pid fixtures
maria-rcks Nov 11, 2026
ad7fd46
fix(ssh): preserve service discovery and managed process checks
maria-rcks Nov 11, 2026
3af36fa
fix(ssh): 丢弃失效运行记录后复用同目录服务
nateEc Nov 12, 2026
3f459d0
Merge remote-tracking branch 'origin/main' into codex/fix-17039-ssh-c…
juliusmarminge Nov 12, 2026
f70944e
refactor(ssh): resolve the service home through bootServiceUnitPath
juliusmarminge Nov 12, 2026
ea14a6b
Merge remote-tracking branch 'origin/main' into codex/fix-17039-ssh-c…
juliusmarminge Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion apps/server/src/cli/sshHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>((resolve) => {
Expand Down Expand Up @@ -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]),
);
90 changes: 89 additions & 1 deletion apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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)),
);
2 changes: 2 additions & 0 deletions packages/ssh/src/launcherTestHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ;;
Expand Down
Loading
Loading