Skip to content
Open
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
108 changes: 101 additions & 7 deletions src/browser/components/Settings/sections/SecretsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAPI } from "@/browser/contexts/API";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { Button } from "@/browser/components/ui/button";
import { Switch } from "@/browser/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/browser/components/ui/toggle-group";
import {
Select,
Expand Down Expand Up @@ -96,6 +97,7 @@ function secretsEqual(a: Secret[], b: Secret[]): boolean {
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;
}
Expand All @@ -119,6 +121,7 @@ export const SecretsSection: React.FC = () => {
const [visibleSecrets, setVisibleSecrets] = useState<Set<number>>(() => new Set());

const [globalSecretKeys, setGlobalSecretKeys] = useState<string[]>([]);
const [injectedGlobalSecretKeys, setInjectedGlobalSecretKeys] = useState<string[]>([]);

// Track the last plaintext value per row index so toggling Source back to
// "Value" restores the user's input instead of clearing it.
Expand Down Expand Up @@ -163,10 +166,15 @@ export const SecretsSection: React.FC = () => {
.slice()
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));

const sortedInjectedGlobalSecretKeys = injectedGlobalSecretKeys
.slice()
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));

const loadSecrets = useCallback(async () => {
if (!api) {
setLoadedSecrets([]);
setSecrets([]);
setInjectedGlobalSecretKeys([]);
setVisibleSecrets(new Set());
setError(null);
return;
Expand All @@ -175,6 +183,7 @@ export const SecretsSection: React.FC = () => {
if (scope === "project" && !currentProjectPath) {
setLoadedSecrets([]);
setSecrets([]);
setInjectedGlobalSecretKeys([]);
setVisibleSecrets(new Set());
setError(null);
return;
Expand All @@ -184,17 +193,38 @@ export const SecretsSection: React.FC = () => {
setError(null);

try {
const nextSecrets = await api.secrets.get(
scope === "project" ? { projectPath: currentProjectPath } : {}
);
setLoadedSecrets(nextSecrets);
setSecrets(nextSecrets);
if (scope === "project") {
const projectPath = currentProjectPath;
if (!projectPath) {
setLoadedSecrets([]);
setSecrets([]);
setInjectedGlobalSecretKeys([]);
setVisibleSecrets(new Set());
setError(null);
return;
}

const [nextSecrets, injectedKeys] = await Promise.all([
api.secrets.get({ projectPath }),
api.secrets.getInjectedGlobals({ projectPath }),
]);
setLoadedSecrets(nextSecrets);
setSecrets(nextSecrets);
setInjectedGlobalSecretKeys(injectedKeys);
} else {
const nextSecrets = await api.secrets.get({});
setLoadedSecrets(nextSecrets);
setSecrets(nextSecrets);
setInjectedGlobalSecretKeys([]);
}

setVisibleSecrets(new Set());
lastLiteralValuesRef.current = new Map();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load secrets";
setLoadedSecrets([]);
setSecrets([]);
setInjectedGlobalSecretKeys([]);
setVisibleSecrets(new Set());
lastLiteralValuesRef.current = new Map();
setError(message);
Expand Down Expand Up @@ -285,6 +315,18 @@ export const SecretsSection: React.FC = () => {
});
}, []);

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;
});
}, []);

const updateSecretValueKind = useCallback(
(index: number, kind: "literal" | "global") => {
setSecrets((prev) => {
Expand Down Expand Up @@ -374,6 +416,15 @@ export const SecretsSection: React.FC = () => {

if (scope === "global") {
setGlobalSecretKeys(validSecrets.map((s) => s.key));
setInjectedGlobalSecretKeys([]);
} else {
const projectPath = currentProjectPath;
if (!projectPath) {
setInjectedGlobalSecretKeys([]);
} else {
const injectedKeys = await api.secrets.getInjectedGlobals({ projectPath });
setInjectedGlobalSecretKeys(injectedKeys);
}
}
setVisibleSecrets(new Set());
// Save compacts rows (filters out empty entries), which shifts indices.
Expand All @@ -398,7 +449,7 @@ export const SecretsSection: React.FC = () => {
Scope: <span className="text-foreground">{scopeLabel}</span>
</p>
<p className="text-muted mt-1 text-xs">
Global secrets are shared storage only; they are not injected by default.
Toggle Inject on a global secret to automatically inject it into every project.
</p>
<p className="text-muted mt-1 text-xs">
Project secrets control injection. Use Type: Global to reference a global value.
Expand Down Expand Up @@ -451,6 +502,36 @@ export const SecretsSection: React.FC = () => {
</div>
)}

{scope === "project" && currentProjectPath && (
<div className="space-y-2">
<div>
<div className="text-foreground text-sm">Injected from Global</div>
<div className="text-muted text-xs">
Read-only. Project secrets override injected globals when keys match.
</div>
</div>

{sortedInjectedGlobalSecretKeys.length === 0 ? (
<div className="text-muted border-border-medium rounded-md border border-dashed px-3 py-2 text-xs">
No global secrets are currently injected into this project.
</div>
) : (
<div className="border-border-medium bg-background-secondary rounded-md border px-3 py-2">
<div className="flex flex-wrap gap-1.5">
{sortedInjectedGlobalSecretKeys.map((key) => (
<code
key={key}
className="bg-modal-bg border-border-medium text-foreground inline-flex items-center rounded border px-2 py-0.5 font-mono text-[12px]"
>
{key}
</code>
))}
</div>
</div>
)}
</div>
)}

{error && (
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-md px-3 py-2 text-sm">
{error}
Expand All @@ -475,13 +556,14 @@ export const SecretsSection: React.FC = () => {
className={`[&>label]:text-muted grid ${
scope === "project"
? "grid-cols-[1fr_auto_1fr_auto_auto]"
: "grid-cols-[1fr_1fr_auto_auto]"
: "grid-cols-[1fr_1fr_auto_auto_auto]"
} items-end gap-1 [&>label]:mb-0.5 [&>label]:text-[11px]`}
>
<label>Key</label>
{scope === "project" && <label>Source</label>}
<label>Value</label>
<div />
{scope === "global" && <label className="text-center">Inject</label>}
<div />

{secrets.map((secret, index) => {
Expand Down Expand Up @@ -585,6 +667,18 @@ export const SecretsSection: React.FC = () => {
</button>
)}

{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>
)}

<button
type="button"
onClick={() => removeSecret(index)}
Expand Down
27 changes: 27 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,31 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl

let defaultRuntime: RuntimeEnablementId | null = initialDefaultRuntime ?? null;
let globalSecretsState: Secret[] = [...globalSecrets];
const getInjectedGlobalSecretKeys = (projectPath: string): string[] => {
const normalizedProjectPath = projectPath.trim();
if (!normalizedProjectPath) {
return [];
}

const projectScopedSecrets = projectSecrets.get(normalizedProjectPath) ?? [];
const projectScopedKeys = new Set(projectScopedSecrets.map((secret) => secret.key));

// Match config semantics: for duplicate global keys, the latest entry decides injectAll.
const latestGlobalByKey = new Map<string, Secret>();
for (const secret of globalSecretsState) {
latestGlobalByKey.set(secret.key, secret);
}

const injectedKeys: string[] = [];
for (const [key, secret] of latestGlobalByKey) {
if (secret.injectAll === true && !projectScopedKeys.has(key)) {
injectedKeys.push(key);
}
}

return injectedKeys;
};

const globalMcpServersState: MockMcpServers = { ...globalMcpServers };

let serverAuthSessionsState: ServerAuthSession[] = initialServerAuthSessions.map((session) => ({
Expand Down Expand Up @@ -876,6 +901,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl

return Promise.resolve(globalSecretsState);
},
getInjectedGlobals: (input: { projectPath: string }) =>
Promise.resolve(getInjectedGlobalSecretKeys(input.projectPath)),
update: (input: { projectPath?: string; secrets: Secret[] }) => {
const projectPath = typeof input.projectPath === "string" ? input.projectPath.trim() : "";

Expand Down
9 changes: 9 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,15 @@ export const secrets = {
input: z.object({ projectPath: z.string().optional() }),
output: z.array(SecretSchema),
},
/**
* Read-only list of global secret keys injected into a project via `injectAll`.
*
* Project-owned keys are excluded because local/project secrets always win on collision.
*/
getInjectedGlobals: {
input: z.object({ projectPath: z.string() }).strict(),
output: z.array(z.string()),
},
update: {
input: z.object({
projectPath: z.string().optional(),
Expand Down
1 change: 1 addition & 0 deletions src/common/orpc/schemas/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const SecretSchema = z
.object({
key: z.string(),
value: SecretValueSchema,
injectAll: z.boolean().optional(),
})
.meta({
description: "A key-value pair for storing sensitive configuration",
Expand Down
102 changes: 102 additions & 0 deletions src/node/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,92 @@ describe("Config", () => {
});
});

it("injects global secrets with injectAll into any project's effective secrets", async () => {
await config.updateGlobalSecrets([
{ key: "INJECTED", value: "everywhere", injectAll: true },
{ key: "STORED_ONLY", value: "shared" },
]);

const record = secretsToRecord(config.getEffectiveSecrets("/fake/project"));
expect(record).toEqual({
INJECTED: "everywhere",
});
});

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

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

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

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

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

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

it("returns only globally injected secrets for project settings visibility", async () => {
await config.updateGlobalSecrets([
{ key: "GLOBAL_VISIBLE", value: "v", injectAll: true },
{ key: "GLOBAL_HIDDEN", value: "h" },
{ key: "SHARED", value: "global", injectAll: true },
]);

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

expect(config.getInjectedGlobalSecrets(projectPath)).toEqual([
{ key: "GLOBAL_VISIBLE", value: "v" },
]);
});

it("does not inject global secrets unless injectAll is true", 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("/fake/project"));
expect(record).toEqual({
C: "3",
});
});

it("uses last global duplicate to decide injectAll behavior", async () => {
await config.updateGlobalSecrets([
{ key: "DUP", value: "first", injectAll: true },
{ key: "DUP", value: "second", injectAll: false },
]);

expect(secretsToRecord(config.getEffectiveSecrets("/fake/project"))).toEqual({});

await config.updateGlobalSecrets([
{ key: "DUP", value: "first", injectAll: false },
{ key: "DUP", value: "second", injectAll: true },
]);

expect(secretsToRecord(config.getEffectiveSecrets("/fake/project"))).toEqual({
DUP: "second",
});
});

it('resolves project secret aliases to global secrets via {secret:"KEY"}', async () => {
await config.updateGlobalSecrets([{ key: "GLOBAL_TOKEN", value: "abc" }]);

Expand Down Expand Up @@ -432,5 +518,21 @@ describe("Config", () => {
expect(config.getGlobalSecrets()).toEqual([]);
expect(config.getProjectSecrets("/repo")).toEqual([{ key: "A", value: "1" }]);
});
it("sanitizes malformed injectAll values without dropping valid secrets", () => {
const projectPath = "/repo";
const secretsFile = path.join(tempDir, "secrets.json");
fs.writeFileSync(
secretsFile,
JSON.stringify({
__global__: [{ key: "GLOBAL_TOKEN", value: "abc", injectAll: "true" }],
[projectPath]: [{ key: "TOKEN", value: { secret: "GLOBAL_TOKEN" } }],
})
);

expect(config.getGlobalSecrets()).toEqual([{ key: "GLOBAL_TOKEN", value: "abc" }]);
expect(secretsToRecord(config.getEffectiveSecrets(projectPath))).toEqual({
TOKEN: "abc",
});
});
});
});
Loading
Loading