Skip to content

Fix isolated Claude subscription login on macOS - #23

Open
Him188 wants to merge 1 commit into
ProblemFactory:masterfrom
Him188:agent/macos-claude-credential-fallback
Open

Fix isolated Claude subscription login on macOS#23
Him188 wants to merge 1 commit into
ProblemFactory:masterfrom
Him188:agent/macos-claude-credential-fallback

Conversation

@Him188

@Him188 Him188 commented Jul 28, 2026

Copy link
Copy Markdown

What

  • Run isolated Claude subscription OAuth through an interactive helper.
  • On macOS, read Claude's per-directory Keychain item immediately after the official login succeeds, then atomically write only claudeAiOauth to the isolated fallback file.
  • Make local and on-host login watchers report the exact attempt's success or failure instead of polling indefinitely.
  • Mark macOS Keychain-backed logins local-only, with matching UI guidance and server-side enforcement.
  • Add focused regression tests and document the security/portability boundary.

Why

On macOS, claude auth login --claudeai can report success for an isolated CLAUDE_SECURESTORAGE_CONFIG_DIR while storing the credential only in Keychain. A VibeSpace server started by launchd often cannot read that item later, so the named subscription stays "not logged in" even though the interactive login completed.

Root cause

Claude Code 2.1.220 derives a Keychain service from the NFC-normalized secure-storage directory:

Claude Code-credentials-${sha256(dir).slice(0, 8)}

The interactive login terminal can access the newly written item, while the launchd/daemon context may receive a Keychain authorization error. VibeSpace previously waited only for <dir>/.credentials.json, so it never observed the successful Keychain-only login.

Related Claude Code reports:

Impact and safety

  • The official Claude login flow is unchanged.
  • Credentials never enter argv, API responses, logs, or status markers.
  • The helper validates a non-empty OAuth access token and writes only the claudeAiOauth root using a same-directory 0600 temp file, fsync, and atomic rename; account directories are forced to 0700.
  • A failed Keychain read or write preserves the previous credential file.
  • VibeSpace does not refresh tokens or call Anthropic during capture.
  • The fallback is not exported or copied to another host. Keychain and file copies can diverge when refresh tokens rotate, so another host must perform its own login.
  • macOS Keychain-backed account directories are not auto-merged or renamed because the Keychain service hash includes the directory path.
  • Linux file-backed behavior remains portable and unchanged.

Checks

  • node scripts/test-claude-subscription-login.mjs
  • npm run build
  • syntax checks for all changed server/helper modules
  • node scripts/secret-scan.mjs <changed files>
  • git diff --check

Summary by Sourcery

Handle Claude subscription logins via a dedicated helper that captures macOS Keychain-backed credentials into per-subscription dirs and treats those logins as local-only, with precise login status reporting and safer remote handling.

New Features:

  • Introduce a Claude subscription login helper script that runs the official OAuth flow and, on macOS, copies the resulting Keychain credential into the subscription’s fallback credential file.
  • Expose per-subscription login status (including failure codes) so clients can stop watching on the exact login attempt’s success or failure.

Bug Fixes:

  • Ensure isolated Claude subscription logins on macOS persist usable credentials for VibeSpace even when the server cannot later read the Keychain-only item.
  • Prevent macOS Keychain-backed subscription credentials from being exported or shipped to remote hosts, avoiding broken logins from copied rotating OAuth tokens.

Enhancements:

  • Mark macOS Claude subscription accounts as local-only throughout the UI and session selection flow, with tailored messaging when they cannot be used on remote hosts.
  • Tighten subscription credential file handling with atomic 0600 writes, directory mode enforcement, and guarded merge behavior that avoids unsafe dir renames for Keychain-bound logins.
  • Allow remote helper terminals to receive only the Claude subscription login helper even when broader agent integration is disabled, while enforcing that required helper/tool distribution errors fail closed.

Documentation:

  • Document how macOS Claude subscription logins are captured from Keychain, why they are treated as non-portable local shadows, and how this affects exports and remote host usage.

Tests:

  • Add a focused test script covering macOS Keychain service naming, safe credential capture, atomic writes, non-macOS behavior, export/merge constraints, helper command construction, and host status parsing for Claude subscription logins.

@sourcery-ai

sourcery-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a dedicated Claude subscription login helper that runs the official OAuth flow, snapshots macOS Keychain-backed credentials into per-subscription directories with atomic, local-only fallbacks, and threads new localOnly/shippable semantics plus precise per-attempt login status through the server, host probing, WS spawning, and UI layers, with regression tests and documentation updates.

Sequence diagram for macOS Claude subscription login via helper

sequenceDiagram
  actor User
  participant WebUI as ManageAgents_UI
  participant WS as ws_handler
  participant Host as RemoteHost_shell
  participant Helper as vibespace_claude_subscription_login.mjs
  participant Claude as claude_CLI
  participant Keychain as macOS_Keychain

  User->>WebUI: Click "Add subscription…" (remote host)
  WebUI->>WebUI: remoteClaudeSubscriptionLoginCommand(id)
  WebUI->>WS: openShellTerminal(initialCommand=login.command)
  WS->>WS: detect needsClaudeLoginHelper
  WS->>Host: deviceAgentSetup (ship vibespace-claude-subscription-login.mjs)

  Host->>Helper: run initialCommand
  Helper->>Helper: parseArgs(--config-dir, --claude, --attempt)
  Helper->>Helper: writeLoginStatus(state=running, attempt)
  Helper->>Claude: runLogin(configDir, claudeCmd)
  Claude-->>Helper: auth login --claudeai succeeds
  alt platform is darwin
    Helper->>Keychain: readMacOSKeychain(configDir)
    Keychain-->>Helper: claudeAiOauth JSON
    Helper->>Helper: writeCredentialsFile(configDir, credentials)
  else non-darwin
    Helper->>Helper: readCredentialsFile(configDir)
  end
  Helper->>Helper: writeLoginStatus(state=success, attempt)

  loop poll host status
    WebUI->>WS: GET /api/hosts/{id}/accounts-status
    WS->>Host: HostManager.probeHostStatus
    Host-->>WS: hostSubLoginStatus[accountId]
    WS-->>WebUI: { hostSubLoginStatus }
    WebUI->>WebUI: _watchHostLogin(hostId, accountId, loginAttempt)
    alt status.state == success and attempt matches
      WebUI->>WebUI: complete(false)
    else status.state == error
      WebUI->>User: showToast("Subscription login could not be saved…")
    end
  end
Loading

File-Level Changes

Change Details Files
Route all Claude subscription logins through a helper-based command and per-attempt status watcher in the UI.
  • Introduce remoteClaudeSubscriptionLoginCommand to construct a helper-based login command and opaque attempt id for remote hosts.
  • Update Add-subscription and per-host "Log in on {host}" flows to use the helper command instead of invoking claude auth login directly with CLAUDE_* env vars.
  • Extend _watchHostLogin to optionally track a specific accountId+loginAttempt via hostSubLoginStatus and stop on success or explicit failure, surfacing a user-facing error toast.
src/lib/manage-agents.js
Teach AccountManager about macOS-local Claude subscription logins and make their fallbacks non-portable and safer to update.
  • Add platform-aware _localOnlyClaudeSub and expose localOnly on listed accounts and finalizeSubscription responses.
  • Record helper-written login status via .vibespace-login-status.json so finalizeSubscription can report loginFailed/loginErrorCode when no credential landed.
  • Exclude macOS subscription .credentials.json from export bundles, forbid merges that would move a Keychain-bound dir, and mark remoteCreds.shippable=false so these logins never ship to other hosts.
  • Harden subscription credential copies in mergeSubscription with atomic temp-file writes, 0600 permissions, fsync, and directory chmod to 0700.
src/accounts.js
Block shipping of macOS Keychain-backed subscriptions at the transport layer and ensure the Claude login helper is installed and required for remote terminals.
  • Respect remoteCreds.shippable in ws-handler when deciding whether a subscription credential dir can be sent to a remote host or used in a spawn.
  • Add detection of the Claude login helper in remote tool shipping, sending it even when integration is off for helper terminals, and fail closed when it is missing or cannot be copied.
  • Prevent deviceAgentSetup from degrading silently when the login helper is required, and enforce that non-shippable remoteCreds cause a hard error instead of copying a macOS fallback.
src/ws-handler.js
Use a central builder for the Claude subscription login command and wire it into account creation and host status/merge behavior.
  • Add buildClaudeSubscriptionLoginCommand and shellQuote helper to construct a shell-safe node helper invocation with configDir and claude path.
  • Have /api/accounts/subscription use the builder with NODE_CMD, CLAUDE_CMD, and a fixed helper path instead of hand-writing CLAUDE_* env vars in the loginCmd string.
  • Guard host subscription auto-merge and dir renames with platform-awareness, skipping merges on Darwin or when either of the involved accounts is localOnly.
  • Plumb platform and hostSubLoginStatus from HostManager.accountsStatus (via uname and scanning helper status files) through /api/hosts/:id/accounts-status.
server.js
src/claude-subscription-login.js
src/hosts.js
Mark macOS Keychain-backed subscriptions as local-only in the UI and session routing, updating messaging and blocking rules.
  • Propagate localOnly on accounts to Manage Agents rendering so per-host blocked hints distinguish macOS-only logins from generally non-shipped subscriptions, and reuse that messaging when a blocked account is chosen for a remote host.
  • Update session properties and session-lifecycle billing explanations to consider localOnly when deciding whether a subscription is blocked or why it cannot ship.
  • Ensure host/session account pickers omit localOnly subscriptions from remote-usable options, forcing on-host logins for those accounts.
src/lib/manage-agents.js
src/lib/session-props.js
src/lib/session-lifecycle.js
src/lib/app.js
src/lib/i18n-ja.js
src/lib/i18n-zh.js
Add an interactive Claude subscription login helper script for macOS Keychain capture with robust credential handling and status markers.
  • Implement vibespace-claude-subscription-login.mjs to run claude auth login --claudeai in an isolated config dir, then on macOS read the per-dir Keychain entry, validate claudeAiOauth, and atomically write a 0600 .credentials.json; on non-macOS, validate and normalize the CLI-written file instead.
  • Derive the Keychain service name from NFC-normalized configDir and choose an appropriate account (USER or fallback) to mirror Claude Code behavior; keep all errors sanitized with small loginCode identifiers.
  • Introduce atomicWritePrivate/writeCredentialsFile/writeLoginStatus helpers that enforce directory 0700, temp-file 0600, fsync+rename semantics, and a sanitized .vibespace-login-status.json with {state,code,attempt,updatedAt} for watcher consumption.
  • Provide a small CLI wrapper (parseArgs + main) that sets a strict umask, writes running/success/error status markers, and prints user-facing error messages without leaking Keychain specifics.
data/bin/vibespace-claude-subscription-login.mjs
Extend documentation and tests to cover macOS Keychain-backed subscription behavior, helper wiring, and safety guarantees.
  • Document Add subscription behavior on macOS, the one-time Keychain snapshot, non-exportability, non-shippability, and non-merge semantics in accounts.md.
  • Expand CLAUDE.md API doc for /api/accounts and /api/hosts/:id/agent-tools to describe the login helper, localOnly flag, and Keychain snapshot behavior.
  • Add scripts/test-claude-subscription-login.mjs that validates Keychain service/account naming, helper Keychain reads, atomic writes and permission enforcement, AccountManager localOnly/shippable/export/merge semantics, HostManager host status parsing, manage-agents watcher wiring, and ws-handler/platform guards.
docs/accounts.md
CLAUDE.md
scripts/test-claude-subscription-login.mjs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Him188
Him188 marked this pull request as ready for review July 28, 2026 16:36

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The needsClaudeLoginHelper detection in ws-handler currently relies on String(data.initialCommand || '').includes('/vibespace-claude-subscription-login.mjs'), which is fairly brittle; consider passing an explicit flag in the spawn request instead so future changes to the command string don’t silently bypass helper shipping/enforcement.
  • In shellQuote, rejecting any control characters in the paths (including newlines) is good for safety but will throw at runtime if a config dir or binary path ever contains one; if that’s a realistic risk, you might want to validate/sanitize earlier when these paths are configured so the error surfaces closer to the source.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `needsClaudeLoginHelper` detection in `ws-handler` currently relies on `String(data.initialCommand || '').includes('/vibespace-claude-subscription-login.mjs')`, which is fairly brittle; consider passing an explicit flag in the spawn request instead so future changes to the command string don’t silently bypass helper shipping/enforcement.
- In `shellQuote`, rejecting any control characters in the paths (including newlines) is good for safety but will throw at runtime if a config dir or binary path ever contains one; if that’s a realistic risk, you might want to validate/sanitize earlier when these paths are configured so the error surfaces closer to the source.

## Individual Comments

### Comment 1
<location path="src/accounts.js" line_range="179-185" />
<code_context>
     } catch { return { loggedIn: false }; }
   }

+  _subscriptionLoginStatus(id) {
+    try {
+      const status = JSON.parse(fs.readFileSync(path.join(this.subDir(id), '.vibespace-login-status.json'), 'utf-8'));
+      if (status?.state !== 'error' || !/^[a-z0-9-]{1,40}$/.test(status.code || '')) return null;
+      return { state: 'error', code: status.code };
+    } catch { return null; }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Login-status code validation is stricter than the writer and may silently ignore future error codes.

`_subscriptionLoginStatus` only returns errors whose `code` matches `/^[a-z0-9-]{1,40}$/`, but `writeLoginStatus` can persist any trimmed string and current callers already use values like `claude-login-exit`. If future codes add characters like `_` or uppercase, they’ll be written but then silently ignored, so `finalizeSubscription` will never see `loginFailed` for those cases.

To avoid this mismatch, either broaden the regex to match what `writeLoginStatus` can emit (e.g. `[A-Za-z0-9._-]{1,40}`) or drop the format check and just require `status.state === 'error'` and a non-empty `code`.

```suggestion
  _subscriptionLoginStatus(id) {
    try {
      const status = JSON.parse(
        fs.readFileSync(
          path.join(this.subDir(id), '.vibespace-login-status.json'),
          'utf-8',
        ),
      );
      if (
        status?.state !== 'error' ||
        !/^[A-Za-z0-9._-]{1,40}$/.test(status.code || '')
      ) {
        return null;
      }
      return { state: 'error', code: status.code };
    } catch {
      return null;
    }
  }
```
</issue_to_address>

### Comment 2
<location path="src/lib/manage-agents.js" line_range="141" />
<code_context>
   // (§ban-safety) — until the credential files CHANGE vs the pre-login
   // snapshot, then brings the Agents surface back on the SAME machine.
-  _watchHostLogin(hostId, hostLabel) {
+  _watchHostLogin(hostId, hostLabel, accountId = null, loginAttempt = null) {
     if (!hostId) return;
     if (this._hostLoginWatch) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; }
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the per-attempt and global polling paths plus the shared completion behavior in `_watchHostLogin` into separate helper methods to make the logic clearer and flatter.

You can simplify `_watchHostLogin` by making the dual-mode logic and the `complete` side effects explicit helpers. That keeps the polling loops single-purpose and flattens the nested conditions without changing behavior.

Example refactor:

```js
_watchHostLogin(hostId, hostLabel, accountId = null, loginAttempt = null) {
  if (!hostId) return;
  if (this._hostLoginWatch) {
    clearInterval(this._hostLoginWatch);
    this._hostLoginWatch = null;
  }

  if (accountId && loginAttempt) {
    this._watchHostLoginForAttempt({ hostId, hostLabel, accountId, loginAttempt });
  } else {
    this._watchHostLoginGlobal({ hostId, hostLabel });
  }
}
```

Then split out the two polling modes and the shared completion:

```js
_completeHostLoginWatch({ hostId, hostLabel, machineLoginChanged }) {
  clearInterval(this._hostLoginWatch);
  this._hostLoginWatch = null;

  if (machineLoginChanged) (this._hostLoginSeenAt ||= {})[hostId] = Date.now();

  if (this._agentsHostPref && this._agentsHostPref !== hostId) {
    showToast(t('✓ Login on {host} updated', { host: hostLabel }), { duration: 5000 });
    return;
  }

  showToast(t('✓ Login on {host} updated — reopening Agents there', { host: hostLabel }), { duration: 5000 });
  this._agentsHostPref = hostId;
  if (!this._agentsRefreshHook?.(hostId)) this._showAgentsDialog();
}

_watchHostLoginForAttempt({ hostId, hostLabel, accountId, loginAttempt }) {
  let tries = 0;
  this._hostLoginWatch = setInterval(async () => {
    if (++tries > 50) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; return; }

    let cur;
    try { cur = await fetchJson(`/api/hosts/${encodeURIComponent(hostId)}/accounts-status`); } catch { return; }

    const loginStatus = cur?.hostSubLoginStatus?.[accountId];
    if (!loginStatus || loginStatus.attempt !== loginAttempt || loginStatus.state === 'running') return;

    if (loginStatus.state === 'error') {
      clearInterval(this._hostLoginWatch); this._hostLoginWatch = null;
      showToast(t('Subscription login could not be saved. Check the login terminal for details, then try again.'), { type: 'error', duration: 8000 });
      return;
    }

    if (loginStatus.state === 'success') {
      this._completeHostLoginWatch({ hostId, hostLabel, machineLoginChanged: false });
    }
  }, 6000);
}

_watchHostLoginGlobal({ hostId, hostLabel }) {
  const sig = (r) => (r && !r.error)
    ? [r.credsMtime || 0, r.codexAuthMtime || 0, r.subscription?.loggedIn ? 1 : 0, r.subscription?.email || '', r.codex?.email || '', (r.hostSubs || []).join('+')].join('|')
    : null;

  let baseSig = null;
  let tries = 0;

  this._hostLoginWatch = setInterval(async () => {
    if (++tries > 50) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; return; }

    let cur;
    try { cur = await fetchJson(`/api/hosts/${encodeURIComponent(hostId)}/accounts-status`); } catch { return; }

    const s = sig(cur);
    if (s === null) return;
    if (baseSig === null) { baseSig = s; return; }
    if (s === baseSig) return;

    const machinePart = (x) => x.split('|').slice(0, 5).join('|');
    const machineLoginChanged = machinePart(s) !== machinePart(baseSig);
    this._completeHostLoginWatch({ hostId, hostLabel, machineLoginChanged });
  }, 6000);
}
```

This keeps all current behaviors (including the per-attempt short-circuiting and machine-login-change stamp) but makes:

- The two modes (`attempt` vs global fingerprint) explicit and mutually exclusive.
- The “what happens when we’re done watching” logic clearly visible and reusable.
- The interval bodies simpler and easier to reason about and test in isolation.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/accounts.js
Comment on lines +179 to +185
_subscriptionLoginStatus(id) {
try {
const status = JSON.parse(fs.readFileSync(path.join(this.subDir(id), '.vibespace-login-status.json'), 'utf-8'));
if (status?.state !== 'error' || !/^[a-z0-9-]{1,40}$/.test(status.code || '')) return null;
return { state: 'error', code: status.code };
} catch { return null; }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Login-status code validation is stricter than the writer and may silently ignore future error codes.

_subscriptionLoginStatus only returns errors whose code matches /^[a-z0-9-]{1,40}$/, but writeLoginStatus can persist any trimmed string and current callers already use values like claude-login-exit. If future codes add characters like _ or uppercase, they’ll be written but then silently ignored, so finalizeSubscription will never see loginFailed for those cases.

To avoid this mismatch, either broaden the regex to match what writeLoginStatus can emit (e.g. [A-Za-z0-9._-]{1,40}) or drop the format check and just require status.state === 'error' and a non-empty code.

Suggested change
_subscriptionLoginStatus(id) {
try {
const status = JSON.parse(fs.readFileSync(path.join(this.subDir(id), '.vibespace-login-status.json'), 'utf-8'));
if (status?.state !== 'error' || !/^[a-z0-9-]{1,40}$/.test(status.code || '')) return null;
return { state: 'error', code: status.code };
} catch { return null; }
}
_subscriptionLoginStatus(id) {
try {
const status = JSON.parse(
fs.readFileSync(
path.join(this.subDir(id), '.vibespace-login-status.json'),
'utf-8',
),
);
if (
status?.state !== 'error' ||
!/^[A-Za-z0-9._-]{1,40}$/.test(status.code || '')
) {
return null;
}
return { state: 'error', code: status.code };
} catch {
return null;
}
}

Comment thread src/lib/manage-agents.js
// (§ban-safety) — until the credential files CHANGE vs the pre-login
// snapshot, then brings the Agents surface back on the SAME machine.
_watchHostLogin(hostId, hostLabel) {
_watchHostLogin(hostId, hostLabel, accountId = null, loginAttempt = null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (complexity): Consider extracting the per-attempt and global polling paths plus the shared completion behavior in _watchHostLogin into separate helper methods to make the logic clearer and flatter.

You can simplify _watchHostLogin by making the dual-mode logic and the complete side effects explicit helpers. That keeps the polling loops single-purpose and flattens the nested conditions without changing behavior.

Example refactor:

_watchHostLogin(hostId, hostLabel, accountId = null, loginAttempt = null) {
  if (!hostId) return;
  if (this._hostLoginWatch) {
    clearInterval(this._hostLoginWatch);
    this._hostLoginWatch = null;
  }

  if (accountId && loginAttempt) {
    this._watchHostLoginForAttempt({ hostId, hostLabel, accountId, loginAttempt });
  } else {
    this._watchHostLoginGlobal({ hostId, hostLabel });
  }
}

Then split out the two polling modes and the shared completion:

_completeHostLoginWatch({ hostId, hostLabel, machineLoginChanged }) {
  clearInterval(this._hostLoginWatch);
  this._hostLoginWatch = null;

  if (machineLoginChanged) (this._hostLoginSeenAt ||= {})[hostId] = Date.now();

  if (this._agentsHostPref && this._agentsHostPref !== hostId) {
    showToast(t('✓ Login on {host} updated', { host: hostLabel }), { duration: 5000 });
    return;
  }

  showToast(t('✓ Login on {host} updated — reopening Agents there', { host: hostLabel }), { duration: 5000 });
  this._agentsHostPref = hostId;
  if (!this._agentsRefreshHook?.(hostId)) this._showAgentsDialog();
}

_watchHostLoginForAttempt({ hostId, hostLabel, accountId, loginAttempt }) {
  let tries = 0;
  this._hostLoginWatch = setInterval(async () => {
    if (++tries > 50) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; return; }

    let cur;
    try { cur = await fetchJson(`/api/hosts/${encodeURIComponent(hostId)}/accounts-status`); } catch { return; }

    const loginStatus = cur?.hostSubLoginStatus?.[accountId];
    if (!loginStatus || loginStatus.attempt !== loginAttempt || loginStatus.state === 'running') return;

    if (loginStatus.state === 'error') {
      clearInterval(this._hostLoginWatch); this._hostLoginWatch = null;
      showToast(t('Subscription login could not be saved. Check the login terminal for details, then try again.'), { type: 'error', duration: 8000 });
      return;
    }

    if (loginStatus.state === 'success') {
      this._completeHostLoginWatch({ hostId, hostLabel, machineLoginChanged: false });
    }
  }, 6000);
}

_watchHostLoginGlobal({ hostId, hostLabel }) {
  const sig = (r) => (r && !r.error)
    ? [r.credsMtime || 0, r.codexAuthMtime || 0, r.subscription?.loggedIn ? 1 : 0, r.subscription?.email || '', r.codex?.email || '', (r.hostSubs || []).join('+')].join('|')
    : null;

  let baseSig = null;
  let tries = 0;

  this._hostLoginWatch = setInterval(async () => {
    if (++tries > 50) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; return; }

    let cur;
    try { cur = await fetchJson(`/api/hosts/${encodeURIComponent(hostId)}/accounts-status`); } catch { return; }

    const s = sig(cur);
    if (s === null) return;
    if (baseSig === null) { baseSig = s; return; }
    if (s === baseSig) return;

    const machinePart = (x) => x.split('|').slice(0, 5).join('|');
    const machineLoginChanged = machinePart(s) !== machinePart(baseSig);
    this._completeHostLoginWatch({ hostId, hostLabel, machineLoginChanged });
  }, 6000);
}

This keeps all current behaviors (including the per-attempt short-circuiting and machine-login-change stamp) but makes:

  • The two modes (attempt vs global fingerprint) explicit and mutually exclusive.
  • The “what happens when we’re done watching” logic clearly visible and reusable.
  • The interval bodies simpler and easier to reason about and test in isolation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant