From 3bc99e5182035432fa5a17d3d1f6c3a985b824ab Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:53:51 +0000 Subject: [PATCH 1/3] Add 1Password brokered credential vault commands --- cmd/vaults.go | 19 ++- cmd/vaults_commands.go | 41 ++++-- cmd/vaults_credentials.go | 135 ++++++++++++++++- cmd/vaults_credentials_test.go | 2 +- cmd/vaults_fill.go | 71 +++++++++ cmd/vaults_onepassword_test.go | 252 ++++++++++++++++++++++++++++++++ cmd/vaults_operation_params.go | 108 +++++++++++++- cmd/vaults_output.go | 63 +++++++- cmd/vaults_sdk_contract_test.go | 2 +- go.mod | 5 + go.sum | 4 +- 11 files changed, 666 insertions(+), 36 deletions(-) create mode 100644 cmd/vaults_onepassword_test.go diff --git a/cmd/vaults.go b/cmd/vaults.go index 20fb1cf4..54d99985 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -212,10 +212,13 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "prepare_checkout" && (params == nil || params.Checkout == nil) { return fmt.Errorf("prepare_checkout requires checkout parameters") } + if isOnePasswordOperation(operation) && (params == nil || params.OnePassword == nil) { + return fmt.Errorf("%s requires its documented parameters", operation) + } item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { - if operation == "fill" { - return fmt.Errorf("could not retrieve vault item; fill was not invoked") + if operation == "fill" || operation == "1pw_fill" { + return fmt.Errorf("could not retrieve vault item; %s was not invoked", operation) } return util.CleanedUpSdkError{Err: err} } @@ -245,8 +248,14 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par if operation == "fill" { return c.fill(ctx, vault, key, params.Fill, output) } + if operation == "1pw_fill" { + return c.onePasswordFill(ctx, vault, key, params.OnePassword, output) + } request := kernel.VaultItemPerformOperationParams{IDOrName: vault} - if operation == "prepare_checkout" { + if params != nil && params.OnePassword != nil { + request = *params.OnePassword + request.IDOrName = vault + } else if operation == "prepare_checkout" { if item.Type != "card" || item.Spec.Provider != "agentcard" { return fmt.Errorf("prepare_checkout requires an AgentCard card") } @@ -259,12 +268,12 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par } response, err := c.vaults.Items.PerformOperation(ctx, key, request, option.WithMaxRetries(0)) if err != nil { - if item.Type == "credential" { + if item.Type == "credential" || item.Type == "credential_account" { return vaultCredentialError(err) } return util.CleanedUpSdkError{Err: err} } - if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential") { + if response == nil || (response.Type != "card" && response.Type != "wallet" && response.Type != "credential" && response.Type != "credential_account") { return fmt.Errorf("unexpected vault operation response; inspect the item and do not retry") } var updated kernel.VaultItemUnion diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index d725e66c..25b7ece3 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -52,12 +52,14 @@ func vaultPreRun(cmd *cobra.Command, args []string) error { func newVaultsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "vaults", Aliases: []string{"vault"}, Short: "Collect user credentials and manage payment credentials", - Long: `Collect user credentials and manage payment credentials; fill never submits website forms. + Long: `Collect user credentials and manage payment credentials. Do not use credential items to store, collect, or fill credit card data. Use wallet and card item types for credit cards and payment checkout instead. -User credential flow: +` + vaultCredentialPathsHelp + ` + +Kernel-hosted credential flow (fill never submits website forms): 1. Create a vault per end user and create a browser with --vault . 2. Navigate to a sensitive form and define its fields in natural top-to-bottom order with credentials create --spec-file; that array order controls the user-facing collection form. 3. Present the returned collection URL to the user. Poll items get --wait 60 for ready. @@ -66,6 +68,9 @@ Use credentials update --version for edits, or items invoke collect to reopen th Credential values belong in protected files/stdin, never command-line arguments. See credentials --help and items invoke --help for examples. +1Password credential flow: connect the account with credentials connect, create a +1password credential, then invoke the advertised 1pw_* operations. See credentials --help. + Payment credential flow: Optionally select a project with --project or KERNEL_PROJECT. @@ -122,7 +127,7 @@ JSON output preserves returned public fields but omits unknown/opaque provider d addVaultJSONOutputFlag(get) cmd.AddCommand(create, list, get, newVaultDeleteCommand(false)) - items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} + items := &cobra.Command{Use: "items", Short: "Inspect readiness and collection URLs, or invoke collect/fill", Long: "Use get --wait 60 to observe readiness and get -o json for schema/version/presence.\nUse invoke collect to obtain a collection URL, or invoke fill --spec-file to fill a browser.\n1Password credentials use the advertised 1pw_* operations instead of collect/fill.\nCreate and edit credentials with vaults credentials; payment items use wallets/cards."} itemList := &cobra.Command{Use: "list ", Short: "List items by vault ID or name", Args: cobra.ExactArgs(1), PreRunE: vaultPreRun, RunE: func(cmd *cobra.Command, args []string) error { return getVaultsHandler(cmd).ListItems(cmd.Context(), args[0], vaultOutput(cmd)) @@ -185,20 +190,38 @@ establish test mode; merchant credentials determine it. Use only when advertised for an AgentCard card. Keep the returned approval page open, poll until ready_to_submit, then submit native Pay before preparation.expires_at. Preparations are single-use, including after failure or expiry; never retry automatically. -collect/authorize/prepare_checkout may use --open. Fill returns value-free per-field outcomes; -completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json.`, +collect/authorize/prepare_checkout/1pw_recover may use --open. Fill returns value-free per-field outcomes; +completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json. + +1Password credentials (see credentials --help) use --params without type: +1pw_request_access: browser_id (vault-bound session ID); optional goal (<=140), reason + (<=100), keywords (1-5 strings). Present the returned onepassword:// approval link and + instructions to the account owner unchanged; do not issue a second request while pending. +1pw_poll_access: browser_id; optional timeout_seconds 0-120 (default 10). +1pw_fill: browser_id and the exact page_url of one open login page on the requested + origin; optional timeout_ms 1-30000. The extension selects fields and submits. + fill_submitted exits 0 and does not confirm login; fill_failed and fill_unknown exit + nonzero. After fill_unknown, do not retry in the same browser. +1pw_reconcile_access: {"acknowledge_unconfirmed":true}, only after checking 1Password for + an existing request; it does not cancel anything upstream. +1pw_recover (credential accounts, no parameters): starts human-consented OAuth recovery + of a lost integration key; it can revoke the connection. Never delete the item to recover. +Never automatically retry 1Password operations after failures or uncertain outcomes.`, Example: ` kernel vaults items invoke user-vault login collect kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' {"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} JSON + kernel vaults items invoke user-vault github 1pw_request_access --params '{"browser_id":"","reason":"Sign in to GitHub"}' + kernel vaults items invoke user-vault github 1pw_poll_access --params '{"browser_id":"","timeout_seconds":60}' + kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") raw, _ := cmd.Flags().GetString("params") paramsSet := cmd.Flags().Changed("params") if cmd.Flags().Changed("spec-file") { - if args[2] != "fill" && args[2] != "prepare_checkout" { - return fmt.Errorf("--spec-file is only supported for fill and prepare_checkout") + if !vaultOperationTakesParams(args[2]) { + return fmt.Errorf("--spec-file is only supported for fill, prepare_checkout, and 1Password operations with parameters") } data, err := readVaultSpecFile(cmd) if err != nil { @@ -212,8 +235,8 @@ JSON } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Fill or prepare_checkout parameters JSON (maximum 128 KiB); omit type and credential values") - invoke.Flags().String("spec-file", "", "Fill or prepare_checkout parameters JSON file (use '-' for stdin; maximum 128 KiB)") + invoke.Flags().String("params", "", "Operation parameters JSON for fill, prepare_checkout, or 1pw_* (maximum 128 KiB); omit type and credential values") + invoke.Flags().String("spec-file", "", "Operation parameters JSON file (use '-' for stdin; maximum 128 KiB)") invoke.MarkFlagsMutuallyExclusive("params", "spec-file") invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") addVaultJSONOutputFlag(invoke) diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 03b697c8..e6fa8003 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -13,15 +13,52 @@ import ( "github.com/spf13/cobra" ) -const vaultCredentialHelp = `Create credentials from the fields observed on a website. +// Shared by vaults and vaults credentials help so both paths are always presented together. +const vaultCredentialPathsHelp = `Credential vaults have two paths. Ask the user which one they want before creating +anything; do not choose for them. +1. Kernel-hosted collection (spec provider "kernel", the default): you define the + site's fields, the user types values into a Kernel-hosted form at the returned + collection URL, Kernel stores them encrypted, and items invoke fill writes them + into a vault-bound browser without submitting. +2. 1Password brokered approval (spec provider "1password", preview): values stay in + the user's 1Password account. Connect the account once with credentials connect, + create a credential referencing it, request access from a vault-bound browser, and + the account owner approves or denies in the 1Password app. 1pw_fill fills and + submits through the 1Password extension. Availability depends on the deployment. +Neither path returns secret values through the API or CLI. Never ask the user to paste +passwords, OAuth codes, or keys into the terminal or chat; share only returned URLs. +An agent controlling the browser can still read filled pages.` +const vaultOnePasswordCredentialHelp = `1Password flow: +1. credentials connect --provider 1password. Share the returned + 1Password authorization URL with the account owner and poll items get --wait 60 + until the account state is connected. Its item ID is the account_id below. +2. credentials create --spec-file with provider "1password", + account_id, and a version 2 requests object with exactly one login entry for the + site's HTTPS URL. No field definitions, selectors, or values are accepted. +3. Invoke 1pw_request_access with a vault-bound browser_id. Present the returned + onepassword:// approval link and instructions to the account owner unchanged. +4. Invoke 1pw_poll_access (timeout_seconds 0-120) until the credential is ready, + declined, or failed. Ready means approved, not logged in. +5. Open the login page and invoke 1pw_fill with browser_id and the exact page_url. + fill_submitted means the form was submitted, not that login succeeded. +Invoke only advertised operations. Never automatically retry request, fill, or +recovery failures or uncertain outcomes; 1pw_reconcile_access requires checking +1Password for an existing request first.` + +const vaultCredentialHelp = `Create credentials for a website. + +` + vaultCredentialPathsHelp + ` + +Kernel-hosted flow: Do not use credential items to store, collect, or fill credit card data, including card numbers (PANs), security codes (CVV/CVC), or expiration dates. Use wallet and card item types for credit cards and payment checkout instead. First create a vault for the end user and attach it with browsers create --vault. Use a protected JSON file or stdin, never secret values in shell arguments. -The spec contains description and fields as an ordered array of named definitions. +The spec contains an optional provider ("kernel", the default), description, and +fields as an ordered array of named definitions. Inspect the website and list fields in its natural top-to-bottom order because the user-facing collection form renders that order unchanged. Definitions accept a stable name and an optional non-secret human-readable label; forms fall back to name. Updates, @@ -38,7 +75,9 @@ Ready means populated, not a successful login. An agent controlling the browser can read filled values. TOTP seeds must not be collected through the hosted form. Get/list output includes definitions, has_value, and explicitly non-sensitive text/email values. Sensitive values and TOTP seeds are omitted. -Collection URLs are bearer credentials: share only with the intended user.` +Collection URLs are bearer credentials: share only with the intended user. + +` + vaultOnePasswordCredentialHelp func newVaultCredentialsCommand() *cobra.Command { group := &cobra.Command{Use: "credentials", Short: "Collect, update, and fill user credentials", Long: vaultCredentialHelp} @@ -63,14 +102,20 @@ func newVaultCredentialsCommand() *cobra.Command { }, } if update { - cmd.Long += "\nUpdate preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." + cmd.Long += "\nUpdate applies to Kernel-hosted credentials only. It preserves omitted fields, replaces nonempty string values, and clears supported values with null or an empty string. Clearing a required text/email/password field returns pending_collection; form submissions still require a nonempty value.\nField definitions are immutable. Do not automatically retry version conflicts." cmd.Flags().Int64("version", 0, "Expected version from items get (required; never auto-refreshed)") _ = cmd.MarkFlagRequired("version") cmd.Flags().String("expected-item-id", "", "Immutable item ID from the original read; reject an update if the key now refers to a replacement item") cmd.Example = " kernel vaults credentials update user-vault login --version 2 --spec-file changes.json" } else { - cmd.Example = ` kernel vaults credentials create user-vault login --spec-file - <<'JSON' + cmd.Example = ` # Kernel-hosted collection + kernel vaults credentials create user-vault login --spec-file - <<'JSON' {"description":"Hacker News","fields":[{"name":"username","label":"Username","type":"text","required":true,"sensitive":false},{"name":"password","label":"Password","type":"password","required":true,"sensitive":true}]} +JSON + + # 1Password brokered approval (account_id is the connected account's item ID) + kernel vaults credentials create user-vault github --spec-file - <<'JSON' +{"provider":"1password","account_id":"","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}} JSON` } cmd.Flags().String("spec-file", "", "Credential spec JSON file (use '-' for stdin; maximum 128 KiB)") @@ -79,6 +124,31 @@ JSON` addVaultJSONOutputFlag(cmd) group.AddCommand(cmd) } + connect := &cobra.Command{Use: "connect --provider 1password", Short: "Connect a 1Password account and return its authorization URL", Args: cobra.ExactArgs(2), PreRunE: vaultPreRun, + Long: `Create a credential_account item that connects the user's 1Password account. +Only use this after the user chose 1Password brokered approval over Kernel-hosted collection. +Share the returned 1Password authorization URL with the account owner; they sign in and +consent at 1Password, and Kernel receives the grant. No tokens or keys are displayed. +Poll items get --wait 60 until the account state is connected, then reference its +item ID as account_id in credentials create. Repeating the request returns the +existing account. Invoke 1pw_recover only when the account advertises it. + +` + vaultOnePasswordCredentialHelp, + Example: " kernel vaults credentials connect user-vault onepassword --provider 1password", + RunE: func(cmd *cobra.Command, args []string) error { + provider, _ := cmd.Flags().GetString("provider") + if provider != "1password" { + return fmt.Errorf("--provider must be 1password") + } + open, _ := cmd.Flags().GetBool("open") + return getVaultsHandler(cmd).connectCredentialAccount(cmd.Context(), args[0], args[1], vaultOutput(cmd), open) + }, + } + connect.Flags().String("provider", "", "Credential account provider: 1password (required)") + _ = connect.MarkFlagRequired("provider") + connect.Flags().Bool("open", false, "Open the returned HTTPS authorization URL") + addVaultJSONOutputFlag(connect) + group.AddCommand(connect) return group } @@ -125,9 +195,9 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ } item, err = c.vaults.Items.Update(ctx, key, kernel.VaultItemUpdateParams{IDOrName: vault, OfCredentialVaultItemUpdateRequest: &request}, option.WithMaxRetries(0)) } else { - var spec kernel.CredentialVaultItemSpecInputParam - if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { - return fmt.Errorf("credential spec requires fields") + spec, specErr := credentialSpecInput(data) + if specErr != nil { + return specErr } item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) } @@ -136,3 +206,52 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ } return c.showItem(item, output, open) } + +// Specs without a provider predate 1Password support and remain Kernel-hosted. +func credentialSpecInput(data []byte) (kernel.CredentialVaultItemSpecInputUnionParam, error) { + var header struct { + Provider *string `json:"provider"` + } + if json.Unmarshal(data, &header) != nil { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("invalid credential spec") + } + provider := "kernel" + if header.Provider != nil { + provider = *header.Provider + } + switch provider { + case "kernel": + var spec kernel.KernelCredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec requires fields") + } + spec.Provider = kernel.KernelCredentialVaultItemSpecInputProviderKernel + return kernel.CredentialVaultItemSpecInputUnionParam{OfKernel: &spec}, nil + case "1password": + var spec kernel.OnePasswordCredentialVaultItemSpecInputParam + if json.Unmarshal(data, &spec) != nil || strings.TrimSpace(spec.AccountID) == "" || len(spec.Requests.Entries) == 0 { + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("1Password credential spec requires account_id and requests with a login entry") + } + return kernel.CredentialVaultItemSpecInputUnionParam{Of1password: &spec}, nil + default: + return kernel.CredentialVaultItemSpecInputUnionParam{}, fmt.Errorf("credential spec provider must be kernel or 1password") + } +} + +func (c VaultsCmd) connectCredentialAccount(ctx context.Context, vault, key, output string, open bool) error { + request := kernel.CredentialAccountVaultItemRequestParam{ + Type: kernel.CredentialAccountVaultItemRequestTypeCredentialAccount, + Spec: kernel.OnePasswordCredentialAccountSpecParam{ + Provider: kernel.OnePasswordCredentialAccountSpecProvider1password, + Authorization: kernel.OnePasswordCredentialAccountSpecAuthorizationParam{ + Method: "oauth", + Client: kernel.OnePasswordCredentialAccountSpecAuthorizationClientUnionParam{OfKernelManaged: &kernel.OnePasswordCredentialAccountSpecAuthorizationClientKernelManagedParam{}}, + }, + }, + } + item, err := c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredentialAccount: &request}, option.WithMaxRetries(0)) + if err != nil { + return vaultCredentialError(err) + } + return c.showItem(item, output, open) +} diff --git a/cmd/vaults_credentials_test.go b/cmd/vaults_credentials_test.go index 298b715e..b9f40f9d 100644 --- a/cmd/vaults_credentials_test.go +++ b/cmd/vaults_credentials_test.go @@ -42,7 +42,7 @@ func TestCredentialCreateAndUpdate(t *testing.T) { assert.JSONEq(t, `{"fields":{"password":{"value":null}}}`, string(body["spec"])) } else { assert.Equal(t, "PUT", r.Method) - assert.JSONEq(t, `{"fields":[{"name":"password","label":"Account Password","type":"password","required":true}]}`, string(body["spec"])) + assert.JSONEq(t, `{"provider":"kernel","fields":[{"name":"password","label":"Account Password","type":"password","required":true}]}`, string(body["spec"])) } w.Header().Set("Content-Type", "application/json") io.WriteString(w, credentialFixture) diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index b2d787d8..94b6b853 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -177,3 +177,74 @@ func parseVaultFillResult(raw json.RawMessage, count int) (*vaultFillResult, err } return &result, nil } + +const onePasswordFillUncertain = "the form may have been submitted; inspect the browser and do not retry in the same browser" + +var onePasswordFillResultFields = vaultFieldsOf("type status error_code") + +func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, request *kernel.VaultItemPerformOperationParams, output string) error { + params := *request + params.IDOrName = vault + response, err := c.vaults.Items.PerformOperation(ctx, key, params, option.WithMaxRetries(0)) + if err != nil { + var apiErr *kernel.Error + if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + var body struct { + Code string `json:"code"` + } + guidance := "nothing was filled by this request; inspect the item, browser, and page_url before deciding on a new fill" + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + if message, ok := vaultFillErrorMessages[body.Code]; ok { + return fmt.Errorf("1pw_fill failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) + } + } + return fmt.Errorf("1pw_fill rejected (HTTP %d); %s", apiErr.StatusCode, guidance) + } + if errors.As(err, &apiErr) && apiErr.StatusCode == 503 { + return fmt.Errorf("1pw_fill unavailable (HTTP 503): the 1Password browser integration is not available in this deployment") + } + return fmt.Errorf("1pw_fill result unavailable; %s", onePasswordFillUncertain) + } + if response == nil { + return fmt.Errorf("empty 1pw_fill result; %s", onePasswordFillUncertain) + } + safe, err := filterVaultJSON(json.RawMessage(response.RawJSON()), onePasswordFillResultFields) + if err != nil { + return fmt.Errorf("invalid 1pw_fill result; %s", onePasswordFillUncertain) + } + var result struct { + Type string `json:"type"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + } + if json.Unmarshal(safe, &result) != nil || result.Type != "1pw_fill" { + return fmt.Errorf("invalid 1pw_fill result; %s", onePasswordFillUncertain) + } + switch result.Status { + case "fill_submitted", "fill_failed", "fill_unknown": + default: + return fmt.Errorf("invalid 1pw_fill result; %s", onePasswordFillUncertain) + } + if output == "json" { + if err := printVaultJSON(result); err != nil { + return err + } + } else { + pterm.Printf("1Password fill: %s\n", result.Status) + if result.ErrorCode != "" { + pterm.Printf("Error code: %s\n", result.ErrorCode) + } + switch result.Status { + case "fill_submitted": + pterm.Println("The extension filled and submitted the form; this does not confirm the website accepted the login.") + case "fill_failed": + pterm.Println("The extension reported a failure. Inspect the page before deciding on a new fill; do not retry automatically.") + default: + pterm.Println(onePasswordFillUncertain) + } + } + if result.Status != "fill_submitted" { + return vaultFillOutcomeError{status: result.Status} + } + return nil +} diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go new file mode 100644 index 00000000..46159f75 --- /dev/null +++ b/cmd/vaults_onepassword_test.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const onePasswordAccountFixture = `{"id":"account-1","key":"onepassword","type":"credential_account","spec":{"provider":"1password","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"1password","status":"pending_authorization"},"action":{"name":"1password_oauth","url":"https://my.1password.example/oauth/authorize?client_id=kernel&state=opaque"},"available_operations":[],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` + +const onePasswordCredentialFixture = `{"id":"credential-2","key":"github","type":"credential","version":1,"spec":{"provider":"1password","account_id":"account-1","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}},"state":{"provider":"1password","status":"pending_authorization","access_request_id":"req-1","access_request":{"id":"req-1","state":"pending","identity":"never-print-identity","path":"never-print-path","has_autofill_token":false,"granted_count":0}},"action":{"name":"1password_access_approval","url":"onepassword://grant-brokered-access?access_request_reference=ref-1","instructions":"Present this link to the account owner."},"available_operations":[{"type":"1pw_poll_access","description":"Check the request."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` + +func onePasswordCredentialWithOperation(operation string) string { + return strings.Replace(onePasswordCredentialFixture, `"1pw_poll_access"`, `"`+operation+`"`, 1) +} + +func TestCredentialConnectOnePassword(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/vaults/user/items/onepassword", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential_account","spec":{"provider":"1password","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}}}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, onePasswordAccountFixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "connect", "user", "onepassword", "--provider", "1password", "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.Contains(t, out, `"name": "1password_oauth"`) + assert.Contains(t, out, "https://my.1password.example/oauth/authorize") + + _, text, err := executeVaultCommand(t, client, "vaults", "credentials", "connect", "user", "onepassword", "--provider", "1password") + require.NoError(t, err) + assert.Contains(t, text, "Share the 1Password authorization URL with the account owner") + + _, _, err = executeVaultCommand(t, client, "vaults", "credentials", "connect", "user", "onepassword", "--provider", "kernel") + require.ErrorContains(t, err, "--provider must be 1password") + assert.Equal(t, 2, calls) +} + +func TestCredentialCreateOnePassword(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"provider":"1password","account_id":"account-1","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"},"reason":"Sign in"}]}}` + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"credential","spec":`+spec+`}`, string(body)) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, onePasswordCredentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "github", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Equal(t, 1, calls) + + for _, invalid := range []string{ + `{"provider":"1password","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}}`, + `{"provider":"1password","account_id":"account-1"}`, + `{"provider":"lastpass","fields":[{"name":"password","type":"password"}]}`, + } { + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user", "github", "--spec-file", credentialSpecFile(t, invalid)) + require.Error(t, err, invalid) + } + assert.Equal(t, 1, calls) +} + +func TestOnePasswordCredentialOutput(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + fixture := onePasswordCredentialFixture + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github", "-o", "json") + require.NoError(t, err) + for _, want := range []string{`"account_id": "account-1"`, `"website": "https://github.com"`, `"access_request_id": "req-1"`, `"state": "pending"`, "onepassword://grant-brokered-access?access_request_reference=ref-1", `"instructions": "Present this link to the account owner."`} { + assert.Contains(t, out, want) + } + assert.NotContains(t, out, "never-print") + + out, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github") + require.NoError(t, err) + output := out + text + assert.Contains(t, output, "onepassword://grant-brokered-access?access_request_reference=ref-1") + assert.Contains(t, output, "Present this link to the account owner.") + assert.Contains(t, output, "account-1") + assert.Contains(t, output, "Invoke: kernel vaults items invoke --params '' -- user github 1pw_poll_access") + assert.NotContains(t, output, "field definitions") + assert.NotContains(t, output, "never-print") + + for _, forged := range []string{ + "onepassword://grant-brokered-access?access_request_reference=ref-1&code=secret", + "onepassword://other?access_request_reference=ref-1", + } { + fixture = strings.Replace(onePasswordCredentialFixture, "onepassword://grant-brokered-access?access_request_reference=ref-1", forged, 1) + out, _, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, out, "onepassword://", forged) + } + fixture = strings.Replace(onePasswordCredentialFixture, `"name":"1password_access_approval"`, `"name":"collect"`, 1) + out, _, err = executeVaultCommand(t, client, "vaults", "items", "get", "user", "github", "-o", "json") + require.NoError(t, err) + assert.NotContains(t, out, "onepassword://") +} + +func TestOnePasswordOperationRequests(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + operation, params, body, item string + }{ + {"1pw_request_access", `{"browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, `{"type":"1pw_request_access","browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, onePasswordCredentialFixture}, + {"1pw_poll_access", `{"browser_id":"browser-1","timeout_seconds":60}`, `{"type":"1pw_poll_access","browser_id":"browser-1","timeout_seconds":60}`, onePasswordCredentialFixture}, + {"1pw_reconcile_access", `{"acknowledge_unconfirmed":true}`, `{"type":"1pw_reconcile_access","acknowledge_unconfirmed":true}`, onePasswordCredentialFixture}, + {"1pw_recover", "", `{"type":"1pw_recover"}`, onePasswordAccountFixture}, + } { + t.Run(tc.operation, func(t *testing.T) { + item := strings.Replace(tc.item, `"available_operations":[]`, `"available_operations":[{"type":"1pw_poll_access","description":"x"}]`, 1) + item = strings.Replace(item, `"1pw_poll_access"`, `"`+tc.operation+`"`, 1) + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, tc.body, string(body)) + } + io.WriteString(w, item) + }) + args := []string{"vaults", "items", "invoke", "user", "github", tc.operation, "-o", "json"} + if tc.params != "" { + args = append(args, "--params", tc.params) + } + _, _, err := executeVaultCommand(t, client, args...) + require.NoError(t, err) + assert.Equal(t, 1, posts) + }) + } +} + +func TestOnePasswordOperationValidation(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct{ operation, params, err string }{ + {"1pw_request_access", "", "requires --params"}, + {"1pw_request_access", `{"browser_id":""}`, "browser_id"}, + {"1pw_request_access", `{"browser_id":"b","password":"x"}`, "only supported"}, + {"1pw_request_access", `{"type":"1pw_request_access","browser_id":"b"}`, "must not contain type"}, + {"1pw_poll_access", `{"browser_id":"b","timeout_seconds":121}`, "timeout_seconds"}, + {"1pw_reconcile_access", `{"acknowledge_unconfirmed":false}`, "acknowledge_unconfirmed must be true"}, + {"1pw_fill", `{"browser_id":"b"}`, "page_url"}, + {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","timeout_ms":0}`, "timeout_ms"}, + {"1pw_recover", `{}`, "takes no parameters"}, + } { + t.Run(tc.operation+tc.params, func(t *testing.T) { + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { calls++ }) + args := []string{"vaults", "items", "invoke", "user", "github", tc.operation} + if tc.params != "" { + args = append(args, "--params", tc.params) + } + _, _, err := executeVaultCommand(t, client, args...) + require.ErrorContains(t, err, tc.err) + assert.Zero(t, calls) + }) + } + + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts++ + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, onePasswordCredentialFixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_fill", "--params", `{"browser_id":"b","page_url":"https://github.com/login"}`) + require.ErrorContains(t, err, "not advertised") + assert.Zero(t, posts) +} + +func TestOnePasswordFillOutcomes(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + status int + body string + err string + output string + }{ + {200, `{"type":"1pw_fill","status":"fill_submitted"}`, "", "does not confirm"}, + {200, `{"type":"1pw_fill","status":"fill_failed","error_code":"fillFailed"}`, "fill fill_failed", "fillFailed"}, + {200, `{"type":"1pw_fill","status":"fill_unknown"}`, "fill fill_unknown", "do not retry in the same browser"}, + {200, `{"type":"fill","status":"completed","fields":[]}`, "invalid 1pw_fill result", ""}, + {403, `{"code":"destination_denied","message":"page is outside the approved login origin"}`, "destination_denied (HTTP 403)", ""}, + {503, `{"code":"provider_unavailable","message":"unavailable"}`, "not available in this deployment", ""}, + {500, `{"code":"internal_error","message":"secret-echo"}`, "may have been submitted", ""}, + } { + t.Run(fmt.Sprint(tc.status, tc.body), func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + io.WriteString(w, onePasswordCredentialWithOperation("1pw_fill")) + return + } + posts++ + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"1pw_fill","browser_id":"browser-1","page_url":"https://github.com/login"}`, string(body)) + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + }) + out, text, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_fill", "--params", `{"browser_id":"browser-1","page_url":"https://github.com/login"}`) + assert.Equal(t, 1, posts) + if tc.err == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.err) + assert.NotContains(t, err.Error(), "secret-echo") + } + assert.Contains(t, out+text, tc.output) + }) + } +} + +func TestCredentialHelpPresentsBothPaths(t *testing.T) { + credentials, _, err := newVaultsCommand().Find([]string{"credentials", "create"}) + require.NoError(t, err) + connect, _, err := newVaultsCommand().Find([]string{"credentials", "connect"}) + require.NoError(t, err) + invoke, _, err := newVaultsCommand().Find([]string{"items", "invoke"}) + require.NoError(t, err) + for _, long := range []string{newVaultsCommand().Long, credentials.Long} { + assert.Contains(t, long, "Ask the user which one they want") + assert.Contains(t, long, "Kernel-hosted collection") + assert.Contains(t, long, "1Password brokered approval") + assert.Contains(t, long, "Never ask the user to paste") + } + assert.Contains(t, connect.Long, "credential_account") + assert.Contains(t, credentials.Example, `"provider":"1password"`) + for _, operation := range []string{"1pw_request_access", "1pw_poll_access", "1pw_fill", "1pw_reconcile_access", "1pw_recover"} { + assert.Contains(t, invoke.Long, operation) + } +} diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index c1f8ca86..dcf0915f 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -14,6 +14,17 @@ import ( type vaultOperationParams struct { Fill *vaultFillParams Checkout *kernel.VaultCheckoutContextParam + // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. + OnePassword *kernel.VaultItemPerformOperationParams +} + +func isOnePasswordOperation(operation string) bool { + return strings.HasPrefix(operation, "1pw_") +} + +// vaultOperationTakesParams reports whether an operation accepts --params or --spec-file. +func vaultOperationTakesParams(operation string) bool { + return operation == "fill" || operation == "prepare_checkout" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -73,12 +84,28 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( if strings.TrimSpace(operation) == "" { return nil, fmt.Errorf("operation must not be empty") } - if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" { - return nil, fmt.Errorf("--open is only supported for authorize, collect, and prepare_checkout") + if openSet && operation != "authorize" && operation != "collect" && operation != "prepare_checkout" && operation != "1pw_recover" { + return nil, fmt.Errorf("--open is only supported for authorize, collect, prepare_checkout, and 1pw_recover") } if len(raw) > 128*1024 { return nil, fmt.Errorf("operation parameters exceed 128 KiB") } + if isOnePasswordOperation(operation) { + if operation == "1pw_recover" { + if paramsSet { + return nil, fmt.Errorf("1pw_recover takes no parameters") + } + return &vaultOperationParams{OnePassword: &kernel.VaultItemPerformOperationParams{Of1pwRecover: &kernel.OnePasswordRecoverVaultItemOperationRequestParam{Type: kernel.OnePasswordRecoverVaultItemOperationRequestType1pwRecover}}}, nil + } + if !paramsSet { + return nil, fmt.Errorf("%s requires --params or --spec-file", operation) + } + request, err := parseOnePasswordOperationParams(operation, raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{OnePassword: request}, nil + } if operation == "prepare_checkout" { if !paramsSet { return nil, fmt.Errorf("prepare_checkout requires --params or --spec-file with checkout") @@ -91,7 +118,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill and prepare_checkout; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, prepare_checkout, and 1Password operations; authorize takes no parameters") } return nil, nil } @@ -154,3 +181,78 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { } return ¶ms, nil } + +func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPerformOperationParams, error) { + allowed := map[string]string{ + "1pw_request_access": "browser_id goal reason keywords", + "1pw_poll_access": "browser_id timeout_seconds", + "1pw_reconcile_access": "acknowledge_unconfirmed", + "1pw_fill": "browser_id page_url timeout_ms", + }[operation] + if allowed == "" { + return nil, fmt.Errorf("unsupported 1Password operation %q", operation) + } + object, err := vaultParamsObject(raw, allowed) + if err != nil { + return nil, err + } + var browserID string + if strings.Contains(allowed, "browser_id") { + if json.Unmarshal(object["browser_id"], &browserID) != nil || strings.TrimSpace(browserID) == "" { + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") + } + } + switch operation { + case "1pw_request_access": + request := kernel.OnePasswordRequestAccessVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordRequestAccessVaultItemOperationRequestType1pwRequestAccess} + for _, name := range []string{"goal", "reason"} { + if value, ok := object[name]; ok { + var text string + if json.Unmarshal(value, &text) != nil { + return nil, fmt.Errorf("%s must be a string", name) + } + if name == "goal" { + request.Goal = kernel.Opt(text) + } else { + request.Reason = kernel.Opt(text) + } + } + } + if value, ok := object["keywords"]; ok && json.Unmarshal(value, &request.Keywords) != nil { + return nil, fmt.Errorf("keywords must be an array of strings") + } + return &kernel.VaultItemPerformOperationParams{Of1pwRequestAccess: &request}, nil + case "1pw_poll_access": + request := kernel.VaultItemPerformOperationParamsBody1pwPollAccess{BrowserID: browserID} + if value, ok := object["timeout_seconds"]; ok { + var timeout *int64 + if json.Unmarshal(value, &timeout) != nil || timeout == nil || *timeout < 0 || *timeout > 120 { + return nil, fmt.Errorf("timeout_seconds must be an integer between 0 and 120") + } + request.TimeoutSeconds = kernel.Opt(*timeout) + } + return &kernel.VaultItemPerformOperationParams{Of1pwPollAccess: &request}, nil + case "1pw_reconcile_access": + var acknowledged bool + if json.Unmarshal(object["acknowledge_unconfirmed"], &acknowledged) != nil || !acknowledged { + return nil, fmt.Errorf("acknowledge_unconfirmed must be true; first check 1Password for an existing request") + } + return &kernel.VaultItemPerformOperationParams{Of1pwReconcileAccess: &kernel.VaultItemPerformOperationParamsBody1pwReconcileAccess{AcknowledgeUnconfirmed: true}}, nil + default: + request := kernel.OnePasswordFillVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordFillVaultItemOperationRequestType1pwFill} + if json.Unmarshal(object["page_url"], &request.PageURL) != nil { + return nil, fmt.Errorf("page_url must be the exact absolute URL of the open login page") + } + if u, err := url.ParseRequestURI(request.PageURL); err != nil || u.Scheme == "" { + return nil, fmt.Errorf("page_url must be the exact absolute URL of the open login page") + } + if value, ok := object["timeout_ms"]; ok { + var timeout *int64 + if json.Unmarshal(value, &timeout) != nil || timeout == nil || *timeout < 1 || *timeout > 30000 { + return nil, fmt.Errorf("timeout_ms must be an integer between 1 and 30000") + } + request.TimeoutMs = kernel.Opt(*timeout) + } + return &kernel.VaultItemPerformOperationParams{Of1pwFill: &request}, nil + } +} diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 9d1c60bd..6909337b 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -26,6 +26,11 @@ func vaultFieldsOf(names string) vaultOutputFields { var vaultFields = vaultFieldsOf("id name created_at updated_at") var vaultOperationFields = vaultFieldsOf("type description") var vaultTotalFields = vaultFieldsOf("type display_text amount") +var onePasswordRequestEntryFields = vaultOutputFields{ + "id": nil, "type": nil, "reason": nil, "keywords": nil, + "parameters": vaultFieldsOf("website"), +} +var onePasswordRequestFields = vaultOutputFields{"version": nil, "goal": nil, "entries": onePasswordRequestEntryFields} var vaultMethodFields = vaultOutputFields{ "id": nil, "provider": nil, "type": nil, "is_default": nil, "display": vaultFieldsOf("label brand last4"), @@ -35,12 +40,13 @@ var vaultItemFields = vaultOutputFields{ "id": nil, "key": nil, "type": nil, "version": nil, "created_at": nil, "updated_at": nil, "expires_at": nil, "available_operations": vaultOperationFields, "available_expansions": vaultOperationFields, - "action": vaultFieldsOf("name url expires_at"), + "action": vaultFieldsOf("name url expires_at instructions"), "expanded": {"payment_methods": vaultMethodFields}, "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, - "context": nil, "expires_at": nil, "description": nil, + "context": nil, "expires_at": nil, "description": nil, "account_id": nil, + "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), "provider_config": vaultFieldsOf("id name"), "authorization": {"method": nil, "client": {"type": nil, "provider_config": vaultFieldsOf("id name")}}, @@ -51,7 +57,11 @@ var vaultItemFields = vaultOutputFields{ }, }, "state": { - "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, + "provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, "access_request_id": nil, + "access_request": { + "id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil, + "request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields, + }, "fields": {"*": vaultFieldsOf("has_value")}, "masks": vaultFieldsOf("brand last4"), "aliases": vaultFieldsOf("number cvc exp_month exp_year"), @@ -111,9 +121,14 @@ func filterVaultJSON(raw json.RawMessage, fields vaultOutputFields) (json.RawMes continue } if value, ok := object[key]; ok { - if key == "url" || key == "approval_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" { + if key == "url" || key == "approval_url" || key == "merchant_url" || key == "merchant_origin" || key == "image_url" || key == "product_url" || key == "website" { var address string - if json.Unmarshal(value, &address) != nil || !vaultDisplayURL(address) { + if json.Unmarshal(value, &address) != nil { + continue + } + var name string + approval := key == "url" && json.Unmarshal(object["name"], &name) == nil && name == "1password_access_approval" + if !vaultDisplayURL(address) && !(approval && onePasswordApprovalURL(address)) { continue } } @@ -239,6 +254,17 @@ func vaultDisplayURL(address string) bool { return true } +// The native 1Password approval link is handed to the account owner unchanged; it +// grants nothing until they approve in their own 1Password app. +func onePasswordApprovalURL(address string) bool { + u, err := url.Parse(address) + if err != nil || u.Scheme != "onepassword" || u.Host != "grant-brokered-access" || u.User != nil || u.Fragment != "" { + return false + } + query, err := url.ParseQuery(u.RawQuery) + return err == nil && len(query) == 1 && len(query["access_request_reference"]) == 1 && query.Get("access_request_reference") != "" +} + func vaultShellArgument(value string) string { if vaultNamePattern.MatchString(value) { return value @@ -257,7 +283,7 @@ func printVaultOperationHints(item *kernel.VaultItemUnion, vault, key, project s } for _, op := range actions.Operations { command := prefix - if op.Type == "fill" || op.Type == "prepare_checkout" { + if vaultOperationTakesParams(op.Type) { command += " --params ''" } pterm.Printf("Invoke: %s -- %s %s %s\n", command, vaultShellArgument(vault), vaultShellArgument(key), vaultShellArgument(op.Type)) @@ -288,7 +314,17 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error { } if item.Type == "credential" { rows = append(rows, []string{"Version", fmt.Sprint(item.Version)}) - pterm.Info.Println("Use -o json for field definitions, presence, and non-sensitive values; sensitive values are omitted") + if item.Spec.Provider == "1password" { + rows = append(rows, []string{"1Password account ID (immutable)", item.Spec.AccountID}) + for _, entry := range item.Spec.Requests.Entries { + rows = append(rows, []string{"Requested login", entry.Parameters.Website}) + } + if item.State.JSON.AccessRequest.Valid() { + rows = append(rows, []string{"Access request state", item.State.AccessRequest.State}) + } + } else { + pterm.Info.Println("Use -o json for field definitions, presence, and non-sensitive values; sensitive values are omitted") + } } if item.Type == "wallet" { configID, configName := item.Spec.ProviderConfig.ID, item.Spec.ProviderConfig.Name @@ -376,6 +412,19 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction for _, op := range actions.Operations { pterm.Printf("Available operation: %s — %s\n", op.Type, op.Description) } + if item.Type == "credential_account" { + if actions.RequiredAction != "" { + pterm.Info.Println("Share the 1Password authorization URL with the account owner. Observe the connection with items get --wait 60; never ask for 1Password passwords or codes.") + } + return + } + if item.Type == "credential" && item.Spec.Provider == "1password" { + if item.Action.Instructions != "" { + pterm.Printf("Approval instructions:\n%s\n", item.Action.Instructions) + } + pterm.Info.Println("Ready means the account owner approved access, not that login succeeded. 1pw_fill submits the form; never retry request or fill automatically.") + return + } if item.Type == "credential" { if actions.RequiredAction != "" { pterm.Info.Println("Share the collection URL with the user to complete the credential form. Observe readiness with items get --wait 60; for edits to an already-ready item, compare versions without --wait.") diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index 8fa3a16c..00f19b59 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -73,7 +73,7 @@ func TestCredentialInitialValuesWithGeneratedSDK(t *testing.T) { assert.Equal(t, "PUT", r.Method) var body map[string]json.RawMessage require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.JSONEq(t, spec, string(body["spec"])) + assert.JSONEq(t, `{"provider":"kernel",`+spec[1:], string(body["spec"])) w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"ready","fields":{"otp":{"has_value":true,"value":"JBSWY3DPEHPK3PXP"}}},"available_operations":[],"available_expansions":[]}`, spec) }) diff --git a/go.mod b/go.mod index 8655cc53..827e5db7 100644 --- a/go.mod +++ b/go.mod @@ -61,3 +61,8 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect ) + +// TODO: Temporary private stlc preview of kernel/kernel#4256 (1Password credential +// vaults). Before merge, delete this replace, bump github.com/kernel/kernel-go-sdk to +// the official release that includes that API, and run go mod tidy. +replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925021738-7ed365222d7b diff --git a/go.sum b/go.sum index dbb5d098..d3b49acc 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.112.0 h1:WCWHRtHQs/z4Q8cwZ/uS/HnxZZi6roge+VYai5d73ic= -github.com/kernel/kernel-go-sdk v0.112.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925021738-7ed365222d7b h1:k1Cu/jC357sI5XLMU9T/vNxmVa8Il6Tc20zq2EygvFg= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925021738-7ed365222d7b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From 1d7af895dd863500ab916d3329963342597e09ab Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:00:08 +0000 Subject: [PATCH 2/3] Keep fill lookup status and uncertain 1pw_fill 4xx guidance --- cmd/vaults.go | 2 +- cmd/vaults_fill.go | 47 ++++++++++++++++++++++++---------- cmd/vaults_onepassword_test.go | 30 ++++++++++++++++++++++ 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 54d99985..bb2d6435 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -218,7 +218,7 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par item, err := c.vaults.Items.Get(ctx, key, kernel.VaultItemGetParams{IDOrName: vault}, option.WithMaxRetries(0)) if err != nil { if operation == "fill" || operation == "1pw_fill" { - return fmt.Errorf("could not retrieve vault item; %s was not invoked", operation) + return vaultFillLookupError(err, operation) } return util.CleanedUpSdkError{Err: err} } diff --git a/cmd/vaults_fill.go b/cmd/vaults_fill.go index 94b6b853..e9d49b5c 100644 --- a/cmd/vaults_fill.go +++ b/cmd/vaults_fill.go @@ -50,6 +50,23 @@ var vaultFillErrorMessages = map[string]string{ "execution_failed": "fill execution failed", } +// vaultFillLookupError reports the item lookup status without response details; the fill was never sent. +func vaultFillLookupError(err error, operation string) error { + var apiErr *kernel.Error + if errors.As(err, &apiErr) { + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + if _, ok := vaultFillErrorMessages[body.Code]; ok { + return fmt.Errorf("could not retrieve vault item: %s (HTTP %d); %s was not invoked", body.Code, apiErr.StatusCode, operation) + } + } + return fmt.Errorf("could not retrieve vault item (HTTP %d); %s was not invoked", apiErr.StatusCode, operation) + } + return fmt.Errorf("could not retrieve vault item; %s was not invoked", operation) +} + func vaultFillRequestError(err error) error { var apiErr *kernel.Error if errors.As(err, &apiErr) { @@ -188,22 +205,26 @@ func (c VaultsCmd) onePasswordFill(ctx context.Context, vault, key string, reque response, err := c.vaults.Items.PerformOperation(ctx, key, params, option.WithMaxRetries(0)) if err != nil { var apiErr *kernel.Error - if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { - var body struct { - Code string `json:"code"` - } - guidance := "nothing was filled by this request; inspect the item, browser, and page_url before deciding on a new fill" - if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { - if message, ok := vaultFillErrorMessages[body.Code]; ok { - return fmt.Errorf("1pw_fill failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) - } - } - return fmt.Errorf("1pw_fill rejected (HTTP %d); %s", apiErr.StatusCode, guidance) + if !errors.As(err, &apiErr) { + return fmt.Errorf("1pw_fill result unavailable; %s", onePasswordFillUncertain) } - if errors.As(err, &apiErr) && apiErr.StatusCode == 503 { + // Only these statuses are returned before the extension is invoked. + guidance := onePasswordFillUncertain + switch apiErr.StatusCode { + case 400, 403, 404, 409: + guidance = "nothing was submitted by this request; inspect the item, browser, and page_url before deciding on a new fill; do not automatically retry" + case 503: return fmt.Errorf("1pw_fill unavailable (HTTP 503): the 1Password browser integration is not available in this deployment") } - return fmt.Errorf("1pw_fill result unavailable; %s", onePasswordFillUncertain) + var body struct { + Code string `json:"code"` + } + if json.Unmarshal([]byte(apiErr.RawJSON()), &body) == nil { + if message, ok := vaultFillErrorMessages[body.Code]; ok { + return fmt.Errorf("1pw_fill failed: %s (HTTP %d): %s; %s", body.Code, apiErr.StatusCode, message, guidance) + } + } + return fmt.Errorf("1pw_fill request failed (HTTP %d); %s", apiErr.StatusCode, guidance) } if response == nil { return fmt.Errorf("empty 1pw_fill result; %s", onePasswordFillUncertain) diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index 46159f75..70198f8c 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -200,6 +200,8 @@ func TestOnePasswordFillOutcomes(t *testing.T) { {200, `{"type":"1pw_fill","status":"fill_unknown"}`, "fill fill_unknown", "do not retry in the same browser"}, {200, `{"type":"fill","status":"completed","fields":[]}`, "invalid 1pw_fill result", ""}, {403, `{"code":"destination_denied","message":"page is outside the approved login origin"}`, "destination_denied (HTTP 403)", ""}, + {409, `{"code":"conflict","message":"not ready"}`, "nothing was submitted", ""}, + {429, `{"code":"rate_limited","message":"slow down"}`, "may have been submitted", ""}, {503, `{"code":"provider_unavailable","message":"unavailable"}`, "not available in this deployment", ""}, {500, `{"code":"internal_error","message":"secret-echo"}`, "may have been submitted", ""}, } { @@ -250,3 +252,31 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { assert.Contains(t, invoke.Long, operation) } } + +func TestOnePasswordFillLookupErrorKeepsStatus(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + for _, tc := range []struct { + status int + body string + want string + }{ + {404, `{"code":"not_found","message":"item not found"}`, "not_found (HTTP 404); 1pw_fill was not invoked"}, + {403, `{"code":"other","message":"secret-echo"}`, "(HTTP 403); 1pw_fill was not invoked"}, + } { + t.Run(fmt.Sprint(tc.status), func(t *testing.T) { + posts := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts++ + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "user", "github", "1pw_fill", "--params", `{"browser_id":"b","page_url":"https://github.com/login"}`) + require.ErrorContains(t, err, tc.want) + assert.NotContains(t, err.Error(), "secret-echo") + assert.Zero(t, posts) + }) + } +} From 426a97c1631735491ee7d0042e9cf7e7358bd2de Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:52:36 +0000 Subject: [PATCH 3/3] Track renamed 1Password operations and drop reconcile --- cmd/vaults_commands.go | 23 ++++++++++++----------- cmd/vaults_credentials.go | 17 +++++++++-------- cmd/vaults_onepassword_test.go | 34 +++++++++++++++++++--------------- cmd/vaults_operation_params.go | 31 +++++++++++-------------------- go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 54 insertions(+), 57 deletions(-) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 25b7ece3..22c51f3e 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -194,25 +194,26 @@ collect/authorize/prepare_checkout/1pw_recover may use --open. Fill returns valu completed exits 0, failed/unknown exit nonzero with valid JSON retained on stdout in -o json. 1Password credentials (see credentials --help) use --params without type: -1pw_request_access: browser_id (vault-bound session ID); optional goal (<=140), reason - (<=100), keywords (1-5 strings). Present the returned onepassword:// approval link and - instructions to the account owner unchanged; do not issue a second request while pending. -1pw_poll_access: browser_id; optional timeout_seconds 0-120 (default 10). +1pw_create_access_request: browser_id (vault-bound session ID); optional goal (<=140), + reason (<=100), keywords (1-5 strings). Present the returned onepassword:// approval + link and instructions to the account owner unchanged; do not create a second request. +1pw_access_request_status: browser_id; optional timeout_seconds 0-120 (default 10). + Check status after the account owner has the approval link. 1pw_fill: browser_id and the exact page_url of one open login page on the requested origin; optional timeout_ms 1-30000. The extension selects fields and submits. fill_submitted exits 0 and does not confirm login; fill_failed and fill_unknown exit nonzero. After fill_unknown, do not retry in the same browser. -1pw_reconcile_access: {"acknowledge_unconfirmed":true}, only after checking 1Password for - an existing request; it does not cancel anything upstream. -1pw_recover (credential accounts, no parameters): starts human-consented OAuth recovery - of a lost integration key; it can revoke the connection. Never delete the item to recover. -Never automatically retry 1Password operations after failures or uncertain outcomes.`, +1pw_recover (credential accounts, no parameters): returns a new 1Password link that + recovers a failed account connection. Share it with the account owner; never delete + the item to recover. +Never retry 1Password operations after failures or uncertain outcomes; stop and tell the +user instead.`, Example: ` kernel vaults items invoke user-vault login collect kernel vaults items invoke user-vault login fill --spec-file - <<'JSON' {"browser_id":"","fields":[{"field":"username","selector":"#username"},{"field":"password","selector":"#password"}]} JSON - kernel vaults items invoke user-vault github 1pw_request_access --params '{"browser_id":"","reason":"Sign in to GitHub"}' - kernel vaults items invoke user-vault github 1pw_poll_access --params '{"browser_id":"","timeout_seconds":60}' + kernel vaults items invoke user-vault github 1pw_create_access_request --params '{"browser_id":"","reason":"Sign in to GitHub"}' + kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"browser_id":"","timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index e6fa8003..6efe6102 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -36,15 +36,15 @@ const vaultOnePasswordCredentialHelp = `1Password flow: 2. credentials create --spec-file with provider "1password", account_id, and a version 2 requests object with exactly one login entry for the site's HTTPS URL. No field definitions, selectors, or values are accepted. -3. Invoke 1pw_request_access with a vault-bound browser_id. Present the returned - onepassword:// approval link and instructions to the account owner unchanged. -4. Invoke 1pw_poll_access (timeout_seconds 0-120) until the credential is ready, - declined, or failed. Ready means approved, not logged in. +3. Invoke 1pw_create_access_request with a vault-bound browser_id. Present the + returned onepassword:// approval link and instructions to the account owner unchanged. +4. Invoke 1pw_access_request_status (timeout_seconds 0-120) until the credential is + ready, declined, or failed. Ready means approved, not logged in. 5. Open the login page and invoke 1pw_fill with browser_id and the exact page_url. fill_submitted means the form was submitted, not that login succeeded. -Invoke only advertised operations. Never automatically retry request, fill, or -recovery failures or uncertain outcomes; 1pw_reconcile_access requires checking -1Password for an existing request first.` +Invoke only advertised operations. Never retry access request, fill, or recovery +failures or uncertain outcomes, and never create a second access request for the same +credential; stop and tell the user instead.` const vaultCredentialHelp = `Create credentials for a website. @@ -131,7 +131,8 @@ Share the returned 1Password authorization URL with the account owner; they sign consent at 1Password, and Kernel receives the grant. No tokens or keys are displayed. Poll items get --wait 60 until the account state is connected, then reference its item ID as account_id in credentials create. Repeating the request returns the -existing account. Invoke 1pw_recover only when the account advertises it. +existing account. If linking fails and the account advertises 1pw_recover, invoke it +and share the new link with the account owner. ` + vaultOnePasswordCredentialHelp, Example: " kernel vaults credentials connect user-vault onepassword --provider 1password", diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index 70198f8c..d6750ae3 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -13,10 +13,10 @@ import ( const onePasswordAccountFixture = `{"id":"account-1","key":"onepassword","type":"credential_account","spec":{"provider":"1password","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"1password","status":"pending_authorization"},"action":{"name":"1password_oauth","url":"https://my.1password.example/oauth/authorize?client_id=kernel&state=opaque"},"available_operations":[],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` -const onePasswordCredentialFixture = `{"id":"credential-2","key":"github","type":"credential","version":1,"spec":{"provider":"1password","account_id":"account-1","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}},"state":{"provider":"1password","status":"pending_authorization","access_request_id":"req-1","access_request":{"id":"req-1","state":"pending","identity":"never-print-identity","path":"never-print-path","has_autofill_token":false,"granted_count":0}},"action":{"name":"1password_access_approval","url":"onepassword://grant-brokered-access?access_request_reference=ref-1","instructions":"Present this link to the account owner."},"available_operations":[{"type":"1pw_poll_access","description":"Check the request."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` +const onePasswordCredentialFixture = `{"id":"credential-2","key":"github","type":"credential","version":1,"spec":{"provider":"1password","account_id":"account-1","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}},"state":{"provider":"1password","status":"pending_authorization","access_request_id":"req-1","access_request":{"id":"req-1","state":"pending","identity":"never-print-identity","path":"never-print-path","has_autofill_token":false,"granted_count":0}},"action":{"name":"1password_access_approval","url":"onepassword://grant-brokered-access?access_request_reference=ref-1","instructions":"Present this link to the account owner."},"available_operations":[{"type":"1pw_access_request_status","description":"Check the request."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` func onePasswordCredentialWithOperation(operation string) string { - return strings.Replace(onePasswordCredentialFixture, `"1pw_poll_access"`, `"`+operation+`"`, 1) + return strings.Replace(onePasswordCredentialFixture, `"1pw_access_request_status"`, `"`+operation+`"`, 1) } func TestCredentialConnectOnePassword(t *testing.T) { @@ -94,7 +94,7 @@ func TestOnePasswordCredentialOutput(t *testing.T) { assert.Contains(t, output, "onepassword://grant-brokered-access?access_request_reference=ref-1") assert.Contains(t, output, "Present this link to the account owner.") assert.Contains(t, output, "account-1") - assert.Contains(t, output, "Invoke: kernel vaults items invoke --params '' -- user github 1pw_poll_access") + assert.Contains(t, output, "Invoke: kernel vaults items invoke --params '' -- user github 1pw_access_request_status") assert.NotContains(t, output, "field definitions") assert.NotContains(t, output, "never-print") @@ -118,14 +118,13 @@ func TestOnePasswordOperationRequests(t *testing.T) { for _, tc := range []struct { operation, params, body, item string }{ - {"1pw_request_access", `{"browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, `{"type":"1pw_request_access","browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, onePasswordCredentialFixture}, - {"1pw_poll_access", `{"browser_id":"browser-1","timeout_seconds":60}`, `{"type":"1pw_poll_access","browser_id":"browser-1","timeout_seconds":60}`, onePasswordCredentialFixture}, - {"1pw_reconcile_access", `{"acknowledge_unconfirmed":true}`, `{"type":"1pw_reconcile_access","acknowledge_unconfirmed":true}`, onePasswordCredentialFixture}, + {"1pw_create_access_request", `{"browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, `{"type":"1pw_create_access_request","browser_id":"browser-1","goal":"Manage billing","reason":"Sign in","keywords":["personal"]}`, onePasswordCredentialFixture}, + {"1pw_access_request_status", `{"browser_id":"browser-1","timeout_seconds":60}`, `{"type":"1pw_access_request_status","browser_id":"browser-1","timeout_seconds":60}`, onePasswordCredentialFixture}, {"1pw_recover", "", `{"type":"1pw_recover"}`, onePasswordAccountFixture}, } { t.Run(tc.operation, func(t *testing.T) { - item := strings.Replace(tc.item, `"available_operations":[]`, `"available_operations":[{"type":"1pw_poll_access","description":"x"}]`, 1) - item = strings.Replace(item, `"1pw_poll_access"`, `"`+tc.operation+`"`, 1) + item := strings.Replace(tc.item, `"available_operations":[]`, `"available_operations":[{"type":"1pw_access_request_status","description":"x"}]`, 1) + item = strings.Replace(item, `"1pw_access_request_status"`, `"`+tc.operation+`"`, 1) posts := 0 client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -151,12 +150,12 @@ func TestOnePasswordOperationRequests(t *testing.T) { func TestOnePasswordOperationValidation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") for _, tc := range []struct{ operation, params, err string }{ - {"1pw_request_access", "", "requires --params"}, - {"1pw_request_access", `{"browser_id":""}`, "browser_id"}, - {"1pw_request_access", `{"browser_id":"b","password":"x"}`, "only supported"}, - {"1pw_request_access", `{"type":"1pw_request_access","browser_id":"b"}`, "must not contain type"}, - {"1pw_poll_access", `{"browser_id":"b","timeout_seconds":121}`, "timeout_seconds"}, - {"1pw_reconcile_access", `{"acknowledge_unconfirmed":false}`, "acknowledge_unconfirmed must be true"}, + {"1pw_create_access_request", "", "requires --params"}, + {"1pw_create_access_request", `{"browser_id":""}`, "browser_id"}, + {"1pw_create_access_request", `{"browser_id":"b","password":"x"}`, "only supported"}, + {"1pw_create_access_request", `{"type":"1pw_create_access_request","browser_id":"b"}`, "must not contain type"}, + {"1pw_access_request_status", `{"browser_id":"b","timeout_seconds":121}`, "timeout_seconds"}, + {"1pw_reconcile_access", `{"acknowledge_unconfirmed":true}`, "unsupported 1Password operation"}, {"1pw_fill", `{"browser_id":"b"}`, "page_url"}, {"1pw_fill", `{"browser_id":"b","page_url":"https://github.com/login","timeout_ms":0}`, "timeout_ms"}, {"1pw_recover", `{}`, "takes no parameters"}, @@ -248,9 +247,14 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { } assert.Contains(t, connect.Long, "credential_account") assert.Contains(t, credentials.Example, `"provider":"1password"`) - for _, operation := range []string{"1pw_request_access", "1pw_poll_access", "1pw_fill", "1pw_reconcile_access", "1pw_recover"} { + for _, operation := range []string{"1pw_create_access_request", "1pw_access_request_status", "1pw_fill", "1pw_recover"} { assert.Contains(t, invoke.Long, operation) } + for _, help := range []string{invoke.Long, invoke.Example, credentials.Long, connect.Long} { + for _, removed := range []string{"1pw_request_access", "1pw_poll_access", "reconcile", "integration key", "Family"} { + assert.NotContains(t, help, removed) + } + } } func TestOnePasswordFillLookupErrorKeepsStatus(t *testing.T) { diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index dcf0915f..ca29e6a8 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -184,10 +184,9 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPerformOperationParams, error) { allowed := map[string]string{ - "1pw_request_access": "browser_id goal reason keywords", - "1pw_poll_access": "browser_id timeout_seconds", - "1pw_reconcile_access": "acknowledge_unconfirmed", - "1pw_fill": "browser_id page_url timeout_ms", + "1pw_create_access_request": "browser_id goal reason keywords", + "1pw_access_request_status": "browser_id timeout_seconds", + "1pw_fill": "browser_id page_url timeout_ms", }[operation] if allowed == "" { return nil, fmt.Errorf("unsupported 1Password operation %q", operation) @@ -197,14 +196,12 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe return nil, err } var browserID string - if strings.Contains(allowed, "browser_id") { - if json.Unmarshal(object["browser_id"], &browserID) != nil || strings.TrimSpace(browserID) == "" { - return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") - } + if json.Unmarshal(object["browser_id"], &browserID) != nil || strings.TrimSpace(browserID) == "" { + return nil, fmt.Errorf("browser_id must be a non-empty browser session ID, not a name") } switch operation { - case "1pw_request_access": - request := kernel.OnePasswordRequestAccessVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordRequestAccessVaultItemOperationRequestType1pwRequestAccess} + case "1pw_create_access_request": + request := kernel.OnePasswordRequestAccessVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordRequestAccessVaultItemOperationRequestType1pwCreateAccessRequest} for _, name := range []string{"goal", "reason"} { if value, ok := object[name]; ok { var text string @@ -221,9 +218,9 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe if value, ok := object["keywords"]; ok && json.Unmarshal(value, &request.Keywords) != nil { return nil, fmt.Errorf("keywords must be an array of strings") } - return &kernel.VaultItemPerformOperationParams{Of1pwRequestAccess: &request}, nil - case "1pw_poll_access": - request := kernel.VaultItemPerformOperationParamsBody1pwPollAccess{BrowserID: browserID} + return &kernel.VaultItemPerformOperationParams{Of1pwCreateAccessRequest: &request}, nil + case "1pw_access_request_status": + request := kernel.VaultItemPerformOperationParamsBody1pwAccessRequestStatus{BrowserID: browserID} if value, ok := object["timeout_seconds"]; ok { var timeout *int64 if json.Unmarshal(value, &timeout) != nil || timeout == nil || *timeout < 0 || *timeout > 120 { @@ -231,13 +228,7 @@ func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPe } request.TimeoutSeconds = kernel.Opt(*timeout) } - return &kernel.VaultItemPerformOperationParams{Of1pwPollAccess: &request}, nil - case "1pw_reconcile_access": - var acknowledged bool - if json.Unmarshal(object["acknowledge_unconfirmed"], &acknowledged) != nil || !acknowledged { - return nil, fmt.Errorf("acknowledge_unconfirmed must be true; first check 1Password for an existing request") - } - return &kernel.VaultItemPerformOperationParams{Of1pwReconcileAccess: &kernel.VaultItemPerformOperationParamsBody1pwReconcileAccess{AcknowledgeUnconfirmed: true}}, nil + return &kernel.VaultItemPerformOperationParams{Of1pwAccessRequestStatus: &request}, nil default: request := kernel.OnePasswordFillVaultItemOperationRequestParam{BrowserID: browserID, Type: kernel.OnePasswordFillVaultItemOperationRequestType1pwFill} if json.Unmarshal(object["page_url"], &request.PageURL) != nil { diff --git a/go.mod b/go.mod index 827e5db7..4f72a3fa 100644 --- a/go.mod +++ b/go.mod @@ -65,4 +65,4 @@ require ( // TODO: Temporary private stlc preview of kernel/kernel#4256 (1Password credential // vaults). Before merge, delete this replace, bump github.com/kernel/kernel-go-sdk to // the official release that includes that API, and run go mod tidy. -replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925021738-7ed365222d7b +replace github.com/kernel/kernel-go-sdk => github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925200915-a98c8419227e diff --git a/go.sum b/go.sum index d3b49acc..d849e6bb 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925021738-7ed365222d7b h1:k1Cu/jC357sI5XLMU9T/vNxmVa8Il6Tc20zq2EygvFg= -github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925021738-7ed365222d7b/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925200915-a98c8419227e h1:TbL7iCaOaex7u4EqvyQwzqxFz8T2jve0MHUet4xcwb4= +github.com/kernel/kernel-go-sdk-staging v0.86.1-0.20260925200915-a98c8419227e/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=