diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..65bc5687e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,69 @@ +name: Bug report +description: Something in Switch behaves incorrectly. +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file this. + + **Do not report security vulnerabilities here.** Use the Security tab → + *Report a vulnerability*, as described in [SECURITY.md](../blob/main/SECURITY.md). + + - type: dropdown + id: component + attributes: + label: Component + description: Where did you see this? + options: + - switch-core (control plane) + - gateway (operator dashboard) + - Switch Console (desktop app) + - Connector plugin (Claude Code / Codex) + - Collaboration bridge (Slack / Mattermost) + - Deployment (Docker Compose / Helm) + - Not sure + validations: + required: true + + - type: input + id: version + attributes: + label: Version or commit + description: Release version, image tag, or commit SHA. + placeholder: 'switch-core 0.13.0 / console 0.20.0 / d8db4e5' + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What did you observe, and what did you expect instead? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or error output + description: Paste any relevant output. It is rendered as a code block, so no backticks needed. + render: shell + + - type: textarea + id: environment + attributes: + label: Environment + description: OS and version, and anything else that might matter. + placeholder: 'macOS 15.2 (arm64), Docker 27.0.0' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a0d95bff0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: https://github.com/sandbox-quantum/switch/security/advisories/new + about: Report privately through a GitHub security advisory — never as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..54a7e80d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,38 @@ +name: Feature request +description: Suggest a capability or an improvement. +labels: ['enhancement'] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: What are you trying to do that Switch makes hard or impossible today? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you would like to see + description: Describe the change. Rough shape is fine. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you have considered + description: Including any workaround you are using now. + + - type: dropdown + id: component + attributes: + label: Component + options: + - switch-core (control plane) + - gateway (operator dashboard) + - Switch Console (desktop app) + - Connector plugin (Claude Code / Codex) + - Collaboration bridge (Slack / Mattermost) + - Deployment (Docker Compose / Helm) + - Not sure diff --git a/.github/workflows/switch-agent-runtime-publish.yml b/.github/workflows/switch-agent-runtime-publish.yml index d1073772d..aa22563de 100644 --- a/.github/workflows/switch-agent-runtime-publish.yml +++ b/.github/workflows/switch-agent-runtime-publish.yml @@ -75,7 +75,7 @@ jobs: working-directory: console steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # Full history + tags so the version guard can resolve the tag pointing # at HEAD on a workflow_dispatch (where github.ref is a branch, not the tag). with: @@ -83,11 +83,11 @@ jobs: # Pinned rather than read from packageManager: that field lives in # console/package.json, and the action looks at the repo root. - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 10.28.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/switch-console-release.yml b/.github/workflows/switch-console-release.yml index d9334b56f..726896d31 100644 --- a/.github/workflows/switch-console-release.yml +++ b/.github/workflows/switch-console-release.yml @@ -1,8 +1,8 @@ name: Switch Console release # Builds the Switch Console desktop app and publishes it to a GitHub Release on this -# (private) repo, so anyone with repo-read access can install it without building -# from source. Release assets inherit the repo's read access — no separate ACL. +# repo, so anyone can install it without building from source. The repo is public, +# so the release assets are too — no token, no ACL. # # macOS (arm64) is signed + notarized. Linux (x64) builds AppImage, deb and rpm, # unsigned. Windows is not built yet (needs an Authenticode / Azure Trusted @@ -48,7 +48,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/') steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Verify tag matches package.json version run: | @@ -96,15 +96,15 @@ jobs: APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 11.5.3 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: console/.nvmrc cache: pnpm @@ -175,7 +175,7 @@ jobs: - name: Upload build artifacts (dispatch runs, no Release) if: ${{ !startsWith(github.ref, 'refs/tags/') }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: switch-console-macos-arm64 path: | @@ -192,15 +192,15 @@ jobs: if: ${{ !cancelled() && needs.create-release.result != 'failure' }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 11.5.3 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: console/.nvmrc cache: pnpm @@ -251,7 +251,7 @@ jobs: - name: Upload build artifacts (dispatch runs, no Release) if: ${{ !startsWith(github.ref, 'refs/tags/') }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: switch-console-linux-x64 path: | diff --git a/.github/workflows/switch-release.yml b/.github/workflows/switch-release.yml index 539030626..43a56ef4c 100644 --- a/.github/workflows/switch-release.yml +++ b/.github/workflows/switch-release.yml @@ -9,10 +9,9 @@ name: switch release # MUST match pyproject.toml `[project].version`; the prep job verifies this). # `workflow_dispatch` builds everything WITHOUT pushing, for verification. # -# Artifacts go to GHCR by default. While this repo is private the packages are -# private too and flip to public with the repo at the public-repo move -# (CHOO-1260). REGISTRY / IMAGE_NAMESPACE are the only things to change to -# retarget another registry (e.g. ECR) — a config flip, not a rewrite. +# Artifacts go to GHCR by default, and the packages are public alongside the +# repo. REGISTRY / IMAGE_NAMESPACE are the only things to change to retarget +# another registry (e.g. ECR) — a config flip, not a rewrite. on: push: @@ -39,7 +38,7 @@ jobs: compose_accepts: ${{ steps.contract.outputs.accepts }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Resolve version id: version @@ -94,25 +93,25 @@ jobs: dockerfile: deploy/shared_resources/images/Dockerfile.setup steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up QEMU # Enables arm64 emulation so buildx can produce a multi-arch manifest # from an amd64 runner. - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: # All three Dockerfiles build with the repo root as context. context: . @@ -134,10 +133,10 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 - name: Lint chart run: helm lint deploy/remote/helm/switch @@ -172,10 +171,10 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up ORAS - uses: oras-project/setup-oras@v1 + uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4 - name: Log in to GHCR (ORAS) if: ${{ needs.prep.outputs.push == 'true' }} diff --git a/.gitignore b/.gitignore index 5c3ad050c..5b576661f 100644 --- a/.gitignore +++ b/.gitignore @@ -229,4 +229,11 @@ __marimo__/ .agenthub related-projects .claude/settings.local.json -.claude/agents/ \ No newline at end of file +.claude/agents/ + +# Switch Console working-tree artifacts. Build output and scratch downloads that +# land at the repo root; ignored so they cannot be committed by accident. +.switchdash/ +.switchdash-icon-drop/ +download.txt +download.xml \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70fb1028e..c9c0dfc9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,6 @@ get a change merged. ## Development setup -See [CLAUDE.md](CLAUDE.md) for the full developer guide. In short: - ```bash uv sync # install dependencies just up # start Switch locally (Docker Compose) @@ -22,10 +20,12 @@ just migrate # apply database migrations ## Conventions -[CLAUDE.md](CLAUDE.md) documents the code style, import rules, and the -error-handling philosophy ("fail loud, never fake") this project follows. -Please read it before making substantial changes — matching the surrounding -code and these conventions keeps review fast. +Code style, import rules, and the error-handling philosophy ("fail loud, never +fake") are documented in [CLAUDE.md](CLAUDE.md). That file is written as +instructions for AI coding agents working in this repository, but the +conventions it describes are the ones the project follows, so it is worth +reading before making substantial changes — matching the surrounding code keeps +review fast. ## License diff --git a/README.md b/README.md index 72b7d21e2..817fba83e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![License: Apache 2.0 + Commons Clause](https://img.shields.io/badge/license-Apache%202.0%20%2B%20Commons%20Clause-blue)](LICENSE) [![Documentation](https://img.shields.io/badge/docs-coming%20soon-FF895E)](#) [![Website](https://img.shields.io/badge/website-coming%20soon-FF895E)](#) -[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CLAUDE.md) +[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) @@ -205,7 +205,4 @@ derives substantially from it); all other Apache 2.0 grants are unchanged. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get a change merged, including -the required [Contributor License Agreement](CLA.md). [CLAUDE.md](CLAUDE.md) -covers code style, the error-handling philosophy, and the conventions to follow -when working in this repository. +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get a change merged. diff --git a/RELEASING.md b/RELEASING.md index 657eff61e..ae94ac9da 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -111,11 +111,10 @@ switch-core releases are **not** gated and need no such ping. ## Where artifacts are published The images, the chart, and the standalone compose artifact all go to **GitHub -Container Registry (GHCR)** by default. While the repository is private the -packages are private too; they become public automatically when the -repository/packages are made public at the public-repo move (CHOO-1260). The -registry and namespace are workflow env vars (`REGISTRY`, `IMAGE_NAMESPACE`) so -retargeting to another registry (e.g. ECR) is a one-line change, not a rewrite. +Container Registry (GHCR)** by default, and are public alongside the repository +— pulling them needs no credential. The registry and namespace are workflow env +vars (`REGISTRY`, `IMAGE_NAMESPACE`) so retargeting to another registry (e.g. +ECR) is a one-line change, not a rewrite. Consuming the published artifacts: diff --git a/connectors/claude-code-plugin/skills/configure/SKILL.md b/connectors/claude-code-plugin/skills/configure/SKILL.md index fe7119b2d..8d06fbbe7 100644 --- a/connectors/claude-code-plugin/skills/configure/SKILL.md +++ b/connectors/claude-code-plugin/skills/configure/SKILL.md @@ -27,81 +27,6 @@ choosing project-local vs. user-global settings, the user decides whether this Switch identity is tied to one repo or shared across all of them. This skill walks through registration and writes that block. -Separately from the agent's identity, the runtime itself is fetched from a -private registry, which needs its own one-time setup — Step 0. - -## Step 0 — Registry access for the runtime - -The plugin's MCP server is fetched with -`npx @sandbox-quantum/switch-agent-runtime`. That package is published to -**GitHub Packages** and is private, so npm needs to know which registry -serves the `@sandbox-quantum` scope and how to authenticate. Without both, -`npx` fails — and it fails **misleadingly**: a private package you are not -authorised for returns **404**, not 403, because registries do not admit that -private packages exist. "Package not found" almost always means "not logged -in" here. - -Sessions launched by Switch Console get this handed to them and need nothing. -This step is for **standalone Claude Code**, where nothing is injecting it. - -First check whether it is already set up: - -```bash -npm config get @sandbox-quantum:registry -``` - -If that prints `https://npm.pkg.github.com`, skip to Step 1. - -Otherwise it needs the GitHub CLI, authenticated **and holding the -`read:packages` scope**: - -```bash -gh auth status -``` - -If `gh` is missing or logged out, stop and tell the user to install it and run -`gh auth login` — everything below depends on it, and guessing a token is not -something to attempt. - -Then look at the `Token scopes:` line. **`gh auth login` does not request -`read:packages`** — the default scopes are `gist`, `read:org`, `repo` and -`workflow`. A perfectly healthy login therefore produces a token the registry -refuses: - -``` -npm error 403 Permission permission_denied: The token provided does not match expected scopes. -``` - -If `read:packages` is absent, have the user add it: - -```bash -gh auth refresh -h github.com -s read:packages -``` - -That opens a device-code prompt in a browser. If they cannot complete it (a -headless box, for instance), the alternative is a classic PAT with -`read:packages`, used in place of `gh auth token` below. - -Then, with the user's agreement (this writes to their `~/.npmrc`): - -```bash -npm config set @sandbox-quantum:registry https://npm.pkg.github.com -npm config set //npm.pkg.github.com/:_authToken "$(gh auth token)" -``` - -Verify it resolves before moving on, so a failure surfaces here rather than as -a broken MCP server later: - -```bash -npm view @sandbox-quantum/switch-agent-runtime version -``` - -Two things to tell the user plainly rather than leave them to discover: - -- This writes a **real token into `~/.npmrc`** (mode 0600). It is how npm - authenticates to any private registry, but it is a credential at rest. -- It **expires when `gh` rotates its token**, and the symptom is the same - misleading 404. Re-running the two `npm config set` lines fixes it. ## Step 1 — Check existing config diff --git a/connectors/codex-plugin/.mcp.json b/connectors/codex-plugin/.mcp.json index 89d57fa17..110ca6f96 100644 --- a/connectors/codex-plugin/.mcp.json +++ b/connectors/codex-plugin/.mcp.json @@ -8,9 +8,7 @@ "SWITCH_API_TOKEN", "SWITCH_AGENT_ID", "SWITCH_CONNECTION_ID", - "SWITCH_CHANNEL_DISABLE_POLL", - "npm_config_userconfig", - "SWITCHDASH_GITHUB_TOKEN" + "SWITCH_CHANNEL_DISABLE_POLL" ], "startup_timeout_sec": 60, "default_tools_approval_mode": "approve" diff --git a/connectors/codex-plugin/README.md b/connectors/codex-plugin/README.md index 20329e6ea..9ad5b50e5 100644 --- a/connectors/codex-plugin/README.md +++ b/connectors/codex-plugin/README.md @@ -52,9 +52,9 @@ depends on them: such. (This was previously documented the other way round, as a reason the server could not ship here at all.) - **An unset name is simply not forwarded.** So the list can name - Switch Console-only variables (`SWITCH_CONNECTION_ID`, the npm registry settings) - without breaking a session that has none of them. The Claude connector cannot - do this: `${VAR}` expansion makes every declared variable mandatory. + Switch Console-only variables (`SWITCH_CONNECTION_ID`) without breaking a + session that has none of them. The Claude connector cannot do this: `${VAR}` + expansion makes every declared variable mandatory. `startup_timeout_sec` is raised from Codex's 10s default because a host that has never run the runtime can exceed it on the `npx` fetch alone, and a timeout diff --git a/console/AGENTS.md b/console/AGENTS.md index df74b7846..b904e69ba 100644 --- a/console/AGENTS.md +++ b/console/AGENTS.md @@ -57,10 +57,9 @@ One cost was accepted rather than avoided: because `APP_NAME_LOWER` moved, `apt` `dnf` treat `switch-console` as a new package rather than an upgrade of `switchdash`, so a Linux user upgrading across the rename has to remove the old package by hand. -The **update feed** points at GitHub Releases on the private -`sandbox-quantum/switch` repo (see `electron-builder.config.ts` `publish`): the app -distributes to repo-readers and authenticates the updater with the user's `gh` CLI token -(`src/main/core/updates/github-token.ts`). The SQLite database file is `switchdash.db`; +The **update feed** points at GitHub Releases on the public +`sandbox-quantum/switch` repo (see `electron-builder.config.ts` `publish`): the feed is +read unauthenticated, so the updater needs no token. The SQLite database file is `switchdash.db`; installs upgrading from a pre-rebrand build are migrated forward from their legacy database on first launch — see `LEGACY_DB_FILENAMES` in `src/main/db/default-path.ts` and the copy-migration in `database-file.ts` (those legacy filenames are the only place the diff --git a/console/agents/risky-areas/updater.md b/console/agents/risky-areas/updater.md index 0665db870..5534e5e6b 100644 --- a/console/agents/risky-areas/updater.md +++ b/console/agents/risky-areas/updater.md @@ -35,8 +35,8 @@ electron-builder emits channel manifests named by the publish provider's `channe - Stable: `provider: github` (`releaseType: 'release'`) has no explicit channel → defaults to `latest` → emits `latest*.yml`. - Canary: `provider: github` (`releaseType: 'draft'`) sets `channel: 'canary'` → emits `canary*.yml`. -The release repo is `sandbox-quantum/switch`, private, so the updater uses -electron-updater's authenticated GitHub provider. +The release repo is `sandbox-quantum/switch`, public, so the updater reads the feed +with electron-updater's GitHub provider and no credential. ### Draft until every platform has uploaded @@ -75,12 +75,14 @@ The app does **not** override `autoUpdater.channel`; the GitHub provider resolve ## Authenticating the updater -The release repo is private, so a plain feed fetch 404s. `github-token.ts` sources a token -from the user's `gh` CLI and it is handed to `autoUpdater.setFeedURL(...)` rather than -exported as `GH_TOKEN`. That is deliberate and worth preserving: Switch Console's environment -is inherited by every child process it spawns — including `gh` itself, which prefers -`GH_TOKEN` over its keyring — so a token parked there outlives the login it came from and -shadows the next one until the app restarts. +Nothing to authenticate: the release repo is public, so the feed and the release-notes +API are both read anonymously. + +If a credential is ever needed here again, hand it to `autoUpdater.setFeedURL(...)` +rather than exporting `GH_TOKEN`. Switch Console's environment is inherited by every +child process it spawns — including `gh` itself, which prefers `GH_TOKEN` over its +keyring — so a token parked there outlives the login it came from and shadows the next +one until the app restarts. ## Current Notes diff --git a/console/apps/switch-console-desktop/electron-builder.config.ts b/console/apps/switch-console-desktop/electron-builder.config.ts index 4a7c2cf61..1d9d2659b 100644 --- a/console/apps/switch-console-desktop/electron-builder.config.ts +++ b/console/apps/switch-console-desktop/electron-builder.config.ts @@ -71,16 +71,14 @@ const config: Configuration = { ], directories: { output: 'release' }, artifactName: `${ARTIFACT_PREFIX}-\${arch}.\${ext}`, - // Switch Console is distributed via GitHub Releases on the private switch repo. - // The app reads the auth-gated feed using the user's `gh` CLI token (see - // src/main/core/updates). This is the one place the release identity diverges - // from Switch Console (the app id / artifact names stay on Switch Console — see AGENTS.md). + // Switch Console is distributed via GitHub Releases on the switch repo. This is + // the one place the release identity diverges from Switch Console (the app id / + // artifact names stay on Switch Console — see AGENTS.md). publish: [ { provider: 'github', owner: RELEASE_REPO_OWNER, repo: RELEASE_REPO_NAME, - private: true, releaseType: 'release', }, ], diff --git a/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts b/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts index c352e983e..4ba1b366e 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts @@ -22,7 +22,6 @@ import { getTerminalColorEnv } from '@main/core/pty/terminal-color-scheme'; import { killTmuxSession, makeAgentTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { sessionHooks } from '@main/core/sessions/session-hooks'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; -import { npmRegistryAuthEnv } from '@main/core/switch-rooms/npm-registry-auth'; import { readAgentSwitchEnvFromFs } from '@main/core/switch-rooms/switch-credentials'; import { switchNotificationPoller } from '@main/core/switch-rooms/switch-notification-poller'; import { switchRoomService } from '@main/core/switch-rooms/switch-room-service'; @@ -239,13 +238,6 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { ptyId, }); - // The Claude Code plugin resolves its MCP server with `npx` from a - // private registry, so the session needs to know where that registry is - // and how to authenticate. Empty when `gh` has no token, which lets the - // session start regardless — a session with no MCP server beats no - // session, and the missing login is reported at host setup. - const npmAuthEnv = await npmRegistryAuthEnv(); - const sessionEnv = { ...buildAgentEnv({ hook: port > 0 ? { port, ptyId, token } : undefined, @@ -255,7 +247,6 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { ...colorEnv, ...this.sessionEnvVars, ...identityVars, - ...npmAuthEnv, ...(switchConnectionId ? { SWITCH_CONNECTION_ID: switchConnectionId } : {}), }; diff --git a/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts b/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts index 62e50f63d..12213de20 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts @@ -66,15 +66,6 @@ vi.mock('@main/core/pty/ssh2-pty', () => ({ openSsh2Pty })); vi.mock('@main/core/pty/spawn-utils', () => ({ resolveSshCommand })); -const remoteNpmRegistryAuthEnv = vi.hoisted(() => - vi.fn(async () => ({ - npm_config_userconfig: '/repo/.switchdash/npmrc', - SWITCHDASH_GITHUB_TOKEN: 'remote-tok', - })) -); - -vi.mock('@main/core/switch-rooms/npm-registry-auth', () => ({ remoteNpmRegistryAuthEnv })); - vi.mock('@main/core/pty/terminal-color-scheme', () => ({ getTerminalColorEnv: vi.fn(async () => ({})), })); @@ -253,7 +244,6 @@ describe('SshAgentRuntime', () => { vi.mocked(getPlugin).mockImplementation(defaultGetPlugin as never); resolveSshCommand.mockClear(); deployAndLaunch.mockClear(); - remoteNpmRegistryAuthEnv.mockClear(); sidecarStop.mockClear(); httpPostJsonOverChannel.mockClear(); httpPostForJsonOverChannel.mockReset(); @@ -664,42 +654,6 @@ describe('SshAgentRuntime', () => { await expect(sshProvider({ tmux: true }).start(session())).rejects.toThrow(/no connection id/); }); - // The runtime is fetched with `npx` from a private registry, so without this - // the remote session comes up with no MCP server and npm reports a 404 that - // names neither the registry nor the missing credential. - it('gives a freshly launched remote session its registry access', async () => { - const exitHandlers: Array void>> = []; - mockSpawn(exitHandlers); - - await sshProvider({ tmux: true }).start(session()); - - expect(remoteNpmRegistryAuthEnv).toHaveBeenCalledTimes(1); - const env = (resolveSshCommand.mock.calls[0] as unknown[])[2] as Record; - expect(env.npm_config_userconfig).toBe('/repo/.switchdash/npmrc'); - expect(env.SWITCHDASH_GITHUB_TOKEN).toBe('remote-tok'); - }); - - // Re-attach is decided from whether this runtime has already opened the pane, - // not from the sidecar being up: with on-demand attachment the sidecar is - // running long before the first PTY, so reading it would make every first - // attach look like a re-attach and silently lose its registry config. - it('does not recompute registry access when re-attaching', async () => { - const exitHandlers: Array void>> = []; - mockSpawn(exitHandlers); - const provider = sshProvider({ tmux: true }); - - await provider.start(session()); - expect(remoteNpmRegistryAuthEnv).toHaveBeenCalledTimes(1); - - for (const handler of exitHandlers[0] ?? []) handler({ exitCode: 1 }); - await provider.attach(); - expect(openSsh2Pty).toHaveBeenCalledTimes(2); - - // The pane still has the environment it was created with; tmux applies - // `-e` only at creation, so recomputing would cost round trips for nothing. - expect(remoteNpmRegistryAuthEnv).toHaveBeenCalledTimes(1); - }); - it('starts the hook-event relay against the sidecar endpoint when tmux is on', async () => { const exitHandlers: Array void>> = []; mockSpawn(exitHandlers); diff --git a/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts b/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts index c1c43b44e..b764321ae 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts @@ -22,7 +22,6 @@ import { killTmuxSession, makeAgentTmuxSessionName } from '@main/core/pty/tmux-s import { sessionHooks } from '@main/core/sessions/session-hooks'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; -import { remoteNpmRegistryAuthEnv } from '@main/core/switch-rooms/npm-registry-auth'; import { readAgentSwitchEnvFromFs } from '@main/core/switch-rooms/switch-credentials'; import { events } from '@main/lib/events'; import { runWithLogContext } from '@main/lib/log-context'; @@ -763,13 +762,6 @@ export class SshAgentRuntime implements AgentRuntimeProvider, AttachableRuntime ); } - // Skipped on the re-attach path above: the pane already has its - // environment and tmux applies `-e` only when it creates a session, so - // recomputing this would cost two round trips and change nothing. - const npmAuthEnv = reattaching - ? {} - : await remoteNpmRegistryAuthEnv(this.ctx, this.sessionPath); - const [profile, colorEnv] = await Promise.all([ this.proxy.getRemoteShellProfile(), getTerminalColorEnv(), @@ -782,7 +774,6 @@ export class SshAgentRuntime implements AgentRuntimeProvider, AttachableRuntime ...colorEnv, ...this.sessionEnvVars, ...hookEnv, - ...npmAuthEnv, ...identityVars, ...switchEnv, }, diff --git a/console/apps/switch-console-desktop/src/main/core/dependencies/core-dependencies.ts b/console/apps/switch-console-desktop/src/main/core/dependencies/core-dependencies.ts index 13e6739e2..3818a2399 100644 --- a/console/apps/switch-console-desktop/src/main/core/dependencies/core-dependencies.ts +++ b/console/apps/switch-console-desktop/src/main/core/dependencies/core-dependencies.ts @@ -26,10 +26,10 @@ function aptInstall(packages: string): string { } /** - * Core host tools a remote host needs to run Switch Console agent sessions: the same - * binaries the remote-session preflight verifies (tmux, node, git) plus gh, which - * agents commonly rely on. Unlike agent dependencies (built from the plugin - * registry), these are static — the plugin system has no notion of host tooling. + * Core host tools a remote host needs to run Switch Console agent sessions: the + * same binaries the remote-session preflight verifies (tmux, node, git). Unlike + * agent dependencies (built from the plugin registry), these are static — the + * plugin system has no notion of host tooling. * * These are surfaced only on the remote-host management page; the local * dependency manager continues to track agent CLIs only. `updates`/`uninstall` @@ -101,30 +101,4 @@ export const CORE_DEPENDENCIES: DependencyDescriptor[] = [ ], }, }, - { - id: 'gh', - name: 'GitHub CLI', - category: 'core', - commands: ['gh'], - versionArgs: ['--version'], - docUrl: 'https://github.com/cli/cli#installation', - installCommands: { - macos: [{ method: 'homebrew', command: 'brew install gh', recommended: true }], - linux: [ - { - method: 'apt', - command: - 'sudo mkdir -p -m 755 /etc/apt/keyrings && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && ' + - aptInstall('gh'), - label: 'apt', - recommended: true, - }, - { - method: 'homebrew', - command: 'brew install gh', - label: 'Homebrew', - }, - ], - }, - }, ]; diff --git a/console/apps/switch-console-desktop/src/main/core/execution-context/ssh-execution-context.test.ts b/console/apps/switch-console-desktop/src/main/core/execution-context/ssh-execution-context.test.ts index b64ab2f92..1d662128e 100644 --- a/console/apps/switch-console-desktop/src/main/core/execution-context/ssh-execution-context.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/execution-context/ssh-execution-context.test.ts @@ -179,7 +179,7 @@ describe('SshExecutionContext.exec', () => { describe('stripExecBanner', () => { it('drops a login-shell banner printed before the marker', () => { - const banner = ' _____ SANDBOXAQ _____\nalg-bench-debian-12\n'; + const banner = ' _____ EXAMPLE _____\nexample-host-debian-12\n'; const raw = `${banner}${EXEC_STDOUT_MARKER}\nreal output\n`; expect(stripExecBanner(raw, EXEC_STDOUT_MARKER)).toBe('real output\n'); diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/constants.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/constants.ts index 35202083d..66fa07210 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/constants.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/constants.ts @@ -48,8 +48,7 @@ export const LOCAL_SERVER_NAME = 'Local Switch server'; * web dashboard is available. */ export const LOCAL_SERVER_PROFILES = ['collab', 'gateway'] as const; -/** Registry the private release images live in until the public-repo flip - * (CHOO-1260); used for the authenticated `docker login` before pulling. */ +/** Registry the release images are published to and pulled from. */ export const GHCR_REGISTRY = 'ghcr.io'; /** The gateway admin account the stack seeds (GATEWAY_ADMIN_EMAIL). Switch Console diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/ghcr-auth.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/ghcr-auth.ts deleted file mode 100644 index fa057e3a1..000000000 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/ghcr-auth.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { execFile, spawn } from 'node:child_process'; -import { promisify } from 'node:util'; -import { GH_EXECUTABLE, getGithubTokenFromGhCli } from '@main/core/updates/github-token'; -import { log } from '@main/lib/logger'; -import { GHCR_REGISTRY } from './constants'; -import { DOCKER_EXECUTABLE } from './docker'; - -const execFileAsync = promisify(execFile); - -/** The signed-in GitHub username, needed as the `docker login` user for a GHCR - * personal-access token. Null when `gh` is missing / not authenticated. */ -async function getGithubLogin(): Promise { - try { - const { stdout } = await execFileAsync(GH_EXECUTABLE, ['api', 'user', '--jq', '.login'], { - timeout: 10_000, - }); - const login = stdout.trim(); - return login.length > 0 ? login : null; - } catch { - return null; - } -} - -/** The desktop's GitHub identity (username + token) from the `gh` CLI, or null - * when unavailable. Used both for the local `docker login` and to forward - * credentials to a remote host that has no `gh` of its own. */ -export async function getLocalGithubIdentity(): Promise<{ - username: string; - token: string; -} | null> { - const token = await getGithubTokenFromGhCli(); - if (!token) return null; - const username = await getGithubLogin(); - if (!username) return null; - return { username, token }; -} - -/** `docker login -u --password-stdin`, feeding the token over - * stdin so it never lands in the process argv / command history. */ -function dockerLoginWithToken(registry: string, username: string, token: string): Promise { - return new Promise((resolve, reject) => { - const child = spawn(DOCKER_EXECUTABLE, ['login', registry, '-u', username, '--password-stdin']); - let stderr = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`docker login ${registry} failed (exit ${code}): ${stderr.trim()}`)); - }); - child.stdin.write(token); - child.stdin.end(); - }); -} - -/** - * Authenticate the local Docker to GHCR using the user's existing `gh` login, so - * private release images pull before the public-repo flip (CHOO-1260). When no - * gh token is available we warn and proceed: if the images are already public - * this is a no-op, and if they are private the subsequent `docker compose up` - * fails loudly on the pull — which surfaces the real problem rather than hiding - * it here. - */ -export async function ensureLocalGhcrLogin(): Promise { - const identity = await getLocalGithubIdentity(); - if (!identity) { - log.warn( - 'local-switch-server: no gh CLI token/login; skipping GHCR login (private image pulls will fail)' - ); - return; - } - await dockerLoginWithToken(GHCR_REGISTRY, identity.username, identity.token); - log.info('local-switch-server: authenticated Docker to GHCR'); -} diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/local-host.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/local-host.ts index df68ac857..625b42c1f 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/local-host.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/local-host.ts @@ -7,7 +7,6 @@ import type { DockerAvailability } from '@shared/core/managed-switch-server/mana import { LOCAL_SERVER_PROJECT_NAME } from '../constants'; import { DOCKER_EXECUTABLE, detectDocker } from '../docker'; import { type LocalServerPorts, pickFreePorts } from '../free-port'; -import { ensureLocalGhcrLogin } from '../ghcr-auth'; import { localServerDir } from '../paths'; import type { ServerHost } from './types'; @@ -90,10 +89,6 @@ export class LocalServerHost implements ServerHost { return detectDocker(); } - ensureGhcrLogin(): Promise { - return ensureLocalGhcrLogin(); - } - pickFreePorts(): Promise { return pickFreePorts(); } diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-ghcr-auth.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-ghcr-auth.ts deleted file mode 100644 index bfa702558..000000000 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-ghcr-auth.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { IExecutionContext } from '@main/core/execution-context/types'; -import { log } from '@main/lib/logger'; -import { GHCR_REGISTRY } from '../constants'; -import { getLocalGithubIdentity } from '../ghcr-auth'; - -/** The GitHub login of an authenticated `gh` on the remote host, or null when - * `gh` is missing or not signed in (`gh api user` needs an auth token). */ -async function remoteGhUsername(ctx: IExecutionContext): Promise { - try { - const { stdout } = await ctx.exec('gh', ['api', 'user', '--jq', '.login'], { timeout: 15_000 }); - const login = stdout.trim(); - return login.length > 0 ? login : null; - } catch { - return null; - } -} - -/** Forward the desktop's GitHub token to the remote `docker login`: write it to - * a 0600 temp file on the host, `docker login --password-stdin < file`, delete - * it. Keeps the token out of argv/history on the remote. */ -async function loginWithForwardedToken( - host: { - ctx: IExecutionContext; - writeFile: (relPath: string, content: string, mode?: number) => Promise; - }, - username: string, - token: string -): Promise { - const tokenFile = '.ghcr-token'; - await host.writeFile(tokenFile, token, 0o600); - try { - await host.ctx.exec('sh', [ - '-c', - `docker login ${GHCR_REGISTRY} -u ${username} --password-stdin < ${tokenFile}`, - ]); - } finally { - // Remove the token file whether or not the login succeeded. - await host.ctx.exec('rm', ['-f', tokenFile]).catch(() => {}); - } -} - -/** - * Authenticate a remote host's Docker to GHCR so private release images pull - * before the public-repo flip (CHOO-1260). - * - * Prefers the DESKTOP's `gh` identity (forwarded over the SSH connection): it is - * the identity the operator already uses to pull these images in local-server - * mode, so it is known to have read access to every release package. The remote - * host's own `gh` is only a fallback — a host may have `gh` logged in as a - * different or less-privileged account that lacks access to some package, which - * would 403 mid-pull. If neither is available we warn and proceed: a public - * image is a no-op, and a private one fails loudly on the subsequent pull. - */ -export async function ensureRemoteGhcrLogin(host: { - ctx: IExecutionContext; - writeFile: (relPath: string, content: string, mode?: number) => Promise; - label: string; -}): Promise { - const { ctx, label } = host; - - const identity = await getLocalGithubIdentity(); - if (identity) { - await loginWithForwardedToken(host, identity.username, identity.token); - log.info( - `remote-switch-server: authenticated ${label} Docker to GHCR via forwarded desktop token` - ); - return; - } - - const remoteUser = await remoteGhUsername(ctx); - if (remoteUser) { - await ctx.exec('sh', [ - '-c', - `gh auth token | docker login ${GHCR_REGISTRY} -u ${remoteUser} --password-stdin`, - ]); - log.info(`remote-switch-server: authenticated ${label} Docker to GHCR via its own gh login`); - return; - } - - log.warn( - `remote-switch-server: no desktop gh token and no gh login on ${label}; skipping GHCR login (private image pulls will fail)` - ); -} diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-host.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-host.ts index f85c79181..622979b2c 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-host.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/remote-host.ts @@ -15,7 +15,6 @@ import type { LocalServerPorts } from '../free-port'; import { remoteServerStateDir } from '../paths'; import { PortForwarder } from './port-forward'; import { pickRemoteFreePorts } from './remote-free-port'; -import { ensureRemoteGhcrLogin } from './remote-ghcr-auth'; import { hostSlug, remoteSecretsKey } from './remote-identity'; import type { ServerHost } from './types'; @@ -183,14 +182,6 @@ export class RemoteServerHost implements ServerHost { } } - ensureGhcrLogin(): Promise { - return ensureRemoteGhcrLogin({ - ctx: this.ctx, - writeFile: (relPath, content, mode) => this.writeFile(relPath, content, mode), - label: this.label, - }); - } - pickFreePorts(): Promise { return pickRemoteFreePorts(this.ctx); } diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/types.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/types.ts index bd4915f3f..991e377d2 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/types.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/host/types.ts @@ -78,12 +78,6 @@ export interface ServerHost { /** Whether Docker is usable on the host (CLI resolves AND daemon answers). */ detectDocker(): Promise; - /** Authenticate the host's Docker to GHCR so the private release images pull - * before the public-repo flip (CHOO-1260). Best-effort: warns and proceeds - * when no credentials are available, so a public image is a no-op and a - * private one fails loudly on the subsequent pull. */ - ensureGhcrLogin(): Promise; - /** Free host ports for the stack to publish on the host's loopback. The * remote host picks numbers free on BOTH the remote and the desktop loopback, * so the same number can be mirrored by the local forward. */ diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.test.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.test.ts index b23468a90..ccec8bd0b 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.test.ts @@ -64,7 +64,6 @@ function options() { label: 'this computer', writeFile, detectDocker: () => Promise.resolve({ available: true, version: '27.0.0' }), - ensureGhcrLogin: vi.fn(() => Promise.resolve()), establishNetworking: vi.fn(() => Promise.resolve()), }; return { @@ -185,14 +184,14 @@ describe('startStack version guard', () => { expect(logWarn).not.toHaveBeenCalled(); }); - it('refuses before authenticating to the registry, so nothing off-host happens either', async () => { + it('refuses before writing configuration, so nothing off-host happens either', async () => { readDeployedVersionMock.mockResolvedValue({ kind: 'deployed', version: '0.12.0', source: 'env-file', }); - const { opts } = options(); + const { writeFile, opts } = options(); await startStack(opts); - expect(opts.host.ensureGhcrLogin).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); }); }); diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.ts index 2b7c919ef..e796683f1 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/pipeline.ts @@ -116,9 +116,6 @@ export async function startStack(opts: StartStackOptions): Promise { }; }); - // gh being installed is not enough — it must also be authenticated. Only probe - // auth when the binary is present (probing gh auth without gh would just fail). - const gh = views.find((v) => v.id === 'gh'); - if (gh && gh.status === 'available') { - gh.ghAuth = await probeGhAuthStatus(sshHost); - } - return views; } -/** - * Start an interactive `gh auth login` device-flow session on a remote host over - * SSH. Runs in a PTY registered with the shared PtySessionRegistry so the renderer - * can attach a live terminal (subscribe to output, send keystrokes) via the pty - * RPC/events. gh prints a one-time code and a verification URL; the user opens the - * URL in their own browser and enters the code. Returns the PTY session id. - * - * `read:packages` is requested explicitly because `gh auth login` does not ask - * for it — its defaults are `gist`, `read:org`, `repo` and `workflow`. Sessions - * on this host fetch their MCP runtime from GitHub Packages, and without that - * scope the registry refuses with - * `403 … token provided does not match expected scopes`, several layers below - * anything that mentions `gh`. Asking for it during the one interactive login - * the user already performs is the only point where it costs nothing; every - * other route ends in `gh auth refresh` on a box they thought was set up. - */ -async function startGhAuth(sshHost: string): Promise<{ sessionId: string }> { - const proxy = await ensureSshConnected(sshConnectionIdForHost(sshHost), sshHost); - const profile = await proxy.getRemoteShellProfile(); - // Login when logged out, refresh when already logged in. `gh auth login` on - // an authenticated host stops to ask whether you meant to re-authenticate, - // which is a confusing thing to meet when all you needed was a scope; `gh - // auth refresh` adds it without disturbing the existing login. Both are the - // same device-code flow in this PTY, so the user sees no difference. - const remoteCommand = buildRemoteShellCommand( - profile, - 'if gh auth status >/dev/null 2>&1; then ' + - 'gh auth refresh --hostname github.com --scopes read:packages; ' + - 'else ' + - 'gh auth login --hostname github.com --git-protocol https --web --scopes read:packages; ' + - 'fi' - ); - const sessionId = `gh-auth:${crypto.randomUUID()}`; - - const opened = await openSsh2Pty(proxy, { - id: sessionId, - command: remoteCommand, - cols: 80, - rows: 24, - }); - if (!opened.success) { - throw new Error(`Could not start gh auth on ${sshHost}: ${opened.error.message}`); - } - - ptySessionRegistry.register(sessionId, opened.data, { - metadata: { title: `gh auth login (${sshHost})`, isRemote: true }, - }); - return { sessionId }; -} - export const remoteHostsController = createRPCController({ /** SSH aliases from ~/.ssh/config, for the onboarding picker. */ listSshConfigHosts: (): Promise => listSshConfigHosts(), @@ -246,10 +175,6 @@ export const remoteHostsController = createRPCController({ probeDeps: (sshHost: string): Promise => probeDeps(sshHost), - /** Begin an interactive `gh auth login` PTY session on the host; returns its pty session id. */ - startGhAuth: (params: { sshHost: string }): Promise<{ sessionId: string }> => - startGhAuth(params.sshHost), - installDep: async (params: { sshHost: string; id: string; diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/gh-auth.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/gh-auth.ts deleted file mode 100644 index 21c519db8..000000000 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/gh-auth.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * GitHub CLI authentication on a remote host. - * - * `gh` being on PATH is not enough to use it — it must also be logged in with a - * token that can read GitHub Packages, and that login is an interactive device - * flow. Extracted from the remote-hosts controller so the setup runner - * (CHOO-1809) can probe auth as its own step rather than as a side effect of a - * dependency sweep. - */ - -import { isTransportFailure } from '@switch-console/core/exec'; -import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { sshConnectionIdForHost } from '@main/core/locations/location-transport'; -import { ensureSshConnected } from '@main/core/ssh/connect/connect-agent-ssh'; -import { - GH_AUTH_STATUS_ARGS, - type GhAuthState, - parseGhAuthStatus, - READ_PACKAGES_SCOPE, -} from '@shared/core/npm-registry'; - -export type GhAuthStatus = { - authenticated: boolean; - account: string | null; - /** - * Whether the token can read GitHub Packages. - * - * Authenticated is not sufficient: `gh auth login` requests `gist`, - * `read:org`, `repo` and `workflow`, and not `read:packages`. Sessions on - * this host fetch their MCP runtime from GitHub Packages, so without it they - * start with no tools and the registry's `403 … does not match expected - * scopes` is the only clue — reported nowhere near the host that caused it. - */ - canReadPackages: boolean; - /** Why the credential is unusable, when it is. Null when it is fine. */ - detail: string | null; -}; - -const NOT_LOGGED_IN: GhAuthStatus = { - authenticated: false, - account: null, - canReadPackages: false, - detail: 'Not logged in to GitHub on this host.', -}; - -function statusFrom(state: GhAuthState): GhAuthStatus { - switch (state.status) { - case 'ok': - return { authenticated: true, account: state.login, canReadPackages: true, detail: null }; - case 'missing-scope': - return { - authenticated: true, - account: state.login, - canReadPackages: false, - detail: `The GitHub token is missing the ${READ_PACKAGES_SCOPE} scope.`, - }; - case 'invalid': - return { ...NOT_LOGGED_IN, detail: state.detail }; - case 'unknown': - // The check did not apply — do not invent a fault the host may not have. - return { authenticated: true, account: null, canReadPackages: true, detail: null }; - } -} - -/** - * What `gh` on a remote host will do with the credentials it currently has. - * - * A non-zero exit (which SshExecutionContext throws on) means `gh` is missing - * or has no login at all. A transport failure propagates rather than being read - * as "not logged in" — a dead connection is not evidence about the login state. - * - * `--json` exits zero even when the token is rejected, so an unusable - * credential arrives as a parsed `invalid` rather than as a throw, and is - * reported as not authenticated: a token the API refuses is no better than - * none, and saying "authenticated" of it sends the user looking elsewhere. - */ -export async function probeGhAuthStatus(sshHost: string): Promise { - const proxy = await ensureSshConnected(sshConnectionIdForHost(sshHost), sshHost); - const ctx = new SshExecutionContext(proxy); - try { - const { stdout } = await ctx.exec('gh', GH_AUTH_STATUS_ARGS); - return statusFrom(parseGhAuthStatus(stdout)); - } catch (error) { - if (isTransportFailure(error)) throw error; - // Older gh versions reject `--json` and exit non-zero with nothing useful - // to parse; they land here alongside a genuinely logged-out host. The - // sign-in flow that follows is the right answer for both. - const stdout = (error as { stdout?: string } | undefined)?.stdout; - if (stdout) { - const state = parseGhAuthStatus(stdout); - if (state.status !== 'unknown') return statusFrom(state); - } - return NOT_LOGGED_IN; - } -} diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.test.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.test.ts index e693f90e9..1e839229d 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.test.ts @@ -516,46 +516,6 @@ describe('plan completion', () => { }); }); -describe('gh-auth steps', () => { - const ghStep = (patch: Partial = {}) => - step('gh:auth', { kind: 'gh-auth', name: 'GitHub CLI login', optional: true, ...patch }); - - it('tells the user to sign in rather than reporting a missing install command', async () => { - const { runner, installOrder } = makeRunner({ - checks: { 'gh:auth': [{ outcome: 'missing' }] }, - installs: {}, - canInstall: () => false, - }); - - const result = await runner.runSingleStep(plan([ghStep()]), 'gh:auth'); - - expect(installOrder).toEqual([]); - expect(result.steps[0]!.error).toMatch(/Use Sign in to start it/); - }); - - it('leads with why a login that already exists is still not enough', async () => { - const { runner } = makeRunner({ - checks: { - 'gh:auth': [ - { - outcome: 'missing', - error: 'The GitHub token is missing the read:packages scope.', - }, - ], - }, - installs: {}, - canInstall: () => false, - }); - - const result = await runner.runSingleStep(plan([ghStep()]), 'gh:auth'); - - expect(result.steps[0]!.state).toBe('failed'); - expect(result.steps[0]!.error).toMatch( - /^The GitHub token is missing the read:packages scope\./ - ); - }); -}); - describe('runner determinism', () => { it('does not mutate the plan it was given', async () => { const { runner } = makeRunner({ checks: { git: [{ outcome: 'satisfied' }] }, installs: {} }); diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.ts index e82090014..0f10dfadf 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-runner.ts @@ -430,16 +430,6 @@ function findStep(plan: HostSetupPlan, stepId: string): HostSetupStep { /** Why a step cannot be advanced automatically — stated in terms of what we saw. */ function describeUnactionable(step: HostSetupStep): string { - // Not a failure of ours to fix: signing in to GitHub is a device flow the - // user drives in a terminal. Say what they need to do rather than reporting - // it as a missing install command. A login that exists but lacks a scope - // says so first — "sign in" reads as wrong advice to someone already signed - // in, and re-running the flow is nonetheless the fix. - if (step.kind === 'gh-auth') { - const prefix = step.error ? `${step.error} ` : ''; - return `${prefix}Signing in to GitHub needs a one-time code you enter yourself. Use Sign in to start it.`; - } - switch (step.outcome) { case 'not-running': return `${step.name} is installed but not running. Start it on the host, then retry.`; diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-service.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-service.ts index fc0f9053f..adae73b4e 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-service.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/host-setup-service.ts @@ -25,22 +25,15 @@ import { type HostSetupPlan, type HostSetupStep, } from '@shared/core/remote-hosts/setup'; -import { probeGhAuthStatus } from '../gh-auth'; import { hostReachabilityService } from '../production-host-reachability'; import { HostSetupRunner, type StepCheckResult, type StepInstallResult } from './host-setup-runner'; import { InstallProgressReader } from './install-progress'; -import { - agentPluginStepId, - buildSetupPlan, - GH_AUTH_STEP_ID, - reconcileInterruptedPlan, -} from './plan-builder'; +import { agentPluginStepId, buildSetupPlan, reconcileInterruptedPlan } from './plan-builder'; import { deleteSetupPlan, getSetupPlan, listSetupPlans, saveSetupPlan } from './setup-plan-store'; import { condenseCommandOutput, describeInstallFailure, outcomeForDependency, - outcomeForGhAuth, } from './step-outcomes'; /** Runners are per-host so two hosts can be set up at once, but a host only once. */ @@ -165,9 +158,6 @@ function runnerFor(sshHost: string, manager: HostDependencyManager): HostSetupRu publish: (plan) => events.emit(hostSetupPlanEventChannel, plan), requireReachable: (host) => hostReachabilityService.requireReachable(host), canInstall: (step) => { - // The gh device flow is interactive by nature — it needs a terminal the - // user types into, so it can never be part of an unattended run. - if (step.kind === 'gh-auth') return false; if (step.kind === 'agent-plugin') return true; return manager.getInstallOptions(step.id).length > 0; }, @@ -193,10 +183,6 @@ export async function checkStep( manager: HostDependencyManager, step: HostSetupStep ): Promise { - if (step.kind === 'gh-auth') { - return outcomeForGhAuth(await probeGhAuthStatus(sshHost)); - } - if (step.kind === 'agent-plugin') { const service = await getRemoteSwitchSetupService(sshHost); const agentId = stepAgentId(step); @@ -348,10 +334,6 @@ export async function updateStep( manager: HostDependencyManager, step: HostSetupStep ): Promise { - if (step.kind === 'gh-auth') { - return { ok: false, error: 'A GitHub login is not something that can be updated.' }; - } - if (step.kind === 'agent-plugin') { const service = await getRemoteSwitchSetupService(sshHost); const result = await service.update(stepAgentId(step)); @@ -464,4 +446,4 @@ export async function discardSetupPlan(sshHost: string): Promise { await deleteSetupPlan(sshHost); } -export { agentPluginStepId, GH_AUTH_STEP_ID }; +export { agentPluginStepId }; diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.test.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.test.ts index fd37e5483..f8ce7fdf0 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.test.ts @@ -4,12 +4,7 @@ import { listPlugins } from '@main/core/providers/plugin-registry'; import { deriveHostStatus } from '@shared/core/remote-hosts/host-status'; import type { HostReachability } from '@shared/core/remote-hosts/reachability'; import type { HostSetupPlan, HostSetupStep } from '@shared/core/remote-hosts/setup'; -import { - agentPluginStepId, - buildSetupPlan, - GH_AUTH_STEP_ID, - reconcileInterruptedPlan, -} from './plan-builder'; +import { agentPluginStepId, buildSetupPlan, reconcileInterruptedPlan } from './plan-builder'; const NOW = '2026-02-02T00:00:00.000Z'; @@ -32,23 +27,20 @@ function build(existing: HostSetupPlan | null = null) { } describe('buildSetupPlan', () => { - it('orders core tools first, then gh login, then each agent CLI before its plugin', () => { + it('orders core tools first, then each agent CLI before its plugin', () => { expect(build().steps.map((s) => s.id)).toEqual([ 'git', 'node', 'tmux', 'gh', - GH_AUTH_STEP_ID, 'claude-code', agentPluginStepId('claude-code'), ]); }); - it('marks gh and its login optional so they cannot strand a host', () => { + it('leaves every core tool required, so none can strand a host silently', () => { const plan = build(); const optional = plan.steps.filter((s) => s.optional).map((s) => s.id); - // gh is required: without it the Switch connector cannot fetch its runtime, - // so an agent on this host would start with no Switch tools (CHOO-1873). expect(optional).toEqual([]); }); @@ -79,7 +71,6 @@ describe('buildSetupPlan', () => { it('assigns the right kind to each step', () => { const kinds = Object.fromEntries(build().steps.map((s) => [s.id, s.kind])); expect(kinds.git).toBe('core-dependency'); - expect(kinds[GH_AUTH_STEP_ID]).toBe('gh-auth'); expect(kinds['claude-code']).toBe('agent-cli'); expect(kinds[agentPluginStepId('claude-code')]).toBe('agent-plugin'); }); @@ -286,10 +277,7 @@ describe('buildSetupPlan — against the real registry', () => { it('reports a host with every prerequisite installed as ready, whatever agent types ship', () => { // The reported bug: all prerequisites present, one agent CLI absent, and the // host badge read "Setup required". - const plan = satisfy( - realPlan(), - (step) => step.kind === 'core-dependency' || step.kind === 'gh-auth' - ); + const plan = satisfy(realPlan(), (step) => step.kind === 'core-dependency'); expect(deriveHostStatus(reachable, plan).kind).toBe('ready'); }); @@ -298,7 +286,7 @@ describe('buildSetupPlan — against the real registry', () => { // The inverse, so the test above cannot pass by never blocking anything. const plan = satisfy( realPlan(), - (step) => (step.kind === 'core-dependency' || step.kind === 'gh-auth') && step.id !== 'node' + (step) => step.kind === 'core-dependency' && step.id !== 'node' ); expect(deriveHostStatus(reachable, plan).kind).toBe('setup-required'); diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.ts index 7fb2f165c..ad0da87d4 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/plan-builder.ts @@ -7,8 +7,7 @@ * declared once, in one place: * * 1. core host tools, in dependency order (git → node → tmux → …) - * 2. gh, then its interactive login - * 3. per agent type: its CLI, then the Switch connector plugin + * 2. per agent type: its CLI, then the Switch connector plugin * * Rebuilding is **merge, not replace**. A plan is rebuilt whenever the set of * known dependencies changes (a new agent type ships, say), and discarding the @@ -49,9 +48,6 @@ export type BuildPlanInput = { now: string; }; -/** Step id for the interactive `gh auth login`. */ -export const GH_AUTH_STEP_ID = 'gh:auth'; - function blankStep( id: string, kind: HostSetupStepKind, @@ -81,21 +77,7 @@ export function buildSetupPlan(input: BuildPlanInput): HostSetupPlan { const steps: HostSetupStep[] = []; for (const dep of coreDependencies) { - // gh was optional on the theory that its interactive login could be - // deferred without the host being unusable. CHOO-1873 disproved that: the - // Switch connector fetches its MCP runtime from GitHub Packages at session - // start, so without gh — authenticated, with read:packages — every agent on - // this host comes up with no Switch tools. A host in that state is not - // ready, and calling it ready is the failure this rewrite exists to remove. steps.push(blankStep(dep.id, 'core-dependency', dep.name, now)); - - if (dep.id === 'gh') { - steps.push( - blankStep(GH_AUTH_STEP_ID, 'gh-auth', 'GitHub CLI login', now, { - dependsOn: ['gh'], - }) - ); - } } for (const agent of agentTypes) { diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/setup-plan-store.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/setup-plan-store.ts index 8c3599d5f..496253573 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/setup-plan-store.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/setup-plan-store.ts @@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm'; import { db } from '@main/db/client'; import { remoteHostSetupPlans, type RemoteHostSetupPlanRow } from '@main/db/schema'; +import { log } from '@main/lib/logger'; import type { DependencyCheckOutcome, HostSetupPlan, @@ -46,7 +47,8 @@ const LEGACY_STEP_STATES: Record = { // "never attempted because something upstream failed" is just "not yet". blocked: 'pending', }; -const STEP_KINDS: HostSetupStepKind[] = ['core-dependency', 'agent-cli', 'agent-plugin', 'gh-auth']; +const STEP_KINDS: HostSetupStepKind[] = ['core-dependency', 'agent-cli', 'agent-plugin']; + const OUTCOMES: DependencyCheckOutcome[] = [ 'satisfied', 'missing', @@ -55,6 +57,19 @@ const OUTCOMES: DependencyCheckOutcome[] = [ 'unknown', ]; +/** + * A step whose kind this version no longer defines is dropped, not rejected. + * + * `listSetupPlans` parses every host's row in one pass, so raising here would + * take the readiness list — the sidebar and the agent-creation gate — down for + * ALL hosts on account of one stale row. The plan is rebuilt from the live + * dependency set immediately afterwards, so the step is not lost, it is + * recomputed. Dropping is logged rather than silent. + */ +function isKnownStepKind(raw: unknown): raw is HostSetupStepKind { + return typeof raw === 'string' && (STEP_KINDS as string[]).includes(raw); +} + function oneOf( allowed: T[], raw: unknown, @@ -120,10 +135,21 @@ function toPlan(row: RemoteHostSetupPlanRow): HostSetupPlan { throw new Error(`Persisted setup plan for host ${row.sshHost} is not a list of steps`); } + const live = parsed.filter((step) => { + if (typeof step !== 'object' || step === null) return true; + const kind = (step as { kind?: unknown }).kind; + if (isKnownStepKind(kind)) return true; + log.warn('remote-host setup: dropping a step kind this version no longer defines', { + sshHost: row.sshHost, + kind: String(kind), + }); + return false; + }); + return { sshHost: row.sshHost, status: oneOf(PLAN_STATUSES, row.status, 'plan status', row.sshHost, LEGACY_PLAN_STATUSES), - steps: parsed.map((step) => toStep(step, row.sshHost)), + steps: live.map((step) => toStep(step, row.sshHost)), currentStepId: row.currentStepId, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.test.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.test.ts index e750f6660..cac66c031 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.test.ts @@ -1,53 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { GhAuthStatus } from '../gh-auth'; import { condenseCommandOutput, describeInstallFailure, outcomeForDependency, - outcomeForGhAuth, } from './step-outcomes'; -function ghStatus(overrides: Partial): GhAuthStatus { - return { - authenticated: true, - account: 'octocat', - canReadPackages: true, - detail: null, - ...overrides, - }; -} - -describe('outcomeForGhAuth', () => { - it('is satisfied only when the login can also read packages', () => { - expect(outcomeForGhAuth(ghStatus({}))).toEqual({ outcome: 'satisfied', version: 'octocat' }); - }); - - it('does not accept a login that lacks read:packages, and says why', () => { - const result = outcomeForGhAuth( - ghStatus({ - canReadPackages: false, - detail: 'The GitHub token is missing the read:packages scope.', - }) - ); - - expect(result.outcome).toBe('missing'); - expect(result.error).toMatch(/read:packages/); - }); - - it('reports a host with no login at all as missing', () => { - const result = outcomeForGhAuth( - ghStatus({ - authenticated: false, - account: null, - canReadPackages: false, - detail: 'Not logged in.', - }) - ); - - expect(result).toEqual({ outcome: 'missing', error: 'Not logged in.' }); - }); -}); - describe('outcomeForDependency', () => { it('reports an available dependency as satisfied with its version', () => { expect(outcomeForDependency({ status: 'available', version: '2.44.0' }, false)).toEqual({ @@ -175,7 +132,7 @@ describe('describeInstallFailure — the install command could not run', () => { const banner = [ ' ____ _ ', ' / ___| __ _ _ __ | |', - 'alg-bench-debian-12-v260624', + 'example-host-debian-12-v260624', '/bin/bash: line 1: npm: command not found', ].join('\n'); diff --git a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.ts b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.ts index 15a745feb..0e87e99bb 100644 --- a/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.ts +++ b/console/apps/switch-console-desktop/src/main/core/remote-hosts/setup/step-outcomes.ts @@ -8,8 +8,6 @@ */ import type { DependencyCheckOutcome } from '@shared/core/remote-hosts/setup'; -import type { GhAuthStatus } from '../gh-auth'; -import type { StepCheckResult } from './host-setup-runner'; /** * Translate a probed dependency into an observation. @@ -132,19 +130,3 @@ export function describeInstallFailure( return message; } - -/** - * Translate a probed GitHub login into an observation. - * - * Being logged in is not the same as being usable: without `read:packages` - * every session this host starts fetches its MCP runtime from GitHub Packages - * and gets a 403 several layers below anything that mentions `gh` (CHOO-1873). - * Reporting that login as satisfied is the stale-green bug in another coat, so - * the step stays outstanding and carries the reason. - */ -export function outcomeForGhAuth(status: GhAuthStatus): StepCheckResult { - if (status.authenticated && status.canReadPackages) { - return { outcome: 'satisfied', version: status.account }; - } - return { outcome: 'missing', error: status.detail ?? undefined }; -} diff --git a/console/apps/switch-console-desktop/src/main/core/ssh/lifecycle/ssh-client-proxy.ts b/console/apps/switch-console-desktop/src/main/core/ssh/lifecycle/ssh-client-proxy.ts index 03fc57387..c34026b79 100644 --- a/console/apps/switch-console-desktop/src/main/core/ssh/lifecycle/ssh-client-proxy.ts +++ b/console/apps/switch-console-desktop/src/main/core/ssh/lifecycle/ssh-client-proxy.ts @@ -28,7 +28,7 @@ type RemoteShellProfileState = * Max concurrent short-lived `exec` (command) channels per connection. SSH * servers cap simultaneous sessions (OpenSSH MaxSessions defaults to 10, and * proxies/tunnels can be lower); bursts of parallel probes (dependency checks, - * plugin status, gh auth) otherwise trip "Channel open failure: open failed". + * plugin status) otherwise trip "Channel open failure: open failed". * Long-lived PTY channels (execPty) are intentionally not counted here — they * would hold a slot for the terminal's whole life. They get their own * open-only semaphore below. diff --git a/console/apps/switch-console-desktop/src/main/core/switch-rooms/npm-registry-auth.test.ts b/console/apps/switch-console-desktop/src/main/core/switch-rooms/npm-registry-auth.test.ts deleted file mode 100644 index 69b06db15..000000000 --- a/console/apps/switch-console-desktop/src/main/core/switch-rooms/npm-registry-auth.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import { isEnvShadowedToken, NPMRC_CONTENTS, parseGhAuthStatus } from '@shared/core/npm-registry'; -import { remoteNpmRegistryAuthEnv } from './npm-registry-auth'; - -vi.mock('electron', () => ({ app: { getPath: () => '/userData' } })); -vi.mock('@main/lib/logger', () => ({ - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})); -vi.mock('@main/core/updates/github-token', () => ({ - GH_EXECUTABLE: 'gh', - getGithubTokenFromGhCli: vi.fn(), -})); - -type Call = { command: string; args: string[] }; - -function fakeCtx(handlers: { - token?: () => Promise<{ stdout: string; stderr: string }>; - status?: () => Promise<{ stdout: string; stderr: string }>; - write?: () => Promise<{ stdout: string; stderr: string }>; -}): { ctx: IExecutionContext; calls: Call[] } { - const calls: Call[] = []; - const ok = async () => ({ stdout: '', stderr: '' }); - const ctx = { - supportsLocalSpawn: false, - exec: async (command: string, args: string[] = []) => { - calls.push({ command, args }); - if (command === 'gh' && args[1] === 'token') return (handlers.token ?? ok)(); - if (command === 'gh' && args[1] === 'status') return (handlers.status ?? ok)(); - return (handlers.write ?? ok)(); - }, - execStreaming: async () => {}, - dispose: () => {}, - } as unknown as IExecutionContext; - return { ctx, calls }; -} - -function ghStatus(...entries: Record[]): string { - return JSON.stringify({ hosts: { 'github.com': entries } }); -} - -const KEYRING = '/home/u/.config/gh/hosts.yml'; - -describe('parseGhAuthStatus', () => { - it('accepts a token carrying the scope', () => { - const state = parseGhAuthStatus( - ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'gist, read:org, read:packages, repo, workflow', - }) - ); - expect(state).toEqual({ - status: 'ok', - login: 'octocat', - scopes: ['gist', 'read:org', 'read:packages', 'repo', 'workflow'], - tokenSource: KEYRING, - }); - }); - - it('flags a token missing the scope', () => { - const state = parseGhAuthStatus( - ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'gist, read:org, repo, workflow', - }) - ); - expect(state.status).toBe('missing-scope'); - }); - - // The bug this parser replaces: the old check searched the whole of - // `gh auth status` for the scope, so a second account that had it answered - // for the active account that did not. - it('judges only the active account, not whichever account has the scope', () => { - const state = parseGhAuthStatus( - ghStatus( - { state: 'success', active: true, login: 'work', tokenSource: KEYRING, scopes: 'repo' }, - { - state: 'success', - active: false, - login: 'personal', - tokenSource: KEYRING, - scopes: 'repo, read:packages', - } - ) - ); - expect(state.status).toBe('missing-scope'); - }); - - // The state a stale GH_TOKEN produces: the env token is active and rejected, - // while the keyring account below it is healthy and has the scope. - it('reports an unusable active token even when another account is fine', () => { - const state = parseGhAuthStatus( - ghStatus( - { - state: 'error', - active: true, - login: '', - tokenSource: 'GH_TOKEN', - error: 'non-200 OK status code: 401 Unauthorized', - }, - { - state: 'success', - active: false, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'repo, read:packages', - } - ) - ); - expect(state).toMatchObject({ status: 'invalid', tokenSource: 'GH_TOKEN' }); - expect(isEnvShadowedToken(state)).toBe(true); - }); - - it('recognises an environment token that is otherwise healthy', () => { - const state = parseGhAuthStatus( - ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: 'GH_TOKEN', - scopes: 'repo, read:packages', - }) - ); - expect(state.status).toBe('ok'); - // Healthy today, but it outlives the next `gh auth login` — worth saying. - expect(isEnvShadowedToken(state)).toBe(true); - }); - - it('does not treat the keyring as a shadowing token', () => { - const state = parseGhAuthStatus( - ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'read:packages', - }) - ); - expect(isEnvShadowedToken(state)).toBe(false); - }); - - // Guessing wrong must not send someone chasing a scope they already have, - // nor block setup on a check that did not apply. - it.each([ - ['unparseable output', 'gh: unknown flag --json'], - ['no hosts', JSON.stringify({ hosts: {} })], - ])('returns unknown for %s', (_label, stdout) => { - expect(parseGhAuthStatus(stdout)).toEqual({ status: 'unknown' }); - }); - - it('returns unknown when the scope field is absent', () => { - const state = parseGhAuthStatus( - ghStatus({ state: 'success', active: true, login: 'octocat', tokenSource: KEYRING }) - ); - expect(state).toEqual({ status: 'unknown' }); - }); -}); - -describe('remoteNpmRegistryAuthEnv', () => { - it('writes the npmrc on the remote host and returns the env pointing at it', async () => { - const { ctx, calls } = fakeCtx({ - token: async () => ({ stdout: 'ghp_remote\n', stderr: '' }), - status: async () => ({ - stdout: ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'repo, read:packages', - }), - stderr: '', - }), - }); - - const env = await remoteNpmRegistryAuthEnv(ctx, '/home/ubuntu/repo'); - - expect(env).toEqual({ - npm_config_userconfig: '/home/ubuntu/repo/.switchdash/npmrc', - SWITCHDASH_GITHUB_TOKEN: 'ghp_remote', - }); - - // The file must be written on the remote host — the desktop's own npmrc - // path does not exist there, which is the whole reason this exists. - const write = calls.find((c) => c.command === 'sh'); - expect(write?.args[1]).toContain('/home/ubuntu/repo/.switchdash/npmrc'); - expect(write?.args[1]).toContain(NPMRC_CONTENTS.split('\n')[0]); - }); - - it('returns nothing when the remote host has no gh token', async () => { - const { ctx } = fakeCtx({ token: async () => ({ stdout: ' \n', stderr: '' }) }); - expect(await remoteNpmRegistryAuthEnv(ctx, '/repo')).toEqual({}); - }); - - // A session with no MCP server beats no session at all. - it('returns nothing rather than throwing when gh is absent', async () => { - const { ctx } = fakeCtx({ - token: async () => { - throw new Error('command not found: gh'); - }, - }); - expect(await remoteNpmRegistryAuthEnv(ctx, '/repo')).toEqual({}); - }); - - it('still returns the env when the scope probe fails', async () => { - const { ctx } = fakeCtx({ - token: async () => ({ stdout: 'ghp_remote', stderr: '' }), - status: async () => { - throw new Error('gh auth status exploded'); - }, - }); - const env = await remoteNpmRegistryAuthEnv(ctx, '/repo'); - expect(env.SWITCHDASH_GITHUB_TOKEN).toBe('ghp_remote'); - }); -}); diff --git a/console/apps/switch-console-desktop/src/main/core/switch-rooms/npm-registry-auth.ts b/console/apps/switch-console-desktop/src/main/core/switch-rooms/npm-registry-auth.ts deleted file mode 100644 index 5637f0ef4..000000000 --- a/console/apps/switch-console-desktop/src/main/core/switch-rooms/npm-registry-auth.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { app } from 'electron'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import { GH_EXECUTABLE, getGithubTokenFromGhCli } from '@main/core/updates/github-token'; -import { events } from '@main/lib/events'; -import { log } from '@main/lib/logger'; -import { quoteShellArg } from '@main/utils/shellEscape'; -import { - GH_AUTH_STATUS_ARGS, - type GhAuthState, - isEnvShadowedToken, - NPMRC_CONTENTS, - npmRegistryEnv, - parseGhAuthStatus, - READ_PACKAGES_FIX, - READ_PACKAGES_SCOPE, -} from '@shared/core/npm-registry'; -import { switchToolsUnavailableEvent } from '@shared/events/switchSetupEvents'; - -const execFileAsync = promisify(execFile); - -/** - * Registry access for sessions Switch Console starts, local and remote. - * - * Both functions here return an empty environment when `gh` has no usable - * token. The caller should start the session anyway: one with no MCP server is - * worse than one whose agent cannot reach Switch, and the warnings below name - * the cause, which a bare npm 404 would not. - * - * See `@shared/core/npm-registry` for what the two settings are and why. - */ - -const MISSING_SCOPE_DETAIL = - 'gh auth login does not request read:packages, so the registry will refuse ' + - 'with 403 and the session will start without its MCP tools'; - -/** - * Report what `gh` will do with its current credentials. - * - * A shadowed token is called out separately because the remedy differs: the - * user has usually just authenticated, and being told to authenticate again - * would send them round the loop that produced the state. - */ -function warnAboutGhAuth(state: GhAuthState, host: string): void { - if (isEnvShadowedToken(state)) { - log.warn('npmRegistryAuth: an environment token is shadowing the gh login', { - event: 'npm_registry_auth_env_shadowed', - host, - tokenSource: state.status === 'unknown' ? 'unknown' : state.tokenSource, - detail: - 'gh prefers GH_TOKEN/GITHUB_TOKEN over the keyring, so re-running gh auth ' + - 'login will not change which token is used until that variable is unset', - }); - } - if (state.status === 'missing-scope') { - log.warn('npmRegistryAuth: the GitHub token cannot read packages', { - event: 'npm_registry_auth_missing_scope', - host, - account: state.login, - scopes: state.scopes.join(', '), - fix: READ_PACKAGES_FIX, - detail: MISSING_SCOPE_DETAIL, - }); - } else if (state.status === 'invalid') { - log.warn('npmRegistryAuth: the active GitHub token is not usable', { - event: 'npm_registry_auth_invalid_token', - host, - tokenSource: state.tokenSource, - detail: state.detail, - }); - } -} - -/** - * Tell the user, not just the log, when the session will come up without tools. - * - * Only for sessions on this machine: a remote host's state belongs on that - * host's setup page, where it is already reported, and a toast cannot say which - * host it meant. - */ -function announceUnusableAuth(state: GhAuthState): void { - if (state.status === 'missing-scope') { - events.emit(switchToolsUnavailableEvent, { - reason: 'missing-scope', - detail: `The GitHub token is missing the ${READ_PACKAGES_SCOPE} scope.`, - }); - } else if (state.status === 'invalid') { - events.emit(switchToolsUnavailableEvent, { - reason: isEnvShadowedToken(state) ? 'env-shadowed' : 'invalid-token', - detail: isEnvShadowedToken(state) - ? `A ${state.tokenSource} environment variable is overriding your gh login, and it is not usable.` - : state.detail, - }); - } -} - -function localNpmrcPath(): string { - return join(app.getPath('userData'), 'npm', 'npmrc'); -} - -/** - * Registry access for a session on this machine. - * - * The npmrc is ours, not the user's: `~/.npmrc` is their configuration and - * editing it to make our plugin work is a reach, while a file in their project - * shows up in git status. `npm_config_userconfig` makes npm read ours instead, - * confining the footprint to Switch Console's own directory. - */ -export async function npmRegistryAuthEnv(): Promise> { - const token = await getGithubTokenFromGhCli(); - if (!token) { - log.warn('npmRegistryAuth: no GitHub token from `gh` — the agent runtime will not resolve', { - event: 'npm_registry_auth_missing_token', - hint: 'run `gh auth login`; a private package reads as 404 without it', - }); - events.emit(switchToolsUnavailableEvent, { - reason: 'not-authenticated', - detail: 'This machine is not authenticated to GitHub.', - }); - return {}; - } - - try { - const { stdout } = await execFileAsync(GH_EXECUTABLE, GH_AUTH_STATUS_ARGS, { - timeout: 10_000, - }); - const state = parseGhAuthStatus(stdout); - warnAboutGhAuth(state, 'this machine'); - announceUnusableAuth(state); - } catch { - // Never fatal — a diagnostic, not a gate. - } - - const path = localNpmrcPath(); - try { - await mkdir(join(app.getPath('userData'), 'npm'), { recursive: true }); - // 0600: it carries no secret today, but it is npm auth configuration and - // should not be world-readable if that ever changes. - await writeFile(path, NPMRC_CONTENTS, { mode: 0o600 }); - } catch (error) { - log.warn('npmRegistryAuth: could not write npmrc', { - event: 'npm_registry_auth_write_failed', - path, - error: String(error), - }); - return {}; - } - - log.info('npmRegistryAuth: registry access configured for spawned sessions', { - event: 'npm_registry_auth_ready', - npmrc: path, - }); - return npmRegistryEnv(path, token); -} - -/** - * Registry access for a session on a remote host, over that host's execution - * context. - * - * The desktop's own configuration is useless here: its npmrc path does not - * exist on the VM and its token is the wrong machine's. Both have to be - * produced there, which is why this exists rather than reusing the above. - * - * Writes to the same `/.switchdash/npmrc` the sidecar uses, so a host - * ends up with one file however its sessions were started. - */ -export async function remoteNpmRegistryAuthEnv( - ctx: IExecutionContext, - repoDir: string -): Promise> { - let token = ''; - try { - const { stdout } = await ctx.exec('gh', ['auth', 'token'], { timeout: 15_000 }); - token = stdout.trim(); - } catch (error) { - log.warn('npmRegistryAuth: `gh auth token` failed on the remote host', { - event: 'npm_registry_auth_no_gh', - error: String(error), - }); - return {}; - } - if (!token) { - log.warn('npmRegistryAuth: no GitHub token on the remote host', { - event: 'npm_registry_auth_missing_token', - hint: 'run `gh auth login` there; the package is private and reads as 404 without it', - }); - return {}; - } - - try { - const { stdout } = await ctx.exec('gh', GH_AUTH_STATUS_ARGS, { timeout: 15_000 }); - warnAboutGhAuth(parseGhAuthStatus(stdout), 'the remote host'); - } catch { - // Never fatal — a diagnostic, not a gate. - } - - const dir = `${repoDir}/.switchdash`; - const npmrc = `${dir}/npmrc`; - try { - await ctx.exec('sh', [ - '-c', - `mkdir -p ${quoteShellArg(dir)} && printf %s ${quoteShellArg(NPMRC_CONTENTS)} > ${quoteShellArg(npmrc)} && chmod 600 ${quoteShellArg(npmrc)}`, - ]); - } catch (error) { - log.warn('npmRegistryAuth: could not write npmrc on the remote host', { - event: 'npm_registry_auth_write_failed', - path: npmrc, - error: String(error), - }); - return {}; - } - - log.info('npmRegistryAuth: registry access configured for the remote session', { - event: 'npm_registry_auth_ready', - npmrc, - }); - return npmRegistryEnv(npmrc, token); -} diff --git a/console/apps/switch-console-desktop/src/main/core/switch-setup/controller.ts b/console/apps/switch-console-desktop/src/main/core/switch-setup/controller.ts index bd5857cc5..2acefe901 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-setup/controller.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-setup/controller.ts @@ -2,7 +2,6 @@ import { hostReachabilityService } from '@main/core/remote-hosts/production-host import { log } from '@main/lib/logger'; import type { AgentTypeAvailability } from '@shared/core/switch-setup/agent-type-availability'; import { createRPCController } from '@shared/lib/ipc/rpc'; -import { type LocalGhAuthStatus, probeLocalGhAuth, startLocalGhAuth } from './local-gh-auth'; import { getRemoteSwitchSetupService } from './remote-switch-setup'; import { switchSetupService } from './switch-setup-service'; @@ -10,11 +9,6 @@ export const switchSetupController = createRPCController({ /** Every Switch-capable agent type on this machine, usable or not. */ listAgentTypeAvailability: () => switchSetupService.listAgentTypeAvailability(), - /** Whether this machine can fetch the MCP runtime from GitHub Packages. */ - getLocalGhAuth: (): Promise => probeLocalGhAuth(), - - /** Interactive `gh` login/refresh on this machine; returns a PTY session id. */ - startLocalGhAuth: (): Promise<{ sessionId: string }> => startLocalGhAuth(), /** * Every Switch-capable agent type on a remote host, usable or not. * diff --git a/console/apps/switch-console-desktop/src/main/core/switch-setup/local-gh-auth.test.ts b/console/apps/switch-console-desktop/src/main/core/switch-setup/local-gh-auth.test.ts deleted file mode 100644 index ca7ae9810..000000000 --- a/console/apps/switch-console-desktop/src/main/core/switch-setup/local-gh-auth.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const execFileMock = vi.hoisted(() => vi.fn()); -const spawnLocalPty = vi.hoisted(() => - vi.fn((_options: { args: string[]; env: Record }) => ({ id: 'pty' })) -); -const register = vi.hoisted(() => vi.fn()); - -vi.mock('node:child_process', () => ({ execFile: execFileMock })); -vi.mock('node:util', () => ({ promisify: () => execFileMock })); -vi.mock('@main/core/pty/local-pty', () => ({ spawnLocalPty })); -vi.mock('@main/core/pty/pty-session-registry', () => ({ ptySessionRegistry: { register } })); -vi.mock('@main/core/updates/github-token', () => ({ GH_EXECUTABLE: 'gh' })); -vi.mock('@main/lib/logger', () => ({ - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})); - -const { probeLocalGhAuth, startLocalGhAuth } = await import('./local-gh-auth'); - -function ghStatus(...entries: Record[]): string { - return JSON.stringify({ hosts: { 'github.com': entries } }); -} - -const KEYRING = '/home/u/.config/gh/hosts.yml'; - -const HEALTHY = ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'repo, read:packages', -}); - -beforeEach(() => { - execFileMock.mockReset(); - spawnLocalPty.mockClear(); - register.mockClear(); -}); - -describe('probeLocalGhAuth', () => { - it('reports a usable login', async () => { - execFileMock.mockResolvedValue({ stdout: HEALTHY, stderr: '' }); - expect(await probeLocalGhAuth()).toEqual({ - ghInstalled: true, - authenticated: true, - canReadPackages: true, - account: 'octocat', - envShadowed: false, - detail: null, - }); - }); - - it('reports a login that cannot read packages', async () => { - execFileMock.mockResolvedValue({ - stdout: ghStatus({ - state: 'success', - active: true, - login: 'octocat', - tokenSource: KEYRING, - scopes: 'repo', - }), - stderr: '', - }); - const status = await probeLocalGhAuth(); - expect(status).toMatchObject({ authenticated: true, canReadPackages: false }); - expect(status.detail).toContain('read:packages'); - }); - - // A token the API rejects is no better than none, and calling it - // authenticated sends the user looking in the wrong place. - it('reports a rejected token as not authenticated, and names the shadowing', async () => { - execFileMock.mockResolvedValue({ - stdout: ghStatus({ - state: 'error', - active: true, - login: '', - tokenSource: 'GH_TOKEN', - error: '401 Unauthorized', - }), - stderr: '', - }); - expect(await probeLocalGhAuth()).toMatchObject({ - authenticated: false, - envShadowed: true, - detail: '401 Unauthorized', - }); - }); - - it('reports a missing gh binary distinctly from a missing login', async () => { - execFileMock.mockRejectedValue(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })); - expect(await probeLocalGhAuth()).toMatchObject({ - ghInstalled: false, - authenticated: false, - }); - }); - - it('treats a non-zero exit as no login', async () => { - execFileMock.mockRejectedValue(new Error('exit 1')); - expect(await probeLocalGhAuth()).toMatchObject({ - ghInstalled: true, - authenticated: false, - detail: 'Not logged in to GitHub.', - }); - }); - - // Reporting a fault we cannot see would block setup for a working install. - it('assumes ready when the output cannot be understood', async () => { - execFileMock.mockResolvedValue({ stdout: 'gh: unknown flag --json', stderr: '' }); - expect(await probeLocalGhAuth()).toMatchObject({ - ghInstalled: true, - authenticated: true, - canReadPackages: true, - }); - }); -}); - -describe('startLocalGhAuth', () => { - it('refreshes when already logged in, asking for the scope', async () => { - execFileMock.mockResolvedValue({ stdout: HEALTHY, stderr: '' }); - await startLocalGhAuth(); - const { args } = spawnLocalPty.mock.calls[0][0]; - expect(args.slice(0, 2)).toEqual(['auth', 'refresh']); - expect(args).toContain('read:packages'); - }); - - it('logs in when there is no login, asking for the scope', async () => { - execFileMock.mockRejectedValue(new Error('exit 1')); - await startLocalGhAuth(); - const { args } = spawnLocalPty.mock.calls[0][0]; - expect(args.slice(0, 2)).toEqual(['auth', 'login']); - expect(args).toContain('read:packages'); - }); - - // gh prefers these over the keyring, so `gh auth refresh` would act on the - // environment token — the very thing the user is trying to get past. - it('runs without the environment tokens that would shadow the keyring', async () => { - execFileMock.mockResolvedValue({ stdout: HEALTHY, stderr: '' }); - process.env.GH_TOKEN = 'stale'; - process.env.GITHUB_TOKEN = 'stale'; - try { - await startLocalGhAuth(); - } finally { - delete process.env.GH_TOKEN; - delete process.env.GITHUB_TOKEN; - } - const { env } = spawnLocalPty.mock.calls[0][0]; - expect(env.GH_TOKEN).toBeUndefined(); - expect(env.GITHUB_TOKEN).toBeUndefined(); - }); - - it('refuses when gh is not installed, naming where to get it', async () => { - execFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); - await expect(startLocalGhAuth()).rejects.toThrow(/cli\.github\.com/); - expect(spawnLocalPty).not.toHaveBeenCalled(); - }); -}); diff --git a/console/apps/switch-console-desktop/src/main/core/switch-setup/local-gh-auth.ts b/console/apps/switch-console-desktop/src/main/core/switch-setup/local-gh-auth.ts deleted file mode 100644 index ca8f981b0..000000000 --- a/console/apps/switch-console-desktop/src/main/core/switch-setup/local-gh-auth.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { execFile } from 'node:child_process'; -import { homedir } from 'node:os'; -import { promisify } from 'node:util'; -import { spawnLocalPty } from '@main/core/pty/local-pty'; -import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; -import { GH_EXECUTABLE } from '@main/core/updates/github-token'; -import { log } from '@main/lib/logger'; -import { - GH_AUTH_STATUS_ARGS, - GH_HOST, - type GhAuthState, - isEnvShadowedToken, - READ_PACKAGES_SCOPE, -} from '@shared/core/npm-registry'; -import { parseGhAuthStatus } from '@shared/core/npm-registry'; - -const execFileAsync = promisify(execFile); - -/** - * Whether this machine can fetch the Switch MCP runtime from GitHub Packages. - * - * The remote-hosts page has had this for a while; locally there was nothing, so - * the same misconfiguration produced a session that looked healthy and silently - * had no Switch tools. - */ -export type LocalGhAuthStatus = { - ghInstalled: boolean; - authenticated: boolean; - account: string | null; - canReadPackages: boolean; - /** - * An environment token is being used instead of the keyring. - * - * Worth its own field rather than folding into the others: it is the one - * state that re-authenticating does not fix, so the UI has to say something - * different. `gh` prefers `GH_TOKEN`/`GITHUB_TOKEN`, and Switch Console inherits - * whatever the user's shell exported. - */ - envShadowed: boolean; - /** Why the credential is unusable, when it is. */ - detail: string | null; -}; - -const READY: Pick = { - ghInstalled: true, - authenticated: true, - canReadPackages: true, -}; - -function statusFrom(state: GhAuthState): LocalGhAuthStatus { - const envShadowed = isEnvShadowedToken(state); - switch (state.status) { - case 'ok': - return { ...READY, account: state.login, envShadowed, detail: null }; - case 'missing-scope': - return { - ...READY, - canReadPackages: false, - account: state.login, - envShadowed, - detail: `The GitHub token is missing the ${READ_PACKAGES_SCOPE} scope.`, - }; - case 'invalid': - return { - ghInstalled: true, - authenticated: false, - canReadPackages: false, - account: null, - envShadowed, - detail: state.detail, - }; - case 'unknown': - // The check did not apply. Claiming a fault we cannot see would block - // setup for someone whose install works. - return { ...READY, account: null, envShadowed: false, detail: null }; - } -} - -/** What `gh` on this machine will do, in the shape the setup UI renders. */ -export async function probeLocalGhAuth(): Promise { - try { - const { stdout } = await execFileAsync(GH_EXECUTABLE, GH_AUTH_STATUS_ARGS, { - timeout: 10_000, - }); - return statusFrom(parseGhAuthStatus(stdout)); - } catch (error) { - if ((error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { - return { - ghInstalled: false, - authenticated: false, - canReadPackages: false, - account: null, - envShadowed: false, - detail: 'The GitHub CLI (gh) is not installed.', - }; - } - // `gh auth status --json` exits zero even for a rejected token, so a - // non-zero exit here means no login at all rather than a bad one. Older gh - // versions that reject `--json` also land here; they are reported the same - // way, and the authenticate flow that follows is correct for both. - const stdout = (error as { stdout?: string } | undefined)?.stdout; - if (stdout) { - const state = parseGhAuthStatus(stdout); - if (state.status !== 'unknown') return statusFrom(state); - } - return { - ghInstalled: true, - authenticated: false, - canReadPackages: false, - account: null, - envShadowed: false, - detail: 'Not logged in to GitHub.', - }; - } -} - -/** - * The environment for the interactive login. - * - * `GH_TOKEN`/`GITHUB_TOKEN` are removed so the flow acts on the keyring: `gh` - * prefers them, and `gh auth refresh` against an environment token fails rather - * than adding the scope. Removing them here only affects this one child — a - * user who genuinely authenticates by environment variable still has it, and - * `envShadowed` is how they are told it is what the runtime will use. - */ -function ghAuthEnv(): Record { - const env: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value === undefined) continue; - if (key === 'GH_TOKEN' || key === 'GITHUB_TOKEN') continue; - env[key] = value; - } - return env; -} - -/** - * Start an interactive `gh` device-flow login on this machine, in a PTY the - * renderer attaches a live terminal to. - * - * `read:packages` is requested explicitly because `gh auth login` does not ask - * for it — its defaults are `gist`, `read:org`, `repo` and `workflow`. Asking - * during the one interactive login the user already performs is the only point - * where it costs nothing; every other route ends in `gh auth refresh` on a - * machine they thought was set up. - * - * Refresh when already logged in, login when not: `gh auth login` on an - * authenticated machine stops to ask whether you meant to re-authenticate, - * which is a confusing thing to meet when all you needed was a scope. The - * branch is decided here rather than in a shell so there is no quoting to get - * wrong and nothing that assumes a POSIX shell exists. - */ -export async function startLocalGhAuth(): Promise<{ sessionId: string }> { - const status = await probeLocalGhAuth(); - if (!status.ghInstalled) { - throw new Error( - 'The GitHub CLI (gh) is not installed. Install it from https://cli.github.com and try again.' - ); - } - - const args = status.authenticated - ? ['auth', 'refresh', '--hostname', GH_HOST, '--scopes', READ_PACKAGES_SCOPE] - : [ - 'auth', - 'login', - '--hostname', - GH_HOST, - '--git-protocol', - 'https', - '--web', - '--scopes', - READ_PACKAGES_SCOPE, - ]; - - const sessionId = `gh-auth-local:${crypto.randomUUID()}`; - log.info('startLocalGhAuth: starting interactive gh auth', { - event: 'local_gh_auth_start', - mode: status.authenticated ? 'refresh' : 'login', - sessionId, - }); - - const pty = spawnLocalPty({ - id: sessionId, - command: GH_EXECUTABLE, - args, - cwd: homedir(), - env: ghAuthEnv(), - cols: 80, - rows: 24, - }); - - ptySessionRegistry.register(sessionId, pty, { - metadata: { title: 'gh auth login', isRemote: false }, - }); - return { sessionId }; -} diff --git a/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.test.ts b/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.test.ts index b7e45080d..55aa970f2 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.test.ts @@ -6,11 +6,8 @@ const mocks = vi.hoisted(() => ({ getPlugin: vi.fn(), listPlugins: vi.fn(), readFile: vi.fn(), - probeLocalGhAuth: vi.fn(), })); -vi.mock('./local-gh-auth', () => ({ probeLocalGhAuth: mocks.probeLocalGhAuth })); - vi.mock('@main/core/execution-context/local-execution-context', () => ({ LocalExecutionContext: class { exec = mocks.exec; @@ -213,14 +210,6 @@ beforeEach(() => { vi.clearAllMocks(); mocks.resolveCommandPath.mockResolvedValue('/usr/bin/claude'); mocks.getPlugin.mockReturnValue(CLI_AGENT); - mocks.probeLocalGhAuth.mockResolvedValue({ - ghInstalled: true, - authenticated: true, - canReadPackages: true, - account: 'octocat', - envShadowed: false, - detail: null, - }); }); describe('switchSetupService.getStatus', () => { @@ -311,35 +300,6 @@ describe('switchSetupService.listAgentTypeAvailability', () => { expect(availability.map((entry) => entry.agentId)).not.toContain(NONE_AGENT.metadata.id); }); - - // An installed plugin still resolves its MCP server from a private registry - // at session start. Offering the agent type on the strength of the install - // alone onboards an agent that comes up with no Switch tools. - it.each([ - ['gh is not installed', { ghInstalled: false, authenticated: false, canReadPackages: false }], - ['there is no login', { ghInstalled: true, authenticated: false, canReadPackages: false }], - [ - 'the token cannot read packages', - { ghInstalled: true, authenticated: true, canReadPackages: false }, - ], - ])('makes nothing available when %s, even with the connector installed', async (_label, gh) => { - mocks.listPlugins.mockReturnValue([CLI_AGENT, NONE_AGENT]); - mocks.exec.mockImplementation(execImpl('0.1.0')); - mocks.readFile.mockImplementation(readFileImpl('0.1.0', '0.1.0')); - mocks.probeLocalGhAuth.mockResolvedValue({ - ...gh, - account: null, - envShadowed: false, - detail: 'nope', - }); - - const availability = await switchSetupService.listAgentTypeAvailability(); - - expect(availability.every((entry) => !entry.available)).toBe(true); - // Blocked for a reason no per-type install would fix, so it has to name the - // GitHub access rather than telling the user to install what they have. - expect(availability[0]!.blockedReason).toContain('GitHub'); - }); }); describe('switchSetupService.checkForUpdates', () => { diff --git a/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.ts b/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.ts index 6a398d06e..98648caa3 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-setup/switch-setup-service.ts @@ -4,10 +4,8 @@ import { resolveCommandPath } from '@switch-console/core/deps/runtime'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { log } from '@main/lib/logger'; import { isNewerVersion } from '@main/lib/semver'; -import { READ_PACKAGES_SCOPE } from '@shared/core/npm-registry'; import type { AgentTypeAvailability } from '@shared/core/switch-setup/agent-type-availability'; import { getPlugin, listPlugins } from '../providers/plugin-registry'; -import { probeLocalGhAuth } from './local-gh-auth'; import { cliRulesFor, type InstalledPlugin, @@ -42,38 +40,9 @@ export type SwitchSetupResult = { success: boolean; message?: string }; const EXEC_TIMEOUT_MS = 120_000; -/** Failures that mean the CLI could not read the private marketplace repo. */ -const NOT_FOUND_RE = /\b(404|403|not found|could not resolve|authentication|permission denied)\b/i; - -/** - * Attach a cause to an install failure. - * - * The marketplace lives in a private repo, so an unauthenticated CLI is told - * the repo does not exist. Passed through untouched — as it was — the user - * reads "404" and has no way to know it is about their GitHub login, or gets - * "Install failed." with nothing at all when the CLI wrote no stderr. - * - * The GitHub state is only consulted once something has already failed: it - * costs a subprocess, and a healthy install should not pay for it. - */ -async function describeInstallFailure(raw: string): Promise { - const message = raw || 'Install failed.'; - if (raw && !NOT_FOUND_RE.test(raw)) return message; - - const gh = await probeLocalGhAuth(); - if (!gh.ghInstalled) { - return `${message}\n\nThe Switch plugin is published to a private GitHub repository, and the GitHub CLI (gh) is not installed on this machine. Install it from https://cli.github.com, then authenticate.`; - } - if (!gh.authenticated) { - return `${message}\n\nThe Switch plugin is published to a private GitHub repository and this machine is not authenticated to GitHub${gh.detail ? ` (${gh.detail})` : ''}. Authenticate from Switch setup and try again.`; - } - if (!gh.canReadPackages) { - return `${message}\n\nThe GitHub token is missing the ${READ_PACKAGES_SCOPE} scope, which is required to read the private Switch packages. Re-authenticate from Switch setup and try again.`; - } - if (gh.envShadowed) { - return `${message}\n\nGitHub access is coming from a GH_TOKEN/GITHUB_TOKEN environment variable rather than your gh login, and that token cannot reach the private Switch repository. Unset it, or give it the ${READ_PACKAGES_SCOPE} scope.`; - } - return message; +/** A CLI failure with no stderr still needs to say something. */ +function installFailureMessage(raw: string): string { + return raw || 'Install failed.'; } type RunResult = { code: number; stdout: string; stderr: string }; @@ -265,39 +234,14 @@ class SwitchSetupService { * onboarding agent-type picker, which only offers ready-to-use types. */ /** - * Agent types that can actually be onboarded on this machine. - * - * An installed plugin is not sufficient. The plugin resolves its MCP server - * from a private registry at session start, so without GitHub access the - * agent onboards, reports itself installed, and then runs with no Switch - * tools — which is what "installed" appeared to promise. Availability is - * withheld until the credential can actually fetch it, matching how a remote - * host reports readiness. + * Agent types that can actually be onboarded on this machine — those whose + * Switch connector plugin is installed. */ async listAgentTypeAvailability(): Promise { const types = listPlugins() .filter((plugin) => plugin.capabilities.switchSetup.kind === 'cli') .map((plugin) => plugin.metadata.id); - const gh = await probeLocalGhAuth(); - if (!gh.ghInstalled || !gh.authenticated || !gh.canReadPackages) { - log.warn('switch-setup: no agent type is onboardable — GitHub access is not usable', { - event: 'switch_setup_onboarding_blocked_by_github', - ghInstalled: gh.ghInstalled, - authenticated: gh.authenticated, - canReadPackages: gh.canReadPackages, - envShadowed: gh.envShadowed, - }); - // Blocked for every type at once, and not for a reason any per-type - // install would fix, so say so on each rather than probing them. - return types.map((agentId) => ({ - agentId, - available: false, - blockedReason: - 'This computer cannot reach the private GitHub packages the Switch connector needs.', - })); - } - const availability: AgentTypeAvailability[] = []; for (const agentId of types) { const status = await this.getStatus(agentId); @@ -357,12 +301,12 @@ class SwitchSetupService { rules ); } catch (err) { - return { success: false, message: await describeInstallFailure(String(err)) }; + return { success: false, message: installFailureMessage(String(err)) }; } const res = await this.run(bin, rules.installArgs(ref, descriptor.scope)); return res.code === 0 ? { success: true } - : { success: false, message: await describeInstallFailure(res.stderr.trim()) }; + : { success: false, message: installFailureMessage(res.stderr.trim()) }; } /** diff --git a/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.test.ts b/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.test.ts index 83991d82a..8eae7a596 100644 --- a/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.test.ts @@ -18,7 +18,6 @@ function makeSignals(): UpdateSignals & { calls: string[] } { progress: vi.fn(() => void calls.push('progress')), downloaded: vi.fn(() => void calls.push('downloaded')), failed: vi.fn(() => void calls.push('failed')), - authRequired: vi.fn(() => void calls.push('authRequired')), }; } @@ -105,14 +104,6 @@ describe('FakeUpdateDriver', () => { expect(signals.calls).toEqual(['checking', 'failed']); }); - it('signals missing credentials', async () => { - const signals = makeSignals(); - const driver = new FakeUpdateDriver('auth-required', signals, '0.18.0', 40); - - expect(await driver.check()).toBeNull(); - expect(signals.calls).toEqual(['checking', 'authRequired']); - }); - it('signals up-to-date', async () => { const signals = makeSignals(); const driver = new FakeUpdateDriver('up-to-date', signals, '0.18.0', 40); diff --git a/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.ts b/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.ts index 1cb69c5bf..f7ffdb39b 100644 --- a/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.ts +++ b/console/apps/switch-console-desktop/src/main/core/updates/dev-harness.ts @@ -20,8 +20,6 @@ export const FAKE_UPDATE_SCENARIOS = [ 'download-error', /** The check itself fails. */ 'check-error', - /** The check reports no GitHub credentials. */ - 'auth-required', /** Check finds nothing. */ 'up-to-date', ] as const; @@ -48,7 +46,6 @@ export interface UpdateSignals { }): void; downloaded(version: string): void; failed(error: unknown): void; - authRequired(): void; } export function isFakeUpdateScenario(value: string): value is FakeUpdateScenario { @@ -107,10 +104,6 @@ export class FakeUpdateDriver { if (this.disposed) return null; switch (this.scenario) { - case 'auth-required': - this.signals.authRequired(); - return null; - case 'check-error': this.signals.failed(new Error('Simulated update check failure (HTTP 503)')); return null; diff --git a/console/apps/switch-console-desktop/src/main/core/updates/github-token.ts b/console/apps/switch-console-desktop/src/main/core/updates/github-token.ts deleted file mode 100644 index 72cf90fbe..000000000 --- a/console/apps/switch-console-desktop/src/main/core/updates/github-token.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { execFile } from 'node:child_process'; -import fs from 'node:fs'; -import { promisify } from 'node:util'; -import { log } from '@main/lib/logger'; - -const execFileAsync = promisify(execFile); - -function resolveGhBin(): string { - const candidates = [ - (process.env.GH_PATH || '').trim(), - '/opt/homebrew/bin/gh', - '/usr/local/bin/gh', - '/usr/bin/gh', - ].filter(Boolean) as string[]; - for (const p of candidates) { - try { - if (fs.existsSync(p)) return p; - } catch {} - } - return 'gh'; -} - -/** Resolved path to the GitHub CLI binary. */ -export const GH_EXECUTABLE = resolveGhBin(); - -/** - * Read the user's existing GitHub token from the `gh` CLI (`gh auth token`). - * - * Reuses the login the user already performed with `gh auth login` so the - * updater can reach the private switch release feed without us shipping a - * baked secret or running our own OAuth app. Returns null when `gh` is missing, - * not authenticated, or returns nothing — callers treat that as "auth not - * available yet" rather than an error. - */ -export async function getGithubTokenFromGhCli(): Promise { - try { - const { stdout } = await execFileAsync(GH_EXECUTABLE, ['auth', 'token'], { - timeout: 10_000, - }); - const token = stdout.trim(); - return token.length > 0 ? token : null; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code === 'ENOENT') { - log.info('gh CLI not found; GitHub-authenticated updates are unavailable'); - } else { - log.info('gh auth token unavailable; GitHub-authenticated updates are unavailable'); - } - return null; - } -} diff --git a/console/apps/switch-console-desktop/src/main/core/updates/update-service.ts b/console/apps/switch-console-desktop/src/main/core/updates/update-service.ts index 8d545ac38..df2d96c1b 100644 --- a/console/apps/switch-console-desktop/src/main/core/updates/update-service.ts +++ b/console/apps/switch-console-desktop/src/main/core/updates/update-service.ts @@ -14,7 +14,6 @@ import { UPDATE_CHANNEL, } from '@shared/app-identity'; import { - updateAuthRequiredEvent, updateAvailableEvent, updateCheckingEvent, updateDownloadedEvent, @@ -33,7 +32,6 @@ import { readFakeDownloadDuration, readFakeUpdateScenario, } from './dev-harness'; -import { getGithubTokenFromGhCli } from './github-token'; import { formatUpdaterError, sanitizeUpdaterLogArgs } from './utils'; const { autoUpdater } = _electronUpdater; @@ -49,15 +47,7 @@ const FAKE_STARTUP_DELAY_MS = 1_500; const FAKE_INSTALL_ROLLBACK_MS = 3_000; export interface UpdateState { - status: - | 'idle' - | 'checking' - | 'available' - | 'downloading' - | 'downloaded' - | 'installing' - | 'error' - | 'auth-required'; + status: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'error'; lastCheck?: Date; nextCheck?: Date; currentVersion: string; @@ -215,11 +205,6 @@ class UpdateService implements IInitializable, IDisposable { this.updateState.rollbackVersion = this.updateState.currentVersion; events.emit(updateDownloadedEvent, { version }); }, - - authRequired: () => { - this.updateState.status = 'auth-required'; - events.emit(updateAuthRequiredEvent, undefined); - }, }; private setupEventListeners(): void { @@ -270,40 +255,18 @@ class UpdateService implements IInitializable, IDisposable { } private async _performCheck(): Promise { - if (this.updateState.status === 'error' || this.updateState.status === 'auth-required') { + if (this.updateState.status === 'error') { this.updateState.status = 'idle'; this.updateState.error = undefined; } if (this.fake) return this.fake.check(); - // The switch release feed is a private repo, so the updater needs a token. - // Reuse the user's existing `gh` login rather than shipping a secret. Without - // a token we stay dormant (auth-required) instead of erroring every hour. - const token = await getGithubTokenFromGhCli(); - if (!token) { - this.signals.authRequired(); - log.info('Skipping update check: no GitHub token from gh CLI (run `gh auth login`)'); - return null; - } - // A private repo needs electron-updater's authenticated provider - // (api.github.com); the public releases.atom feed 404s and the auth header - // alone does not select it. The token goes through setFeedURL rather than - // GH_TOKEN: the environment is inherited by every child process Switch Console - // spawns — including `gh` itself, which prefers GH_TOKEN over the keyring — - // so a token left there outlives the login it came from and shadows the - // next one until the app restarts. autoUpdater.setFeedURL({ provider: 'github', owner: RELEASE_REPO_OWNER, repo: RELEASE_REPO_NAME, - private: true, - token, }); - autoUpdater.requestHeaders = { - 'Cache-Control': 'no-cache', - authorization: `token ${token}`, - }; log.info('Checking for updates...', { channel: UPDATE_CHANNEL, @@ -437,12 +400,7 @@ class UpdateService implements IInitializable, IDisposable { const version = this.updateState.availableVersion; if (!version) return null; - // The release feed is a private repo: an unauthenticated read 404s on - // every release, so reuse the token the update check already relies on. - const token = await getGithubTokenFromGhCli(); - const response = await fetch(switchConsoleReleaseApiUrl(version), { - headers: token ? { authorization: `token ${token}` } : {}, - }); + const response = await fetch(switchConsoleReleaseApiUrl(version)); if (!response.ok) { log.warn('Could not fetch release notes', { @@ -450,7 +408,6 @@ class UpdateService implements IInitializable, IDisposable { stage: 'fetch', errorCode: `http_${response.status}`, version, - authenticated: token !== null, }); return null; } diff --git a/console/apps/switch-console-desktop/src/renderer/App.tsx b/console/apps/switch-console-desktop/src/renderer/App.tsx index 9c36f6c44..e803ee39f 100644 --- a/console/apps/switch-console-desktop/src/renderer/App.tsx +++ b/console/apps/switch-console-desktop/src/renderer/App.tsx @@ -3,7 +3,6 @@ import { AppMenuEvents } from './app/app-menu-events'; import { Workspace } from './app/workspace'; import { SessionFocusReporter } from './features/sessions/session-focus-reporter-mount'; import { SessionDeeplinkListener } from './features/switch-rooms/session-deeplink-listener'; -import { SwitchToolsUnavailableListener } from './features/switch-rooms/switch-tools-unavailable-listener'; import { WorkspaceLayoutContextProvider } from './lib/layout/layout-provider'; import { WorkspaceViewProvider } from './lib/layout/provider'; import { ModalRenderer } from './lib/modal/modal-renderer'; @@ -22,7 +21,6 @@ function AppContent() { - diff --git a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx index dc99633bd..aa857735c 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx @@ -1,6 +1,5 @@ import { CircleAlert } from 'lucide-react'; import { useEffect, useMemo } from 'react'; -import { useLocalGhAuth } from '@renderer/features/settings/agents-page/LocalGhAuthRow'; import { AgentIcon } from '@renderer/lib/components/agent-icon'; import { useAgents } from '@renderer/lib/stores/use-agents'; import { useAgentTypeAvailability } from '@renderer/lib/stores/use-switch-setup'; @@ -35,9 +34,6 @@ export function AgentTypePicker({ }) { const { data: availability, isPending } = useAgentTypeAvailability(sshHost); const { data: agents } = useAgents(); - const { data: ghAuth } = useLocalGhAuth(); - const githubBlocked = - !!ghAuth && !(ghAuth.ghInstalled && ghAuth.authenticated && ghAuth.canReadPackages); // Every known type, each with the agent registry's name and icon plus the // verdict from the machine being targeted. Types the registry does not know @@ -70,17 +66,7 @@ export function AgentTypePicker({ but availability is per-host — the connector may well be installed locally and simply missing on the machine being targeted. */} - {/* The plugin may be installed and the agent still unusable, so the - GitHub reason has to be given ahead of the install advice — - otherwise it sends the user to install something they already - have. */} - {!sshHost && githubBlocked ? ( - <> - This computer cannot reach the private GitHub packages the Switch connector needs - {ghAuth?.detail ? ` — ${ghAuth.detail}` : '.'} Authenticate under Settings → Agents - → Switch setup, then try again. - - ) : sshHost ? ( + {sshHost ? ( <> No agent type is set up for Switch on {sshHost}. Install an agent's Switch connector on that host in Settings → Remote hosts diff --git a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/gh-auth-panel.tsx b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/gh-auth-panel.tsx deleted file mode 100644 index b9a60630b..000000000 --- a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/gh-auth-panel.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { events, rpc } from '@renderer/lib/ipc'; -import { FrontendPty } from '@renderer/lib/pty/pty'; -import { PtyPane } from '@renderer/lib/pty/pty-pane'; -import { Button } from '@renderer/lib/ui/button'; -import { Spinner } from '@renderer/lib/ui/spinner'; -import { ptyExitChannel } from '@shared/core/pty/ptyEvents'; - -type Props = { - sshHost: string; - /** Called when the user dismisses the panel (cancel or done). */ - onDone: () => void; -}; - -/** - * Interactive `gh auth login` (device flow) for a remote host, rendered as a live - * terminal INLINE on the remote-hosts page. - * - * This must not live inside a modal: Switch Console's terminal input path is disabled - * whenever a `[role="dialog"]` is present (usePty guards focus, and xterm's key - * handler discards every event), so a terminal in a dialog can never receive - * keystrokes. Rendered inline, it behaves like any other terminal. - * - * The main process spawns `gh auth login --web` in an SSH PTY; the user reads the - * one-time code, clicks the printed verification URL (opens in their browser), and - * authorizes. On exit code 0 the parent re-probes so the gh row flips to - * authenticated. - */ -export function GhAuthPanel({ sshHost, onDone }: Props) { - const [pty, setPty] = useState(null); - const [sessionId, setSessionId] = useState(null); - const [error, setError] = useState(null); - const [exitCode, setExitCode] = useState(undefined); - - const ptyRef = useRef(null); - const sessionIdRef = useRef(null); - const paneRef = useRef<{ focus: () => void } | null>(null); - - useEffect(() => { - let cancelled = false; - let offExit: (() => void) | undefined; - - void (async () => { - try { - const { sessionId: sid } = await rpc.remoteHosts.startGhAuth({ sshHost }); - if (cancelled) { - void rpc.pty.kill(sid); - return; - } - sessionIdRef.current = sid; - setSessionId(sid); - - const frontend = new FrontendPty(sid); - await frontend.connect(); - if (cancelled) { - frontend.dispose(); - void rpc.pty.kill(sid); - return; - } - ptyRef.current = frontend; - setPty(frontend); - - offExit = events.on( - ptyExitChannel, - (info: { exitCode: number; signal?: number }) => { - setExitCode(info.exitCode ?? null); - }, - sid - ); - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); - } - })(); - - return () => { - cancelled = true; - offExit?.(); - const sid = sessionIdRef.current; - if (sid) void rpc.pty.kill(sid); - ptyRef.current?.dispose(); - }; - }, [sshHost]); - - // Focus the terminal as soon as it mounts so keystrokes reach gh's prompts - // without having to click into the pane first. - useEffect(() => { - if (!pty) return; - const id = requestAnimationFrame(() => paneRef.current?.focus()); - return () => cancelAnimationFrame(id); - }, [pty]); - - const exited = exitCode !== undefined; - const succeeded = exitCode === 0; - - return ( -
-
-
Authenticate GitHub CLI on {sshHost}
- -
- -

- gh will print a one-time code and a verification URL below. Click the URL (or open{' '} - github.com/login/device) in your browser, enter the code, - and authorize. -

- - {error ? ( -

{error}

- ) : !pty || !sessionId ? ( -
- Starting gh auth on the host… -
- ) : ( -
- -
- )} - - {exited && - (succeeded ? ( -

Authenticated successfully.

- ) : ( -

- gh auth exited{exitCode == null ? '' : ` with code ${exitCode}`} without completing. You - can close and try again. -

- ))} -
- ); -} diff --git a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-detail-sheet.tsx b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-detail-sheet.tsx index 3bab5a640..04be25881 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-detail-sheet.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-detail-sheet.tsx @@ -20,19 +20,13 @@ import { Label } from '@renderer/lib/ui/label'; import { Sheet, SheetContent, SheetHeader } from '@renderer/lib/ui/sheet'; import { StatusBadge } from '@renderer/lib/ui/status-badge'; import { cn } from '@renderer/utils/utils'; -import { - isStepInFlight, - type HostSetupPlan, - type HostSetupStep, -} from '@shared/core/remote-hosts/setup'; +import { isStepInFlight, type HostSetupStep } from '@shared/core/remote-hosts/setup'; import { agentTypeBadge, canInstall, canOfferAction, - canSignIn, canSkip, outcomeLabel, - signInLabel, stepBadge, type AgentTypeRow, } from './step-presentation'; @@ -161,40 +155,29 @@ function ObservationCard({ function StepActions({ step, - plan, onInstall, installing, hostBusy, onSkip, skipping, - onAuthenticate, }: { step: HostSetupStep; - /** Needed to tell whether this step's own prerequisites are in place. */ - plan: HostSetupPlan | null; onInstall: () => void; installing: boolean; /** True while any operation is running on this host. */ hostBusy: boolean; onSkip: () => void; skipping: boolean; - onAuthenticate: () => void; }) { // The same rule the rows follow: while the host is working, the only honest // thing to show is what it is doing. The runner would refuse these anyway. if (!canOfferAction(hostBusy, installing)) return null; - const signInOffered = canSignIn(step, plan); const installable = canInstall(step); const busy = installing || isStepInFlight(step); - if (!signInOffered && !installable && !canSkip(step)) return null; + if (!installable && !canSkip(step)) return null; return (
- {signInOffered && ( - - )} {installable && (
@@ -342,18 +316,15 @@ export function SetupDetailSheet({ function AgentTypeDetail({ row, sshHost, - plan, activityFor, onInstall, installingStepId, hostBusy, onSkip, skippingStepId, - onAuthenticate, }: { row: AgentTypeRow; sshHost: string; - plan: HostSetupPlan | null; activityFor: (stepId: string) => string | null; onInstall: (stepId: string) => void; installingStepId: string | null; @@ -361,7 +332,6 @@ function AgentTypeDetail({ hostBusy: boolean; onSkip: (stepId: string) => void; skippingStepId: string | null; - onAuthenticate: () => void; }) { const badge = agentTypeBadge(row); return ( @@ -381,13 +351,11 @@ function AgentTypeDetail({ actions={ onInstall(row.cli.id)} installing={installingStepId === row.cli.id} hostBusy={hostBusy} onSkip={() => onSkip(row.cli.id)} skipping={skippingStepId === row.cli.id} - onAuthenticate={onAuthenticate} /> } /> @@ -409,13 +377,11 @@ function AgentTypeDetail({ onInstall(row.plugin!.id)} installing={installingStepId === row.plugin.id} hostBusy={hostBusy} onSkip={() => onSkip(row.plugin!.id)} skipping={skippingStepId === row.plugin.id} - onAuthenticate={onAuthenticate} /> {row.plugin.state === 'failed' && row.plugin.error && ( diff --git a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-rows.tsx b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-rows.tsx index 6d04d5187..fac6ba190 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-rows.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/setup-rows.tsx @@ -7,33 +7,18 @@ * for detail — so a host reads like the rest of the product. */ -import { - GitBranch, - Github, - KeyRound, - Package, - Puzzle, - RefreshCw, - Server, - SquareTerminal, -} from 'lucide-react'; +import { GitBranch, Package, Puzzle, RefreshCw, Server, SquareTerminal } from 'lucide-react'; import { Loader2 } from 'lucide-react'; import { AgentIcon } from '@renderer/lib/components/agent-icon'; import { Button } from '@renderer/lib/ui/button'; import { Label } from '@renderer/lib/ui/label'; import { StatusBadge } from '@renderer/lib/ui/status-badge'; import { cn } from '@renderer/utils/utils'; -import { - isStepInFlight, - type HostSetupPlan, - type HostSetupStep, -} from '@shared/core/remote-hosts/setup'; +import { isStepInFlight, type HostSetupStep } from '@shared/core/remote-hosts/setup'; import { canInstall, canOfferAction, - canSignIn, canUpdate, - signInLabel, stepBadge, versionSubtitle, type AgentTypeRow, @@ -45,11 +30,10 @@ const PREREQUISITE_ICON: Record; } @@ -226,23 +210,18 @@ function UpdateAction({ export function PrerequisiteRow({ step, - plan, isCurrent, installing, updating, rechecking, hostBusy, activity, - authenticating, onInstall, onUpdate, onRecheck, - onAuthenticate, onOpen, }: { step: HostSetupStep; - /** Needed to tell whether this step's own prerequisites are in place. */ - plan: HostSetupPlan | null; isCurrent: boolean; installing: boolean; /** True while this row's update is the operation in flight. */ @@ -252,11 +231,9 @@ export function PrerequisiteRow({ hostBusy: boolean; activity: string | null; /** True while the sign-in terminal for this step is already open. */ - authenticating: boolean; onInstall: () => void; onUpdate: () => void; onRecheck: () => void; - onAuthenticate: () => void; onOpen: () => void; }) { return ( @@ -269,29 +246,12 @@ export function PrerequisiteRow({ highlighted={isCurrent} action={ <> - {/* - Signing in is this row's install: it is the one action that makes the - step satisfied, so it belongs beside it rather than one click away - inside the detail sheet. - */} - {canOfferAction(hostBusy, installing || updating) && - (canSignIn(step, plan) ? ( - - ) : ( - <> - - - - ))} + {canOfferAction(hostBusy, installing || updating) && ( + <> + + + + )} {/* Last, so the primary action keeps the same place whether or not there is one to take. */} { const grouped = groupPlanSteps( plan([ step({ id: 'git', name: 'Git' }), - step({ id: 'gh:auth', kind: 'gh-auth', name: 'GitHub CLI login' }), + step({ id: 'gh', kind: 'core-dependency', name: 'GitHub CLI' }), step({ id: 'claude-code', kind: 'agent-cli', name: 'Claude Code' }), step({ id: 'claude-code:plugin', @@ -158,7 +156,7 @@ describe('groupPlanSteps', () => { ]) ); - expect(grouped.prerequisites.map((s) => s.id)).toEqual(['git', 'gh:auth']); + expect(grouped.prerequisites.map((s) => s.id)).toEqual(['git', 'gh']); expect(grouped.agentTypes).toHaveLength(1); expect(grouped.agentTypes[0]!.agentId).toBe('claude-code'); expect(grouped.agentTypes[0]!.plugin?.id).toBe('claude-code:plugin'); @@ -215,28 +213,30 @@ describe('canInstall', () => { expect(canInstall(step({ state: 'installing' }))).toBe(false); expect(canInstall(step({ state: 'checking' }))).toBe(false); }); - - it('never offers an install for the GitHub login — it is a device flow, not a package', () => { - expect(canInstall(step({ kind: 'gh-auth', state: 'pending', outcome: 'missing' }))).toBe(false); - }); }); describe('dependenciesMet — do not offer an action that cannot work', () => { - const ghAuth = (patch: Partial = {}) => - step({ id: 'gh:auth', kind: 'gh-auth', name: 'GitHub CLI login', dependsOn: ['gh'], ...patch }); + const dependent = (patch: Partial = {}) => + step({ + id: 'claude-code', + kind: 'agent-cli', + name: 'Claude Code', + dependsOn: ['node'], + ...patch, + }); - it('is false while the CLI the flow needs is still missing', () => { - // The device flow runs `gh` on the host; offering Sign in before gh exists - // sends the user into a failure that says nothing about the real problem. - const p = plan([step({ id: 'gh', name: 'GitHub CLI', outcome: 'missing' }), ghAuth()]); + it('is false while the dependency it needs is still missing', () => { + // Offering an install before Node exists sends the user into a failure that + // says nothing about the real problem. + const p = plan([step({ id: 'node', name: 'Node.js', outcome: 'missing' }), dependent()]); expect(dependenciesMet(p.steps[1]!, p)).toBe(false); }); - it('is true once the CLI is there', () => { + it('is true once the dependency is there', () => { const p = plan([ - step({ id: 'gh', name: 'GitHub CLI', state: 'satisfied', outcome: 'satisfied' }), - ghAuth(), + step({ id: 'node', name: 'Node.js', state: 'satisfied', outcome: 'satisfied' }), + dependent(), ]); expect(dependenciesMet(p.steps[1]!, p)).toBe(true); @@ -247,61 +247,10 @@ describe('dependenciesMet — do not offer an action that cannot work', () => { }); it('is false when there is no plan to check against', () => { - expect(dependenciesMet(ghAuth(), null)).toBe(false); + expect(dependenciesMet(dependent(), null)).toBe(false); }); }); -describe('canSignIn — the row and the sheet must agree', () => { - // Both surfaces offer this button, so the rule lives in one place: two copies - // of it is how the sheet once hid Sign in in exactly the state that needs it. - const ghAuth = (patch: Partial = {}) => - step({ id: 'gh:auth', kind: 'gh-auth', name: 'GitHub CLI login', dependsOn: ['gh'], ...patch }); - const withGh = (auth: HostSetupStep) => - plan([step({ id: 'gh', name: 'GitHub CLI', state: 'satisfied', outcome: 'satisfied' }), auth]); - - it('is offered for an outstanding login once gh is installed', () => { - const p = withGh(ghAuth({ outcome: 'missing' })); - - expect(canSignIn(p.steps[1]!, p)).toBe(true); - }); - - it('is offered after a re-check leaves the step pending', () => { - // The state a host actually sits in when the user wants this button. - const p = withGh(ghAuth({ state: 'pending', outcome: 'missing' })); - - expect(canSignIn(p.steps[1]!, p)).toBe(true); - }); - - it('is not offered once the login is good', () => { - const p = withGh(ghAuth({ state: 'satisfied', outcome: 'satisfied' })); - - expect(canSignIn(p.steps[1]!, p)).toBe(false); - }); - - it('is not offered while gh itself is missing', () => { - const p = plan([step({ id: 'gh', name: 'GitHub CLI', outcome: 'missing' }), ghAuth()]); - - expect(canSignIn(p.steps[1]!, p)).toBe(false); - }); - - it('is never offered for something that is not a login', () => { - expect(canSignIn(step({ id: 'git', outcome: 'missing' }), null)).toBe(false); - }); - - it('says re-authenticate when the login exists but lacks the scope', () => { - // "Sign in" reads as wrong advice to someone already signed in, even though - // re-running the flow is the fix. - expect(signInLabel(ghAuth({ error: 'gh is missing the read:packages scope' }))).toBe( - 'Re-authenticate' - ); - expect(signInLabel(ghAuth())).toBe('Sign in'); - }); -}); - -/** - * An available update is information, not a defect — but it has to be - * actionable, and only when we actually know a newer version exists. - */ describe('canUpdate', () => { it('offers an update when a newer version is known', () => { expect( @@ -323,11 +272,7 @@ describe('canUpdate', () => { ); }); - it('never offers to update a login', () => { - expect(canUpdate(step({ kind: 'gh-auth', state: 'satisfied', updateAvailable: true }))).toBe( - false - ); - }); + it('never offers to update a login', () => {}); it('offers nothing while the update is already running', () => { expect(canUpdate(step({ state: 'updating', updateAvailable: true }))).toBe(false); diff --git a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/step-presentation.ts b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/step-presentation.ts index 151608a3b..b26241d37 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/step-presentation.ts +++ b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/setup/step-presentation.ts @@ -51,13 +51,11 @@ export function canSkip(step: HostSetupStep): boolean { /** * Whether Switch Console can attempt an install for this step. * - * The GitHub login is excluded because it is an interactive device flow, not an - * install. Whether an install command actually exists for this host's platform - * is known only in the main process — if it does not, the attempt reports that - * plainly rather than the button being silently absent. + * Whether an install command actually exists for this host's platform is known + * only in the main process — if it does not, the attempt reports that plainly + * rather than the button being silently absent. */ export function canInstall(step: HostSetupStep): boolean { - if (step.kind === 'gh-auth') return false; if ( step.state === 'satisfied' || step.state === 'checking' || @@ -74,22 +72,14 @@ export function canInstall(step: HostSetupStep): boolean { * * Gated on `updateAvailable` alone, which is only ever true when a newer * version is *known* to exist — never inferred from a version we could not - * read. A login has no version to replace, and something not yet installed - * needs Install rather than Update. + * read. Something not yet installed needs Install rather than Update. */ export function canUpdate(step: HostSetupStep): boolean { - if (step.kind === 'gh-auth') return false; if (step.state !== 'satisfied') return false; return step.updateAvailable; } -/** - * Whether everything this step declares a dependency on has been satisfied. - * - * The GitHub login is the case that matters: its device flow runs `gh` on the - * host, so offering it before `gh` exists sends the user into a failure that - * says nothing about the real problem. - */ +/** Whether everything this step declares a dependency on has been satisfied. */ export function dependenciesMet(step: HostSetupStep, plan: HostSetupPlan | null): boolean { if (step.dependsOn.length === 0) return true; if (!plan) return false; @@ -98,25 +88,6 @@ export function dependenciesMet(step: HostSetupStep, plan: HostSetupPlan | null) ); } -/** - * Whether to offer the GitHub sign-in for this step. - * - * Only once `gh` itself is installed: the device flow runs `gh` on the host, so - * offering it earlier sends the user into a failure that says nothing about the - * real problem. - */ -export function canSignIn(step: HostSetupStep, plan: HostSetupPlan | null): boolean { - if (step.kind !== 'gh-auth') return false; - // Same in-flight exclusion `canInstall` makes. A check moves the step through - // `checking` on its way back to a verdict, and "not yet satisfied" during - // that window is not the same fact as "signed out" — without this, re-checking - // a working login flashes a Sign in button at someone already signed in. - if (step.state === 'satisfied' || step.state === 'checking' || step.state === 'installing') { - return false; - } - return dependenciesMet(step, plan); -} - /** * Whether a row may offer its fix-it action right now. * @@ -133,15 +104,6 @@ export function canOfferAction(hostBusy: boolean, isInstallingThisRow: boolean): return !hostBusy || isInstallingThisRow; } -/** - * A login that exists but lacks a scope is not signed out, and telling someone - * already signed in to "sign in" reads as wrong advice — even though re-running - * the flow is in fact the fix. - */ -export function signInLabel(step: HostSetupStep): string { - return step.error?.includes('read:packages') ? 'Re-authenticate' : 'Sign in'; -} - /** * The version line under a row's name. * diff --git a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/views/remote-host-view.tsx b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/views/remote-host-view.tsx index b16e1e38e..a0467eb38 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/views/remote-host-view.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/remote-hosts/views/remote-host-view.tsx @@ -30,7 +30,6 @@ import { Spinner } from '@renderer/lib/ui/spinner'; import { StatusBadge } from '@renderer/lib/ui/status-badge'; import { deriveHostStatus } from '@shared/core/remote-hosts/host-status'; import { isHostBlocked } from '@shared/core/remote-hosts/reachability'; -import { GhAuthPanel } from '../gh-auth-panel'; import { hostReachabilityStore } from '../host-reachability-store'; import { hostSetupStore } from '../host-setup-store'; import { HostUnreachablePanel } from '../host-unreachable-panel'; @@ -75,7 +74,6 @@ export const RemoteHostMainPanel = observer(function RemoteHostMainPanel() { const recheckStep = useRecheckSetupStep(sshHost); const updateStep = useUpdateSetupStep(sshHost); - const [authenticatingGh, setAuthenticatingGh] = useState(false); const [sheetTarget, setSheetTarget] = useState(null); const reachability = hostReachabilityStore.get(sshHost); const blocked = isHostBlocked(reachability); @@ -228,38 +226,17 @@ export const RemoteHostMainPanel = observer(function RemoteHostMainPanel() {
installStep.mutate(step.id)} onUpdate={() => updateStep.mutate(step.id)} onRecheck={() => recheckStep.mutate(step.id)} - onAuthenticate={() => setAuthenticatingGh(true)} onOpen={() => setSheetTarget({ kind: 'prerequisite', step })} /> - {/* - Opens against the row it belongs to rather than at the - foot of the page: the terminal is the continuation of - that one row's Sign in, and appending it below - everything else meant scrolling away from the thing you - just clicked to find it. - */} - {authenticatingGh && step.kind === 'gh-auth' && ( -
- { - setAuthenticatingGh(false); - recheck.mutate(); - }} - /> -
- )}
))} @@ -293,7 +270,6 @@ export const RemoteHostMainPanel = observer(function RemoteHostMainPanel() { @@ -306,10 +282,6 @@ export const RemoteHostMainPanel = observer(function RemoteHostMainPanel() { hostBusy={busy} onSkip={(stepId) => skip.mutate(stepId)} skippingStepId={skip.isPending ? (skip.variables ?? null) : null} - onAuthenticate={() => { - setSheetTarget(null); - setAuthenticatingGh(true); - }} /> )} diff --git a/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/LocalGhAuthPanel.tsx b/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/LocalGhAuthPanel.tsx deleted file mode 100644 index c2355f6bc..000000000 --- a/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/LocalGhAuthPanel.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { events, rpc } from '@renderer/lib/ipc'; -import { FrontendPty } from '@renderer/lib/pty/pty'; -import { PtyPane } from '@renderer/lib/pty/pty-pane'; -import { Button } from '@renderer/lib/ui/button'; -import { Spinner } from '@renderer/lib/ui/spinner'; -import { ptyExitChannel } from '@shared/core/pty/ptyEvents'; - -type Props = { - /** Called when the user dismisses the panel (cancel or done). */ - onDone: () => void; -}; - -/** - * Interactive `gh` device-flow login for this machine, rendered as a live - * terminal INLINE on the agents settings page. - * - * The local counterpart of the remote-hosts GhAuthPanel, and inline for the - * same reason: Switch Console's terminal input path is disabled whenever a - * `[role="dialog"]` is present, so a terminal in a modal can never receive - * keystrokes. - */ -export function LocalGhAuthPanel({ onDone }: Props) { - const [pty, setPty] = useState(null); - const [sessionId, setSessionId] = useState(null); - const [error, setError] = useState(null); - const [exitCode, setExitCode] = useState(undefined); - - const ptyRef = useRef(null); - const sessionIdRef = useRef(null); - const paneRef = useRef<{ focus: () => void } | null>(null); - - useEffect(() => { - let cancelled = false; - let offExit: (() => void) | undefined; - - void (async () => { - try { - const { sessionId: sid } = await rpc.switchSetup.startLocalGhAuth(); - if (cancelled) { - void rpc.pty.kill(sid); - return; - } - sessionIdRef.current = sid; - setSessionId(sid); - - const frontend = new FrontendPty(sid); - await frontend.connect(); - if (cancelled) { - frontend.dispose(); - void rpc.pty.kill(sid); - return; - } - ptyRef.current = frontend; - setPty(frontend); - - offExit = events.on( - ptyExitChannel, - (info: { exitCode: number; signal?: number }) => { - setExitCode(info.exitCode ?? null); - }, - sid - ); - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); - } - })(); - - return () => { - cancelled = true; - offExit?.(); - const sid = sessionIdRef.current; - if (sid) void rpc.pty.kill(sid); - ptyRef.current?.dispose(); - }; - }, []); - - useEffect(() => { - if (!pty) return; - const id = requestAnimationFrame(() => paneRef.current?.focus()); - return () => cancelAnimationFrame(id); - }, [pty]); - - const exited = exitCode !== undefined; - const succeeded = exitCode === 0; - - return ( -
-
-
Authenticate GitHub CLI on this machine
- -
- -

- gh will print a one-time code and a verification URL below. Click the URL (or open{' '} - github.com/login/device) in your browser, enter the code, - and authorize. -

- - {error ? ( -

{error}

- ) : !pty || !sessionId ? ( -
- Starting gh auth… -
- ) : ( -
- -
- )} - - {exited && - (succeeded ? ( -

Authenticated successfully.

- ) : ( -

- gh auth exited{exitCode == null ? '' : ` with code ${exitCode}`} without completing. You - can close and try again. -

- ))} -
- ); -} diff --git a/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/LocalGhAuthRow.tsx b/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/LocalGhAuthRow.tsx deleted file mode 100644 index 08254b6bc..000000000 --- a/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/LocalGhAuthRow.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { AlertTriangle, Check, Loader2 } from 'lucide-react'; -import { useState } from 'react'; -import { rpc } from '@renderer/lib/ipc'; -import { Button } from '@renderer/lib/ui/button'; -import { LocalGhAuthPanel } from './LocalGhAuthPanel'; - -export const localGhAuthQueryKey = ['switchSetup', 'localGhAuth'] as const; - -export function useLocalGhAuth() { - return useQuery({ - queryKey: localGhAuthQueryKey, - queryFn: () => rpc.switchSetup.getLocalGhAuth(), - staleTime: 30_000, - }); -} - -/** - * GitHub access for the Switch plugin, on this machine. - * - * Remote hosts have had this row since the scope became a requirement; locally - * there was nothing, so the same misconfiguration produced a plugin that would - * not install and sessions that came up silently without their Switch tools. - */ -export function LocalGhAuthRow() { - const { data, isLoading, refetch } = useLocalGhAuth(); - const [authenticating, setAuthenticating] = useState(false); - const queryClient = useQueryClient(); - - if (isLoading || !data) return null; - - const ready = data.ghInstalled && data.authenticated && data.canReadPackages; - - const problem = !data.ghInstalled - ? 'GitHub CLI not installed' - : !data.authenticated - ? 'Not authenticated' - : !data.canReadPackages - ? 'Missing read:packages — re-run Authenticate' - : null; - - return ( -
-
-
- GitHub - {ready ? ( - - - {data.account ? `Authenticated as ${data.account}` : 'Authenticated'} - - ) : ( - - - {problem} - - )} -
- {/* Also offered when the login is fine but lacks the scope — the fix is - the same flow, and `gh auth refresh` adds it without a fresh login. */} - {!ready && data.ghInstalled && !authenticating && ( - - )} - {isLoading && } -
- - {!data.ghInstalled && ( -

- The Switch plugin and its MCP runtime are published privately on GitHub. Install the - GitHub CLI from cli.github.com, then authenticate here. -

- )} - - {/* Re-authenticating cannot fix this one, so it says something different. */} - {data.envShadowed && ( -

- A GH_TOKEN /{' '} - GITHUB_TOKEN environment variable is overriding your gh - login, and it is what sessions will use. Authenticating again will not change that — unset - it in your shell, or give that token the read:packages{' '} - scope. -

- )} - - {authenticating && ( - { - setAuthenticating(false); - void refetch(); - // The plugin may now install where it could not before. - void queryClient.invalidateQueries({ queryKey: ['switchSetup'] }); - }} - /> - )} -
- ); -} diff --git a/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx b/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx index 8604a6b97..46854b215 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx @@ -11,7 +11,6 @@ import { UpdateAvailableBadge, UpdatingBadge, } from './agent-status-badge'; -import { LocalGhAuthRow, useLocalGhAuth } from './LocalGhAuthRow'; /** * Surfaces the Switch connector plugin status for an agent type and exposes @@ -32,17 +31,12 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { isUninstalling, } = useSwitchSetup(agentId); - const { data: ghAuth } = useLocalGhAuth(); - // Hide until we know the agent supports Switch setup. if (isLoading || !status?.supported) return null; const busy = isInstalling || isUpdating || isUninstalling || isChecking; // Distinct from "no update available": there is nothing to compare against. const currencyUnknown = updateCheckUnavailable(status); - // Until the probe answers, do not disable Install — a slow check should not - // look like a blocked one. - const ghReady = !ghAuth || (ghAuth.ghInstalled && ghAuth.authenticated && ghAuth.canReadPackages); const badge = isInstalling ? ( @@ -67,7 +61,6 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) {
-
switch-connector @@ -76,7 +69,7 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) {
{!status.installed ? ( - ) : ( @@ -124,12 +117,6 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { whether an update exists. Reinstall to be sure you are on the latest.

)} - {!status.installed && !ghReady && ( -

- The plugin is published to a private GitHub repository. Authenticate above to install - it. -

- )}

Connects this agent to a Switch instance. Credentials are managed when you add the agent to a Switch server. diff --git a/console/apps/switch-console-desktop/src/renderer/features/switch-rooms/switch-tools-unavailable-listener.tsx b/console/apps/switch-console-desktop/src/renderer/features/switch-rooms/switch-tools-unavailable-listener.tsx deleted file mode 100644 index f863d0742..000000000 --- a/console/apps/switch-console-desktop/src/renderer/features/switch-rooms/switch-tools-unavailable-listener.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect } from 'react'; -import { useToast } from '@renderer/lib/hooks/use-toast'; -import { events } from '@renderer/lib/ipc'; -import { switchToolsUnavailableEvent } from '@shared/events/switchSetupEvents'; - -const TITLE = 'Session started without Switch tools'; - -/** - * Report a session that came up without its Switch MCP tools. - * - * The session still starts — that is deliberate — but without this the only - * evidence was a log line, so it looked like a healthy session that simply had - * no Switch tools, and the cause was several layers below anything the user - * could see. - */ -export function SwitchToolsUnavailableListener() { - const { toast } = useToast(); - - useEffect( - () => - events.on(switchToolsUnavailableEvent, ({ reason, detail }) => { - const fix = - reason === 'env-shadowed' - ? 'Unset it in your shell, or give that token the read:packages scope.' - : 'Fix this under Settings → Agents → Switch setup.'; - toast({ title: TITLE, description: `${detail} ${fix}`, variant: 'destructive' }); - }), - [toast] - ); - - return null; -} diff --git a/console/apps/switch-console-desktop/src/renderer/lib/pty/dialog-backgrounding.ts b/console/apps/switch-console-desktop/src/renderer/lib/pty/dialog-backgrounding.ts index 818f4c0de..9231883d0 100644 --- a/console/apps/switch-console-desktop/src/renderer/lib/pty/dialog-backgrounding.ts +++ b/console/apps/switch-console-desktop/src/renderer/lib/pty/dialog-backgrounding.ts @@ -4,7 +4,7 @@ * False when the terminal is inside the dialog. The guard exists so a terminal * behind a modal does not eat keystrokes aimed at it; a terminal the modal * itself renders is the opposite case, and treating the two alike left an - * interactive prompt (`gh auth`, waiting on Enter) with no way to answer. + * interactive prompt waiting on Enter with no way to answer. * * With no element to place there is nothing to compare against, so it stays * blocked — the previous behaviour, rather than letting keys through on a diff --git a/console/apps/switch-console-desktop/src/renderer/lib/stores/update-store.ts b/console/apps/switch-console-desktop/src/renderer/lib/stores/update-store.ts index 08e429490..e2a5134c5 100644 --- a/console/apps/switch-console-desktop/src/renderer/lib/stores/update-store.ts +++ b/console/apps/switch-console-desktop/src/renderer/lib/stores/update-store.ts @@ -6,7 +6,6 @@ import { openExternalUrl } from '@renderer/lib/open-external'; import { appState } from '@renderer/lib/stores/app-state'; import { menuCheckForUpdatesChannel } from '@shared/events/appEvents'; import { - updateAuthRequiredEvent, updateAvailableEvent, updateCheckingEvent, updateDownloadedEvent, @@ -42,8 +41,7 @@ export type UpdateState = | { status: 'downloading'; progress?: DownloadProgress } | { status: 'downloaded' } | { status: 'installing' } - | { status: 'error'; message: string } - | { status: 'auth-required' }; + | { status: 'error'; message: string }; /** Statuses where main is mid-flight and a fresh check would interrupt it. */ const IN_FLIGHT_STATUSES: ReadonlySet = new Set([ @@ -58,15 +56,7 @@ const IN_FLIGHT_STATUSES: ReadonlySet = new Set([ * src/main/core/updates/update-service.ts. */ type MainUpdateState = { - status: - | 'idle' - | 'checking' - | 'available' - | 'downloading' - | 'downloaded' - | 'installing' - | 'error' - | 'auth-required'; + status: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'error'; currentVersion: string; availableVersion?: string; updateInfo?: { version: string; releaseDate?: string; releaseName?: string | null }; @@ -97,8 +87,6 @@ export function mainStateToRendererState(main: MainUpdateState): UpdateState { return { status: 'installing' }; case 'error': return { status: 'error', message: main.error || 'The update could not be completed.' }; - case 'auth-required': - return { status: 'auth-required' }; default: return { status: 'idle' }; } @@ -222,12 +210,6 @@ export class UpdateStore { }); }); - events.on(updateAuthRequiredEvent, () => { - runInAction(() => { - this.state = { status: 'auth-required' }; - }); - }); - events.on(menuCheckForUpdatesChannel, () => { void this.check(); }); @@ -278,11 +260,7 @@ export class UpdateStore { }); } else if (res.result === null) { runInAction(() => { - // The main process emits update:auth-required when no gh token is - // available; don't clobber that with idle on the null check result. - if (this.state.status !== 'auth-required') { - this.state = { status: 'idle' }; - } + this.state = { status: 'idle' }; }); } } catch { diff --git a/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.test.ts b/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.test.ts index e78b5c37d..4a94ed211 100644 --- a/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.test.ts +++ b/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.test.ts @@ -124,14 +124,6 @@ describe('presentUpdate', () => { expect(failed.indicatorLabel).toBe('Update failed'); expect(failed.actionable).toBe(true); }); - - it('treats missing credentials as a fixable prompt, not a failure', () => { - const auth = present({ status: 'auth-required' }); - - expect(auth.title).toMatch(/sign in/i); - expect(auth.detail).toMatch(/gh auth login/); - expect(auth.actionKind).toBe('check'); - }); }); it('never renders an empty indicator label once a version is known', () => { @@ -144,7 +136,6 @@ describe('presentUpdate', () => { { status: 'downloaded' }, { status: 'installing' }, { status: 'error', message: 'x' }, - { status: 'auth-required' }, ]; for (const state of states) { diff --git a/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.ts b/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.ts index a2e1c6726..14bea9b75 100644 --- a/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.ts +++ b/console/apps/switch-console-desktop/src/renderer/lib/updates/update-presentation.ts @@ -169,20 +169,6 @@ export function presentUpdate(state: UpdateState, currentVersion: string): Updat progressPercent: null, }; - case 'auth-required': - return { - tone: 'warning', - indicatorLabel: 'Sign in for updates', - indicatorIcon: 'alert', - actionable: true, - title: 'Sign in to GitHub for updates', - detail: 'Run `gh auth login` in a terminal, then check again.', - actionKind: 'check', - actionLabel: 'Check again', - busy: false, - progressPercent: null, - }; - default: return { tone: 'neutral', diff --git a/console/apps/switch-console-desktop/src/renderer/tests/browser/remote-host-prerequisite-row.test.tsx b/console/apps/switch-console-desktop/src/renderer/tests/browser/remote-host-prerequisite-row.test.tsx index 8482da12b..e5e538b75 100644 --- a/console/apps/switch-console-desktop/src/renderer/tests/browser/remote-host-prerequisite-row.test.tsx +++ b/console/apps/switch-console-desktop/src/renderer/tests/browser/remote-host-prerequisite-row.test.tsx @@ -22,7 +22,7 @@ vi.hoisted(() => { * through. Every other row had its Install button in plain sight. */ import { PrerequisiteRow } from '@renderer/features/remote-hosts/setup/setup-rows'; -import type { HostSetupPlan, HostSetupStep } from '@shared/core/remote-hosts/setup'; +import type { HostSetupStep } from '@shared/core/remote-hosts/setup'; let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -36,9 +36,9 @@ afterEach(async () => { function step(patch: Partial): HostSetupStep { return { - id: 'gh:auth', - kind: 'gh-auth', - name: 'GitHub CLI login', + id: 'tmux', + kind: 'core-dependency', + name: 'tmux', state: 'pending', outcome: 'missing', version: null, @@ -47,30 +47,12 @@ function step(patch: Partial): HostSetupStep { error: null, output: null, optional: false, - dependsOn: ['gh'], + dependsOn: [], updatedAt: '2026-02-02T00:00:00.000Z', ...patch, }; } -function planWith(auth: HostSetupStep, ghState: HostSetupStep['state']): HostSetupPlan { - return { - sshHost: 'dev-vm', - status: 'idle', - steps: [ - { - ...step({ id: 'gh', kind: 'core-dependency', name: 'GitHub CLI', dependsOn: [] }), - state: ghState, - outcome: ghState === 'satisfied' ? 'satisfied' : 'missing', - }, - auth, - ], - currentStepId: null, - createdAt: '2026-02-02T00:00:00.000Z', - updatedAt: '2026-02-02T00:00:00.000Z', - }; -} - async function render(node: React.ReactNode): Promise { container = document.createElement('div'); document.body.appendChild(container); @@ -80,7 +62,7 @@ async function render(node: React.ReactNode): Promise { } function row( - auth: HostSetupStep, + target: HostSetupStep, ghState: HostSetupStep['state'] = 'satisfied', overrides: { hostBusy?: boolean; @@ -92,19 +74,16 @@ function row( ) { return ( {}} onUpdate={overrides.onUpdate ?? (() => {})} onRecheck={overrides.onRecheck ?? (() => {})} - onAuthenticate={() => {}} onOpen={() => {}} /> ); @@ -118,46 +97,6 @@ function recheckButton(el: HTMLElement): HTMLButtonElement | null { return el.querySelector('button[aria-label^="Re-check"]'); } -describe('the GitHub login row', () => { - it('offers Sign in on the row itself, not only inside the sheet', async () => { - const el = await render(row(step({}))); - - expect(buttonLabels(el)).toContain('Sign in'); - }); - - it('says Re-authenticate when the login exists but lacks read:packages', async () => { - const el = await render(row(step({ error: 'gh is missing the read:packages scope' }))); - - expect(buttonLabels(el)).toContain('Re-authenticate'); - }); - - it('offers nothing while gh itself is still missing', async () => { - // The device flow runs `gh` on the host. A Sign in button here would only - // produce a failure that says nothing about the real problem. - const el = await render(row(step({}), 'pending')); - - expect(buttonLabels(el)).not.toContain('Sign in'); - }); - - it('offers no action once the login is good', async () => { - // The row itself is a button (it opens the detail sheet), so this asks that - // no *action* is offered rather than that no button exists. - const el = await render( - row(step({ state: 'satisfied', outcome: 'satisfied', version: 'amaudruz' })) - ); - - expect(buttonLabels(el)).not.toContain('Sign in'); - expect(buttonLabels(el)).not.toContain('Re-authenticate'); - expect(buttonLabels(el)).not.toContain('Install'); - }); - - it('never offers Install for a login — it is a device flow, not a package', async () => { - const el = await render(row(step({}))); - - expect(buttonLabels(el)).not.toContain('Install'); - }); -}); - /** * Re-checking one row exists because the whole-host re-check costs an SSH round * trip per step, which is a lot to pay to answer "is this one still there?". @@ -217,34 +156,22 @@ describe('the per-row re-check', () => { /** * While a check runs, a row offers nothing but the check (CHOO-1809). * - * Two separate defects produced the same complaint. A check moves a step - * through `checking` on its way back to a verdict, and the sign-in predicate - * read "not satisfied" as "signed out" — so re-checking a working GitHub login - * flashed a Sign in button at someone already signed in. Meanwhile Install - * stayed live on every other row, even though the runner takes one operation - * per host and would have refused it. + * Install stayed live on every other row even though the runner takes one + * operation per host and would have refused it — an offer that cannot be + * honoured reads as this dependency's fault rather than as the button never + * having been live. */ describe('actions while the host is working', () => { - it('offers no Sign in while this row is mid-check', async () => { + it('offers no Install while this row is mid-check', async () => { const el = await render(row(step({ state: 'checking' }))); - expect(buttonLabels(el)).not.toContain('Sign in'); - }); - - it('offers no Sign in on a satisfied login being re-checked', async () => { - // The case Louis hit: the button appeared on a login that was fine. - const el = await render( - row(step({ state: 'checking', outcome: 'satisfied', version: 'amaudruz' })) - ); - - expect(buttonLabels(el)).not.toContain('Sign in'); - expect(buttonLabels(el)).not.toContain('Re-authenticate'); + expect(buttonLabels(el)).not.toContain('Install'); }); - it('withdraws Sign in from an idle row while the host is busy elsewhere', async () => { + it('withdraws Install from an idle row while the host is busy elsewhere', async () => { const el = await render(row(step({}), 'satisfied', { hostBusy: true })); - expect(buttonLabels(el)).not.toContain('Sign in'); + expect(buttonLabels(el)).not.toContain('Install'); }); it('still shows the check control, so something says work is happening', async () => { diff --git a/console/apps/switch-console-desktop/src/shared/app-identity.canary.ts b/console/apps/switch-console-desktop/src/shared/app-identity.canary.ts index 185ffce0f..8d381f6b6 100644 --- a/console/apps/switch-console-desktop/src/shared/app-identity.canary.ts +++ b/console/apps/switch-console-desktop/src/shared/app-identity.canary.ts @@ -9,7 +9,7 @@ export const APP_NAME_LOWER = 'switch-console-canary'; export const UPDATE_CHANNEL = 'v1-canary'; export const ARTIFACT_PREFIX = 'switch-console-canary'; -// Keep in sync with RELEASE_REPO_* in ./app-identity.ts (CHOO-1260 flip point). +// Keep in sync with RELEASE_REPO_* in ./app-identity.ts. export const RELEASE_REPO_OWNER = 'sandbox-quantum'; export const RELEASE_REPO_NAME = 'switch'; diff --git a/console/apps/switch-console-desktop/src/shared/app-identity.ts b/console/apps/switch-console-desktop/src/shared/app-identity.ts index 3d24c05aa..8f1cdaf62 100644 --- a/console/apps/switch-console-desktop/src/shared/app-identity.ts +++ b/console/apps/switch-console-desktop/src/shared/app-identity.ts @@ -38,7 +38,7 @@ export const ARTIFACT_PREFIX = isCanary ? 'switch-console-canary' : 'switch-cons export const IS_CANARY = isCanary; // GitHub repo the desktop app publishes releases to / reads auto-updates from. -// CHOO-1260 config-flip point — see RELEASING.md. Mirrored in +// The repo is public, so the feed is read unauthenticated. Mirrored in // app-identity.canary.ts (keep in sync). export const RELEASE_REPO_OWNER = 'sandbox-quantum'; export const RELEASE_REPO_NAME = 'switch'; diff --git a/console/apps/switch-console-desktop/src/shared/core/npm-registry.ts b/console/apps/switch-console-desktop/src/shared/core/npm-registry.ts deleted file mode 100644 index 3132ab659..000000000 --- a/console/apps/switch-console-desktop/src/shared/core/npm-registry.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Registry access for the Switch agent runtime, shared by every spawn path. - * - * The Claude Code plugin fetches its MCP server with - * `npx @sandbox-quantum/switch-agent-runtime`. That package lives on GitHub - * Packages and is private, so npm needs two things it does not know by - * default: which registry serves the scope, and a token for it. Absent both, - * npm asks npmjs.com, which has never heard of the package — so the failure - * reads as a plain 404 for something that does not exist, rather than anything - * about registries or credentials. - * - * Three places have to arrange this — the desktop for local sessions, the - * sidecar for sessions it starts on a VM, and the SSH runtime for remote - * sessions started from the desktop. They differ only in how they run `gh` and - * write a file. Everything they must agree on lives here, because when it was - * copied per path instead, one copy was simply missing and the symptom was a - * 404 that named neither the cause nor the path that lacked it. - */ - -export const NPM_REGISTRY_HOST = 'npm.pkg.github.com'; -export const NPM_SCOPE = '@sandbox-quantum'; - -/** - * The token is referenced, never written. - * - * npm expands `${VAR}` in an `.npmrc` as it reads it, so the file holds a - * pointer to an environment variable rather than a credential. The value is - * placed in the session's environment at spawn and lives only in that process. - */ -export const NPM_TOKEN_VAR = 'SWITCHDASH_GITHUB_TOKEN'; - -export const NPMRC_CONTENTS = [ - `${NPM_SCOPE}:registry=https://${NPM_REGISTRY_HOST}`, - `//${NPM_REGISTRY_HOST}/:_authToken=\${${NPM_TOKEN_VAR}}`, - '', -].join('\n'); - -/** How to fix a token that authenticates but cannot read packages. */ -export const READ_PACKAGES_FIX = 'gh auth refresh -h github.com -s read:packages'; - -export const GH_HOST = 'github.com'; - -/** The scope the registry requires, which `gh auth login` does not request. */ -export const READ_PACKAGES_SCOPE = 'read:packages'; - -/** - * Ask `gh` which identity it will actually use, in machine-readable form. - * - * `--active` narrows the answer to the account that will be used, and `--json` - * makes the scopes a field rather than a line of prose. Both matter: the human - * output lists every known account, so a scope search over it answers a - * question nobody asked — whether *some* account has the scope — and a second - * account that does have it will vouch for one that does not. - */ -export const GH_AUTH_STATUS_ARGS = ['auth', 'status', '--active', '--json', 'hosts']; - -/** What `gh` will do with the credentials it currently has. */ -export type GhAuthState = - | { status: 'ok'; login: string; scopes: string[]; tokenSource: string } - | { status: 'missing-scope'; login: string; scopes: string[]; tokenSource: string } - | { status: 'invalid'; tokenSource: string; detail: string } - | { status: 'unknown' }; - -type GhAuthHostEntry = { - state?: string; - active?: boolean; - login?: string; - tokenSource?: string; - scopes?: string; - error?: string; -}; - -/** - * Interpret `gh auth status --active --json hosts`. - * - * `tokenSource` is carried through because it names the origin of the - * credential — a path for the keyring, or the literal `GH_TOKEN` when an - * environment variable is shadowing it. When a login appears not to have taken - * effect, that distinction is the answer, and it is not recoverable later. - * - * Unrecognised output yields `unknown` rather than a guess. This gates setup, - * and being confidently wrong about someone's working install is worse than - * admitting the check did not apply. - */ -export function parseGhAuthStatus(stdout: string): GhAuthState { - let entries: GhAuthHostEntry[]; - try { - const parsed = JSON.parse(stdout) as { hosts?: Record }; - entries = parsed.hosts?.[GH_HOST] ?? []; - } catch { - return { status: 'unknown' }; - } - - const active = entries.find((entry) => entry.active) ?? entries[0]; - if (!active) return { status: 'unknown' }; - - const tokenSource = active.tokenSource ?? 'unknown'; - if (active.state !== 'success') { - return { - status: 'invalid', - tokenSource, - detail: active.error ?? 'gh reported the active token is not usable', - }; - } - - if (active.scopes === undefined) return { status: 'unknown' }; - const scopes = active.scopes - .split(',') - .map((scope) => scope.trim().replace(/^'|'$/g, '')) - .filter(Boolean); - - return { - status: scopes.includes(READ_PACKAGES_SCOPE) ? 'ok' : 'missing-scope', - login: active.login ?? '', - scopes, - tokenSource, - }; -} - -/** Whether a token is shadowing the keyring, which survives re-authentication. */ -export function isEnvShadowedToken(state: GhAuthState): boolean { - if (state.status === 'unknown') return false; - return state.tokenSource === 'GH_TOKEN' || state.tokenSource === 'GITHUB_TOKEN'; -} - -/** The environment that points npm at a written npmrc. */ -export function npmRegistryEnv(npmrcPath: string, token: string): Record { - return { npm_config_userconfig: npmrcPath, [NPM_TOKEN_VAR]: token }; -} diff --git a/console/apps/switch-console-desktop/src/shared/core/remote-hosts/setup.ts b/console/apps/switch-console-desktop/src/shared/core/remote-hosts/setup.ts index e6556ec0f..0fd260bf7 100644 --- a/console/apps/switch-console-desktop/src/shared/core/remote-hosts/setup.ts +++ b/console/apps/switch-console-desktop/src/shared/core/remote-hosts/setup.ts @@ -79,7 +79,7 @@ export type HostSetupStepState = | 'skipped'; /** What kind of thing a step manages, for rendering and for install routing. */ -export type HostSetupStepKind = 'core-dependency' | 'agent-cli' | 'agent-plugin' | 'gh-auth'; +export type HostSetupStepKind = 'core-dependency' | 'agent-cli' | 'agent-plugin'; export type HostSetupStep = { /** Stable within a plan. Dependency id for deps; `:plugin` for plugins. */ @@ -112,11 +112,7 @@ export type HostSetupStep = { error: string | null; /** Raw command output from a failed install — the detail users need. */ output: string | null; - /** - * An optional step does not block the run or the host's usability. `gh` is - * the motivating case: it needs an interactive device-flow login that a user - * may reasonably defer without the host being unusable. - */ + /** An optional step does not block the run or the host's usability. */ optional: boolean; /** Steps that must be satisfied before this one is attempted. */ dependsOn: string[]; @@ -189,7 +185,7 @@ export function agentPluginStepId(agentId: string): string { * perfectly well installed. */ export function isHostLevelStep(step: HostSetupStep): boolean { - return step.kind === 'core-dependency' || step.kind === 'gh-auth'; + return step.kind === 'core-dependency'; } /** The agent type a step belongs to, or null when the step is host-level. */ diff --git a/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.test.ts b/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.test.ts index 503e9f92a..d1f205c43 100644 --- a/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.test.ts +++ b/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.test.ts @@ -2,7 +2,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { NPM_TOKEN_VAR } from '@shared/core/npm-registry'; import { SWITCH_AGENT_RUNTIME_PACKAGE, SWITCH_AGENT_RUNTIME_VERSION, @@ -61,14 +60,6 @@ function runtimePackageVersion(): string { } describe('the pinned runtime version', () => { - it('forwards what npx itself needs to reach the private registry', () => { - // Without these the child cannot fetch the package at all on a cold cache, - // and the failure reads as a 404 for something that does not exist. - expect(SWITCH_RUNTIME_ENV_VARS).toEqual( - expect.arrayContaining(['npm_config_userconfig', NPM_TOKEN_VAR]) - ); - }); - it.each([ ['Claude', CLAUDE_MCP_JSON], ['Codex', CODEX_MCP_JSON], diff --git a/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.ts b/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.ts index c7f9cf6ae..2ff27015a 100644 --- a/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.ts +++ b/console/apps/switch-console-desktop/src/shared/core/switch-rooms/switch-agent-runtime.ts @@ -10,8 +10,6 @@ * Electron and the database) spawns from it. */ -import { NPM_TOKEN_VAR } from '@shared/core/npm-registry'; - /** npm package name of the local Switch MCP runtime. */ export const SWITCH_AGENT_RUNTIME_PACKAGE = '@sandboxaq/switch-agent-runtime'; @@ -44,8 +42,7 @@ export const SWITCH_RUNTIME_REQUIRED_ENV = [ /** * Values the runtime uses when present and does without when absent — the - * connection id and poll suppression it reads directly, plus the npm settings - * `npx` needs to resolve the package from the private registry. + * connection id and poll suppression it reads directly. * * Kept apart from the required tier because the two hosts cannot treat them * alike. Codex forwards names, so an unset one is simply not passed, and the @@ -58,8 +55,6 @@ export const SWITCH_RUNTIME_REQUIRED_ENV = [ export const SWITCH_RUNTIME_OPTIONAL_ENV = [ 'SWITCH_CONNECTION_ID', 'SWITCH_CHANNEL_DISABLE_POLL', - 'npm_config_userconfig', - NPM_TOKEN_VAR, ] as const; /** Every variable a host must route to the runtime, required tier first. */ diff --git a/console/apps/switch-console-desktop/src/shared/events/switchSetupEvents.ts b/console/apps/switch-console-desktop/src/shared/events/switchSetupEvents.ts deleted file mode 100644 index 491a16b4d..000000000 --- a/console/apps/switch-console-desktop/src/shared/events/switchSetupEvents.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineEvent } from '@shared/lib/ipc/events'; - -/** - * A session started without the Switch MCP tools it was supposed to have. - * - * Sessions deliberately start anyway when the runtime cannot be fetched — one - * with no MCP server beats none at all — but until this event the only trace - * was a line in a log file, so the session looked healthy and simply had no - * Switch tools. The reason distinguishes the fixes: `not-authenticated` and - * `missing-scope` are resolved by the setup flow, while `env-shadowed` is not - * resolved by authenticating at all. - */ -export const switchToolsUnavailableEvent = defineEvent<{ - reason: 'not-authenticated' | 'missing-scope' | 'invalid-token' | 'env-shadowed'; - detail: string; -}>('switch-setup:tools-unavailable'); diff --git a/console/apps/switch-console-desktop/src/shared/events/updateEvents.ts b/console/apps/switch-console-desktop/src/shared/events/updateEvents.ts index b64e75497..fd29f53be 100644 --- a/console/apps/switch-console-desktop/src/shared/events/updateEvents.ts +++ b/console/apps/switch-console-desktop/src/shared/events/updateEvents.ts @@ -24,5 +24,3 @@ export const updateDownloadedEvent = defineEvent<{ version: string }>('update:do export const updateInstallingEvent = defineEvent('update:installing'); export const updateErrorEvent = defineEvent<{ message: string }>('update:error'); - -export const updateAuthRequiredEvent = defineEvent('update:auth-required'); diff --git a/console/apps/switch-console-desktop/src/sidecar/index.ts b/console/apps/switch-console-desktop/src/sidecar/index.ts index a862d1906..d529535d2 100644 --- a/console/apps/switch-console-desktop/src/sidecar/index.ts +++ b/console/apps/switch-console-desktop/src/sidecar/index.ts @@ -14,7 +14,6 @@ import { createTmuxRun } from '@main/core/switch-rooms/tmux-injection-sink'; import { type AgentLaunchSpec } from './agent-launch-spec'; import { atomicWriteFile } from './atomic-file'; import { NotificationWatcher, type WatcherLogger } from './notification-watcher'; -import { npmRegistryAuthEnv } from './npm-registry-auth'; import { InProcessSessionSpawner } from './session-spawner'; import { createSidecarLogger, requireEnv } from './sidecar-logger'; import { @@ -287,10 +286,6 @@ async function main(): Promise { SWITCH_API_ENDPOINT: creds.apiEndpoint, SWITCH_API_TOKEN: creds.token, SWITCH_AGENT_ID: creds.agentId, - // Without this a spawned session's `npx` asks npmjs.com for a package - // that only exists on GitHub Packages, and reports a 404 that says - // nothing about registries or credentials. - ...(await npmRegistryAuthEnv(repoDir, log)), }, isPaneLive, log, diff --git a/console/apps/switch-console-desktop/src/sidecar/npm-registry-auth.ts b/console/apps/switch-console-desktop/src/sidecar/npm-registry-auth.ts deleted file mode 100644 index 79cb521f2..000000000 --- a/console/apps/switch-console-desktop/src/sidecar/npm-registry-auth.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { - GH_AUTH_STATUS_ARGS, - isEnvShadowedToken, - NPMRC_CONTENTS, - npmRegistryEnv, - parseGhAuthStatus, - READ_PACKAGES_FIX, -} from '@shared/core/npm-registry'; -import type { WatcherLogger } from './notification-watcher'; - -const execFileAsync = promisify(execFile); - -/** - * Registry access for sessions this sidecar starts on the VM. - * - * The Claude Code plugin fetches its MCP server with - * `npx @sandbox-quantum/switch-agent-runtime`. That package is on GitHub - * Packages and private, so npm needs to be told which registry serves the - * scope and how to authenticate. Told neither, it asks npmjs.com, which has - * never heard of it — the failure reads as a plain 404 for a package that does - * not exist, rather than anything about registries or credentials. - * - * This is the VM-side counterpart of Switch Console's `npmRegistryAuthEnv`. Same - * two settings, same env-var indirection so no token is written to disk; the - * only difference is that the token comes from the VM's own `gh`, which is a - * core host dependency, rather than the desktop's. - */ - -async function ghToken(log: WatcherLogger): Promise { - try { - const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10_000 }); - const token = stdout.trim(); - return token.length > 0 ? token : null; - } catch (error) { - log.warn('npmRegistryAuth: `gh auth token` failed on this host', { - event: 'npm_registry_auth_no_gh', - error: String(error), - }); - return null; - } -} - -/** - * Warn when the credentials `gh` will use cannot fetch the runtime. - * - * `gh auth login` asks for `gist`, `read:org`, `repo` and `workflow` — not - * `read:packages`. So the default, perfectly healthy login produces a token - * that authenticates fine and is then refused by the registry with a 403 about - * "expected scopes", several layers below anything that mentions `gh`. - * - * Checked here so the cause is stated at spawn, where it is actionable, rather - * than inferred later from an npx failure. Only a warning: an unrecognised - * answer must not stop a session starting. - */ -async function warnAboutGhAuth(log: WatcherLogger): Promise { - try { - const { stdout } = await execFileAsync('gh', GH_AUTH_STATUS_ARGS, { timeout: 10_000 }); - const state = parseGhAuthStatus(stdout); - if (isEnvShadowedToken(state)) { - log.warn('npmRegistryAuth: an environment token is shadowing the gh login', { - event: 'npm_registry_auth_env_shadowed', - tokenSource: state.status === 'unknown' ? 'unknown' : state.tokenSource, - detail: - 'gh prefers GH_TOKEN/GITHUB_TOKEN over the keyring, so authenticating on ' + - 'this host will not change which token is used until that variable is unset', - }); - } - if (state.status === 'missing-scope') { - log.warn('npmRegistryAuth: the GitHub token cannot read packages', { - event: 'npm_registry_auth_missing_scope', - account: state.login, - scopes: state.scopes.join(', '), - fix: READ_PACKAGES_FIX, - detail: - 'gh auth login does not request read:packages, so the registry will refuse ' + - 'with 403 and the session will start without its MCP tools', - }); - } else if (state.status === 'invalid') { - log.warn('npmRegistryAuth: the active GitHub token is not usable', { - event: 'npm_registry_auth_invalid_token', - tokenSource: state.tokenSource, - detail: state.detail, - }); - } - } catch { - // Never fatal — this is a diagnostic, not a gate. - } -} - -/** - * Write the npmrc and return the environment that points npm at it. - * - * Returns an empty environment when the host has no usable `gh`. The session - * still starts: it will fail to fetch the runtime and come up without tools, - * which is bad, but strictly better than not starting at all — and the warning - * here names the cause, which a bare npm 404 would not. - */ -export async function npmRegistryAuthEnv( - repoDir: string, - log: WatcherLogger -): Promise> { - const token = await ghToken(log); - if (!token) { - log.warn('npmRegistryAuth: no GitHub token — the agent runtime will not resolve', { - event: 'npm_registry_auth_missing_token', - hint: 'run `gh auth login` on this host; the package is private and reads as 404 without it', - }); - return {}; - } - - await warnAboutGhAuth(log); - - const dir = path.join(repoDir, '.switchdash'); - const npmrc = path.join(dir, 'npmrc'); - try { - await mkdir(dir, { recursive: true }); - await writeFile(npmrc, NPMRC_CONTENTS, { mode: 0o600 }); - } catch (error) { - log.warn('npmRegistryAuth: could not write npmrc', { - event: 'npm_registry_auth_write_failed', - path: npmrc, - error: String(error), - }); - return {}; - } - - log.info('npmRegistryAuth: registry access configured for spawned sessions', { - event: 'npm_registry_auth_ready', - npmrc, - }); - return npmRegistryEnv(npmrc, token); -} diff --git a/console/docs/INSTALL.md b/console/docs/INSTALL.md index 8b3247dd5..a376b9498 100644 --- a/console/docs/INSTALL.md +++ b/console/docs/INSTALL.md @@ -1,9 +1,8 @@ # Installing Switch Console Switch Console is distributed as a desktop app through **GitHub Releases on this -repository** (`sandbox-quantum/switch`). The repo is private, so the release -downloads are automatically limited to people with repo-read access — there is -no separate sign-up or allowlist. No need to build from source. +repository** (`sandbox-quantum/switch`). The repo is public, so the downloads +need no account, token or sign-up. No need to build from source. > Builds are currently **macOS arm64** (Apple Silicon) and **Linux x64**. > Windows is not built yet. @@ -12,58 +11,41 @@ no separate sign-up or allowlist. No need to build from source. ### Option A — browser (simplest) -1. Make sure you're signed in to GitHub with read access to - `sandbox-quantum/switch`. -2. Open the repo's **[Releases](https://github.com/sandbox-quantum/switch/releases)** +1. Open the repo's **[Releases](https://github.com/sandbox-quantum/switch/releases)** page. -3. Find the latest release titled **`Switch Console `** (tag +2. Find the latest release titled **`Switch Console `** (tag `switch-console-v`). -4. Under **Assets**, download the file for your platform — `.dmg` on macOS, or +3. Under **Assets**, download the file for your platform — `.dmg` on macOS, or one of `.AppImage` / `.deb` / `.rpm` on Linux. -If you don't have repo access the assets return a 404 — ask in the Switch -Workforce hub to be added as a repo reader. - ### Option B — command line -```bash -# Latest Switch Console release (requires `gh auth login` with repo access): -gh release list --repo sandbox-quantum/switch | grep switch-console-v +Release assets are public, so a plain `curl` works — no token, no `gh`: -# Download the installer from a specific release (macOS): -gh release download switch-console-v \ - --repo sandbox-quantum/switch \ - --pattern '*.dmg' +```bash +# Download an installer from a specific release (macOS): +curl -fLO https://github.com/sandbox-quantum/switch/releases/download/switch-console-v/switch-console-arm64.dmg # Linux — pick the format your distro uses: +curl -fLO https://github.com/sandbox-quantum/switch/releases/download/switch-console-v/switch-console-x86_64.AppImage +``` + +With the [`gh` CLI](https://cli.github.com), if you prefer it: + +```bash +gh release list --repo sandbox-quantum/switch | grep switch-console-v gh release download switch-console-v \ --repo sandbox-quantum/switch \ - --pattern '*.AppImage' # or '*.deb' / '*.rpm' + --pattern '*.dmg' ``` -> A plain `curl` of the asset URL will **not** work — private-repo release -> assets require authentication (a browser session or a `gh`/GitHub token). - ## Install (macOS) 1. Open the downloaded `.dmg`. 2. Drag **Switch Console** into your **Applications** folder. -### First launch — one-time Gatekeeper bypass - -These builds are **unsigned** (no Apple Developer certificate), so macOS -Gatekeeper blocks the first launch. Clear it once, either way: - -- **Right-click → Open**: right-click (or Control-click) Switch Console in - Applications, choose **Open**, then confirm **Open** in the dialog. macOS - remembers the choice for future launches. -- **Or via Terminal**: - - ```bash - xattr -dr com.apple.quarantine "/Applications/Switch Console.app" - ``` - -After that, launch Switch Console normally. +Tagged macOS releases are signed and notarized with SandboxAQ's Developer ID, so +they open without a Gatekeeper bypass. ## Install (Linux x64) @@ -94,19 +76,11 @@ Linux builds are **unsigned**. Pick the format your distro uses: ## Updating -Switch Console checks this repo's Releases for new versions in-app. Because the repo -is private, the updater authenticates using the **GitHub CLI token you already -have** — no extra login inside the app: - -1. Make sure the [`gh` CLI](https://cli.github.com) is installed and you've run - `gh auth login` once. -2. Switch Console reads your token via `gh auth token` and offers the update when - one is available (Settings → checks automatically; you can also recheck - manually). +Switch Console checks this repo's Releases for new versions in-app and offers the +update when one is available — no sign-in of any kind. Settings checks +automatically; you can also recheck manually. -If `gh` isn't installed or you're not logged in, the app shows -"Sign in to GitHub to enable updates" and stays on the current version — you can -always grab a newer build manually from the +You can always grab a newer build manually from the [Releases page](https://github.com/sandbox-quantum/switch/releases) and re-install (drag over the old app). diff --git a/console/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts b/console/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts index f9a5c6196..abfc9a345 100644 --- a/console/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts +++ b/console/packages/core/src/host-dependencies/runtime/host-dependency-manager.test.ts @@ -346,9 +346,11 @@ describe('HostDependencyManager install', () => { }); it('extracts the version past login-shell banner noise on stdout', async () => { - const banner = [' _____ _ _ ____', ' / ____| | | | | __ \\', 'alg-bench-debian-12'].join( - '\n' - ); + const banner = [ + ' _____ _ _ ____', + ' / ____| | | | | __ \\', + 'example-host-debian-12', + ].join('\n'); const ctx = makeCtx(async (command, args = []) => { if (command === 'which' && args.at(-1) === 'git') { return { stdout: `${banner}\n/usr/bin/git\n`, stderr: '' }; diff --git a/console/packages/core/src/host-dependencies/runtime/probe.test.ts b/console/packages/core/src/host-dependencies/runtime/probe.test.ts index bf951dee7..43a417888 100644 --- a/console/packages/core/src/host-dependencies/runtime/probe.test.ts +++ b/console/packages/core/src/host-dependencies/runtime/probe.test.ts @@ -22,7 +22,7 @@ const BANNER = [ ' / ____| | | | /\\ / __ \\', " \\___ \\ / _` | '_ \\ / _` | '_ \\ / _ \\ \\/ / / /\\ \\| | | |", '|_____/ \\__,_|_| |_|\\__,_|_.__/ \\___/_/\\_\\/_/ \\_\\___\\_\\', - 'alg-bench-debian-12-v260624-9131585f', + 'example-host-debian-12-v260624-9131585f', ].join('\n'); describe('resolveCommandPath with login-shell banner noise', () => { diff --git a/console/packages/plugins/src/distribution.ts b/console/packages/plugins/src/distribution.ts index b5143e3f0..badc0ef7d 100644 --- a/console/packages/plugins/src/distribution.ts +++ b/console/packages/plugins/src/distribution.ts @@ -3,8 +3,8 @@ * passed to ` plugin marketplace add` (a GitHub `owner/repo` or a path) * when installing the Switch connector plugin. * - * CHOO-1260 config-flip point: the public-repo move repoints this. See - * RELEASING.md. Keep in sync with the Switch Console auto-update target + * The repo is public, so `plugin marketplace add` resolves it without a + * credential. Keep in sync with the Switch Console auto-update target * (RELEASE_REPO_* in apps/switch-console-desktop/src/shared/app-identity.ts). */ export const SWITCH_MARKETPLACE_SOURCE = 'sandbox-quantum/switch';