Skip to content
Merged
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
71 changes: 68 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,13 @@ kernel deploy index.ts -o json
Commands with JSON output support:
- **Browsers**: `create`, `list`, `get`, `view`
- **Browser Pools**: `create`, `list`, `get`, `update`, `acquire`
- **Profiles**: `create`, `list`, `get`
- **Profiles**: `create`, `list`, `get`, `update`
- **Extensions**: `upload`, `list`
- **Proxies**: `create`, `list`, `get`
- **API Keys**: `create`, `list`, `get`, `update`
- **Proxies**: `create`, `list`, `get`, `update`, `check`
- **API Keys**: `create`, `list`, `get`, `update`, `rotate`
- **Auth Connections**: `timeline`
- **Projects**: `update`
- **Org**: `limits get/set`
- **Apps**: `list`, `history`
- **Deploy**: `deploy` (JSONL streaming), `history`
- **Invoke**: `invoke` (JSONL streaming), `history`
Expand Down Expand Up @@ -237,6 +240,7 @@ Commands with JSON output support:
- `--telemetry=all` - Enable telemetry for all categories
- `--telemetry=off` - Disable telemetry
- `--telemetry=<list>` - Per-category config, e.g. `--telemetry=network=on,page=off`
- `--disable-default-proxy` - Disable the default stealth proxy so the browser connects directly; use `--disable-default-proxy=false` to re-enable it
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers curl <id> <url>` - Make HTTP requests through a browser session's Chrome network stack
- `-X, --request <method>` - HTTP method (default: GET; defaults to POST when `--data` is set)
Expand Down Expand Up @@ -341,6 +345,7 @@ Per-category updates are partial — only categories you name are changed; other
- `--timeout <seconds>` - Timeout in seconds
- `--as-user <user>` - Run as user
- `--as-root` - Run as root
- `--env <KEY=VALUE>` - Environment variable to set for the process (repeatable)
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers process spawn <id> [--] [command...]` - Execute a command asynchronously
- `--command <cmd>` - Command to execute (optional; if omitted, trailing args are executed via /bin/bash -c)
Expand All @@ -349,6 +354,10 @@ Per-category updates are partial — only categories you name are changed; other
- `--timeout <seconds>` - Timeout in seconds
- `--as-user <user>` - Run as user
- `--as-root` - Run as root
- `--env <KEY=VALUE>` - Environment variable to set for the process (repeatable)
- `--allocate-tty` - Allocate a pseudo-terminal (PTY) for interactive shells
- `--cols <n>` - Initial terminal columns (requires `--allocate-tty`)
- `--rows <n>` - Initial terminal rows (requires `--allocate-tty`)
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers process kill <id> <process-id>` - Send a signal to a process
- `--signal <signal>` - Signal to send: TERM, KILL, INT, HUP (default: TERM)
Expand Down Expand Up @@ -452,6 +461,22 @@ Per-category updates are partial — only categories you name are changed; other
- `--timeout <seconds>` - Maximum execution time in seconds (defaults server-side)
- If `[code]` is omitted, code is read from stdin

### Profiles

- `kernel profiles update <id-or-name> --name <new-name>` - Rename a profile
- `--name <name>` - New unique profile name (required)
- `--output json`, `-o json` - Output raw JSON object
- `kernel profiles download <id-or-name> --to <dir>` - Download a profile archive
- `--to <dir>` - Directory to extract the profile into (required)
- `--format <format>` - Archive format to request: `tar.zst` (compressed, default) or `tar` (decompressed server-side)

### Projects

- `kernel projects update <id-or-name>` - Update a project's name or status
- `--name <name>` - New project name (1-255 characters)
- `--status <status>` - New project status: `active` or `archived`
- `--output json`, `-o json` - Output raw JSON object

### Extension Management

- `kernel extensions list` - List all uploaded extensions
Expand Down Expand Up @@ -492,9 +517,34 @@ Per-category updates are partial — only categories you name are changed; other
- `--username <username>` - Username for proxy authentication (custom)
- `--password <password>` - Password for proxy authentication (custom)

- `kernel proxies update <id>` - Rename a proxy configuration (recreate the proxy to change its type or config)
- `--name <name>` - New proxy name (required)
- `--output json`, `-o json` - Output raw JSON object
- `kernel proxies check <id>` - Run a health check on a proxy to verify it's working and update its status
- `--url <url>` - Optional public HTTP or HTTPS URL to test reachability against
- `--output json`, `-o json` - Output raw JSON object
- `kernel proxies delete <id>` - Delete a proxy configuration
- `-y, --yes` - Skip confirmation prompt

### Auth Connections

Managed auth connections (`kernel auth connections`). The commands below are new or gained new flags; run `kernel auth connections --help` for the full command list.

- `kernel auth connections timeline <id>` - List the connection's login, reauth, and health-check events, most recent first
- `--type <type>` - Filter to a single event type: `login`, `reauth`, or `health_check`
- `--page <n>` - Page number (1-based, default: 1)
- `--per-page <n>` - Items per page (default: 20)
- `--output json`, `-o json` - Output raw JSON array
- `kernel auth connections create` - New flag:
- `--telemetry=all` / `--telemetry=off` / `--telemetry=<categories>` - Default telemetry for this connection's browser sessions. Same semantics as `kernel browsers create`
- `kernel auth connections update <id>` - New flag:
- `--telemetry=all` / `--telemetry=off` / `--telemetry=<categories>` - Update telemetry for future browser sessions
- `kernel auth connections login <id>` - New flag:
- `--telemetry=all` / `--telemetry=off` / `--telemetry=<categories>` - Telemetry override for this login only, merged onto the connection's config
- `kernel auth connections submit <id>` - New flags:
- `--field-value <id=value>` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field`
- `--choice-id <id>` - Canonical choice ID from the connection's `choices` list

### Agent Auth

Automated authentication for web services. The `run` command orchestrates the full auth flow automatically.
Expand Down Expand Up @@ -579,15 +629,30 @@ Automated authentication for web services. The `run` command orchestrates the fu
- `--output json`, `-o json` - Output raw JSON array

- `kernel api-keys get <id>` - Get an API key
- `--include-deleted` - Include soft-deleted API keys in the lookup
- `--output json`, `-o json` - Output raw JSON object

- `kernel api-keys update <id>` - Update an API key
- `--name <name>` - New API key name
- `--output json`, `-o json` - Output raw JSON object

- `kernel api-keys rotate <id>` - Issue a replacement API key; the rotated key keeps working for a grace period (7 days by default) so callers can migrate
- `--days-to-expire <days>` - Lifetime in days for the new key (1-3650); omit to reuse the rotated key's lifetime
- `--expire-in-days <days>` - Grace period in days before the rotated key expires; `0` expires it immediately, omit for the default 7 days
- `-y, --yes` - Skip confirmation prompt
- `--output json`, `-o json` - Output raw JSON object, including the one-time plaintext key

- `kernel api-keys delete <id>` - Delete an API key
- `-y, --yes` - Skip confirmation prompt

### Org

- `kernel org limits get` - Show the organization's concurrency limit and the default per-project cap applied to projects without an explicit override
- `--output json`, `-o json` - Output raw JSON object
- `kernel org limits set` - Set the default per-project concurrency cap applied to projects without an explicit override
- `--default-project-max-concurrent-sessions <n>` - Default maximum concurrent browsers for projects without an explicit override (`0` to remove the default)
- `--output json`, `-o json` - Output raw JSON object

## Examples

### Create a new app
Expand Down
104 changes: 100 additions & 4 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type APIKeysService interface {
Get(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error)
Update(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error)
List(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error)
Rotate(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error)
Delete(ctx context.Context, id string, opts ...option.RequestOption) error
}

Expand All @@ -40,8 +41,17 @@ type APIKeysListInput struct {
}

type APIKeysGetInput struct {
ID string
Output string
ID string
IncludeDeleted bool
Output string
}

type APIKeysRotateInput struct {
ID string
DaysToExpire Int64Flag
ExpireInDays Int64Flag
SkipConfirm bool
Output string
}

type APIKeysUpdateInput struct {
Expand Down Expand Up @@ -147,7 +157,12 @@ func (c APIKeysCmd) Get(ctx context.Context, in APIKeysGetInput) error {
return err
}

key, err := c.apiKeys.Get(ctx, in.ID, kernel.APIKeyGetParams{})
params := kernel.APIKeyGetParams{}
if in.IncludeDeleted {
params.IncludeDeleted = kernel.Bool(true)
}

key, err := c.apiKeys.Get(ctx, in.ID, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
Expand Down Expand Up @@ -181,6 +196,56 @@ func (c APIKeysCmd) Update(ctx context.Context, in APIKeysUpdateInput) error {
return nil
}

// Rotate issues a replacement API key. The rotated key keeps working until its
// grace period elapses, so this is disruptive but not immediately breaking --
// it still prompts, since the old key does eventually stop working.
func (c APIKeysCmd) Rotate(ctx context.Context, in APIKeysRotateInput) error {
if err := validateJSONOutput(in.Output); err != nil {
return err
}

params := kernel.APIKeyRotateParams{}
if in.DaysToExpire.Set {
if in.DaysToExpire.Value < 1 || in.DaysToExpire.Value > 3650 {
return fmt.Errorf("--days-to-expire must be between 1 and 3650")
}
params.DaysToExpire = kernel.Int(in.DaysToExpire.Value)
}
if in.ExpireInDays.Set {
if in.ExpireInDays.Value < 0 {
return fmt.Errorf("--expire-in-days must be non-negative; use 0 to expire the rotated key immediately")
}
params.ExpireInDays = kernel.Int(in.ExpireInDays.Value)
}

if !in.SkipConfirm {
ok, err := c.prompter.Confirm(
fmt.Sprintf("rotate API key '%s'", in.ID),
fmt.Sprintf("Are you sure you want to rotate API key '%s'? The current key stops working after its grace period.", in.ID),
)
if err != nil {
return err
}
if !ok {
pterm.Info.Println("Rotation cancelled")
return nil
}
}

key, err := c.apiKeys.Rotate(ctx, in.ID, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
}

if in.Output == "json" {
return util.PrintPrettyJSON(key)
}

pterm.Success.Printf("Rotated API key %s into new key: %s\n", in.ID, key.ID)
renderCreatedAPIKey(key)
return nil
}

func (c APIKeysCmd) Delete(ctx context.Context, in APIKeysDeleteInput) error {
if !in.SkipConfirm {
ok, err := c.prompter.Confirm(
Expand Down Expand Up @@ -308,7 +373,23 @@ func runAPIKeysList(cmd *cobra.Command, args []string) error {
func runAPIKeysGet(cmd *cobra.Command, args []string) error {
c := getAPIKeysHandler(cmd)
output, _ := cmd.Flags().GetString("output")
return c.Get(cmd.Context(), APIKeysGetInput{ID: args[0], Output: output})
includeDeleted, _ := cmd.Flags().GetBool("include-deleted")
return c.Get(cmd.Context(), APIKeysGetInput{ID: args[0], IncludeDeleted: includeDeleted, Output: output})
}

func runAPIKeysRotate(cmd *cobra.Command, args []string) error {
c := getAPIKeysHandler(cmd)
daysToExpire, _ := cmd.Flags().GetInt64("days-to-expire")
expireInDays, _ := cmd.Flags().GetInt64("expire-in-days")
skip, _ := cmd.Flags().GetBool("yes")
output, _ := cmd.Flags().GetString("output")
return c.Rotate(cmd.Context(), APIKeysRotateInput{
ID: args[0],
DaysToExpire: Int64Flag{Set: cmd.Flags().Changed("days-to-expire"), Value: daysToExpire},
ExpireInDays: Int64Flag{Set: cmd.Flags().Changed("expire-in-days"), Value: expireInDays},
SkipConfirm: skip,
Output: output,
})
}

func runAPIKeysUpdate(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -362,6 +443,14 @@ var apiKeysUpdateCmd = &cobra.Command{
RunE: runAPIKeysUpdate,
}

var apiKeysRotateCmd = &cobra.Command{
Use: "rotate <id>",
Short: "Rotate an API key",
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.",
Args: cobra.ExactArgs(1),
RunE: runAPIKeysRotate,
}

var apiKeysDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an API key",
Expand All @@ -381,17 +470,24 @@ func init() {
apiKeysListCmd.Flags().Int("offset", 0, "Number of results to skip")

addJSONOutputFlag(apiKeysGetCmd)
apiKeysGetCmd.Flags().Bool("include-deleted", false, "Include soft-deleted API keys in the lookup")

addJSONOutputFlag(apiKeysUpdateCmd)
apiKeysUpdateCmd.Flags().String("name", "", "New API key name (required)")
_ = apiKeysUpdateCmd.MarkFlagRequired("name")

addJSONOutputFlag(apiKeysRotateCmd)
apiKeysRotateCmd.Flags().Int64("days-to-expire", 0, "Lifetime in days for the new key (1-3650); omit to reuse the rotated key's lifetime")
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")
apiKeysRotateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

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

apiKeysCmd.AddCommand(apiKeysCreateCmd)
apiKeysCmd.AddCommand(apiKeysListCmd)
apiKeysCmd.AddCommand(apiKeysGetCmd)
apiKeysCmd.AddCommand(apiKeysUpdateCmd)
apiKeysCmd.AddCommand(apiKeysRotateCmd)
apiKeysCmd.AddCommand(apiKeysDeleteCmd)

rootCmd.AddCommand(apiKeysCmd)
Expand Down
85 changes: 85 additions & 0 deletions cmd/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type FakeAPIKeysService struct {
GetFunc func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error)
UpdateFunc func(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error)
ListFunc func(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error)
RotateFunc func(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error)
DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
}

Expand Down Expand Up @@ -50,6 +51,13 @@ func (f *FakeAPIKeysService) List(ctx context.Context, query kernel.APIKeyListPa
return &pagination.OffsetPagination[kernel.APIKey]{Items: []kernel.APIKey{}}, nil
}

func (f *FakeAPIKeysService) Rotate(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) {
if f.RotateFunc != nil {
return f.RotateFunc(ctx, id, body, opts...)
}
return createdAPIKeyFromJSON(`{"id":"key_rotated","name":"default","key":"sk_rotated","masked_key":"sk_...ated","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil
}

func (f *FakeAPIKeysService) Delete(ctx context.Context, id string, opts ...option.RequestOption) error {
if f.DeleteFunc != nil {
return f.DeleteFunc(ctx, id, opts...)
Expand Down Expand Up @@ -267,3 +275,80 @@ func TestAPIKeysDeleteReturnsAPIError(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "API error")
}

func TestAPIKeysRotateBuildsParamsAndPrintsNewKey(t *testing.T) {
buf := capturePtermOutput(t)
var capturedID string
var captured kernel.APIKeyRotateParams
fake := &FakeAPIKeysService{
RotateFunc: func(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) {
capturedID = id
captured = body
return createdAPIKeyFromJSON(`{"id":"key_new","name":"ci","key":"sk_new","masked_key":"sk_...new","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil
},
}
c := APIKeysCmd{apiKeys: fake}

err := c.Rotate(context.Background(), APIKeysRotateInput{
ID: "key_123",
DaysToExpire: Int64Flag{Set: true, Value: 30},
ExpireInDays: Int64Flag{Set: true, Value: 0},
SkipConfirm: true,
})
require.NoError(t, err)

assert.Equal(t, "key_123", capturedID)
assert.True(t, captured.DaysToExpire.Valid())
assert.Equal(t, int64(30), captured.DaysToExpire.Value)
// 0 must reach the API as an explicit value, not be dropped: it means
// "revoke the rotated key now" rather than "use the default grace period".
assert.True(t, captured.ExpireInDays.Valid())
assert.Equal(t, int64(0), captured.ExpireInDays.Value)

out := buf.String()
assert.Contains(t, out, "Rotated API key key_123 into new key: key_new")
assert.Contains(t, out, "sk_new")
}

func TestAPIKeysRotateRejectsOutOfRangeDaysToExpire(t *testing.T) {
c := APIKeysCmd{apiKeys: &FakeAPIKeysService{}}
err := c.Rotate(context.Background(), APIKeysRotateInput{
ID: "key_123",
DaysToExpire: Int64Flag{Set: true, Value: 4000},
SkipConfirm: true,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "--days-to-expire must be between 1 and 3650")
}

func TestAPIKeysRotateOmitsUnsetParams(t *testing.T) {
capturePtermOutput(t)
var captured kernel.APIKeyRotateParams
fake := &FakeAPIKeysService{
RotateFunc: func(ctx context.Context, id string, body kernel.APIKeyRotateParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error) {
captured = body
return createdAPIKeyFromJSON(`{"id":"key_new","name":"ci","key":"sk_new","masked_key":"sk_...new","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil
},
}
c := APIKeysCmd{apiKeys: fake}

require.NoError(t, c.Rotate(context.Background(), APIKeysRotateInput{ID: "key_123", SkipConfirm: true}))
assert.False(t, captured.DaysToExpire.Valid())
assert.False(t, captured.ExpireInDays.Valid())
}

func TestAPIKeysGetPassesIncludeDeleted(t *testing.T) {
capturePtermOutput(t)
var captured kernel.APIKeyGetParams
fake := &FakeAPIKeysService{
GetFunc: func(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) {
captured = query
return apiKeyFromJSON(`{"id":"` + id + `","name":"default","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil
},
}
c := APIKeysCmd{apiKeys: fake}

require.NoError(t, c.Get(context.Background(), APIKeysGetInput{ID: "key_123", IncludeDeleted: true}))
assert.True(t, captured.IncludeDeleted.Valid())
assert.True(t, captured.IncludeDeleted.Value)
}
Loading
Loading