Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <vault> <key> --version <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 <vault> <key> collect` reopens the full form without
Expand Down
21 changes: 20 additions & 1 deletion cmd/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
}
Expand Down
58 changes: 58 additions & 0 deletions cmd/org_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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{
Expand Down
25 changes: 24 additions & 1 deletion cmd/vaults_credentials.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -127,12 +128,34 @@ 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 {
return vaultCredentialError(err)
}
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] == '{'
}
6 changes: 3 additions & 3 deletions cmd/vaults_fill_credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"}]}`,
} {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/vaults_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
38 changes: 38 additions & 0 deletions cmd/vaults_public_values_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
48 changes: 48 additions & 0 deletions cmd/vaults_sdk_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
26 changes: 26 additions & 0 deletions cmd/vaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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.20260918232759-6e379e6df7b9
github.com/klauspost/compress v1.18.5
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/pterm/pterm v0.12.80
Expand Down
Loading
Loading