From c82bed2262431261d86faa8ff8fce5c2dbac850c Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Tue, 27 Oct 2026 10:14:57 -0700 Subject: [PATCH 1/2] fix(lk): --dev with an API key or secret is an error --api-key and --api-secret also read LIVEKIT_API_KEY and LIVEKIT_API_SECRET, so --dev in a shell with those set made resolveProject fail with "both api-key and dev flags are set". requireProjectWithOpts treated that like nothing resolving and fell back to selectProject, which in non-interactive mode quietly used the only configured project: lk acted on a cloud project instead of the local dev server. It now returns the error, as it does for a --project or --subdomain that doesn't resolve. The error names what to remove: the env var when the value is the env var's, the flag otherwise, e.g. "--dev can't be combined with API key credentials; unset LIVEKIT_API_KEY or drop --dev". Commands using loadProjectDetails already failed here, and now get the same message. --dev alone, or with --url, is unchanged. --- cmd/lk/app.go | 6 +- cmd/lk/testdata/script/dev-with-api-key.txtar | 37 +++++++++ cmd/lk/utils.go | 39 +++++++-- cmd/lk/utils_test.go | 79 +++++++++++++++++++ 4 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 cmd/lk/testdata/script/dev-with-api-key.txtar diff --git a/cmd/lk/app.go b/cmd/lk/app.go index b719c903..3621c20a 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -161,9 +161,9 @@ func requireProjectWithOpts(ctx context.Context, cmd *cli.Command, opts ...loadO case errors.Is(err, config.ErrInvalidConfig): // something is wrong with the project config file return ctx, err - case err != nil && namedProject(cmd): - // the user named a project that doesn't resolve: say so rather than - // act on another one + case err != nil && (namedProject(cmd) || cmd.Bool("dev")): + // the user named a project, or asked for dev credentials, that doesn't + // resolve: say so rather than act on another project return ctx, err case err != nil: // no project could be resolved automatically; choose from existing diff --git a/cmd/lk/testdata/script/dev-with-api-key.txtar b/cmd/lk/testdata/script/dev-with-api-key.txtar new file mode 100644 index 00000000..ced43bb6 --- /dev/null +++ b/cmd/lk/testdata/script/dev-with-api-key.txtar @@ -0,0 +1,37 @@ +# --dev with an API key or secret, from its flag or its env var, is an error +# naming what to remove. lk never falls back to the only project: the fake +# would answer that project's server, so lk can only fail by refusing. +respond POST http://one.test/twirp/livekit.RoomService/ListRooms 200 '' +respond POST http://dev.test/twirp/livekit.RoomService/ListRooms 200 '' + +env LIVEKIT_API_KEY=APIenv +! exec lk --dev room list +stderr '^--dev can''t be combined with API key credentials; unset LIVEKIT_API_KEY or drop --dev$' + +env LIVEKIT_API_SECRET=secretenv +! exec lk --dev room list +stderr '^--dev can''t be combined with API key credentials; unset LIVEKIT_API_KEY and LIVEKIT_API_SECRET or drop --dev$' + +env LIVEKIT_API_KEY= +env LIVEKIT_API_SECRET= +! exec lk --dev --api-key APIflag room list +stderr '^--dev can''t be combined with API key credentials; remove --api-key or drop --dev$' + +requests +! stdout . + +# --dev alone still uses the dev credentials, at --url. +exec lk --dev --url ws://dev.test room list +stderr '^Using dev credentials$' +requests +cmp stdout want-requests + +-- home/.livekit/cli-config.yaml -- +projects: + - name: one + project_id: p_1 + url: ws://one.test + api_key: APIone + api_secret: secretone +-- want-requests -- +POST http://dev.test/twirp/livekit.RoomService/ListRooms apikey=devkey diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 9323b41d..c0d2ad08 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -394,7 +394,8 @@ func (rp *resolvedProject) announce() { // // It performs no prompting and no printing: callers print rp.notice via out.Status. // requireProjectWithOpts handles a returned error by offering interactive -// selection, unless --project or --subdomain named a project (see namedProject). +// selection, unless --project or --subdomain named a project (see namedProject) +// or --dev asked for dev credentials. func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) { // 1. explicit project if c.String("project") != "" { @@ -421,20 +422,17 @@ func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) { } // 3. inline credentials (flags or environment) + if c.Bool("dev") && (c.String("api-key") != "" || c.String("api-secret") != "") { + return nil, devCredentialsError(c) + } pc := &config.ProjectConfig{} if val := c.String("url"); val != "" { pc.URL = val } if val := c.String("api-key"); val != "" { - if c.Bool("dev") { - return nil, errors.New("both api-key and dev flags are set") - } pc.APIKey = val } if val := c.String("api-secret"); val != "" { - if c.Bool("dev") { - return nil, errors.New("both api-secret and dev flags are set") - } pc.APISecret = val } if pc.APIKey != "" && pc.APISecret != "" && (pc.URL != "" || !p.requireURL) { @@ -493,6 +491,33 @@ func resolveProject(c *cli.Command, p loadParams) (*resolvedProject, error) { return &resolvedProject{project: pc, source: sourceInlineFlags}, nil } +// devCredentialsError is the error for --dev with an API key or secret. It +// names each one to remove: the env var when the value is the env var's, the +// flag otherwise. +func devCredentialsError(c *cli.Command) error { + var envVars, flags []string + for _, cred := range []struct{ flag, env string }{ + {"api-key", "LIVEKIT_API_KEY"}, + {"api-secret", "LIVEKIT_API_SECRET"}, + } { + switch val := c.String(cred.flag); { + case val == "": + case val == os.Getenv(cred.env): + envVars = append(envVars, cred.env) + default: + flags = append(flags, "--"+cred.flag) + } + } + var fixes []string + if len(envVars) > 0 { + fixes = append(fixes, "unset "+strings.Join(envVars, " and ")) + } + if len(flags) > 0 { + fixes = append(fixes, "remove "+strings.Join(flags, " and ")) + } + return fmt.Errorf("--dev can't be combined with API key credentials; %s or drop --dev", strings.Join(fixes, " and ")) +} + // loadProjectDetails resolves project credentials for commands that consume the result // directly (egress, ingress, token, …) and announces the resolution. Commands that rely on // the package-level `project` (app/agent) go through requireProject instead, which layers diff --git a/cmd/lk/utils_test.go b/cmd/lk/utils_test.go index 50920fb9..4ecff6d5 100644 --- a/cmd/lk/utils_test.go +++ b/cmd/lk/utils_test.go @@ -148,6 +148,33 @@ func TestResolveProjectSource(t *testing.T) { assert.Contains(t, err.Error(), "both project and dev flags are set") }) + t.Run("dev and inline credentials conflict", func(t *testing.T) { + for _, tc := range []struct { + name string + key, secret string // LIVEKIT_API_KEY, LIVEKIT_API_SECRET + args []string + want string + }{ + {name: "env key", key: "envkey", + want: "--dev can't be combined with API key credentials; unset LIVEKIT_API_KEY or drop --dev"}, + {name: "env key and secret", key: "envkey", secret: "envsecret", + want: "--dev can't be combined with API key credentials; unset LIVEKIT_API_KEY and LIVEKIT_API_SECRET or drop --dev"}, + {name: "flag key", args: []string{"--api-key", "k"}, + want: "--dev can't be combined with API key credentials; remove --api-key or drop --dev"}, + {name: "flag key over env key", key: "envkey", args: []string{"--api-key", "k"}, + want: "--dev can't be combined with API key credentials; remove --api-key or drop --dev"}, + {name: "flag key and env secret", secret: "envsecret", args: []string{"--api-key", "k"}, + want: "--dev can't be combined with API key credentials; unset LIVEKIT_API_SECRET and remove --api-key or drop --dev"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LIVEKIT_API_KEY", tc.key) + t.Setenv("LIVEKIT_API_SECRET", tc.secret) + _, err := resolveWith(t, append([]string{"--dev"}, tc.args...)...) + assert.EqualError(t, err, tc.want) + }) + } + }) + t.Run("--quiet suppresses the breadcrumb", func(t *testing.T) { prev := out var buf bytes.Buffer @@ -204,6 +231,58 @@ func TestRequireProjectNamedProjectIsAnError(t *testing.T) { } } +// TestRequireProjectDevWithCredentialsIsAnError checks that --dev with an API +// key or secret, from its flag or its env var, fails instead of falling back +// to the only configured project, even when prompts are skipped. +func TestRequireProjectDevWithCredentialsIsAnError(t *testing.T) { + t.Setenv("LIVEKIT_URL", "") + t.Setenv("LIVEKIT_API_KEY", "") + t.Setenv("LIVEKIT_API_SECRET", "") + setHome(t, t.TempDir()) + conf := &config.CLIConfig{Projects: []config.ProjectConfig{ + {Name: "one", URL: "https://one.livekit.cloud", APIKey: "APIone", APISecret: "secretone"}, + }} + require.NoError(t, conf.PersistQuietly()) + prevProject, prevConfig := project, cliConfig + t.Cleanup(func() { project, cliConfig = prevProject, prevConfig }) + + for _, tc := range []struct { + name string + env string // LIVEKIT_API_KEY + args []string + }{ + {name: "env key", env: "APIenv"}, + {name: "flag key", args: []string{"--api-key", "APIflag"}}, + {name: "flag secret", args: []string{"--api-secret", "secretflag"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("LIVEKIT_API_KEY", tc.env) + project = nil + var err error + app := &cli.Command{ + Name: "lk", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "url", Sources: cli.EnvVars("LIVEKIT_URL"), Value: "http://localhost:7880"}, + &cli.StringFlag{Name: "api-key", Sources: cli.EnvVars("LIVEKIT_API_KEY")}, + &cli.StringFlag{Name: "api-secret", Sources: cli.EnvVars("LIVEKIT_API_SECRET")}, + &cli.BoolFlag{Name: "dev"}, + &cli.StringFlag{Name: "project"}, + &cli.StringFlag{Name: "subdomain"}, + &cli.BoolFlag{Name: "user-auth"}, + &cli.BoolFlag{Name: "yes"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + _, err = requireProjectWithOpts(ctx, cmd) + return nil + }, + } + require.NoError(t, app.Run(context.Background(), append([]string{"lk", "--yes", "--dev"}, tc.args...))) + assert.ErrorContains(t, err, "--dev can't be combined with API key credentials") + assert.Nil(t, project, "no project is chosen when --dev conflicts with API key credentials") + }) + } +} + func TestOptionalFlag(t *testing.T) { requiredFlag := &cli.StringFlag{ Name: "test", From d57caa5a8c266eba792bc6af8c1b1aa8cc9cda34 Mon Sep 17 00:00:00 2001 From: Topher Hindman Date: Tue, 27 Oct 2026 10:15:01 -0700 Subject: [PATCH 2/2] test(lk): expect --dev with an inline key or secret to fail In API-key mode, --dev with an API key or secret from a flag or the environment is now an error for every command, so the property requires lk to fail without naming a project, instead of letting room list fall back to the only configured project. --- cmd/lk/credential_property_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/lk/credential_property_test.go b/cmd/lk/credential_property_test.go index 3b539137..3c52b8fe 100644 --- a/cmd/lk/credential_property_test.go +++ b/cmd/lk/credential_property_test.go @@ -533,15 +533,17 @@ func requestProject(line string) (string, bool) { } // mustFail is set when lk can't act: both mode flags at once, a Public API -// command outside --user-auth, or, under --user-auth, no user who can act (no -// unexpired session, a --user with no unexpired session, or a Public API URL -// nobody is signed in to). Every drawn user is signed in to publicAPI. +// command outside --user-auth, --dev with an inline key or secret outside +// --user-auth, or, under --user-auth, no user who can act (no unexpired +// session, a --user with no unexpired session, or a Public API URL nobody is +// signed in to). Every drawn user is signed in to publicAPI. func (c credentialCase) mustFail() bool { + _, key, secret := c.inlineCredentials() switch { case c.userAuth && c.legacyAuth: return true case !c.userAuth: - return c.command.publicAPI + return c.command.publicAPI || c.dev && (key != "" || secret != "") case !slices.ContainsFunc(c.users, func(u sessionUser) bool { return !u.expired }): return true case c.user != "" && !slices.ContainsFunc(c.users, func(u sessionUser) bool { return (u.id == c.user || u.email == c.user) && !u.expired }):