From e231a1f10146084b6afffb9d6c0d474a06e9919f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 14:05:46 +0000 Subject: [PATCH 1/4] test(server): reproduce #30870, linking failures hide why each address failed These tests fail on main: a picked-machine link drops each address's reason (one answers as another environment, one is plain http), and a link to a closed port reports the same "Nothing at answered as a T3 Code environment." as a server that answers but is not T3 Code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinks.test.ts | 31 +++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 36c9b09fb4459..ea3e39839ccb9 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -623,9 +623,10 @@ 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.`, - ); + // Each address says why it was not Box. + expect(failed.message).toContain("could not reach Box"); + expect(failed.message).toContain("Impostor"); + expect(failed.message).toContain("plain http"); expect(yield* a.links.list).toEqual([]); expect(yield* linkedSessions(impostor)).toEqual([]); @@ -644,3 +645,27 @@ it.effect("linking a picked machine that answers nowhere names it and links noth }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("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.html("

Not Found

", { status: 404 })), + ); + const a = yield* makeA(laptop); + const linking = (url: string) => + a.links.link({ url, pairingCode: "unused", access: "auto" }).pipe(Effect.flip); + const refused = yield* linking(closed); + const answered = yield* linking(notT3); + expect(answered.message).toContain(notT3); + expect(refused.message).toContain(closed); + expect(refused.message.replace(closed, "")).not.toBe( + answered.message.replace(notT3, ""), + ); + expect(yield* a.links.list).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); From ebf885775b97e69ab570c0406599dba3ab3f70ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 14:14:08 +0000 Subject: [PATCH 2/4] fix(server): linking says why each address failed discovery Linking another environment threw away why discovery failed. The probe ended in Effect.option, so a refused connection, a DNS failure, a timeout and an HTTP server that is not T3 Code all read as "could not reach" or "Nothing at answered as a T3 Code environment." The descriptor probe now fails with a short reason: did not answer (with the system code such as ECONNREFUSED or ENOTFOUND), did not answer within N seconds, or answered but not as a T3 Code environment this version understands. A picked machine lists every address's reason, including one that answers as another environment. Linking by address reports the primary's reason. describe() still returns an Option, and the error reason tags are unchanged. The reproduction test now serves a real HTML 404 instead of passing the html template tag a plain string. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinks.test.ts | 4 +- apps/server/src/peer/PeerLinks.ts | 86 ++++++++++++++++++-------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index ea3e39839ccb9..6110cd4afa904 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -653,7 +653,9 @@ it.effect("linking an address with nothing listening says so, unlike one that is serveRaw(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))), ); const notT3 = yield* serveRaw(() => - Effect.succeed(HttpServerResponse.html("

Not Found

", { status: 404 })), + Effect.succeed( + HttpServerResponse.text("

Not Found

", { status: 404, contentType: "text/html" }), + ), ); const a = yield* makeA(laptop); const linking = (url: string) => diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index dfcab9f11f273..961e2be3ca84c 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -12,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"; @@ -22,12 +23,14 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; 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"; @@ -163,6 +166,28 @@ 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, +) => { + if (Cause.isTimeoutError(error)) { + return `${origin} did not answer within ${Duration.toSeconds(timeout)} seconds.`; + } + if (HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError") { + const code = connectionCode(error.reason.cause); + return code === undefined ? `${origin} did not answer.` : `${origin} did not answer (${code}).`; + } + return `${origin} answered, but not as a T3 Code environment this version understands.`; +}; + /** Whether a peer holds a linked agent to its limits and keeps its work within its link. */ const linkLimitsHold = (peer: { readonly capabilities: ExecutionEnvironmentCapabilities }) => peer.capabilities.mcpModeLimitHeader === true && peer.capabilities.linkFence === true; @@ -197,13 +222,25 @@ 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. */ - 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()), - Effect.timeout(probeTimeout(origin)), - Effect.option, + Effect.timeout(timeout), + // No cause: a decode error can carry the response body to the client. + Effect.mapError( + (error) => + new PeerLinkError({ + reason: "unreachable", + message: probeFailure(origin, error, timeout), + }), + ), ); + }; + + /** The environment answering at `origin`, or nothing when it does not answer as T3 Code. */ + const describe = (origin: string) => probe(origin).pipe(Effect.option); const toLink = Effect.fn("PeerLinks.toLink")(function* (row: PeerLinkRow) { return { @@ -353,7 +390,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, @@ -365,28 +403,28 @@ const make = Effect.gen(function* () { (url) => Effect.gen(function* () { const origin = yield* peerOrigin(url); - 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 ${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. @@ -398,14 +436,12 @@ 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 }; + const peer = yield* probe(primary).pipe( + Effect.mapError( + ({ message }) => new PeerLinkError({ reason: "not_a_t3_environment", message }), + ), + ); + return { primary, peer, origins }; }); const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { From 79c443d247d86039747be2122179af69dfcafe30 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 14:15:42 +0000 Subject: [PATCH 3/4] fix(server): name the other environment an address answers as Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 961e2be3ca84c..b402bfcef58d8 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -407,7 +407,7 @@ const make = Effect.gen(function* () { if (descriptor.environmentId !== environmentId) { return yield* new PeerLinkError({ reason: "unreachable", - message: `${origin} answered as ${descriptor.label}.`, + message: `${origin} answered as another environment, ${descriptor.label}.`, }); } return { origin, descriptor }; From d542dd8f4798e352cbc4b4e2533dc248f0cb0063 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 14:23:37 +0000 Subject: [PATCH 4/4] test(server): pin the picked-machine link failure message Co-Authored-By: Claude Opus 5.5 --- apps/server/src/peer/PeerLinks.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 6110cd4afa904..2a71dc81b53c1 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -624,9 +624,9 @@ it.effect("linking a picked machine that answers nowhere names it and links noth .pipe(Effect.flip); expect(failed.reason).toBe("unreachable"); // Each address says why it was not Box. - expect(failed.message).toContain("could not reach Box"); - expect(failed.message).toContain("Impostor"); - expect(failed.message).toContain("plain http"); + expect(failed.message).toBe( + `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([]);