Skip to content

fix(listen): fix WebSocket HTTP/2 and path glob encoding for relayfile listen#334

Merged
khaliqgant merged 6 commits into
mainfrom
pear/relayfiletests-c0f635be
Jun 23, 2026
Merged

fix(listen): fix WebSocket HTTP/2 and path glob encoding for relayfile listen#334
khaliqgant merged 6 commits into
mainfrom
pear/relayfiletests-c0f635be

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

Two bug fixes for the relayfile listen command (PR #332) discovered during E2E testing against Linear:

  • BUG 1: WebSocket connection fails when the server negotiates HTTP/2 via ALPN TLS. The Upgrade: websocket header is incompatible with HTTP/2 framing. Fix: clone the default transport and set TLSNextProto = make(map[...]) to disable h2 negotiation, forcing HTTP/1.1 for the WS dial.

  • BUG 7: Path filter glob characters (/, *, ?) are percent-encoded by url.Values.Encode(), causing /linear/** to arrive at the server as %2Flinear%2F%2A%2A which matches nothing. Fix: add wsEncodeGlob() helper that preserves these characters as literals while encoding everything else, then build the raw query string manually.

Test plan

  • Build passes: go build ./cmd/relayfile-cli/
  • E2E verified: relayfile listen --provider linear connects successfully and filters to /linear/** events only
  • Multiple writebacks confirmed: writeback.succeeded + file.updated events stream correctly with path filter active
  • No GitHub events appear when using --provider linear filter (before fix they appeared due to encoding issue)

Follow-up (separate PRs/issues)

  • BUG 8 (from=now server-side replay): fixed in paired cloud branch pear/relayfiletests-c0f635be
  • Nango webhook subscription not registered for Linear connection — blocks live by-state/by-uuid alias events after writeback; needs create-webhook.ts action to be run

🤖 Generated with Claude Code

Review in cubic

khaliqgant and others added 2 commits June 23, 2026 11:48
api.relayfile.dev negotiates HTTP/2 via ALPN; the Upgrade header
used by nhooyr.io/websocket is rejected over h2. Cloning the
default transport and clearing TLSNextProto forces HTTP/1.1 for
the WebSocket dial only, leaving all other CLI calls unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ob helper

url.Values.Encode() percent-encodes /, *, and ? in path globs, causing the
server-side glob matcher to receive a literal "%2Flinear%2F%2A%2A" and match
nothing. Replace with manual raw query building via wsEncodeGlob(), which
preserves /, *, ? as literal characters while still encoding everything else.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@khaliqgant, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 12 minutes and 35 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5392674e-f26f-4356-b191-af63b9f43e5b

📥 Commits

Reviewing files that changed from the base of the PR and between de3bbf5 and 02c8803.

📒 Files selected for processing (2)
  • cmd/relayfile-cli/listen_test.go
  • cmd/relayfile-cli/main.go
📝 Walkthrough

Walkthrough

This PR adds a websocket-based listen/watch/dev subcommand with background daemon and supervisor support, refactors delegated credential storage paths and scope derivation (removing sentinel errors), simplifies mount-loop degraded recovery and error handling, removes --wait-sync from integration connect, removes runtime-status overlay from integration list, and updates CLI routing, aliases, and help text throughout.

Changes

CLI Feature and Auth Refactor

Layer / File(s) Summary
CLI routing, alias, and help text updates
cmd/relayfile-cli/main.go
Updates top-level command dispatcher to add listen, dev, stop, logs, supervisor routes and remove legacy on, off, logout aliases. Updates printHelpForArgs, printMountHelp, printUsage, and commandHasMountSubcommand to reflect all new/removed verbs.
listen/watch/dev websocket streaming
cmd/relayfile-cli/main.go
Adds crypto/tls and websocket imports, defines listenEvent wire struct, implements wsEncodeGlob, runListen (websocket connect, event filtering/templating, background mode), runDev onboarding wrapper, printListenUsage, and supervisor helpers for systemd (Linux) and launchd (macOS).
integration connect/list simplification
cmd/relayfile-cli/main.go
Removes --wait-sync flag from integration connect, making waitForInitialSync unconditional. Removes overlayIntegrationListRuntimeStatus and its call site from integration list. Updates connected message to show provider-specific remote root.
Writeback delegation scope refactor
cmd/relayfile-cli/main.go
Splits writebackPushScopes into writebackPushJoinScopes and writebackPushRequiredRelayfileScopes, removing provider-ID validation and error return from scope derivation. Updates the push/update/delete entry point accordingly.
Delegated credential storage and scope refactor
cmd/relayfile-cli/main.go
Changes bootstrap join scopes to only fs:read/fs:write. Replaces shard-key path derivation with delegatedCredentialsWorkspaceKey + delegatedCredentialsScopeKey. Simplifies loadDelegatedCredentialsForRequest legacy fallback to scope-satisfaction gating. Removes ErrDelegatedScopeInsufficient/ErrDelegatedScopeInvalid sentinel errors and clearAuthCredentials.
Mount-loop auth, degraded recovery, and error handling
cmd/relayfile-cli/main.go
Simplifies isMountCredentialExpired to only cloud refresh and delegated-credential expiry checks. Replaces mapDelegatedTokenCloudError with direct wrapping. Refactors degraded constants to degradedRecoveryInterval + stall-reason, adjusts recovery-notice cadence, removes SetCredentialExpiry from auth refresh, and updates the degraded recovery-fail branch to use isMountCredentialExpired for snapshot/return decisions.
Status formatting, stop/restart, tree/read output
cmd/relayfile-cli/main.go
Adds humanized started time to status output, adjusts stall-reason and credential-freshness helpers, updates runStop/runRestart parsing. Fixes runTree default path from remotePath, refactors runRead for JSON/text output variants, removes looksLikeRemotePathArg.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • AgentWorkforce/relayfile#229: Introduced the on/off legacy aliases for mount/start/stop and updated related help/usage output—this PR removes those same aliases.
  • AgentWorkforce/relayfile#294: Made earlier coordinated changes to delegated credential minting, workspace-scoped credential paths, and scope-satisfaction validation in the same file—this PR continues that refactor by replacing the shard-key scheme and removing sentinel scope errors.

Poem

🐇 Hoppity hop through the socket stream,
listen and watch — a websocket dream!
Old aliases gone, the scopes trimmed right,
Credentials stored by key and by might.
The degraded loop now knows when to rest,
A cleaner CLI — the rabbit's best! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the primary fixes: WebSocket HTTP/2 compatibility and path glob encoding for the relayfile listen command.
Description check ✅ Passed The description is well-related to the changeset, detailing two specific bug fixes with clear explanations of the problems and solutions implemented.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pear/relayfiletests-c0f635be

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces the listen and dev commands to stream live file events from a workspace over WebSockets, along with a supervisor command to manage the listener as a system service (systemd/launchd). It also removes several legacy commands (such as logout), simplifies delegated credential resolution, and removes exponential backoff for degraded mount recovery. The review feedback highlights a critical command injection vulnerability in the command execution template expansion, where untrusted event fields are not shell-escaped. Additionally, a potential panic was identified due to an unsafe type assertion on http.DefaultTransport when setting up the WebSocket client.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread cmd/relayfile-cli/main.go
Comment on lines +5968 to +5976
func listenExpandTemplate(tmpl string, evt listenEvent, raw json.RawMessage) string {
return strings.NewReplacer(
"{{path}}", evt.Path,
"{{type}}", evt.Type,
"{{provider}}", evt.Provider,
"{{revision}}", evt.Revision,
"{{event}}", string(raw),
).Replace(tmpl)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-critical critical

Executing user-defined commands constructed via simple string replacement of untrusted event fields (like evt.Path or raw JSON) leads to a command injection vulnerability. A malicious file path or event payload containing shell metacharacters (e.g., backticks, semicolons, or subshells) could execute arbitrary commands on the host system.

To prevent this, shell-escape all replaced template variables before executing them via sh -c.

func shellEscape(s string) string {
	if s == "" {
		return "''"
	}
	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}

func listenExpandTemplate(tmpl string, evt listenEvent, raw json.RawMessage) string {
	return strings.NewReplacer(
		"{{path}}", shellEscape(evt.Path),
		"{{type}}", shellEscape(evt.Type),
		"{{provider}}", shellEscape(evt.Provider),
		"{{revision}}", shellEscape(evt.Revision),
		"{{event}}", shellEscape(string(raw)),
	).Replace(tmpl)
}

Comment thread cmd/relayfile-cli/main.go
Comment on lines +5829 to +5831
wsTransport := http.DefaultTransport.(*http.Transport).Clone()
wsTransport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper)
wsHTTPClient := &http.Client{Transport: wsTransport}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The type assertion http.DefaultTransport.(*http.Transport) can panic if http.DefaultTransport has been wrapped or replaced by another http.RoundTripper implementation (e.g., by APM, tracing, or logging libraries). Use a comma-ok type assertion to safely handle cases where http.DefaultTransport is not a concrete *http.Transport.

var wsTransport *http.Transport
if t, ok := http.DefaultTransport.(*http.Transport); ok {
	wsTransport = t.Clone()
} else {
	wsTransport = &http.Transport{}
}
wsTransport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper)
wsHTTPClient := &http.Client{Transport: wsTransport}

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Relayfile Eval Review

Run: .relayfile/evals/runs/2026-06-23T10-53-57-734Z-HEAD-provider
Mode: provider
Git SHA: 3dc7e04

Passed: 4 | Needs human: 0 | Reviewable: 0 | Missing output: 0 | Failed: 0 | Skipped: 0

Human Review Cases

No reviewable human-review cases captured Relayfile output.

…kage

The server-side glob filter is not applied to the historical-event replay
(last 100 events sent on connection); without client-side enforcement,
events for other providers leak through until the from=now server fix is
deployed.  Add matchListenPath (handles /** double-star suffix) and apply
it in the receive loop after the existing type filter.  Add unit tests for
matchListenPath and wsEncodeGlob.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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: de3bbf59e0

ℹ️ 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".

Comment thread cmd/relayfile-cli/main.go Outdated
// server-side reconcile kicks. Both must be present on every credential
// so narrow prior credentials cannot silently break a subset of providers.
var defaultJoinScopes = []string{"fs:read", "fs:write", "ops:read", "sync:trigger"}
var defaultJoinScopes = []string{"fs:read", "fs:write"}

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 Badge Restore operational scopes to joined credentials

With only fs:read/fs:write minted here, commands that reuse defaultJoinScopes no longer have the scopes required by their own runtime calls: for example runPull posts to /v1/workspaces/{id}/sync/refresh, and internal/httpapi/server.go requires sync:trigger for that route; mount/writeback status polling similarly needs ops:read for /ops/{opId}. Newly bootstrapped credentials will therefore hit 403s in these flows even though the credential was minted by the CLI.

Useful? React with 👍 / 👎.

Comment thread cmd/relayfile-cli/main.go
if !ok {
return false
childArgs := append([]string{"listen"}, filtered...)
childArgs = append(childArgs, "--daemonized", "--pid-file", pidFile)

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 Do not pass an undefined flag to background listeners

When users run relayfile listen --background, the parent starts a child with --pid-file, but runListen does not define or accept that flag, so the daemonized child exits during flag parsing while the parent still prints that listening started. This makes the advertised background mode fail immediately.

Useful? React with 👍 / 👎.

Comment thread cmd/relayfile-cli/main.go
"background": false, "daemonized": false,
}))

commandClient, err := prepareWorkspaceCommandClient("", *serverPeek, *tokenPeek, defaultInspectScopes)

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 Use the positional workspace during dev preflight

For relayfile dev OTHER_WORKSPACE ..., this preflight always prepares credentials for the active workspace ("") instead of the positional workspace that runListen(args) would later parse. If the active workspace is unset or has different delegated credentials, dev aborts with onboarding/credential errors even though the explicit workspace argument is valid.

Useful? React with 👍 / 👎.

@agent-relay-code

Copy link
Copy Markdown
Contributor

Review: PR #334 — relayfile listen/dev/supervisor CLI + auth/scope simplification

Summary

This PR adds the listen/watch/dev event-streaming commands (WebSocket to /fs/ws) and rewrites supervisor to manage the listen daemon. Alongside that it removes a number of things: the logout, on, off aliases; the --wait-sync flag (connect now always waits); the syncProviderStatus.Ready field and overlayIntegrationListRuntimeStatus; the delegated-scope error classifier (mapDelegatedTokenCloudError, ErrDelegatedScopeInsufficient, ErrDelegatedScopeInvalid); the writeback writebackPushScopes/writebackPushProvider helpers (replaced by validation-free variants); the legacy delegated-credential shard/alias migration; and the degraded-mode exponential backoff.

The production binaries (relayfile, relayfile-mount, relayfile-cli) build cleanly, and every package except cmd/relayfile-cli passes go test. However CI will fail, and there are several semantic/safety regressions. I made no file edits — every issue found is semantic, safety-critical, or a test-coverage decision that requires human judgment, so per the review rules I left the working tree unchanged.

Blocking findings

1. CI is RED — cmd/relayfile-cli test package does not compile

The PR deleted production functions but left the tests that reference them. go test ./... fails to build:

delegated_token_error_test.go:31  undefined: ErrDelegatedScopeInvalid
delegated_token_error_test.go:37  undefined: ErrDelegatedScopeInsufficient
delegated_token_error_test.go:68  undefined: mapDelegatedTokenCloudError
main_test.go:2755,2769            undefined: writebackPushScopes
main_test.go:2901                 undefined: canonicalWorkspaceShardValueStatus
main_test.go:2904                 undefined: workspaceShardKey, rawWorkspaceShardKey

(verified locally with Go 1.22 — the CI version — via go test ./cmd/relayfile-cli/.)

I did not delete or rewrite these tests. Removing them silently drops coverage of fail-closed behavior (see findings #2 and #3), and changing what's tested is a human decision. The author needs to decide, per orphaned test, whether the removed behavior is intentionally gone (delete the test + acknowledge the coverage/behavior loss) or was dropped by mistake (restore the production code). This must be resolved before merge.

2. Safety regression: delegated-scope errors are no longer fail-closed (relates to the deleted delegated_token_error_test.go)

Previously, mapDelegatedTokenCloudError classified invalid_scope and scope_insufficient as permanent errors that pause for a human (isMountCredentialExpired → stop retrying). The PR removes that classifier and reduces isMountCredentialExpired to only ErrCloudRefreshExpired || ErrDelegatedRelayfileCredentialsExpired. Now a 400 invalid_scope (which re-sends the same bad scopes every retry and can never succeed) falls into the transient retry path — exactly the retry-storm the deleted test was guarding against (TestMapDelegatedTokenCloudError, "storm-retry fix"). This is a fail-closed → fail-open change. I left it unchanged; if dropping this classification is intentional, please call out the retry-storm trade-off explicitly; otherwise the classifier (and its test) should be restored.

3. Scope-broadening regression in writeback push scopes (relates to the deleted writebackPushScopes test)

Old writebackPushScopes rejected malformed/non-provider paths with an error and validated the provider via validateLocalProviderID, never minting whole-tree scopes. The new writebackPushJoinScopes/writebackPushRequiredRelayfileScopes (main.go:3488–3509):

  • For an empty/root path, return whole-tree fs:write:/** and relayfile:fs:write:/**.
  • For any non-empty first segment, return fs:write:/<seg>/** with no validateLocalProviderID check.

For writeback push mode there is no isCanonicalWritebackTargetPath guard (main.go:3040 only guards non-push modes), so a push with an empty/root remote path now mints whole-tree write credentials instead of erroring. The deleted test (main_test.go:2768–2778) specifically asserted these inputs are rejected and never get /**. This is a privilege-broadening change; I left it unchanged for a human to confirm or correct.

4. relayfile listen --background spawns a child that immediately dies (new feature bug — lifecycle code, not auto-edited)

spawnBackgroundListenProcess (main.go:5946) appends --daemonized --pid-file <path> to the child args, but runListen's FlagSet (main.go:5760–5770) does not define --pid-file. The standard flag parse therefore fails in the daemonized child:

flag provided but not defined: -pid-file

(reproduced with a standalone harness using the repo's own normalizeFlagArgs.) The parent prints "Listen started in background" while the child exits non-zero, so background listen never runs. Additionally, the daemonized path never actually writes listen.pid, and there's no listen stop, so the pid file is unused even if parsed. This is process-spawn/lifecycle code, so I did not edit it — it needs a human patch (either define/consume --pid-file in runListen or stop passing it).

Advisory Notes

  • The bulk of the +1.1k/−1.1k diff is functions being relocated within main.go (e.g. runTree, runRead, runSeed, the status/stop/restart block, supervisor helpers) rather than changed; the substantive logic deltas are the items above plus the removal of degraded-mode exponential backoff in runMountLoop and the removal of Syncer.SetCredentialExpiry calls. The backoff removal (reverting to a flat 1-minute recovery cadence) is a behavior change in mount-loop recovery — out of scope to fix here, flagging for awareness.
  • Syncer.SetCredentialExpiry is now dead (no callers) in internal/mountsync/syncer.go but still exported; harmless, not a build error. Cleanup is optional and out of scope for this PR.
  • openapi//contract surface is unaffected — this PR touches only the CLI, no internal/httpapi handlers.

Addressed comments

  • No bot or human reviewer comments were present in the provided PR context (.workforce/context.json carried no comment threads, and .workforce/ contained only the diff, changed-files list, and context). Nothing to reconcile.

Verification performed

Working tree left unchanged (verified clean); no tests added, modified, or weakened.

CI is failing (test package does not compile) and there are unresolved fail-open/safety regressions, so this PR is not ready for a human merge decision yet.

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/relayfile-cli/main.go`:
- Around line 5588-5603: The help text for the listen command in the
printListenUsage function contains an incorrect reference to the --listen flag,
which is not actually parsed by the supervisor or listen commands and will cause
failures if users follow this help. Remove the --listen flag from the help
documentation. Additionally, the --background flag is supported by the listen
command but is not currently documented in the printListenUsage function. Add
the --background flag to the Flags section of the help text to accurately
reflect all supported options.
- Around line 3043-3044: The provider segment (parts[0]) from the path is being
used directly in glob-style scope strings without validation, which could allow
unsafe patterns like *, ?, or .. to create overly broad or invalid scopes.
Before calling writebackPushJoinScopes and writebackPushRequiredRelayfileScopes
(and before ensureWritebackDelegatedCredentials), add validation to check that
the provider segment is safe and does not contain glob patterns or path
traversal sequences. Return an error immediately if the provider segment is
invalid, rejecting it before any delegated write scopes are derived. Apply this
same validation logic to the similar code block at lines 3488-3508.
- Around line 5820-5822: The websocket URL containing the sensitive token
parameter (appended to base.RawQuery) could be exposed in dial error messages
that get logged or persisted. When handling the dial error around line 5840
(where the error from the websocket connection is wrapped or returned), redact
the token parameter from the URL string before including it in the error
message, or return a sanitized error that does not include the full request URL.
This prevents the bearer token from being accidentally printed or persisted in
CLI or background logs.
- Around line 5884-5890: The listenExpandTemplate function is directly
interpolating event-controlled values like {{path}} and {{event}} into the shell
command string passed to sh -c, creating a shell injection vulnerability where
crafted paths or event data could escape the template and execute arbitrary
commands. Instead of embedding these values directly in the command string, pass
the event fields via environment variables that the sh command can reference
(using cmd.Env with the exec.CommandContext call), and update
listenExpandTemplate and any related help documentation to use environment
variable references instead of direct {{path}} and {{event}} substitutions. This
applies to both the main runCmd execution block and similar patterns elsewhere
in the file.
- Around line 5758-5770: The runListen function passes a --pid-file flag to the
daemonized child process at line 5946, but this flag is never defined in the
flagset fs, causing the child to fail during flag parsing while the parent
reports success. Add a flag definition for --pid-file to the runListen function
using fs.String (similar to how "background" and "daemonized" are defined), and
also add "pid-file" to the normalizeFlagArgs map passed to fs.Parse to ensure
the flag is properly recognized during argument parsing.
- Around line 5773-5776: The runListen function currently silently ignores extra
positional arguments beyond the first one. After extracting the workspaceValue
using fs.Arg(0), add validation to check if fs.NArg() is greater than 1, and if
so, return an error message to the user rejecting the extra positional
arguments. This ensures users are immediately alerted if they accidentally
provide multiple workspace arguments instead of being silently ignored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e094911-28f1-47e0-b30c-d545080f9ac6

📥 Commits

Reviewing files that changed from the base of the PR and between 60d54c6 and de3bbf5.

📒 Files selected for processing (1)
  • cmd/relayfile-cli/main.go

Comment thread cmd/relayfile-cli/main.go Outdated
Comment thread cmd/relayfile-cli/main.go
Comment thread cmd/relayfile-cli/main.go
Comment on lines +5758 to 5770
background := fs.Bool("background", false, "run in background; logs to ~/.relayfile/listen.log")
daemonized := fs.Bool("daemonized", false, "internal flag used by relayfile listen --background")
if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
"server": true,
"token": true,
"path": true,
"depth": true,
"json": false,
"server": true,
"token": true,
"provider": true,
"path": true,
"event": true,
"run": true,
"format": true,
"background": false,
"daemonized": false,
})); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Do not pass an undefined --pid-file flag to the daemonized child.

Line 5946 appends --pid-file, but runListen does not define that flag. The child exits during flag parsing, so relayfile listen --background can report success while the daemon immediately fails in the log.

Proposed hidden flag fix
 	background := fs.Bool("background", false, "run in background; logs to ~/.relayfile/listen.log")
 	daemonized := fs.Bool("daemonized", false, "internal flag used by relayfile listen --background")
+	pidFile := fs.String("pid-file", "", "internal flag used by relayfile listen --background")
 	if err := fs.Parse(normalizeFlagArgs(args, map[string]bool{
@@
 		"background": false,
 		"daemonized": false,
+		"pid-file":   true,
 	})); err != nil {
@@
 	if *daemonized {
+		if p := strings.TrimSpace(*pidFile); p != "" {
+			if err := os.WriteFile(p, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
+				return fmt.Errorf("write listen pid file: %w", err)
+			}
+			defer os.Remove(p)
+		}
 		if err := rotateLogFile(listenLogFile()); err != nil {
 			return err
 		}
 	}

Also applies to: 5783-5787, 5945-5946

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/relayfile-cli/main.go` around lines 5758 - 5770, The runListen function
passes a --pid-file flag to the daemonized child process at line 5946, but this
flag is never defined in the flagset fs, causing the child to fail during flag
parsing while the parent reports success. Add a flag definition for --pid-file
to the runListen function using fs.String (similar to how "background" and
"daemonized" are defined), and also add "pid-file" to the normalizeFlagArgs map
passed to fs.Parse to ensure the flag is properly recognized during argument
parsing.

Comment thread cmd/relayfile-cli/main.go
Comment on lines +5773 to 5776
var workspaceValue string
if fs.NArg() > 0 {
workspaceValue = strings.TrimSpace(fs.Arg(0))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject extra listen positional arguments.

runListen only consumes fs.Arg(0) and silently ignores the rest, so typos like relayfile listen workspace-a workspace-b run against workspace-a without warning.

Proposed validation
 	var workspaceValue string
+	if fs.NArg() > 1 {
+		return errors.New("usage: relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]")
+	}
 	if fs.NArg() > 0 {
 		workspaceValue = strings.TrimSpace(fs.Arg(0))
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var workspaceValue string
if fs.NArg() > 0 {
workspaceValue = strings.TrimSpace(fs.Arg(0))
}
var workspaceValue string
if fs.NArg() > 1 {
return errors.New("usage: relayfile listen [WORKSPACE] [--provider PROVIDER] [--path GLOB] [--event TYPE] [--run CMD] [--format text|json] [--background]")
}
if fs.NArg() > 0 {
workspaceValue = strings.TrimSpace(fs.Arg(0))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/relayfile-cli/main.go` around lines 5773 - 5776, The runListen function
currently silently ignores extra positional arguments beyond the first one.
After extracting the workspaceValue using fs.Arg(0), add validation to check if
fs.NArg() is greater than 1, and if so, return an error message to the user
rejecting the extra positional arguments. This ensures users are immediately
alerted if they accidentally provide multiple workspace arguments instead of
being silently ignored.

Comment thread cmd/relayfile-cli/main.go
Comment on lines +5820 to +5822
// Token in query param for WS upgrade (server does not yet support Authorization on upgrade).
rawParts = append(rawParts, "token="+url.QueryEscape(commandClient.client.token))
base.RawQuery = strings.Join(rawParts, "&")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact the websocket query token from dial errors.

base.RawQuery contains token=..., and dial failures can include the request URL in the returned error. Wrapping that error at Line 5840 can print or persist the bearer token in CLI/background logs.

Redact token from the error string before returning it, or return a sanitized connection error that does not include the URL-bearing wrapped error.

Also applies to: 5833-5840

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/relayfile-cli/main.go` around lines 5820 - 5822, The websocket URL
containing the sensitive token parameter (appended to base.RawQuery) could be
exposed in dial error messages that get logged or persisted. When handling the
dial error around line 5840 (where the error from the websocket connection is
wrapped or returned), redact the token parameter from the URL string before
including it in the error message, or return a sanitized error that does not
include the full request URL. This prevents the bearer token from being
accidentally printed or persisted in CLI or background logs.

Comment thread cmd/relayfile-cli/main.go
Comment on lines +5884 to +5890
if runCmd != "" {
expanded := listenExpandTemplate(runCmd, evt, raw)
cmd := exec.CommandContext(rootCtx, "sh", "-c", expanded)
cmd.Stdout = stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintf(os.Stderr, "run error for %s: %v\n", evt.Path, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Keep provider event data out of sh -c templates.

{{path}} and {{event}} are event-controlled values inserted before sh -c; a crafted path can escape the intended command template. Pass event fields via environment/stdin, or centrally shell-escape placeholders before invoking the shell.

Safer execution shape
-			expanded := listenExpandTemplate(runCmd, evt, raw)
-			cmd := exec.CommandContext(rootCtx, "sh", "-c", expanded)
+			cmd := exec.CommandContext(rootCtx, "sh", "-c", runCmd)
+			cmd.Env = append(os.Environ(),
+				"RELAYFILE_EVENT_PATH="+evt.Path,
+				"RELAYFILE_EVENT_TYPE="+evt.Type,
+				"RELAYFILE_EVENT_PROVIDER="+evt.Provider,
+				"RELAYFILE_EVENT_REVISION="+evt.Revision,
+				"RELAYFILE_EVENT_JSON="+string(raw),
+			)
 			cmd.Stdout = stdout
 			cmd.Stderr = os.Stderr

Update the help examples to reference these environment variables instead of raw {{...}} substitutions.

Also applies to: 5968-5975

🧰 Tools
🪛 OpenGrep (1.23.0)

[ERROR] 5886-5886: Dynamic command passed to exec.Command with a shell invocation. Pass arguments directly to exec.Command without a shell wrapper.

(coderabbit.command-injection.go-exec-command)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/relayfile-cli/main.go` around lines 5884 - 5890, The listenExpandTemplate
function is directly interpolating event-controlled values like {{path}} and
{{event}} into the shell command string passed to sh -c, creating a shell
injection vulnerability where crafted paths or event data could escape the
template and execute arbitrary commands. Instead of embedding these values
directly in the command string, pass the event fields via environment variables
that the sh command can reference (using cmd.Env with the exec.CommandContext
call), and update listenExpandTemplate and any related help documentation to use
environment variable references instead of direct {{path}} and {{event}}
substitutions. This applies to both the main runCmd execution block and similar
patterns elsewhere in the file.

Source: Linters/SAST tools

khaliqgant and others added 2 commits June 23, 2026 12:17
…rom listen branch

The initial listen-command commit used a version of main.go that predated
several additions merged to main:
- ErrDelegatedScopeInsufficient / ErrDelegatedScopeInvalid + mapDelegatedTokenCloudError
  (storm-retry fix for bad delegated token scopes, tested by delegated_token_error_test.go)
- writebackPushScopes / writebackPushProvider
  (provider-scoped scope derivation, tested by main_test.go)
- workspaceShardKey / rawWorkspaceShardKey / canonicalWorkspaceShardValueStatus / workspaceRecordForShardValue
  (delegated credential cache keying, tested by main_test.go)

Restores all removed symbols so the test package compiles and passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@agent-relay-code

Copy link
Copy Markdown
Contributor

Review: PR #334relayfile listen/watch/dev/supervisor event streaming

Summary

The PR's stated purpose is a new event-streaming surface: listen/watch, the dev onboarding wrapper, a rewritten supervisor (listen daemon), plus listen_test.go. That feature code itself builds and its two new tests pass (after one mechanical fix below).

However, the diff also deletes a large amount of unrelated, partly safety-critical code that is still exercised by existing, unmodified tests. The branch history even contains ed2b2a7 "restore deleted error sentinels and shard helpers missing from listen branch", confirming the listen branch dropped things it shouldn't. The result: go test ./cmd/relayfile-cli/ is red with 14 failing tests. I verified this with the repo's own toolchain (go build ./... ✓, go vet ./... ✓, go test ./cmd/relayfile-cli/ ✗).

I auto-applied only one mechanical fix (a space-encoding mismatch in the new wsEncodeGlob). Everything else is a semantic/safety/scope decision and is left for a human — I did not edit tests or revert behavior.

Auto-applied fix (mechanical, non-semantic)

  • wsEncodeGlob space encodingcmd/relayfile-cli/main.go:5827. The new TestWsEncodeGlob expects /foo bar/**/foo%20bar/**, but url.QueryEscape emits + (/foo+bar/**), so the test failed. I verified against a throwaway fixture that url.Query() decodes both + and %20 to a space, so server-side the path filter is identical — the change is purely a more-robust encoding, not a behavior change. Fixed by rewriting the escaped + to %20. TestWsEncodeGlob and TestMatchListenPath now pass.

Blocking issues (NOT fixed — require human decision; tests must not be weakened)

These 14 failures all come from the PR removing production behavior that existing tests still assert. They cannot be fixed mechanically, and per policy I will not delete/loosen the tests or make safety-weakening reverts.

  • Coarse-scope default narrowed (safety / fail-closed regression). defaultJoinScopes went from {fs:read, fs:write, ops:read, sync:trigger} to {fs:read, fs:write} (main.go:58). The removed comment explicitly warned these "must be present on every credential so narrow prior credentials cannot silently break a subset of providers." Dropping ops:read breaks writeback op-status polling and sync:trigger breaks reconcile kicks. Fails TestDelegatedRelayfileTokenViaCloudDefaultsToCoarseScopes. Leave the default unchanged.
  • logout command removed — fails TestLogoutRejectsExtraArguments, TestLogoutClearsAuthCredentialsOnly, TestLogoutIsIdempotentWhenNoCredentialsExist, TestHelpFlagPrintsUsageForCommandsAndSubcommands/logout. Out of scope for a listen feature.
  • on/off aliases removed — fails TestOnOffAliasesDispatchLikeMountAndStop, TestHelpFlag.../on_alias + /off_alias, TestMountDaemonCommandMatchesAliasesAndLocalDirBoundaries.
  • --wait-sync removed and integration connect now always polls sync — fails TestIntegrationConnectWaitSyncPreservesInitialSyncGate, TestIntegrationConnectKeepsSelectedWorkspaceAndUsesJoinedRelayWorkspaceForSync, and TestIntegrationConnectRefreshesCloudAccessTokenAndReusesWorkspace (which asserts connect must NOT poll unless --wait-sync). This is a behavior change to the connect flow.
  • overlayIntegrationListRuntimeStatus removed — fails TestIntegrationListOverlaysRuntimeReadyStatus, TestIntegrationListUsesSavedWorkspaceScopesForRuntimeStatus.
  • Delegated-credential shard keying + legacy-shard migration removed — fails TestDelegatedCredentialsPathCanonicalizesWorkspaceAliases (shard key changed) and TestLoadDelegatedCredentialsMigratesLegacyRelayWorkspaceShard (migration path deleted, so existing creds under the old shard are no longer found). This is a credential-resolution change with upgrade-compatibility implications.

Advisory Notes (out of this PR's scope / safety-critical — flag, do not change here)

  • Degraded-mount credential recovery lost its exponential backoff. runMountLoop (main.go:~10216, ~10344) now calls refreshMountAuth(true) every cycle, gated only by a 1-minute notice cadence; the removed code backed off 30s→10m specifically to "avoid hammering the auth endpoint if the operator session is truly gone." This is in the credential-refresh/degraded-lifecycle path and reached only by mount/restart, not by listen. Reintroduces an auth-endpoint retry storm. Should be handled in a human-authored patch, not folded into the listen PR.
  • Delegated-token cloud errors no longer classified for retry. delegatedRelayfileTokenViaCloud was changed (main.go:1746) from mapDelegatedTokenCloudError(err) to a plain fmt.Errorf(...%w). mapDelegatedTokenCloudError (and its TestMapDelegatedTokenCloudError, which still passes) marks invalid_scope/scope_insufficient/needs_reauth as permanent — the test even labels it the "storm-retry fix." Bypassing it makes those permanent failures look transient again. The function is now production-dead. Re-wire the classifier or remove it deliberately in a separate change.
  • Scope cleanliness. The listen feature could ship on its own; the removals of logout, on/off, --wait-sync, the runtime-status overlay, the credential-migration helpers, and the backoff are unrelated and are what turns CI red. Recommend splitting them out (or restoring them) so the listen feature is reviewable in isolation.

Addressed comments

No prior bot or human review comments were present in .workforce/ (only pr.diff, changed-files.txt, context.json; context.json lists no review threads). Nothing stale to validate.

Verification performed

  • go build ./... → exit 0
  • go vet ./... → clean
  • go test ./cmd/relayfile-cli/ -run TestMatchListenPath|TestWsEncodeGlob → PASS (after my fix)
  • go test ./cmd/relayfile-cli/FAIL, 14 tests (listed above), all from PR removals, none introduced by my edit
  • Confirmed only working-tree change is the one-line wsEncodeGlob fix (git status: M cmd/relayfile-cli/main.go).

The PR cannot merge as-is: the full package test suite is red because of intentional-looking but unguarded removals that existing tests still enforce. Resolving them requires human decisions (restore the removed behavior, or remove the corresponding tests), and the two safety regressions (scope narrowing, degraded backoff) must not be merged. I am not printing READY because required tests are failing.

…cleanly

The initial listen-command commit replaced main.go with an older branch
version, silently dropping logout, on/off aliases, --wait-sync, integration
list runtime status, degraded-backoff, and other features added since the
branch diverged.

Reset main.go to origin/main (10275-line baseline with all current features),
then grafted the listen-specific additions on top:
- crypto/tls + nhooyr.io/websocket imports
- listenEvent struct
- matchListenPath — double-star glob matching for client-side path filtering
- wsEncodeGlob — preserves / * ? in WS query params (BUG 7 fix)
- runListen — HTTP/1.1 forced via TLSNextProto (BUG 1 fix), from=now, client filter (BUG 9 fix)
- runDev — thin wrapper around runListen with setup hint
- spawnBackgroundListenProcess, listenExpandTemplate, listenPIDFile, listenLogFile
- listen/watch and dev dispatch in run(), help cases for both

All existing tests pass; TestWsEncodeGlob and TestMatchListenPath also pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@khaliqgant khaliqgant merged commit 7a48d06 into main Jun 23, 2026
9 checks passed
@khaliqgant khaliqgant deleted the pear/relayfiletests-c0f635be branch June 23, 2026 10:56
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