From e8e09d2e6052979bcd7445a3c9d7934e53e5b2c5 Mon Sep 17 00:00:00 2001 From: cjkenned Date: Wed, 20 May 2026 16:35:56 -0700 Subject: [PATCH 1/2] to update a connector, redirect to web app for now --- AGENTS.md | 1 + CONTEXT.md | 15 +- internal/resources/connectors.go | 1 + internal/resources/connectors_test.go | 1 + internal/resources/connectors_update.go | 175 ++++++ internal/resources/connectors_update_test.go | 537 ++++++++++++++++++ internal/spec/extracted_gen.go | 9 + skills/airbyte-agent/SKILL.md | 1 + .../references/connectors-update.md | 65 +++ 9 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 internal/resources/connectors_update.go create mode 100644 internal/resources/connectors_update_test.go create mode 100644 skills/airbyte-agent/references/connectors-update.md diff --git a/AGENTS.md b/AGENTS.md index a63090a..86558f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ The CLI uses a **resource-registry** pattern: | `connectors` | `describe` | Get connector details + schema | `name`+`workspace` or `--id` | | `connectors` | `execute` | Execute a connector action | `name`+`workspace` or `--id`, `entity`, `action`, `params` | | `connectors` | `create` | Interactive credential flow | `workspace`, `name` (template) or `id` (template ID) | +| `connectors` | `update` | Open the browser to edit a connector's credentials | `name`+`workspace` or `--id` | | `connectors` | `delete` | Delete a connector | `name`+`workspace` or `--id` | ### Common Flags diff --git a/CONTEXT.md b/CONTEXT.md index 6598cac..9890cea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -135,7 +135,16 @@ airbyte-agent connectors create --json '{ }' ``` -### 5. Deleting a Connector +### 5. Updating Connector Credentials + +```bash +# Open the credentials page in your browser to edit an existing connector +airbyte-agent connectors update --json '{"workspace": "my-workspace", "name": "my-source"}' +``` + +The CLI resolves the connector, prints the action message + a `Type 'yes' to confirm (skips after 10s)` prompt, and opens your browser to `/organizations//credentials` only if you type `yes` within the timeout. Any other input — `no`, empty line, EOF, or the timeout firing — skips the browser open. The result on stdout always includes `url`, `connector_id`, `message`, and `browser_opened: bool`, so non-interactive callers (MCP, CI, piped subprocesses) get the URL even when the prompt is skipped. Honors `AIRBYTE_WEBAPP_URL` for non-prod environments. + +### 6. Deleting a Connector ```bash airbyte-agent connectors delete --json '{"workspace": "my-workspace", "name": "old-source"}' @@ -143,7 +152,7 @@ airbyte-agent connectors delete --json '{"workspace": "my-workspace", "name": "o Delete is destructive and prompts for an interactive `"Type 'yes' to confirm:"` on a TTY. Without a TTY (e.g. piped agent input), the command refuses with a `validation_error` whose hint tells you to set `"allow_destructive": true` in `~/.airbyte-agent/settings.json` (or `AIRBYTE_ALLOW_DESTRUCTIVE=true`). Once that permission is granted, the prompt is skipped. -### 6. Schema Introspection +### 7. Schema Introspection Use the top-level `schema` command to see an operation's parameter schema (and underlying OpenAPI request/response) before calling it: @@ -164,7 +173,7 @@ airbyte-agent schema connectors execute # } ``` -### 7. Loading Parameters from a File +### 8. Loading Parameters from a File For complex JSON payloads, use `@filename`: diff --git a/internal/resources/connectors.go b/internal/resources/connectors.go index b1e639d..9e9f200 100644 --- a/internal/resources/connectors.go +++ b/internal/resources/connectors.go @@ -23,6 +23,7 @@ func (cr *connectorsResource) Description() string { return "Create, manage, and func (cr *connectorsResource) Operations() []registry.Operation { return []registry.Operation{ connectorsCreateOperation(), + connectorsUpdateOperation(), { Name: "list", Description: "List connectors in a workspace", diff --git a/internal/resources/connectors_test.go b/internal/resources/connectors_test.go index 41108d8..1397e62 100644 --- a/internal/resources/connectors_test.go +++ b/internal/resources/connectors_test.go @@ -1142,6 +1142,7 @@ func TestConnectorsResourceOperations(t *testing.T) { expected := map[string]bool{ "create": false, + "update": false, "list": false, "list-available": false, "describe": false, diff --git a/internal/resources/connectors_update.go b/internal/resources/connectors_update.go new file mode 100644 index 0000000..a178736 --- /dev/null +++ b/internal/resources/connectors_update.go @@ -0,0 +1,175 @@ +package resources + +import ( + "bufio" + "context" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/airbytehq/airbyte-agent-cli/internal/client" + "github.com/airbytehq/airbyte-agent-cli/internal/registry" +) + +// connectorsUpdateOperation registers `connectors update`, a browser-launch +// command that resolves the target connector and opens the user's webapp +// credentials page. The CLI never accepts credentials directly — credential +// entry happens in the browser-based widget mounted on the credentials page +// (sonar/frontend/src/routes/organizations/$organizationId/credentials.tsx:1423). +// +// The SpecRef points at the conceptual PUT /api/v1/integrations/connectors/{id} +// route the webapp invokes after the user submits the edit form. The CLI does +// NOT call that endpoint — the value is informational and feeds +// `airbyte-agent schema connectors update`. +func connectorsUpdateOperation() registry.Operation { + return registry.Operation{ + Name: "update", + Description: "Open the browser to edit a connector's credentials/config", + Schema: registry.OperationSchema{ + Description: "Open the credentials page in your browser so you can edit an existing connector. The CLI never accepts credentials directly — entry happens in the browser-based widget.", + Params: map[string]registry.ParamSchema{ + "name": {Type: "string", Required: false, Description: "Connector name (requires workspace)"}, + "workspace": {Type: "string", Required: false, Description: "Workspace name (defaults to 'default' when used with name)"}, + "id": {Type: "string", Required: false, Description: "Connector ID (alternative to name)"}, + }, + }, + SpecRef: registry.SpecRef{Path: "/api/v1/integrations/connectors/{id}", Method: "PUT"}, + Hooks: registry.OperationHooks{ + PreRun: resolveConnectorID, + }, + Run: connectorsUpdate, + } +} + +// connectorsUpdate is the Run function for `connectors update`. After +// `resolveConnectorID` populates `params["id"]`, it builds the credentials- +// page URL, displays the human-readable message + a yes/no prompt, and only +// opens the browser on an explicit "yes". The prompt has a short timeout so +// non-interactive callers (MCP, CI, piped invocations) don't hang waiting +// for input that will never arrive — they simply receive the URL in the +// result map and can act on it themselves. The result always includes +// `browser_opened` so callers can tell which path ran. +func connectorsUpdate(ctx context.Context, c *client.Client, params map[string]any) (any, error) { + id, _ := params["id"].(string) + + orgID := c.OrganizationID() + if orgID == "" { + return nil, client.NewValidationError( + "no organization_id configured", + "run 'airbyte-agent login' or set AIRBYTE_ORGANIZATION_ID", + ) + } + + pageURL, err := credentialsPageURL(webAppBaseURL(), orgID) + if err != nil { + return nil, fmt.Errorf("building credentials page URL: %w", err) + } + + name, _ := params["name"].(string) + workspace, _ := params["workspace"].(string) + message := updateMessageFor(name, workspace, id) + + opened := false + if confirmOpenBrowser(message, pageURL) { + openBrowser(pageURL) + opened = true + } + + return map[string]any{ + "url": pageURL, + "connector_id": id, + "message": message, + "browser_opened": opened, + }, nil +} + +// confirmOpenBrowserTimeout caps how long the confirmation prompt waits for +// stdin before defaulting to "no". Long enough for a TTY user glancing at +// the prompt to type a response; short enough that non-interactive callers +// don't hang noticeably. It is a var (not a const) so tests can shrink it. +var confirmOpenBrowserTimeout = 10 * time.Second + +// confirmOpenBrowser prints the action message + a yes/no prompt to +// confirmWriter (stderr by default), then reads a single line from +// confirmReader (stdin by default) with a confirmOpenBrowserTimeout-bounded +// wait. Returns true ONLY on an exact "yes" (case-insensitive, whitespace- +// trimmed). Any other input, EOF, or a timeout returns false — the URL is +// still surfaced in the caller's result map so the user/agent can act on it +// independently. Declared as a var so tests can stub the whole prompt +// rather than driving the real stdin read. +var confirmOpenBrowser = func(message, url string) bool { + fmt.Fprintln(confirmWriter, message) + fmt.Fprintf(confirmWriter, "Open %s in your browser? Type 'yes' to confirm (skips after %s): ", url, confirmOpenBrowserTimeout) + + type readResult struct { + line string + err error + } + ch := make(chan readResult, 1) + go func() { + line, err := bufio.NewReader(confirmReader).ReadString('\n') + ch <- readResult{line: line, err: err} + }() + + select { + case r := <-ch: + confirmed := strings.EqualFold(strings.TrimSpace(r.line), "yes") + switch { + case confirmed: + return true + case r.err == io.EOF && strings.TrimSpace(r.line) == "": + // Non-TTY callers (MCP, piped subprocess) typically hit EOF + // instantly. Skip the chatty "(not opening browser)" notice + // since the result map already conveys the outcome. + return false + default: + fmt.Fprintln(confirmWriter, "(not opening browser)") + return false + } + case <-time.After(confirmOpenBrowserTimeout): + fmt.Fprintln(confirmWriter, "\n(timed out; not opening browser)") + return false + } +} + +// updateMessageFor crafts the user-facing instruction printed alongside the +// browser launch. The lead sentence makes it explicit that the CLI does not +// itself edit the connector — the link (returned in the `url` field) is the +// only path, and the trailing clause points the user at the pencil icon +// inside the credentials page. The most specific phrasing +// (name + workspace + id) is preferred; the id-only fallback covers the +// --id invocation path. +const updateDisclaimer = "Connectors cannot be edited through the CLI. Visit the link below to update the connector config" + +func updateMessageFor(name, workspace, id string) string { + switch { + case name != "" && workspace != "": + return fmt.Sprintf("%s — find connector %q (id %s) in workspace %q on the credentials page and click the pencil icon to edit.", updateDisclaimer, name, id, workspace) + case name != "": + return fmt.Sprintf("%s — find connector %q (id %s) on the credentials page and click the pencil icon to edit.", updateDisclaimer, name, id) + default: + return fmt.Sprintf("%s — find the connector with id %s on the credentials page and click the pencil icon to edit.", updateDisclaimer, id) + } +} + +// credentialsPageURL builds the webapp URL the browser opens for credential +// edits: /organizations//credentials. The base URL may carry +// an existing path; a trailing slash is collapsed so the result never +// contains "//organizations/...". orgID is URL-path-escaped to defend against +// path injection if the org id ever contains slashes or other reserved +// characters. Both u.Path and u.RawPath are set so url.URL.String preserves +// the already-escaped form instead of percent-encoding the escape sequences +// a second time. +func credentialsPageURL(baseURL, orgID string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("parsing web app URL: %w", err) + } + trimmed := strings.TrimRight(u.Path, "/") + escapedOrg := url.PathEscape(orgID) + u.Path = trimmed + "/organizations/" + orgID + "/credentials" + u.RawPath = trimmed + "/organizations/" + escapedOrg + "/credentials" + return u.String(), nil +} diff --git a/internal/resources/connectors_update_test.go b/internal/resources/connectors_update_test.go new file mode 100644 index 0000000..862bc89 --- /dev/null +++ b/internal/resources/connectors_update_test.go @@ -0,0 +1,537 @@ +package resources + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/airbytehq/airbyte-agent-cli/internal/auth" + "github.com/airbytehq/airbyte-agent-cli/internal/client" +) + +// stubConfirmOpenBrowser swaps the package confirmation hook for the +// duration of the test, recording the message/url it was called with and +// returning the supplied answer. Restores the original on cleanup. +func stubConfirmOpenBrowser(t *testing.T, answer bool) *struct { + called bool + gotURL string + gotMsg string + callCount int +} { + t.Helper() + state := &struct { + called bool + gotURL string + gotMsg string + callCount int + }{} + prev := confirmOpenBrowser + confirmOpenBrowser = func(message, url string) bool { + state.called = true + state.callCount++ + state.gotURL = url + state.gotMsg = message + return answer + } + t.Cleanup(func() { confirmOpenBrowser = prev }) + return state +} + +func TestCredentialsPageURL(t *testing.T) { + cases := []struct { + name string + baseURL string + orgID string + want string + wantErr bool + }{ + { + name: "default base URL", + baseURL: "https://app.airbyte.ai", + orgID: "org-123", + want: "https://app.airbyte.ai/organizations/org-123/credentials", + }, + { + name: "base URL with trailing slash collapses", + baseURL: "https://app.airbyte.ai/", + orgID: "org-123", + want: "https://app.airbyte.ai/organizations/org-123/credentials", + }, + { + name: "env-override style staging URL", + baseURL: "https://staging.airbyte.ai", + orgID: "org-123", + want: "https://staging.airbyte.ai/organizations/org-123/credentials", + }, + { + name: "org id with characters needing escape", + baseURL: "https://app.airbyte.ai", + orgID: "org/special", + want: "https://app.airbyte.ai/organizations/org%2Fspecial/credentials", + }, + { + name: "invalid base URL returns error", + baseURL: "://broken", + orgID: "org-123", + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := credentialsPageURL(tc.baseURL, tc.orgID) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (got=%q)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestConnectorsUpdate_HappyPath_NameAndWorkspace(t *testing.T) { + t.Setenv("AIRBYTE_WEBAPP_URL", "https://app.airbyte.ai") + + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected API call: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + defer apiServer.Close() + + c, cleanup := newTestClient(t, apiServer) + defer cleanup() + + prevOpen := openBrowserFunc + var capturedURL string + openBrowserFunc = func(u string) { capturedURL = u } + defer func() { openBrowserFunc = prevOpen }() + + prevStatus := statusWriter + var stderr bytes.Buffer + statusWriter = &stderr + defer func() { statusWriter = prevStatus }() + + confirmState := stubConfirmOpenBrowser(t, true) + + params := map[string]any{ + "id": "conn-1", + "name": "acme", + "workspace": "production", + } + result, err := connectorsUpdate(context.Background(), c, params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantURL := "https://app.airbyte.ai/organizations/org-123/credentials" + if capturedURL != wantURL { + t.Errorf("openBrowser URL = %q, want %q", capturedURL, wantURL) + } + if !confirmState.called { + t.Error("confirmOpenBrowser was not called") + } + if confirmState.gotURL != wantURL { + t.Errorf("confirmOpenBrowser url = %q, want %q", confirmState.gotURL, wantURL) + } + + resMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any result, got %T", result) + } + if resMap["url"] != wantURL { + t.Errorf("result url = %q, want %q", resMap["url"], wantURL) + } + if resMap["connector_id"] != "conn-1" { + t.Errorf("result connector_id = %q, want %q", resMap["connector_id"], "conn-1") + } + if resMap["browser_opened"] != true { + t.Errorf("result browser_opened = %v, want true", resMap["browser_opened"]) + } + msg, _ := resMap["message"].(string) + if !strings.Contains(msg, "acme") { + t.Errorf("message missing connector name: %q", msg) + } + if !strings.Contains(msg, "production") { + t.Errorf("message missing workspace name: %q", msg) + } + if !strings.Contains(msg, "conn-1") { + t.Errorf("message missing connector id: %q", msg) + } + // The lead disclaimer is the user-visible signal that the CLI does not + // edit the connector itself — keep it in front of the actionable phrasing. + if !strings.HasPrefix(msg, "Connectors cannot be edited through the CLI") { + t.Errorf("message must lead with CLI-cannot-edit disclaimer, got %q", msg) + } + + // No stderr envelope: the same data is returned as the result and printed + // to stdout once by the standard writer. Writing it to stderr too would + // duplicate the output (an artifact of the connectors-create polling + // pattern that doesn't apply here). The confirm prompt's own output goes + // through confirmWriter, not statusWriter. + if got := bytes.TrimSpace(stderr.Bytes()); len(got) != 0 { + t.Errorf("expected empty stderr (statusWriter), got %q", got) + } +} + +func TestConnectorsUpdate_DeclinedConfirmation_SkipsBrowser(t *testing.T) { + t.Setenv("AIRBYTE_WEBAPP_URL", "https://app.airbyte.ai") + + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected API call: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + defer apiServer.Close() + + c, cleanup := newTestClient(t, apiServer) + defer cleanup() + + prevOpen := openBrowserFunc + browserCalled := false + openBrowserFunc = func(string) { browserCalled = true } + defer func() { openBrowserFunc = prevOpen }() + + stubConfirmOpenBrowser(t, false) + + params := map[string]any{"id": "conn-1"} + result, err := connectorsUpdate(context.Background(), c, params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if browserCalled { + t.Error("openBrowser was invoked despite declined confirmation") + } + + resMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any result, got %T", result) + } + if resMap["browser_opened"] != false { + t.Errorf("result browser_opened = %v, want false", resMap["browser_opened"]) + } + // URL is still surfaced so the user/agent can act on it. + if resMap["url"] != "https://app.airbyte.ai/organizations/org-123/credentials" { + t.Errorf("URL must still be returned even when browser is skipped, got %v", resMap["url"]) + } +} + +func TestConnectorsUpdate_HappyPath_IDOnly(t *testing.T) { + t.Setenv("AIRBYTE_WEBAPP_URL", "https://app.airbyte.ai") + + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected API call: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + defer apiServer.Close() + + c, cleanup := newTestClient(t, apiServer) + defer cleanup() + + prevOpen := openBrowserFunc + var capturedURL string + openBrowserFunc = func(u string) { capturedURL = u } + defer func() { openBrowserFunc = prevOpen }() + + prevStatus := statusWriter + var stderr bytes.Buffer + statusWriter = &stderr + defer func() { statusWriter = prevStatus }() + + stubConfirmOpenBrowser(t, true) + + params := map[string]any{"id": "conn-1"} + result, err := connectorsUpdate(context.Background(), c, params) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wantURL := "https://app.airbyte.ai/organizations/org-123/credentials" + if capturedURL != wantURL { + t.Errorf("openBrowser URL = %q, want %q", capturedURL, wantURL) + } + + resMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any result, got %T", result) + } + msg, _ := resMap["message"].(string) + // Id-only fallback phrasing — no quoted name token, no workspace label. + if !strings.Contains(msg, "the connector with id conn-1") { + t.Errorf("message should use id-only fallback phrasing, got %q", msg) + } + if strings.Contains(msg, "\"") { + t.Errorf("id-only message should not contain quoted name token, got %q", msg) + } + if resMap["browser_opened"] != true { + t.Errorf("result browser_opened = %v, want true", resMap["browser_opened"]) + } + + if got := bytes.TrimSpace(stderr.Bytes()); len(got) != 0 { + t.Errorf("expected empty stderr (statusWriter), got %q", got) + } +} + +func TestConnectorsUpdate_MissingOrgID(t *testing.T) { + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected API call: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + defer apiServer.Close() + + tokenServer := newTestTokenServer(t) + defer tokenServer.Close() + creds := &auth.Credentials{ClientID: "id", ClientSecret: "secret"} + tm := auth.NewTokenManager(tokenServer.URL, "", creds) + c := client.New(apiServer.URL, "", "test", tm) + + prevOpen := openBrowserFunc + called := false + openBrowserFunc = func(string) { called = true } + defer func() { openBrowserFunc = prevOpen }() + + prevStatus := statusWriter + var stderr bytes.Buffer + statusWriter = &stderr + defer func() { statusWriter = prevStatus }() + + confirmState := stubConfirmOpenBrowser(t, true) + + _, err := connectorsUpdate(context.Background(), c, map[string]any{"id": "conn-1"}) + if err == nil { + t.Fatal("expected validation error, got nil") + } + apiErr, ok := err.(*client.APIError) + if !ok { + t.Fatalf("expected *client.APIError, got %T", err) + } + if apiErr.Type != "validation_error" { + t.Errorf("expected type validation_error, got %q", apiErr.Type) + } + if called { + t.Error("openBrowserFunc was invoked despite missing org id") + } + if confirmState.called { + t.Error("confirmOpenBrowser was invoked despite missing org id — validation must short-circuit first") + } +} + +func TestConnectorsUpdate_InvalidWebAppURL(t *testing.T) { + t.Setenv("AIRBYTE_WEBAPP_URL", "://broken") + + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected API call: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + defer apiServer.Close() + + c, cleanup := newTestClient(t, apiServer) + defer cleanup() + + prevOpen := openBrowserFunc + called := false + openBrowserFunc = func(string) { called = true } + defer func() { openBrowserFunc = prevOpen }() + + prevStatus := statusWriter + var stderr bytes.Buffer + statusWriter = &stderr + defer func() { statusWriter = prevStatus }() + + confirmState := stubConfirmOpenBrowser(t, true) + + _, err := connectorsUpdate(context.Background(), c, map[string]any{"id": "conn-1"}) + if err == nil { + t.Fatal("expected error from invalid web app URL, got nil") + } + if called { + t.Error("openBrowserFunc was invoked despite invalid web app URL") + } + if confirmState.called { + t.Error("confirmOpenBrowser was invoked despite URL parse failure — URL build must short-circuit first") + } +} + +func TestConnectorsUpdate_ResolveConnectorIDIntegration(t *testing.T) { + t.Setenv("AIRBYTE_WEBAPP_URL", "https://app.airbyte.ai") + + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/integrations/connectors" { + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if got := r.URL.Query().Get("workspace_name"); got != "production" { + t.Errorf("expected workspace_name=production, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data": [{"id": "conn-1", "name": "acme"}]}`)) + })) + defer apiServer.Close() + + c, cleanup := newTestClient(t, apiServer) + defer cleanup() + + prevOpen := openBrowserFunc + var capturedURL string + openBrowserFunc = func(u string) { capturedURL = u } + defer func() { openBrowserFunc = prevOpen }() + + prevStatus := statusWriter + var stderr bytes.Buffer + statusWriter = &stderr + defer func() { statusWriter = prevStatus }() + + stubConfirmOpenBrowser(t, true) + + params := map[string]any{"name": "acme", "workspace": "production"} + resolved, err := resolveConnectorID(context.Background(), c, params) + if err != nil { + t.Fatalf("resolveConnectorID error: %v", err) + } + if resolved["id"] != "conn-1" { + t.Fatalf("expected resolved id=conn-1, got %v", resolved["id"]) + } + + result, err := connectorsUpdate(context.Background(), c, resolved) + if err != nil { + t.Fatalf("connectorsUpdate error: %v", err) + } + + wantURL := "https://app.airbyte.ai/organizations/org-123/credentials" + if capturedURL != wantURL { + t.Errorf("openBrowser URL = %q, want %q", capturedURL, wantURL) + } + + resMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any result, got %T", result) + } + if resMap["connector_id"] != "conn-1" { + t.Errorf("result connector_id = %q, want %q", resMap["connector_id"], "conn-1") + } + msg, _ := resMap["message"].(string) + if !strings.Contains(msg, "acme") { + t.Errorf("message missing connector name: %q", msg) + } + if !strings.Contains(msg, "production") { + t.Errorf("message missing workspace name: %q", msg) + } + if resMap["browser_opened"] != true { + t.Errorf("result browser_opened = %v, want true", resMap["browser_opened"]) + } + + // resolveConnectorID was called with workspace=production, so + // applyDefaultWorkspace doesn't print a fallback notice. statusWriter + // must be clean — connectorsUpdate emits no envelope and the confirm + // prompt writes elsewhere (confirmWriter). + if got := bytes.TrimSpace(stderr.Bytes()); len(got) != 0 { + t.Errorf("expected empty stderr (statusWriter), got %q", got) + } +} + +// blockingReader satisfies io.Reader by blocking on Read forever (until the +// process exits). Used to exercise the confirmOpenBrowser timeout path. +type blockingReader struct{} + +func (blockingReader) Read(p []byte) (int, error) { + select {} // block forever; the test goroutine cleans up on process exit +} + +func TestConfirmOpenBrowser_YesOpens(t *testing.T) { + withConfirmIO(t, "yes\n") + if !confirmOpenBrowser("msg", "https://example/credentials") { + t.Error("expected true on 'yes' input") + } +} + +func TestConfirmOpenBrowser_YesIsCaseAndWhitespaceInsensitive(t *testing.T) { + for _, input := range []string{"YES\n", " yes \n", "Yes\n"} { + t.Run(input, func(t *testing.T) { + withConfirmIO(t, input) + if !confirmOpenBrowser("msg", "https://example/credentials") { + t.Errorf("expected true on %q", input) + } + }) + } +} + +func TestConfirmOpenBrowser_NonYesSkips(t *testing.T) { + for _, input := range []string{"no\n", "y\n", "yeah\n", "\n", "1\n"} { + t.Run(input, func(t *testing.T) { + withConfirmIO(t, input) + if confirmOpenBrowser("msg", "https://example/credentials") { + t.Errorf("expected false on %q", input) + } + }) + } +} + +func TestConfirmOpenBrowser_EOFSkipsQuietly(t *testing.T) { + prevReader := confirmReader + prevWriter := confirmWriter + confirmReader = strings.NewReader("") // immediate EOF + var w bytes.Buffer + confirmWriter = &w + defer func() { + confirmReader = prevReader + confirmWriter = prevWriter + }() + + if confirmOpenBrowser("msg", "https://example/credentials") { + t.Error("expected false on EOF") + } + if strings.Contains(w.String(), "not opening browser") { + t.Errorf("EOF path should skip the chatty notice; got: %q", w.String()) + } +} + +func TestConfirmOpenBrowser_TimeoutSkips(t *testing.T) { + prevReader := confirmReader + prevWriter := confirmWriter + prevTimeout := confirmOpenBrowserTimeout + confirmReader = blockingReader{} + var w bytes.Buffer + confirmWriter = &w + confirmOpenBrowserTimeout = 50 * time.Millisecond + defer func() { + confirmReader = prevReader + confirmWriter = prevWriter + confirmOpenBrowserTimeout = prevTimeout + }() + + start := time.Now() + if confirmOpenBrowser("msg", "https://example/credentials") { + t.Error("expected false on timeout") + } + elapsed := time.Since(start) + if elapsed > 500*time.Millisecond { + t.Errorf("timeout should fire within ~50ms, took %s", elapsed) + } + if !strings.Contains(w.String(), "timed out") { + t.Errorf("timeout path should print a timeout notice; got: %q", w.String()) + } +} + +// withConfirmIO swaps confirmReader / confirmWriter for the test and +// restores them after. The reader is seeded with `input` (typically +// "yes\n"); the writer captures whatever the prompt emits. +func withConfirmIO(t *testing.T, input string) { + t.Helper() + prevReader := confirmReader + prevWriter := confirmWriter + confirmReader = strings.NewReader(input) + confirmWriter = &bytes.Buffer{} + t.Cleanup(func() { + confirmReader = prevReader + confirmWriter = prevWriter + }) +} diff --git a/internal/spec/extracted_gen.go b/internal/spec/extracted_gen.go index df37c8e..6f12ca7 100644 --- a/internal/spec/extracted_gen.go +++ b/internal/spec/extracted_gen.go @@ -66,4 +66,13 @@ var schemas = map[string]RouteSchema{ RequestBody: json.RawMessage(`{"description":"Request body for connector execution.\n\nAll actions use the same unified format:\n - entity: resource type or stream name\n - action: operation type (list, get, create, download, search, api_search)\n - params: operation-specific parameters\n\nFor SEARCH action, params should contain:\n - query: Query object with filter/sort conditions\n - limit: maximum number of results (default 10)\n - cursor: pagination cursor for next page\n - fields: list of field paths to include in results","properties":{"action":{"title":"Action","type":"string"},"entity":{"title":"Entity","type":"string"},"exclude_fields":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"description":"Blocklist of dot-notation field paths to remove from the response. Ignored when select_fields is provided.","title":"Exclude Fields"},"params":{"additionalProperties":true,"title":"Params","type":"object"},"select_fields":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"description":"Allowlist of dot-notation field paths to include in the response. Takes priority over exclude_fields if both are provided.","title":"Select Fields"},"skip_truncation":{"default":true,"description":"Disable automatic truncation of long text fields in list/search responses.","title":"Skip Truncation","type":"boolean"}},"required":["entity","action"],"title":"ConnectorExecuteRequest","type":"object"}`), Response: json.RawMessage(`{"description":"Standardized response envelope for connector execution.","properties":{"connector_metadata":{"title":"Connector Metadata"},"execution_metadata":{"description":"Metadata about the execution.","properties":{"connector_instance_id":{"title":"Connector Instance Id","type":"string"},"execution_time_ms":{"title":"Execution Time Ms","type":"integer"}},"required":["connector_instance_id","execution_time_ms"],"title":"ExecutionMetadata","type":"object"},"result":{"title":"Result"},"status":{"title":"Status","type":"string"}},"required":["status","result","connector_metadata","execution_metadata"],"title":"ConnectorExecuteResponse","type":"object"}`), }, + "PUT /api/v1/integrations/connectors/{id}": { + Path: "/api/v1/integrations/connectors/{id}", + Method: "PUT", + Summary: "Update a connector", + Description: "**Requires an Access Token as the bearer token.**\n\nUpdate an end user's configured connector, config should be validated before.", + Parameters: json.RawMessage(`[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","title":"Id","type":"string"}},{"description":"The organization ID to target for this request","in":"header","name":"x-organization-id","required":false,"schema":{"description":"The organization ID to target for this request","format":"uuid","title":"X-Organization-Id","type":"string"}}]`), + RequestBody: json.RawMessage(`{"properties":{"entities":{"anyOf":[{"items":{"properties":{"entity":{"title":"Entity","type":"string"},"modes":{"anyOf":[{"items":{"enum":["read","write"],"title":"RequestedEntityMode","type":"string"},"type":"array"},{"type":"null"}],"title":"Modes"}},"required":["entity"],"title":"RequestedEntity","type":"object"},"type":"array"},{"type":"null"}],"description":"Entity selection to apply to the source. Omitting preserves existing selection; explicit [] clears it.","title":"Entities"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"replication_config":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"description":"The configuration for the replication connector.","title":"Replication Config"}},"title":"ConnectorSourceUpdateRequest","type":"object"}`), + Response: json.RawMessage(`{"properties":{"created_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Created At"},"entities":{"anyOf":[{"items":{"properties":{"entity":{"title":"Entity","type":"string"},"modes":{"anyOf":[{"items":{"enum":["read","write"],"title":"RequestedEntityMode","type":"string"},"type":"array"},{"type":"null"}],"title":"Modes"}},"required":["entity"],"title":"RequestedEntity","type":"object"},"type":"array"},{"type":"null"}],"description":"User-selected entity/mode pairs that constrain stream selection.","title":"Entities"},"id":{"format":"uuid","title":"Id","type":"string"},"name":{"title":"Name","type":"string"},"replication_config":{"additionalProperties":true,"title":"Replication Config","type":"object"},"source_template":{"properties":{"created_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Created At"},"customization":{"anyOf":[{"properties":{"stream_customizations":{"anyOf":[{"additionalProperties":{"properties":{"cursor_field":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Cursor Field"},"primary_key_fields":{"anyOf":[{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},{"type":"null"}],"title":"Primary Key Fields"}},"title":"StreamCustomization","type":"object"},"type":"object"},{"type":"null"}],"title":"Stream Customizations"},"stream_mappers":{"anyOf":[{"additionalProperties":{"items":{"discriminator":{"mapping":{"encryption":"#/components/schemas/EncryptionMapper","field-filtering":"#/components/schemas/FieldFilteringMapper","field-renaming":"#/components/schemas/FieldRenamingMapper","hashing":"#/components/schemas/HashingMapper","row-filtering":"#/components/schemas/RowFilteringMapper-Output"},"propertyName":"type"},"oneOf":[{"description":"Hashing mapper - converts field values to cryptographic hashes.\n\nApplies a hash function (SHA256, SHA512, or MD5) to transform field values.\nCan either replace the original field or create a new field with a suffix.","properties":{"mapper_configuration":{"description":"Configuration for hashing mapper - uses snake_case.\n\nTransforms a field value by applying a cryptographic hash function.\nThe original field is replaced with the hashed value, or a new field\ncan be created with a suffix.","properties":{"field_name_suffix":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Optional suffix for new field with hashed value","title":"Field Name Suffix"},"method":{"description":"Hashing algorithm to use","enum":["SHA256","SHA512","MD5"],"title":"Method","type":"string"},"target_field":{"description":"Field to hash","minLength":1,"title":"Target Field","type":"string"}},"required":["target_field","method"],"title":"HashingMapperConfig","type":"object"},"type":{"const":"hashing","title":"Type","type":"string"}},"required":["type","mapper_configuration"],"title":"HashingMapper","type":"object"},{"description":"Field renaming mapper - renames a single field per configuration.\n\nRenames one field in the data stream (original_field_name → new_field_name).\nUseful for standardizing field names across different data sources.","properties":{"mapper_configuration":{"description":"Configuration for field renaming mapper - uses snake_case.\n\nRenames a single field in the data stream.","properties":{"new_field_name":{"description":"New name for the field","minLength":1,"title":"New Field Name","type":"string"},"original_field_name":{"description":"Original field name to rename","minLength":1,"title":"Original Field Name","type":"string"}},"required":["original_field_name","new_field_name"],"title":"FieldRenamingMapperConfig","type":"object"},"type":{"const":"field-renaming","title":"Type","type":"string"}},"required":["type","mapper_configuration"],"title":"FieldRenamingMapper","type":"object"},{"description":"Field filtering mapper - removes a specific field from each record.\n\nFilters out a single specified field from the data stream. The targeted\nfield is removed from all records in the output stream.\n\nNote: This currently supports removing a single field (denylist approach).\nTo support allowlist filtering (keeping only specified fields), the config\nwould need to be extended with an allowed_fields list instead of target_field.","properties":{"mapper_configuration":{"description":"Configuration for field filtering mapper - uses snake_case.\n\nFilters out a specific field from the data stream.","properties":{"target_field":{"description":"Field to filter out","minLength":1,"title":"Target Field","type":"string"}},"required":["target_field"],"title":"FieldFilteringMapperConfig","type":"object"},"type":{"const":"field-filtering","title":"Type","type":"string"}},"required":["type","mapper_configuration"],"title":"FieldFilteringMapper","type":"object"},{"description":"Row filtering mapper - filters rows based on field equality.\n\nOnly keeps rows where a specific field equals a given value. Rows that\ndon't match the condition are excluded from the output.","properties":{"mapper_configuration":{"description":"Configuration for row filtering mapper - uses snake_case.\n\nFilters rows based on a condition. Only rows where the specified field\nequals the provided value are kept.","properties":{"conditions":{"description":"Filtering condition","properties":{"comparison_value":{"description":"Value to compare against","minLength":1,"title":"Comparison Value","type":"string"},"field_name":{"description":"Field to check","minLength":1,"title":"Field Name","type":"string"},"type":{"const":"equal","description":"Type of comparison","title":"Type","type":"string"}},"required":["type","field_name","comparison_value"],"title":"RowFilterCondition","type":"object"}},"required":["conditions"],"title":"RowFilteringMapperConfig","type":"object"},"type":{"const":"row-filtering","title":"Type","type":"string"}},"required":["type","mapper_configuration"],"title":"RowFilteringMapper","type":"object"},{"description":"Encryption mapper - encrypts sensitive fields with RSA.\n\nEncrypts specified fields using RSA public key encryption. The encrypted\nvalues replace the original field values in the output stream.\n\nNote: Only RSA encryption is supported. AES is not available due to\nsecrets hydration issues in the Airbyte platform.","properties":{"mapper_configuration":{"description":"Configuration for encryption mapper - uses snake_case.\n\nEncrypts a specified field using RSA (OAEP-SHA256). The encrypted value\nreplaces the original field value or is emitted with the configured suffix.\n\nNOTE: Symmetric AES is NOT supported in this API.\n\nWhy we don't support AES:\n- Secrets hydration for AES mappers is not currently supported in the platform\n- AES requires symmetric key management which doesn't align well with\n Sonar's secrets management architecture\n- RSA with public keys is more appropriate for this use case as it\n allows encryption without exposing private keys in the configuration\n\nIf you need AES encryption, this must be resolved at the platform level\nwith proper secrets storage and hydration support before we can add it here.","properties":{"algorithm":{"const":"RSA","default":"RSA","description":"Encryption algorithm (RSA only)","title":"Algorithm","type":"string"},"field_name_suffix":{"anyOf":[{"type":"string"},{"type":"null"}],"default":"_encrypted","description":"Optional suffix for encrypted field","title":"Field Name Suffix"},"oaep_hash":{"const":"SHA256","default":"SHA256","description":"Hash function for OAEP padding","title":"Oaep Hash","type":"string"},"output_encoding":{"default":"base64","description":"Encoding for ciphertext bytes","enum":["base64","hex"],"title":"Output Encoding","type":"string"},"padding":{"const":"RSA-OAEP","default":"RSA-OAEP","description":"RSA padding mode","title":"Padding","type":"string"},"public_key":{"description":"RSA public key in PEM format","minLength":1,"title":"Public Key","type":"string"},"target_field":{"description":"Field to encrypt","minLength":1,"title":"Target Field","type":"string"}},"required":["target_field","public_key"],"title":"EncryptionMapperConfig","type":"object"},"type":{"const":"encryption","title":"Type","type":"string"}},"required":["type","mapper_configuration"],"title":"EncryptionMapper","type":"object"}]},"type":"array"},"type":"object"},{"type":"null"}],"title":"Stream Mappers"},"stream_selection_mode":{"default":"suggested","description":"Strategy for selecting streams when creating connections","enum":["all","suggested","whitelist"],"title":"StreamSelectionMode","type":"string"},"stream_whitelist":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Stream Whitelist"}},"title":"SourceTemplateCustomization","type":"object"},{"type":"null"}],"description":"\nCustomizations for the source template. If stream_whitelist is provided, only the specified streams will be synced.\nIf stream_customizations are provided, and stream fields specified will override the default settings for only that\nfield. Where any streams fields are not specified, the default settings will be used.\n"},"icon":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon"},"id":{"format":"uuid","title":"Id","type":"string"},"mode":{"default":"REPLICATION","enum":["DIRECT","REPLICATION","MULTI"],"title":"SourceConfigTemplateMode","type":"string"},"name":{"title":"Name","type":"string"},"partial_default_config":{"additionalProperties":true,"title":"Partial Default Config","type":"object"},"source_definition_id":{"format":"uuid","title":"Source Definition Id","type":"string"},"tags":{"items":{"type":"string"},"title":"Tags","type":"array"},"updated_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Updated At"},"user_config_spec":{"properties":{"advanced_auth":{"anyOf":[{"properties":{"auth_flow_type":{"title":"Auth Flow Type","type":"string"},"oauth_config_specification":{"properties":{"complete_oauth_output_specification":{"additionalProperties":true,"default":{},"title":"Complete Oauth Output Specification","type":"object"},"complete_oauth_server_input_specification":{"additionalProperties":true,"default":{},"title":"Complete Oauth Server Input Specification","type":"object"},"complete_oauth_server_output_specification":{"additionalProperties":true,"default":{},"title":"Complete Oauth Server Output Specification","type":"object"},"oauth_connector_input_specification":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Oauth Connector Input Specification"},"oauth_user_input_from_connector_config_specification":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Oauth User Input From Connector Config Specification"}},"title":"OAuthConfigSpecification","type":"object"},"predicate_key":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Predicate Key"},"predicate_value":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Predicate Value"}},"required":["auth_flow_type","oauth_config_specification"],"title":"AdvancedAuth","type":"object"},{"type":"null"}]},"connectionSpecification":{"properties":{"$schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"$Schema"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"properties":{"additionalProperties":true,"title":"Properties","type":"object"},"required":{"items":{"type":"string"},"title":"Required","type":"array"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"type":{"title":"Type","type":"string"}},"required":["type"],"title":"ConnectionSpecification","type":"object"},"documentationUrl":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Documentationurl"},"supported_destination_sync_modes":{"anyOf":[{"items":{"enum":["append","append_dedup","overwrite","overwrite_dedup","soft_delete","update"],"title":"DestinationSyncMode","type":"string"},"type":"array"},{"type":"null"}],"title":"Supported Destination Sync Modes"}},"required":["connectionSpecification"],"title":"ConnectorSpecification","type":"object"}},"required":["id","name","source_definition_id","user_config_spec","partial_default_config"],"title":"DetailedSourceTemplate","type":"object"},"updated_at":{"anyOf":[{"format":"date-time","type":"string"},{"type":"null"}],"title":"Updated At"}},"required":["id","name","source_template","replication_config"],"title":"ConnectorSourceUpdateResponse","type":"object"}`), + }, } diff --git a/skills/airbyte-agent/SKILL.md b/skills/airbyte-agent/SKILL.md index 6cc660e..d3c1505 100644 --- a/skills/airbyte-agent/SKILL.md +++ b/skills/airbyte-agent/SKILL.md @@ -44,6 +44,7 @@ Each row points to the per-command playbook with usage, workflows, error recover | Run an action (list/get/search/create/update) against connector data — **the workhorse** | [`references/connectors-execute.md`](references/connectors-execute.md) | | Discover a connector's entities, actions, params, and field schemas | [`references/connectors-describe.md`](references/connectors-describe.md) | | Install a new connector via the browser credential flow | [`references/connectors-create.md`](references/connectors-create.md) | +| Re-enter or fix credentials for an existing connector via the browser | [`references/connectors-update.md`](references/connectors-update.md) | | Delete a connector (destructive — confirm first) | [`references/connectors-delete.md`](references/connectors-delete.md) | | List connectors configured in a workspace | [`references/connectors-list.md`](references/connectors-list.md) | | List connector templates available to install | [`references/connectors-list-available.md`](references/connectors-list-available.md) | diff --git a/skills/airbyte-agent/references/connectors-update.md b/skills/airbyte-agent/references/connectors-update.md new file mode 100644 index 0000000..21c7df8 --- /dev/null +++ b/skills/airbyte-agent/references/connectors-update.md @@ -0,0 +1,65 @@ +# connectors update + +Open the user's browser to the credentials page so they can edit an existing connector's configuration. The CLI never accepts credentials directly — entry happens in the browser-based widget. + +## When to use + +- A connector's OAuth token expired and `connectors execute` started returning `auth_error`. +- The user rotated their API key for a SaaS source. +- The user explicitly wants to change a connector's configuration (auth, entity selection, etc.). + +Do NOT use this command for renaming a connector or other metadata-only edits — those would need a real PUT call, which this command does not make. + +## Usage + +```bash +airbyte-agent connectors update --json '{"workspace": "my-workspace", "name": "my-source"}' + +# By connector ID instead of name +airbyte-agent connectors update --json '{"id": ""}' +``` + +`workspace` is optional when used with `name` — it falls back to the configured default workspace (then to `default`) and a JSON notice is printed to stderr. + +## What happens + +1. The CLI looks up the connector by name in the workspace (or accepts the `id` directly). +2. It builds the URL `/organizations//credentials`, prints the action message + a confirmation prompt to stderr: `Open in your browser? Type 'yes' to confirm (skips after 10s): `. +3. **If** the user types `yes` (case-insensitive, whitespace-trimmed) within 10 seconds, the CLI opens the URL in the user's default browser. +4. **Otherwise** (`no`, any other input, EOF, or timeout — typical for MCP/CI/piped invocations where stdin is empty), the browser is NOT opened. Exit code is still 0; the URL is still returned. +5. The CLI prints a JSON object on stdout with `url`, `connector_id`, `browser_opened: bool`, and `message` (leading with **"Connectors cannot be edited through the CLI. Visit the link below to update the connector config"** then the pencil-icon hint). +6. The user follows the link, finds the named connector in the credentials list, and clicks the pencil icon — that launches the embedded edit dialog where credentials are re-entered. + +**Agent guidance**: when driving the CLI from an MCP/automation context, expect `browser_opened: false` (stdin is closed → instant EOF → no open). Relay the `url` to the human and let them open it themselves. + +`AIRBYTE_WEBAPP_URL` overrides the base URL for staging/preview environments. + +## Workflows + +**Credential rotation after `auth_error`** + +```bash +# 1. Identify the failing connector +airbyte-agent connectors list --json '{"workspace": "my-workspace"}' + +# 2. Launch the edit flow +airbyte-agent connectors update --json '{"workspace": "my-workspace", "name": "my-source"}' + +# 3. Wait for the user to confirm they completed the browser flow, then re-run the failing call +airbyte-agent connectors execute --json '{"workspace": "my-workspace", "name": "my-source", "entity": "...", "action": "..."}' +``` + +## Error recovery + +- **`auth_error`** (exit 2) — your CLI session token expired. Run `airbyte-agent login` and retry. +- **`not_found`** (exit 3) — the name doesn't exist in that workspace. Run `connectors list --json '{"workspace": "..."}'` to see what's actually there. +- **`validation_error: provide either 'id' or 'name', not both`** (exit 4) — pick one. +- **`validation_error: either 'name' + 'workspace' or 'id' is required`** (exit 4) — pass at least one form of identification. +- **`validation_error: ambiguous: N connectors named "X" in workspace "Y"`** (exit 4) — use `"id": ""` instead. +- **`validation_error: no organization_id configured`** (exit 4) — run `airbyte-agent login` (the login flow records the org id in settings.json). + +## Do NOT + +- Do NOT ask the user to paste credentials into the chat or the CLI. The CLI does not take credentials as parameters — entry happens exclusively in the browser-based widget. +- Do NOT manually construct a PUT request to `/api/v1/integrations/connectors/{id}` as a "shortcut". The widget owns secret handling and a direct PUT bypasses it. +- Do NOT use this command for irreversible operations like deleting/replacing a connector — for that, use `connectors delete` and `connectors create`. From 93eeca4e70a61f0fc1187f13825c34cdfdaf668c Mon Sep 17 00:00:00 2001 From: cjkenned Date: Wed, 20 May 2026 16:50:22 -0700 Subject: [PATCH 2/2] avoid race --- internal/resources/connectors_update.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/resources/connectors_update.go b/internal/resources/connectors_update.go index a178736..a773cef 100644 --- a/internal/resources/connectors_update.go +++ b/internal/resources/connectors_update.go @@ -103,13 +103,21 @@ var confirmOpenBrowser = func(message, url string) bool { fmt.Fprintln(confirmWriter, message) fmt.Fprintf(confirmWriter, "Open %s in your browser? Type 'yes' to confirm (skips after %s): ", url, confirmOpenBrowserTimeout) + // Capture the reader into a local before spawning the goroutine. The + // goroutine outlives this function call (it stays blocked on Read until + // the process exits or input arrives), so reading the package-level + // `confirmReader` from inside the goroutine would race with tests that + // restore the var in a deferred cleanup. The local capture establishes + // a happens-before edge that's safe for the race detector. + reader := confirmReader + type readResult struct { line string err error } ch := make(chan readResult, 1) go func() { - line, err := bufio.NewReader(confirmReader).ReadString('\n') + line, err := bufio.NewReader(reader).ReadString('\n') ch <- readResult{line: line, err: err} }()