diff --git a/README.md b/README.md index 46483a8c..8044962b 100644 --- a/README.md +++ b/README.md @@ -298,8 +298,17 @@ populated, not that login succeeded. `fill` requires an already-open page and ne navigates or submits it. Optional `page_url` selects the exact page; cards require it. Do not automatically retry failed/unknown fills or fall back to aliases. +Create specs list `fields` as an ordered array. Each entry carries a stable `name` +(letters, digits, and underscores, starting with a letter) that keys values, updates, +and fills. Order is preserved: list fields in the same top-to-bottom order as the +website, because the collection form renders that order unchanged. An optional `label` +supplies non-secret display text for that field on the collection form; it never +affects value keys, updates, or fills. Use a single trimmed line of at most 128 UTF-8 +bytes, and it is returned as metadata in `get`/`list` output. + Use `credentials update --version --spec-file changes.json` -with a spec such as `{"fields":{"password":{"value":"replacement"}}}`. Keep actual +with a spec such as `{"fields":{"password":{"value":"replacement"}}}`; update specs key +`fields` by name rather than using the ordered array. Keep actual secrets in protected files or stdin, never shell arguments. Omission preserves values; null or an empty string clears supported fields, including required text/email/password fields (returning them to pending collection). The form still requires nonempty required inputs. Field definitions cannot change. Stale versions fail, without retries. `items invoke collect` reopens the full form without @@ -560,14 +569,21 @@ kernel vaults items get user-123 order-1 --wait 60 -o json this vault attached. `merchant_origin` is the canonical HTTPS origin of the top-level merchant document, not a processor iframe; HTTP localhost is allowed for tests. -Optional `psp` selects the tokenization processor: `square`, `braintree`, `worldpay`, -`bambora`, or `mercado_pago`. Omit it for Square; non-Square processors require +Optional `psp` selects the checkout processor: `square`, `braintree`, `worldpay`, +`bambora`, `mercado_pago`, or `adyen`. Omit it for Square; non-Square processors require multi-processor preparation enablement. `environment` is `production`, `sandbox`, or -`shared`: use `production` or `sandbox` for Square, Braintree and Worldpay, and `shared` -for Bambora and Mercado Pago. Shared endpoints do not establish test mode; merchant +`shared`: use `production` or `sandbox` for Square, Braintree, Worldpay and Adyen, and +`shared` for Bambora and Mercado Pago. Shared endpoints do not establish test mode; merchant credentials and configuration determine processor test mode, independently of the AgentCard credential mode. +`adyen` supports fresh-card Sessions requests on Adyen hosts only. Fill the public dummy +card fields rather than vault aliases, and keep the approval page open through device +handoff, including Adyen encryption. The unique armed preparation is associated with the +next eligible request from the declared browser and merchant origin; competing preparations +are rejected. Adyen device approval and browser `Authorised` responses are not capture or +fulfillment evidence. + Keep the approval page open. Poll until the item's status is `ready_to_submit`, then submit native Pay before `state.preparation.expires_at`. Readiness lasts at most 30 seconds, and polling does not extend it. The CLI displays the preparation ID, status, diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index be139f69..f1c17ed9 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -122,6 +122,7 @@ type AuthConnectionLoginInput struct { Region string Stealth BoolFlag RecordSession BoolFlag + SkillMode string Telemetry string TelemetryCdpExclude string TelemetryExport string @@ -794,6 +795,20 @@ func (c AuthConnectionCmd) Delete(ctx context.Context, in AuthConnectionDeleteIn return nil } +// parseSkillModeFlag validates the --skill-mode value against the modes the API +// accepts for a login, so a typo fails locally instead of starting a flow with +// the wrong skill behavior. +func parseSkillModeFlag(mode string) (kernel.AuthConnectionLoginParamsSkillMode, error) { + switch kernel.AuthConnectionLoginParamsSkillMode(mode) { + case kernel.AuthConnectionLoginParamsSkillModeEnabled: + return kernel.AuthConnectionLoginParamsSkillModeEnabled, nil + case kernel.AuthConnectionLoginParamsSkillModeDisabled: + return kernel.AuthConnectionLoginParamsSkillModeDisabled, nil + default: + return "", fmt.Errorf("invalid --skill-mode value: %s (must be one of enabled, disabled)", mode) + } +} + func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -825,6 +840,14 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } + if in.SkillMode != "" { + mode, err := parseSkillModeFlag(in.SkillMode) + if err != nil { + return err + } + params.SkillMode = mode + } + if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" { t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) if err != nil { @@ -1069,7 +1092,7 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli return nil } - tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} + tableData := pterm.TableData{{"Timestamp", "Completed", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} for _, e := range events { details := e.ErrorMessage if details == "" { @@ -1087,6 +1110,9 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli } tableData = append(tableData, []string{ util.FormatLocal(e.Timestamp), + // Absent (dashed out) for in-progress attempts, health checks, and + // older attempts recorded before completion times were persisted. + util.FormatLocal(e.CompletedAt), string(e.Type), string(e.Status), string(e.Step), @@ -1381,6 +1407,7 @@ func init() { authConnectionsLoginCmd.Flags().String("region", "", "Geographic region override for this login: 'us-east', 'eu-west', or 'ap-southeast'") authConnectionsLoginCmd.Flags().Bool("stealth", true, "Override stealth mode for this login's browser session; use --stealth=false to disable") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") + authConnectionsLoginCmd.Flags().String("skill-mode", "", "Whether this login reads and writes learned domain skills: 'enabled' (default) or 'disabled'. Automatic reauths inherit the selected mode until a later accepted login sets enabled or omits the flag") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") authConnectionsLoginCmd.Flags().String("telemetry-export-otlp", "", "Export override for this login only: an OTLP destination ID or name; --telemetry-export-otlp=off disables export for this login. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") authConnectionsLoginCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") @@ -1600,6 +1627,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") + skillMode, _ := cmd.Flags().GetString("skill-mode") telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") @@ -1614,6 +1642,7 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { Region: region, Stealth: readBoolFlag(cmd.Flags(), "stealth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + SkillMode: skillMode, Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, TelemetryExport: telemetryExport, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index f23888c3..339a4aa8 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "testing" + "time" "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" @@ -1223,7 +1224,8 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { "type": "login", "status": "SUCCESS", "browser_session_id": "browser_1", - "telemetry_captured": true + "telemetry_captured": true, + "completed_at": "2026-09-21T12:00:00Z" }`), &loginEvent)) fake := &FakeAuthConnectionService{ TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { @@ -1252,6 +1254,9 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { // Telemetry capture is reported for events that have a browser session. assert.Contains(t, out, "Telemetry") assert.Regexp(t, `browser_1.*yes`, out) + // completed_at is shown for terminal attempts and dashed out otherwise. + assert.Contains(t, out, "Completed") + assert.Contains(t, out, util.FormatLocal(time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC))) // The third event is truncated off the page. assert.NotContains(t, out, "health_check") assert.Contains(t, out, "Has more: yes") @@ -1390,3 +1395,43 @@ func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) assert.NotContains(t, outBuf.String(), "Browser Telemetry") } + +func TestLogin_SkillMode(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", SkillMode: "disabled"})) + assert.Equal(t, kernel.AuthConnectionLoginParamsSkillModeDisabled, captured.SkillMode) +} + +// Omitting --skill-mode leaves the field unset, so the API keeps its default of +// enabled rather than the CLI pinning a mode the user never asked for. +func TestLogin_SkillModeOmitted(t *testing.T) { + capturePtermOutput(t) + var captured kernel.AuthConnectionLoginParams + fake := &FakeAuthConnectionService{ + LoginFunc: func(ctx context.Context, id string, body kernel.AuthConnectionLoginParams, opts ...option.RequestOption) (*kernel.LoginResponse, error) { + captured = body + return &kernel.LoginResponse{ID: id}, nil + }, + } + c := AuthConnectionCmd{svc: fake} + require.NoError(t, c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1"})) + assert.Empty(t, string(captured.SkillMode)) +} + +func TestLogin_InvalidSkillModeErrors(t *testing.T) { + capturePtermOutput(t) + c := AuthConnectionCmd{svc: &FakeAuthConnectionService{}} + + err := c.Login(context.Background(), AuthConnectionLoginInput{ID: "auth_1", SkillMode: "mars"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --skill-mode value") +} diff --git a/cmd/org.go b/cmd/org.go index 2bce469d..92143ade 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -135,6 +135,16 @@ func renderOrgLimits(limits *kernel.OrgLimits) { {"Default Project Max Concurrent Sessions", formatProjectLimitValue(limits.DefaultProjectMaxConcurrentSessions, limits.JSON.DefaultProjectMaxConcurrentSessions)}, } + // Concurrency usage is measured live and only returned by newer API + // versions. Unlike the limit rows, a null here means usage could not be + // read rather than "unlimited", so render it as unknown. + if orgLimitFieldPresent(limits.JSON.ConcurrentSessionsUsed) { + rows = append(rows, []string{"Concurrent Sessions Used", formatOrgUsageValue(limits.ConcurrentSessionsUsed, limits.JSON.ConcurrentSessionsUsed)}) + } + if orgLimitFieldPresent(limits.JSON.ConcurrentSessionsAvailable) { + rows = append(rows, []string{"Concurrent Sessions Available", formatOrgUsageValue(limits.ConcurrentSessionsAvailable, limits.JSON.ConcurrentSessionsAvailable)}) + } + // Managed auth limits are plan-derived and only returned by newer API // versions, so render each row only when the field is present. A null // max_auth_connections means unlimited, so presence — not validity — is the @@ -167,6 +177,15 @@ func orgLimitFieldPresent(field respjson.Field) bool { return field.Raw() != respjson.Omitted } +// formatOrgUsageValue renders a live usage counter, where a null means the API +// could not read current usage rather than "unlimited". +func formatOrgUsageValue(value int64, field respjson.Field) string { + if !field.Valid() { + return "unknown" + } + return fmt.Sprintf("%d", value) +} + func renderOrgEntitlements(entitlements *kernel.OrgEntitlements) { if entitlements == nil { pterm.Info.Println("No organization entitlements found") @@ -266,7 +285,7 @@ var orgLimitsCmd = &cobra.Command{ var orgLimitsGetCmd = &cobra.Command{ Use: "get", Short: "Get organization limits", - Long: "Show the organization's effective limits: the concurrency limit, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", + Long: "Show the organization's effective limits: the concurrency limit, current organization-wide concurrent browser usage and remaining capacity, the default per-project cap applied to projects without an explicit override, and the plan-derived managed auth and vault limits along with current auth connection and vault usage.", Args: cobra.NoArgs, RunE: runOrgLimitsGet, } diff --git a/cmd/org_test.go b/cmd/org_test.go index 9118f619..33afdbbd 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -296,6 +297,63 @@ func TestOrgLimitsGet_NullDefaultShownAsUnlimited(t *testing.T) { assert.Contains(t, buf.String(), "unlimited") } +func TestOrgLimitsGet_RendersConcurrencyUsage(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{ + MaxConcurrentSessions: 100, + ConcurrentSessionsUsed: 12, + ConcurrentSessionsAvailable: 88, + } + limits.JSON.ConcurrentSessionsUsed = respjson.NewField("12") + limits.JSON.ConcurrentSessionsAvailable = respjson.NewField("88") + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Concurrent Sessions Used") + assert.Contains(t, out, "12") + assert.Contains(t, out, "Concurrent Sessions Available") + assert.Contains(t, out, "88") +} + +func TestOrgLimitsGet_NullConcurrencyUsageShownAsUnknown(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeOrgLimitsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { + limits := &kernel.OrgLimits{MaxConcurrentSessions: 100} + // Null (not omitted) means usage could not be read, which is not + // the same as unlimited. + limits.JSON.ConcurrentSessionsUsed = respjson.NewField(respjson.Null) + limits.JSON.ConcurrentSessionsAvailable = respjson.NewField(respjson.Null) + return limits, nil + }, + } + c := OrgCmd{limits: fake} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + // Both usage rows render as unknown rather than borrowing the "unlimited" + // meaning a null limit would have. + assert.Contains(t, out, "Concurrent Sessions Used") + assert.Contains(t, out, "Concurrent Sessions Available") + assert.Equal(t, 2, strings.Count(out, "unknown")) +} + +func TestOrgLimitsGet_OmitsConcurrencyUsageRowsWhenAbsent(t *testing.T) { + buf := capturePtermOutput(t) + c := OrgCmd{limits: &FakeOrgLimitsService{}} + assert.NoError(t, c.LimitsGet(context.Background(), OrgLimitsGetInput{})) + + out := buf.String() + assert.NotContains(t, out, "Concurrent Sessions Used") + assert.NotContains(t, out, "Concurrent Sessions Available") +} + func TestOrgLimitsGet_RendersManagedAuthLimits(t *testing.T) { buf := capturePtermOutput(t) fake := &FakeOrgLimitsService{ diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index d725e66c..95518a87 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -177,11 +177,13 @@ and corrective guidance; no fields were written by that request. Inspect and cor the cause before deciding on a new fill. Transport loss remains an uncertain outcome. prepare_checkout requires checkout.browser_id, checkout.merchant_origin (canonical HTTPS origin of the top-level merchant page, not a processor iframe), and checkout.environment -(production, sandbox, or shared). Optional checkout.psp selects the tokenization processor: -square, braintree, worldpay, bambora, or mercado_pago. Omit psp for Square; non-Square +(production, sandbox, or shared). Optional checkout.psp selects the checkout processor: +square, braintree, worldpay, bambora, mercado_pago, or adyen. Omit psp for Square; non-Square processors require multi-processor preparation enablement. Use production or sandbox for -square, braintree and worldpay; shared for bambora and mercado_pago. Shared endpoints do not -establish test mode; merchant credentials determine it. +square, braintree, worldpay and adyen; shared for bambora and mercado_pago. Shared endpoints do +not establish test mode; merchant credentials determine it. adyen prepares fresh-card Sessions +requests on Adyen hosts only: fill public dummy card fields, not vault aliases. Adyen device +approval and browser Authorised responses are not capture or fulfillment evidence. 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. diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 03b697c8..5d20dca9 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "context" "encoding/json" "fmt" @@ -63,7 +64,7 @@ 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 spec fields are an object keyed by field name, not the ordered array used on create.\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.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") @@ -127,8 +128,17 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ } else { var spec kernel.CredentialVaultItemSpecInputParam if json.Unmarshal(data, &spec) != nil || len(spec.Fields) == 0 { + if credentialSpecUsesKeyedFields(data) { + return fmt.Errorf("credential spec fields must be an ordered array of definitions carrying a name, not an object keyed by name") + } return fmt.Errorf("credential spec requires fields") } + // Names key values, updates, and fills; reject specs the form cannot address. + for _, field := range spec.Fields { + if strings.TrimSpace(field.Name) == "" { + return fmt.Errorf("every credential spec field requires a name") + } + } item, err = c.vaults.Items.Upsert(ctx, key, kernel.VaultItemUpsertParams{IDOrName: vault, OfCredential: &kernel.CredentialVaultItemRequestParam{Type: "credential", Spec: spec}}, option.WithMaxRetries(0)) } if err != nil { @@ -136,3 +146,16 @@ func (c VaultsCmd) saveCredential(ctx context.Context, vault, key string, data [ } return c.showItem(item, output, open) } + +// The create spec moved from fields keyed by name to an ordered array; point +// callers still sending the object form at the replacement shape. +func credentialSpecUsesKeyedFields(data []byte) bool { + var object struct { + Fields json.RawMessage `json:"fields"` + } + if json.Unmarshal(data, &object) != nil { + return false + } + fields := bytes.TrimSpace(object.Fields) + return len(fields) > 0 && fields[0] == '{' +} diff --git a/cmd/vaults_fill_credentials_test.go b/cmd/vaults_fill_credentials_test.go index c351e70c..29d7b7f6 100644 --- a/cmd/vaults_fill_credentials_test.go +++ b/cmd/vaults_fill_credentials_test.go @@ -19,7 +19,7 @@ func TestVaultFillBothItemTypesAndInputs(t *testing.T) { for _, input := range []string{"params", "spec-file"} { for _, test := range []struct{ name, item, params, result string }{ {"card", readyFillCardFixture, fillParamsFixture, completedFillFixture}, - {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, + {"credential", readyFillCredentialFixture, `{"browser_id":"browser-id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom_field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, completedFillFixture}, {"credential URL", readyFillCredentialFixture, `{"browser_id":"browser-id","page_url":"http://localhost/login","fields":[{"field":"expiration","selector":"#password"}]}`, `{"type":"fill","status":"completed","fields":[{"index":0,"status":"filled"}]}`}, } { t.Run(input+"/"+test.name, func(t *testing.T) { @@ -56,7 +56,7 @@ func TestVaultCredentialFillValidation(t *testing.T) { for _, params := range []string{ `{"browser_id":"id","fields":[{"field":"unknown","selector":"#field"}]}`, `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","format":"MM/YY"}]}`, - `{"browser_id":"id","fields":[{"field":"custom field","selector":"#field","format":"MM/YYYY"}]}`, + `{"browser_id":"id","fields":[{"field":"custom_field","selector":"#field","format":"MM/YYYY"}]}`, `{"browser_id":"id","fields":[{"field":"expiration","selector":"#field","value":"secret-sentinel"}]}`, `{"browser_id":"id","browser_id":"secret-sentinel","fields":[{"field":"expiration","selector":"#field"}]}`, } { @@ -106,7 +106,7 @@ func TestCredentialFillCLIOutcomes(t *testing.T) { io.WriteString(w, result) })) defer server.Close() - out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") + out, stderr, exit := runVaultFillCLI(t, server.URL, "fill", "--params", `{"browser_id":"id","fields":[{"field":"expiration","selector":"#password"},{"field":"custom_field","selector":"#custom"},{"field":"otp","selector":"#code"}]}`, "-o", "json") assert.True(t, json.Valid([]byte(out))) assert.JSONEq(t, result, out) assert.Empty(t, stderr) diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 8e3dc275..e9b38ab7 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -69,7 +69,7 @@ type AgentCardCardSpec = { merchant: string; // approval-screen name; 1..120 characters amount: number; // integer minor units; 1..9007199254740991 currency: string; // three letters - card_id?: string; // vc_...; otherwise chosen at approval + card_id?: string; // opaque AgentCard ID, pass through unchanged; else chosen at approval }; type LinkLineItem = { diff --git a/cmd/vaults_prepare_checkout.go b/cmd/vaults_prepare_checkout.go index b58ca4d8..74967c6e 100644 --- a/cmd/vaults_prepare_checkout.go +++ b/cmd/vaults_prepare_checkout.go @@ -10,9 +10,9 @@ import ( kernel "github.com/kernel/kernel-go-sdk" ) -// Environments and tokenization processors accepted by prepare_checkout. Square, -// Braintree and Worldpay use production or sandbox; Bambora and Mercado Pago use -// shared. Pairing is enforced by the API, which owns processor enablement. +// Environments and checkout processors accepted by prepare_checkout. Square, +// Braintree, Worldpay and Adyen use production or sandbox; Bambora and Mercado Pago +// use shared. Pairing is enforced by the API, which owns processor enablement. var vaultCheckoutEnvironments = []kernel.VaultCheckoutContextEnvironment{ kernel.VaultCheckoutContextEnvironmentProduction, kernel.VaultCheckoutContextEnvironmentSandbox, @@ -25,6 +25,7 @@ var vaultCheckoutProcessors = []kernel.AgentcardPreparedProcessor{ kernel.AgentcardPreparedProcessorWorldpay, kernel.AgentcardPreparedProcessorBambora, kernel.AgentcardPreparedProcessorMercadoPago, + kernel.AgentcardPreparedProcessorAdyen, } func vaultCheckoutProcessorNames() []string { diff --git a/cmd/vaults_prepare_checkout_test.go b/cmd/vaults_prepare_checkout_test.go index 5cb43950..cadecc72 100644 --- a/cmd/vaults_prepare_checkout_test.go +++ b/cmd/vaults_prepare_checkout_test.go @@ -108,7 +108,7 @@ func TestVaultPrepareCheckoutInvalidParams(t *testing.T) { } _, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, "https://shop.example", "http://localhost:3000", 1)) require.NoError(t, err) - for _, psp := range []string{"square", "braintree", "worldpay", "bambora", "mercado_pago"} { + for _, psp := range []string{"square", "braintree", "worldpay", "bambora", "mercado_pago", "adyen"} { params, err := parseVaultCheckoutParams(strings.Replace(checkoutParamsFixture, `"environment":`, `"psp":"`+psp+`","environment":`, 1)) require.NoError(t, err, psp) assert.Equal(t, psp, string(params.Psp)) diff --git a/cmd/vaults_public_values_test.go b/cmd/vaults_public_values_test.go index b88bc680..ca6b98c6 100644 --- a/cmd/vaults_public_values_test.go +++ b/cmd/vaults_public_values_test.go @@ -121,3 +121,41 @@ func TestVaultFillActionableErrors(t *testing.T) { }) } } + +// label is non-secret display metadata: it must reach the API unchanged on create +// and survive the display-safe output projection on every read. +func TestVaultCredentialLabelsRoundTrip(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Hacker News","fields":[{"name":"username","label":"Username or email","type":"text","required":true,"sensitive":false},{"name":"password","label":"Password","type":"password","required":true,"sensitive":true}]}` + fixture := fmt.Sprintf(`{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"pending_collection","fields":{"username":{"has_value":false},"password":{"has_value":false}}},"available_operations":[],"available_expansions":[]}`, spec) + sent := "" + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec struct { + Fields json.RawMessage `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + sent = string(body.Spec.Fields) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, fixture) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Contains(t, sent, `"label":"Username or email"`) + assert.Contains(t, sent, `"label":"Password"`) + assert.Contains(t, out, `"label": "Username or email"`) + assert.Contains(t, out, `"label": "Password"`) +} + +// A label is metadata only; it must never carry a value into the output. +func TestVaultCredentialLabelDoesNotExposeValues(t *testing.T) { + fixture := strings.Replace(publicCredentialFixture, + `{"name":"password","type":"password"}`, + `{"name":"password","label":"Password","type":"password"}`, 1) + require.NotEqual(t, publicCredentialFixture, fixture) + out, err := filterVaultJSON(json.RawMessage(fixture), vaultItemFields) + require.NoError(t, err) + assert.Contains(t, string(out), `"label":"Password"`) + assert.NotContains(t, string(out), "private-password") +} diff --git a/cmd/vaults_sdk_contract_test.go b/cmd/vaults_sdk_contract_test.go index 8fa3a16c..802591fb 100644 --- a/cmd/vaults_sdk_contract_test.go +++ b/cmd/vaults_sdk_contract_test.go @@ -124,3 +124,51 @@ func TestVaultPreparationEventsAreProjected(t *testing.T) { assert.Contains(t, out, `"preparation_id": "prep-1"`) assert.NotContains(t, out, "never-print") } + +func TestCredentialFieldOrderIsPreserved(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + spec := `{"description":"Example","fields":[{"name":"email","type":"email","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true},{"name":"otp","type":"totp","required":false,"sensitive":true}]}` + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body struct { + Spec struct { + Fields json.RawMessage `json:"fields"` + } `json:"spec"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + // The website's top-to-bottom order must reach the API unchanged. + assert.Equal(t, `[{"name":"email","type":"email","required":true,"sensitive":false},{"name":"password","type":"password","required":true,"sensitive":true},{"name":"otp","type":"totp","required":false,"sensitive":true}]`, string(body.Spec.Fields)) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"credential-1","key":"login","type":"credential","version":1,"spec":%s,"state":{"status":"pending_collection","fields":{"email":{"has_value":false},"password":{"has_value":false},"otp":{"has_value":false}}},"available_operations":[],"available_expansions":[]}`, spec) + }) + out, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", "--spec-file", credentialSpecFile(t, spec), "-o", "json") + require.NoError(t, err) + assert.Less(t, strings.Index(out, `"email"`), strings.Index(out, `"password"`)) + assert.Less(t, strings.Index(out, `"password"`), strings.Index(out, `"otp"`)) + for _, name := range []string{"email", "password", "otp"} { + assert.Contains(t, out, fmt.Sprintf(`"name": %q`, name)) + } +} + +func TestCredentialKeyedFieldsAreRejectedWithGuidance(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("a keyed create spec must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", + "--spec-file", credentialSpecFile(t, `{"fields":{"password":{"type":"password","value":"secret-echo"}}}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "ordered array") + assert.NotContains(t, err.Error(), "secret-echo") +} + +func TestCredentialFieldsRequireNames(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("an unnamed field must not reach the API") + }) + _, _, err := executeVaultCommand(t, client, "vaults", "credentials", "create", "user-123", "login", + "--spec-file", credentialSpecFile(t, `{"fields":[{"type":"password","value":"secret-echo"}]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "name") + assert.NotContains(t, err.Error(), "secret-echo") +} diff --git a/cmd/vaults_test.go b/cmd/vaults_test.go index 14942156..5ec933b3 100644 --- a/cmd/vaults_test.go +++ b/cmd/vaults_test.go @@ -303,6 +303,32 @@ func TestVaultCardRequestMapping(t *testing.T) { } } +func TestVaultCardAgentcardCardIDIsOpaque(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "project-test") + // AgentCard card IDs are opaque: the CLI must forward whatever the caller + // supplies without assuming a prefix or format. + for _, cardID := range []string{"vc_chosen", "chosen", "card-123", "AGC/9f2e::7"} { + t.Run(cardID, func(t *testing.T) { + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + var spec struct { + CardID string `json:"card_id"` + } + require.NoError(t, json.Unmarshal(body["spec"], &spec)) + assert.Equal(t, cardID, spec.CardID) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, requestedCardFixture) + }) + spec := fmt.Sprintf(`{"wallet":"wallet-1","amount":1234,"currency":"usd","merchant":"Example Shop","card_id":%q}`, cardID) + _, _, err := executeVaultCommand(t, client, + "vaults", "cards", "create", "checkout", "order-1", "-o", "json", + "--provider", "agentcard", "--spec", spec) + require.NoError(t, err) + }) + } +} + func TestVaultInvokeRequiresAdvertisedOperation(t *testing.T) { t.Setenv("KERNEL_PROJECT", "project-test") for _, state := range []string{"requested", "pending_authorization", "ready", "consumed", "expired", "declined"} { diff --git a/go.mod b/go.mod index dcae6b62..c0793f27 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.110.0 + github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 1f47cec9..cf08de2e 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,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.110.0 h1:2KkE0hAlJav5xg2818Eg+mIK2p1F2nDZ0rZdA2EO1QQ= -github.com/kernel/kernel-go-sdk v0.110.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b h1:qb0fJhkc/ms44bzP9AMShSYvqIGdlC0CAMUaOY00y68= +github.com/kernel/kernel-go-sdk v0.110.1-0.20260921195148-aee0b02b9b0b/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=