Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
65 changes: 64 additions & 1 deletion apps/server/src/cli/sshHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,71 @@ 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 Path from "effect/Path";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { Argument, Command } from "effect/cli";

import {
bootServiceBaseDirOf,
BOOT_SERVICE_PLIST_FILE,
BOOT_SERVICE_UNIT_FILE,
defaultSystemdUnitDir,
readSystemdUnitDirectory,
} from "../cloud/bootService.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 path = yield* Path.Path;
const home = yield* HostProcess.HomeDirectory;
const platform = yield* HostProcess.Platform;
const uid = yield* HostProcess.UserId;
const processRunner = yield* ProcessRunner.ProcessRunner;
const userManagerEnv =
platform === "linux" &&
!environment.XDG_RUNTIME_DIR &&
uid !== undefined &&
!(uid === 0 && environment.SUDO_USER)
? { XDG_RUNTIME_DIR: `/run/user/${uid}` }
: undefined;
const runner: ProcessRunner.ProcessRunner["Service"] = {
run: (request) => processRunner.run({ ...request, env: { ...userManagerEnv, ...request.env } }),
};
const serviceFile =
platform === "darwin"
? path.join(home, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE)
: path.join(
(yield* readSystemdUnitDirectory(runner, path)) ??
defaultSystemdUnitDir(path, home, environment.XDG_CONFIG_HOME ?? ""),
BOOT_SERVICE_UNIT_FILE,
);
const service = 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) => {
const server = NodeNet.createServer();
Expand Down Expand Up @@ -126,5 +189,5 @@ const runtimePort = Command.make("runtime-port", {

export const sshHelperCommand = Command.make("__ssh-helper").pipe(
Command.unlisted,
Command.withSubcommands([pickPort, waitReady, runtimePort]),
Command.withSubcommands([pickPort, waitReady, runtimePort, homeDir]),
);
95 changes: 94 additions & 1 deletion apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @effect-diagnostics-next-line nodeBuiltinImport:off -- Cross-process launcher contention uses real filesystem leases and IPC barriers.
import * as NodeChildProcess from "node:child_process";
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 @@ -18,6 +18,7 @@ import { vi } from "vite-plus/test";

import * as ProcessRunner from "../processRunner.ts";
import * as BootService from "./bootService.ts";
import { resolveSshServerHome } from "../cli/sshHelper.ts";
import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
import {
parseServiceState,
Expand Down Expand Up @@ -1336,3 +1337,95 @@ 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") {
const xdgConfigHome = path.join(directory, "relocated config");
assert.strictEqual(
yield* resolve({ XDG_CONFIG_HOME: xdgConfigHome }),
path.join(directory, ".t3"),
);
yield* fs.makeDirectory(path.join(xdgConfigHome, "systemd/user"), { recursive: true });
yield* fs.rename(servicePath, path.join(xdgConfigHome, "systemd/user/t3code.service"));
assert.strictEqual(yield* resolve({ XDG_CONFIG_HOME: xdgConfigHome }), serviceHome);
const managerDirectory = path.join(xdgConfigHome, "systemd/user");
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)),
);
4 changes: 2 additions & 2 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export interface BootServiceManager {
readonly finalize: ReadonlyArray<BootServiceStep>;
}

const defaultSystemdUnitDir = (path: Path.Path, homeDir: string, xdgConfigHome: string) =>
export const defaultSystemdUnitDir = (path: Path.Path, homeDir: string, xdgConfigHome: string) =>
path.join(
path.isAbsolute(xdgConfigHome) ? xdgConfigHome : path.join(homeDir, ".config"),
"systemd",
Expand Down Expand Up @@ -373,7 +373,7 @@ const decodeSystemdUnitPaths = Schema.decodeUnknownEffect(
);

// The manager may not share the installer's environment; JSON preserves spaces in its paths.
const readSystemdUnitDirectory = Effect.fn("cloud.boot_service.read_systemd_unit_directory")(
export const readSystemdUnitDirectory = Effect.fn("cloud.boot_service.read_systemd_unit_directory")(
function* (runner: ProcessRunner.ProcessRunner["Service"], path: Path.Path) {
const result = yield* runner.run({
command: "busctl",
Expand Down
Loading
Loading