From 02ba53e3f7a54ae9cd325abc190f067819a86229 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:08:05 +0000 Subject: [PATCH 1/3] test(server): a link the peer revoked is not listed as reachable Fails on main: after the peer revokes the link's session and an authenticated call gets 401, PeerLinks.list still reports the link as reachable because statusOf only probes the unauthenticated descriptor. Refs pingdotgg/t3code#26772 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/peer/PeerLinks.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index f35e760bea4d7..bba02b67054a9 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -581,6 +581,8 @@ it.effect("tells the agent to link again once the peer revokes the link or it ex const revoked = yield* probe(a).pipe(Effect.flip); expect(revoked.code).toBe("capability_denied"); expect(revoked.message).toContain("revoked there"); + // The peer refuses this link's token, so listing must not call it usable. + expect((yield* a.links.list).map((link) => link.status)).not.toContain("reachable"); yield* a.links.link({ url: b.url, From 583ddfd631ae408efe0b46455ca2287b0e0663f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:14:16 +0000 Subject: [PATCH 2/3] fix(server): a link the peer revoked no longer lists as reachable Listing only probed the peer's unauthenticated descriptor, so a link whose token the peer had revoked stayed "reachable" while every call got a 401. When the peer refuses the link's token, record that by ending the link now: it lists as expired, like any link only a new pairing code brings back, and calls stop sending the dead token. Fixes pingdotgg/t3code#26772 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/peer/PeerLinks.ts | 21 ++++++++++++++------ apps/server/src/peer/PeerMcpClient.ts | 28 ++++++++++++++------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 48f61e6554a17..12013ecf578ec 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -106,10 +106,13 @@ export class PeerLinks extends Context.Service< readonly resolve: ( environmentId: EnvironmentId, ) => Effect.Effect; - /** Records the outcome of a call, so listing shows whether the peer answered last time. */ + /** + * Records the outcome of a call, so listing shows whether the peer answered last time. + * `refused` means the peer no longer accepts the link's token, so only linking again helps. + */ readonly recordOutcome: ( environmentId: EnvironmentId, - outcome: { readonly error: string | null }, + outcome: { readonly error: string | null; readonly refused?: boolean }, ) => Effect.Effect; } >()("t3/peer/PeerLinks") {} @@ -686,10 +689,16 @@ const make = Effect.gen(function* () { SET last_reached_at = ${DateTime.formatIso(now)}, last_error = NULL WHERE environment_id = ${environmentId} ` - : sql` - UPDATE peer_environment_links SET last_error = ${outcome.error} - WHERE environment_id = ${environmentId} - `, + : outcome.refused === true + ? sql` + UPDATE peer_environment_links + SET last_error = ${outcome.error}, expires_at = ${DateTime.formatIso(now)} + WHERE environment_id = ${environmentId} + ` + : sql` + UPDATE peer_environment_links SET last_error = ${outcome.error} + WHERE environment_id = ${environmentId} + `, ), Effect.asVoid, Effect.ignoreCause({ log: true }), diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index e800333fb1e08..3ab453ff2cab7 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -90,6 +90,13 @@ const linkedEnvironmentsOffThere = new OrchestratorMcpFailure({ "The linked environment has Linked environments turned off. Turn it on in its Settings → Labs.", }); +/** The peer turned away the link's token: it was revoked or expired there. */ +const linkRefused = new OrchestratorMcpFailure({ + code: "capability_denied", + message: + "The linked environment no longer accepts this link. It may have been revoked there; link it again.", +}); + /** The body of an MCP response: JSON, or a single server-sent event carrying JSON. */ const responseJson = (contentType: string | undefined, text: string) => contentType?.includes("text/event-stream") === true @@ -140,14 +147,12 @@ const make = Effect.gen(function* () { limitHeader, ); const sessionId = initialized.headers["mcp-session-id"]; + if (initialized.status === 401) return yield* linkRefused; if (initialized.status === 403) return yield* linkedEnvironmentsOffThere; if (initialized.status !== 200 || sessionId === undefined) { return yield* new OrchestratorMcpFailure({ - code: initialized.status === 401 ? "capability_denied" : "orchestration_error", - message: - initialized.status === 401 - ? "The linked environment no longer accepts this link. It may have been revoked there; link it again." - : `The linked environment refused to start an MCP session (HTTP ${initialized.status}).`, + code: "orchestration_error", + message: `The linked environment refused to start an MCP session (HTTP ${initialized.status}).`, }); } yield* post( @@ -211,13 +216,7 @@ const make = Effect.gen(function* () { yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed)); response = yield* callOnce(renewed); } - if (response.status === 401) { - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: - "The linked environment no longer accepts this link. It may have been revoked there; link it again.", - }); - } + if (response.status === 401) return yield* linkRefused; if (response.status === 403) return yield* linkedEnvironmentsOffThere; if (response.status !== 200) { return yield* new OrchestratorMcpFailure({ @@ -279,7 +278,10 @@ const make = Effect.gen(function* () { (effect, input) => effect.pipe( Effect.tapError((error) => - links.recordOutcome(input.environmentId, { error: error.message }), + links.recordOutcome(input.environmentId, { + error: error.message, + refused: error === linkRefused, + }), ), // A refusal is still an answer: the link works, so only this call fails. Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })), From 12ddfb039295b76e3fd9743229b3c86ddbd020ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:30:27 +0000 Subject: [PATCH 3/3] fix(server): a late refusal of an old link token leaves a new link alone Ending a link on a 401 matched only the environment id, so a refusal of the old token that arrived after linking again expired the new link. The refusal now names the stored token it was for, and only ends the link while that token is still the link's. Refs pingdotgg/t3code#26772 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/peer/PeerLinks.test.ts | 7 ++++++ apps/server/src/peer/PeerLinks.ts | 12 ++++++---- apps/server/src/peer/PeerMcpClient.test.ts | 1 + apps/server/src/peer/PeerMcpClient.ts | 26 +++++++++++++--------- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index bba02b67054a9..c06beac0b6757 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -575,6 +575,7 @@ it.effect("tells the agent to link again once the peer revokes the link or it ex const pairing = yield* b.auth.issuePairingCredential(); yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); yield* probe(a); + const before = yield* a.links.resolve(box.environmentId); const [session] = yield* linkedSessions(b); yield* b.auth.revokeSession(session!.sessionId); @@ -589,6 +590,12 @@ it.effect("tells the agent to link again once the peer revokes the link or it ex pairingCode: (yield* b.auth.issuePairingCredential()).credential, access: "auto", }); + // A refusal of the old token that arrives late leaves the new link alone. + yield* a.links.recordOutcome(box.environmentId, { + error: revoked.message, + refusedSecret: before.secretName, + }); + expect((yield* a.links.list).map((link) => link.status)).toEqual(["reachable"]); // A link made with a peer that later answers without the fence, as // one made before the fence or a peer moved back to such a version. yield* Ref.set(b.descriptor, descriptorOf("environment-box", "Box", { linkFence: false })); diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 12013ecf578ec..4401a8c332eec 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -78,6 +78,8 @@ export interface ResolvedPeerLink { /** The first address that answered as this peer. */ readonly url: string; readonly token: string; + /** Names the stored token, so an outcome for it never lands on a later link. */ + readonly secretName: string; } export const LINKED_ENVIRONMENTS_OFF = @@ -108,11 +110,12 @@ export class PeerLinks extends Context.Service< ) => Effect.Effect; /** * Records the outcome of a call, so listing shows whether the peer answered last time. - * `refused` means the peer no longer accepts the link's token, so only linking again helps. + * `refusedSecret` names a token the peer no longer accepts; while it is still the link's, + * only linking again helps. */ readonly recordOutcome: ( environmentId: EnvironmentId, - outcome: { readonly error: string | null; readonly refused?: boolean }, + outcome: { readonly error: string | null; readonly refusedSecret?: string }, ) => Effect.Effect; } >()("t3/peer/PeerLinks") {} @@ -676,6 +679,7 @@ const make = Effect.gen(function* () { link: yield* withLabel(link, label), url: url.value, token: new TextDecoder().decode(stored.value), + secretName: row.value.secret_name, }; }, ); @@ -689,11 +693,11 @@ const make = Effect.gen(function* () { SET last_reached_at = ${DateTime.formatIso(now)}, last_error = NULL WHERE environment_id = ${environmentId} ` - : outcome.refused === true + : outcome.refusedSecret !== undefined ? sql` UPDATE peer_environment_links SET last_error = ${outcome.error}, expires_at = ${DateTime.formatIso(now)} - WHERE environment_id = ${environmentId} + WHERE environment_id = ${environmentId} AND secret_name = ${outcome.refusedSecret} ` : sql` UPDATE peer_environment_links SET last_error = ${outcome.error} diff --git a/apps/server/src/peer/PeerMcpClient.test.ts b/apps/server/src/peer/PeerMcpClient.test.ts index ef8a3aeff6a65..d11d58620def8 100644 --- a/apps/server/src/peer/PeerMcpClient.test.ts +++ b/apps/server/src/peer/PeerMcpClient.test.ts @@ -253,6 +253,7 @@ it.effect("renews a missing peer session once and reuses its replacement", () => }, url: "https://peer.example", token: "fixture", + secretName: "peer-link-fixture", }), recordOutcome: (_, outcome) => Effect.sync(() => outcomes.push(outcome.error)), }), diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 3ab453ff2cab7..0dfc0bdfcc1ae 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -126,11 +126,20 @@ const make = Effect.gen(function* () { ), ); + /** Ends the link only while the refused token is still its own, not one a newer link replaced. */ + const refuse = (resolved: PeerLinks.ResolvedPeerLink) => + links + .recordOutcome(resolved.link.environmentId, { + error: linkRefused.message, + refusedSecret: resolved.secretName, + }) + .pipe(Effect.andThen(Effect.fail(linkRefused))); + const openSession = Effect.fn("PeerMcpClient.openSession")(function* ( - url: string, - token: string, + resolved: PeerLinks.ResolvedPeerLink, limitHeader: Record, ) { + const { url, token } = resolved; const initialized = yield* post( url, token, @@ -147,7 +156,7 @@ const make = Effect.gen(function* () { limitHeader, ); const sessionId = initialized.headers["mcp-session-id"]; - if (initialized.status === 401) return yield* linkRefused; + if (initialized.status === 401) return yield* refuse(resolved); if (initialized.status === 403) return yield* linkedEnvironmentsOffThere; if (initialized.status !== 200 || sessionId === undefined) { return yield* new OrchestratorMcpFailure({ @@ -205,18 +214,18 @@ const make = Effect.gen(function* () { }, ); const existing = (yield* Ref.get(sessions)).get(sessionKey); - const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader)); + const sessionId = existing ?? (yield* openSession(resolved, limitHeader)); // The initialized session outlives a tool request, including a long wait or cancellation. if (existing === undefined) { yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId)); } let response = yield* callOnce(sessionId); if (response.status === 404) { - const renewed = yield* openSession(resolved.url, resolved.token, limitHeader); + const renewed = yield* openSession(resolved, limitHeader); yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed)); response = yield* callOnce(renewed); } - if (response.status === 401) return yield* linkRefused; + if (response.status === 401) return yield* refuse(resolved); if (response.status === 403) return yield* linkedEnvironmentsOffThere; if (response.status !== 200) { return yield* new OrchestratorMcpFailure({ @@ -278,10 +287,7 @@ const make = Effect.gen(function* () { (effect, input) => effect.pipe( Effect.tapError((error) => - links.recordOutcome(input.environmentId, { - error: error.message, - refused: error === linkRefused, - }), + links.recordOutcome(input.environmentId, { error: error.message }), ), // A refusal is still an answer: the link works, so only this call fails. Effect.tap(() => links.recordOutcome(input.environmentId, { error: null })),