Skip to content

[codex] Download files over WebSocket when direct host is unavailable#1863

Open
kfihihc wants to merge 1 commit into
getpaseo:mainfrom
kfihihc:codex/websocket-file-download
Open

[codex] Download files over WebSocket when direct host is unavailable#1863
kfihihc wants to merge 1 commit into
getpaseo:mainfrom
kfihihc:codex/websocket-file-download

Conversation

@kfihihc

@kfihihc kfihihc commented Jul 2, 2026

Copy link
Copy Markdown

Linked issue

Related to #1350. No dedicated open issue was found for the relay-only/direct-host-unavailable fallback path.

Type of change

  • Bug fix
  • New feature (with prior issue + design alignment)
  • Refactor / code improvement
  • Docs

What does this PR do

File downloads currently depend on a direct HTTP download host. That breaks for relay-only or otherwise non-direct connections because the relay only carries encrypted WebSocket traffic, not /api/files/download HTTP requests.

This PR keeps the existing direct token download path when the active connection is direct TCP, and adds a WebSocket byte-transfer fallback when there is no direct HTTP download host. The fallback:

  • requests file bytes through the existing file explorer action path
  • caps WebSocket downloads at 50 MB before reading bytes
  • verifies returned byte counts against the advertised size when available
  • writes downloaded bytes to native cache before sharing
  • uses browser blob downloads on web and revokes object URLs after a delay
  • threads the active host connection through the file explorer so mixed relay/direct profiles choose the right path
  • documents why relay connections need the binary WebSocket file-transfer path

How did you verify it

Ran the required local checks from the PR branch:

  • npm run format
  • npm run typecheck
  • npm run lint
  • npm run test --workspace=@getpaseo/app -- src/stores/download-store.test.ts --bail=1

The targeted app test file passed 8 tests covering:

  • relay-only downloads using WebSocket file bytes
  • mixed relay/direct profiles selecting WebSocket bytes when the active connection is relay
  • mixed profiles using the direct token download path when active connection is direct TCP
  • oversized WebSocket downloads failing before reading bytes
  • byte-count mismatch failures
  • native writes completing before the download is marked complete
  • web blob download behavior

I also checked the Git scope:

  • git log --oneline --left-right --cherry-pick origin/main...codex/websocket-file-download confirms the branch contains one commit on top of origin/main.
  • git diff --stat origin/main..codex/websocket-file-download confirms the PR scope is the download fallback change.

Checklist

  • One focused change. Unrelated cleanups split out.
  • npm run typecheck passes
  • npm run lint passes
  • npm run format ran (Biome)
  • UI changes include screenshots or video for every affected platform (N/A: no visual UI layout changes)
  • Tests added or updated where it made sense

Fall back to transferring file bytes over the WebSocket connection when
the daemon has no reachable direct URL (e.g. relay-only connections),
with a 50MB cap, progress reporting, and share-sheet integration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kfihihc kfihihc marked this pull request as ready for review July 2, 2026 14:22

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7c27ef7d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);
}

const file = await input.requestFileBytes(input.path);

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 Badge Avoid the default RPC timeout for WebSocket downloads

When the active connection has no direct HTTP host, this awaits requestFileBytes, which is wired to client.readFile; that file-explorer request uses the normal correlated session RPC timeout with no override, so it times out after the default 60s. On relay/mobile connections, an otherwise allowed 30–50 MB download can easily take longer than that, causing the new fallback path to fail even though the socket is still healthy and the file is below the cap. Please use a long-running file-transfer request/timeout for this download path instead of the default explorer RPC timeout.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a WebSocket byte-transfer fallback for file downloads when there is no direct HTTP download host — fixing relay-only and non-direct connections where /api/files/download is unreachable. The active connection ID is threaded from the host runtime snapshot through the file explorer into the download store so mixed relay/direct profiles choose the correct path at download time.

  • download-store.ts: new resolveDownloadConnection selects the direct-TCP connection only when the active or preferred connection is directTcp; downloadViaFileBytes handles the WS fallback with a 50 MB pre-read cap, self-consistency byte-count check, native file write, and browser blob download.
  • file-explorer-pane.tsx / use-file-explorer-actions.ts: requestFileBytes added alongside requestFileDownloadToken; activeConnectionId is derived from the live host snapshot with a fallback to preferredConnectionId.
  • Seven locale files updated with the new websocketTooLarge key.

Confidence Score: 4/5

The production logic is sound and well-scoped, but the test file uses module mocking and global stubbing patterns that the project explicitly disallows, which could mask regressions if the test approach is continued.

The download fallback logic is clearly structured and the integration points (component → hook → store) are minimal. The main concern is the test file: it uses vi.mock, vi.hoisted, vi.stubGlobal, and a polling loop — all of which are banned by the project's test standards. Additionally, the post-read size guard inspects the server-reported size field rather than the actual byte length, meaning the 50 MB memory cap is not enforced when the token response omits a size and the daemon misreports its own size. These are real gaps, though neither affects the happy path in normal operation.

download-store.test.ts (banned test patterns throughout) and download-store.ts (post-read size check on line 357).

Important Files Changed

Filename Overview
packages/app/src/stores/download-store.ts Core logic for WebSocket fallback download path; adds resolveDownloadConnection, downloadViaFileBytes, triggerBrowserBytesDownload, and shareDownloadedFile. Post-read size cap checks server-reported size rather than actual byte length, and the token request always precedes the WS path even when the token won't be used.
packages/app/src/stores/download-store.test.ts New test file for WS download logic; uses vi.mock, vi.hoisted, vi.stubGlobal, and a polling loop — all banned by the project's test discipline rules. Coverage of the happy paths and size-limit cases is otherwise good.
packages/app/src/components/file-explorer-pane.tsx Threads activeConnectionId (live snapshot vs. preferred fallback) and the new requestFileBytes action into downloadExplorerEntry; integration point changes are minimal and correctly wired.
packages/app/src/hooks/use-file-explorer-actions.ts Adds requestFileBytes callback that delegates to client.readFile; guards on missing workspace root and disconnected client mirror the existing requestFileDownloadToken pattern.
docs/architecture.md Adds a short paragraph explaining why relay connections require the binary WebSocket file-transfer path; accurate and useful.
packages/app/src/i18n/resources/en.ts Adds websocketTooLarge key with {{size}} interpolation across all eight locale files; consistent and complete.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as FileExplorerPane
    participant DS as DownloadStore
    participant Token as requestFileDownloadToken
    participant WS as requestFileBytes (WebSocket)
    participant HTTP as HTTP download (expo-fs)

    UI->>DS: "startDownload({ daemonProfile, activeConnectionId, ... })"
    DS->>DS: resolveDaemonDownloadTarget(daemonProfile, activeConnectionId)
    DS->>Token: requestFileDownloadToken(path)
    Token-->>DS: "{ token, fileName, size, mimeType }"

    alt baseUrl present (direct TCP active connection)
        DS->>HTTP: createDownloadResumable(url, token)
        HTTP-->>DS: "{ uri }"
        DS->>DS: completeDownload()
    else no baseUrl (relay / socket / pipe connection)
        DS->>DS: downloadViaFileBytes()
        note over DS: check expectedSize <= 50 MB
        DS->>WS: requestFileBytes(path)
        WS-->>DS: "{ bytes, size, mime }"
        note over DS: check bytes.byteLength === file.size
        alt isWeb
            DS->>DS: triggerBrowserBytesDownload(blob URL)
        else native
            DS->>DS: write bytes to cache file
            DS->>DS: Sharing.shareAsync()
        end
        DS->>DS: completeDownload()
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as FileExplorerPane
    participant DS as DownloadStore
    participant Token as requestFileDownloadToken
    participant WS as requestFileBytes (WebSocket)
    participant HTTP as HTTP download (expo-fs)

    UI->>DS: "startDownload({ daemonProfile, activeConnectionId, ... })"
    DS->>DS: resolveDaemonDownloadTarget(daemonProfile, activeConnectionId)
    DS->>Token: requestFileDownloadToken(path)
    Token-->>DS: "{ token, fileName, size, mimeType }"

    alt baseUrl present (direct TCP active connection)
        DS->>HTTP: createDownloadResumable(url, token)
        HTTP-->>DS: "{ uri }"
        DS->>DS: completeDownload()
    else no baseUrl (relay / socket / pipe connection)
        DS->>DS: downloadViaFileBytes()
        note over DS: check expectedSize <= 50 MB
        DS->>WS: requestFileBytes(path)
        WS-->>DS: "{ bytes, size, mime }"
        note over DS: check bytes.byteLength === file.size
        alt isWeb
            DS->>DS: triggerBrowserBytesDownload(blob URL)
        else native
            DS->>DS: write bytes to cache file
            DS->>DS: Sharing.shareAsync()
        end
        DS->>DS: completeDownload()
    end
Loading

Reviews (1): Last reviewed commit: "feat(app): download files over WebSocket..." | Re-trigger Greptile

Comment on lines +5 to +58
const fsMock = vi.hoisted(() => ({
writes: new Map<string, Uint8Array>(),
pendingWrite: null as Promise<void> | null,
}));
const platformMock = vi.hoisted(() => ({
isWeb: false,
}));
const legacyFileSystemMock = vi.hoisted(() => ({
createDownloadResumable: vi.fn(() => ({
downloadAsync: vi.fn().mockResolvedValue({ uri: "file:///cache/report.txt" }),
})),
}));

vi.mock("@/constants/platform", () => ({
get isWeb() {
return platformMock.isWeb;
},
}));

vi.mock("expo-file-system", () => {
class File {
readonly uri: string;

constructor(directory: string, name?: string) {
this.uri = name ? `${directory.replace(/\/$/, "")}/${name}` : directory;
}

get exists(): boolean {
return fsMock.writes.has(this.uri);
}

write(bytes: Uint8Array): void | Promise<void> {
fsMock.writes.set(this.uri, bytes);
return fsMock.pendingWrite ?? undefined;
}
}

return {
File,
Paths: {
cache: "file:///cache",
document: "file:///document",
},
};
});

vi.mock("expo-file-system/legacy", () => ({
createDownloadResumable: legacyFileSystemMock.createDownloadResumable,
}));

vi.mock("expo-sharing", () => ({
isAvailableAsync: vi.fn().mockResolvedValue(false),
shareAsync: vi.fn(),
}));

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 Banned test patterns: vi.hoisted, vi.mock, vi.stubGlobal, and polling loop

The test file relies on four patterns that the project's review standards explicitly ban:

  • vi.hoisted (lines 5–16) — banned by policy.
  • vi.mock (lines 18, 24, 51, 55) — module-level mocking is banned; a port/adapter approach or a real in-memory substitute is preferred.
  • vi.stubGlobal("URL", ...) and vi.stubGlobal("document", ...) (lines 343–344) — monkey-patching globals is banned.
  • The polling loop on line 305 (for (let index = 0; index < 5 && !fsMock.writes.has(...)...)) — polling inside a test body is explicitly prohibited.

The expo-file-system and expo-sharing dependencies are "local-substitutable" (the test already constructs an in-memory File class and write map); the right approach is to wire that substitution through an injected interface rather than replacing the module at import time. The document/URL browser globals are harder — either extract triggerBrowserBytesDownload behind a port the test can inject, or make the test an integration test against the real DOM.

Rule Used: # Code Review Pattern Reference: Slop, Tests, Feat... (source)

Comment on lines +356 to +364
const file = await input.requestFileBytes(input.path);
if (file.size > MAX_WEBSOCKET_DOWNLOAD_BYTES) {
throw new Error(
i18n.t("downloads.websocketTooLarge", {
size: formatDownloadSize(MAX_WEBSOCKET_DOWNLOAD_BYTES),
}),
);
}
if (file.bytes.byteLength !== file.size) {

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 Post-read size cap checks server-reported file.size, not actual bytes

file.size comes from the daemon's WS response and can disagree with file.bytes.byteLength. If the daemon sends 60 MB of bytes but reports size: 3, this guard passes and the bytes are already resident in memory. The effective memory cap is only the pre-read expectedSize check (which is skipped when the token response omits a size). Guarding against file.bytes.byteLength catches the actual allocation regardless of what the server claims.

Suggested change
const file = await input.requestFileBytes(input.path);
if (file.size > MAX_WEBSOCKET_DOWNLOAD_BYTES) {
throw new Error(
i18n.t("downloads.websocketTooLarge", {
size: formatDownloadSize(MAX_WEBSOCKET_DOWNLOAD_BYTES),
}),
);
}
if (file.bytes.byteLength !== file.size) {
const file = await input.requestFileBytes(input.path);
if (file.bytes.byteLength > MAX_WEBSOCKET_DOWNLOAD_BYTES) {
throw new Error(
i18n.t("downloads.websocketTooLarge", {
size: formatDownloadSize(MAX_WEBSOCKET_DOWNLOAD_BYTES),
}),
);
}
if (file.bytes.byteLength !== file.size) {

Comment on lines 100 to 121
try {
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId);
const tokenResponse = await requestFileDownloadToken(path);
if (tokenResponse.error || !tokenResponse.token) {
throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed"));
}

const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
const resolvedFileName = tokenResponse.fileName ?? fileName;
const expectedSize = tokenResponse.size;

if (!downloadTarget.baseUrl) {
throw new Error(i18n.t("downloads.hostUnavailable"));
await downloadViaFileBytes({
id,
path,
fileName: resolvedFileName,
expectedSize,
requestFileBytes,
completeDownload: get().completeDownload,
updateProgress: get().updateProgress,
});
return;
}

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 Token request is always made before the WebSocket path executes

requestFileDownloadToken is called unconditionally, even when downloadTarget.baseUrl is null and the code will take the WS fallback. The token is used only to populate resolvedFileName and expectedSize — not for authorization on the WS call. If the token endpoint is unavailable (e.g., the daemon's HTTP API is wholly unreachable, not just the download host), this will fail with a misleading "request token failed" error instead of falling back cleanly to WS. Consider whether requestFileBytes should carry filename and size information directly, or whether the token request should be made lazily only when the direct path is taken.

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