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/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..391847fe 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 @@ -601,7 +604,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 +673,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 +899,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 +986,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") @@ -999,7 +1032,7 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { 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 { @@ -1048,6 +1081,7 @@ func runSetup(args []string, stdin io.Reader, stdout io.Writer) error { localDir = "./relayfile-mount" } } + name, localDir = resolveSetupTarget(name, localDir, options.preserveExistingLocalDir) absLocalDir, err := filepath.Abs(localDir) if err != nil { return err @@ -1128,10 +1162,72 @@ 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 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 + } + + requestedIdentity := setupTargetPathIdentity(requestedLocalDir) + existing, ok := workspaceRecordByName(workspaceName) + if !ok { + return workspaceName, requestedLocalDir + } + 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(requestedIdentity))) + 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 candidateIdentity := setupTargetPathIdentity(candidateRecord.LocalDir); candidateIdentity != "" && candidateIdentity == requestedIdentity { + return candidate, candidateRecord.LocalDir + } + } + + 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)) +} + 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..9ad371ab 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,133 @@ 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 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), + 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) + projectDir := t.TempDir() + existingDir := filepath.Join(projectDir, "relayfile-mount") + if _, err := upsertWorkspaceDetails(workspaceRecord{ + Name: "acme-api", + ID: "ws_acme", + LocalDir: existingDir, + }); err != nil { + t.Fatalf("store existing workspace: %v", err) + } + + 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) + } + 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) + } +} + +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 @@ -4152,6 +4280,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/docs/cli-design.md b/docs/cli-design.md index 0d976040..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.** 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. 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. @@ -26,22 +26,26 @@ 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`. 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 + `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 +104,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 +120,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,23 +137,32 @@ 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. 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. -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. 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. 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` @@ -598,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/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..69769bc0 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 @@ -323,17 +329,18 @@ 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. @@ -482,9 +506,9 @@ If the refresh token also expires (default 7 days), the mount **MUST**: - Continue serving local reads from disk (already-synced state). - Refuse local writes for affected paths and place them in `.relay/permissions-denied.log` with reason `cloud_session_expired`. -- Print one stderr line per minute (capped) directing the user to run - `agent-relay cloud login`. This is the only condition under which v1 mounts - enter a degraded read-only state. +- Print one stderr line per minute (capped) directing the user to rerun + `npx relayfile@latest` or `agent-relay cloud login`. This is the only + condition under which v1 mounts enter a degraded read-only state. --- @@ -784,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`, @@ -811,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/package.json b/package.json index 9bd480ed..e2d70216 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "scripts": { "workflow": "agent-relay run", "build": "npm run build --workspace=packages/core && npm run build --workspace=packages/sdk/typescript && npm run build --workspace=@relayfile/client && npm run build --workspace=packages/agents && npm run build --workspace=packages/local-mount && npm run build --workspace=packages/cli", - "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=@relayfile/client && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test:go", + "test": "npm run test --workspace=packages/core && npm run test --workspace=packages/sdk/typescript && npm run test --workspace=@relayfile/client && npm run test --workspace=packages/local-mount && npm run test --workspace=@relayfile/file-observer && npm run test --workspace=packages/cli && npm run test:go", "test:go": "go test ./...", "typecheck": "npm run typecheck --workspace=packages/sdk/typescript && npm run typecheck --workspace=@relayfile/client && npm run typecheck --workspace=packages/agents && npm run typecheck --workspace=packages/local-mount && npm run typecheck:go", "typecheck:go": "go vet ./...", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index f96328ec..57f20259 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Mount bootstrap stalls now terminate the CLI daemon after the persisted retry limit, survive supervisor restarts without rereading already committed files, and surface as `mount: stalled` with the blocked path. Bootstrap progress uses the tree API's authoritative file total when safe, suppresses totals that include pruned runtime subtrees, and never renders a synthetic `/0` denominator. -- Cloud session auth no longer shells out to `agent-relay cloud session`. The CLI reads the canonical credential file `agent-relay cloud login` writes (`~/.agentworkforce/relay/cloud-auth.json`), or the `CLOUD_API_*` environment, and refreshes preemptively — an access token within five minutes of expiry, or a refresh token within 24 hours of its own expiry — through Cloud's `/api/v1/auth/token/refresh` endpoint, writing the rotated pair back under the same lock `agent-relay` uses. This restores auto-recovery from a routine token expiry. A session supplied through `CLOUD_API_*` is refreshed in memory only and never written to disk; an access token supplied with no refresh token is used as-is and never refreshed. +- Cloud session auth no longer shells out to `agent-relay cloud session`. The CLI reads the canonical credential file shared with the Agent Relay Cloud SDK (`~/.agentworkforce/relay/cloud-auth.json`), or the `CLOUD_API_*` environment, and refreshes preemptively — an access token within five minutes of expiry, or a refresh token within 24 hours of its own expiry — through Cloud's `/api/v1/auth/token/refresh` endpoint, writing the rotated pair back under the same lock Agent Relay uses. This restores auto-recovery from a routine token expiry. A session supplied through `CLOUD_API_*` is refreshed in memory only and never written to disk; an access token supplied with no refresh token is used as-is and never refreshed. - Relayfile no longer reads `AGENT_RELAY_BIN` to locate the `agent-relay` CLI. Agent Relay uses that variable for the *broker* binary, so every relay-spawned agent pointed Relayfile at `agent-relay-broker` — which has no `cloud` or `workspace` subcommand — and a routine expiry surfaced as `agent-relay CLI >= 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..caabd4f7 --- /dev/null +++ b/packages/cli/scripts/cloud-auth.cjs @@ -0,0 +1,1266 @@ +/* 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 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; + } + 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); + 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", + 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: requestSignal.signal + }); + payload = await response.json().catch(() => null); + throwIfAborted(options.signal); + } catch (error) { + throwIfAborted(options.signal); + 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 + }); + } finally { + requestSignal.cleanup(); + } + 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, 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 (; ; ) { + 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; + let payload; + 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: requestSignal.signal + }); + payload = await response.json().catch(() => null); + throwIfAborted(hooks.signal); + } catch (error) { + throwIfAborted(hooks.signal); + 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 } + ); + } finally { + requestSignal.cleanup(); + } + 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 } : {}, + ...options.signal ? { signal: options.signal } : {} + }); + 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, options = {}) { + throwIfAborted(options.signal); + 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 { + 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 }); + } +} +async function refreshStoredCloudIdentity(auth, options = {}) { + const env = options.env ?? process.env; + try { + const { response } = await authorizedApiFetch( + auth, + "/api/v1/auth/whoami", + { method: "GET", signal: options.signal }, + { 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, 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; + 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") { + 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 + }); + finish(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." + }); + finish(reject, new Error("CLI login callback was missing required fields")); + return; + } + redirectToHostedCliAuthPage(response, returnedApiUrl, { + status: "success", + detail: `API endpoint: ${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") { + finish(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); + 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 { + const child = openBrowser(loginUrl.toString()); + child.once("error", () => { + }); + child.unref(); + } catch { + } + }); + server.on("error", (error) => { + finish(reject, error); + }); + 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(); + } + } + if (!settled) { + timeout = setTimeout(() => { + finish(reject, new Error("Timed out waiting for browser login")); + }, loginTimeoutMs); + timeout.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, options); + 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, 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; + console.log(`Signed in as ${identity.email}${org ? ` (${org})` : ""}`); + } + return auth; +} +async function loginWithBrowser(apiUrl, options = {}) { + return completeLogin(await beginBrowserLogin(apiUrl, options), options); +} +async function loginWithDevice(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( + 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, + client: options.client, + loginTimeoutMs: options.loginTimeoutMs, + signal: options.signal + }); + return createCloudSession(auth, { refreshTimeoutMs }); + } + if (!shouldRefreshStoredAuth(stored)) { + throwIfAborted(options.signal); + return createCloudSession(stored, { refreshTimeoutMs }); + } + try { + const auth = await refreshStoredAuth(stored, { refreshTimeoutMs, signal: options.signal }); + return createCloudSession(auth, { refreshTimeoutMs }); + } catch (error) { + throwIfAborted(options.signal); + if (isEnvBackedAuth(stored)) { + throw toEnvAuthRefreshError(error); + } + if (!interactive) { + throw error; + } + const auth = await loginInteractive(stored.apiUrl, { + device: options.device, + env, + client: options.client, + loginTimeoutMs: options.loginTimeoutMs, + signal: options.signal + }); + 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..e069065b --- /dev/null +++ b/packages/cli/scripts/cloud-preflight.js @@ -0,0 +1,312 @@ +"use strict"; + +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; +const MAX_GO_DURATION_NANOSECONDS = 9_223_372_036_854_775_807; +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", +]); +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 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) { + 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; + } + 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 parts > 0 ? (sign * nanoseconds) / 1_000_000 : null; +} + +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 === "--") { + if (index !== setupArgs.length - 1) { + return { valid: false, error: "setup does not accept positional arguments" }; + } + break; + } + + const match = /^--?([^=]+)(?:=(.*))?$/.exec(arg); + if (!match) { + return { + valid: false, + error: `unexpected setup argument ${JSON.stringify(arg)}`, + }; + } + const [, name, inlineValue] = match; + const takesValue = SETUP_FLAGS.get(name); + if (takesValue === undefined) { + return { valid: false, error: `unknown setup flag --${name}` }; + } + if (takesValue) { + let value = inlineValue; + if (inlineValue === undefined) { + const next = setupArgs[index + 1]; + if (next === undefined) { + 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 { + valid: false, + error: `--${name} has invalid boolean value ${JSON.stringify(inlineValue)}`, + }; + } + values.set(name, inlineValue === undefined || GO_TRUE_VALUES.has(inlineValue)); + } + + 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) { + const setupCommand = args.length === 0 || args[0] === "setup"; + if (!setupCommand) { + return false; + } + if (wantsNativeVersion(args) || wantsNativeHelp(args)) { + 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. + const relayfileCloudToken = parsed.values.has("cloud-token") + ? String(parsed.values.get("cloud-token") || "").trim() + : String(env.RELAYFILE_CLOUD_TOKEN || "").trim(); + if ( + relayfileCloudToken || + String(env.CLOUD_API_ACCESS_TOKEN || "").trim() + ) { + return false; + } + // Let the native CLI report malformed flags without first opening a login + // flow or mutating the caller's canonical Cloud session. + 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 { + 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 = {}) { + const setupCommand = args.length === 0 || args[0] === "setup"; + if (wantsNativeVersion(args) || wantsNativeHelp(args)) { + return false; + } + const parsed = + setupCommand ? parseSetupArguments(args) : null; + if (parsed && !parsed.valid) { + throw new Error(parsed.error); + } + if (!shouldPrepareCloudSession(args, env)) { + return false; + } + + const ensureCloudSession = + dependencies.ensureCloudSession || loadCloudSessionSDK(); + const apiUrl = + 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; + const loginTimeoutMs = + parsed.durations.get("login-timeout") || DEFAULT_LOGIN_TIMEOUT_MS; + const loginAbort = new AbortController(); + let timer; + try { + await Promise.race([ + ensureCloudSession({ + apiUrl, + client: "relayfile", + interactive: true, + device: parsed.values.get("no-open") === true, + loginTimeoutMs, + refreshTimeoutMs: Math.max( + 1, + Math.min(loginTimeoutMs, DEFAULT_REFRESH_TIMEOUT_MS), + ), + signal: loginAbort.signal, + }), + new Promise((_, reject) => { + 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 { + 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 + // 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; +} + +module.exports = { + DEFAULT_CLOUD_API_URL, + SETUP_INTENT, + SETUP_INTENT_PRINTED_ENV, + announceSetupIntent, + hasValidSetupArguments, + parseGoDurationMilliseconds, + parseSetupArguments, + 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..a90eda0a --- /dev/null +++ b/packages/cli/scripts/cloud-preflight.test.js @@ -0,0 +1,491 @@ +"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 { + 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 = []; + 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.equal(calls[0].signal instanceof AbortSignal, true); + assert.equal(calls[0].signal.aborted, false); + assert.deepEqual(calls, [ + { + apiUrl: "https://agentrelay.com/cloud", + client: "relayfile", + interactive: true, + device: false, + loginTimeoutMs: 300000, + refreshTimeoutMs: 10000, + signal: calls[0].signal, + }, + ]); + assert.deepEqual(env, {}); +}); + +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", + "--login-timeout=10s", + ], + 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", + client: "relayfile", + interactive: true, + device: true, + loginTimeoutMs: 10000, + refreshTimeoutMs: 10000, + signal: received.signal, + }); + assert.equal(received.signal instanceof AbortSignal, true); + 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/); + 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("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); + assert.equal(shouldPrepareCloudSession(["setup", "--help=false"], {}), false); + assert.equal(shouldPrepareCloudSession(["setup", "-h=0"], {}), false); + assert.equal( + shouldPrepareCloudSession(["setup", "--cloud-token", "explicit"], {}), + false, + ); + assert.equal( + shouldPrepareCloudSession(["setup", "-cloud-token", "explicit"], {}), + false, + ); + assert.equal( + shouldPrepareCloudSession(["setup", "--cloud-token="], {}), + true, + ); + assert.equal( + 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, + ); + assert.equal(shouldPrepareCloudSession(["status"], {}), false); +}); + +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, + { RELAYFILE_CLOUD_TOKEN: "inherited-token" }, + { + ensureCloudSession: async () => { + calls += 1; + }, + }, + ); + assert.equal(prepared, false); + assert.equal(calls, 0); + } +}); + +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); + 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); + assert.equal(hasValidSetupArguments(["setup", "unexpected"]), false); + assert.equal( + shouldPrepareCloudSession(["setup", "--provider"], {}), + false, + ); + assert.equal( + shouldPrepareCloudSession(["setup", "--unknown"], {}), + false, + ); + let authCalls = 0; + await assert.rejects( + prepareCloudSession( + ["setup", "--provider"], + {}, + { + ensureCloudSession: async () => { + authCalls += 1; + throw new Error("interactive auth must not run"); + }, + }, + ), + /--provider requires a value/, + ); + 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 () => { + let receivedSignal; + let lateCredentialWrite = false; + await assert.rejects( + prepareCloudSession( + ["setup", "--login-timeout=1ms"], + {}, + { + 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 () => { + 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([ + "setup", + "--provider", + "github", + "--workspace=frontend", + "--once", + "--no-open=true", + ]), + true, + ); + assert.equal( + shouldPrepareCloudSession( + ["setup", "--provider", "github", "--workspace=frontend", "--once"], + {}, + ), + true, + ); +}); + +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 }); + 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 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: 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: childEnv, + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + apiUrl: "https://cloud.example", + 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 9eb1622b..4f71e0ea 100755 --- a/packages/cli/scripts/run.js +++ b/packages/cli/scripts/run.js @@ -12,6 +12,11 @@ if (args[0] === "--version") { process.exit(0); } +const { + announceSetupIntent, + prepareCloudSession, +} = require("./cloud-preflight.js"); + const PLATFORM_MAP = { darwin: "darwin", linux: "linux", @@ -58,46 +63,60 @@ 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. 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; + 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); +});