Skip to content

Reverse route: let the VM reach a laptop that can't be reached, over a line the laptop opens #951

Open

Description

@Jacksondr5

Problem

Upstream's linked environments (Labs beta, in v0.1.7) let agents on one machine use T3 tools on another: thread tools, queues, projects, scheduled tasks, pull-request tools and delegate_task, 38 tools plus delegate_task. A link is a synchronous call to the other machine's /mcp, so it only works towards a machine that can be reached.

In the corporate setup (an always-on work VM, and a laptop behind a firewall that refuses every inbound connection, reaching the VM over Zscaler Private Access), the laptop can link to the VM but the VM can never link to the laptop. Adopting upstream's link features as shipped would leave every one of them one-way there. That's a gap that depends on how the two devices connect, which is a downgrade from J5's peering today, where poll mode makes everything J5 offers work both ways. Hiding the features instead would withhold upstream's work.

Alternatives considered and set aside:

  • Holding upstream's tools on the polling rail. Every held tool's result would need a "pending" variant on result types upstream owns. Reads would need snapshots, and control calls (interrupt, steer, answering approvals) would need to be tied to their target and expire.
  • T3 Connect. Upstream's hosted Connect can't be used: one account per server, and J5 points at none of upstream's infrastructure. Running J5's own (Should J5 run its own T3 Connect? #725) is operations work on the scale of v3, and cloudflared connects out on port 7844 rather than through the corporate network, which security teams commonly flag.
  • Polling as a give-back. Upstream declined a poll mode for the first version of links (pingdotgg/t3code#16655 reply).

Proposal: a reverse route

The laptop opens a connection to the VM over ordinary HTTPS, through ZPA, and keeps it open. The VM exposes that connection on its own loopback address, such as 127.0.0.1:<port>, so anything on the VM that calls that address is talking to the laptop. Upstream's link from the VM to the laptop uses that address. Upstream accepts plain http on loopback (peerOrigin in apps/server/src/peer/PeerLinks.ts) and checks that the answer reports the laptop's environment id.

Upstream's link tools then work both ways and synchronously, with no edit to upstream's code. J5's direct mode can use the same route. It's what Connect does, with your own reachable server in Cloudflare's place.

Evidence that it will hold: the J5 web app on the laptop already keeps a WebSocket to the VM's J5 open through ZPA all day, and Zscaler's client signs in silently through Jamf SSO, so redialling never waits on the person.

Design questions

  1. The line says which machine it carries. The VM needs to know which laptop is on which loopback address. The laptop stating its environment id when it opens the line is enough. Forgery and impersonation aren't design considerations on J5's closed, trusted network (Jackson, 2026-11-07), so the line needs no sign-in of its own beyond what reaching the VM already requires.
  2. Only the VM uses the route. The address stays on the VM's own loopback. Reaching the laptop from a phone through the VM is out of scope.
  3. Poll mode's storage stays. A sleeping laptop has no line, and J5 messages still need holding until it wakes.
  4. A distinct failure code when the machine can't be reached, and a notice when the line comes back. Upstream turns an unreachable machine into a generic orchestration_error (PeerForwarding.ts). Keep a distinct code (such as peer_unreachable), record which thread hit it with which tools, and send each such thread one platform notice when the line is back. The agent decides whether to retry; no automatic retry, and no instruction to end its turn. This needs a small hook in upstream's forwarding, which would be a FORK.md entry. J5's cross-machine j5_spawn_agent fails with the same code when the line is down.
    • A delegated task already running when the line drops. Upstream's follower (RemoteDelegation.ts) treats every failure as retryable except capability_denied, thread_not_found, run_not_found and an invalid_request for a forgotten link, and backs off from 2 seconds up to 5 minutes. A new code is outside that list, so the follower keeps retrying; the new code must not reuse one of those four.
    • Where the hook sits. On PeerForwarding.call, which carries agents' tool calls and the launch step of delegate_task, not on readThread and waitForThread, which the follower uses. A launch that fails gets the notice; a follower's retries don't produce one.
    • No notice for a delegate's parent. The parent isn't waiting on a call; the task's completion wakes it. The follower's own retry is enough, at the cost of up to 5 minutes after the line returns before it notices.
  5. No nginx changes. The route is a WebSocket on a path the VM's J5 already serves, so the existing nginx block forwards it. Keepalives every few seconds, like the web app's (it treats 15 s of silence as unresponsive, PING_TIMEOUT), keep it inside nginx's and ZPA's idle timeouts; poll mode's 25-second held requests already pass the same block. nginx's 1 MB body limit covers request bodies, not WebSocket messages. The VM-side hop to 127.0.0.1:<port> never touches nginx.
  6. Docs. The October poll-mode session listed "relay, tunnel and client forwarding" as out of scope. The peering definition's rule that "a relay or tunnel is never a required part of it" still holds, because the route is optional.

Line test

Skipped (Jackson, 2026-11-10). With keepalives on the web app's rhythm, ZPA's idle timeout no longer matters: the web app's WebSocket already survives all day through ZPA and nginx. The two things the test would still have shown are measured on the real route during the live test instead:

  • a 5 MB message from the laptop to the VM over the WebSocket, through ZPA;
  • how fast the line comes back after sleep and network changes.

The test script stays in https://github.com/Jacksondr5/j5-line-test (private) if a question comes up later.

Related

#725 (J5's own Connect), #772 (v0.1.7 advance: linked environments), #399 (poll mode).

Drafted with Claude Opus 5.5.

Activity

  1. Jacksondr5 commented on Nov 7, 2026

    @Jacksondr5
    OwnerAuthor

    Sequencing (Jackson, 2026-11-07): cross-machine work starts after the upstream v0.1.7 merge. This issue comes first, then #956 (j5_spawn_agent on another J5 machine, which needs this for the VM-to-laptop direction) and the remote persona delegate, then #953 (Crews across machines). The line test can run any time; its results tune this design and don't gate it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions