From 91497a25329747b0e209260d517ecf2c228b4db7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 13:24:13 +0200 Subject: [PATCH 01/14] feat(cli): use Agent Relay SDK for self-serve setup --- README.md | 19 +- cmd/relayfile-cli/main.go | 58 +- cmd/relayfile-cli/main_test.go | 57 + docs/cli-design.md | 43 +- docs/guides/cloud-integration.md | 16 +- docs/guides/getting-started.md | 10 +- docs/guides/vfs-cloud-setup.md | 27 +- docs/productized-cloud-mount-contract.md | 63 +- package.json | 2 +- packages/cli/CHANGELOG.md | 4 +- packages/cli/package.json | 1 + packages/cli/scripts/cloud-auth.cjs | 1176 ++++++++++++++++++ packages/cli/scripts/cloud-preflight.js | 101 ++ packages/cli/scripts/cloud-preflight.test.js | 131 ++ packages/cli/scripts/run.js | 85 +- 15 files changed, 1682 insertions(+), 111 deletions(-) create mode 100644 packages/cli/scripts/cloud-auth.cjs create mode 100644 packages/cli/scripts/cloud-preflight.js create mode 100644 packages/cli/scripts/cloud-preflight.test.js diff --git a/README.md b/README.md index f45a724d..cb3875ac 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ That's the entire interface. No new SDK to learn, no MCP schemas eating your con ## Quick paths -- **Hosted integrations:** `npx relayfile setup --provider notion --workspace research-room --local-dir ./relayfile-mount` +- **Hosted integrations:** `npx relayfile@latest` — sign in, connect GitHub, and mount the current project with no prior account or CLI setup. - **Local OSS:** run the Docker stack below, then mount `ws_demo` as a normal directory. - **Sandbox SDK:** use `RelayfileSetup.ensureMountedWorkspace()` when your runtime already has a cloud access token. - **Programmatic agents:** use [`@relayfile/agents`](packages/agents/README.md) for Vercel AI SDK, OpenAI Agents SDK, and LangChain, or wrap `RelayFileClient.readFile()` / `writeFile()` directly in any custom harness. @@ -320,18 +320,23 @@ Hosted Agent Relay runs these pieces for you. Fully self-hosted provider-backed If you want Notion, Slack, Linear, GitHub, or other provider-backed files without running any infrastructure, use hosted Agent Relay. Agent Relay Cloud runs the workspace, relayfile API, scoped auth, Nango OAuth, provider sync workers, and writeback workers for you. -Use the [`setting-up-relayfile` skill](https://github.com/AgentWorkforce/skills/blob/main/skills/setting-up-relayfile/SKILL.md) when an agent should set up hosted files: +From the project where your agent will work, run: ```bash -relayfile setup \ +npx relayfile@latest +``` + +That one command opens Google sign-in, creates the account and workspace, opens GitHub OAuth, and mounts files at `./relayfile-mount`. No invite code, API token, or separate `agent-relay` installation is required. The command stays open to keep the mount synchronized and prints the exact path and starter prompt for a second terminal. + +For another provider or a custom workspace name, use the explicit setup form: + +```bash +npx relayfile@latest setup \ --provider notion \ --workspace my-agent \ - --local-dir ./relayfile-mount \ - --no-open + --local-dir ./relayfile-mount ``` -That command connects to `agentrelay.com`, creates or joins a cloud workspace, completes provider auth, waits for sync, and mounts the resulting files for the agent. The local directory is just the agent's file interface; the integration stack is hosted. - Use the OSS repo when you want to run the file server yourself. Use hosted Agent Relay when you want the whole integration path managed: | Need | Local OSS | Hosted Agent Relay | diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 9ebb12b0..44605f99 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -601,7 +601,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return nil } if len(args) == 0 { - return runSetup(nil, stdin, stdout) + return runSetupWithOptions( + quickStartSetupArgs(), + stdin, + stdout, + setupRunOptions{preserveExistingLocalDir: true}, + ) } switch args[0] { @@ -665,6 +670,23 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } } +func quickStartSetupArgs() []string { + workingDir, _ := os.Getwd() + return quickStartSetupArgsForDir(workingDir, time.Now()) +} + +func quickStartSetupArgsForDir(workingDir string, now time.Time) []string { + workspaceName := "relayfile-" + now.UTC().Format("20060102-150405") + if base := strings.TrimSpace(filepath.Base(workingDir)); base != "" && base != "." && base != string(filepath.Separator) { + workspaceName = base + } + return []string{ + "--provider", "github", + "--workspace", workspaceName, + "--local-dir", "./relayfile-mount", + } +} + func wantsVersion(args []string) bool { return len(args) == 1 && (args[0] == "--version" || args[0] == "version") } @@ -874,7 +896,7 @@ func printUsage(w io.Writer) { fmt.Fprintln(w, `relayfile is the RelayFile CLI. Usage: - relayfile + relayfile (hosted GitHub quickstart for the current project) relayfile setup [--provider PROVIDER] [--backend BACKEND] [--workspace NAME] [--local-dir DIR] relayfile login [--no-open] [--provision-messaging-only] [--api-key] [--server URL] [--token TOKEN] relayfile logout @@ -961,7 +983,15 @@ Subcommands: observer Open the hosted file observer for a workspace`) } +type setupRunOptions struct { + preserveExistingLocalDir bool +} + func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { + return runSetupWithOptions(args, stdin, stdout, setupRunOptions{}) +} + +func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, options setupRunOptions) error { fs := flag.NewFlagSet("setup", flag.ContinueOnError) fs.SetOutput(io.Discard) cloudAPIURL := fs.String("cloud-api-url", envOrDefault("RELAYFILE_CLOUD_API_URL", defaultCloudAPIURL), "Relayfile Cloud API URL") @@ -1048,6 +1078,7 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { localDir = "./relayfile-mount" } } + localDir = resolveSetupLocalDir(name, localDir, options.preserveExistingLocalDir) absLocalDir, err := filepath.Abs(localDir) if err != nil { return err @@ -1128,10 +1159,31 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { return nil } - fmt.Fprintf(stdout, "Starting VFS mount at %s\n", localDir) + fmt.Fprintf(stdout, "Starting VFS mount at %s\n", absLocalDir) + fmt.Fprintln(stdout, "Keep this terminal open while Relayfile syncs. In another terminal, start your agent and give it this prompt:") + if selectedProvider != "" && selectedProvider != "none" && selectedProvider != "skip" { + fmt.Fprintf(stdout, " Use %s as the source of truth. Read LAYOUT.md first, then show me what needs attention.\n", setupAgentPromptPath(absLocalDir, selectedProvider)) + } else { + fmt.Fprintf(stdout, " Use %s as our shared workspace. Read LAYOUT.md first.\n", absLocalDir) + } return runMount(mountArgs) } +func resolveSetupLocalDir(workspaceName, requestedLocalDir string, preserveExisting bool) string { + if preserveExisting { + if existing, ok := workspaceRecordByName(workspaceName); ok { + if localDir := strings.TrimSpace(existing.LocalDir); localDir != "" { + return localDir + } + } + } + return requestedLocalDir +} + +func setupAgentPromptPath(absLocalDir, provider string) string { + return filepath.Join(absLocalDir, mountscope.ProviderRoot(provider)) +} + func ensureCloudCredentials(cloudAPIURL, explicitToken string, timeout time.Duration, shouldOpenBrowser bool, stdout io.Writer) (cloudCredentials, error) { explicitToken = strings.TrimSpace(explicitToken) cloudAPIURL = strings.TrimRight(strings.TrimSpace(cloudAPIURL), "/") diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 02e471ac..9bcd863c 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync/atomic" @@ -210,6 +211,62 @@ func parseBrowserFragment(t *testing.T, rawURL string) url.Values { return fragment } +func TestQuickStartUsesProjectNameGitHubAndLocalMountDefaults(t *testing.T) { + args := quickStartSetupArgsForDir( + filepath.Join(string(filepath.Separator), "workspaces", "acme-api"), + time.Date(2026, time.August, 23, 12, 0, 0, 0, time.UTC), + ) + want := []string{ + "--provider", "github", + "--workspace", "acme-api", + "--local-dir", "./relayfile-mount", + } + if !slices.Equal(args, want) { + t.Fatalf("quick-start args = %#v, want %#v", args, want) + } +} + +func TestQuickStartFallsBackToTimestampedWorkspaceOutsideAProject(t *testing.T) { + args := quickStartSetupArgsForDir( + string(filepath.Separator), + time.Date(2026, time.August, 23, 12, 34, 56, 0, time.UTC), + ) + if got, want := args[3], "relayfile-20260823-123456"; got != want { + t.Fatalf("fallback workspace = %q, want %q", got, want) + } +} + +func TestSetupAgentPromptUsesProviderMountRoot(t *testing.T) { + got := setupAgentPromptPath( + filepath.Join(string(filepath.Separator), "workspace", "relayfile-mount"), + "slack-my-senior-dev", + ) + want := filepath.Join(string(filepath.Separator), "workspace", "relayfile-mount", "slack-msd") + if got != want { + t.Fatalf("setup agent prompt path = %q, want %q", got, want) + } +} + +func TestQuickStartPreservesExistingWorkspaceMirror(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + existingDir := filepath.Join(t.TempDir(), "existing-mirror") + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "acme-api", + ID: "ws_acme", + LocalDir: existingDir, + }); err != nil { + t.Fatalf("store existing workspace: %v", err) + } + + if got := resolveSetupLocalDir("acme-api", "./relayfile-mount", true); got != existingDir { + t.Fatalf("quickstart local dir = %q, want existing %q", got, existingDir) + } + if got := resolveSetupLocalDir("acme-api", "./explicit-mount", false); got != "./explicit-mount" { + t.Fatalf("explicit setup local dir = %q, want caller value", got) + } +} + func TestHelpFlagPrintsUsageForCommandsAndSubcommands(t *testing.T) { cases := []struct { name string diff --git a/docs/cli-design.md b/docs/cli-design.md index 0d976040..dd848191 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -12,7 +12,7 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera ### Design principles - **Minimal flags, sensible defaults.** The happy path should require as few arguments as possible. -- **Canonical auth over local fallbacks.** Cloud login and active workspace selection are owned by `agent-relay cloud login` and the `@agent-relay/cloud` session. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. +- **Canonical auth over local fallbacks.** The npm entrypoint uses a bundled, login-only slice of `@agent-relay/cloud` to establish or refresh the shared session before setup starts. It never invokes the `agent-relay` CLI. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. - **Composable with pipes and scripts.** All commands emit structured JSON when `--json` is passed; human-readable tables otherwise. - **No implicit destructive actions.** Deletes require confirmation unless `--yes` is passed. @@ -26,22 +26,24 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera |----------|--------|----------| | 1 | `--token` flag | One-off override | | 2 | `RELAYFILE_TOKEN` env var | CI/CD pipelines | -| 3 | `~/.agentworkforce/relay/cloud-auth.json` (or `CLOUD_API_*`) + `agent-relay workspace active --json` | Cloud-hosted interactive use | +| 3 | `~/.agentworkforce/relay/cloud-auth.json` (or `CLOUD_API_*`) | Cloud-hosted interactive use | | 4 | `~/.relayfile/credentials.json` | Self-hosted/API-key compatibility | ### Auth flow: Cloud-hosted ``` -agent-relay cloud login -agent-relay workspace switch my-project -relayfile mount +npx relayfile@latest ``` -1. `agent-relay cloud login` writes the canonical cloud session in the relay SDK store. -2. `agent-relay workspace switch ` selects the active relay workspace. -3. `relayfile` commands resolve the cloud session without running any CLI, and - call `agent-relay workspace active --json` for the canonical - `relayfileWorkspaceId`. Session resolution order: +1. The npm launcher calls `ensureCloudSession` from its bundled Agent Relay + Cloud SDK slice. The SDK opens hosted login when needed, refreshes existing + credentials, and writes the canonical shared session. +2. The quickstart creates a Cloud workspace named after the current directory, + connects GitHub, and mounts it at `./relayfile-mount`. +3. `relayfile setup` resolves the Cloud session without invoking + `agent-relay`. Some other workspace-resolution paths still call + `agent-relay workspace active --json` for the canonical + `relayfileWorkspaceId`. Cloud session resolution itself uses this order: - A `CLOUD_API_ACCESS_TOKEN` in the environment wins, together with `CLOUD_API_URL`, `CLOUD_API_REFRESH_TOKEN`, `CLOUD_API_ACCESS_TOKEN_EXPIRES_AT` and @@ -100,7 +102,7 @@ relayfile login --api-key --server https://api.relayfile.dev - `server` — base URL for all API calls. Default: `https://api.relayfile.dev`. - `token` — Bearer JWT or API key. -- `refreshToken` / `expiresAt` — legacy fields. Cloud-hosted refresh is owned by `agent-relay`. +- `refreshToken` / `expiresAt` — legacy fields. Cloud-hosted refresh uses the shared canonical Cloud session instead. - File permissions: `0600` (user-only read/write). --- @@ -116,6 +118,10 @@ relayfile relayfile setup [--provider github] [--workspace my-project] [--local-dir ./relayfile-mount] ``` +With no arguments, `relayfile` supplies `github`, the current directory name, +and `./relayfile-mount` automatically. The flag defaults below describe the +explicit `relayfile setup` wizard. + | Flag | Default | Description | |------|---------|-------------| | `--cloud-api-url` | `https://agentrelay.com/cloud` | Relayfile Cloud API URL | @@ -129,12 +135,15 @@ relayfile setup [--provider github] [--workspace my-project] [--local-dir ./rela **Behavior:** -1. Ensure the user has run `agent-relay cloud login`; `relayfile setup` reads the canonical relay session instead of starting its own login flow. -2. Read the Cloud access token from the canonical credential file +1. The npm launcher asks the bundled Agent Relay Cloud SDK to establish or + refresh the canonical shared session, then passes it to the native runtime + only through inherited `CLOUD_API_*` environment variables. +2. The native runtime reads the Cloud access token from `~/.agentworkforce/relay/cloud-auth.json` (or the `CLOUD_API_*` environment), refreshing it in place when it is inside its expiry window. -3. Use `agent-relay workspace active --json` for the canonical workspace descriptor and `relayfileWorkspaceId`. -4. Create/join the Cloud workspace when needed, minting Relayfile runtime credentials without persisting them as a second login. +3. Create or reuse the named Cloud workspace directly; setup does not require + an installed `agent-relay` binary or a preselected Agent Relay workspace. +4. Mint Relayfile runtime credentials without persisting them as a second login. 5. Request a hosted Nango connect session for the selected integration and wait until the Cloud status endpoint reports it ready. 6. Start the existing `relayfile mount` sync loop so the user and agent see ordinary files. @@ -146,6 +155,10 @@ connected. This path is intentionally a wrapper over the lower-level commands and Cloud APIs. Existing `login`, `workspace`, `mount`, `tree`, and `read` commands remain available for CI, self-hosted servers, and scripted workflows. +The bundled SDK surface is intentionally limited to Cloud session creation and +refresh. This keeps the published package small while retaining Agent Relay's +canonical browser/device flow, auth-file locking, and refresh behavior. + --- ### `relayfile login` diff --git a/docs/guides/cloud-integration.md b/docs/guides/cloud-integration.md index c00df22b..f12e0cbb 100644 --- a/docs/guides/cloud-integration.md +++ b/docs/guides/cloud-integration.md @@ -4,17 +4,19 @@ Hosted Agent Relay is the managed cloud path for provider-backed files. Agent Re ## Hosted Agent Relay -Use the `setting-up-relayfile` skill from [AgentWorkforce/skills#28](https://github.com/AgentWorkforce/skills/pull/28) when an agent needs provider-backed files from `agentrelay.com`. +From the project where the agent will work, run the clean-machine quickstart: ```bash -relayfile setup \ - --provider notion \ - --workspace my-agent \ - --local-dir ./relayfile-mount \ - --no-open +npx relayfile@latest ``` -The skill covers the full hosted flow: cloud login, workspace creation, provider OAuth, initial sync, local mount verification, writeback checks, and recovery guidance. No relayfile server, relayauth service, Nango instance, adapter, or worker has to run on the user's machine. +Relayfile owns the full hosted flow: Google sign-in, automatic account and workspace creation, GitHub OAuth, initial sync, and the local mount. No invite code, separate Agent Relay CLI, copied token, relayfile server, relayauth service, Nango instance, adapter, or worker has to run on the user's machine. + +Use the explicit form for a different provider or workspace: + +```bash +npx relayfile@latest setup --provider notion --workspace my-agent --local-dir ./relayfile-mount +``` After setup, hand the agent the mount path: diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 00e48f7a..d1857a4f 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -11,14 +11,12 @@ This repo is the file server and mount layer. For the rest of the ecosystem, see If you want Notion, Slack, Linear, GitHub, or other provider-backed files without running any infrastructure, start here. Agent Relay Cloud runs the daemon, OAuth, sync workers, and writeback workers for you. ```bash -relayfile setup \ - --provider notion \ - --workspace my-agent \ - --local-dir ./relayfile-mount \ - --no-open +npx relayfile@latest ``` -That command connects to `agentrelay.com`, completes provider auth, waits for sync, and mounts provider files locally. Your agent reads and reacts to the mounted files; the integration stack is hosted. +That command signs the user in with Google, creates the account and workspace without an invite code, connects GitHub, and mounts provider files at `./relayfile-mount`. No separate Agent Relay CLI or copied token is needed. Your agent reads and reacts to the mounted files; the integration stack is hosted. + +Use `npx relayfile@latest setup --provider notion --workspace my-agent --local-dir ./relayfile-mount` when you want a different provider or explicit names. ## Local OSS Quickstart diff --git a/docs/guides/vfs-cloud-setup.md b/docs/guides/vfs-cloud-setup.md index 845d83ad..924764c7 100644 --- a/docs/guides/vfs-cloud-setup.md +++ b/docs/guides/vfs-cloud-setup.md @@ -15,20 +15,26 @@ The mirror has real limitations. Read [Known Limitations](#known-limitations) be ### Interactive (recommended for humans) ```bash -relayfile +npx relayfile@latest ``` -This runs the full setup wizard: +This runs the zero-configuration GitHub quickstart: 1. Opens a browser to sign in to Relayfile Cloud. -2. Prompts for a workspace name (default: `relayfile-`). -3. Prompts for an integration provider (GitHub, Notion, Linear, Slack, or none). -4. Prompts for a local directory (default: `./relayfile-mount`). +2. Creates the user's account without an invite code and names the workspace after the current directory. +3. Selects GitHub as the first integration. +4. Uses `./relayfile-mount` as the local directory. 5. Opens the integration OAuth consent page. 6. Waits for the initial sync to complete, showing live progress. 7. Starts the sync loop in the foreground. -Press `Ctrl+C` at any time to stop. The local files remain; re-run `relayfile` or `relayfile mount ` to resume. +No separate `agent-relay` CLI, API token, or prior Cloud session is required. Press `Ctrl+C` at any time to stop. The local files remain; re-run `npx relayfile@latest` or `relayfile mount ` to resume. + +For interactive provider/workspace/directory prompts, run `relayfile setup` without flags. For an explicit path, use: + +```bash +npx relayfile@latest setup --provider notion --workspace my-project --local-dir ./relayfile-mount +``` ### Non-interactive / CI @@ -326,14 +332,15 @@ If the mount has not reconciled for ≥10 minutes it logs `mount stalled: = 8.7.0 required`. Use `RELAYFILE_AGENT_RELAY_BIN` to override the CLI path; otherwise `agent-relay` is resolved from `PATH`. - The `agent-relay` CLI compatibility probe now names the exact argv it ran, the binary it ran it with, and how that binary was resolved. It no longer probes `cloud session`, since Relayfile does not use it. - Delegated-credential recovery errors no longer blame a healthy Agent Relay cloud session or direct users to log in again. They report that automatic Cloud re-mint failed and preserve the underlying re-mint error; regression coverage now exercises both the re-mint and ordinary refresh arms while `AGENT_RELAY_BIN` points at a broker with no `cloud` subcommand. @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bare `npx relayfile@latest` is now a hosted GitHub quickstart using the current directory name and `./relayfile-mount` defaults. Its npm entrypoint uses a small bundled slice of the Agent Relay Cloud SDK for browser/device login and session refresh, so setup does not invoke or require the `agent-relay` CLI. + - The minimum `agent-relay` CLI version now gates workspace resolution only. A CLI without a `cloud` subcommand no longer blocks Relayfile's cloud session. - `relayfile integration list` and the local integration control plane now honor `RELAYFILE_CLOUD_TOKEN` and bound optional runtime-status enrichment, avoiding provider-status timeouts when explicit Cloud credentials are available or the runtime data plane is slow. diff --git a/packages/cli/package.json b/packages/cli/package.json index d25d9c1a..b3b3a9c3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,6 +11,7 @@ ], "scripts": { "build": "node scripts/build-binaries.js", + "test": "node --test scripts/*.test.js", "prepack": "npm run build", "postinstall": "node scripts/install.js" }, diff --git a/packages/cli/scripts/cloud-auth.cjs b/packages/cli/scripts/cloud-auth.cjs new file mode 100644 index 00000000..0d7155da --- /dev/null +++ b/packages/cli/scripts/cloud-auth.cjs @@ -0,0 +1,1176 @@ +/* Agent Relay Cloud SDK 11.8.1; bundled for Relayfile login. */ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// packages/cli/scripts/cloud-auth-entry.mjs +var cloud_auth_entry_exports = {}; +__export(cloud_auth_entry_exports, { + ensureCloudSession: () => ensureCloudSession +}); +module.exports = __toCommonJS(cloud_auth_entry_exports); + +// ../relay/packages/cloud/src/auth.ts +var import_node_crypto = require("node:crypto"); +var import_promises3 = __toESM(require("node:fs/promises"), 1); +var import_node_http = __toESM(require("node:http"), 1); +var import_node_os4 = __toESM(require("node:os"), 1); +var import_node_path3 = __toESM(require("node:path"), 1); +var import_node_child_process = require("node:child_process"); +var import_promises4 = require("node:timers/promises"); + +// ../relay/packages/cloud/src/types.ts +var import_node_os = __toESM(require("node:os"), 1); +var import_node_path = __toESM(require("node:path"), 1); +var CloudAuthError = class extends Error { + constructor(code, message, options) { + super(message, options); + this.code = code; + this.name = "CloudAuthError"; + } + code; +}; +var REFRESH_WINDOW_MS = 5 * 6e4; +var REFRESH_TOKEN_WINDOW_MS = 24 * 60 * 60 * 1e3; +var DEFAULT_REFRESH_TIMEOUT_MS = 1e4; +var AUTH_FILE_PATH = import_node_path.default.join(import_node_os.default.homedir(), ".agentworkforce/relay", "cloud-auth.json"); +function defaultApiUrl() { + return process.env.CLOUD_API_URL?.trim() || "https://agentrelay.com/cloud"; +} + +// ../relay/packages/cloud/src/telemetry-headers.ts +var AGENT_RELAY_DISTINCT_ID_HEADER = "X-Agent-Relay-Distinct-Id"; +var AGENT_RELAY_MACHINE_ID_HEADER = "X-Agent-Relay-Machine-Id"; +var AGENT_RELAY_USER_ID_HEADER = "X-Agent-Relay-User-Id"; +var AGENT_RELAY_ORG_ID_HEADER = "X-Agent-Relay-Org-Id"; +var AGENT_RELAY_ORG_SLUG_HEADER = "X-Agent-Relay-Org-Slug"; +var RELAYCAST_HARNESS_HEADER = "X-Relaycast-Harness"; +var RELAYCAST_ORIGIN_CLIENT_HEADER = "X-Relaycast-Origin-Client"; +var RELAYCAST_ORIGIN_VERSION_HEADER = "X-Relaycast-Origin-Version"; +var AGENT_RELAY_DISTINCT_ID_ENV = "AGENT_RELAY_DISTINCT_ID"; +var AGENT_RELAY_MACHINE_ID_ENV = "AGENT_RELAY_MACHINE_ID"; +var AGENT_RELAY_USER_ID_ENV = "AGENT_RELAY_USER_ID"; +var AGENT_RELAY_ORG_ID_ENV = "AGENT_RELAY_ORG_ID"; +var AGENT_RELAY_ORG_SLUG_ENV = "AGENT_RELAY_ORG_SLUG"; +var ORCHESTRATOR_HARNESS_ENV = "AGENT_RELAY_ORCHESTRATOR_HARNESS"; +var TELEMETRY_CLIENT_ENV = "AGENT_RELAY_TELEMETRY_CLIENT"; +var DISTINCT_ID_ALLOWED = /^[a-z0-9._:-]+$/i; +var HEADER_VALUE_ALLOWED = /^[a-z0-9 ._\-/():=;,+@]+$/i; +function sanitizeHeaderValue(raw, options) { + if (!raw) return void 0; + const trimmed = raw.trim(); + if (!trimmed) return void 0; + if (options.pattern && !options.pattern.test(trimmed)) return void 0; + return trimmed.slice(0, options.maxLength); +} +function isTelemetryDisabledByEnv(env) { + const disabled = env.AGENT_RELAY_TELEMETRY_DISABLED ?? env.DO_NOT_TRACK; + return disabled === "1" || disabled?.toLowerCase() === "true"; +} +function buildAgentRelayTelemetryHeaders(env = process.env) { + if (isTelemetryDisabledByEnv(env)) return {}; + const userId = sanitizeHeaderValue(env[AGENT_RELAY_USER_ID_ENV], { + maxLength: 128, + pattern: DISTINCT_ID_ALLOWED + }); + const machineId = sanitizeHeaderValue(env[AGENT_RELAY_MACHINE_ID_ENV], { + maxLength: 128, + pattern: DISTINCT_ID_ALLOWED + }); + const distinctId = sanitizeHeaderValue(env[AGENT_RELAY_DISTINCT_ID_ENV], { + maxLength: 128, + pattern: DISTINCT_ID_ALLOWED + }) ?? userId ?? machineId; + if (!distinctId) return {}; + const headers = { + [AGENT_RELAY_DISTINCT_ID_HEADER]: distinctId + }; + if (machineId) headers[AGENT_RELAY_MACHINE_ID_HEADER] = machineId; + if (userId) headers[AGENT_RELAY_USER_ID_HEADER] = userId; + const orgId = sanitizeHeaderValue(env[AGENT_RELAY_ORG_ID_ENV], { + maxLength: 128, + pattern: DISTINCT_ID_ALLOWED + }); + if (orgId) headers[AGENT_RELAY_ORG_ID_HEADER] = orgId; + const orgSlug = sanitizeHeaderValue(env[AGENT_RELAY_ORG_SLUG_ENV], { + maxLength: 120, + pattern: DISTINCT_ID_ALLOWED + }); + if (orgSlug) headers[AGENT_RELAY_ORG_SLUG_HEADER] = orgSlug; + const harness = sanitizeHeaderValue(env[ORCHESTRATOR_HARNESS_ENV], { + maxLength: 120, + pattern: HEADER_VALUE_ALLOWED + }); + const client = sanitizeHeaderValue(env[TELEMETRY_CLIENT_ENV], { + maxLength: 80, + pattern: HEADER_VALUE_ALLOWED + }); + const version = sanitizeHeaderValue(env.AGENT_RELAY_CLI_VERSION ?? env.AGENT_RELAY_SDK_VERSION, { + maxLength: 48, + pattern: HEADER_VALUE_ALLOWED + }); + if (harness) headers[RELAYCAST_HARNESS_HEADER] = harness; + if (client) headers[RELAYCAST_ORIGIN_CLIENT_HEADER] = client; + if (version) headers[RELAYCAST_ORIGIN_VERSION_HEADER] = version; + return headers; +} +function appendAgentRelayTelemetryHeaders(headers, env = process.env) { + for (const [name, value] of Object.entries(buildAgentRelayTelemetryHeaders(env))) { + if (!headers.has(name)) { + headers.set(name, value); + } + } + return headers; +} + +// ../relay/packages/cloud/src/api-client.ts +function trimLeadingSlash(p) { + return p.replace(/^\/+/, ""); +} +function withTrailingSlash(p) { + return p.endsWith("/") ? p : `${p}/`; +} +function buildApiUrl(apiUrl, p) { + return new URL(trimLeadingSlash(p), withTrailingSlash(apiUrl)); +} +var CloudApiClient = class _CloudApiClient { + constructor(options) { + this.options = options; + this.apiUrl = options.apiUrl; + this.accessToken = options.accessToken; + this.refreshToken = options.refreshToken; + this.accessTokenExpiresAt = options.accessTokenExpiresAt; + this.refreshTokenExpiresAt = options.refreshTokenExpiresAt; + } + options; + apiUrl; + accessToken; + refreshToken; + accessTokenExpiresAt; + refreshTokenExpiresAt; + refreshPromise = null; + static fromEnv(env) { + const apiUrl = env.CLOUD_API_URL?.trim(); + const accessToken = env.CLOUD_API_ACCESS_TOKEN?.trim(); + const refreshToken = env.CLOUD_API_REFRESH_TOKEN?.trim(); + const accessTokenExpiresAt = env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT?.trim(); + const refreshTokenExpiresAt = env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT?.trim(); + if (!apiUrl || !accessToken || !refreshToken || !accessTokenExpiresAt) { + return null; + } + return new _CloudApiClient({ + apiUrl, + accessToken, + refreshToken, + accessTokenExpiresAt, + refreshTokenExpiresAt + }); + } + snapshot() { + return { + apiUrl: this.apiUrl, + accessToken: this.accessToken, + refreshToken: this.refreshToken, + accessTokenExpiresAt: this.accessTokenExpiresAt, + ...this.refreshTokenExpiresAt ? { refreshTokenExpiresAt: this.refreshTokenExpiresAt } : {} + }; + } + async fetch(p, init = {}) { + await this.refresh(false, init.signal ?? void 0); + const response = await fetch(buildApiUrl(this.apiUrl, p), { + ...init, + headers: this.buildHeaders(init.headers) + }); + if (response.status !== 401) { + return response; + } + await this.refresh(true, init.signal ?? void 0); + return fetch(buildApiUrl(this.apiUrl, p), { + ...init, + headers: this.buildHeaders(init.headers) + }); + } + async revoke() { + const response = await fetch(buildApiUrl(this.apiUrl, "/api/v1/auth/token/revoke"), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ token: this.refreshToken }) + }); + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to revoke API token: ${response.status} ${response.statusText}`); + } + } + async refresh(force = false, signal) { + if (this.refreshPromise) { + return this.refreshPromise; + } + if (!force && !this.shouldRefresh()) { + return; + } + this.refreshPromise = this.doRefresh(force, signal).finally(() => { + this.refreshPromise = null; + }); + return this.refreshPromise; + } + async doRefresh(force, signal) { + if (this.options.refreshAuth) { + this.applySnapshot(await this.options.refreshAuth(this.snapshot(), { force, signal })); + await this.options.onRefresh?.(this.snapshot()); + return; + } + this.applySnapshot(await this.requestRefresh(signal)); + await this.options.onRefresh?.(this.snapshot()); + } + async requestRefresh(signal) { + const refreshTimeoutMs = this.options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + const controller = new AbortController(); + let timedOut = false; + let callerAborted = false; + const abortFromCaller = () => { + callerAborted = true; + controller.abort(); + }; + if (signal) { + if (signal.aborted) { + callerAborted = true; + controller.abort(); + } else { + signal.addEventListener("abort", abortFromCaller, { once: true }); + } + } + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, refreshTimeoutMs); + let response; + try { + response = await fetch(buildApiUrl(this.apiUrl, "/api/v1/auth/token/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ refreshToken: this.refreshToken }), + signal: controller.signal + }); + } catch (error) { + if (timedOut || !callerAborted && error instanceof Error && error.name === "AbortError") { + throw new CloudAuthError( + "AUTH_REFRESH_TIMEOUT", + `Cloud auth refresh timed out after ${refreshTimeoutMs}ms`, + { cause: error } + ); + } + throw error; + } finally { + clearTimeout(timer); + if (signal) { + signal.removeEventListener("abort", abortFromCaller); + } + } + if (!response.ok) { + throw new CloudAuthError( + "AUTH_REFRESH_EXPIRED", + `Failed to refresh API token: ${response.status} ${response.statusText}` + ); + } + const payload = await response.json(); + if (!payload.accessToken || !payload.accessTokenExpiresAt || !payload.refreshToken) { + throw new CloudAuthError("AUTH_REFRESH_EXPIRED", "Refresh response missing token fields"); + } + const nextRefreshTokenExpiresAt = typeof payload.refreshTokenExpiresAt === "string" && payload.refreshTokenExpiresAt.trim() ? payload.refreshTokenExpiresAt.trim() : this.refreshTokenExpiresAt; + return { + apiUrl: typeof payload.apiUrl === "string" && payload.apiUrl.trim() ? payload.apiUrl.trim() : this.apiUrl, + accessToken: payload.accessToken, + accessTokenExpiresAt: payload.accessTokenExpiresAt, + refreshToken: payload.refreshToken, + ...nextRefreshTokenExpiresAt ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt } : {} + }; + } + applySnapshot(snapshot) { + this.apiUrl = snapshot.apiUrl; + this.accessToken = snapshot.accessToken; + this.accessTokenExpiresAt = snapshot.accessTokenExpiresAt; + this.refreshToken = snapshot.refreshToken; + this.refreshTokenExpiresAt = snapshot.refreshTokenExpiresAt; + } + buildHeaders(headers) { + const merged = new Headers(headers); + merged.set("Authorization", `Bearer ${this.accessToken}`); + return appendAgentRelayTelemetryHeaders(merged); + } + shouldRefresh() { + const expiresAt = Date.parse(this.accessTokenExpiresAt); + if (Number.isNaN(expiresAt)) { + return true; + } + if (expiresAt - Date.now() <= REFRESH_WINDOW_MS) { + return true; + } + if (!this.refreshTokenExpiresAt) { + return false; + } + const refreshExpiresAt = Date.parse(this.refreshTokenExpiresAt); + if (Number.isNaN(refreshExpiresAt)) { + return true; + } + return refreshExpiresAt - Date.now() <= REFRESH_TOKEN_WINDOW_MS; + } +}; + +// ../relay/packages/cloud/src/identity.ts +var import_promises = __toESM(require("node:fs/promises"), 1); +var import_node_os2 = __toESM(require("node:os"), 1); +var import_node_path2 = __toESM(require("node:path"), 1); +var DEFAULT_DATA_DIR = import_node_path2.default.join(import_node_os2.default.homedir(), ".agentworkforce/relay"); +var IDENTITY_FILE_NAME = "cloud-identity.json"; +var IDENTITY_FILE_PATH = import_node_path2.default.join(DEFAULT_DATA_DIR, IDENTITY_FILE_NAME); +function identityFilePath(env = process.env) { + const dataDir = env.AGENT_RELAY_DATA_DIR?.trim() || DEFAULT_DATA_DIR; + return import_node_path2.default.join(dataDir, IDENTITY_FILE_NAME); +} +var IDENTITY_VALUE_ALLOWED = /^[\x20-\x7E]+$/; +function sanitize(value, maxLength) { + if (typeof value !== "string") return void 0; + const trimmed = value.trim(); + if (!trimmed) return void 0; + if (!IDENTITY_VALUE_ALLOWED.test(trimmed)) return void 0; + return trimmed.slice(0, maxLength); +} +function normalizeCloudIdentity(value) { + if (!value || typeof value !== "object") return null; + const raw = value; + const userId = sanitize(raw.userId, 128); + if (!userId) return null; + const apiUrl = sanitize(raw.apiUrl, 512); + const updatedAt = sanitize(raw.updatedAt, 40); + return { + userId, + ...sanitize(raw.email, 320) ? { email: sanitize(raw.email, 320) } : {}, + ...sanitize(raw.name, 120) ? { name: sanitize(raw.name, 120) } : {}, + ...sanitize(raw.organizationId, 128) ? { organizationId: sanitize(raw.organizationId, 128) } : {}, + ...sanitize(raw.organizationSlug, 120) ? { organizationSlug: sanitize(raw.organizationSlug, 120) } : {}, + ...sanitize(raw.organizationName, 200) ? { organizationName: sanitize(raw.organizationName, 200) } : {}, + ...sanitize(raw.organizationRole, 60) ? { organizationRole: sanitize(raw.organizationRole, 60) } : {}, + ...sanitize(raw.workspaceId, 128) ? { workspaceId: sanitize(raw.workspaceId, 128) } : {}, + apiUrl: apiUrl ?? "", + updatedAt: updatedAt ?? (/* @__PURE__ */ new Date(0)).toISOString() + }; +} +async function writeStoredIdentity(identity, env = process.env) { + const normalized = normalizeCloudIdentity(identity); + if (!normalized) return; + const target = identityFilePath(env); + const tmp = `${target}.${process.pid}.tmp`; + try { + await import_promises.default.mkdir(import_node_path2.default.dirname(target), { recursive: true, mode: 448 }); + await import_promises.default.writeFile(tmp, `${JSON.stringify(normalized, null, 2)} +`, { mode: 384 }); + await import_promises.default.rename(tmp, target); + } catch { + await import_promises.default.rm(tmp, { force: true }).catch(() => void 0); + } +} + +// ../relay/packages/cloud/src/device-auth.ts +var import_node_os3 = __toESM(require("node:os"), 1); +var import_promises2 = require("node:timers/promises"); +var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +var DEFAULT_INTERVAL_SECONDS = 5; +var SLOW_DOWN_INCREMENT_SECONDS = 5; +var MAX_INTERVAL_SECONDS = 60; +var DEFAULT_EXPIRES_IN_SECONDS = 600; +var DEFAULT_REQUEST_TIMEOUT_MS = 3e4; +var MAX_TIMER_DELAY_MS = 2147483647; +function deviceError(message) { + return new CloudAuthError("AUTH_DEVICE_FLOW_FAILED", message); +} +function clampTimerDelay(ms) { + if (!Number.isFinite(ms)) { + return MAX_TIMER_DELAY_MS; + } + return Math.min(MAX_TIMER_DELAY_MS, Math.max(1, Math.floor(ms))); +} +function normalizeTimeout(ms) { + if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0) { + return DEFAULT_REQUEST_TIMEOUT_MS; + } + return clampTimerDelay(ms); +} +function formatDuration(ms) { + return ms >= 1e3 ? `${Math.round(ms / 1e3)}s` : `${Math.round(ms)}ms`; +} +function isRequestTimeout(error) { + let current = error; + for (let depth = 0; current instanceof Error && depth < 4; depth += 1) { + if (current.name === "TimeoutError" || current.name === "AbortError") { + return true; + } + current = current.cause; + } + return false; +} +function clampInterval(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) { + return DEFAULT_INTERVAL_SECONDS; + } + return Math.min(MAX_INTERVAL_SECONDS, Math.ceil(seconds)); +} +function isHeadlessEnvironment(env = process.env, platform = import_node_os3.default.platform()) { + if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) { + return true; + } + if (platform !== "darwin" && platform !== "win32") { + return !env.DISPLAY && !env.WAYLAND_DISPLAY; + } + return false; +} +async function startDeviceAuthorization(apiUrl, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const clientName = options.clientName ?? import_node_os3.default.hostname(); + const timeoutMs = normalizeTimeout(options.requestTimeoutMs); + let response; + try { + response = await fetchImpl(buildApiUrl(apiUrl, "/api/v1/auth/device/start"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_name: clientName }), + // Nothing has been printed yet at this point, so a hang here shows the + // user a bare cursor forever. Fail loudly instead. + signal: AbortSignal.timeout(timeoutMs) + }); + } catch (error) { + if (isRequestTimeout(error)) { + throw new CloudAuthError( + "AUTH_DEVICE_FLOW_FAILED", + `Timed out after ${formatDuration(timeoutMs)} trying to reach ${apiUrl} to start device login`, + { cause: error } + ); + } + throw new CloudAuthError("AUTH_DEVICE_FLOW_FAILED", `Could not reach ${apiUrl} to start device login`, { + cause: error + }); + } + const payload = await response.json().catch(() => null); + if (!response.ok) { + if (response.status === 404) { + throw deviceError( + `${apiUrl} does not support device login. Update the cloud deployment, or run \`agent-relay cloud login\` on a machine with a browser.` + ); + } + const detail = payload?.error_description || payload?.error || `HTTP ${response.status}`; + throw deviceError(`Could not start device login: ${detail}`); + } + if (!payload?.device_code || !payload.user_code || !payload.verification_uri) { + throw deviceError("Device login response was missing required fields"); + } + return { + deviceCode: payload.device_code, + userCode: payload.user_code, + verificationUri: payload.verification_uri, + ...payload.verification_uri_complete ? { verificationUriComplete: payload.verification_uri_complete } : {}, + expiresInSeconds: payload.expires_in ?? DEFAULT_EXPIRES_IN_SECONDS, + intervalSeconds: clampInterval(payload.interval ?? DEFAULT_INTERVAL_SECONDS) + }; +} +async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { + const fetchImpl = hooks.fetchImpl ?? fetch; + const sleep = hooks.sleep ?? ((ms) => (0, import_promises2.setTimeout)(ms)); + const now = hooks.now ?? (() => Date.now()); + const log = hooks.log ?? ((message) => console.log(message)); + const requestTimeoutMs = normalizeTimeout(hooks.requestTimeoutMs); + let intervalSeconds = authorization.intervalSeconds; + const deadline = now() + authorization.expiresInSeconds * 1e3; + for (; ; ) { + await sleep(Math.min(intervalSeconds * 1e3, Math.max(0, deadline - now()))); + if (now() >= deadline) { + throw deviceError( + "Device login expired before it was approved. Run the command again to get a new code." + ); + } + const pollTimeoutMs = clampTimerDelay(Math.min(requestTimeoutMs, deadline - now())); + let response; + try { + response = await fetchImpl(buildApiUrl(apiUrl, "/api/v1/auth/device/token"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: DEVICE_GRANT_TYPE, + device_code: authorization.deviceCode + }), + signal: AbortSignal.timeout(pollTimeoutMs) + }); + } catch (error) { + if (isRequestTimeout(error)) { + if (now() >= deadline) { + throw deviceError( + "Device login expired before it was approved. Run the command again to get a new code." + ); + } + log(`No response from ${apiUrl} within ${formatDuration(pollTimeoutMs)}; retrying...`); + intervalSeconds = clampInterval(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS); + continue; + } + throw new CloudAuthError( + "AUTH_DEVICE_FLOW_FAILED", + `Lost connection to ${apiUrl} while waiting for approval`, + { cause: error } + ); + } + const payload = await response.json().catch(() => null); + if (response.ok) { + if (!payload?.access_token || !payload.refresh_token || !payload.access_token_expires_at) { + throw deviceError("Device login response was missing required fields"); + } + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token, + accessTokenExpiresAt: payload.access_token_expires_at, + ...payload.refresh_token_expires_at ? { refreshTokenExpiresAt: payload.refresh_token_expires_at } : {}, + apiUrl: payload.api_url?.trim() || apiUrl + }; + } + if (response.status === 429) { + const retryAfter = Number(response.headers.get("retry-after")); + intervalSeconds = clampInterval( + Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS + ); + continue; + } + if (response.status >= 500) { + intervalSeconds = clampInterval(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS); + continue; + } + switch (payload?.error) { + case "authorization_pending": + if (payload.interval) { + intervalSeconds = Math.max(intervalSeconds, clampInterval(payload.interval)); + } + continue; + case "slow_down": + intervalSeconds = clampInterval( + Math.max(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS, payload.interval ?? 0) + ); + continue; + case "access_denied": + throw deviceError("Device login was denied. No credentials were issued."); + case "expired_token": + throw deviceError( + "Device login expired before it was approved. Run the command again to get a new code." + ); + case "invalid_grant": + throw deviceError("This device code is no longer valid. Run the command again to get a new code."); + default: { + const detail = payload?.error_description || payload?.error || `HTTP ${response.status}`; + throw deviceError(`Device login failed: ${detail}`); + } + } + } +} +function formatDeviceInstructions(authorization) { + return [ + "", + "To authorize this machine, visit:", + ` ${authorization.verificationUri}`, + "", + "and enter code:", + ` ${authorization.userCode}`, + "", + ...authorization.verificationUriComplete ? [`Or open this link directly:`, ` ${authorization.verificationUriComplete}`, ""] : [] + ].join("\n"); +} +async function runDeviceAuthorizationFlow(apiUrl, options = {}) { + const log = options.log ?? ((message) => console.log(message)); + const authorization = await startDeviceAuthorization(apiUrl, { + ...options.clientName ? { clientName: options.clientName } : {}, + ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}, + ...options.requestTimeoutMs !== void 0 ? { requestTimeoutMs: options.requestTimeoutMs } : {} + }); + log(formatDeviceInstructions(authorization)); + log("Waiting for authorization..."); + const auth = await pollForDeviceToken(apiUrl, authorization, options); + return auth; +} + +// ../relay/packages/cloud/src/auth.ts +var AUTH_DIR_PATH = import_node_path3.default.dirname(AUTH_FILE_PATH); +var AUTH_LOCK_PATH = `${AUTH_FILE_PATH}.lock`; +var AUTH_LOCK_RETRY_DELAY_MS = 50; +var AUTH_LOCK_STALE_MS = 3e4; +var AUTH_LOCK_TIMEOUT_MS = 3e4; +var envBackedAuth = /* @__PURE__ */ new WeakSet(); +function markEnvBackedAuth(auth) { + envBackedAuth.add(auth); + return auth; +} +function isEnvBackedAuth(auth) { + return envBackedAuth.has(auth); +} +function readEnvAuth(env = process.env) { + const apiUrl = env.CLOUD_API_URL?.trim(); + const accessToken = env.CLOUD_API_ACCESS_TOKEN?.trim(); + const refreshToken = env.CLOUD_API_REFRESH_TOKEN?.trim(); + const accessTokenExpiresAt = env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT?.trim(); + const refreshTokenExpiresAt = env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT?.trim(); + if (!apiUrl || !accessToken || !refreshToken || !accessTokenExpiresAt) { + return null; + } + try { + new URL(apiUrl); + } catch { + return null; + } + if (Number.isNaN(Date.parse(accessTokenExpiresAt))) { + return null; + } + return markEnvBackedAuth({ + apiUrl, + accessToken, + refreshToken, + accessTokenExpiresAt, + ...refreshTokenExpiresAt && !Number.isNaN(Date.parse(refreshTokenExpiresAt)) ? { refreshTokenExpiresAt } : {} + }); +} +function toEnvAuthRefreshError(error) { + if (error instanceof CloudAuthError && error.code === "AUTH_REFRESH_TIMEOUT") { + return error; + } + const message = error instanceof Error && error.message ? `${error.message}. ` : ""; + return new CloudAuthError( + "AUTH_ENV_REPROVISION_REQUIRED", + `${message}Env-backed cloud auth could not be refreshed interactively; re-provision CLOUD_API_URL, CLOUD_API_ACCESS_TOKEN, CLOUD_API_REFRESH_TOKEN, CLOUD_API_ACCESS_TOKEN_EXPIRES_AT, and optionally CLOUD_API_REFRESH_TOKEN_EXPIRES_AT.`, + { cause: error } + ); +} +function isValidStoredAuth(value) { + if (!value || typeof value !== "object") { + return false; + } + const auth = value; + return typeof auth.accessToken === "string" && typeof auth.refreshToken === "string" && typeof auth.accessTokenExpiresAt === "string" && typeof auth.apiUrl === "string" && (auth.refreshTokenExpiresAt === void 0 || typeof auth.refreshTokenExpiresAt === "string") && !Number.isNaN(Date.parse(auth.accessTokenExpiresAt)) && (auth.refreshTokenExpiresAt === void 0 || !Number.isNaN(Date.parse(auth.refreshTokenExpiresAt))); +} +function isNodeErrorWithCode(error, code) { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} +async function readCanonicalStoredAuth() { + try { + const file = await import_promises3.default.readFile(AUTH_FILE_PATH, "utf8"); + const parsed = JSON.parse(file); + return isValidStoredAuth(parsed) ? parsed : null; + } catch { + return null; + } +} +async function readStoredAuth(env = process.env) { + const envAuth = readEnvAuth(env); + if (envAuth) { + return envAuth; + } + return readCanonicalStoredAuth(); +} +async function writeStoredAuth(auth) { + await import_promises3.default.mkdir(AUTH_DIR_PATH, { + recursive: true, + mode: 448 + }); + const temporaryPath = import_node_path3.default.join( + AUTH_DIR_PATH, + `.${import_node_path3.default.basename(AUTH_FILE_PATH)}.${process.pid}.${Date.now()}.${(0, import_node_crypto.randomUUID)()}.tmp` + ); + try { + await import_promises3.default.writeFile(temporaryPath, `${JSON.stringify(auth, null, 2)} +`, { + encoding: "utf8", + mode: 384 + }); + await import_promises3.default.chmod(temporaryPath, 384); + await import_promises3.default.rename(temporaryPath, AUTH_FILE_PATH); + } finally { + await import_promises3.default.rm(temporaryPath, { force: true }); + } +} +async function refreshStoredCloudIdentity(auth, options = {}) { + const env = options.env ?? process.env; + try { + const { response } = await authorizedApiFetch( + auth, + "/api/v1/auth/whoami", + { method: "GET" }, + { interactive: false } + ); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + if (!payload?.authenticated || !payload.user?.id) return null; + const identity = toCloudIdentity(payload, auth.apiUrl); + if (!identity) return null; + await writeStoredIdentity(identity, env); + return identity; + } catch { + return null; + } +} +function toCloudIdentity(payload, apiUrl) { + if (!payload.user?.id) return null; + return { + userId: payload.user.id, + ...payload.user.email ? { email: payload.user.email } : {}, + ...payload.user.name ? { name: payload.user.name } : {}, + ...payload.currentOrganization ? { + organizationId: payload.currentOrganization.id, + organizationSlug: payload.currentOrganization.slug, + organizationName: payload.currentOrganization.name, + organizationRole: payload.currentOrganization.role + } : {}, + ...payload.currentWorkspace ? { workspaceId: payload.currentWorkspace.id } : {}, + apiUrl, + updatedAt: (/* @__PURE__ */ new Date()).toISOString() + }; +} +async function removeStaleStoredAuthLock() { + try { + const lockStats = await import_promises3.default.stat(AUTH_LOCK_PATH); + if (Date.now() - lockStats.mtimeMs < AUTH_LOCK_STALE_MS) { + return false; + } + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return true; + } + throw error; + } + await import_promises3.default.rm(AUTH_LOCK_PATH, { recursive: true, force: true }); + return true; +} +async function acquireStoredAuthLock(signal) { + const startedAt = Date.now(); + while (true) { + if (signal?.aborted) { + throw signal.reason ?? new Error("Cloud auth lock acquisition aborted"); + } + try { + await import_promises3.default.mkdir(AUTH_LOCK_PATH, { mode: 448 }); + return; + } catch (error) { + if (!isNodeErrorWithCode(error, "EEXIST")) { + throw error; + } + } + if (await removeStaleStoredAuthLock()) { + continue; + } + if (Date.now() - startedAt >= AUTH_LOCK_TIMEOUT_MS) { + throw new Error(`Timed out waiting for cloud auth lock at ${AUTH_LOCK_PATH}`); + } + await (0, import_promises4.setTimeout)(AUTH_LOCK_RETRY_DELAY_MS, void 0, { signal }); + } +} +async function withStoredAuthLock(callback, options = {}) { + await import_promises3.default.mkdir(AUTH_DIR_PATH, { + recursive: true, + mode: 448 + }); + await acquireStoredAuthLock(options.signal); + try { + return await callback(); + } finally { + await import_promises3.default.rm(AUTH_LOCK_PATH, { recursive: true, force: true }); + } +} +function shouldRefresh(accessTokenExpiresAt) { + const expiresAt = Date.parse(accessTokenExpiresAt); + if (Number.isNaN(expiresAt)) { + return true; + } + return expiresAt - Date.now() <= REFRESH_WINDOW_MS; +} +function shouldRefreshStoredAuth(auth) { + if (shouldRefresh(auth.accessTokenExpiresAt)) { + return true; + } + if (!auth.refreshTokenExpiresAt) { + return false; + } + const refreshExpiresAt = Date.parse(auth.refreshTokenExpiresAt); + if (Number.isNaN(refreshExpiresAt)) { + return true; + } + return refreshExpiresAt - Date.now() <= REFRESH_TOKEN_WINDOW_MS; +} +function openBrowser(url) { + const platform = import_node_os4.default.platform(); + if (platform === "darwin") { + return (0, import_node_child_process.spawn)("open", [url], { stdio: "ignore", detached: true }); + } + if (platform === "win32") { + return (0, import_node_child_process.spawn)("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }); + } + return (0, import_node_child_process.spawn)("xdg-open", [url], { stdio: "ignore", detached: true }); +} +function browserRequired(message) { + return new CloudAuthError("AUTH_BROWSER_REQUIRED", message); +} +function refreshExpired(message = "Stored cloud login has expired") { + return new CloudAuthError("AUTH_REFRESH_EXPIRED", message); +} +function isAbortLikeError(error) { + return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError" || /aborted/i.test(error.message)); +} +function addAbortListener(signal, listener) { + signal.addEventListener("abort", listener, { once: true }); + return () => signal.removeEventListener("abort", listener); +} +async function fetchWithRefreshTimeout(url, init, options = {}) { + const refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + const controller = new AbortController(); + const removers = []; + let timedOut = false; + let callerAborted = false; + const abortFromCaller = () => { + callerAborted = true; + controller.abort(); + }; + for (const signal of [options.signal, init.signal]) { + if (!signal) { + continue; + } + if (signal.aborted) { + callerAborted = true; + controller.abort(); + break; + } + removers.push(addAbortListener(signal, abortFromCaller)); + } + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, refreshTimeoutMs); + try { + return await fetch(url, { + ...init, + signal: controller.signal + }); + } catch (error) { + if (timedOut || !callerAborted && isAbortLikeError(error)) { + throw new CloudAuthError( + "AUTH_REFRESH_TIMEOUT", + `Cloud auth refresh timed out after ${refreshTimeoutMs}ms`, + { cause: error } + ); + } + throw error; + } finally { + clearTimeout(timer); + for (const remove of removers) { + remove(); + } + } +} +function redirectToHostedCliAuthPage(response, apiUrl, options) { + const resultUrl = buildApiUrl(apiUrl, "/cli/auth-result"); + resultUrl.searchParams.set("status", options.status); + if (options.detail) { + resultUrl.searchParams.set("detail", options.detail); + } + response.statusCode = 302; + response.setHeader("location", resultUrl.toString()); + response.end(); +} +async function beginBrowserLogin(apiUrl) { + const state = (0, import_node_crypto.randomUUID)(); + return new Promise((resolve, reject) => { + let settled = false; + const server = import_node_http.default.createServer((request, response) => { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); + if (requestUrl.pathname !== "/callback") { + response.statusCode = 404; + response.end("Not found"); + return; + } + const returnedState = requestUrl.searchParams.get("state"); + if (returnedState !== state) { + response.statusCode = 400; + response.setHeader("content-type", "text/plain; charset=utf-8"); + response.end("Ignored invalid CLI login callback. Return to your terminal to continue login."); + return; + } + const error = requestUrl.searchParams.get("error"); + if (error) { + redirectToHostedCliAuthPage(response, apiUrl, { + status: "error", + detail: error + }); + if (!settled) { + settled = true; + server.close(); + reject(new Error(error)); + } + return; + } + const accessToken = requestUrl.searchParams.get("access_token"); + const refreshToken = requestUrl.searchParams.get("refresh_token"); + const accessTokenExpiresAt = requestUrl.searchParams.get("access_token_expires_at"); + const refreshTokenExpiresAt = requestUrl.searchParams.get("refresh_token_expires_at"); + const returnedApiUrl = requestUrl.searchParams.get("api_url"); + if (!accessToken || !refreshToken || !accessTokenExpiresAt || !returnedApiUrl) { + redirectToHostedCliAuthPage(response, apiUrl, { + status: "error", + detail: "Expected access token, refresh token, API URL, and expiration timestamp." + }); + if (!settled) { + settled = true; + server.close(); + reject(new Error("CLI login callback was missing required fields")); + } + return; + } + redirectToHostedCliAuthPage(response, returnedApiUrl, { + status: "success", + detail: `API endpoint: ${returnedApiUrl}` + }); + if (!settled) { + settled = true; + server.close(); + resolve({ + accessToken, + refreshToken, + accessTokenExpiresAt, + ...refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}, + apiUrl: returnedApiUrl + }); + } + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + if (!settled) { + settled = true; + server.close(); + reject(new Error("Failed to start local callback server")); + } + return; + } + const callbackUrl = new URL("/callback", `http://127.0.0.1:${address.port}`); + const loginUrl = buildApiUrl(apiUrl, "/api/v1/cli/login"); + loginUrl.searchParams.set("redirect_uri", callbackUrl.toString()); + loginUrl.searchParams.set("state", state); + console.log(`Opening browser for cloud login: ${loginUrl.toString()}`); + console.log("If the browser does not open, paste this URL into your browser."); + try { + const child = openBrowser(loginUrl.toString()); + child.unref(); + } catch { + } + }); + server.on("error", (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + setTimeout(() => { + if (!settled) { + settled = true; + server.close(); + reject(new Error("Timed out waiting for browser login")); + } + }, 5 * 6e4).unref(); + }); +} +async function refreshStoredAuth(auth, options = {}) { + if (isEnvBackedAuth(auth)) { + return markEnvBackedAuth(await requestStoredAuthRefresh(auth, options)); + } + return withStoredAuthLock(async () => { + const latestAuth = await readCanonicalStoredAuth(); + const refreshSource = latestAuth?.apiUrl === auth.apiUrl ? latestAuth : auth; + if (!options.force && latestAuth?.apiUrl === auth.apiUrl && !shouldRefreshStoredAuth(latestAuth)) { + return latestAuth; + } + const nextAuth = await requestStoredAuthRefresh(refreshSource, options); + await writeStoredAuth(nextAuth); + return nextAuth; + }, options); +} +async function requestStoredAuthRefresh(auth, options = {}) { + const response = await fetchWithRefreshTimeout( + buildApiUrl(auth.apiUrl, "/api/v1/auth/token/refresh"), + { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ refreshToken: auth.refreshToken }) + }, + options + ); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.accessToken || !payload?.refreshToken || !payload?.accessTokenExpiresAt) { + throw refreshExpired(); + } + const nextRefreshTokenExpiresAt = typeof payload.refreshTokenExpiresAt === "string" && payload.refreshTokenExpiresAt.trim() ? payload.refreshTokenExpiresAt.trim() : auth.refreshTokenExpiresAt; + const nextAuth = { + apiUrl: typeof payload.apiUrl === "string" && payload.apiUrl.trim() ? payload.apiUrl.trim() : auth.apiUrl, + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + accessTokenExpiresAt: payload.accessTokenExpiresAt, + ...nextRefreshTokenExpiresAt ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt } : {} + }; + return nextAuth; +} +async function completeLogin(auth) { + await writeStoredAuth(auth); + const identity = await refreshStoredCloudIdentity(auth); + console.log(`Logged in to ${auth.apiUrl}`); + if (identity?.email) { + const org = identity.organizationName ?? identity.organizationSlug; + console.log(`Signed in as ${identity.email}${org ? ` (${org})` : ""}`); + } + return auth; +} +async function loginWithBrowser(apiUrl) { + return completeLogin(await beginBrowserLogin(apiUrl)); +} +async function loginWithDevice(apiUrl, options = {}) { + return completeLogin(await runDeviceAuthorizationFlow(apiUrl, options)); +} +async function loginInteractive(apiUrl, options = {}) { + const env = options.env ?? process.env; + if (options.device === true || isHeadlessEnvironment(env)) { + return loginWithDevice(apiUrl); + } + return loginWithBrowser(apiUrl); +} +async function ensureCloudSession(options = {}) { + const env = options.env ?? process.env; + const apiUrl = options.apiUrl || env.CLOUD_API_URL?.trim() || defaultApiUrl(); + const force = options.force === true; + const interactive = options.interactive !== false; + const refreshTimeoutMs = options.refreshTimeoutMs; + const stored = !force ? await readStoredAuth(env) : null; + if (!stored) { + if (!interactive) { + throw browserRequired( + isHeadlessEnvironment(env) ? "Cloud login required. Run `agent-relay cloud login --device`." : "Cloud login required. Run `agent-relay login`." + ); + } + const auth = await loginInteractive(apiUrl, { device: options.device, env }); + return createCloudSession(auth, { refreshTimeoutMs }); + } + if (!shouldRefreshStoredAuth(stored)) { + return createCloudSession(stored, { refreshTimeoutMs }); + } + try { + const auth = await refreshStoredAuth(stored, { refreshTimeoutMs }); + return createCloudSession(auth, { refreshTimeoutMs }); + } catch (error) { + if (isEnvBackedAuth(stored)) { + throw toEnvAuthRefreshError(error); + } + if (!interactive) { + throw error; + } + const auth = await loginInteractive(stored.apiUrl, { device: options.device, env }); + return createCloudSession(auth, { refreshTimeoutMs }); + } +} +function createCloudSession(auth, options = {}) { + const clientOptions = { + ...auth, + refreshTimeoutMs: options.refreshTimeoutMs + }; + if (!isEnvBackedAuth(auth)) { + clientOptions.refreshAuth = async (snapshot, refreshOptions) => toCloudApiClientSnapshot( + await refreshStoredAuth(toStoredAuth(snapshot), { + force: refreshOptions.force, + refreshTimeoutMs: options.refreshTimeoutMs, + signal: refreshOptions.signal + }) + ); + } + const client = new CloudApiClient(clientOptions); + return { auth, client }; +} +function toStoredAuth(snapshot) { + return { + apiUrl: snapshot.apiUrl, + accessToken: snapshot.accessToken, + refreshToken: snapshot.refreshToken, + accessTokenExpiresAt: snapshot.accessTokenExpiresAt, + ...snapshot.refreshTokenExpiresAt ? { refreshTokenExpiresAt: snapshot.refreshTokenExpiresAt } : {} + }; +} +function toCloudApiClientSnapshot(auth) { + return auth; +} +function apiFetch(apiUrl, accessToken, requestPath, init) { + const headers = new Headers(init.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + headers.set("authorization", `Bearer ${accessToken}`); + appendAgentRelayTelemetryHeaders(headers); + return fetch(buildApiUrl(apiUrl, requestPath), { + ...init, + headers + }); +} +async function authorizedApiFetch(auth, requestPath, init, options = {}) { + let activeAuth = auth; + let response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); + if (response.status !== 401) { + return { response, auth: activeAuth }; + } + try { + activeAuth = await refreshStoredAuth(activeAuth, { + force: true, + refreshTimeoutMs: options.refreshTimeoutMs, + signal: init.signal ?? void 0 + }); + } catch (error) { + if (isEnvBackedAuth(activeAuth)) { + throw toEnvAuthRefreshError(error); + } + if (options.interactive === false) { + throw error; + } + if (init.signal?.aborted) { + throw init.signal.reason ?? new Error("Cloud request aborted before re-authentication"); + } + activeAuth = await loginInteractive(activeAuth.apiUrl, { + device: options.device, + env: options.env + }); + } + response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); + return { response, auth: activeAuth }; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + ensureCloudSession +}); diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js new file mode 100644 index 00000000..fb6b751b --- /dev/null +++ b/packages/cli/scripts/cloud-preflight.js @@ -0,0 +1,101 @@ +"use strict"; + +const path = require("path"); + +const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud"; + +function hasFlag(args, name) { + return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); +} + +function optionValue(args, name) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === name) { + return args[index + 1]; + } + if (arg.startsWith(`${name}=`)) { + return arg.slice(name.length + 1); + } + } + return undefined; +} + +function shouldPrepareCloudSession(args, env) { + const setupCommand = args.length === 0 || args[0] === "setup"; + if (!setupCommand) { + return false; + } + if ( + hasFlag(args, "--help") || + hasFlag(args, "-h") || + hasFlag(args, "--version") || + args[0] === "version" + ) { + return false; + } + // Explicit credentials are caller-owned. Let the Go CLI validate and use + // them without replacing them with an interactive session. + if ( + hasFlag(args, "--cloud-token") || + String(env.RELAYFILE_CLOUD_TOKEN || "").trim() || + String(env.CLOUD_API_ACCESS_TOKEN || "").trim() + ) { + return false; + } + return true; +} + +function loadCloudSessionSDK() { + const bundlePath = path.join(__dirname, "cloud-auth.cjs"); + try { + return require(bundlePath).ensureCloudSession; + } catch (error) { + throw new Error( + "Relayfile's Agent Relay Cloud SDK bundle is missing. Reinstall relayfile or run its package build.", + { cause: error }, + ); + } +} + +async function prepareCloudSession(args, env = process.env, dependencies = {}) { + if (!shouldPrepareCloudSession(args, env)) { + return false; + } + + const ensureCloudSession = + dependencies.ensureCloudSession || loadCloudSessionSDK(); + const apiUrl = + String(optionValue(args, "--cloud-api-url") || "").trim() || + String(env.RELAYFILE_CLOUD_API_URL || "").trim() || + String(env.CLOUD_API_URL || "").trim() || + DEFAULT_CLOUD_API_URL; + const session = await ensureCloudSession({ + apiUrl, + interactive: true, + device: hasFlag(args, "--no-open"), + }); + + // The child receives the session through its environment, never through + // argv or stdout. The SDK has already persisted the same session in Agent + // Relay's canonical auth store for future commands. + env.CLOUD_API_URL = session.auth.apiUrl; + env.CLOUD_API_ACCESS_TOKEN = session.auth.accessToken; + env.CLOUD_API_REFRESH_TOKEN = session.auth.refreshToken; + env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = + session.auth.accessTokenExpiresAt; + if (session.auth.refreshTokenExpiresAt) { + env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT = + session.auth.refreshTokenExpiresAt; + } else { + delete env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT; + } + return true; +} + +module.exports = { + DEFAULT_CLOUD_API_URL, + optionValue, + prepareCloudSession, + shouldPrepareCloudSession, +}; diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js new file mode 100644 index 00000000..bfad605f --- /dev/null +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -0,0 +1,131 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const { + prepareCloudSession, + shouldPrepareCloudSession, +} = require("./cloud-preflight.js"); + +test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () => { + const env = {}; + const calls = []; + const prepared = await prepareCloudSession([], env, { + ensureCloudSession: async (options) => { + calls.push(options); + return { + auth: { + apiUrl: "https://cloud.example", + accessToken: "cld_at_test_secret", + refreshToken: "cld_rt_test_secret", + accessTokenExpiresAt: "2026-08-23T14:00:00Z", + refreshTokenExpiresAt: "2026-09-23T14:00:00Z", + }, + }; + }, + }); + + assert.equal(prepared, true); + assert.deepEqual(calls, [ + { + apiUrl: "https://agentrelay.com/cloud", + interactive: true, + device: false, + }, + ]); + assert.equal(env.CLOUD_API_ACCESS_TOKEN, "cld_at_test_secret"); + assert.equal(env.CLOUD_API_REFRESH_TOKEN, "cld_rt_test_secret"); + assert.equal(env.CLOUD_API_URL, "https://cloud.example"); +}); + +test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { + const env = {}; + let received; + await prepareCloudSession( + [ + "setup", + "--cloud-api-url=https://staging.example/cloud", + "--no-open", + ], + env, + { + ensureCloudSession: async (options) => { + received = options; + return { + auth: { + apiUrl: options.apiUrl, + accessToken: "access", + refreshToken: "refresh", + accessTokenExpiresAt: "2026-08-23T14:00:00Z", + }, + }; + }, + }, + ); + + assert.deepEqual(received, { + apiUrl: "https://staging.example/cloud", + interactive: true, + device: true, + }); + assert.equal(env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT, undefined); +}); + +test("help and caller-owned tokens do not start interactive auth", () => { + assert.equal(shouldPrepareCloudSession(["setup", "--help"], {}), false); + assert.equal( + shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {}), + false, + ); + assert.equal( + shouldPrepareCloudSession([], { CLOUD_API_ACCESS_TOKEN: "ci-token" }), + false, + ); + assert.equal(shouldPrepareCloudSession(["status"], {}), false); +}); + +test("the bundled SDK reuses canonical auth without exposing tokens", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "relayfile-cloud-sdk-")); + const authDir = path.join(home, ".agentworkforce", "relay"); + fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(authDir, "cloud-auth.json"), + `${JSON.stringify({ + apiUrl: "https://cloud.example", + accessToken: "cld_at_bundle_secret", + refreshToken: "cld_rt_bundle_secret", + accessTokenExpiresAt: "2099-08-23T14:00:00Z", + refreshTokenExpiresAt: "2099-09-23T14:00:00Z", + })}\n`, + { mode: 0o600 }, + ); + + const modulePath = path.join(__dirname, "cloud-preflight.js"); + const script = ` + const { prepareCloudSession } = require(${JSON.stringify(modulePath)}); + prepareCloudSession([], process.env).then(() => { + console.log(JSON.stringify({ + apiUrl: process.env.CLOUD_API_URL, + hasAccess: Boolean(process.env.CLOUD_API_ACCESS_TOKEN), + hasRefresh: Boolean(process.env.CLOUD_API_REFRESH_TOKEN), + })); + }).catch((error) => { console.error(error.message); process.exit(1); }); + `; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + env: { ...process.env, HOME: home }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + apiUrl: "https://cloud.example", + hasAccess: true, + hasRefresh: true, + }); + assert.doesNotMatch(result.stdout, /cld_[ar]t_bundle_secret/); +}); diff --git a/packages/cli/scripts/run.js b/packages/cli/scripts/run.js index 9eb1622b..7b2301bb 100755 --- a/packages/cli/scripts/run.js +++ b/packages/cli/scripts/run.js @@ -12,6 +12,8 @@ if (args[0] === "--version") { process.exit(0); } +const { prepareCloudSession } = require("./cloud-preflight.js"); + const PLATFORM_MAP = { darwin: "darwin", linux: "linux", @@ -58,46 +60,59 @@ function sourceCheckoutRoot() { return null; } -let result; -if (binPath) { - result = spawnSync(binPath, args, { stdio: "inherit" }); -} else { - const repoRoot = sourceCheckoutRoot(); - if (!repoRoot) { - console.error( - `relayfile binary not found for ${os.platform()} ${os.arch()}. Reinstall the package or run postinstall again.` - ); - process.exit(1); +async function main() { + // Agent Relay's Cloud SDK owns interactive login, token refresh, locking, + // and the canonical session store. Relayfile consumes that session through + // the child environment instead of invoking the agent-relay CLI. + await prepareCloudSession(args, process.env); + + let result; + if (binPath) { + result = spawnSync(binPath, args, { stdio: "inherit" }); + } else { + const repoRoot = sourceCheckoutRoot(); + if (!repoRoot) { + console.error( + `relayfile binary not found for ${os.platform()} ${os.arch()}. Reinstall the package or run postinstall again.` + ); + process.exit(1); + } + result = spawnSync("go", ["run", "./cmd/relayfile-cli", ...args], { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.error && result.error.code === "ENOENT") { + console.error( + "relayfile binary not found and Go is not installed to run from source. " + + "Install Go or run `npm run build --workspace=packages/cli`." + ); + process.exit(1); + } } - result = spawnSync("go", ["run", "./cmd/relayfile-cli", ...args], { - cwd: repoRoot, - stdio: "inherit", - }); - if (result.error && result.error.code === "ENOENT") { - console.error( - "relayfile binary not found and Go is not installed to run from source. " + - "Install Go or run `npm run build --workspace=packages/cli`." - ); + + if (result.error) { + console.error(`Failed to launch relayfile: ${result.error.message}`); process.exit(1); } -} -if (result.error) { - console.error(`Failed to launch relayfile: ${result.error.message}`); - process.exit(1); -} + if (typeof result.status === "number") { + process.exit(result.status); + } -if (typeof result.status === "number") { - process.exit(result.status); -} + // The child was terminated by a signal: spawnSync reports status === null + // and signal === . Preserve conventional 128 + signal-number exit + // semantics (e.g. 130 for SIGINT) so callers can distinguish user + // cancellation from a generic failure. + if (result.signal) { + const signum = os.constants.signals[result.signal]; + process.exit(typeof signum === "number" ? 128 + signum : 1); + } -// The child was terminated by a signal: spawnSync reports status === null -// and signal === . Preserve conventional 128 + signal-number exit -// semantics (e.g. 130 for SIGINT) so callers can distinguish user -// cancellation from a generic failure. -if (result.signal) { - const signum = os.constants.signals[result.signal]; - process.exit(typeof signum === "number" ? 128 + signum : 1); + process.exit(1); } -process.exit(1); +main().catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + console.error(`Relayfile Cloud sign-in failed: ${detail}`); + process.exit(1); +}); From 87a3e905b4b642b6853d75989146a752912815f1 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 13:36:23 +0200 Subject: [PATCH 02/14] fix(cli): harden self-serve quickstart --- cmd/relayfile-cli/main.go | 38 ++++++++--- cmd/relayfile-cli/main_test.go | 39 +++++++++-- docs/cli-design.md | 4 +- packages/cli/scripts/cloud-preflight.js | 69 ++++++++++++++++++++ packages/cli/scripts/cloud-preflight.test.js | 49 ++++++++++++++ 5 files changed, 185 insertions(+), 14 deletions(-) diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 44605f99..515d2e2b 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -1078,7 +1078,7 @@ func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, optio localDir = "./relayfile-mount" } } - localDir = resolveSetupLocalDir(name, localDir, options.preserveExistingLocalDir) + name, localDir = resolveSetupTarget(name, localDir, options.preserveExistingLocalDir) absLocalDir, err := filepath.Abs(localDir) if err != nil { return err @@ -1169,15 +1169,37 @@ func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, optio return runMount(mountArgs) } -func resolveSetupLocalDir(workspaceName, requestedLocalDir string, preserveExisting bool) string { - if preserveExisting { - if existing, ok := workspaceRecordByName(workspaceName); ok { - if localDir := strings.TrimSpace(existing.LocalDir); localDir != "" { - return localDir - } +func resolveSetupTarget(workspaceName, requestedLocalDir string, preserveExisting bool) (string, string) { + if !preserveExisting { + return workspaceName, requestedLocalDir + } + + requestedAbs := absolutePathIfSet(requestedLocalDir) + existing, ok := workspaceRecordByName(workspaceName) + if !ok { + return workspaceName, requestedLocalDir + } + if existingAbs := absolutePathIfSet(existing.LocalDir); existingAbs != "" && existingAbs == requestedAbs { + return workspaceName, existing.LocalDir + } + + // Quickstart names normally stay human-readable (the project basename). + // If another local project already claimed that name, add a stable path + // discriminator so we never mount or connect the other project's workspace. + digest := sha256.Sum256([]byte(filepath.Clean(requestedAbs))) + digestHex := hex.EncodeToString(digest[:]) + for width := 8; width <= len(digestHex); width += 4 { + candidate := workspaceName + "-" + digestHex[:width] + candidateRecord, exists := workspaceRecordByName(candidate) + if !exists { + return candidate, requestedLocalDir + } + if candidateAbs := absolutePathIfSet(candidateRecord.LocalDir); candidateAbs != "" && candidateAbs == requestedAbs { + return candidate, candidateRecord.LocalDir } } - return requestedLocalDir + + return workspaceName + "-" + digestHex, requestedLocalDir } func setupAgentPromptPath(absLocalDir, provider string) string { diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 9bcd863c..1b9b396c 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -250,7 +250,8 @@ func TestSetupAgentPromptUsesProviderMountRoot(t *testing.T) { func TestQuickStartPreservesExistingWorkspaceMirror(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) - existingDir := filepath.Join(t.TempDir(), "existing-mirror") + projectDir := t.TempDir() + existingDir := filepath.Join(projectDir, "relayfile-mount") if _, err := upsertWorkspaceDetails(workspaceRecord{ Name: "acme-api", ID: "ws_acme", @@ -259,11 +260,39 @@ func TestQuickStartPreservesExistingWorkspaceMirror(t *testing.T) { t.Fatalf("store existing workspace: %v", err) } - if got := resolveSetupLocalDir("acme-api", "./relayfile-mount", true); got != existingDir { - t.Fatalf("quickstart local dir = %q, want existing %q", got, existingDir) + gotName, gotDir := resolveSetupTarget("acme-api", existingDir, true) + if gotName != "acme-api" || gotDir != existingDir { + t.Fatalf("quickstart target = (%q, %q), want (%q, %q)", gotName, gotDir, "acme-api", existingDir) } - if got := resolveSetupLocalDir("acme-api", "./explicit-mount", false); got != "./explicit-mount" { - t.Fatalf("explicit setup local dir = %q, want caller value", got) + gotName, gotDir = resolveSetupTarget("acme-api", "./explicit-mount", false) + if gotName != "acme-api" || gotDir != "./explicit-mount" { + t.Fatalf("explicit setup target = (%q, %q), want caller values", gotName, gotDir) + } +} + +func TestQuickStartDisambiguatesSameNamedProjectDirectories(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + existingDir := filepath.Join(t.TempDir(), "frontend", "relayfile-mount") + requestedDir := filepath.Join(t.TempDir(), "frontend", "relayfile-mount") + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "frontend", + ID: "ws_existing_frontend", + LocalDir: existingDir, + }); err != nil { + t.Fatalf("store existing workspace: %v", err) + } + + firstName, firstDir := resolveSetupTarget("frontend", requestedDir, true) + secondName, secondDir := resolveSetupTarget("frontend", requestedDir, true) + if firstName == "frontend" || !strings.HasPrefix(firstName, "frontend-") { + t.Fatalf("disambiguated workspace name = %q, want stable frontend suffix", firstName) + } + if firstName != secondName { + t.Fatalf("disambiguated workspace changed from %q to %q", firstName, secondName) + } + if firstDir != requestedDir || secondDir != requestedDir { + t.Fatalf("quickstart reused wrong mirror: first=%q second=%q want=%q", firstDir, secondDir, requestedDir) } } diff --git a/docs/cli-design.md b/docs/cli-design.md index dd848191..b9056a14 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -39,7 +39,9 @@ npx relayfile@latest Cloud SDK slice. The SDK opens hosted login when needed, refreshes existing credentials, and writes the canonical shared session. 2. The quickstart creates a Cloud workspace named after the current directory, - connects GitHub, and mounts it at `./relayfile-mount`. + connects GitHub, and mounts it at `./relayfile-mount`. If another local + project with the same directory name is already tracked, Relayfile adds a + stable path suffix instead of reusing that project's workspace or mirror. 3. `relayfile setup` resolves the Cloud session without invoking `agent-relay`. Some other workspace-resolution paths still call `agent-relay workspace active --json` for the canonical diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index fb6b751b..9533261f 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -3,6 +3,35 @@ const path = require("path"); const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud"; +const SETUP_FLAGS = new Map([ + ["cloud-api-url", true], + ["cloud-token", true], + ["workspace", true], + ["provider", true], + ["backend", true], + ["local-dir", true], + ["no-open", false], + ["skip-mount", false], + ["once", false], + ["login-timeout", true], + ["connect-timeout", true], + ["help", false], + ["h", false], +]); +const GO_BOOLEAN_VALUES = new Set([ + "1", + "t", + "T", + "true", + "TRUE", + "True", + "0", + "f", + "F", + "false", + "FALSE", + "False", +]); function hasFlag(args, name) { return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); @@ -21,6 +50,40 @@ function optionValue(args, name) { return undefined; } +function hasValidSetupArguments(args) { + const setupArgs = args[0] === "setup" ? args.slice(1) : args; + for (let index = 0; index < setupArgs.length; index += 1) { + const arg = setupArgs[index]; + if (arg === "--") { + return index === setupArgs.length - 1; + } + + const match = /^--?([^=]+)(?:=(.*))?$/.exec(arg); + if (!match) { + return false; + } + const [, name, inlineValue] = match; + const takesValue = SETUP_FLAGS.get(name); + if (takesValue === undefined) { + return false; + } + if (takesValue) { + if (inlineValue === undefined) { + const next = setupArgs[index + 1]; + if (next === undefined || next.startsWith("-")) { + return false; + } + index += 1; + } + continue; + } + if (inlineValue !== undefined && !GO_BOOLEAN_VALUES.has(inlineValue)) { + return false; + } + } + return true; +} + function shouldPrepareCloudSession(args, env) { const setupCommand = args.length === 0 || args[0] === "setup"; if (!setupCommand) { @@ -43,6 +106,11 @@ function shouldPrepareCloudSession(args, env) { ) { return false; } + // Let the native CLI report malformed flags without first opening a login + // flow or mutating the caller's canonical Cloud session. + if (!hasValidSetupArguments(args)) { + return false; + } return true; } @@ -95,6 +163,7 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { module.exports = { DEFAULT_CLOUD_API_URL, + hasValidSetupArguments, optionValue, prepareCloudSession, shouldPrepareCloudSession, diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index bfad605f..77572f4e 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -8,6 +8,7 @@ const { spawnSync } = require("node:child_process"); const test = require("node:test"); const { + hasValidSetupArguments, prepareCloudSession, shouldPrepareCloudSession, } = require("./cloud-preflight.js"); @@ -89,6 +90,54 @@ test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["status"], {}), false); }); +test("malformed setup arguments fail before interactive auth", async () => { + assert.equal(hasValidSetupArguments(["setup", "--provider"]), false); + assert.equal(hasValidSetupArguments(["setup", "--unknown"]), false); + assert.equal(hasValidSetupArguments(["setup", "unexpected"]), false); + assert.equal( + shouldPrepareCloudSession(["setup", "--provider"], {}), + false, + ); + assert.equal( + shouldPrepareCloudSession(["setup", "--unknown"], {}), + false, + ); + let authCalls = 0; + const prepared = await prepareCloudSession( + ["setup", "--provider"], + {}, + { + ensureCloudSession: async () => { + authCalls += 1; + throw new Error("interactive auth must not run"); + }, + }, + ); + assert.equal(prepared, false); + assert.equal(authCalls, 0); +}); + +test("valid explicit setup arguments still prepare Cloud auth", () => { + assert.equal( + hasValidSetupArguments([ + "setup", + "--provider", + "github", + "--workspace=frontend", + "--once", + "--no-open=true", + ]), + true, + ); + assert.equal( + shouldPrepareCloudSession( + ["setup", "--provider", "github", "--workspace=frontend", "--once"], + {}, + ), + true, + ); +}); + test("the bundled SDK reuses canonical auth without exposing tokens", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "relayfile-cloud-sdk-")); const authDir = path.join(home, ".agentworkforce", "relay"); From 8ec0158c7b41baf34adc3b9a151ceb262e4d9619 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 14:01:21 +0200 Subject: [PATCH 03/14] fix(cli): preserve canonical Cloud auth ownership --- docs/cli-design.md | 5 ++-- docs/productized-cloud-mount-contract.md | 6 ++-- packages/cli/scripts/cloud-preflight.js | 22 +++++---------- packages/cli/scripts/cloud-preflight.test.js | 29 ++++++++++++++------ packages/cli/scripts/run.js | 4 +-- 5 files changed, 36 insertions(+), 30 deletions(-) diff --git a/docs/cli-design.md b/docs/cli-design.md index b9056a14..e9ca7f5c 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -138,8 +138,9 @@ explicit `relayfile setup` wizard. **Behavior:** 1. The npm launcher asks the bundled Agent Relay Cloud SDK to establish or - refresh the canonical shared session, then passes it to the native runtime - only through inherited `CLOUD_API_*` environment variables. + refresh the canonical shared session. The native runtime then reads that + same store directly; the launcher does not copy file-backed credentials + into `CLOUD_API_*` environment variables. 2. The native runtime reads the Cloud access token from `~/.agentworkforce/relay/cloud-auth.json` (or the `CLOUD_API_*` environment), refreshing it in place when it is inside its expiry window. diff --git a/docs/productized-cloud-mount-contract.md b/docs/productized-cloud-mount-contract.md index da66d606..573f4a62 100644 --- a/docs/productized-cloud-mount-contract.md +++ b/docs/productized-cloud-mount-contract.md @@ -76,8 +76,10 @@ step **MUST** be idempotent on re-run: so a clean-machine `npx relayfile@latest` does not install the Agent Relay CLI or the Cloud SDK's unrelated storage dependencies. - The SDK reads or writes `~/.agentworkforce/relay/cloud-auth.json` at - `0600`, then passes the active session to the native Relayfile process via - inherited `CLOUD_API_*` variables rather than argv or stdout. + `0600`; the native Relayfile process reads the same canonical file. The + launcher does not copy file-backed credentials into inherited + `CLOUD_API_*` variables, argv, or stdout, so either runtime can safely + persist refresh-token rotation under the shared lock. - Relayfile does not persist `~/.relayfile/cloud-credentials.json` as a Cloud session source of truth. 3. **Workspace name.** Use `--workspace`, else prompt. Default suggestion: diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 9533261f..de98b0f5 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -138,26 +138,18 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { String(env.RELAYFILE_CLOUD_API_URL || "").trim() || String(env.CLOUD_API_URL || "").trim() || DEFAULT_CLOUD_API_URL; - const session = await ensureCloudSession({ + await ensureCloudSession({ apiUrl, interactive: true, device: hasFlag(args, "--no-open"), }); - // The child receives the session through its environment, never through - // argv or stdout. The SDK has already persisted the same session in Agent - // Relay's canonical auth store for future commands. - env.CLOUD_API_URL = session.auth.apiUrl; - env.CLOUD_API_ACCESS_TOKEN = session.auth.accessToken; - env.CLOUD_API_REFRESH_TOKEN = session.auth.refreshToken; - env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = - session.auth.accessTokenExpiresAt; - if (session.auth.refreshTokenExpiresAt) { - env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT = - session.auth.refreshTokenExpiresAt; - } else { - delete env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT; - } + // The SDK owns and refreshes its canonical on-disk session. Do not promote + // that session into CLOUD_API_* for the child: Relayfile would correctly + // treat those variables as caller-owned and would not persist rotated + // refresh tokens back to the shared file. The native runtime reads the same + // canonical file directly. Genuine caller-provided environment credentials + // bypass this preflight above and remain untouched. return true; } diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index 77572f4e..fe17906d 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -39,9 +39,7 @@ test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () device: false, }, ]); - assert.equal(env.CLOUD_API_ACCESS_TOKEN, "cld_at_test_secret"); - assert.equal(env.CLOUD_API_REFRESH_TOKEN, "cld_rt_test_secret"); - assert.equal(env.CLOUD_API_URL, "https://cloud.example"); + assert.deepEqual(env, {}); }); test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { @@ -74,7 +72,7 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { interactive: true, device: true, }); - assert.equal(env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT, undefined); + assert.deepEqual(env, {}); }); test("help and caller-owned tokens do not start interactive auth", () => { @@ -138,7 +136,7 @@ test("valid explicit setup arguments still prepare Cloud auth", () => { ); }); -test("the bundled SDK reuses canonical auth without exposing tokens", () => { +test("the bundled SDK keeps canonical auth out of the child environment", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "relayfile-cloud-sdk-")); const authDir = path.join(home, ".agentworkforce", "relay"); fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); @@ -155,26 +153,39 @@ test("the bundled SDK reuses canonical auth without exposing tokens", () => { ); const modulePath = path.join(__dirname, "cloud-preflight.js"); + const authPath = path.join(authDir, "cloud-auth.json"); const script = ` + const fs = require("node:fs"); const { prepareCloudSession } = require(${JSON.stringify(modulePath)}); prepareCloudSession([], process.env).then(() => { + const stored = JSON.parse(fs.readFileSync(${JSON.stringify(authPath)}, "utf8")); console.log(JSON.stringify({ - apiUrl: process.env.CLOUD_API_URL, + apiUrl: stored.apiUrl, hasAccess: Boolean(process.env.CLOUD_API_ACCESS_TOKEN), hasRefresh: Boolean(process.env.CLOUD_API_REFRESH_TOKEN), })); }).catch((error) => { console.error(error.message); process.exit(1); }); `; + const childEnv = { ...process.env, HOME: home }; + for (const name of [ + "CLOUD_API_URL", + "CLOUD_API_ACCESS_TOKEN", + "CLOUD_API_REFRESH_TOKEN", + "CLOUD_API_ACCESS_TOKEN_EXPIRES_AT", + "CLOUD_API_REFRESH_TOKEN_EXPIRES_AT", + ]) { + delete childEnv[name]; + } const result = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", - env: { ...process.env, HOME: home }, + env: childEnv, }); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { apiUrl: "https://cloud.example", - hasAccess: true, - hasRefresh: true, + hasAccess: false, + hasRefresh: false, }); assert.doesNotMatch(result.stdout, /cld_[ar]t_bundle_secret/); }); diff --git a/packages/cli/scripts/run.js b/packages/cli/scripts/run.js index 7b2301bb..fba7ac88 100755 --- a/packages/cli/scripts/run.js +++ b/packages/cli/scripts/run.js @@ -62,8 +62,8 @@ function sourceCheckoutRoot() { async function main() { // Agent Relay's Cloud SDK owns interactive login, token refresh, locking, - // and the canonical session store. Relayfile consumes that session through - // the child environment instead of invoking the agent-relay CLI. + // and the canonical session store. The native runtime reads that same store + // directly instead of receiving copied tokens or invoking agent-relay CLI. await prepareCloudSession(args, process.env); let result; From e482404ed916b3f7fa3325e0eaee75d447543f70 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 14:13:15 +0200 Subject: [PATCH 04/14] fix(cli): validate SDK login options --- packages/cli/scripts/cloud-preflight.js | 170 ++++++++++++++++--- packages/cli/scripts/cloud-preflight.test.js | 84 ++++++++- 2 files changed, 223 insertions(+), 31 deletions(-) diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index de98b0f5..25be5a94 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -3,6 +3,10 @@ const path = require("path"); const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud"; +const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_REFRESH_TIMEOUT_MS = 10 * 1000; +const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; +const MAX_GO_DURATION_NANOSECONDS = 9_223_372_036_854_775_807; const SETUP_FLAGS = new Map([ ["cloud-api-url", true], ["cloud-token", true], @@ -32,56 +36,142 @@ const GO_BOOLEAN_VALUES = new Set([ "FALSE", "False", ]); +const GO_TRUE_VALUES = new Set(["1", "t", "T", "true", "TRUE", "True"]); +const GO_DURATION_UNITS_IN_NANOSECONDS = new Map([ + ["ns", 1], + ["us", 1_000], + ["µs", 1_000], + ["μs", 1_000], + ["ms", 1_000_000], + ["s", 1_000_000_000], + ["m", 60 * 1_000_000_000], + ["h", 60 * 60 * 1_000_000_000], +]); +const VALID_INTEGRATION_BACKENDS = new Set([ + "", + "default", + "nango", + "composio", +]); function hasFlag(args, name) { return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); } -function optionValue(args, name) { - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === name) { - return args[index + 1]; +function parseGoDurationMilliseconds(value) { + let remaining = String(value); + let sign = 1; + if (remaining.startsWith("+") || remaining.startsWith("-")) { + sign = remaining[0] === "-" ? -1 : 1; + remaining = remaining.slice(1); + } + if (remaining === "0") { + return 0; + } + if (!remaining) { + return null; + } + + let nanoseconds = 0; + let parts = 0; + while (remaining) { + const match = /^(\d+(?:\.\d*)?|\.\d+)(ns|us|µs|μs|ms|s|m|h)/.exec( + remaining, + ); + if (!match) { + return null; } - if (arg.startsWith(`${name}=`)) { - return arg.slice(name.length + 1); + const amount = Number(match[1]); + const unitNanoseconds = GO_DURATION_UNITS_IN_NANOSECONDS.get(match[2]); + nanoseconds += amount * unitNanoseconds; + if ( + !Number.isFinite(nanoseconds) || + nanoseconds > MAX_GO_DURATION_NANOSECONDS + ) { + return null; } + remaining = remaining.slice(match[0].length); + parts += 1; } - return undefined; + return parts > 0 ? (sign * nanoseconds) / 1_000_000 : null; } -function hasValidSetupArguments(args) { +function parseSetupArguments(args) { const setupArgs = args[0] === "setup" ? args.slice(1) : args; + const values = new Map(); + const durations = new Map(); for (let index = 0; index < setupArgs.length; index += 1) { const arg = setupArgs[index]; if (arg === "--") { - return index === setupArgs.length - 1; + if (index !== setupArgs.length - 1) { + return { valid: false, error: "setup does not accept positional arguments" }; + } + break; } const match = /^--?([^=]+)(?:=(.*))?$/.exec(arg); if (!match) { - return false; + return { + valid: false, + error: `unexpected setup argument ${JSON.stringify(arg)}`, + }; } const [, name, inlineValue] = match; const takesValue = SETUP_FLAGS.get(name); if (takesValue === undefined) { - return false; + return { valid: false, error: `unknown setup flag --${name}` }; } if (takesValue) { + let value = inlineValue; if (inlineValue === undefined) { const next = setupArgs[index + 1]; if (next === undefined || next.startsWith("-")) { - return false; + return { valid: false, error: `--${name} requires a value` }; } + value = next; index += 1; } + values.set(name, value); + if (name === "login-timeout" || name === "connect-timeout") { + const duration = parseGoDurationMilliseconds(value); + if (duration === null) { + return { + valid: false, + error: `--${name} has invalid duration ${JSON.stringify(value)}`, + }; + } + if (name === "login-timeout" && duration <= 0) { + return { + valid: false, + error: "--login-timeout must be greater than zero", + }; + } + durations.set(name, duration); + } continue; } if (inlineValue !== undefined && !GO_BOOLEAN_VALUES.has(inlineValue)) { - return false; + return { + valid: false, + error: `--${name} has invalid boolean value ${JSON.stringify(inlineValue)}`, + }; } + values.set(name, inlineValue === undefined || GO_TRUE_VALUES.has(inlineValue)); } - return true; + + const backend = String(values.get("backend") || "").trim().toLowerCase(); + if (!VALID_INTEGRATION_BACKENDS.has(backend)) { + return { + valid: false, + error: `unsupported integration backend ${JSON.stringify(backend)} (expected nango or composio)`, + }; + } + + return { valid: true, values, durations }; +} + +function hasValidSetupArguments(args) { + return parseSetupArguments(args).valid; } function shouldPrepareCloudSession(args, env) { @@ -127,6 +217,17 @@ function loadCloudSessionSDK() { } async function prepareCloudSession(args, env = process.env, dependencies = {}) { + const setupCommand = args.length === 0 || args[0] === "setup"; + const skipsValidation = + hasFlag(args, "--help") || + hasFlag(args, "-h") || + hasFlag(args, "--version") || + args[0] === "version"; + const parsed = + setupCommand && !skipsValidation ? parseSetupArguments(args) : null; + if (parsed && !parsed.valid) { + throw new Error(parsed.error); + } if (!shouldPrepareCloudSession(args, env)) { return false; } @@ -134,15 +235,39 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { const ensureCloudSession = dependencies.ensureCloudSession || loadCloudSessionSDK(); const apiUrl = - String(optionValue(args, "--cloud-api-url") || "").trim() || + String(parsed.values.get("cloud-api-url") || "").trim() || String(env.RELAYFILE_CLOUD_API_URL || "").trim() || String(env.CLOUD_API_URL || "").trim() || DEFAULT_CLOUD_API_URL; - await ensureCloudSession({ - apiUrl, - interactive: true, - device: hasFlag(args, "--no-open"), - }); + const loginTimeoutMs = + parsed.durations.get("login-timeout") || DEFAULT_LOGIN_TIMEOUT_MS; + let timer; + try { + await Promise.race([ + ensureCloudSession({ + apiUrl, + interactive: true, + device: parsed.values.get("no-open") === true, + refreshTimeoutMs: Math.max( + 1, + Math.min(loginTimeoutMs, DEFAULT_REFRESH_TIMEOUT_MS), + ), + }), + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Cloud sign-in timed out after ${parsed.values.get("login-timeout") || "5m"}`, + ), + ), + Math.min(loginTimeoutMs, MAX_NODE_TIMER_DELAY_MS), + ); + }), + ]); + } finally { + clearTimeout(timer); + } // The SDK owns and refreshes its canonical on-disk session. Do not promote // that session into CLOUD_API_* for the child: Relayfile would correctly @@ -156,7 +281,8 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { module.exports = { DEFAULT_CLOUD_API_URL, hasValidSetupArguments, - optionValue, + parseGoDurationMilliseconds, + parseSetupArguments, prepareCloudSession, shouldPrepareCloudSession, }; diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index fe17906d..5e08642d 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -9,6 +9,7 @@ const test = require("node:test"); const { hasValidSetupArguments, + parseGoDurationMilliseconds, prepareCloudSession, shouldPrepareCloudSession, } = require("./cloud-preflight.js"); @@ -37,6 +38,7 @@ test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () apiUrl: "https://agentrelay.com/cloud", interactive: true, device: false, + refreshTimeoutMs: 10000, }, ]); assert.deepEqual(env, {}); @@ -50,6 +52,7 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { "setup", "--cloud-api-url=https://staging.example/cloud", "--no-open", + "--login-timeout=10s", ], env, { @@ -71,6 +74,7 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { apiUrl: "https://staging.example/cloud", interactive: true, device: true, + refreshTimeoutMs: 10000, }); assert.deepEqual(env, {}); }); @@ -101,20 +105,82 @@ test("malformed setup arguments fail before interactive auth", async () => { false, ); let authCalls = 0; - const prepared = await prepareCloudSession( - ["setup", "--provider"], - {}, - { - ensureCloudSession: async () => { - authCalls += 1; - throw new Error("interactive auth must not run"); + await assert.rejects( + prepareCloudSession( + ["setup", "--provider"], + {}, + { + ensureCloudSession: async () => { + authCalls += 1; + throw new Error("interactive auth must not run"); + }, }, - }, + ), + /--provider requires a value/, ); - assert.equal(prepared, false); assert.equal(authCalls, 0); }); +test("invalid setup values fail before interactive auth", async () => { + assert.equal( + hasValidSetupArguments(["setup", "--connect-timeout=bogus"]), + false, + ); + assert.equal( + hasValidSetupArguments(["setup", "--backend", "invalid"]), + false, + ); + let authCalls = 0; + await assert.rejects( + prepareCloudSession( + ["setup", "--backend", "invalid"], + {}, + { + ensureCloudSession: async () => { + authCalls += 1; + }, + }, + ), + /unsupported integration backend/, + ); + assert.equal(authCalls, 0); +}); + +test("Go durations are validated and converted for SDK login", () => { + assert.equal(parseGoDurationMilliseconds("10s"), 10000); + assert.equal(parseGoDurationMilliseconds("1m30.5s"), 90500); + assert.equal(parseGoDurationMilliseconds("250ms"), 250); + assert.equal(parseGoDurationMilliseconds("bogus"), null); + assert.equal(parseGoDurationMilliseconds("10"), null); +}); + +test("login timeout bounds SDK authentication", async () => { + await assert.rejects( + prepareCloudSession( + ["setup", "--login-timeout=1ms"], + {}, + { ensureCloudSession: () => new Promise(() => {}) }, + ), + /Cloud sign-in timed out after 1ms/, + ); +}); + +test("false no-open values keep browser login enabled", async () => { + for (const value of ["false", "0"]) { + let received; + await prepareCloudSession( + ["setup", `--no-open=${value}`], + {}, + { + ensureCloudSession: async (options) => { + received = options; + }, + }, + ); + assert.equal(received.device, false); + } +}); + test("valid explicit setup arguments still prepare Cloud auth", () => { assert.equal( hasValidSetupArguments([ From 2761e79d8db6fefbda83eb88a1ad276bc130e84f Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 14:14:39 +0200 Subject: [PATCH 05/14] fix(cli): preserve single-dash setup credentials --- packages/cli/scripts/cloud-preflight.js | 14 ++++++++------ packages/cli/scripts/cloud-preflight.test.js | 4 ++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 25be5a94..d4609b11 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -180,17 +180,22 @@ function shouldPrepareCloudSession(args, env) { return false; } if ( - hasFlag(args, "--help") || - hasFlag(args, "-h") || hasFlag(args, "--version") || args[0] === "version" ) { return false; } + const parsed = parseSetupArguments(args); + if (!parsed.valid) { + return false; + } + if (parsed.values.has("help") || parsed.values.has("h")) { + return false; + } // Explicit credentials are caller-owned. Let the Go CLI validate and use // them without replacing them with an interactive session. if ( - hasFlag(args, "--cloud-token") || + parsed.values.has("cloud-token") || String(env.RELAYFILE_CLOUD_TOKEN || "").trim() || String(env.CLOUD_API_ACCESS_TOKEN || "").trim() ) { @@ -198,9 +203,6 @@ function shouldPrepareCloudSession(args, env) { } // Let the native CLI report malformed flags without first opening a login // flow or mutating the caller's canonical Cloud session. - if (!hasValidSetupArguments(args)) { - return false; - } return true; } diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index 5e08642d..fac31c1a 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -85,6 +85,10 @@ test("help and caller-owned tokens do not start interactive auth", () => { shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {}), false, ); + assert.equal( + shouldPrepareCloudSession(["setup", "-cloud-token", "explicit"], {}), + false, + ); assert.equal( shouldPrepareCloudSession([], { CLOUD_API_ACCESS_TOKEN: "ci-token" }), false, From 647977585a63308a4497794730baa99f810ab8b5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 14:21:33 +0200 Subject: [PATCH 06/14] fix(cli): announce setup before SDK login --- cmd/relayfile-cli/main.go | 12 +++++++++++- cmd/relayfile-cli/main_test.go | 17 +++++++++++++++++ packages/cli/scripts/cloud-preflight.js | 17 +++++++++++++++++ packages/cli/scripts/cloud-preflight.test.js | 12 ++++++++++++ packages/cli/scripts/run.js | 6 +++++- 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index 515d2e2b..bf11a91f 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -58,10 +58,13 @@ const ( defaultMountMode = "poll" defaultMountInterval = 30 * time.Second defaultEventSilenceThreshold = 24 * time.Hour + setupIntentPrintedEnv = "RELAYFILE_NPM_SETUP_INTENT_PRINTED" minMountPollInterval = 5 * time.Second defaultMountTimeout = 15 * time.Second ) +const setupIntent = "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount." + var relayfileVersion = relayfileDefaultVersion var relayIntegrationBindingsMu sync.Mutex @@ -1029,7 +1032,7 @@ func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, optio cloudAPI = defaultCloudAPIURL } - fmt.Fprintln(stdout, "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount.") + printSetupIntent(stdout) tokenSet, err := ensureCloudCredentials(cloudAPI, strings.TrimSpace(*cloudToken), *loginTimeout, !*noOpen, stdout) if err != nil { @@ -1169,6 +1172,13 @@ func runSetupWithOptions(args []string, stdin io.Reader, stdout io.Writer, optio return runMount(mountArgs) } +func printSetupIntent(stdout io.Writer) { + if strings.TrimSpace(os.Getenv(setupIntentPrintedEnv)) == "1" { + return + } + fmt.Fprintln(stdout, setupIntent) +} + func resolveSetupTarget(workspaceName, requestedLocalDir string, preserveExisting bool) (string, string) { if !preserveExisting { return workspaceName, requestedLocalDir diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 1b9b396c..09e58bfb 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -226,6 +226,22 @@ func TestQuickStartUsesProjectNameGitHubAndLocalMountDefaults(t *testing.T) { } } +func TestSetupIntentPrintsOnceAcrossNPMAndNativeSetup(t *testing.T) { + var stdout bytes.Buffer + t.Setenv(setupIntentPrintedEnv, "") + printSetupIntent(&stdout) + if got := strings.TrimSpace(stdout.String()); got != setupIntent { + t.Fatalf("setup intent = %q, want %q", got, setupIntent) + } + + stdout.Reset() + t.Setenv(setupIntentPrintedEnv, "1") + printSetupIntent(&stdout) + if stdout.Len() != 0 { + t.Fatalf("native setup duplicated npm intent: %q", stdout.String()) + } +} + func TestQuickStartFallsBackToTimestampedWorkspaceOutsideAProject(t *testing.T) { args := quickStartSetupArgsForDir( string(filepath.Separator), @@ -4238,6 +4254,7 @@ func clearRelayfileEnv(t *testing.T) { t.Setenv("RELAY_BASE_URL", "") t.Setenv("AGENT_RELAY_BIN", "") t.Setenv("RELAYFILE_AGENT_RELAY_BIN", "") + t.Setenv(setupIntentPrintedEnv, "") t.Setenv("CLOUD_API_URL", "") t.Setenv("CLOUD_API_ACCESS_TOKEN", "") t.Setenv("CLOUD_API_REFRESH_TOKEN", "") diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index d4609b11..910caea8 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -3,6 +3,9 @@ const path = require("path"); const DEFAULT_CLOUD_API_URL = "https://agentrelay.com/cloud"; +const SETUP_INTENT = + "Relayfile setup. This signs you in, connects an integration, and prepares a local VFS mount."; +const SETUP_INTENT_PRINTED_ENV = "RELAYFILE_NPM_SETUP_INTENT_PRINTED"; const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_REFRESH_TIMEOUT_MS = 10 * 1000; const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; @@ -206,6 +209,17 @@ function shouldPrepareCloudSession(args, env) { return true; } +function announceSetupIntent(args, env, writeLine = console.log) { + if (!shouldPrepareCloudSession(args, env)) { + return false; + } + if (String(env[SETUP_INTENT_PRINTED_ENV] || "").trim() !== "1") { + writeLine(SETUP_INTENT); + env[SETUP_INTENT_PRINTED_ENV] = "1"; + } + return true; +} + function loadCloudSessionSDK() { const bundlePath = path.join(__dirname, "cloud-auth.cjs"); try { @@ -282,6 +296,9 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { module.exports = { DEFAULT_CLOUD_API_URL, + SETUP_INTENT, + SETUP_INTENT_PRINTED_ENV, + announceSetupIntent, hasValidSetupArguments, parseGoDurationMilliseconds, parseSetupArguments, diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index fac31c1a..dc122dde 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -8,12 +8,24 @@ const { spawnSync } = require("node:child_process"); const test = require("node:test"); const { + SETUP_INTENT, + SETUP_INTENT_PRINTED_ENV, + announceSetupIntent, hasValidSetupArguments, parseGoDurationMilliseconds, prepareCloudSession, shouldPrepareCloudSession, } = require("./cloud-preflight.js"); +test("SDK setup intent is announced once before authentication", () => { + const env = {}; + const lines = []; + assert.equal(announceSetupIntent([], env, (line) => lines.push(line)), true); + assert.equal(announceSetupIntent([], env, (line) => lines.push(line)), true); + assert.deepEqual(lines, [SETUP_INTENT]); + assert.equal(env[SETUP_INTENT_PRINTED_ENV], "1"); +}); + test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () => { const env = {}; const calls = []; diff --git a/packages/cli/scripts/run.js b/packages/cli/scripts/run.js index fba7ac88..4f71e0ea 100755 --- a/packages/cli/scripts/run.js +++ b/packages/cli/scripts/run.js @@ -12,7 +12,10 @@ if (args[0] === "--version") { process.exit(0); } -const { prepareCloudSession } = require("./cloud-preflight.js"); +const { + announceSetupIntent, + prepareCloudSession, +} = require("./cloud-preflight.js"); const PLATFORM_MAP = { darwin: "darwin", @@ -64,6 +67,7 @@ async function main() { // Agent Relay's Cloud SDK owns interactive login, token refresh, locking, // and the canonical session store. The native runtime reads that same store // directly instead of receiving copied tokens or invoking agent-relay CLI. + announceSetupIntent(args, process.env); await prepareCloudSession(args, process.env); let result; From 2bfa1a286470258531ea2338dcd248ed150710f5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 14:33:34 +0200 Subject: [PATCH 07/14] fix(cli): authenticate when token flags are empty --- packages/cli/scripts/cloud-preflight.js | 2 +- packages/cli/scripts/cloud-preflight.test.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 910caea8..427ca316 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -198,7 +198,7 @@ function shouldPrepareCloudSession(args, env) { // Explicit credentials are caller-owned. Let the Go CLI validate and use // them without replacing them with an interactive session. if ( - parsed.values.has("cloud-token") || + String(parsed.values.get("cloud-token") || "").trim() || String(env.RELAYFILE_CLOUD_TOKEN || "").trim() || String(env.CLOUD_API_ACCESS_TOKEN || "").trim() ) { diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index dc122dde..6931e506 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -101,6 +101,14 @@ test("help and caller-owned tokens do not start interactive auth", () => { shouldPrepareCloudSession(["setup", "-cloud-token", "explicit"], {}), false, ); + assert.equal( + shouldPrepareCloudSession(["setup", "--cloud-token="], {}), + true, + ); + assert.equal( + shouldPrepareCloudSession(["setup", "--cloud-token", ""], {}), + true, + ); assert.equal( shouldPrepareCloudSession([], { CLOUD_API_ACCESS_TOKEN: "ci-token" }), false, From ef72f4518383012b473b90c9719fcc715d81c268 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 15:11:49 +0200 Subject: [PATCH 08/14] fix(cli): identify Relayfile Cloud sign-in --- packages/cli/scripts/cloud-auth.cjs | 29 +++++++++++++++----- packages/cli/scripts/cloud-preflight.js | 1 + packages/cli/scripts/cloud-preflight.test.js | 14 ++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/cli/scripts/cloud-auth.cjs b/packages/cli/scripts/cloud-auth.cjs index 0d7155da..e91736b8 100644 --- a/packages/cli/scripts/cloud-auth.cjs +++ b/packages/cli/scripts/cloud-auth.cjs @@ -901,7 +901,7 @@ function redirectToHostedCliAuthPage(response, apiUrl, options) { response.setHeader("location", resultUrl.toString()); response.end(); } -async function beginBrowserLogin(apiUrl) { +async function beginBrowserLogin(apiUrl, options = {}) { const state = (0, import_node_crypto.randomUUID)(); return new Promise((resolve, reject) => { let settled = false; @@ -979,6 +979,9 @@ async function beginBrowserLogin(apiUrl) { const loginUrl = buildApiUrl(apiUrl, "/api/v1/cli/login"); loginUrl.searchParams.set("redirect_uri", callbackUrl.toString()); loginUrl.searchParams.set("state", state); + if (options.client) { + loginUrl.searchParams.set("client", options.client); + } console.log(`Opening browser for cloud login: ${loginUrl.toString()}`); console.log("If the browser does not open, paste this URL into your browser."); try { @@ -1053,8 +1056,8 @@ async function completeLogin(auth) { } return auth; } -async function loginWithBrowser(apiUrl) { - return completeLogin(await beginBrowserLogin(apiUrl)); +async function loginWithBrowser(apiUrl, options = {}) { + return completeLogin(await beginBrowserLogin(apiUrl, options)); } async function loginWithDevice(apiUrl, options = {}) { return completeLogin(await runDeviceAuthorizationFlow(apiUrl, options)); @@ -1062,9 +1065,13 @@ async function loginWithDevice(apiUrl, options = {}) { async function loginInteractive(apiUrl, options = {}) { const env = options.env ?? process.env; if (options.device === true || isHeadlessEnvironment(env)) { - return loginWithDevice(apiUrl); + return loginWithDevice(apiUrl, { + ...options.client ? { clientName: options.client } : {} + }); } - return loginWithBrowser(apiUrl); + return loginWithBrowser(apiUrl, { + ...options.client ? { client: options.client } : {} + }); } async function ensureCloudSession(options = {}) { const env = options.env ?? process.env; @@ -1079,7 +1086,11 @@ async function ensureCloudSession(options = {}) { isHeadlessEnvironment(env) ? "Cloud login required. Run `agent-relay cloud login --device`." : "Cloud login required. Run `agent-relay login`." ); } - const auth = await loginInteractive(apiUrl, { device: options.device, env }); + const auth = await loginInteractive(apiUrl, { + device: options.device, + env, + client: options.client + }); return createCloudSession(auth, { refreshTimeoutMs }); } if (!shouldRefreshStoredAuth(stored)) { @@ -1095,7 +1106,11 @@ async function ensureCloudSession(options = {}) { if (!interactive) { throw error; } - const auth = await loginInteractive(stored.apiUrl, { device: options.device, env }); + const auth = await loginInteractive(stored.apiUrl, { + device: options.device, + env, + client: options.client + }); return createCloudSession(auth, { refreshTimeoutMs }); } } diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 427ca316..a899984b 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -262,6 +262,7 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { await Promise.race([ ensureCloudSession({ apiUrl, + client: "relayfile", interactive: true, device: parsed.values.get("no-open") === true, refreshTimeoutMs: Math.max( diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index 6931e506..a0273773 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -48,6 +48,7 @@ test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () assert.deepEqual(calls, [ { apiUrl: "https://agentrelay.com/cloud", + client: "relayfile", interactive: true, device: false, refreshTimeoutMs: 10000, @@ -84,6 +85,7 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { assert.deepEqual(received, { apiUrl: "https://staging.example/cloud", + client: "relayfile", interactive: true, device: true, refreshTimeoutMs: 10000, @@ -91,6 +93,18 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { assert.deepEqual(env, {}); }); +test("bundled SDK carries the Relayfile marker through both login modes", () => { + const bundledSdk = fs.readFileSync( + path.join(__dirname, "cloud-auth.cjs"), + "utf8", + ); + assert.match( + bundledSdk, + /loginUrl\.searchParams\.set\("client", options\.client\)/, + ); + assert.match(bundledSdk, /clientName: options\.client/); +}); + test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["setup", "--help"], {}), false); assert.equal( From cccaa2ef1252c5ed28deb7e279965858147acf01 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 15:21:09 +0200 Subject: [PATCH 09/14] fix(cli): preserve parsed setup precedence --- packages/cli/scripts/cloud-preflight.js | 27 ++++++++++--- packages/cli/scripts/cloud-preflight.test.js | 40 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index a899984b..96f6773a 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -61,6 +61,18 @@ function hasFlag(args, name) { return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); } +function hasEnabledBooleanFlag(args, name) { + return args.some((arg) => { + if (arg === name) { + return true; + } + if (!arg.startsWith(`${name}=`)) { + return false; + } + return GO_TRUE_VALUES.has(arg.slice(name.length + 1)); + }); +} + function parseGoDurationMilliseconds(value) { let remaining = String(value); let sign = 1; @@ -192,14 +204,19 @@ function shouldPrepareCloudSession(args, env) { if (!parsed.valid) { return false; } - if (parsed.values.has("help") || parsed.values.has("h")) { + if ( + parsed.values.get("help") === true || + parsed.values.get("h") === true + ) { return false; } // Explicit credentials are caller-owned. Let the Go CLI validate and use // them without replacing them with an interactive session. + const relayfileCloudToken = parsed.values.has("cloud-token") + ? String(parsed.values.get("cloud-token") || "").trim() + : String(env.RELAYFILE_CLOUD_TOKEN || "").trim(); if ( - String(parsed.values.get("cloud-token") || "").trim() || - String(env.RELAYFILE_CLOUD_TOKEN || "").trim() || + relayfileCloudToken || String(env.CLOUD_API_ACCESS_TOKEN || "").trim() ) { return false; @@ -235,8 +252,8 @@ function loadCloudSessionSDK() { async function prepareCloudSession(args, env = process.env, dependencies = {}) { const setupCommand = args.length === 0 || args[0] === "setup"; const skipsValidation = - hasFlag(args, "--help") || - hasFlag(args, "-h") || + hasEnabledBooleanFlag(args, "--help") || + hasEnabledBooleanFlag(args, "-h") || hasFlag(args, "--version") || args[0] === "version"; const parsed = diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index a0273773..aff180e9 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -107,6 +107,9 @@ test("bundled SDK carries the Relayfile marker through both login modes", () => test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["setup", "--help"], {}), false); + assert.equal(shouldPrepareCloudSession(["setup", "--help=true"], {}), false); + assert.equal(shouldPrepareCloudSession(["setup", "--help=false"], {}), true); + assert.equal(shouldPrepareCloudSession(["setup", "-h=0"], {}), true); assert.equal( shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {}), false, @@ -123,6 +126,20 @@ test("help and caller-owned tokens do not start interactive auth", () => { shouldPrepareCloudSession(["setup", "--cloud-token", ""], {}), true, ); + assert.equal( + shouldPrepareCloudSession( + ["setup", "--cloud-token="], + { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, + ), + true, + ); + assert.equal( + shouldPrepareCloudSession( + ["setup"], + { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, + ), + false, + ); assert.equal( shouldPrepareCloudSession([], { CLOUD_API_ACCESS_TOKEN: "ci-token" }), false, @@ -130,6 +147,29 @@ test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["status"], {}), false); }); +test("false help and an empty token override still prepare SDK auth", async () => { + for (const { args, env } of [ + { args: ["setup", "--help=false"], env: {} }, + { + args: ["setup", "-h=0", "--cloud-token="], + env: { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, + }, + ]) { + let calls = 0; + const prepared = await prepareCloudSession( + args, + env, + { + ensureCloudSession: async () => { + calls += 1; + }, + }, + ); + assert.equal(prepared, true); + assert.equal(calls, 1); + } +}); + test("malformed setup arguments fail before interactive auth", async () => { assert.equal(hasValidSetupArguments(["setup", "--provider"]), false); assert.equal(hasValidSetupArguments(["setup", "--unknown"]), false); From 541184a4cd67db6d7ebcb1af0631fd5851c9d397 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 15:27:43 +0200 Subject: [PATCH 10/14] fix(cli): mirror native setup value parsing --- packages/cli/scripts/cloud-preflight.js | 23 +++---------- packages/cli/scripts/cloud-preflight.test.js | 34 +++++++++++++------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 96f6773a..1521711e 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -61,18 +61,6 @@ function hasFlag(args, name) { return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); } -function hasEnabledBooleanFlag(args, name) { - return args.some((arg) => { - if (arg === name) { - return true; - } - if (!arg.startsWith(`${name}=`)) { - return false; - } - return GO_TRUE_VALUES.has(arg.slice(name.length + 1)); - }); -} - function parseGoDurationMilliseconds(value) { let remaining = String(value); let sign = 1; @@ -140,7 +128,7 @@ function parseSetupArguments(args) { let value = inlineValue; if (inlineValue === undefined) { const next = setupArgs[index + 1]; - if (next === undefined || next.startsWith("-")) { + if (next === undefined) { return { valid: false, error: `--${name} requires a value` }; } value = next; @@ -204,10 +192,7 @@ function shouldPrepareCloudSession(args, env) { if (!parsed.valid) { return false; } - if ( - parsed.values.get("help") === true || - parsed.values.get("h") === true - ) { + if (parsed.values.has("help") || parsed.values.has("h")) { return false; } // Explicit credentials are caller-owned. Let the Go CLI validate and use @@ -252,8 +237,8 @@ function loadCloudSessionSDK() { async function prepareCloudSession(args, env = process.env, dependencies = {}) { const setupCommand = args.length === 0 || args[0] === "setup"; const skipsValidation = - hasEnabledBooleanFlag(args, "--help") || - hasEnabledBooleanFlag(args, "-h") || + hasFlag(args, "--help") || + hasFlag(args, "-h") || hasFlag(args, "--version") || args[0] === "version"; const parsed = diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index aff180e9..c3a7a160 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -108,8 +108,8 @@ test("bundled SDK carries the Relayfile marker through both login modes", () => test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["setup", "--help"], {}), false); assert.equal(shouldPrepareCloudSession(["setup", "--help=true"], {}), false); - assert.equal(shouldPrepareCloudSession(["setup", "--help=false"], {}), true); - assert.equal(shouldPrepareCloudSession(["setup", "-h=0"], {}), true); + assert.equal(shouldPrepareCloudSession(["setup", "--help=false"], {}), false); + assert.equal(shouldPrepareCloudSession(["setup", "-h=0"], {}), false); assert.equal( shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {}), false, @@ -147,29 +147,39 @@ test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["status"], {}), false); }); -test("false help and an empty token override still prepare SDK auth", async () => { - for (const { args, env } of [ - { args: ["setup", "--help=false"], env: {} }, - { - args: ["setup", "-h=0", "--cloud-token="], - env: { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, - }, +test("pseudo-help values never start SDK auth", async () => { + for (const args of [ + ["setup", "--help=false"], + ["setup", "-h=0", "--cloud-token="], ]) { let calls = 0; const prepared = await prepareCloudSession( args, - env, + { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, { ensureCloudSession: async () => { calls += 1; }, }, ); - assert.equal(prepared, true); - assert.equal(calls, 1); + assert.equal(prepared, false); + assert.equal(calls, 0); } }); +test("dash-prefixed values follow the native setup grammar", async () => { + const args = ["setup", "--local-dir", "-mirror"]; + assert.equal(hasValidSetupArguments(args), true); + let calls = 0; + const prepared = await prepareCloudSession(args, {}, { + ensureCloudSession: async () => { + calls += 1; + }, + }); + assert.equal(prepared, true); + assert.equal(calls, 1); +}); + test("malformed setup arguments fail before interactive auth", async () => { assert.equal(hasValidSetupArguments(["setup", "--provider"]), false); assert.equal(hasValidSetupArguments(["setup", "--unknown"]), false); From a55982955b965c21420f4ece898819d31c356dbc Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 15:42:19 +0200 Subject: [PATCH 11/14] fix(cli): cancel timed out onboarding auth --- cmd/relayfile-cli/main.go | 20 ++- cmd/relayfile-cli/main_test.go | 26 +++ docs/cli-design.md | 12 +- docs/guides/vfs-cloud-setup.md | 2 +- docs/productized-cloud-mount-contract.md | 34 ++-- packages/cli/scripts/cloud-auth.cjs | 170 +++++++++++++------ packages/cli/scripts/cloud-preflight.js | 18 +- packages/cli/scripts/cloud-preflight.test.js | 74 +++++++- 8 files changed, 274 insertions(+), 82 deletions(-) diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index bf11a91f..391847fe 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -1184,19 +1184,19 @@ func resolveSetupTarget(workspaceName, requestedLocalDir string, preserveExistin return workspaceName, requestedLocalDir } - requestedAbs := absolutePathIfSet(requestedLocalDir) + requestedIdentity := setupTargetPathIdentity(requestedLocalDir) existing, ok := workspaceRecordByName(workspaceName) if !ok { return workspaceName, requestedLocalDir } - if existingAbs := absolutePathIfSet(existing.LocalDir); existingAbs != "" && existingAbs == requestedAbs { + if existingIdentity := setupTargetPathIdentity(existing.LocalDir); existingIdentity != "" && existingIdentity == requestedIdentity { return workspaceName, existing.LocalDir } // Quickstart names normally stay human-readable (the project basename). // If another local project already claimed that name, add a stable path // discriminator so we never mount or connect the other project's workspace. - digest := sha256.Sum256([]byte(filepath.Clean(requestedAbs))) + digest := sha256.Sum256([]byte(filepath.Clean(requestedIdentity))) digestHex := hex.EncodeToString(digest[:]) for width := 8; width <= len(digestHex); width += 4 { candidate := workspaceName + "-" + digestHex[:width] @@ -1204,7 +1204,7 @@ func resolveSetupTarget(workspaceName, requestedLocalDir string, preserveExistin if !exists { return candidate, requestedLocalDir } - if candidateAbs := absolutePathIfSet(candidateRecord.LocalDir); candidateAbs != "" && candidateAbs == requestedAbs { + if candidateIdentity := setupTargetPathIdentity(candidateRecord.LocalDir); candidateIdentity != "" && candidateIdentity == requestedIdentity { return candidate, candidateRecord.LocalDir } } @@ -1212,6 +1212,18 @@ func resolveSetupTarget(workspaceName, requestedLocalDir string, preserveExistin return workspaceName + "-" + digestHex, requestedLocalDir } +func setupTargetPathIdentity(value string) string { + absolute := absolutePathIfSet(value) + if absolute == "" { + return "" + } + canonical, err := canonicalMountStartLockRoot(absolute) + if err != nil { + return filepath.Clean(absolute) + } + return canonical +} + func setupAgentPromptPath(absLocalDir, provider string) string { return filepath.Join(absLocalDir, mountscope.ProviderRoot(provider)) } diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 09e58bfb..9ad371ab 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -312,6 +312,32 @@ func TestQuickStartDisambiguatesSameNamedProjectDirectories(t *testing.T) { } } +func TestQuickStartReusesWorkspaceThroughSymlinkAlias(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + projectDir := t.TempDir() + existingDir := filepath.Join(projectDir, "relayfile-mount") + if err := os.MkdirAll(existingDir, 0o755); err != nil { + t.Fatalf("create existing mirror: %v", err) + } + aliasDir := filepath.Join(t.TempDir(), "mirror-alias") + if err := os.Symlink(existingDir, aliasDir); err != nil { + t.Skipf("create symlink alias: %v", err) + } + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "frontend", + ID: "ws_frontend", + LocalDir: existingDir, + }); err != nil { + t.Fatalf("store existing workspace: %v", err) + } + + gotName, gotDir := resolveSetupTarget("frontend", aliasDir, true) + if gotName != "frontend" || gotDir != existingDir { + t.Fatalf("quickstart target = (%q, %q), want existing workspace (%q, %q)", gotName, gotDir, "frontend", existingDir) + } +} + func TestHelpFlagPrintsUsageForCommandsAndSubcommands(t *testing.T) { cases := []struct { name string diff --git a/docs/cli-design.md b/docs/cli-design.md index e9ca7f5c..6d8f20a3 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -12,7 +12,7 @@ The `relayfile` CLI is the primary interface for humans and CI systems to intera ### Design principles - **Minimal flags, sensible defaults.** The happy path should require as few arguments as possible. -- **Canonical auth over local fallbacks.** The npm entrypoint uses a bundled, login-only slice of `@agent-relay/cloud` to establish or refresh the shared session before setup starts. It never invokes the `agent-relay` CLI. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. +- **Canonical auth over local fallbacks.** The npm entrypoint uses a bundled, login-only slice of `@agent-relay/cloud` to establish or refresh the shared session before setup starts. That setup-auth path never invokes the `agent-relay` CLI. The legacy explicit `relayfile login` command may still delegate to an installed Agent Relay CLI; it is not part of the zero-install setup path. Explicit Relayfile tokens (`--token`, `RELAYFILE_TOKEN`, or self-hosted `relayfile login --api-key`) remain available for CI and self-hosted deployments. - **Composable with pipes and scripts.** All commands emit structured JSON when `--json` is passed; human-readable tables otherwise. - **No implicit destructive actions.** Deletes require confirmation unless `--yes` is passed. @@ -151,8 +151,9 @@ explicit `relayfile setup` wizard. 6. Start the existing `relayfile mount` sync loop so the user and agent see ordinary files. Re-running `relayfile setup` with the same workspace name reuses the locally -tracked workspace ID, refreshes the Cloud session if needed, re-joins to mint a -fresh Relayfile JWT, preserves the existing local mirror directory, and only +tracked workspace ID, refreshes the Cloud session if needed, mints a fresh +Relayfile JWT through the client-specific renewal route, preserves the existing +local mirror directory, and only opens a new integration connect flow when the requested provider is not already connected. @@ -614,9 +615,12 @@ The CLI resolves tokens in this order, first match wins: If no token is found, the CLI prints: ``` -Error: not authenticated. Run 'agent-relay cloud login' for Cloud or set RELAYFILE_TOKEN. +Error: not authenticated. Run 'npx relayfile@latest' for Cloud or set RELAYFILE_TOKEN. ``` +An existing Agent Relay installation may use `agent-relay cloud login` as a +legacy alternative. + --- ## Server URL resolution diff --git a/docs/guides/vfs-cloud-setup.md b/docs/guides/vfs-cloud-setup.md index 924764c7..69769bc0 100644 --- a/docs/guides/vfs-cloud-setup.md +++ b/docs/guides/vfs-cloud-setup.md @@ -329,7 +329,7 @@ If the mount has not reconciled for ≥10 minutes it logs `mount stalled: ", "scopes": ["fs:read","fs:write"] } +``` + Triggers: -- The mount **MUST** preemptively rejoin when the JWT's `exp` is within +- The mount **MUST** preemptively renew when the JWT's `exp` is within 10 % of its lifetime, with a floor of 5 minutes. - Any 401/403 from a Relayfile API call **MUST** trigger a single - rejoin attempt before the call is retried. A second 401 fails the + renewal attempt before the call is retried. A second 401 fails the cycle and is logged. -The rejoin **MUST**: +Renewal **MUST**: - Use the Cloud access token from the canonical session (§5.2). -- Keep the minted Relayfile runtime token in memory; do not write it to - `~/.relayfile/credentials.json`. +- Keep the minted Relayfile runtime token in memory or in the native CLI's + mode-`0600` delegated credentials file; do not write it to the legacy + `~/.relayfile/credentials.json` API-key store. - Update the in-memory token of the running syncer/HTTP client without killing the process or losing the websocket. @@ -797,6 +808,7 @@ against realistic mocks of Cloud, Nango, and Relayfile services. - **Mock Cloud** — `httptest` server in Go (CLI tests) and a `node:http` server in TS (SDK tests) implementing `/api/v1/cli/login`, `/api/v1/auth/token/refresh`, `/api/v1/workspaces`, + `/api/v1/workspaces/{id}/relayfile/delegated-token`, `/api/v1/workspaces/{id}/join`, `/api/v1/workspaces/{id}/integrations/connect-session`, `/api/v1/workspaces/{id}/integrations/{provider}/status`, @@ -824,7 +836,7 @@ lives in. | A5 | Mirror conflict: local edit + remote edit with new revision yields `.relay/conflicts/..local` and a refreshed local file. | `internal/mountsync/syncer_conflict_test.go` | | A6 | Schema validation failure on writeback: file lands in `.relay/conflicts/.invalid.`, original restored, exit code 0 in CLI status. | `internal/mountsync/syncer_writeback_test.go` | | A7 | Dead-letter surfacing: a 422 from Cloud during writeback creates `.relay/dead-letter/.json`; `relayfile ops replay` clears it on success. | `cmd/relayfile/ops_e2e_test.go` | -| A8 | Token refresh: VFS token expires mid-mount; mount calls `/join` with the cloud access token, replaces credentials, continues without dropping websocket. | `internal/mountsync/syncer_refresh_test.go` | +| A8 | Token refresh: VFS token expires mid-mount; the native CLI rotates the delegated bundle, falls back to Cloud `/relayfile/delegated-token` when rotation is rejected, replaces credentials, and continues without dropping websocket. The SDK parity test separately covers `/join`. | `internal/mountsync/syncer_refresh_test.go` | | A9 | Cloud refresh token expired: mount enters read-only degraded state, prints recovery instruction once per minute, no exit. | `internal/mountsync/syncer_refresh_test.go` | | A10 | Initial sync gate: Cloud reports `cataloging`; CLI waits, polls `/sync`, exits 0 once `ready`. With a forced timeout it exits 0 with the resume hint. | `cmd/relayfile/setup_e2e_test.go` | | A11 | Webhook unhealthy: Cloud returns `webhookHealthy=false, lagSeconds=80`; `relayfile status` includes the warning row. | `cmd/relayfile/status_e2e_test.go` | diff --git a/packages/cli/scripts/cloud-auth.cjs b/packages/cli/scripts/cloud-auth.cjs index e91736b8..a742f6eb 100644 --- a/packages/cli/scripts/cloud-auth.cjs +++ b/packages/cli/scripts/cloud-auth.cjs @@ -442,6 +442,33 @@ function clampInterval(seconds) { } return Math.min(MAX_INTERVAL_SECONDS, Math.ceil(seconds)); } +function throwIfAborted(signal) { + if (signal?.aborted) { + throw signal.reason ?? new Error("Cloud login aborted"); + } +} +function combinedRequestSignal(timeoutMs, callerSignal) { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!callerSignal) { + return { signal: timeoutSignal, cleanup: () => { + } }; + } + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(callerSignal.reason); + const abortFromTimeout = () => controller.abort(timeoutSignal.reason); + callerSignal.addEventListener("abort", abortFromCaller, { once: true }); + timeoutSignal.addEventListener("abort", abortFromTimeout, { once: true }); + if (callerSignal.aborted) { + abortFromCaller(); + } + return { + signal: controller.signal, + cleanup: () => { + callerSignal.removeEventListener("abort", abortFromCaller); + timeoutSignal.removeEventListener("abort", abortFromTimeout); + } + }; +} function isHeadlessEnvironment(env = process.env, platform = import_node_os3.default.platform()) { if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) { return true; @@ -455,6 +482,8 @@ async function startDeviceAuthorization(apiUrl, options = {}) { const fetchImpl = options.fetchImpl ?? fetch; const clientName = options.clientName ?? import_node_os3.default.hostname(); const timeoutMs = normalizeTimeout(options.requestTimeoutMs); + throwIfAborted(options.signal); + const requestSignal = combinedRequestSignal(timeoutMs, options.signal); let response; try { response = await fetchImpl(buildApiUrl(apiUrl, "/api/v1/auth/device/start"), { @@ -463,9 +492,10 @@ async function startDeviceAuthorization(apiUrl, options = {}) { body: JSON.stringify({ client_name: clientName }), // Nothing has been printed yet at this point, so a hang here shows the // user a bare cursor forever. Fail loudly instead. - signal: AbortSignal.timeout(timeoutMs) + signal: requestSignal.signal }); } catch (error) { + throwIfAborted(options.signal); if (isRequestTimeout(error)) { throw new CloudAuthError( "AUTH_DEVICE_FLOW_FAILED", @@ -476,6 +506,8 @@ async function startDeviceAuthorization(apiUrl, options = {}) { throw new CloudAuthError("AUTH_DEVICE_FLOW_FAILED", `Could not reach ${apiUrl} to start device login`, { cause: error }); + } finally { + requestSignal.cleanup(); } const payload = await response.json().catch(() => null); if (!response.ok) { @@ -501,20 +533,28 @@ async function startDeviceAuthorization(apiUrl, options = {}) { } async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { const fetchImpl = hooks.fetchImpl ?? fetch; - const sleep = hooks.sleep ?? ((ms) => (0, import_promises2.setTimeout)(ms)); + const sleep = hooks.sleep ?? ((ms) => (0, import_promises2.setTimeout)(ms, void 0, { signal: hooks.signal })); const now = hooks.now ?? (() => Date.now()); const log = hooks.log ?? ((message) => console.log(message)); const requestTimeoutMs = normalizeTimeout(hooks.requestTimeoutMs); let intervalSeconds = authorization.intervalSeconds; const deadline = now() + authorization.expiresInSeconds * 1e3; for (; ; ) { - await sleep(Math.min(intervalSeconds * 1e3, Math.max(0, deadline - now()))); + throwIfAborted(hooks.signal); + try { + await sleep(Math.min(intervalSeconds * 1e3, Math.max(0, deadline - now()))); + } catch (error) { + throwIfAborted(hooks.signal); + throw error; + } + throwIfAborted(hooks.signal); if (now() >= deadline) { throw deviceError( "Device login expired before it was approved. Run the command again to get a new code." ); } const pollTimeoutMs = clampTimerDelay(Math.min(requestTimeoutMs, deadline - now())); + const requestSignal = combinedRequestSignal(pollTimeoutMs, hooks.signal); let response; try { response = await fetchImpl(buildApiUrl(apiUrl, "/api/v1/auth/device/token"), { @@ -524,9 +564,10 @@ async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { grant_type: DEVICE_GRANT_TYPE, device_code: authorization.deviceCode }), - signal: AbortSignal.timeout(pollTimeoutMs) + signal: requestSignal.signal }); } catch (error) { + throwIfAborted(hooks.signal); if (isRequestTimeout(error)) { if (now() >= deadline) { throw deviceError( @@ -542,6 +583,8 @@ async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { `Lost connection to ${apiUrl} while waiting for approval`, { cause: error } ); + } finally { + requestSignal.cleanup(); } const payload = await response.json().catch(() => null); if (response.ok) { @@ -610,7 +653,8 @@ async function runDeviceAuthorizationFlow(apiUrl, options = {}) { const authorization = await startDeviceAuthorization(apiUrl, { ...options.clientName ? { clientName: options.clientName } : {}, ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}, - ...options.requestTimeoutMs !== void 0 ? { requestTimeoutMs: options.requestTimeoutMs } : {} + ...options.requestTimeoutMs !== void 0 ? { requestTimeoutMs: options.requestTimeoutMs } : {}, + ...options.signal ? { signal: options.signal } : {} }); log(formatDeviceInstructions(authorization)); log("Waiting for authorization..."); @@ -694,7 +738,8 @@ async function readStoredAuth(env = process.env) { } return readCanonicalStoredAuth(); } -async function writeStoredAuth(auth) { +async function writeStoredAuth(auth, options = {}) { + throwIfAborted(options.signal); await import_promises3.default.mkdir(AUTH_DIR_PATH, { recursive: true, mode: 448 @@ -704,12 +749,14 @@ async function writeStoredAuth(auth) { `.${import_node_path3.default.basename(AUTH_FILE_PATH)}.${process.pid}.${Date.now()}.${(0, import_node_crypto.randomUUID)()}.tmp` ); try { + throwIfAborted(options.signal); await import_promises3.default.writeFile(temporaryPath, `${JSON.stringify(auth, null, 2)} `, { encoding: "utf8", mode: 384 }); await import_promises3.default.chmod(temporaryPath, 384); + throwIfAborted(options.signal); await import_promises3.default.rename(temporaryPath, AUTH_FILE_PATH); } finally { await import_promises3.default.rm(temporaryPath, { force: true }); @@ -721,7 +768,7 @@ async function refreshStoredCloudIdentity(auth, options = {}) { const { response } = await authorizedApiFetch( auth, "/api/v1/auth/whoami", - { method: "GET" }, + { method: "GET", signal: options.signal }, { interactive: false } ); if (!response.ok) return null; @@ -902,9 +949,29 @@ function redirectToHostedCliAuthPage(response, apiUrl, options) { response.end(); } async function beginBrowserLogin(apiUrl, options = {}) { + throwIfAborted(options.signal); const state = (0, import_node_crypto.randomUUID)(); return new Promise((resolve, reject) => { let settled = false; + let timeout; + let removeAbort = () => { + }; + const finish = (callback, value) => { + if (settled) { + return false; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + removeAbort(); + try { + server.close(); + } catch { + } + callback(value); + return true; + }; const server = import_node_http.default.createServer((request, response) => { const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); if (requestUrl.pathname !== "/callback") { @@ -925,11 +992,7 @@ async function beginBrowserLogin(apiUrl, options = {}) { status: "error", detail: error }); - if (!settled) { - settled = true; - server.close(); - reject(new Error(error)); - } + finish(reject, new Error(error)); return; } const accessToken = requestUrl.searchParams.get("access_token"); @@ -942,37 +1005,25 @@ async function beginBrowserLogin(apiUrl, options = {}) { status: "error", detail: "Expected access token, refresh token, API URL, and expiration timestamp." }); - if (!settled) { - settled = true; - server.close(); - reject(new Error("CLI login callback was missing required fields")); - } + finish(reject, new Error("CLI login callback was missing required fields")); return; } redirectToHostedCliAuthPage(response, returnedApiUrl, { status: "success", detail: `API endpoint: ${returnedApiUrl}` }); - if (!settled) { - settled = true; - server.close(); - resolve({ - accessToken, - refreshToken, - accessTokenExpiresAt, - ...refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}, - apiUrl: returnedApiUrl - }); - } + finish(resolve, { + accessToken, + refreshToken, + accessTokenExpiresAt, + ...refreshTokenExpiresAt ? { refreshTokenExpiresAt } : {}, + apiUrl: returnedApiUrl + }); }); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { - if (!settled) { - settled = true; - server.close(); - reject(new Error("Failed to start local callback server")); - } + finish(reject, new Error("Failed to start local callback server")); return; } const callbackUrl = new URL("/callback", `http://127.0.0.1:${address.port}`); @@ -991,18 +1042,22 @@ async function beginBrowserLogin(apiUrl, options = {}) { } }); server.on("error", (error) => { - if (!settled) { - settled = true; - reject(error); - } + finish(reject, error); }); - setTimeout(() => { - if (!settled) { - settled = true; - server.close(); - reject(new Error("Timed out waiting for browser login")); + if (options.signal) { + const abortLogin = () => finish(reject, options.signal.reason ?? new Error("Cloud login aborted")); + options.signal.addEventListener("abort", abortLogin, { once: true }); + removeAbort = () => options.signal.removeEventListener("abort", abortLogin); + if (options.signal.aborted) { + abortLogin(); } - }, 5 * 6e4).unref(); + } + if (!settled) { + timeout = setTimeout(() => { + finish(reject, new Error("Timed out waiting for browser login")); + }, 5 * 6e4); + timeout.unref(); + } }); } async function refreshStoredAuth(auth, options = {}) { @@ -1016,7 +1071,7 @@ async function refreshStoredAuth(auth, options = {}) { return latestAuth; } const nextAuth = await requestStoredAuthRefresh(refreshSource, options); - await writeStoredAuth(nextAuth); + await writeStoredAuth(nextAuth, options); return nextAuth; }, options); } @@ -1046,9 +1101,12 @@ async function requestStoredAuthRefresh(auth, options = {}) { }; return nextAuth; } -async function completeLogin(auth) { - await writeStoredAuth(auth); - const identity = await refreshStoredCloudIdentity(auth); +async function completeLogin(auth, options = {}) { + throwIfAborted(options.signal); + await writeStoredAuth(auth, options); + throwIfAborted(options.signal); + const identity = await refreshStoredCloudIdentity(auth, options); + throwIfAborted(options.signal); console.log(`Logged in to ${auth.apiUrl}`); if (identity?.email) { const org = identity.organizationName ?? identity.organizationSlug; @@ -1057,29 +1115,33 @@ async function completeLogin(auth) { return auth; } async function loginWithBrowser(apiUrl, options = {}) { - return completeLogin(await beginBrowserLogin(apiUrl, options)); + return completeLogin(await beginBrowserLogin(apiUrl, options), options); } async function loginWithDevice(apiUrl, options = {}) { - return completeLogin(await runDeviceAuthorizationFlow(apiUrl, options)); + return completeLogin(await runDeviceAuthorizationFlow(apiUrl, options), options); } async function loginInteractive(apiUrl, options = {}) { const env = options.env ?? process.env; if (options.device === true || isHeadlessEnvironment(env)) { return loginWithDevice(apiUrl, { + ...options, ...options.client ? { clientName: options.client } : {} }); } return loginWithBrowser(apiUrl, { + ...options, ...options.client ? { client: options.client } : {} }); } async function ensureCloudSession(options = {}) { + throwIfAborted(options.signal); const env = options.env ?? process.env; const apiUrl = options.apiUrl || env.CLOUD_API_URL?.trim() || defaultApiUrl(); const force = options.force === true; const interactive = options.interactive !== false; const refreshTimeoutMs = options.refreshTimeoutMs; const stored = !force ? await readStoredAuth(env) : null; + throwIfAborted(options.signal); if (!stored) { if (!interactive) { throw browserRequired( @@ -1089,17 +1151,20 @@ async function ensureCloudSession(options = {}) { const auth = await loginInteractive(apiUrl, { device: options.device, env, - client: options.client + client: options.client, + signal: options.signal }); return createCloudSession(auth, { refreshTimeoutMs }); } if (!shouldRefreshStoredAuth(stored)) { + throwIfAborted(options.signal); return createCloudSession(stored, { refreshTimeoutMs }); } try { - const auth = await refreshStoredAuth(stored, { refreshTimeoutMs }); + const auth = await refreshStoredAuth(stored, { refreshTimeoutMs, signal: options.signal }); return createCloudSession(auth, { refreshTimeoutMs }); } catch (error) { + throwIfAborted(options.signal); if (isEnvBackedAuth(stored)) { throw toEnvAuthRefreshError(error); } @@ -1109,7 +1174,8 @@ async function ensureCloudSession(options = {}) { const auth = await loginInteractive(stored.apiUrl, { device: options.device, env, - client: options.client + client: options.client, + signal: options.signal }); return createCloudSession(auth, { refreshTimeoutMs }); } diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 1521711e..2a97f6de 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -259,6 +259,7 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { DEFAULT_CLOUD_API_URL; const loginTimeoutMs = parsed.durations.get("login-timeout") || DEFAULT_LOGIN_TIMEOUT_MS; + const loginAbort = new AbortController(); let timer; try { await Promise.race([ @@ -271,17 +272,16 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { 1, Math.min(loginTimeoutMs, DEFAULT_REFRESH_TIMEOUT_MS), ), + signal: loginAbort.signal, }), new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `Cloud sign-in timed out after ${parsed.values.get("login-timeout") || "5m"}`, - ), - ), - Math.min(loginTimeoutMs, MAX_NODE_TIMER_DELAY_MS), - ); + timer = setTimeout(() => { + const error = new Error( + `Cloud sign-in timed out after ${parsed.values.get("login-timeout") || "5m"}`, + ); + loginAbort.abort(error); + reject(error); + }, Math.min(loginTimeoutMs, MAX_NODE_TIMER_DELAY_MS)); }), ]); } finally { diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index c3a7a160..f2c4cce9 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -45,6 +45,8 @@ test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () }); assert.equal(prepared, true); + assert.equal(calls[0].signal instanceof AbortSignal, true); + assert.equal(calls[0].signal.aborted, false); assert.deepEqual(calls, [ { apiUrl: "https://agentrelay.com/cloud", @@ -52,6 +54,7 @@ test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () interactive: true, device: false, refreshTimeoutMs: 10000, + signal: calls[0].signal, }, ]); assert.deepEqual(env, {}); @@ -89,7 +92,9 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { interactive: true, device: true, refreshTimeoutMs: 10000, + signal: received.signal, }); + assert.equal(received.signal instanceof AbortSignal, true); assert.deepEqual(env, {}); }); @@ -103,6 +108,49 @@ test("bundled SDK carries the Relayfile marker through both login modes", () => /loginUrl\.searchParams\.set\("client", options\.client\)/, ); assert.match(bundledSdk, /clientName: options\.client/); + assert.match(bundledSdk, /signal: options\.signal/); + assert.match(bundledSdk, /throwIfAborted\(options\.signal\)/); +}); + +test("bundled SDK aborts device polling without issuing or storing credentials", () => { + const modulePath = path.join(__dirname, "cloud-auth.cjs"); + const script = ` + const { ensureCloudSession } = require(${JSON.stringify(modulePath)}); + const controller = new AbortController(); + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { + ok: true, + status: 200, + json: async () => ({ + device_code: "device-test", + user_code: "TEST-CODE", + verification_uri: "https://example.test/device", + expires_in: 600, + interval: 5, + }), + }; + }; + console.log = () => {}; + const auth = ensureCloudSession({ + apiUrl: "https://example.test/cloud", + client: "relayfile", + device: true, + force: true, + signal: controller.signal, + }); + setTimeout(() => controller.abort(new Error("preflight cancelled")), 10); + auth.then( + () => process.exit(2), + (error) => process.exit(error.message === "preflight cancelled" && fetchCalls === 1 ? 0 : 3), + ); + `; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 2000, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); }); test("help and caller-owned tokens do not start interactive auth", () => { @@ -243,14 +291,38 @@ test("Go durations are validated and converted for SDK login", () => { }); test("login timeout bounds SDK authentication", async () => { + let receivedSignal; + let lateCredentialWrite = false; await assert.rejects( prepareCloudSession( ["setup", "--login-timeout=1ms"], {}, - { ensureCloudSession: () => new Promise(() => {}) }, + { + ensureCloudSession: ({ signal }) => { + receivedSignal = signal; + return new Promise((resolve, reject) => { + const lateWrite = setTimeout(() => { + lateCredentialWrite = true; + resolve(); + }, 25); + signal.addEventListener( + "abort", + () => { + clearTimeout(lateWrite); + reject(signal.reason); + }, + { once: true }, + ); + }); + }, + }, ), /Cloud sign-in timed out after 1ms/, ); + assert.equal(receivedSignal.aborted, true); + assert.match(receivedSignal.reason.message, /timed out after 1ms/); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(lateCredentialWrite, false); }); test("false no-open values keep browser login enabled", async () => { From e0b5ed38ee9934355a66582665f2568c95dd6736 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 15:44:03 +0200 Subject: [PATCH 12/14] fix(cli): match native help and version parsing --- packages/cli/scripts/cloud-preflight.js | 25 ++++++------- packages/cli/scripts/cloud-preflight.test.js | 37 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index 2a97f6de..ed6a7b9a 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -57,8 +57,14 @@ const VALID_INTEGRATION_BACKENDS = new Set([ "composio", ]); -function hasFlag(args, name) { - return args.some((arg) => arg === name || arg.startsWith(`${name}=`)); +function wantsNativeVersion(args) { + return ( + args.length === 1 && (args[0] === "--version" || args[0] === "version") + ); +} + +function wantsNativeHelp(args) { + return args.some((arg) => arg === "--help" || arg === "-h"); } function parseGoDurationMilliseconds(value) { @@ -182,10 +188,7 @@ function shouldPrepareCloudSession(args, env) { if (!setupCommand) { return false; } - if ( - hasFlag(args, "--version") || - args[0] === "version" - ) { + if (wantsNativeVersion(args) || wantsNativeHelp(args)) { return false; } const parsed = parseSetupArguments(args); @@ -236,13 +239,11 @@ function loadCloudSessionSDK() { async function prepareCloudSession(args, env = process.env, dependencies = {}) { const setupCommand = args.length === 0 || args[0] === "setup"; - const skipsValidation = - hasFlag(args, "--help") || - hasFlag(args, "-h") || - hasFlag(args, "--version") || - args[0] === "version"; + if (wantsNativeVersion(args) || wantsNativeHelp(args)) { + return false; + } const parsed = - setupCommand && !skipsValidation ? parseSetupArguments(args) : null; + setupCommand ? parseSetupArguments(args) : null; if (parsed && !parsed.valid) { throw new Error(parsed.error); } diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index f2c4cce9..cf8b3399 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -215,6 +215,43 @@ test("pseudo-help values never start SDK auth", async () => { } }); +test("native help short-circuits even when it occupies a setup value slot", async () => { + let calls = 0; + const prepared = await prepareCloudSession( + ["setup", "--provider", "--help"], + {}, + { + ensureCloudSession: async () => { + calls += 1; + }, + }, + ); + assert.equal(prepared, false); + assert.equal(calls, 0); +}); + +test("version only bypasses auth when the native CLI treats it as version", async () => { + assert.equal(shouldPrepareCloudSession(["--version"], {}), false); + assert.equal(shouldPrepareCloudSession(["version"], {}), false); + assert.equal( + shouldPrepareCloudSession(["setup", "--local-dir", "--version"], {}), + true, + ); + + let calls = 0; + const prepared = await prepareCloudSession( + ["setup", "--local-dir", "--version"], + {}, + { + ensureCloudSession: async () => { + calls += 1; + }, + }, + ); + assert.equal(prepared, true); + assert.equal(calls, 1); +}); + test("dash-prefixed values follow the native setup grammar", async () => { const args = ["setup", "--local-dir", "-mirror"]; assert.equal(hasValidSetupArguments(args), true); From 809e137cb65e6479618549eea88e5d67a96aff0e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 15:51:32 +0200 Subject: [PATCH 13/14] fix(cli): abort device response reads --- packages/cli/scripts/cloud-auth.cjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/scripts/cloud-auth.cjs b/packages/cli/scripts/cloud-auth.cjs index a742f6eb..fe4a32eb 100644 --- a/packages/cli/scripts/cloud-auth.cjs +++ b/packages/cli/scripts/cloud-auth.cjs @@ -485,6 +485,7 @@ async function startDeviceAuthorization(apiUrl, options = {}) { throwIfAborted(options.signal); const requestSignal = combinedRequestSignal(timeoutMs, options.signal); let response; + let payload; try { response = await fetchImpl(buildApiUrl(apiUrl, "/api/v1/auth/device/start"), { method: "POST", @@ -494,6 +495,8 @@ async function startDeviceAuthorization(apiUrl, options = {}) { // user a bare cursor forever. Fail loudly instead. signal: requestSignal.signal }); + payload = await response.json().catch(() => null); + throwIfAborted(options.signal); } catch (error) { throwIfAborted(options.signal); if (isRequestTimeout(error)) { @@ -509,7 +512,6 @@ async function startDeviceAuthorization(apiUrl, options = {}) { } finally { requestSignal.cleanup(); } - const payload = await response.json().catch(() => null); if (!response.ok) { if (response.status === 404) { throw deviceError( @@ -556,6 +558,7 @@ async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { const pollTimeoutMs = clampTimerDelay(Math.min(requestTimeoutMs, deadline - now())); const requestSignal = combinedRequestSignal(pollTimeoutMs, hooks.signal); let response; + let payload; try { response = await fetchImpl(buildApiUrl(apiUrl, "/api/v1/auth/device/token"), { method: "POST", @@ -566,6 +569,8 @@ async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { }), signal: requestSignal.signal }); + payload = await response.json().catch(() => null); + throwIfAborted(hooks.signal); } catch (error) { throwIfAborted(hooks.signal); if (isRequestTimeout(error)) { @@ -586,7 +591,6 @@ async function pollForDeviceToken(apiUrl, authorization, hooks = {}) { } finally { requestSignal.cleanup(); } - const payload = await response.json().catch(() => null); if (response.ok) { if (!payload?.access_token || !payload.refresh_token || !payload.access_token_expires_at) { throw deviceError("Device login response was missing required fields"); From f0889250acce918cfb1b07d238833f38d4db79ce Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 23 Aug 2026 16:46:35 +0200 Subject: [PATCH 14/14] fix(cli): harden browser login completion --- .../completed/2026-08/traj_2ng1fbz1wsxb.json | 69 +++++++++++++++++++ .../completed/2026-08/traj_2ng1fbz1wsxb.md | 33 +++++++++ .trajectories/index.json | 9 ++- packages/cli/scripts/cloud-auth.cjs | 7 +- packages/cli/scripts/cloud-preflight.js | 1 + packages/cli/scripts/cloud-preflight.test.js | 37 ++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 .trajectories/completed/2026-08/traj_2ng1fbz1wsxb.json create mode 100644 .trajectories/completed/2026-08/traj_2ng1fbz1wsxb.md diff --git a/.trajectories/completed/2026-08/traj_2ng1fbz1wsxb.json b/.trajectories/completed/2026-08/traj_2ng1fbz1wsxb.json new file mode 100644 index 00000000..db6f4ba7 --- /dev/null +++ b/.trajectories/completed/2026-08/traj_2ng1fbz1wsxb.json @@ -0,0 +1,69 @@ +{ + "id": "traj_2ng1fbz1wsxb", + "version": 1, + "task": { + "title": "Address final Relayfile self-serve onboarding review feedback and merge", + "source": { + "system": "plain", + "id": "PR-438" + } + }, + "status": "completed", + "startedAt": "2026-08-23T14:42:40.801Z", + "completedAt": "2026-08-23T14:45:33.103Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-23T14:42:40.884Z" + } + ], + "chapters": [ + { + "id": "chap_2fufyvo2tx68", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-23T14:42:40.884Z", + "endedAt": "2026-08-23T14:45:33.103Z", + "events": [ + { + "ts": 1787496160884, + "type": "decision", + "content": "Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors: Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors", + "raw": { + "question": "Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors", + "chosen": "Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors", + "alternatives": [], + "reasoning": "The wrapper deadline alone could not extend the SDK's fixed five-minute callback timer, and Node child-process spawn failures bypass try/catch unless an error listener is attached." + }, + "significance": "high" + }, + { + "ts": 1787496333041, + "type": "reflection", + "content": "Final review findings are addressed with one behavioral regression test; CLI tests, contract checks, Go tests, Go vet, syntax checks, and affected TypeScript package typechecks pass.", + "raw": { + "confidence": 0.95 + }, + "significance": "high", + "tags": [ + "confidence:0.95" + ] + } + ] + } + ], + "retrospective": { + "summary": "Propagated the configured login timeout through Relayfile's Cloud SDK boundary, handled asynchronous browser launcher failures, and added behavioral coverage for both.", + "approach": "Standard approach", + "confidence": 0.95 + }, + "commits": [], + "filesChanged": [], + "projectId": "/private/tmp/relayfile-438-fix.79dx8Z", + "tags": [], + "_trace": { + "startRef": "809e137cb65e6479618549eea88e5d67a96aff0e", + "endRef": "809e137cb65e6479618549eea88e5d67a96aff0e" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-08/traj_2ng1fbz1wsxb.md b/.trajectories/completed/2026-08/traj_2ng1fbz1wsxb.md new file mode 100644 index 00000000..df5226d9 --- /dev/null +++ b/.trajectories/completed/2026-08/traj_2ng1fbz1wsxb.md @@ -0,0 +1,33 @@ +# Trajectory: Address final Relayfile self-serve onboarding review feedback and merge + +> **Status:** ✅ Completed +> **Task:** PR-438 +> **Confidence:** 95% +> **Started:** August 23, 2026 at 04:42 PM +> **Completed:** August 23, 2026 at 04:45 PM + +--- + +## Summary + +Propagated the configured login timeout through Relayfile's Cloud SDK boundary, handled asynchronous browser launcher failures, and added behavioral coverage for both. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors +- **Chose:** Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors +- **Reasoning:** The wrapper deadline alone could not extend the SDK's fixed five-minute callback timer, and Node child-process spawn failures bypass try/catch unless an error listener is attached. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors: Propagate the CLI login timeout into the bundled Cloud SDK and swallow asynchronous browser-launch errors +- Final review findings are addressed with one behavioral regression test; CLI tests, contract checks, Go tests, Go vet, syntax checks, and affected TypeScript package typechecks pass. diff --git a/.trajectories/index.json b/.trajectories/index.json index e0d0bf45..44a37940 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-08-15T21:42:38.397Z", + "lastUpdated": "2026-08-23T14:45:33.239Z", "trajectories": { "traj_4pvrlmqfnzng": { "title": "Review PR #278 in AgentWorkforce/relayfile", @@ -190,6 +190,13 @@ "startedAt": "2026-08-15T21:38:24.295Z", "completedAt": "2026-08-15T21:42:38.104Z", "path": ".trajectories/completed/2026-08/traj_1pmb0dufncg0.json" + }, + "traj_2ng1fbz1wsxb": { + "title": "Address final Relayfile self-serve onboarding review feedback and merge", + "status": "completed", + "startedAt": "2026-08-23T14:42:40.801Z", + "completedAt": "2026-08-23T14:45:33.103Z", + "path": ".trajectories/completed/2026-08/traj_2ng1fbz1wsxb.json" } } } diff --git a/packages/cli/scripts/cloud-auth.cjs b/packages/cli/scripts/cloud-auth.cjs index fe4a32eb..caabd4f7 100644 --- a/packages/cli/scripts/cloud-auth.cjs +++ b/packages/cli/scripts/cloud-auth.cjs @@ -955,6 +955,7 @@ function redirectToHostedCliAuthPage(response, apiUrl, options) { async function beginBrowserLogin(apiUrl, options = {}) { throwIfAborted(options.signal); const state = (0, import_node_crypto.randomUUID)(); + const loginTimeoutMs = typeof options.loginTimeoutMs === "number" && Number.isFinite(options.loginTimeoutMs) && options.loginTimeoutMs > 0 ? Math.min(options.loginTimeoutMs, 2147483647) : 5 * 6e4; return new Promise((resolve, reject) => { let settled = false; let timeout; @@ -1041,6 +1042,8 @@ async function beginBrowserLogin(apiUrl, options = {}) { console.log("If the browser does not open, paste this URL into your browser."); try { const child = openBrowser(loginUrl.toString()); + child.once("error", () => { + }); child.unref(); } catch { } @@ -1059,7 +1062,7 @@ async function beginBrowserLogin(apiUrl, options = {}) { if (!settled) { timeout = setTimeout(() => { finish(reject, new Error("Timed out waiting for browser login")); - }, 5 * 6e4); + }, loginTimeoutMs); timeout.unref(); } }); @@ -1156,6 +1159,7 @@ async function ensureCloudSession(options = {}) { device: options.device, env, client: options.client, + loginTimeoutMs: options.loginTimeoutMs, signal: options.signal }); return createCloudSession(auth, { refreshTimeoutMs }); @@ -1179,6 +1183,7 @@ async function ensureCloudSession(options = {}) { device: options.device, env, client: options.client, + loginTimeoutMs: options.loginTimeoutMs, signal: options.signal }); return createCloudSession(auth, { refreshTimeoutMs }); diff --git a/packages/cli/scripts/cloud-preflight.js b/packages/cli/scripts/cloud-preflight.js index ed6a7b9a..e069065b 100644 --- a/packages/cli/scripts/cloud-preflight.js +++ b/packages/cli/scripts/cloud-preflight.js @@ -269,6 +269,7 @@ async function prepareCloudSession(args, env = process.env, dependencies = {}) { client: "relayfile", interactive: true, device: parsed.values.get("no-open") === true, + loginTimeoutMs, refreshTimeoutMs: Math.max( 1, Math.min(loginTimeoutMs, DEFAULT_REFRESH_TIMEOUT_MS), diff --git a/packages/cli/scripts/cloud-preflight.test.js b/packages/cli/scripts/cloud-preflight.test.js index cf8b3399..a90eda0a 100644 --- a/packages/cli/scripts/cloud-preflight.test.js +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -53,6 +53,7 @@ test("bare relayfile prepares Cloud auth through the Agent Relay SDK", async () client: "relayfile", interactive: true, device: false, + loginTimeoutMs: 300000, refreshTimeoutMs: 10000, signal: calls[0].signal, }, @@ -91,6 +92,7 @@ test("setup forwards its Cloud URL and no-open mode to the SDK", async () => { client: "relayfile", interactive: true, device: true, + loginTimeoutMs: 10000, refreshTimeoutMs: 10000, signal: received.signal, }); @@ -153,6 +155,41 @@ test("bundled SDK aborts device polling without issuing or storing credentials", assert.equal(result.status, 0, result.stderr || result.stdout); }); +test("bundled SDK handles browser-launch errors and honors the login timeout", () => { + const modulePath = path.join(__dirname, "cloud-auth.cjs"); + const script = ` + const os = require("node:os"); + os.platform = () => "linux"; + process.env.PATH = ""; + const { ensureCloudSession } = require(${JSON.stringify(modulePath)}); + console.log = () => {}; + const startedAt = Date.now(); + ensureCloudSession({ + apiUrl: "https://example.test/cloud", + client: "relayfile", + interactive: true, + device: false, + force: true, + env: { DISPLAY: ":99" }, + loginTimeoutMs: 25, + }).then( + () => process.exit(2), + (error) => { + const elapsedMs = Date.now() - startedAt; + const passed = + error.message === "Timed out waiting for browser login" && + elapsedMs < 1000; + setTimeout(() => process.exit(passed ? 0 : 3), 25); + }, + ); + `; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 2000, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); +}); + test("help and caller-owned tokens do not start interactive auth", () => { assert.equal(shouldPrepareCloudSession(["setup", "--help"], {}), false); assert.equal(shouldPrepareCloudSession(["setup", "--help=true"], {}), false);