diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts
index f35e760bea4d7..8a55a0d119041 100644
--- a/apps/server/src/peer/PeerLinks.test.ts
+++ b/apps/server/src/peer/PeerLinks.test.ts
@@ -742,7 +742,7 @@ it.effect("linking a picked machine that answers nowhere names it and links noth
.pipe(Effect.flip);
expect(failed.reason).toBe("unreachable");
expect(failed.message).toBe(
- `This environment could not reach Box at ${impostor.url}, http://192.168.1.20:3773.`,
+ `This environment could not reach Box: ${new URL(impostor.url).origin} answered as another environment, Impostor. http://192.168.1.20:3773 is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.`,
);
expect(yield* a.links.list).toEqual([]);
expect(yield* linkedSessions(impostor)).toEqual([]);
@@ -763,6 +763,29 @@ it.effect("linking a picked machine that answers nowhere names it and links noth
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
+it.live("linking an address with nothing listening says so, unlike one that is not T3 Code", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const closed = yield* Effect.scoped(
+ serveRaw(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
+ );
+ const notT3 = yield* serveRaw(() =>
+ Effect.succeed(
+ HttpServerResponse.text("
Not Found
", { status: 404, contentType: "text/html" }),
+ ),
+ );
+ const a = yield* makeA(laptop);
+ const linking = (url: string) =>
+ a.links.link({ url, pairingCode: "unused", access: "auto" }).pipe(Effect.flip);
+ expect((yield* linking(closed)).message).toBe(`${closed} did not answer (ECONNREFUSED).`);
+ expect((yield* linking(notT3)).message).toBe(
+ `${notT3} answered, but not as a T3 Code environment this version understands.`,
+ );
+ expect(yield* a.links.list).toEqual([]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+);
+
/** An https listener whose certificate this machine does not trust. */
const serveUntrusted = Layer.build(
Layer.fresh(
diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts
index 550d3ed757f07..b9c70127971b6 100644
--- a/apps/server/src/peer/PeerLinks.ts
+++ b/apps/server/src/peer/PeerLinks.ts
@@ -5,7 +5,6 @@ import {
EnvironmentHttpApi,
EnvironmentId,
type ExecutionEnvironmentCapabilities,
- type ExecutionEnvironmentDescriptor,
MCP_PEER_LINK_SOFTWARE_ID,
type PeerLink,
type PeerLinkCreateInput,
@@ -13,6 +12,7 @@ import {
type PeerLinkSummary,
} from "@t3tools/contracts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Digest from "effect/crypto/Digest";
import * as SecureRandom from "effect/crypto/SecureRandom";
@@ -31,6 +31,7 @@ import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientError from "effect/http/HttpClientError";
import * as HttpApi from "effect/http-api/HttpApi";
import * as HttpApiClient from "effect/http-api/HttpApiClient";
import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassification";
@@ -176,6 +177,38 @@ const peerOrigin = (url: string) => {
return Effect.succeed(parsed.origin);
};
+/** The system error under a failed connection, such as ECONNREFUSED or ENOTFOUND. */
+const connectionCode = (cause: unknown) =>
+ [cause, Predicate.hasProperty(cause, "cause") ? cause.cause : undefined]
+ .map((error) => (Predicate.hasProperty(error, "code") ? error.code : undefined))
+ .find(Predicate.isString);
+
+/** Why `origin` did not answer as T3 Code, short enough for a toast. Never echoes a body. */
+const probeFailure = (
+ origin: string,
+ error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError,
+ timeout: Duration.Duration,
+) => {
+ const unreachable = (message: string) => new PeerLinkError({ reason: "unreachable", message });
+ const certificate = certificateErrorCode(error);
+ if (certificate !== undefined) {
+ return unreachable(`${origin} presented a certificate this machine rejected (${certificate}).`);
+ }
+ if (Cause.isTimeoutError(error)) {
+ return unreachable(`${origin} did not answer within ${Duration.toSeconds(timeout)} seconds.`);
+ }
+ if (HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError") {
+ const code = connectionCode(error.reason.cause);
+ return unreachable(
+ code === undefined ? `${origin} did not answer.` : `${origin} did not answer (${code}).`,
+ );
+ }
+ return new PeerLinkError({
+ reason: "not_a_t3_environment",
+ message: `${origin} answered, but not as a T3 Code environment this version understands.`,
+ });
+};
+
/** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */
export const LoopbackHost = Context.Reference<(host: string) => boolean>(
"t3/peer/PeerLinks/LoopbackHost",
@@ -217,13 +250,10 @@ const make = Effect.gen(function* () {
Effect.provideService(HttpClient.HttpClient, peerHttpClient),
);
- /**
- * The environment answering at `origin`, or nothing when it does not answer
- * as T3 Code. A certificate this machine rejects fails instead, since
- * the peer may well be there.
- */
- const describe = (origin: string) =>
- clientFor(origin).pipe(
+ /** The environment answering at `origin`, or why nothing there answers as T3 Code. */
+ const probe = (origin: string) => {
+ const timeout = probeTimeout(origin);
+ return clientFor(origin).pipe(
Effect.flatMap((client) => client.metadata.descriptor()),
// A brief network drop must not list a live peer as down; the timeout still bounds both tries.
Effect.retry({
@@ -231,27 +261,18 @@ const make = Effect.gen(function* () {
schedule: Schedule.spaced("1 second"),
while: (error) => certificateErrorCode(error) === undefined,
}),
- Effect.timeout(probeTimeout(origin)),
- Effect.asSome,
- Effect.catch((error) => {
- const code = certificateErrorCode(error);
- return code === undefined
- ? Effect.succeed(Option.none())
- : Effect.fail(
- new PeerLinkError({
- reason: "unreachable",
- message: `${origin} presented a certificate this machine rejected (${code}).`,
- }),
- );
- }),
+ Effect.timeout(timeout),
+ // No cause: a decode error can carry the response body to the client.
+ Effect.mapError((error) => probeFailure(origin, error, timeout)),
);
+ };
- /** Whether `url` answers as `environmentId`, counting an untrusted certificate as no. */
+ /** Whether `url` answers as `environmentId`, counting any failure as no. */
const answersAs = Effect.fn("PeerLinks.answersAs")(function* (
url: string,
environmentId: EnvironmentId,
) {
- const found = yield* describe(url).pipe(Effect.orElseSucceed(() => Option.none()));
+ const found = yield* probe(url).pipe(Effect.option);
return Option.filter(found, (peer) => peer.environmentId === environmentId);
});
@@ -404,7 +425,8 @@ const make = Effect.gen(function* () {
/**
* The addresses that answer as `environmentId`, in the order given, with
* what the first one answered. Addresses this environment would not send
- * credentials to are skipped; when every one is, the failure says why.
+ * credentials to are skipped; when none answers as it, the failure says why
+ * for each.
*/
const answeringAs = Effect.fn("PeerLinks.answeringAs")(function* (
urls: ReadonlyArray,
@@ -423,28 +445,28 @@ const make = Effect.gen(function* () {
message: `${origin} is this machine, not ${name}.`,
});
}
- return { origin, descriptor: yield* describe(origin) };
+ const descriptor = yield* probe(origin);
+ if (descriptor.environmentId !== environmentId) {
+ return yield* new PeerLinkError({
+ reason: "unreachable",
+ message: `${origin} answered as another environment, ${descriptor.label}.`,
+ });
+ }
+ return { origin, descriptor };
}).pipe(Effect.result),
{ concurrency: "unbounded" },
);
const matching = checked.flatMap((result) =>
- Result.isSuccess(result) &&
- Option.isSome(result.success.descriptor) &&
- result.success.descriptor.value.environmentId === environmentId
- ? [{ origin: result.success.origin, descriptor: result.success.descriptor.value }]
- : [],
+ Result.isSuccess(result) ? [result.success] : [],
);
const first = matching[0];
if (first === undefined) {
- const refusals = checked.flatMap((result) =>
+ const reasons = checked.flatMap((result) =>
Result.isFailure(result) ? [result.failure.message] : [],
);
return yield* new PeerLinkError({
reason: "unreachable",
- message:
- refusals.length === checked.length
- ? `This environment could not reach ${name}: ${refusals.join(" ")}`
- : `This environment could not reach ${name} at ${urls.join(", ")}.`,
+ message: `This environment could not reach ${name}: ${reasons.join(" ")}`,
});
}
// Each address is kept once, even when two spellings share an origin.
@@ -456,14 +478,7 @@ const make = Effect.gen(function* () {
const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray) {
const origins = yield* Effect.forEach(urls, peerOrigin);
const primary = origins[0]!;
- const descriptor = yield* describe(primary);
- if (Option.isNone(descriptor)) {
- return yield* new PeerLinkError({
- reason: "not_a_t3_environment",
- message: `Nothing at ${primary} answered as a T3 Code environment.`,
- });
- }
- return { primary, peer: descriptor.value, origins };
+ return { primary, peer: yield* probe(primary), origins };
});
const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) {