Skip to content

Commit f5ac96c

Browse files
authored
Merge branch 'main' into hypeship/llm-credentials-action
2 parents 2df24ff + 71f20e7 commit f5ac96c

10 files changed

Lines changed: 135 additions & 21 deletions

‎cmd/api_keys.go‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,9 @@ func (c APIKeysCmd) Update(ctx context.Context, in APIKeysUpdateInput) error {
308308
return nil
309309
}
310310

311-
// Rotate issues a replacement API key. The rotated key keeps working until its
312-
// grace period elapses, so this is disruptive but not immediately breaking --
313-
// it still prompts, since the old key does eventually stop working.
311+
// Rotate issues a replacement API key. Grace-period and natural expiry do not
312+
// end browser sessions. An immediate rotation does, so all rotations prompt
313+
// because the old key stops working and the immediate form is also disruptive.
314314
func (c APIKeysCmd) Rotate(ctx context.Context, in APIKeysRotateInput) error {
315315
if err := validateJSONOutput(in.Output); err != nil {
316316
return err
@@ -331,9 +331,13 @@ func (c APIKeysCmd) Rotate(ctx context.Context, in APIKeysRotateInput) error {
331331
}
332332

333333
if !in.SkipConfirm {
334+
warning := fmt.Sprintf("Are you sure you want to rotate API key '%s'? The current key stops working after its grace period.", in.ID)
335+
if in.ExpireInDays.Set && in.ExpireInDays.Value == 0 {
336+
warning = fmt.Sprintf("Are you sure you want to rotate API key '%s' immediately? Browser sessions attributed to the current key will end asynchronously and unsaved profile changes will be discarded.", in.ID)
337+
}
334338
ok, err := c.prompter.Confirm(
335339
fmt.Sprintf("rotate API key '%s'", in.ID),
336-
fmt.Sprintf("Are you sure you want to rotate API key '%s'? The current key stops working after its grace period.", in.ID),
340+
warning,
337341
)
338342
if err != nil {
339343
return err
@@ -362,7 +366,7 @@ func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error {
362366
if !in.SkipConfirm {
363367
ok, err := c.prompter.Confirm(
364368
fmt.Sprintf("delete API key '%s'", in.ID),
365-
fmt.Sprintf("Are you sure you want to delete API key '%s'?", in.ID),
369+
fmt.Sprintf("Are you sure you want to delete API key '%s'? Browser sessions attributed to it will end asynchronously and unsaved profile changes will be discarded.", in.ID),
366370
)
367371
if err != nil {
368372
return err
@@ -584,14 +588,15 @@ var apiKeysUpdateCmd = &cobra.Command{
584588
var apiKeysRotateCmd = &cobra.Command{
585589
Use: "rotate <id>",
586590
Short: "Rotate an API key",
587-
Long: "Issue a replacement API key. The rotated key keeps working for a grace period (7 days by default) so callers can migrate; use --expire-in-days 0 to revoke it immediately.",
591+
Long: "Issue a replacement API key. The rotated key keeps working for a grace period (7 days by default) so callers can migrate. Use --expire-in-days 0 to expire it immediately; browser sessions attributed to the old key then end asynchronously and unsaved profile changes are discarded. Grace-period and natural expiry do not end existing browser sessions. If immediate cleanup cannot be scheduled, the rotation is not completed.",
588592
Args: cobra.ExactArgs(1),
589593
RunE: runAPIKeysRotate,
590594
}
591595

592596
var apiKeysDeleteCmd = &cobra.Command{
593597
Use: "delete <id>",
594598
Short: "Delete an API key",
599+
Long: "Delete an API key and asynchronously end browser sessions attributed to it, discarding unsaved profile changes. A key cannot delete itself; use a different key. If browser cleanup cannot be scheduled, the key is not deleted.",
595600
Args: cobra.ExactArgs(1),
596601
RunE: runAPIKeysDelete,
597602
}
@@ -625,7 +630,7 @@ func init() {
625630

626631
addJSONOutputFlag(apiKeysRotateCmd)
627632
apiKeysRotateCmd.Flags().Int64("days-to-expire", 0, "Lifetime in days for the new key (1-3650); omit to reuse the rotated key's lifetime")
628-
apiKeysRotateCmd.Flags().Int64("expire-in-days", 0, "Grace period in days before the rotated key expires; 0 expires it immediately, omit for the default 7 days")
633+
apiKeysRotateCmd.Flags().Int64("expire-in-days", 0, "Grace period in days before the rotated key expires; 0 expires it immediately and asynchronously ends its browser sessions, omit for the default 7 days")
629634
apiKeysRotateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
630635

631636
apiKeysDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

‎cmd/api_keys_test.go‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ import (
1616
"github.com/stretchr/testify/require"
1717
)
1818

19+
func TestAPIKeyCleanupHelpMatchesSessionBehavior(t *testing.T) {
20+
for _, text := range []string{
21+
apiKeysDeleteCmd.Long,
22+
apiKeysRotateCmd.Long,
23+
apiKeysRotateCmd.Flags().Lookup("expire-in-days").Usage,
24+
} {
25+
assert.Contains(t, text, "browser sessions")
26+
}
27+
assert.Contains(t, apiKeysDeleteCmd.Long, "unsaved profile changes")
28+
assert.Contains(t, apiKeysDeleteCmd.Long, "key is not deleted")
29+
assert.Contains(t, apiKeysRotateCmd.Long, "Grace-period and natural expiry do not end")
30+
assert.Contains(t, apiKeysRotateCmd.Long, "rotation is not completed")
31+
}
32+
1933
type FakeAPIKeysService struct {
2034
NewFunc func(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error)
2135
GetFunc func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error)
@@ -379,8 +393,9 @@ func TestAPIKeysRotateBuildsParamsAndPrintsNewKey(t *testing.T) {
379393
assert.Equal(t, "key_123", capturedID)
380394
assert.True(t, captured.DaysToExpire.Valid())
381395
assert.Equal(t, int64(30), captured.DaysToExpire.Value)
382-
// 0 must reach the API as an explicit value, not be dropped: it means
383-
// "revoke the rotated key now" rather than "use the default grace period".
396+
// 0 must reach the API as an explicit value, not be dropped: it expires
397+
// the rotated key and asynchronously ends its browser sessions rather than
398+
// using the default grace period.
384399
assert.True(t, captured.ExpireInDays.Valid())
385400
assert.Equal(t, int64(0), captured.ExpireInDays.Value)
386401

‎cmd/proxies/proxy_ips.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func init() {
4848
ProxyIPsCmd.AddCommand(proxyIPsListCmd, proxyIPsGetCmd)
4949

5050
proxyIPsListCmd.Flags().Int("page", 1, "Page number (1-based)")
51-
proxyIPsListCmd.Flags().Int("per-page", 20, "Items per page")
51+
proxyIPsListCmd.Flags().Int("per-page", 20, "Items per page (1-999)")
5252
proxyIPsListCmd.Flags().String("country", "", "Filter by an ISP-supported ISO 3166 country code")
5353
proxyIPsListCmd.Flags().String("status", "", "Filter by assignability (active|excluded)")
5454
addJSONOutputFlag(proxyIPsListCmd)
@@ -73,6 +73,11 @@ func (p ProxyIPCmd) List(ctx context.Context, in ProxyIPListInput) error {
7373
if perPage <= 0 {
7474
perPage = 20
7575
}
76+
// The API accepts at most 1000 items. Reserve one item for the pagination
77+
// lookahead used to determine whether another page exists.
78+
if perPage > 999 {
79+
return fmt.Errorf("--per-page must be between 1 and 999")
80+
}
7681

7782
params := kernel.ProxyIPListParams{
7883
Limit: kernel.Opt(int64(perPage + 1)),

‎cmd/proxies/proxy_ips_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,37 @@ func TestProxyIPListUsesPagePaginationAndPreservesFilters(t *testing.T) {
6767
assert.Contains(t, out, `Next: kernel proxy-ips list --page 3 --per-page 2 --country "US" --status "active"`)
6868
}
6969

70+
func TestProxyIPListUsesMaximumPageSizeWithLookahead(t *testing.T) {
71+
var captured kernel.ProxyIPListParams
72+
fake := &fakeProxyIPService{
73+
listFunc: func(_ context.Context, params kernel.ProxyIPListParams, _ ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyIP], error) {
74+
captured = params
75+
return &pagination.OffsetPagination[kernel.ProxyIP]{}, nil
76+
},
77+
}
78+
79+
err := ProxyIPCmd{proxyIPs: fake}.List(context.Background(), ProxyIPListInput{Page: 2, PerPage: 999, Output: "json"})
80+
81+
require.NoError(t, err)
82+
assert.Equal(t, int64(1000), captured.Limit.Value)
83+
assert.Equal(t, int64(999), captured.Offset.Value)
84+
}
85+
86+
func TestProxyIPListRejectsPageSizeAboveLookaheadLimit(t *testing.T) {
87+
called := false
88+
fake := &fakeProxyIPService{
89+
listFunc: func(_ context.Context, _ kernel.ProxyIPListParams, _ ...option.RequestOption) (*pagination.OffsetPagination[kernel.ProxyIP], error) {
90+
called = true
91+
return nil, nil
92+
},
93+
}
94+
95+
err := ProxyIPCmd{proxyIPs: fake}.List(context.Background(), ProxyIPListInput{PerPage: 1000})
96+
97+
assert.EqualError(t, err, "--per-page must be between 1 and 999")
98+
assert.False(t, called)
99+
}
100+
70101
func TestProxyIPListPrintsFooterWhenEmpty(t *testing.T) {
71102
buf := captureOutput(t)
72103

@@ -126,6 +157,7 @@ func TestProxyIPCommandsExposePageBasedFlags(t *testing.T) {
126157
assert.NotNil(t, ProxyIPsCmd.Commands())
127158
assert.NotNil(t, proxyIPsListCmd.Flags().Lookup("page"))
128159
assert.NotNil(t, proxyIPsListCmd.Flags().Lookup("per-page"))
160+
assert.Contains(t, proxyIPsListCmd.Flags().Lookup("per-page").Usage, "1-999")
129161
assert.Nil(t, proxyIPsListCmd.Flags().Lookup("limit"))
130162
assert.Nil(t, proxyIPsListCmd.Flags().Lookup("offset"))
131163
assert.NotNil(t, proxyIPsListCmd.Flags().Lookup("country"))

‎cmd/vaults_link.go‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,16 @@ const linkCardFlowHelp = `Link card checkout and payment_request checkout (each
2424
3. once the user has approved this purchase, authorize with items invoke <vault> <key>
2525
authorize --browser-id <browser-session-id> --page-url <exact current top-level checkout
2626
url>. KERNEL inspects the checkout, chooses how the credential is delivered (a Link Pay
27-
Token or a virtual card), limits fill to that checkout's origin, and sends the user an
28-
approval request. do not inspect or invoke WebMCP yourself.
27+
Token or a virtual card), records card or link_pay_token in state.credential_type after
28+
authorize, limits fill to that checkout's origin, and sends the user an approval request.
29+
callers do not choose the delivery type; do not inspect or invoke WebMCP yourself.
2930
4. share the approval url from the item's action with the user, then use items get --wait 60
3031
until fill is advertised. to re-check sooner, invoke authorize again; checkout context is
3132
not needed, and re-checking never creates another payment request.
32-
5. fill as the item's fill description says, in any browser with this vault attached: a Link
33-
Pay Token takes browser_id and page_url and omits fields; a virtual card also takes
34-
field/selector bindings.
33+
5. fill as the item's fill description says, in any browser with this vault attached:
34+
link_pay_token takes browser_id and page_url and omits fields; card also takes
35+
field/selector bindings. state.credential_type is absent before authorize and after Link
36+
rejects the request; a ready item without it fills like card.
3537
6. submit the checkout yourself; fill never submits payment. failed or unknown fills are safe
3638
to retry, but only after checking the page and not in an automatic loop.
3739
if authorize returns page_not_found, ambiguous_page, browser_not_found, browser_unavailable,

‎cmd/vaults_link_test.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ func TestLinkCardFlowHelpOrder(t *testing.T) {
4242
}
4343
for _, want := range []string{
4444
"a Link Pay Token or a virtual card",
45-
"a Link Pay Token takes browser_id and page_url and omits fields",
45+
"records card or link_pay_token in state.credential_type after authorize",
46+
"callers do not choose the delivery type",
47+
"link_pay_token takes browser_id and page_url and omits fields",
48+
"state.credential_type is absent before authorize and after Link rejects the request",
49+
"a ready item without it fills like card",
4650
"page_not_found, ambiguous_page, browser_not_found, browser_unavailable, destination_denied, timeout, or browser_error",
4751
"make sure the checkout is open in exactly one tab at page_url, then authorize again",
4852
"authorize without --browser-id and --page-url to issue a virtual card",

‎cmd/vaults_output.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ var vaultItemFields = vaultOutputFields{
8181
},
8282
},
8383
"state": {
84-
"provider": nil, "status": nil, "status_reason": nil, "user_id": nil, "domains": nil, "access_request_id": nil,
84+
"provider": nil, "status": nil, "status_reason": nil, "credential_type": nil, "user_id": nil, "domains": nil, "access_request_id": nil,
8585
"access_request": {
8686
"id": nil, "state": nil, "goal": nil, "createdAt": nil, "has_autofill_token": nil, "granted_count": nil,
8787
"request": onePasswordRequestFields, "entries": onePasswordRequestEntryFields,
@@ -465,6 +465,9 @@ func printVaultItem(item *kernel.VaultItemUnion, output string) error {
465465
if item.State.StatusReason != "" {
466466
rows = append(rows, []string{"status reason", item.State.StatusReason})
467467
}
468+
if item.State.JSON.CredentialType.Valid() && item.State.CredentialType != "" {
469+
rows = append(rows, []string{"credential type", item.State.CredentialType})
470+
}
468471
if item.Type == "card" || item.Type == "payment_request" {
469472
merchant := item.Spec.MerchantName
470473
if item.Spec.Provider == "agentcard" {
@@ -662,6 +665,14 @@ func printVaultItemGuidance(item *kernel.VaultItemUnion, actions vaultItemAction
662665
}
663666
}
664667
case "link":
668+
switch {
669+
case item.State.JSON.CredentialType.Valid() && item.State.CredentialType == "link_pay_token":
670+
pterm.Info.Println("credential type link_pay_token: invoke fill without field bindings; KERNEL supplies the Link Pay Token through the checkout's Link WebMCP tool.")
671+
case item.State.JSON.CredentialType.Valid() && item.State.CredentialType == "card":
672+
pterm.Info.Println("credential type card: invoke fill with the field bindings advertised by the item.")
673+
case item.State.Status == "ready":
674+
pterm.Info.Println("no credential type was returned; a ready Link item without state.credential_type fills like card, so use the advertised card field bindings.")
675+
}
665676
pterm.Info.Println("fill never submits payment; submit the checkout yourself. if the result of submitting payment is unclear, check the merchant's order confirmation or order history before paying again. if authorization is declined or expires, delete this purchase item and create a new one.")
666677
if item.Spec.JSON.Recurring.Valid() {
667678
pterm.Info.Println("this recurring Link purchase remains ready for merchant-scheduled charges until it expires or is deleted; do not create a new card each period, or a new payment_request for the same recurring purchase. deleting the item, wallet, or vault revokes the Link credential but does not cancel the merchant subscription. if deletion returns 409 provider_revocation_pending, retry deletion.")

‎cmd/vaults_output_test.go‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const readyAgentCardFixture = `{
2525
const readyLinkCardFixture = `{
2626
"id":"card-id","key":"order-1","type":"card",
2727
"spec":{"provider":"link","wallet":"wallet-1","payment_method_id":"pm-1","amount":1234,"currency":"usd","merchant_name":"Example Shop","merchant_url":"https://shop.example","provider_secret":"SECRET_SPEC"},
28-
"state":{"provider":"link","status":"ready","domains":["shop.example"],"masks":{"brand":"visa","last4":"1234"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"},
28+
"state":{"provider":"link","status":"ready","credential_type":"card","domains":["shop.example"],"masks":{"brand":"visa","last4":"1234"},"card_number":"SECRET_CARD","secret_enc":"SECRET_CIPHERTEXT"},
2929
"available_operations":[{"type":"fill","description":"Fill checkout fields."}],"available_expansions":[],"oauth_tokens":"SECRET_OAUTH"
3030
}`
3131

@@ -84,6 +84,8 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) {
8484
operation := "authorize"
8585
if item.State.Status == "ready" {
8686
operation = "fill"
87+
assert.Contains(t, buf.String(), "credential type")
88+
assert.Contains(t, buf.String(), "credential type card")
8789
assert.Contains(t, buf.String(), "permitted domains (provider-assigned)")
8890
assert.Contains(t, buf.String(), "shop.example")
8991
}
@@ -97,13 +99,51 @@ func TestVaultOutputLinkHasNoAliases(t *testing.T) {
9799
require.NoError(t, json.Unmarshal([]byte(out), &decoded))
98100
assert.JSONEq(t, fmt.Sprintf("%q", item.State.Status), string(decoded.State["status"]))
99101
if item.State.Status == "ready" {
102+
assert.JSONEq(t, `"card"`, string(decoded.State["credential_type"]))
100103
assert.JSONEq(t, `["shop.example"]`, string(decoded.State["domains"]))
101104
assert.JSONEq(t, `{"brand":"visa","last4":"1234"}`, string(decoded.State["masks"]))
102105
}
103106
})
104107
}
105108
}
106109

110+
func TestVaultOutputLinkCredentialTypeVariants(t *testing.T) {
111+
for _, tc := range []struct {
112+
name string
113+
fixture string
114+
want []string
115+
absent bool
116+
}{
117+
{
118+
name: "link pay token",
119+
fixture: strings.Replace(readyLinkCardFixture, `"credential_type":"card"`, `"credential_type":"link_pay_token"`, 1),
120+
want: []string{"credential type", "link_pay_token", "without field bindings", "Link WebMCP tool"},
121+
},
122+
{
123+
name: "ready legacy response",
124+
fixture: strings.Replace(readyLinkCardFixture, `,"credential_type":"card"`, "", 1),
125+
want: []string{"no credential type was returned", "fills like card", "card field bindings"},
126+
absent: true,
127+
},
128+
} {
129+
t.Run(tc.name, func(t *testing.T) {
130+
var item kernel.VaultItemUnion
131+
require.NoError(t, json.Unmarshal([]byte(tc.fixture), &item))
132+
buf := capturePtermOutput(t)
133+
require.NoError(t, printVaultItem(&item, ""))
134+
for _, want := range tc.want {
135+
assert.Contains(t, buf.String(), want)
136+
}
137+
out := captureStdout(t, func() { require.NoError(t, printVaultItem(&item, "json")) })
138+
if tc.absent {
139+
assert.NotContains(t, out, `"credential_type"`)
140+
} else {
141+
assert.Contains(t, out, `"credential_type": "link_pay_token"`)
142+
}
143+
})
144+
}
145+
}
146+
107147
func TestVaultOutputKernelCardMerchantCountry(t *testing.T) {
108148
var item kernel.VaultItemUnion
109149
require.NoError(t, json.Unmarshal([]byte(`{

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ require (
99
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
1010
github.com/golang-jwt/jwt/v5 v5.2.2
1111
github.com/joho/godotenv v1.5.1
12-
github.com/kernel/kernel-go-sdk v0.144.0
12+
github.com/kernel/kernel-go-sdk v0.145.0
1313
github.com/klauspost/compress v1.18.5
1414
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1515
github.com/pterm/pterm v0.12.80

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
6868
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
6969
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
7070
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
71-
github.com/kernel/kernel-go-sdk v0.144.0 h1:KxburLFDs4Dlm/tvX8xFLPZz7DJRAQpy6WUrFRGnDO0=
72-
github.com/kernel/kernel-go-sdk v0.144.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U=
71+
github.com/kernel/kernel-go-sdk v0.145.0 h1:IJDd9I1nxpSMWlnVDqqJuieLhVJAz8dK0rYSrHFn1WE=
72+
github.com/kernel/kernel-go-sdk v0.145.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U=
7373
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
7474
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
7575
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=

0 commit comments

Comments
 (0)