Skip to content

Feat/ssh remote usage - #1350

Closed
livejiaquan wants to merge 6 commits into
robinebers:mainfrom
livejiaquan:feat/ssh-remote-usage
Closed

livejiaquan wants to merge 6 commits into
robinebers:mainfrom
livejiaquan:feat/ssh-remote-usage

Conversation

@livejiaquan

Copy link
Copy Markdown

Approved issue

Fixes #

TL;DR

What was happening

What this changes

Heads-up

Tests

Screenshots

Copilot AI balanced review requested due to automatic review settings October 4, 2026 10:57

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for your interest in contributing to OpenUsage!

External pull requests must reference an open issue that:

  1. Has been approved by a maintainer with the approved label.
  2. Is assigned to the pull request author (@livejiaquan).

Please discuss the change on an issue first, wait for a maintainer to approve and assign it to you, then reopen this pull request with Fixes #123 in its description.

Read the contribution guidelines.

@pullfrog pullfrog 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.

Important

Successful Codex imports can disappear from the dashboard, and a stalled SSH command can stop subsequent automatic refreshes. These issues, plus the pipe-draining and scope-restoration problems below, should be addressed before merging.

Reviewed changes: Reviewed the full SSH remote-usage feature and its local-log-root integration.

  • Additional Log Roots: Extends existing Claude and Codex scanners with configured read-only archive directories.
  • Remote Collection: Adds a bundled Python exporter, SSH client, device persistence, last-good summaries, and periodic refresh integration.
  • Device Scopes: Adds Settings controls and dashboard filtering for local, combined, and individual-device history while keeping live quotas local.
  • Coverage And Docs: Adds scanner, pricing, scope, and exporter tests, build workflows, and setup/privacy documentation.

The Python exporter test passed. macOS Swift tests and UI verification were not run because this runner is Linux.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

)
}
return UsageHistoryDocument(
deviceID: device.id, deviceName: device.name, updatedAt: now, providers: providers

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Successful Codex imports are rejected for resolved local accounts. This document contains only codex/claude family keys and no identities, but the existing merger rejects unattributed Codex history whenever the local identity contains |, including a normal single-account login. Settings therefore reports a successful import while All Devices and the selected remote device show no imported Codex spend.

Technical details
## Affected Sites
- SSHRemoteUsageClient.document creates a v1 document without identities.
- UsageHistoryAggregator.merged routes resolved Codex identities and scoped cards through accountHistory with allowsUnattributedHistory: false.
- accountHistory returns nil for this document, so WidgetDataStore cannot render its remote Codex history.
- The new scope test supplies no providerIdentityKeys and therefore misses the normal resolved-login case.

## Required Outcome
- Make successfully collected machine-level Codex history visible under the intended device scope without attributing ambiguous historical usage to an account.
- Add a regression using a complete local Codex identity (accountID|email), and cover scoped cards separately.
- Do not fix this by stamping all historical remote usage with the remote home's current login: that does not establish who paid for old turns.

See the merger routing and ownership guard.

process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
process.arguments = [
"-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=10",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A hung remote command stops the entire automatic refresh loop. ConnectTimeout only bounds connection setup, and ServerAliveInterval/ServerAliveCountMax do not expire a command while its SSH server still answers keepalives. If Python blocks reading a session on a stalled filesystem, this fetch never completes, preventing all following remote devices and subsequent local provider refreshes and notification evaluations.

Technical details
## Affected Sites
- SSHRemoteUsageClient.runSSH performs blocking writes/reads/waitUntilExit without an overall deadline or cancellation cleanup.
- RemoteUsageDeviceStore.refreshAll awaits devices serially.
- AppContainer.startPeriodicRefresh awaits remoteDevices.refreshAll before notifications and the next loop iteration.

## Required Outcome
- Bound the complete SSH operation, not just connection setup, and terminate/reap the process when it expires or is cancelled.
- Release the in-flight device state and surface an actionable error while preserving its last-good summary.
- Test with a connected child that never finishes or closes stdout.

## Contract Evidence
- https://man.openbsd.org/ssh_config#ConnectTimeout limits connection establishment and initial handshake/key exchange.
- https://man.openbsd.org/ssh_config#ServerAliveCountMax disconnects only after unanswered server-alive messages.

throw SSHRemoteUsageError.oversizedResponse
}
}
let errors = stderr.fileHandleForReading.readDataToEndOfFile()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Drain stderr concurrently with stdout. Stderr is not read until stdout reaches EOF, so an SSH alias with verbose logging or a noisy remote startup script can fill the stderr pipe and block the child before it closes stdout. The client then waits forever for EOF instead of completing the import or displaying its error.

Technical details
## Affected Sites
- SSHRemoteUsageClient.runSSH drains stdout first and stderr afterward.
- SystemProcessRunner already uses independent drain queues for this exact dependency.

## Required Outcome
- Drain both output streams independently while SSH runs, retaining the stdout size cap and bounded diagnostic capture.
- Add a subprocess regression that writes more than a pipe's capacity to stderr before writing/closing stdout.

## Validation
- A synthetic child writing 1 MB to stderr before stdout leaves the stdout reader blocked when stderr is undrained; terminating the child releases it.

let allPeers = UsageHistoryDocument.newestByDevice(peerHistoryDocuments + remoteHistoryDocuments)
if historyScopeID != "all" && historyScopeID != "local"
&& !allPeers.contains(where: { $0.deviceID == historyScopeID }) {
historyScopeID = "all"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Do not discard a saved iCloud device selection before peer loading finishes. At launch, the remote-device store publishes its cached documents synchronously, while iCloud peer loading has not completed yet. A persisted iCloud device ID is therefore absent from allPeers here and is overwritten with all on every restart, so the user's selected device scope does not survive relaunch.

Technical details
## Affected Sites
- WidgetDataStore.init restores historyScopeID from preferences.
- AppContainer initializes ICloudUsageSyncStore, which schedules asynchronous peer loading, then immediately initializes RemoteUsageDeviceStore.
- RemoteUsageDeviceStore.init calls setRemoteHistoryDocuments, triggering this rebuild before the iCloud task has loaded any peers.

## Required Outcome
- Distinguish sources that have not finished loading from a device that has actually been removed before persisting a fallback scope.
- Add a regression restoring an iCloud device ID, publishing remote documents first, then loading that iCloud peer; the selected ID must remain intact.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants