Skip to content

fix(web): switching both T3 Connect switches off unlinks this PC - #178

Merged
ImZoomBoy merged 2 commits into
mainfrom
fix/t3-connect-unlink-followup
Oct 12, 2026
Merged

ImZoomBoy merged 2 commits into
mainfrom
fix/t3-connect-unlink-followup

Conversation

@ImZoomBoy

@ImZoomBoy ImZoomBoy commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #177. It fixes the four points that review left owed, and the points from the review of this pull request's first commit.

1. Why switching both switches off did not unlink

Cause

reconcileCloudState unlinks only when the request asks for no tunnel and no publishing. ConnectionsSettings.tsx builds each request from the switch that moved plus what the server holds for the other one.

The PC held a link made under account A, with the tunnel up and publishing on. Signed in as account B:

  1. T3 Connect off. The request is { managedTunnel: false, publish: true }. A link is still wanted, and the tunnel mode changed, so the client relinks in publish-only mode with B's token. The relay accepts. The PC refuses the relay config with the different-account 409 (validateLinkedCloudUser). The switch stays ON.
  2. Publishing off. The request is { managedTunnel: true, publish: false }, because the server still holds a tunnel. A link is still wanted and the tunnel mode did not change, so the client only writes the preference.

The request { false, false } is never made, so environment.cloud.unlink is never called. This matches the reported server trace: link proof accepted, applyRelayConfig refused, then preference writes.

The other order works on main. Publishing off first is a preference write. T3 Connect off second asks for { false, false } and unlinks. I ran that order on main as the check that could have disproved the cause. It unlinked.

Fix

A refused T3 Connect switch-off is carried into the next request from the Settings switches. If that request is "publishing off", it unlinks. The carry is narrow on purpose:

  • It is set only when this PC refused the switch-off with the typed reason linked_to_different_cloud_account. A network error, a relay failure, a token failure or a signed-out session does not set it.
  • It counts only for the request that directly follows the refusal. Any other request drops it.
  • It counts only while the signed-in account, the environment and the link state are all the same as when the switch-off was refused.
  • Only the two Settings switches use it (reconcileSettingsSwitches). The first-run Connect dialog keeps the plain reconcileCloudState, so onboarding can never tear down a link.

I did not carry a switch-off that the relay refused. The client cannot tell a relay refusal of the publish-only downgrade from a relay outage with certainty, so that case keeps the link, as on main.

The switch still shows ON after the refusal, as before. No server logic changed: the unlink is the same local call and clears only this PC's own link.

Real run

Throwaway dev server on an empty home under %TEMP%/fm-unlink-followup. Made-up account ids. Nobody signed in to any service. The link under account A was made through the real endpoint:

POST /api/connect/relay-config -> 200 {"ok":true,"endpointRuntimeStatus":{"status":"running","providerKind":"cloudflare_tunnel","pid":20852}}
POST /api/connect/preferences  -> 200 {"linked":true,"cloudUserId":"user_madeup_account_A",...,"managedTunnelActive":true,"publishAgentActivity":true}

A headless browser then clicked the two real switches, signed in as made-up account B.

Before, on main (f690eff):

1-start                 {"Enable T3 Connect":"ON","Publish agent activity to mobile clients":"ON","Unlink this PC row":"present"}
after T3 Connect off    toasts: ["Could not update T3 Connect","This PC is already linked to a different T3 Connect account. Use \"Unlink this PC\" in Settings > Connections, then try again."]
2-after-t3-connect-off  {"Enable T3 Connect":"ON","Publish agent activity to mobile clients":"ON","Unlink this PC row":"present"}
after publishing off    toasts: ["Agent activity disabled","This environment will stop publishing agent activity."]
3-after-publishing-off  {"Enable T3 Connect":"ON","Publish agent activity to mobile clients":"OFF","Unlink this PC row":"present"}
requests:
  -- click: T3 Connect switch off
  POST relay:/v1/client/environment-link-challenges -> 200
  POST /api/connect/link-proof -> 200
  POST relay:/v1/client/environment-links -> 200
  POST /api/connect/relay-config -> 409
  GET /api/connect/link-state -> 200
  -- click: Publish agent activity switch off
  POST /api/connect/preferences -> 200
  GET relay:/v1/environments -> 200
  GET /api/connect/link-state -> 200
GET /api/connect/link-state -> 200 {"linked":true,"cloudUserId":"user_madeup_account_A",...,"managedTunnelActive":true,"publishAgentActivity":false}

After, on this branch (be464e4), same state and same clicks:

1-start                 {"Enable T3 Connect":"ON","Publish agent activity to mobile clients":"ON","Unlink this PC row":"present"}
after T3 Connect off    toasts: ["Could not update T3 Connect","This PC is already linked to a different T3 Connect account. Use \"Unlink this PC\" in Settings > Connections, then try again."]
2-after-t3-connect-off  {"Enable T3 Connect":"ON","Publish agent activity to mobile clients":"ON","Unlink this PC row":"present"}
after publishing off    toasts: ["Another account still lists this PC","The T3 Connect account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister.","This PC is unlinked","T3 Connect and agent activity are both off, so the T3 Connect link stored on this PC was removed.","Agent activity disabled","This environment will stop publishing agent activity."]
3-after-publishing-off  {"Enable T3 Connect":"OFF","Publish agent activity to mobile clients":"OFF","Unlink this PC row":"absent"}
requests:
  -- click: T3 Connect switch off
  POST relay:/v1/client/environment-link-challenges -> 200
  POST /api/connect/link-proof -> 200
  POST relay:/v1/client/environment-links -> 200
  POST /api/connect/relay-config -> 409
  GET /api/connect/link-state -> 200
  -- click: Publish agent activity switch off
  POST /api/connect/unlink -> 200
  GET /api/connect/link-state -> 200
  GET /api/connect/link-state -> 200
  DELETE relay:/v1/client/environment-links/5ea6aee3-7dbf-4af5-8397-5f9580712c7e -> 200
  GET relay:/v1/environments -> 200
GET /api/connect/link-state -> 200 {"linked":false,"cloudUserId":null,"relayUrl":null,"relayIssuer":null,"managedTunnelActive":false,"publishAgentActivity":false}

The toast list is top first. The warning is on top.

On this branch, with the relay failing the link request as an outage would, the same two clicks keep the link:

after T3 Connect off    toasts: ["Could not update T3 Connect","https://relay.made-up.invalid/v1/client/environment-links failed"]
after publishing off    toasts: ["Agent activity disabled","This environment will stop publishing agent activity."]
3-after-publishing-off  {"Enable T3 Connect":"ON","Publish agent activity to mobile clients":"OFF","Unlink this PC row":"present"}
requests:
  -- click: Publish agent activity switch off
  POST /api/connect/preferences -> 200
GET /api/connect/link-state -> 200 {"linked":true,"cloudUserId":"user_madeup_account_A",...,"managedTunnelActive":true,"publishAgentActivity":false}
main: T3 Connect off is refused main: publishing off, still linked Branch: publishing off unlinks Branch: relay outage, link kept
main refused main still linked branch unlinked branch link kept

Limits of this run, none of which changed product code:

  • Clerk. The page loaded a stand-in for the dev server's pre-bundled @clerk/react module. It reports a signed-in made-up account B and a made-up token. No sign-in service was contacted.
  • Relay. The headless browser answered requests to the made-up relay URL itself. It accepts the link and returns credentials naming account B, which is what the relay source in infra/relay does. The live relay was not used.
  • Tunnel. The server started with T3CODE_CLOUDFLARED_PATH set to a stand-in executable, so the made-up connector token counts as a running tunnel. No tunnel existed.
  • Desktop bridge. A stand-in window.desktopBridge was set after boot, as in fix(web): Unlink this PC clears a T3 Connect link from any state #177. The T3 Connect row only renders when it exists.
  • Link proof port. The dev page is served by Vite on its own port, and the server signs a link proof only for its own port. The script rewrote the port in the link-proof request to the server's port. In the desktop app the page and the server share one origin.
  • The machine name in the screenshots is covered with "MADE-UP-PC".

2. The row when the link state fails to load

linked falls back to false when the link state cannot be read, so the row was hidden. The controller now returns unlinkOffer: linked, unknown or none. The row shows for linked and unknown, and its wording for unknown does not claim a link exists.

Real run, with the link-state response replaced by a 500 in the headless page and a real link on the server:

main:   1-start {"Unlink this PC row":"absent"}                    requests: GET /api/connect/link-state -> 500
branch: 1-start {"Unlink this PC row":"present, button disabled=false"}
        after Unlink toasts: ["No T3 Connect link is stored on this PC","If this PC held a link, it was removed."]
        requests: GET /api/connect/link-state -> 500, POST /api/connect/unlink -> 200, GET /api/connect/link-state -> 500
GET /api/connect/link-state -> 200 {"linked":false,...}     (asked directly, without the replaced response)
main: no row Branch: row offered
main no row branch row

3. Typed conflict reason

packages/contracts exports EnvironmentConflictReason, a schema and a type with one value: linked_to_different_cloud_account. EnvironmentHttpConflictError gains an optional reason. validateLinkedCloudUser sets it, typed against EnvironmentConflictReason. The check itself is unchanged. On the wire:

POST /api/connect/relay-config -> 409 {"_tag":"EnvironmentHttpConflictError","message":"This environment is already linked to a different cloud account. Unlink it before switching accounts.","reason":"linked_to_different_cloud_account"}

The web client finds the decoded error in the failure's cause chain and compares reason with a constant of type EnvironmentConflictReason. The substring match on the message is gone.

Compatibility:

  • Old server, new client. A server that sends only the message degrades to the plain error. The user sees the server's own text without the pointer to the row. The row itself still says the PC is linked to a different account, because that comes from comparing account ids.
  • New server, old client. The old client ignores the extra field.
  • Newer server with a reason this client does not know. The field is a plain string on the wire, so the error still decodes. The client shows the server's plain message.
  • Mobile. apps/mobile/src/features/cloud/linkEnvironment.ts decodes the same error and reads only message. It decodes any reason value for the same cause. I left mobile unchanged: the "Unlink this PC" row lives on the host, so mobile has no control to point at and keeps showing the server's own text, which already says to unlink before switching accounts.

4. Stray relay entries, and what the user is told

Read in infra/relay, not checked against the live relay:

  • relay_environment_links has the primary key (user_id, environment_id) (persistence/schema.ts).
  • The relay stores the link before the PC accepts the relay config, so each refused attempt as account B leaves a publish-only entry for this PC under B.
  • revokeForUser filters on the caller's user id and the environment id (environments/EnvironmentLinks.ts). The caller's id comes from the token. So a revoke with B's token removes B's entry and cannot touch A's.
  • The environment credential is revoked only when no other active link for the environment is left (revokeForEnvironmentPublicKey), so B's revoke does not break A's entry either.

Unlink order, the same for the row and for the switches:

  1. The local unlink runs, with no token.
  2. The link state is refreshed and the toasts are queued.
  3. If someone is signed in, the relay revoke follows on its own: read the token, check the PC has not been linked again, revoke, refresh the relay list.

A token read that never settles, a failed revoke or a failed relay list refresh cannot block or undo step 1, and cannot drop the toasts. The check in step 3 exists because the token can arrive late: if the PC was linked again in the meantime, the revoke is skipped so it cannot remove the new entry.

Warning after an unlink of a known link, the same on both paths:

Case Warning
Another account is signed in "Another account still lists this PC"
Signed out "T3 Connect still lists this PC"
Same account, revoke went through none
Same account, revoke or token read failed "T3 Connect may still list this PC"

Real run on this branch, Unlink clicked on a link made under account A:

signed in as account B:
  after Unlink toasts: ["Another account still lists this PC","The T3 Connect account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister.","This PC is unlinked","The T3 Connect link stored on this PC was removed."]
  requests: POST /api/connect/unlink -> 200, GET /api/connect/link-state -> 200, DELETE relay:/v1/client/environment-links/5ea6aee3-... -> 200

signed out:
  after Unlink toasts: ["T3 Connect still lists this PC","You are signed out of T3 Connect, so the account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister.","This PC is unlinked","The T3 Connect link stored on this PC was removed."]
  requests: POST /api/connect/unlink -> 200, GET /api/connect/link-state -> 200

signed in as account B, token read never settles:
  after Unlink toasts: ["Another account still lists this PC", ..., "This PC is unlinked","The T3 Connect link stored on this PC was removed."]
  requests: POST /api/connect/unlink -> 200, GET /api/connect/link-state -> 200
  GET /api/connect/link-state -> 200 {"linked":false,...}
Another account signed in Signed out
unlink under another account unlink signed out

User-visible strings as they now read

Row, title "Unlink this PC", button "Unlink":

  • Linked: "Remove the T3 Connect link stored on this PC. This works without signing in to the account that made the link." (unchanged)
  • Linked under another account: "This PC is linked to a different T3 Connect account than the one you are signed in to. Unlink it, then switch T3 Connect on to link it to this account." (unchanged)
  • Link state unreadable: "T3 Code could not read whether this PC holds a T3 Connect link. If it does, this removes it."

Success toasts:

  • Row, known link: "This PC is unlinked" / "The T3 Connect link stored on this PC was removed." (unchanged)
  • Row, link state unreadable: "No T3 Connect link is stored on this PC" / "If this PC held a link, it was removed."
  • Switches, after a refused switch-off: "This PC is unlinked" / "T3 Connect and agent activity are both off, so the T3 Connect link stored on this PC was removed."

Warning toasts:

  • "Another account still lists this PC" / "The T3 Connect account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister."
  • "T3 Connect still lists this PC" / "You are signed out of T3 Connect, so the account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister."
  • "T3 Connect may still list this PC" / "This PC could not be removed from your T3 Connect account. If your account still lists it, choose Deregister there."

Error, when this PC refuses a link for another account: "This PC is already linked to a different T3 Connect account. Use "Unlink this PC" in Settings > Connections, then try again." (unchanged wording, now matched by reason)

Docs, docs/user/remote-access.md: "If you are signed out, or signed in to a different account, the account that made the link still lists the PC until you sign in as that account and choose Deregister."

Review points taken and skipped

Taken:

  • CloudLinkDesiredState is reused in place of a copy.
  • One shared title string for the "This PC is unlinked" toasts, and every toast is a named constant.
  • Plainer names: reconcileSettingsSwitches for the switch entry point, refusedTunnelOffIn for the state a switch-off was refused in.
  • The exported reason type, used by the web client and the server.
  • The long comment on the carry is shorter and says how to use it.

Skipped, with the reason:

  • Replacing the row's description ternary with a map. There are three short cases chosen by two inputs. A map would need a derived key and reads no plainer.
  • Splitting useForkUnlinkThisPc. The row and the switches now share one unlink, one revoke and one set of warnings. Splitting would put that shared part behind a third piece.
  • Renaming the offer prop. It matches its type, UnlinkThisPcOffer.
  • "PC" in place of "environment". The wording "Unlink this PC" was asked for by name.
  • Four concerns in one pull request. The task asked for one.

Tests

apps/web/src/cloud/forkUnlinkThisPc.test.tsx mounts useCloudLinkController with the commands mocked. It asserts what the user ends up with: whether the PC was unlinked, whether the link was kept, and what the user was told. apps/server/src/server.test.ts gains one assertion on the 409 body.

On this branch:

$ cd apps/web && vp test run src/cloud/forkUnlinkThisPc.test.tsx
 Test Files  1 passed (1)
      Tests  28 passed (28)

Against the first commit of this pull request (c20f115)

This shows the review-round tests fail before the review-round fix. I restored that commit's source files and kept the new test file. That commit has no reconcileSettingsSwitches, so I added one line to its controller that exposes its wrapped reconcileCloudState under that name. The two "never settles" tests hang on that commit, so I ran them apart from the rest.

$ cd apps/web && vp test run src/cloud/forkUnlinkThisPc.test.tsx        (c20f115b8f sources, the two "never settles" tests skipped)
 × unlinks a PC linked under another account with both switches off
 × unlinks while signed out and does not call the linking account another account
 × revokes at the relay without a warning when the signed-in account made the link
 × says the user's own account may still list this PC when the revoke fails
 × says the user's own account may still list this PC when the relay token cannot be read
 × shows a newer server's unknown reason as the server worded it
 × unlink when this PC refused the T3 Connect switch-off for another account
 × still say so when the relay list cannot be refreshed after that unlink
 × keep the link when the T3 Connect switch-off failed for a network error
 × keep the link when the T3 Connect switch-off failed for a conflict without the typed reason
 × keep the link when the T3 Connect switch-off failed on the relay token
 × keep the link when the T3 Connect switch-off failed for a signed-out session
 × forget a refused switch-off when the signed-in account changes
 × forget a refused switch-off when the environment changes
 × forget a refused switch-off when the link state changes
 × never carry a refused switch-off into onboarding
 × unlink and warn about the other account when the last switch goes off
 Test Files  1 failed (1)
      Tests  17 failed | 9 passed | 2 skipped (28)

$ cd apps/web && vp test run src/cloud/forkUnlinkThisPc.test.tsx -t "never settles" --testTimeout 3000        (c20f115b8f sources)
 × unlinks when the relay token read never settles
 × unlink when the last switch goes off and the relay token read never settles
Error: Test timed out in 3000ms.
      Tests  2 failed | 26 skipped (28)

By review point:

  • Carry too wide: the four "keep the link when the T3 Connect switch-off failed ..." tests, and the three "forget a refused switch-off when ..." tests for a sign-in change, an environment change and a link-state change. "forget a refused switch-off once another request goes through" already passed before; it stays as a guard.
  • Onboarding: "never carry a refused switch-off into onboarding".
  • Token read that never settles: the two tests run apart, one for the row and one for the switches.
  • Wording per case: "unlinks while signed out and does not call the linking account another account", and the two "own account may still list" tests.
  • Relay list refresh: "still say so when the relay list cannot be refreshed after that unlink".
  • Unknown reason: "shows a newer server's unknown reason as the server worded it". The 409 body is decoded from its wire form in the test.

Against main (f690eff)

From the first commit, unchanged: with main's sources and that commit's 17 tests, 11 failed and 6 passed. The server assertion fails on main with expected undefined to equal 'linked_to_different_cloud_account'. The four original points each have a test that fails on main: the refused switch-off then publishing off (point 1), the unreadable-state offer (point 2), the typed reason with a reworded message (point 3), the other-account warning (point 4).

Checks

$ vp fmt --check
All matched files use the correct format.
Finished in 977ms on 4167 files using 24 threads.
exit 0

$ vp lint --report-unused-disable-directives
exit 0
(812 warning lines repo-wide, 0 in the files this change touches)

$ cd apps/web && vp run typecheck             (tsc --noEmit)
web exit 0
$ cd apps/server && vp run typecheck          (tsc --noEmit)
server exit 0
$ cd packages/contracts && vp run typecheck   (tsc --noEmit)
contracts exit 0
$ cd apps/mobile && vp run typecheck          (tsc --noEmit)
mobile exit 0

$ vp run knip:check
exit 0

$ cd apps/web && vp run test
 Test Files  412 passed (412)
      Tests  5504 passed (5504)
exit 0

$ cd packages/contracts && vp run test
 Test Files  27 passed (27)
      Tests  477 passed (477)
exit 0

$ cd apps/server && vp test run src/server.test.ts src/cloud
 Test Files  1 failed | 12 passed (13)
      Tests  3 failed | 324 passed | 10 skipped (337)
exit 1

$ node scripts/check-fork-features.ts
 Test Files  1 failed | 35 passed (36)
      Tests  11 failed | 633 passed (644)
exit 1

The two red results are this Windows machine, not this change. CI runs both on Linux and is green.

Server. The 3 failures are in src/cloud/bootService.test.ts, which this change does not touch. src/server.test.ts passes. The full server suite ran on the first commit: 16 failures in 10 files (cli/update, cloud/bootService, device/LocalDeviceHost, mcp/McpHttpServer, project/AgentSessionScanner, provider/Layers/OpenCodeProvider, provider/userInputAttachments, serviceLauncher, vcs/GitVcsDriver, vcs/GitVcsDriverCore). The same 10 files with main's sources:

$ cd apps/server && vp test run <those 10 files>        (main sources)
 Test Files  10 failed (10)
      Tests  16 failed | 297 passed | 12 skipped (325)

The failing test names were identical on both. The server change since then is one type annotation, so I reran only src/server.test.ts and src/cloud this round.

Fork features check. The 11 failures are all in scripts/build-desktop-artifact.test.ts. With main's sources:

$ vp test run scripts/build-desktop-artifact.test.ts apps/desktop/src/updates/forkAutomaticUpdates.test.ts        (main sources)
 Test Files  1 failed | 1 passed (2)
      Tests  11 failed | 68 passed (79)

Every other fork feature test passes, including forkUnlinkThisPc.test.tsx.

Not changed

  • validateLinkedCloudUser still refuses a relay config from another account. It only adds the reason to the error it already returned.
  • Unlink still needs the relay:write scope. No authentication or origin check changed.
  • The unlink clears only this PC's own link. The relay revoke is scoped by the relay to the caller's own entry.
  • The wording "Unlink this PC" is kept.

Surfaces

  • Entry points: the row and both switches live in ConfiguredCloudLinkRow, shared by every place that renders the T3 Connect rows. The first-run Connect dialog keeps reconcileCloudState as upstream wrote it.
  • Clients: web and desktop. Mobile is unchanged, as described in point 3.
  • Contracts: one optional string field on EnvironmentHttpConflictError, and the exported reason schema and type.
  • Providers: not involved.
  • Reverse state: after an unlink the row goes away and the switches link again as normal.
  • Connection modes: the link controls act on the primary environment only.
  • Docs: the Unlink this PC paragraph in docs/user/remote-access.md.
  • fork-features.json: the t3-connect-unlink-this-pc entry describes the behaviour, lists the files and gives the keep rule for each.

Left for later

  • Whether the live relay matches infra/relay was not checked.
  • A switch-off that the relay itself refuses is not carried, for the reason given in point 1.

Switching T3 Connect off while publishing is on needs a relink in
publish-only mode. With another account signed in, this PC refuses that
relink, so the switch stays ON. Switching publishing off next then only
wrote the preference, because a tunnel still counted as wanted. The link
stayed and unlink was never called.

A refused T3 Connect switch-off is now carried into the next request, so
switching publishing off next unlinks. Only the request right after the
refusal carries it.

Also in this change:

- The Unlink this PC row stays when the link state cannot be read, and
  says the link is unknown.
- The different-account conflict has a typed reason in the contracts
  package. The client matches on it and no longer reads the message.
- Unlink this PC sends the signed-in account's token with the relay
  revoke when the accounts differ. That clears the entry the refused
  link attempts left under the signed-in account.
- After an unlink that leaves the linking account's relay entry in
  place, a warning says that account still lists this PC until it is
  removed there with Deregister.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful main measurement shown below.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.5 KiB 13.4 KiB −33 B (−0.2%) 15.1 KiB ✅
Codex Thread snapshot wire 7.1 KiB 7.1 KiB +1 B (+0.0%) 7.3 KiB ✅
Codex Live turn WebSocket wire 6.4 KiB 6.4 KiB −34 B (−0.5%) 7.8 KiB ✅
Codex Live turn WebSocket decoded 56.1 KiB 56.1 KiB 0 B (0.0%) 66.4 KiB ✅
Codex Live turn messages 7 7 0 (0.0%) 21 ✅
Claude Total thread wire 13.5 KiB 13.5 KiB +33 B (+0.2%) 15.1 KiB ✅
Claude Thread snapshot wire 7.1 KiB 7.1 KiB +9 B (+0.1%) 7.3 KiB ✅
Claude Live turn WebSocket wire 6.4 KiB 6.4 KiB +24 B (+0.4%) 7.8 KiB ✅
Claude Live turn WebSocket decoded 56.9 KiB 57.0 KiB +44 B (+0.1%) 66.4 KiB ✅
Claude Live turn messages 7 8 +1 (+14.3%) 21 ✅

Baseline: f690eff · PR result: be464e4 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 114.0 KiB
  • Claude decoded thread snapshot: 114.7 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@ImZoomBoy

Copy link
Copy Markdown
Owner Author

Review - standards and spec

Two independent read-only reviews of c20f115. Security limits hold: http.ts adds only the typed reason; the account check on linking, the relay:write scope and the authentication and origin checks are unchanged; the relay revoke filters on the token's account plus environment, runs after the local unlink, and its failure is caught. The named root cause matches main and the regression test drives that switch order.

Blocking, to fix before merge:

  1. The carried switch-off is set on any failed T3 Connect switch-off (network error, signed out, token failure), never expires, and survives a sign-in change. A later "publishing off" then unlinks and drops the tunnel without the user asking for it.
  2. The carry wrapper also wraps the first-run Connect dialog, whose own comment says an existing link must not be torn down from onboarding. Not exercised or tested.

Should fix in the same round:

  1. A token read that never settles blocks the local unlink, on both the row and the switch path.
  2. Signed out, the toast says "Another account still lists this PC"; the linking account may be the user's own. The switch path warns only when accounts differ, the row path in both cases.
  3. If the relay list refresh fails after a carried unlink, both toasts are skipped.
  4. reason is a closed literal, so a future server sending a new reason may fail this client's decode. No exported type; the web file compares a bare string. No decision recorded for the mobile client, which decodes the same error.

Noted, not blocking: four concerns in one pull request (the task asked for that); naming and duplication points in the reports below.

Full reports follow.


Spec review - PR 178

The four points are implemented and the security limits hold. Nine findings; the main one is that the carried switch-off never expires. Nothing was run; this is from reading the code.

Confirmed safe

  • http.ts adds only reason. validateLinkedCloudUser, the relay:write scope, and the authentication and origin checks are unchanged.
  • Relay revoke filters on the token's user id plus environment id. The shared credential is revoked only when no active link is left. It runs after the local unlink, and its failure is caught.
  • Root cause matches main. The regression test drives that switch order.

(c) Implemented, looks wrong

  1. "turning off the last wanted feature unlinks" - the carried flag is set on any failed T3 Connect switch-off (network error, signed out, token failure). It has no expiry and survives a sign-in change. A later "publishing off" then unlinks and drops the tunnel. Only a remount or another request clears it. Confidence high, severity medium.
  2. "tell the user plainly" - signed out, the toast title says "Another account still lists this PC". The linking account may be the user's own. Confidence high, severity low.
  3. The switch path warns only when accounts differ, not when signed out. The row path warns in both. Confidence high, severity low.
  4. If the relay list refresh fails after a carried unlink, both toasts are skipped. Confidence medium, severity low.

(a) Missing or partial
5. "A failed or skipped revoke never blocks ... the local unlink" - a token read that never settles blocks both the row and the switch unlink. The PR body admits the row case. Confidence high, severity low-medium.
6. Acceptance 4, "pass" - the server suite and the fork features check exit 1. The body says the same failures occur on main. Lint output is not pasted. Confidence high, severity low.
7. Acceptance 1 - several tests assert mostly mocked command calls, which is close to call wiring. Confidence low, severity low.
8. Point 3 - reason is a closed literal. A future server sending a new reason would likely fail this client's decode. Old clients ignore the field. Old servers degrade to the plain error, as stated. Confidence medium, severity low.

(b) Scope beyond the brief, for the user to accept
9. All verified in code:

  • Switch-caused unlink toast: "This PC is unlinked" / "T3 Connect and agent activity are both off, so the T3 Connect link stored on this PC was removed."
  • Still-listed warning, also shown when signed out: "Another account still lists this PC" / "The T3 Connect account that linked this PC still lists it. To remove it there, sign in as that account and choose Deregister."
  • Unknown-state row: "T3 Code could not read whether this PC holds a T3 Connect link. If it does, this removes it."
  • Unknown-state toast: "No T3 Connect link is stored on this PC" / "If this PC held a link, it was removed."
  • Docs sentence: "That account still lists the PC until you sign in as that account and choose Deregister."

Upstream file additions are small. The fork-features.json entry is updated. No em dash, agent name or co-author line.

Next action: decide whether finding 1 needs a fix before merge.


Standards review of PR 178: no hard blocker. One hard violation, eleven judgement calls.

Hard violation

  1. PR scope - AGENTS.md "One concern per PR", CONTRIBUTING.md "Do not mix unrelated fixes". The body lists four points: the carried switch-off, the typed wire reason, the unreadable-state offer, and the revoke and warning change. Confidence high, severity medium.

Documented standards, judgement calls

  1. forkUnlinkThisPc.ts - "UI stays dumb" and "smallest model". carryRefusedTunnelOff guesses intent from a hidden ref set by an earlier failed request. Confidence medium, severity medium.
  2. useCloudLinkController.ts - "Hit every surface: entry points". The wrapper also wraps ConnectOnboardingDialog, whose comment says an existing link must not be torn down from onboarding. A refused request there followed by { managedTunnel: true, publish: false } would unlink. Not exercised or tested. Confidence medium, severity medium.
  3. forkUnlinkThisPc.ts - "Hit every surface: connection modes". clerkToken now goes along when another account is signed in. A multi-account behavior change with mocked tests only. Confidence low, severity medium.
  4. environmentHttp.ts - "Hit every surface: clients". apps/mobile/src/features/cloud/linkEnvironment.ts decodes the same error. The PR records no decision for mobile. Confidence medium, severity low.
  5. environmentHttp.ts - sibling reason schemas each export a matching type. EnvironmentConflictReason does not, so the web file compares a bare string. Confidence high, severity low.
  6. forkUnlinkThisPc.test.tsx - "Do not add tests that mirror the implementation". Many assertions pin toast copy and mock call shapes. Confidence medium, severity low.
  7. forkUnlinkThisPc.ts - "Comments describe how a thing is used". The nine-line comment on carryRefusedTunnelOff narrates control flow. Confidence low, severity low.
  8. fork-features.json - the keep line grows to seven files and three layers. The conflict doc warns against widening what the fork carries. Confidence low, severity low.

Baseline smells, all judgement calls

  1. Duplicated Code - interface WantedCloudFeatures { managedTunnel; publish } copies the exported CloudLinkDesiredState. Confidence high, severity low.
  2. Duplicated Code - two inline success toasts beside UNLINKED_BY_SWITCHES, all titled "This PC is unlinked". Confidence medium, severity low.
  3. Divergent Change - useForkUnlinkThisPc now owns the unlink, the offer state, toast queueing and switch-intent carrying. It returns four things. Confidence medium, severity medium.
  4. Mysterious Name - carryRefusedTunnelOff, tunnelOffRefused and switchUnlinkToasts need the comment to be understood. offer as a prop name is vague. Confidence medium, severity low.
  5. Primitive Obsession - a three-level nested ternary on offer and linkedAccountDiffers picks the description. A map keyed by state would read plainer. Confidence low, severity low.
  6. Shotgun Surgery - one reason value touches the contract, server, server test, web and registry. The contracts rule endorses most of this, so mostly suppressed. Confidence low, severity low.

Clean

  • Commit title is conventional and plain.
  • Server change ships a focused test.
  • No any. No className restyle.
  • The docs edit is one user-voice sentence in the existing section.

Nothing was run or changed. This is a read of the diff and the four standards files.

Next action: decide on finding 3, the onboarding path, first.

Review of the previous commit found the carried switch-off too wide. It
was set on any failed T3 Connect switch-off, never expired, and also
wrapped the first-run Connect dialog.

- The switch-off is carried only when this PC refused it for the typed
  different-account reason. A network error, a missing token or a
  signed-out session no longer sets it.
- It only counts for the request that directly follows, and only while
  the signed-in account, the environment and the link state are
  unchanged.
- Only the two Settings switches use it. Onboarding keeps the plain
  reconcileCloudState and never unlinks.
- The local unlink runs first, on the row and on the switches. The relay
  revoke follows on its own, so a token read that never settles no
  longer holds the unlink up. The revoke is skipped when the PC has been
  linked again by the time the token arrives.
- The warning after an unlink is worded per case: another account,
  signed out, or the user's own account when the revoke did not go
  through. The row and the switches warn in the same cases.
- The unlink toasts no longer depend on the relay list refresh.
- The conflict reason is a plain string on the wire, so a reason from a
  newer server still decodes and falls back to the plain message. The
  reason type is exported from the contracts package and used by the
  web client and the server.
@ImZoomBoy
ImZoomBoy merged commit af3ef03 into main Oct 12, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant