Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
133 changes: 133 additions & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
DeviceBootError,
DeviceError,
DeviceId,
DeviceOperationError,
LOCAL_DEVICE_HOST_ID,
Expand All @@ -13,6 +14,7 @@ import {
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NetService from "@t3tools/shared/Net";
import { SshCommandError } from "@t3tools/ssh/errors";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Deferred from "effect/Deferred";
Expand All @@ -37,6 +39,7 @@ import { makeDeviceHubOperations } from "./DeviceHubOperations.ts";
import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";

import * as DeviceService from "./DeviceService.ts";
import * as SshDeviceHost from "./SshDeviceHost.ts";
import { agentDeviceSession, releaseAgentDeviceSession } from "./AgentDeviceTarget.ts";

const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
Expand Down Expand Up @@ -315,6 +318,7 @@ const fixture = Effect.fn("fixture")(function* (
};
return {
service,
host,
starts,
agentStarts,
agentStops,
Expand Down Expand Up @@ -361,6 +365,61 @@ describe("device setup consent", () => {
}).pipe(Effect.scoped),
);

it.effect.each([
{
name: "a Windows host",
exitCode: 1,
stderr: "private diagnostics",
expected:
"Connected over SSH, but this host runs Windows, and SSH device hosts currently need macOS or Linux. To use this machine's devices, run T3 Code on it and connect to that environment.",
},
{
name: "a Windows host returning 9009",
exitCode: 9009,
stderr: "private diagnostics",
expected:
"Connected over SSH, but this host runs Windows, and SSH device hosts currently need macOS or Linux. To use this machine's devices, run T3 Code on it and connect to that environment.",
},
{
name: "missing Node on a Windows host with a POSIX shell",
exitCode: 1,
stderr: "private diagnostics\nNode is missing from the non-interactive SSH PATH",
expected:
"Connected over SSH, but Node is not on this host's PATH for non-interactive SSH commands.",
},
])(
"reports SSH probe failure on $name through readiness and host status",
({ exitCode, stderr, expected }) =>
Effect.gen(function* () {
const failure = new DeviceHost.DeviceHostError({
hostId: LOCAL_DEVICE_HOST_ID,
step: "probe",
cause: new SshDeviceHost.SshWindowsHostError({
cause: new SshCommandError({
command: ["ssh", "private arguments"],
exitCode,
stderr,
message: "private diagnostics",
}),
}),
});
const { service, settings } = yield* fixture(Effect.void, undefined, false, failure);
yield* Ref.update(settings, (current) => ({
...current,
enableDeviceSupport: true,
enableAgentDeviceAccess: true,
}));
for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) {
const error = yield* readiness.pipe(Effect.flip);
expect(error).toMatchObject({ reason: expected, cause: failure });
expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({
status: "failed",
detail: expected,
});
}
}).pipe(Effect.scoped),
);

it.effect("listing and provider startup do not start helpers before consent", () =>
Effect.gen(function* () {
const { service, starts, requests } = yield* fixture();
Expand Down Expand Up @@ -540,6 +599,80 @@ const layerSecretResolver = Layer.succeed(SecretResolver.SecretResolver, {
invalidate: () => Effect.void,
});

it.effect.each([1, 9009])(
"Test connection preserves Windows exit %s guidance across the wire",
(exitCode) =>
Effect.gen(function* () {
const { host } = yield* fixture();
const service = yield* DeviceService.make.pipe(
Effect.provideService(DeviceHost.DeviceHost, host),
Effect.provideService(ProcessRunner.ProcessRunner, {
run: () => Effect.die("Testing SSH does not install local tools"),
}),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("Testing SSH makes no hub requests")),
),
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
if (command._tag !== "StandardCommand") return Effect.die("Unexpected command");
const config = command.args.includes("-G");
const windows = command.args.includes("echo %OS% $env:OS");
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(123),
stdout: Stream.make(
new TextEncoder().encode(
config ? "hostname 192.0.2.1\nport 22\n" : windows ? "Windows_NT\r\n" : "",
),
),
stderr: Stream.make(new TextEncoder().encode("private SSH diagnostics")),
all: Stream.empty,
exitCode: Effect.succeed(
ChildProcessSpawner.ExitCode(config || windows ? 0 : exitCode),
),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: Sink.drain,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
}),
);
}),
),
);
const error = yield* service
.testHost({ kind: "ssh", id: "windows", label: "Windows test", target: "windows.test" })
.pipe(Effect.flip);
expect(error).toMatchObject({
_tag: "DeviceHostUnavailableError",
hostId: "windows",
hostLabel: "Windows test",
reason:
"Connected over SSH, but this host runs Windows, and SSH device hosts currently need macOS or Linux. To use this machine's devices, run T3 Code on it and connect to that environment.",
});
const codec = Schema.fromJsonString(DeviceError);
const decoded = yield* Schema.decodeEffect(codec)(yield* Schema.encodeEffect(codec)(error));
expect(decoded.message).toBe(
"Windows test is unavailable: Connected over SSH, but this host runs Windows, and SSH device hosts currently need macOS or Linux. To use this machine's devices, run T3 Code on it and connect to that environment.",
);
expect(decoded.message).not.toContain("private SSH diagnostics");
}).pipe(
Effect.provide(
Layer.mergeAll(
ServerSettings.layerTest(),
layerSecretResolver,
ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-probe-" }),
NodeCrypto.layer,
NetService.layer,
).pipe(Layer.provideMerge(NodeServices.layer)),
),
Effect.scoped,
),
);

it.effect("publishes boot progress and does not restore sessions after support is disabled", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
Expand Down
26 changes: 17 additions & 9 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ import * as LocalDeviceHost from "./LocalDeviceHost.ts";
export const DEVICE_HUB_ROUTE_PREFIX = "/api/device-hub";

const sshProbeFailureMessage = (error: DeviceHost.DeviceHostError): string => {
const cause = error.cause;
const windows = SshDeviceHost.isSshWindowsHostError(error.cause) ? error.cause : null;
const cause = windows ? windows.cause : error.cause;
if (
!(cause instanceof SshCommandError) ||
cause.exitCode === null ||
cause.exitCode < 1 ||
cause.exitCode > 254
(cause.exitCode > 254 && cause.exitCode !== 9009)
)
return "Could not connect to this host over SSH.";

Expand All @@ -99,6 +100,7 @@ const sshProbeFailureMessage = (error: DeviceHost.DeviceHostError): string => {
.some((line) => line.trim() === "Node is missing from the non-interactive SSH PATH")
)
return "Connected over SSH, but Node is not on this host's PATH for non-interactive SSH commands.";
if (windows) return windows.message;

return `Connected over SSH, but the device host check failed (exit code ${cause.exitCode}).`;
};
Expand Down Expand Up @@ -1354,13 +1356,19 @@ export const make = Effect.gen(function* () {
}
return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext));
}).pipe(
Effect.mapError(
(error) =>
new DeviceOperationError({
operation: "probe host",
reason: "request_failed",
cause: error,
}),
Effect.mapError((error) =>
error._tag === "DeviceHostError" && SshDeviceHost.isSshWindowsHostError(error.cause)
? new DeviceHostUnavailableError({
hostId: host.id,
hostLabel: host.label,
reason: sshProbeFailureMessage(error),
cause: error,
})
: new DeviceOperationError({
operation: "probe host",
reason: "request_failed",
cause: error,
}),
),
),
configureAgent,
Expand Down
116 changes: 116 additions & 0 deletions apps/server/src/device/SshDeviceHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,119 @@ it.effect(
Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, TestClock.layer())),
),
);

it.effect.each([
{
name: "a Windows PowerShell login shell",
exitCode: 1,
stderr: "sh : The term 'sh' is not recognized as the name of a cmdlet\r\n",
shellEcho: "%OS%\r\nWindows_NT\r\n",
nodePlatform: null,
windows: true,
},
{
name: "a Windows cmd login shell",
exitCode: 1,
stderr: "'sh' is not recognized as an internal or external command,\r\n",
shellEcho: "Windows_NT $env:OS\r\n",
nodePlatform: null,
windows: true,
},
{
name: "a Windows cmd login shell returning 9009",
exitCode: 9009,
stderr: "'sh' is not recognized as an internal or external command,\r\n",
shellEcho: "Windows_NT $env:OS\r\n",
nodePlatform: null,
windows: true,
},
{
name: "a Windows Git Bash login shell without a spawnable npm",
exitCode: 1,
stderr: "npm is missing from the non-interactive SSH PATH\n",
shellEcho: "%OS% :OS\n",
nodePlatform: "win32",
windows: true,
},
{
name: "a Linux host without npm",
exitCode: 1,
stderr: "npm is missing from the non-interactive SSH PATH\n",
shellEcho: "%OS% :OS\n",
nodePlatform: "linux",
windows: false,
},
{
name: "a POSIX host without Node",
exitCode: 1,
stderr: "Node is missing from the non-interactive SSH PATH\n",
shellEcho: "%OS% :OS\n",
nodePlatform: null,
windows: false,
},
{
name: "a connection failure",
exitCode: 255,
stderr: "Connection refused\n",
shellEcho: "Windows_NT\n",
nodePlatform: "win32",
windows: false,
},
])(
"probe failure on $name keeps the SSH cause",
({ exitCode, stderr, shellEcho, nodePlatform, windows }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const home = yield* fs.makeTempDirectoryScoped();
const shellChecks: string[] = [];
const spawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
const remote = command.args.slice(command.args.indexOf("test.example") + 1).join(" ");
const shellCheck = remote === "echo %OS% $env:OS";
const platformCheck = remote === "node -p process.platform";
if (shellCheck || platformCheck) shellChecks.push(remote);
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(123),
stdout: Stream.make(
new TextEncoder().encode(
shellCheck ? shellEcho : platformCheck ? `${nodePlatform ?? ""}\n` : "",
),
),
stderr: Stream.make(
new TextEncoder().encode(shellCheck || platformCheck ? "" : stderr),
),
all: Stream.empty,
exitCode: Effect.succeed(
ChildProcessSpawner.ExitCode(
shellCheck ? 0 : platformCheck ? (nodePlatform === null ? 1 : 0) : exitCode,
),
),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: Sink.drain,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
});
}),
);
const failure = yield* SshDeviceHost.probe({
kind: "ssh",
id: "test",
label: "Test",
target: "test.example",
}).pipe(
Effect.provide(ServerConfig.layerTest(home, home)),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.flip,
);
expect(failure).toMatchObject({ _tag: "DeviceHostError", step: "probe" });
const sshFailure = { _tag: "SshCommandError", exitCode, stderr };
expect(failure.cause).toMatchObject(
windows ? { _tag: "SshWindowsHostError", cause: sshFailure } : sshFailure,
);
// A transport failure has no remote shell to ask.
if (exitCode === 255) expect(shellChecks).toEqual([]);
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);
Loading
Loading