Skip to content

Commit 7a1be0e

Browse files
BruceDu521claude
andcommitted
fix: route interactive + implicit-context consumers through ephemeral context (PLA-1590)
Codex's round-5 review showed B+ (commit b5667e2) only covered half the surface: my new helpers (CurrentProjectID etc.) plus a few skip-write guards in project create / deploy. Two whole entry classes still walked straight past them. Path 1 — interactive ParamFiller bypass: $ zeabur workspace switch team-A $ zeabur context set project --id <team-A-project> $ zeabur --workspace team-B service restart # interactive (default) `runRestartInteractive` passed the raw `f.Config.GetContext()` to ParamFiller.ServiceByNameWithEnvironment. The filler read team-A's pinned project ID as `projectCtx.GetProject().GetID()`, used it as the project scope for SelectService, and quietly restarted team-A's service while the user thought they were operating on team-B. Worse, when the persisted context was empty the filler called `projectCtx.SetProject(picked)` and *wrote* the team-B project under the persisted team-A workspace — cross-team contamination that survived the command. Same shape across service/{restart,delete,suspend,exec,network,port-forward,redeploy, instruction,metric,get,update/tag}, variable/{create,delete,env,list,update}, deployment/{get,log,list}, domain/{create,delete,list}. Path 2 — `project get/delete` PreRunE auto-fill: $ zeabur --workspace team-B project delete --yes -i=false # no --id `util.DefaultIDNameByContext(f.Config.GetContext().GetProject(), ...)` was bound at Cobra command-construction time and unconditionally copied team-A's persisted project ID into opts.id. The runE then deleted team-A's project, even though every flag on the line said team-B. Fix: ephemeral context + EffectiveContext audit. 1. New `zcontext.NewEphemeralContext(workspace)` — an in-memory Context implementation that starts empty, writes to memory only, and reports the supplied workspace via GetWorkspace() (so consumers that ask "what workspace is this context for?" get the override answer rather than personal — a second-order trap Codex flagged). 2. New `Factory.EffectiveContext() zcontext.Context`: - Without override: returns the persisted config context, byte- equivalent to today. - Under override: lazy-initialises a per-Factory ephemeral context and returns it for every subsequent call (so ParamFiller's `Set → later Get` cycle still works in-process; nothing leaks to disk). 3. 24 interactive callers swap `f.Config.GetContext()` for `f.EffectiveContext()`: every service/, variable/, deployment/, domain/ command that ran ParamFiller. 4. project get/delete PreRunE swap to a lazy closure so EffectiveContext is resolved at PreRunE time (after PersistentPreRunE parses `--workspace`), not at command construction. The signature of util.DefaultIDNameByContext changes from `BasicInfo` to `func() BasicInfo` to make that lazy evaluation explicit. 5. `context get` reads EffectiveContext and, under override, prints an extra "Note: --workspace is one-shot; persisted ... is not used" line for human-readable output. JSON output stays structurally identical so scripts keep parsing. 6. `context clear` rejects under override — clearing belongs to the persisted state, not a one-shot override. 7. Deleted internal/util.NeedProjectContextWhenNonInteractive and DefaultIDByContext — both had zero callers and would re-introduce the same pattern if revived from copy/paste later. What stays on `f.Config.GetContext()` (intentional): workspace/{switch,clear}, auth/logout, root.go's lazy workspace verify, context/{set,clear} command bodies (set already rejects override; clear now does too), project create / deploy interactive flows (already wrapped in `if !HasWorkspaceOverride` from b5667e2 with a user-visible hint). Tests: zcontext/ephemeral_test.go (new): TestEphemeralContext_WorkspaceFromConstructor TestEphemeralContext_NilWorkspaceIsPersonal TestEphemeralContext_ReadEmptyByDefault TestEphemeralContext_SetReadCycleWorksInMemory TestEphemeralContext_ClearAll cmdutil/factory_test.go (added): TestFactory_EffectiveContext_NoOverride TestFactory_EffectiveContext_OverrideReturnsEphemeral_WithWorkspace TestFactory_EffectiveContext_OverrideCachedWithinCommand TestFactory_EffectiveContext_OverridePersistedUnpolluted Dev-2 E2E (this round): C1: --workspace team-B service delete --name redis (interactive, persisted=team-A + pinned project) → opens Select project from team-B; for-clone-test redis NOT deleted (verified via list). C3: --workspace team-B project delete --yes -i=false (no --id) → "please specify project by --name or --id"; for-clone-test project NOT deleted. C4: --workspace team-B context get → all inner context shows "<not set>" + the Note line; --json stays structurally clean. C5: --workspace team-B context clear → rejected with actionable hint pointing at workspace switch. C6: no override → context get reads persisted normally (back-compat red line). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5667e2 commit 7a1be0e

31 files changed

Lines changed: 413 additions & 55 deletions

File tree

internal/cmd/context/clear/clear.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package clear
22

33
import (
4+
"fmt"
5+
46
"github.com/spf13/cobra"
57

68
"github.com/zeabur/cli/internal/cmdutil"
@@ -24,6 +26,18 @@ func NewCmdClear(f *cmdutil.Factory) *cobra.Command {
2426
}
2527

2628
func runClear(f *cmdutil.Factory, opts *Options) error {
29+
// `context clear` modifies the persisted inner context. Under a
30+
// `--workspace` override the persisted state belongs to a (potentially)
31+
// different workspace than the user thinks they're in, so silently
32+
// clearing it would surprise them. Reject up front and tell them to
33+
// switch first if they really mean to wipe the persisted context
34+
// (PLA-1590 B+).
35+
if f.HasWorkspaceOverride() {
36+
return fmt.Errorf(
37+
"`context clear` cannot be combined with `--workspace`; the override does not modify persisted context",
38+
)
39+
}
40+
2741
confirm := true
2842

2943
if f.Interactive {

internal/cmd/context/get/get.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
2323
}
2424

2525
func runGet(f *cmdutil.Factory, opts *Options) error {
26-
project := f.Config.GetContext().GetProject()
27-
environment := f.Config.GetContext().GetEnvironment()
28-
service := f.Config.GetContext().GetService()
26+
// Use the effective context so `--workspace` override truthfully shows
27+
// "(not set)" for inner context — displaying the persisted team-A
28+
// project under an override to team-B would mislead the user into
29+
// thinking it's available there (PLA-1590 B+).
30+
ctx := f.EffectiveContext()
31+
project := ctx.GetProject()
32+
environment := ctx.GetEnvironment()
33+
service := ctx.GetService()
2934

3035
header := []string{"Context", "Name", "ID"}
3136
data := [][]string{
@@ -56,5 +61,13 @@ func runGet(f *cmdutil.Factory, opts *Options) error {
5661

5762
f.Printer.Table(header, data)
5863

64+
// Human-readable mode also tells the user *why* everything is unset when
65+
// they're running under a `--workspace` override, so they don't
66+
// misread it as a config bug. JSON mode stays structurally clean
67+
// (no prose mixed into the payload) so scripts keep parsing it.
68+
if f.HasWorkspaceOverride() {
69+
f.Log.Info("Note: --workspace is one-shot; persisted project/service/environment context is not used.")
70+
}
71+
5972
return nil
6073
}

internal/cmd/deployment/get/get.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func runGet(f *cmdutil.Factory, opts *Options) error {
4949

5050
func runGetInteractive(f *cmdutil.Factory, opts *Options) error {
5151
if opts.deploymentID == "" {
52-
zctx := f.Config.GetContext()
52+
zctx := f.EffectiveContext()
5353
_, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
5454
ProjectCtx: zctx,
5555
ServiceID: &opts.serviceID,

internal/cmd/deployment/list/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func runList(f *cmdutil.Factory, opts *Options) error {
4545
}
4646

4747
func runListInteractive(f *cmdutil.Factory, opts *Options) error {
48-
zctx := f.Config.GetContext()
48+
zctx := f.EffectiveContext()
4949
_, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
5050
ProjectCtx: zctx,
5151
ServiceID: &opts.serviceID,

internal/cmd/deployment/log/log.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func runLog(f *cmdutil.Factory, opts *Options) error {
6161

6262
func runLogInteractive(f *cmdutil.Factory, opts *Options) error {
6363
if opts.deploymentID == "" {
64-
zctx := f.Config.GetContext()
64+
zctx := f.EffectiveContext()
6565
_, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
6666
ProjectCtx: zctx,
6767
ServiceID: &opts.serviceID,

internal/cmd/domain/create/create.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func runCreateDomain(f *cmdutil.Factory, opts *Options) error {
5252
}
5353

5454
func runCreateDomainInteractive(f *cmdutil.Factory, opts *Options) error {
55-
zctx := f.Config.GetContext()
55+
zctx := f.EffectiveContext()
5656

5757
if _, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
5858
ProjectCtx: zctx,

internal/cmd/domain/delete/delete.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func runDeleteDomain(f *cmdutil.Factory, opts *Options) error {
5050
}
5151

5252
func runDeleteDomainInteractive(f *cmdutil.Factory, opts *Options) error {
53-
zctx := f.Config.GetContext()
53+
zctx := f.EffectiveContext()
5454

5555
if _, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
5656
ProjectCtx: zctx,

internal/cmd/domain/list/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func runListDomains(f *cmdutil.Factory, opts *Options) error {
4747
}
4848

4949
func runListDomainsInteractive(f *cmdutil.Factory, opts *Options) error {
50-
zctx := f.Config.GetContext()
50+
zctx := f.EffectiveContext()
5151

5252
if _, err := f.ParamFiller.ServiceByNameWithEnvironment(fill.ServiceByNameWithEnvironmentOptions{
5353
ProjectCtx: zctx,

internal/cmd/project/delete/delete.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/zeabur/cli/internal/cmdutil"
99
"github.com/zeabur/cli/internal/util"
1010
"github.com/zeabur/cli/pkg/model"
11+
"github.com/zeabur/cli/pkg/zcontext"
1112
)
1213

1314
type Options struct {
@@ -23,7 +24,14 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
2324
Use: "delete",
2425
Short: "Delete project",
2526
Aliases: []string{"del"},
26-
PreRunE: util.DefaultIDNameByContext(f.Config.GetContext().GetProject(), &opts.id, &opts.name),
27+
// Closure (not direct call) so EffectiveContext is resolved at
28+
// PreRunE time — after PersistentPreRunE has parsed `--workspace`
29+
// — instead of at Cobra construction time when the override flag
30+
// has not yet been seen (PLA-1590 B+).
31+
PreRunE: util.DefaultIDNameByContext(
32+
func() zcontext.BasicInfo { return f.EffectiveContext().GetProject() },
33+
&opts.id, &opts.name,
34+
),
2735
RunE: func(cmd *cobra.Command, args []string) error {
2836
return runDelete(f, opts)
2937
},

internal/cmd/project/get/get.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/zeabur/cli/internal/cmdutil"
1111
"github.com/zeabur/cli/pkg/model"
12+
"github.com/zeabur/cli/pkg/zcontext"
1213
)
1314

1415
type Options struct {
@@ -23,7 +24,14 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
2324
Use: "get",
2425
Short: "Get project",
2526
Long: "Get project, use --id or --name to specify the project",
26-
PreRunE: util.DefaultIDNameByContext(f.Config.GetContext().GetProject(), &opts.id, &opts.name),
27+
// Closure (not direct call) so EffectiveContext is resolved at
28+
// PreRunE time — after PersistentPreRunE has parsed `--workspace`
29+
// — instead of at Cobra construction time when the override flag
30+
// has not yet been seen (PLA-1590 B+).
31+
PreRunE: util.DefaultIDNameByContext(
32+
func() zcontext.BasicInfo { return f.EffectiveContext().GetProject() },
33+
&opts.id, &opts.name,
34+
),
2735
RunE: func(cmd *cobra.Command, args []string) error {
2836
return runGet(f, opts)
2937
},

0 commit comments

Comments
 (0)