diff --git a/cmd/relayfile-cli/main.go b/cmd/relayfile-cli/main.go index e9f6f199..a46952c9 100644 --- a/cmd/relayfile-cli/main.go +++ b/cmd/relayfile-cli/main.go @@ -64,6 +64,7 @@ var relayfileVersion = relayfileDefaultVersion var relayIntegrationBindingsMu sync.Mutex var agentRelayWorkspaceKeyInResolverError = regexp.MustCompile(`(?:[?&]key=|/api/v1/workspaces/)(rk_live_[A-Za-z0-9_-]+)`) +var usableAgentRelayWorkspaceKeyPattern = regexp.MustCompile(`^rk_live_[A-Za-z0-9_-]+$`) // defaultJoinScopes are the scopes minted for every delegated-credential // workspace join. ops:read is required for writeback op-status polling @@ -1249,8 +1250,16 @@ func runAgentRelayLogin(stdin io.Reader, stdout io.Writer, noOpen bool) error { func cloudCredentialsFromAgentRelay() (cloudCredentials, error) { var session agentRelayCloudSession - if err := runAgentRelayJSON([]string{"cloud", "session", "--json"}, &session); err != nil { - return cloudCredentials{}, err + // agent-relay masks accessToken in `cloud session --json` unless + // --reveal-token is passed; CLIs predating the flag reject it as an + // unknown option. Ask for the raw token first, fall back for older CLIs. + if err := runAgentRelayJSON([]string{"cloud", "session", "--json", "--reveal-token"}, &session); err != nil { + if !strings.Contains(err.Error(), "unknown option") { + return cloudCredentials{}, err + } + if err := runAgentRelayJSON([]string{"cloud", "session", "--json"}, &session); err != nil { + return cloudCredentials{}, err + } } apiURL := strings.TrimRight(strings.TrimSpace(session.APIURL), "/") accessToken := strings.TrimSpace(session.AccessToken) @@ -1266,6 +1275,9 @@ func cloudCredentialsFromAgentRelay() (cloudCredentials, error) { if accessToken == "" { return cloudCredentials{}, errors.New("agent-relay cloud session --json did not include an accessToken") } + if strings.Contains(accessToken, "…") { + return cloudCredentials{}, errors.New("agent-relay cloud session --json returned a masked accessToken; upgrade the agent-relay CLI or re-run `agent-relay cloud login`") + } return cloudCredentials{ APIURL: apiURL, AccessToken: accessToken, @@ -1275,8 +1287,16 @@ func cloudCredentialsFromAgentRelay() (cloudCredentials, error) { func activeWorkspaceFromAgentRelay() (agentRelayActiveWorkspace, error) { var workspace agentRelayActiveWorkspace - if err := runAgentRelayJSON([]string{"workspace", "active", "--json"}, &workspace); err != nil { - return agentRelayActiveWorkspace{}, classifyAgentRelayActiveWorkspaceError(err) + // agent-relay masks workspace keys in `workspace active --json` unless + // --reveal-secrets is passed; CLIs predating the flag reject it as an + // unknown option. Ask for raw keys first, fall back for older CLIs. + if err := runAgentRelayJSON([]string{"workspace", "active", "--json", "--reveal-secrets"}, &workspace); err != nil { + if !strings.Contains(err.Error(), "unknown option") { + return agentRelayActiveWorkspace{}, classifyAgentRelayActiveWorkspaceError(err) + } + if err := runAgentRelayJSON([]string{"workspace", "active", "--json"}, &workspace); err != nil { + return agentRelayActiveWorkspace{}, classifyAgentRelayActiveWorkspaceError(err) + } } if strings.TrimSpace(workspace.RelayfileWorkspaceID) == "" { return agentRelayActiveWorkspace{}, errors.New("agent-relay workspace active --json did not include relayfileWorkspaceId") @@ -1300,12 +1320,19 @@ func classifyAgentRelayActiveWorkspaceError(err error) error { !strings.Contains(normalized, "404") { return err } - match := agentRelayWorkspaceKeyInResolverError.FindStringSubmatch(detail) - if len(match) != 2 { - return err + workspaceKey := "" + if match := agentRelayWorkspaceKeyInResolverError.FindStringSubmatch(detail); len(match) == 2 { + if unescaped, unescapeErr := url.QueryUnescape(match[1]); unescapeErr == nil { + workspaceKey = usableAgentRelayWorkspaceKey(unescaped) + } + } + if workspaceKey == "" { + // agent-relay redacts credentials in its error output, so the key can + // no longer be read out of the message; fall back to the environment + // and the shared workspace store. + workspaceKey = activeWorkspaceKeyFromAgentRelayStore() } - workspaceKey, unescapeErr := url.QueryUnescape(match[1]) - if unescapeErr != nil || !strings.HasPrefix(workspaceKey, "rk_live_") { + if workspaceKey == "" { return err } @@ -1331,6 +1358,53 @@ func classifyAgentRelayActiveWorkspaceError(err error) error { } } +// usableAgentRelayWorkspaceKey converts external key-shaped input into either a +// validated workspace key or the empty sentinel. Agent Relay intentionally +// keeps masked values recognizable by retaining the rk_live_ prefix, so +// relayfile must enforce usability at its own trust boundary instead of trying +// to change or outguess Relay's display contract. +func usableAgentRelayWorkspaceKey(value string) string { + value = strings.TrimSpace(value) + if !usableAgentRelayWorkspaceKeyPattern.MatchString(value) { + return "" + } + return value +} + +// activeWorkspaceKeyFromAgentRelayStore resolves the active workspace key the +// way agent-relay itself does: canonical env vars first, then the shared +// workspace store at $AGENT_RELAY_HOME (default ~/.agentworkforce/relay) +// /workspaces.json. Returns "" when no key can be resolved. +func activeWorkspaceKeyFromAgentRelayStore() string { + for _, name := range []string{"AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACE_KEY", "RELAY_API_KEY"} { + if value := usableAgentRelayWorkspaceKey(os.Getenv(name)); value != "" { + return value + } + } + dir := strings.TrimSpace(os.Getenv("AGENT_RELAY_HOME")) + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir = filepath.Join(home, ".agentworkforce", "relay") + } + data, err := os.ReadFile(filepath.Join(dir, "workspaces.json")) + if err != nil { + return "" + } + var store struct { + Active string `json:"active"` + Workspaces map[string]struct { + Key string `json:"key"` + } `json:"workspaces"` + } + if json.Unmarshal(data, &store) != nil || store.Active == "" { + return "" + } + return usableAgentRelayWorkspaceKey(store.Workspaces[store.Active].Key) +} + func validateRelaycastWorkspaceKey(ctx context.Context, workspaceKey string) (string, int, error) { baseURL := firstNonEmpty(os.Getenv("RELAYCAST_BASE_URL"), os.Getenv("RELAY_BASE_URL"), defaultRelaycastAPIURL) endpoint := strings.TrimRight(baseURL, "/") + "/v1/workspace" diff --git a/cmd/relayfile-cli/main_test.go b/cmd/relayfile-cli/main_test.go index 022fc6b9..06eb7b03 100644 --- a/cmd/relayfile-cli/main_test.go +++ b/cmd/relayfile-cli/main_test.go @@ -2885,11 +2885,11 @@ fi func installFakeAgentRelaySession(t *testing.T, apiURL, accessToken, name, cloudWorkspaceID, relayfileWorkspaceID string) { t.Helper() body := fmt.Sprintf(` -if [ "$*" = "cloud session --json" ]; then +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then echo '{"apiUrl":%q,"accessToken":%q}' exit 0 fi -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo '{"name":%q,"cloudWorkspaceId":%q,"relayfileWorkspaceId":%q}' exit 0 fi @@ -2899,6 +2899,74 @@ exit 2 installFakeAgentRelay(t, body) } +func TestCloudCredentialsFallBackWhenRevealTokenUnsupported(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installFakeAgentRelay(t, ` +if [ "$*" = "cloud session --json --reveal-token" ]; then + echo "error: unknown option '--reveal-token'" >&2 + exit 1 +fi +if [ "$*" = "cloud session --json" ]; then + echo '{"apiUrl":"https://cloud.test","accessToken":"cld_at_raw_fallback_token"}' + exit 0 +fi +echo "unexpected args: $*" >&2 +exit 2 +`) + + creds, err := cloudCredentialsFromAgentRelay() + if err != nil { + t.Fatalf("cloudCredentialsFromAgentRelay failed: %v", err) + } + if creds.AccessToken != "cld_at_raw_fallback_token" { + t.Fatalf("unexpected access token: %q", creds.AccessToken) + } +} + +func TestCloudCredentialsRejectMaskedAccessToken(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installFakeAgentRelay(t, ` +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then + echo '{"apiUrl":"https://cloud.test","accessToken":"cld_at_…oken"}' + exit 0 +fi +echo "unexpected args: $*" >&2 +exit 2 +`) + + _, err := cloudCredentialsFromAgentRelay() + if err == nil || !strings.Contains(err.Error(), "masked accessToken") { + t.Fatalf("expected masked accessToken error, got %v", err) + } +} + +func TestActiveWorkspaceFallsBackWhenRevealSecretsUnsupported(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + installFakeAgentRelay(t, ` +if [ "$*" = "workspace active --json --reveal-secrets" ]; then + echo "error: unknown option '--reveal-secrets'" >&2 + exit 1 +fi +if [ "$*" = "workspace active --json" ]; then + echo '{"name":"legacy","cloudWorkspaceId":"rw_cloud","relayfileWorkspaceId":"rw_relayfile"}' + exit 0 +fi +echo "unexpected args: $*" >&2 +exit 2 +`) + + workspace, err := activeWorkspaceFromAgentRelay() + if err != nil { + t.Fatalf("activeWorkspaceFromAgentRelay failed: %v", err) + } + if workspace.Name != "legacy" || workspace.CloudWorkspaceID != "rw_cloud" || workspace.RelayfileWorkspaceID != "rw_relayfile" { + t.Fatalf("unexpected workspace fallback result: %+v", workspace) + } +} + func relayfileCLITestFixture(t *testing.T, name string) string { t.Helper() content, err := os.ReadFile(filepath.Join("testdata", name)) @@ -2912,6 +2980,70 @@ func agentRelayResolver404Error(workspaceKey, body string) error { return errors.New("Workspace resolve failed at /api/v1/workspaces/active?key=" + workspaceKey + ": 404 " + body) } +func TestActiveWorkspaceClassifierFallsBackToStoreWhenErrorRedacted(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + clearRelayfileEnv(t) + for _, name := range []string{"AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACE_KEY", "RELAY_API_KEY", "AGENT_RELAY_HOME"} { + t.Setenv(name, "") + } + // A masked environment value must normalize to the empty sentinel so it + // cannot block fallback to the usable key in the shared workspace store. + const maskedWorkspaceKey = "rk_live_…masked" + if got := usableAgentRelayWorkspaceKey(maskedWorkspaceKey); got != "" { + t.Fatalf("masked workspace key normalized to %q, want empty sentinel", got) + } + t.Setenv("AGENT_RELAY_WORKSPACE_KEY", maskedWorkspaceKey) + + const workspaceKey = "rk_live_messaging_only_redacted" + var validationCalls int + relaycast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validationCalls++ + if got := r.Header.Get("Authorization"); got != "Bearer "+workspaceKey { + t.Fatalf("unexpected Relaycast Authorization: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"data":{"id":"rc_123","name":"chat-only"}}`)) + })) + defer relaycast.Close() + t.Setenv("RELAYCAST_BASE_URL", relaycast.URL) + + storeDir := filepath.Join(os.Getenv("HOME"), ".agentworkforce", "relay") + if err := os.MkdirAll(storeDir, 0o700); err != nil { + t.Fatalf("mkdir workspace store dir failed: %v", err) + } + store := `{"active":"demo","workspaces":{"demo":{"key":"` + workspaceKey + `"}}}` + if err := os.WriteFile(filepath.Join(storeDir, "workspaces.json"), []byte(store), 0o600); err != nil { + t.Fatalf("write workspace store failed: %v", err) + } + + // A CLI that redacts credentials emits a masked key the resolver-error + // regex cannot capture; the classifier must fall back to the store. + resolverFailure := agentRelayResolver404Error("rk_live_…cted", relayfileCLITestFixture(t, "cloud-workspace-not-found.json")) + installFakeAgentRelay(t, fmt.Sprintf(` +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then + printf '%%s\n' %s >&2 + exit 1 +fi +echo "unexpected args: $*" >&2 +exit 2 +`, strconv.Quote(resolverFailure.Error()))) + + _, err := activeWorkspaceFromAgentRelay() + if err == nil { + t.Fatal("expected messaging-only workspace error") + } + var messagingOnly *agentRelayMessagingOnlyWorkspaceError + if !errors.As(err, &messagingOnly) { + t.Fatalf("expected agentRelayMessagingOnlyWorkspaceError, got %T: %v", err, err) + } + if messagingOnly.Name != "chat-only" { + t.Fatalf("messaging-only workspace name = %q, want chat-only", messagingOnly.Name) + } + if validationCalls != 1 { + t.Fatalf("expected exactly one Relaycast validation call, got %d", validationCalls) + } +} + func TestActiveWorkspaceFromAgentRelayReportsMessagingOnlyWorkspace(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) @@ -2936,7 +3068,7 @@ func TestActiveWorkspaceFromAgentRelayReportsMessagingOnlyWorkspace(t *testing.T t.Setenv("RELAYCAST_BASE_URL", relaycast.URL) resolverFailure := agentRelayResolver404Error(workspaceKey, relayfileCLITestFixture(t, "cloud-workspace-not-found.json")) installFakeAgentRelay(t, fmt.Sprintf(` -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then printf '%%s\n' %s >&2 exit 1 fi @@ -3027,7 +3159,14 @@ func TestClassifyAgentRelayActiveWorkspaceErrorReportsUnexpectedRelaycastStatus( } func TestClassifyAgentRelayActiveWorkspaceErrorPreservesRegexMiss(t *testing.T) { + // A regex miss falls through to Agent Relay's shared workspace store. + // Isolate every store input so this test cannot read an operator's live key. + t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) + for _, name := range []string{"AGENT_RELAY_WORKSPACE_KEY", "RELAY_WORKSPACE_KEY", "RELAY_API_KEY"} { + t.Setenv(name, "") + } + t.Setenv("AGENT_RELAY_HOME", t.TempDir()) var validationCalls int relaycast := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -3060,7 +3199,7 @@ func TestActiveWorkspaceFromAgentRelayDoesNotMisclassifyInvalidKey(t *testing.T) defer relaycast.Close() t.Setenv("RELAYCAST_BASE_URL", relaycast.URL) installFakeAgentRelay(t, ` -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo 'Workspace resolve failed at /api/v1/workspaces/`+workspaceKey+`/resolve: 404 Workspace not found' >&2 exit 1 fi @@ -3137,11 +3276,11 @@ if [ "$*" = "cloud login --no-open" ]; then echo "agent-relay login ok" exit 0 fi -if [ "$*" = "cloud session --json" ]; then +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then echo '{"apiUrl":"`+cloud.URL+`","accessToken":"cld_access"}' exit 0 fi -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then printf '%%s\n' %s >&2 exit 1 fi @@ -5200,11 +5339,11 @@ if [ "$*" = "cloud login --no-open" ]; then echo "agent-relay login ok" exit 0 fi -if [ "$*" = "cloud session --json" ]; then +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then echo '{"apiUrl":"`+server.URL+`","accessToken":"cld_new"}' exit 0 fi -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo '{"name":"demo","cloudWorkspaceId":"ws_123","relayfileWorkspaceId":"ws_123"}' exit 0 fi @@ -5633,7 +5772,7 @@ func TestRefreshDelegatedCredentialsSurfacesRemintFailure(t *testing.T) { t.Setenv("HOME", t.TempDir()) clearRelayfileEnv(t) installFakeAgentRelay(t, ` -if [ "$*" = "cloud session --json" ]; then +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then echo "no active cloud session" >&2 exit 2 fi @@ -5794,7 +5933,7 @@ func TestEnsureCloudCredentialsUsesAgentRelaySession(t *testing.T) { t.Fatalf("saveCloudCredentials failed: %v", err) } installFakeAgentRelay(t, ` -if [ "$*" = "cloud session --json" ]; then +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then echo '{"apiUrl":"https://relay-cloud.test","accessToken":"agent_cloud_token"}' exit 0 fi @@ -5832,7 +5971,7 @@ if [ "$*" = "--version" ]; then echo "8.3.7" exit 0 fi -if [ "$*" = "cloud session --json" ]; then +if [ "$*" = "cloud session --json --reveal-token" ] || [ "$*" = "cloud session --json" ]; then touch %q echo '{"apiUrl":"https://relay-cloud.test","accessToken":"agent_cloud_token"}' exit 0 @@ -5930,7 +6069,7 @@ func TestWorkspaceCurrentPrefersAgentRelayActiveWorkspace(t *testing.T) { t.Fatalf("setDefaultWorkspace failed: %v", err) } installFakeAgentRelay(t, ` -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo '{"name":"canonical","cloudWorkspaceId":"rw_cloud","relayfileWorkspaceId":"rw_relayfile","relaycastWorkspaceId":"rw_relaycast","relayauthWorkspaceId":"rw_relayauth"}' exit 0 fi @@ -5961,7 +6100,7 @@ func TestResolveWorkspaceUsesAgentRelayRelayfileWorkspaceID(t *testing.T) { t.Fatalf("setDefaultWorkspace failed: %v", err) } installFakeAgentRelay(t, ` -if [ "$*" = "workspace active --json" ]; then +if [ "$*" = "workspace active --json --reveal-secrets" ] || [ "$*" = "workspace active --json" ]; then echo '{"name":"canonical","cloudWorkspaceId":"rw_cloud","relayfileWorkspaceId":"rw_relayfile"}' exit 0 fi