Skip to content

🤖 feat: add inject toggle for global secrets#2600

Open
ThomasK33 wants to merge 4 commits intomainfrom
settings-1gq8
Open

🤖 feat: add inject toggle for global secrets#2600
ThomasK33 wants to merge 4 commits intomainfrom
settings-1gq8

Conversation

@ThomasK33
Copy link
Member

@ThomasK33 ThomasK33 commented Feb 24, 2026

Summary

Add an injectAll toggle for global secrets and show a read-only "Injected from Global" list in project settings so inherited env keys are visible.

Background

Global secrets previously acted only as shared storage and were injected only when a project secret explicitly referenced them. This change makes common secrets (e.g. org-wide tokens) opt-in injectable across all projects.

Implementation

  • Extended shared SecretSchema with optional injectAll.
  • Updated Config.getEffectiveSecrets() to:
    • include globally opted-in secrets for all projects,
    • continue resolving project { secret: "KEY" } aliases,
    • preserve project-over-global precedence for duplicate keys,
    • honor last-writer semantics for duplicate global keys when evaluating injectAll.
  • Sanitized malformed persisted injectAll values by stripping the flag while preserving valid key/value secrets.
  • Treated inject-all as enabled only when injectAll === true.
  • Added a global-scope Inject switch in settings UI and wired dirty-state/save behavior.
  • Added secrets.getInjectedGlobals(projectPath) API and Config.getInjectedGlobalSecrets() to expose injected global keys for a project in a read-only way.
  • Added a read-only Injected from Global section in project secrets settings so users can see inherited keys and understand project-over-global precedence.
  • Added config tests covering injection, override precedence, coexistence with project secrets, non-injection defaults, duplicate-key edge cases, and malformed injectAll recovery.

Validation

  • bun test src/node/config.test.ts
  • make typecheck
  • make lint
  • make static-check

Risks

  • Low-to-moderate: changes affect secret resolution and settings UX.
  • Mitigated by explicit precedence logic and targeted tests for injectAll behavior.

📋 Implementation Plan

Plan: Add injectAll Toggle to Global Secrets

Context & Goal

Global secrets today are shared storage only — they are never injected into project environments unless a project explicitly references them via { secret: "GLOBAL_KEY" }. The user wants a simple toggle on each global secret so that when enabled, that secret is automatically injected into every project's environment without requiring per-project references.

User impact: One toggle per secret replaces N per-project reference entries. New projects automatically get the secret with zero configuration.

Architecture Summary

The existing secrets system has 3 layers:

  1. Schema (SecretSchema in src/common/orpc/schemas/secrets.ts) → defines { key, value }
  2. Resolution (Config.getEffectiveSecrets in src/node/config.ts) → merges project secrets + resolved global references
  3. Injection (callers of secretsToRecord(config.getEffectiveSecrets(projectPath))) → converts to Record<string, string> for process.env

All callers already go through getEffectiveSecretssecretsToRecord. This means a single change in getEffectiveSecrets propagates to every injection point (workspace init, bash tool, terminal, tasks, AI service).

Estimated LoC: ~120 product code, ~80 test code


Implementation Steps

Step 1: Extend SecretSchema with injectAll

File: src/common/orpc/schemas/secrets.ts

Add an optional injectAll boolean to the schema. Existing secrets without this field default to false (backward-compatible).

export const SecretSchema = z
  .object({
    key: z.string(),
    value: SecretValueSchema,
    injectAll: z.boolean().optional(),
  })
  .meta({
    description: "A key-value pair for storing sensitive configuration",
  });

Why .optional(): This field only appears on global secrets. The Zod schema is shared between global and project secrets, so the field must be optional. Existing secrets.json files without injectAll parse cleanly — undefined is treated as false.

Note: We use .optional() here (not .nullish()) because this is not a tool input parameter — it's a persisted data schema. The .nullish() convention in AGENTS.md applies specifically to tool input schemas for AI model consumption.

Step 2: Update Config.getEffectiveSecrets to merge injectAll globals

File: src/node/config.ts, method getEffectiveSecrets (line ~1367)

Currently this method only returns project secrets (with global references resolved). Change it to also prepend global secrets that have injectAll: true, with project secrets taking precedence (last-writer-wins).

getEffectiveSecrets(projectPath: string): Secret[] {
  const normalizedProjectPath = Config.normalizeSecretsProjectPath(projectPath) || projectPath;
  const config = this.loadSecretsConfig();
  const globalSecrets = config[Config.GLOBAL_SECRETS_KEY] ?? [];
  const projectSecrets = config[normalizedProjectPath] ?? [];

  // Collect global secrets marked for universal injection.
  // These are injected as literal values (resolved) so downstream
  // consumers don't need to understand the injectAll flag.
  const globalSecretsByKey = secretsToRecord(globalSecrets);
  const injectedGlobals: Secret[] = [];
  for (const gs of globalSecrets) {
    if (!gs.injectAll) continue;
    const resolved = globalSecretsByKey[gs.key];
    if (resolved !== undefined) {
      injectedGlobals.push({ key: gs.key, value: resolved });
    }
  }

  // Resolve project secret references against global values.
  const resolvedProjectSecrets = projectSecrets.map((secret) => {
    if (!Config.isSecretReferenceValue(secret.value)) {
      return secret;
    }
    const targetKey = secret.value.secret.trim();
    if (!targetKey) return secret;
    const resolvedGlobalValue = globalSecretsByKey[targetKey];
    if (resolvedGlobalValue !== undefined) {
      return { ...secret, value: resolvedGlobalValue };
    }
    return secret;
  });

  // Merge: project secrets override injected globals (last-writer-wins).
  // Put injected globals first so project secrets can override them.
  const projectKeys = new Set(resolvedProjectSecrets.map((s) => s.key));
  const nonOverriddenGlobals = injectedGlobals.filter((g) => !projectKeys.has(g.key));
  return [...nonOverriddenGlobals, ...resolvedProjectSecrets];
}

Key behaviors:

  • injectAll globals are prepended, so explicit project secrets always win.
  • A project can override an injectAll global by defining the same key (either literal or reference).
  • The injectAll field is stripped from the output — callers receive plain { key, value: string } entries.

Step 3: Update Config.isSecret to accept injectAll

File: src/node/config.ts, static method isSecret (line ~1245)

The isSecret type guard validates persisted data. It needs to tolerate the injectAll field. Currently it checks for key and value — no change needed since it doesn't reject extra properties. But verify this is the case. If parseSecretsArray or normalizeSecretsConfig strips unknown fields, those need updating too.

After review: parseSecretsArray filters via Config.isSecret() which checks typeof value.key === "string" && isSecretValue(value.value). The injectAll field passes through as an extra property — no code change needed for parsing/normalization.

Step 4: Update the Secrets Settings UI — add toggle per global secret row

File: src/browser/components/Settings/sections/SecretsSection.tsx

When scope === "global", add a Switch toggle in each secret row for injectAll. The toggle should be inline and only appear in global scope (project secrets don't have injectAll).

4a. Import the Switch component:

import { Switch } from "@/browser/components/ui/switch";

4b. Add an updateSecretInjectAll callback:

const updateSecretInjectAll = useCallback((index: number, checked: boolean) => {
  setSecrets((prev) => {
    const next = [...prev];
    const existing = next[index] ?? { key: "", value: "" };
    next[index] = { ...existing, injectAll: checked || undefined };
    return next;
  });
}, []);

checked || undefined: when false, omit the field entirely to keep secrets.json clean for secrets that don't use the feature.

4c. Update the grid layout for global scope to include the toggle column. Currently:

  • Global: grid-cols-[1fr_1fr_auto_auto] (key, value, eye, trash)
  • Project: grid-cols-[1fr_auto_1fr_auto_auto] (key, type, value, eye, trash)

Change global to: grid-cols-[1fr_1fr_auto_auto_auto] (key, value, eye, inject-toggle, trash)

4d. Add a column header label Inject (only in global scope).

4e. Render the Switch in each global secret row:

{
  scope === "global" && (
    <div className="flex items-center justify-center self-center">
      <Switch
        checked={!!secret.injectAll}
        onCheckedChange={(checked) => updateSecretInjectAll(index, checked)}
        disabled={saving}
        aria-label="Inject into all projects"
        title="Inject into all projects"
      />
    </div>
  );
}

4f. Fix the secretsEqual comparison to include injectAll:

function secretsEqual(a: Secret[], b: Secret[]): boolean {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    const left = a[i];
    const right = b[i];
    if (!left || !right) return false;
    if (left.key !== right.key) return false;
    if (!secretValuesEqual(left.value, right.value)) return false;
    if (!!left.injectAll !== !!right.injectAll) return false;
  }
  return true;
}

4g. Update the help text. Replace:

Global secrets are shared storage only; they are not injected by default.

With:

Toggle "Inject" on a global secret to automatically inject it into every project.

Step 5: Add tests

File: src/node/config.test.ts

Add tests in the existing describe("secrets") block:

it("injects injectAll global secrets into effective secrets for any project", async () => {
  await config.updateGlobalSecrets([
    { key: "INJECTED", value: "everywhere", injectAll: true },
    { key: "NOT_INJECTED", value: "stored-only" },
  ]);

  const projectPath = "/fake/project";
  // No project secrets configured at all.
  const record = secretsToRecord(config.getEffectiveSecrets(projectPath));
  expect(record).toEqual({ INJECTED: "everywhere" });
});

it("project secrets override injectAll global secrets", async () => {
  await config.updateGlobalSecrets([
    { key: "TOKEN", value: "global-value", injectAll: true },
  ]);

  const projectPath = "/fake/project";
  await config.updateProjectSecrets(projectPath, [
    { key: "TOKEN", value: "project-value" },
  ]);

  const record = secretsToRecord(config.getEffectiveSecrets(projectPath));
  expect(record).toEqual({ TOKEN: "project-value" });
});

it("injectAll global secrets coexist with project-specific secrets", async () => {
  await config.updateGlobalSecrets([
    { key: "GLOBAL_TOKEN", value: "g", injectAll: true },
  ]);

  const projectPath = "/fake/project";
  await config.updateProjectSecrets(projectPath, [
    { key: "LOCAL_TOKEN", value: "l" },
  ]);

  const record = secretsToRecord(config.getEffectiveSecrets(projectPath));
  expect(record).toEqual({ GLOBAL_TOKEN: "g", LOCAL_TOKEN: "l" });
});

it("does not inject injectAll:false or undefined global secrets", async () => {
  await config.updateGlobalSecrets([
    { key: "A", value: "1", injectAll: false },
    { key: "B", value: "2" },
    { key: "C", value: "3", injectAll: true },
  ]);

  const record = secretsToRecord(config.getEffectiveSecrets("/any/project"));
  expect(record).toEqual({ C: "3" });
});

Step 6: Update the existing test assertion

File: src/node/config.test.ts

The existing test "does not inherit global secrets by default" (line 339) should still pass because:

  • Global secrets without injectAll: true are not injected.
  • This test sets global secrets without injectAll, so behavior is unchanged.

Verify this test still passes after the change. No modification expected.


Files Changed (Summary)

File Change LoC
src/common/orpc/schemas/secrets.ts Add injectAll: z.boolean().optional() to SecretSchema +1
src/node/config.ts Rewrite getEffectiveSecrets to merge injectAll globals ~+15 net
src/browser/components/Settings/sections/SecretsSection.tsx Add Switch toggle, grid column, help text, equality check ~+30 net
src/node/config.test.ts Add 4 new test cases ~+40
Total ~85 product + ~40 test

Validation

  1. make typecheck — ensure Secret type flows correctly everywhere.
  2. bun test src/node/config.test.ts — new + existing secret tests pass.
  3. make lint — no lint errors.
  4. Manual: open Settings → Secrets → Global, verify toggle renders and persists.

Generated with mux • Model: openai:gpt-5.3-codex • Thinking: xhigh • Cost: $2.45

@ThomasK33
Copy link
Member Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fcae9f2350

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33
Copy link
Member Author

@codex review

Addressed both review items and pushed fixes. Please take another look.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf1cdc7c3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33
Copy link
Member Author

@codex review

Addressed malformed injectAll sanitization and added regression coverage. Please re-review.

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. More of your lovely PRs please.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Add optional `injectAll` on global secrets, inject opted-in globals into
`getEffectiveSecrets`, expose an Inject switch in global secrets settings, and
cover behavior with config tests.

---

_Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$2.45`_

<!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=2.45 -->
Fix project-scope secrets grid header alignment and ensure duplicate global
secret keys honor last-writer semantics for injectAll decisions. Add regression
test coverage for duplicate-key behavior.

---

_Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$2.45`_

<!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=2.45 -->
Keep valid key/value secrets when persisted `injectAll` is malformed, and treat
inject-all as enabled only when explicitly `true`. Add regression coverage for
malformed injectAll with project alias resolution.

---

_Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$2.45`_

<!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=2.45 -->
Add a read-only injected-global view in Project secrets settings, backed by a
new secrets API endpoint that returns global injectAll keys active for the
selected project. Keep precedence explicit: project keys override injected
globals.

---

_Generated with `mux` • Model: `openai:gpt-5.3-codex` • Thinking: `xhigh` • Cost: `$2.45`_

<!-- mux-attribution: model=openai:gpt-5.3-codex thinking=xhigh costs=2.45 -->
@ThomasK33
Copy link
Member Author

@codex review

Added project-scope visibility for globally injected secrets and corresponding API support/tests. Please review.

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. 🎉

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant