Skip to content

feat(cli): add pair --reset to discard a stuck OpenTunnel tunnel - #55502

Open
IgTriguero wants to merge 1 commit into
anomalyco:devfrom
IgTriguero:pair-reset
Open

IgTriguero wants to merge 1 commit into
anomalyco:devfrom
IgTriguero:pair-reset

Conversation

@IgTriguero

@IgTriguero IgTriguero commented Oct 14, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #54624

Type of change

  • New feature

What does this PR do?

Adds opencode pair --reset (requires --remote). It discards the stored OpenTunnel state and then runs the normal pairing flow, so a tunnel whose certificate issuance got stuck (for example during the opentunnel.xyz outage) can be replaced without deleting files under ~/.local/share/opentunnel/<profile>/ by hand.

RemoteTunnel.reset() calls the SDK's public tunnel.remove() (server-side removal for a completed tunnel) and then removes the profile directory. The directory removal is what clears a stuck pending issuance, because remove() only acts on completed tunnels. The handler stops the background service first, because it keeps retrying the wedged tunnel and can write the same profile while the reset runs. The service route is kept, so the pairing subdomain survives; only the tunnel hostname changes, because a new tunnel is created.

CLI-only: the flag, the handler wiring, and the small RemoteTunnel.reset() helper in packages/cli.

How did you verify your code works?

  • bun test test/pair-remote-reset.test.ts in packages/cli (4 pass): reset removes a planted stuck pending profile under an isolated XDG_DATA_HOME; reset succeeds with no stored tunnel; pair --reset without --remote exits 1; a refused first-time consent leaves the stored profile untouched.
  • bun test --timeout 30000 test/pair-remote-consent.test.ts (5 pass) and bun typecheck in packages/cli.
  • bun run dev pair --help shows the new flag:
  --reset         Discard a stuck OpenTunnel tunnel and start a new one (requires --remote)

The live end-to-end path needs a real tunnel, so it is written out as a reviewer script below.

Screenshots / recordings

No visual change; CLI output is covered above.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR
Reviewer test plan: reproduce the stuck pending tunnel without an outage

Setup. Tunnel data lives at ~/.local/share/opentunnel/default ($XDG_DATA_HOME/opentunnel/default; Windows %USERPROFILE%\.local\share\opentunnel\default). A completed tunnel has tunnel.json, token, private-key.pem, certificate.pem, chain.pem; a pending issuance has pending.json, token, private-key.pem. ensure() resumes a pending identity instead of creating a fresh tunnel, which is the reported wedge.

1. Plant an unresumable pending state

  • Back up the existing profile directory, then leave only a pending identity in place: either interrupt bun run dev pair --remote --yes as soon as pending.json appears, or write a minimal pending.json ({"id":"...","hostname":"...","csr":"..."}) next to the existing token / private-key.pem.
  • Make the resume fail persistently: corrupt the token (append a character) or block opentunnel.xyz. A corrupted token fails fast with Failed to start certificate issuance; a blackholed network can take the 5-minute ensure timeout.

2. Confirm the bug (same build, no flag)

cd packages/cli && bun run dev pair --remote --yes

It keeps resuming the dead pending identity, fails again on every run, and leaves the files behind — the only recovery is deleting them by hand.

3. Confirm the fix

cd packages/cli && bun run dev pair --reset --remote --yes

The profile is discarded first, then Setting up remote access; this can take a minute... appears and a new tunnel is created with a fresh pairing link/QR whose hostname differs from the old tunnel.json.

4. Completed tunnel, network on

  • pair --reset --remote --yes removes the old tunnel server-side, creates a new one, and restarts the service; the old remote URL stops serving.
  • pair --remote --yes without the flag still reuses the existing tunnel and does not create a new one.

5. Background service recovery (the original outage shape)

  • With a wedged pending state, the running service keeps logging remote access tunnel unavailable; retrying and pair --remote eventually times out with Timed out waiting for the remote tunnel.
  • After pair --reset --remote --yes, the service restarts with the new tunnel and the warnings stop.

Edge cases

  • Offline + completed tunnel: tunnel.remove() needs the API, so reset fails and keeps the local state (no server-side orphan). Offline + pending-only works, since that removal is local.
  • The flag is destructive by design: it deletes the device-shared default profile used by the opentunnel CLI and other SDK apps.
  • Reset only runs after first-time consent succeeds and with the background service stopped, so declining consent leaves the stored state untouched and a running service cannot race the reset.

Added by the opencode bot (AI-generated).

Related: #54596

Public API: adds the --reset flag to opencode pair (requires --remote; backwards compatible)

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for a stuck pending tunnel. I set one up in an isolated home with the network blocked. On the base, pair --remote --yes keeps trying to resume it and leaves the files behind. With this PR, pair --reset --remote --yes deletes them and goes straight to creating a new tunnel. The new tests pass and packages/cli type-checks.

Public API: adds the --reset flag to opencode pair. It only works together with --remote, and it's backwards compatible.

The main concern is the order of steps in pair.ts (inline comment). The reset runs before the first-time consent prompt and while the background service is still running. I reproduced the first part: when consent is refused, the command still deletes the device-wide tunnel data. The second part, from reading the code, is that the running service can write tunnel files at the same time as the reset.

Also, CONTRIBUTING asks for a maintainer's design approval before feature PRs, and #54624 doesn't have one yet.

Related: #54596

if (input.remote && config.remote === undefined) {
if (!(yield* remoteConsent(config, input))) return
if (input.reset && !input.remote) return yield* Effect.fail(new Error("--reset requires --remote"))
if (input.reset) yield* RemoteTunnel.reset()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two ordering problems here:

  1. The reset runs before remoteConsent. I ran pair --reset --remote with no TTY and no --yes: it printed the warning and exited 1, but ~/.local/share/opentunnel/default was already deleted. That's the device-wide profile the opentunnel CLI and other SDK apps also use. Declining at the interactive prompt has the same effect. Running the reset after consent succeeds would fix it.

  2. When remote access is already on, the background service is still running here. It's only stopped later, inside ServiceConfig.set("remote", "true"), after RemoteTunnel.ensure() has already created the new tunnel. In the outage case this flag is for, the service keeps retrying client.tunnel.connect. That calls ensure()/create(), which either resumes the pending identity or creates a new tunnel, writing into the same profile. So the service can recreate the old state right after the deletion, or run a create at the same time as the foreground ensure(). Both write token/private-key.pem/pending.json, so the saved files could come from two different tunnels. Stopping the service (Service.stop(yield* ServiceConfig.options())) before resetting would avoid this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in the latest push (bf1acbbfd7). The reset now runs only after first-time consent succeeds, so a refused or non-interactive run leaves the stored profile untouched — added a regression test for the case you reproduced. The handler also stops the service with Service.stop(ServiceConfig.options()) before RemoteTunnel.reset(), so it cannot recreate the pending state or race the new tunnel. Thanks for the detailed repro.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both concerns from my last review are fixed. The reset now runs only after first-time consent succeeds. Rerunning my earlier repro (pair --reset --remote with no TTY and no --yes) now leaves the stored profile in place. The background service is also stopped before the reset, so it can't write to the profile during it. With the network blocked, pair --reset --remote --yes still deletes a stuck pending profile and goes straight to creating a new tunnel. The 4 reset tests pass and packages/cli type-checks.

Public API: adds the --reset flag to opencode pair. It only works together with --remote, and it's backwards compatible.

No further concerns. As noted before, CONTRIBUTING asks for a maintainer's design approval before feature PRs, and #54624 doesn't have one yet.

Related: #54596

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli: add a way to reset/discard a stuck pending OpenTunnel tunnel (pair --remote)

1 participant