mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
19782f4e1a
## Why
A credential could only reach a typing tool through an
`ARGENT_SECRET_<NAME>` environment variable. That is the right CI
contract — and stays the highest-priority source — but interactively it
is poor ergonomics: an editor-hosted tool-server inherits the
environment its editor was launched with, so changing a credential means
restarting the editor, and there is nowhere to write one down that
survives a reboot. "Is loading these from a `.env` file on the roadmap?"
is the recurring ask.
## What
`{{secret:NAME}}` now resolves through an ordered chain, first match
wins, re-read on every resolution — so editing a file applies to the
next keystroke with **no server restart** (the same runtime-read design
`ios.additionalDeviceSets` already uses).
| # | Source | Which keys it exposes |
| - | ------ | --------------------- |
| 1 | `ARGENT_SECRET_<NAME>` in the environment | prefixed variables
only — unchanged, still wins |
| 2 | `<project>/.argent/secrets.env` | every key (gitignore it) |
| 3 | `<project>/.env.local`, then `<project>/.env` | **only
`ARGENT_SECRET_`-prefixed keys** |
| 4 | `~/.argent/secrets.env` | every key, any project |
Covers `keyboard`, `paste`, `run-sequence` keyboard steps, and flow
`type:` steps — all four already funnel through the same resolution
point, so nothing else changed.
Also adds `argent secrets [list] [--json]` so a setup is verifiable
without an agent: names only, never values, shadowed names marked, and a
warning when a linked remote tool-server means resolution happens on a
different machine.
## The one design decision worth reviewing
**Row 3 is why this isn't just "read the `.env`".** An agent composes
the tool call, so anything reachable through `{{secret:…}}` is
exfiltratable by a prompt-injected one — type it into a field, then read
the field back. "Which values may argent type" has to stay an allowlist
the user wrote deliberately.
So: a *dedicated* argent secrets file exists for no other purpose and
therefore **is** the allowlist; a file the app *shares* keeps the prefix
as the allowlist, exactly as the process environment does. Your
`STRIPE_SECRET_KEY` sitting in the same `.env` stays unreachable —
verified live, not just asserted. Adding `ARGENT_SECRET_` to one line is
the deliberate act that exposes it.
Two smaller calls, both reversible:
- The chain lives in `@argent/configuration-core`, not the tool-server,
so the CLI reads the same code; the tool-server keeps only the
placeholder engine.
- The project scope is discovered by the **cwd walk-up every other
project-scoped configuration uses**. A per-request project root (header
→ `ToolContext`) was considered and rejected as new protocol surface
across two client paths; reusing flows' module-global
`setActiveProjectRoot` was rejected as a cross-project leak on a reused
server. The cost is that a tool-server whose cwd sits outside the
project skips project files — mitigated by the failure now listing every
path it consulted, and by `~/.argent/secrets.env` needing no project at
all.
## Verification
Unit + integration: tool-server 3122, configuration-core 122 (19 new),
argent-cli 370 (6 new). Lint and prettier clean. All four publish
bundles build.
Live, driving this branch's tool-server on :3001 against iPhone 17 Pro /
iOS 26.4 and the Electron testbed:
- global `~/.argent/secrets.env` → typed correctly; the response echoed
only the placeholder
- prefixed key in the project `.env` → typed; **unprefixed
`STRIPE_SECRET_KEY` in that same file → rejected**
- `.argent/secrets.env` beat `.env`; the environment variable beat both
- edited a secrets file mid-session → applied on the very next call,
same server pid
- a chromium flow with `type: { text: "{{secret:APP_PASSWORD}}" }` → 3/3
pass, asserting on the greeting the app rendered, so the *correct value*
provably landed
- an undefined name inside a flow surfaces the full source list in the
step report
Sample of the failure output (no value ever appears):
```
Unknown secret "LOGIN_PASSWORD" — no source on the machine running the tool-server defines
LOGIN_PASSWORD. Available secrets: APP_USER.
Sources consulted, first match wins:
1. environment (ARGENT_SECRET_*) — 0 secrets
2. /path/to/project/.argent/secrets.env — found, 1 secret
3. /path/to/project/.env.local — not found
4. /path/to/project/.env — found, 1 secret
5. /Users/me/.argent/secrets.env — not found
To make it available, ask the user to add `LOGIN_PASSWORD=…` to …
```
## Out of scope
No general flow variable/parameter system — a non-secret external value
(a test account's email, a staging tenant id) can use the same
placeholder, but argent treats every such value as a secret: redacted
from errors, never echoed, so it is not for something a report should
show. No config key pointing at an arbitrary secrets file. Remote
(`argent link`) still resolves on the remote host, which the CLI now
says out loud.
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import {
|
|
secretSources,
|
|
lookupSecret,
|
|
secretNames,
|
|
describeSecretSources,
|
|
secretPlacementAdvice,
|
|
type SecretSourceOptions,
|
|
} from "../src/secrets.js";
|
|
|
|
// Sandbox both scopes, as config-access.test.ts does: `homeDir` for the global
|
|
// `~/.argent/secrets.env`, `cwd` for the project root (a tmp dir seeded with a
|
|
// `.git` marker so the project-root walk-up stops there). Without this the
|
|
// developer's real files would decide the result.
|
|
let homeDir: string;
|
|
let projectDir: string;
|
|
let options: SecretSourceOptions;
|
|
|
|
/** Environment with nothing exposed, so file behavior is what is under test. */
|
|
const NO_ENV: NodeJS.ProcessEnv = { PATH: "/bin" };
|
|
|
|
function write(filePath: string, content: string): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content);
|
|
}
|
|
|
|
const projectSecrets = () => path.join(projectDir, ".argent", "secrets.env");
|
|
const globalSecrets = () => path.join(homeDir, ".argent", "secrets.env");
|
|
|
|
/** Resolve one name through the whole chain. */
|
|
function get(name: string, env: NodeJS.ProcessEnv = NO_ENV): string | undefined {
|
|
return lookupSecret(name, secretSources({ ...options, env }));
|
|
}
|
|
|
|
function names(env: NodeJS.ProcessEnv = NO_ENV): string[] {
|
|
return secretNames(secretSources({ ...options, env }));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-home-")));
|
|
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-project-")));
|
|
fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true });
|
|
options = { cwd: projectDir, homeDir };
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("environment source", () => {
|
|
it("exposes prefixed variables, without the prefix", () => {
|
|
expect(get("APP_PASSWORD", { ARGENT_SECRET_APP_PASSWORD: "hunter2" })).toBe("hunter2");
|
|
});
|
|
|
|
it("ignores everything unprefixed", () => {
|
|
expect(get("APP_PASSWORD", { APP_PASSWORD: "hunter2" })).toBeUndefined();
|
|
expect(names({ GITHUB_TOKEN: "ghp_x" })).toEqual([]);
|
|
});
|
|
|
|
it("exposes an empty value as a defined (empty) secret", () => {
|
|
expect(get("BLANK", { ARGENT_SECRET_BLANK: "" })).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("dedicated secrets files", () => {
|
|
it("exposes every key in the project file", () => {
|
|
write(projectSecrets(), "APP_PASSWORD=from-project\nAPP_USER=tester\n");
|
|
expect(get("APP_PASSWORD")).toBe("from-project");
|
|
expect(names()).toEqual(["APP_PASSWORD", "APP_USER"]);
|
|
});
|
|
|
|
it("exposes every key in the global file, which needs no project", () => {
|
|
write(globalSecrets(), "APP_PASSWORD=from-home\n");
|
|
const detached = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-detached-")));
|
|
expect(
|
|
lookupSecret("APP_PASSWORD", secretSources({ cwd: detached, homeDir, env: NO_ENV }))
|
|
).toBe("from-home");
|
|
fs.rmSync(detached, { recursive: true, force: true });
|
|
});
|
|
|
|
it("accepts a redundant ARGENT_SECRET_ prefix on a key", () => {
|
|
write(projectSecrets(), "ARGENT_SECRET_APP_PASSWORD=prefixed\n");
|
|
expect(get("APP_PASSWORD")).toBe("prefixed");
|
|
});
|
|
|
|
it("honors the dotenv grammar", () => {
|
|
write(
|
|
projectSecrets(),
|
|
["export EXPORTED=exported", 'QUOTED="pa ss=word#1"', "TRAILING=x # comment"].join("\n")
|
|
);
|
|
expect(get("EXPORTED")).toBe("exported");
|
|
expect(get("QUOTED")).toBe("pa ss=word#1");
|
|
expect(get("TRAILING")).toBe("x");
|
|
});
|
|
|
|
it("drops keys no placeholder could reference", () => {
|
|
write(projectSecrets(), "with.dot=x\nwith-dash=y\nOK=z\n");
|
|
expect(names()).toEqual(["OK"]);
|
|
});
|
|
});
|
|
|
|
describe("files the app shares", () => {
|
|
it("exposes only ARGENT_SECRET_-prefixed keys from .env", () => {
|
|
write(
|
|
path.join(projectDir, ".env"),
|
|
"STRIPE_SECRET_KEY=sk_live_x\nARGENT_SECRET_APP_PASSWORD=from-dotenv\n"
|
|
);
|
|
expect(get("APP_PASSWORD")).toBe("from-dotenv");
|
|
// The app's own config in the same file is not reachable through a
|
|
// placeholder — the prefix is the allowlist, exactly as in the environment.
|
|
expect(get("STRIPE_SECRET_KEY")).toBeUndefined();
|
|
expect(names()).toEqual(["APP_PASSWORD"]);
|
|
});
|
|
|
|
it("reports a shared file that exists but exposes nothing", () => {
|
|
write(path.join(projectDir, ".env"), "STRIPE_SECRET_KEY=sk_live_x\n");
|
|
const sources = secretSources({ ...options, env: NO_ENV });
|
|
const dotenv = sources.find((s) => s.label === path.join(projectDir, ".env"))!;
|
|
expect(dotenv.present).toBe(true);
|
|
expect(dotenv.needsPrefix).toBe(true);
|
|
expect(describeSecretSources(sources)).toContain("only prefixed keys are exposed");
|
|
});
|
|
});
|
|
|
|
describe("precedence", () => {
|
|
it("resolves nearest-first: env, .argent/secrets.env, .env.local, .env, then home", () => {
|
|
write(globalSecrets(), "NAME=home\n");
|
|
expect(get("NAME")).toBe("home");
|
|
|
|
write(path.join(projectDir, ".env"), "ARGENT_SECRET_NAME=dotenv\n");
|
|
expect(get("NAME")).toBe("dotenv");
|
|
|
|
write(path.join(projectDir, ".env.local"), "ARGENT_SECRET_NAME=dotenv-local\n");
|
|
expect(get("NAME")).toBe("dotenv-local");
|
|
|
|
write(projectSecrets(), "NAME=project\n");
|
|
expect(get("NAME")).toBe("project");
|
|
|
|
expect(get("NAME", { ARGENT_SECRET_NAME: "env" })).toBe("env");
|
|
});
|
|
|
|
it("falls through a source that does not define the name", () => {
|
|
write(projectSecrets(), "OTHER=x\n");
|
|
write(globalSecrets(), "APP_PASSWORD=from-home\n");
|
|
expect(get("APP_PASSWORD")).toBe("from-home");
|
|
});
|
|
|
|
it("unions names across sources, sorted and deduplicated", () => {
|
|
write(projectSecrets(), "B=1\nA=2\n");
|
|
write(globalSecrets(), "A=3\nC=4\n");
|
|
expect(names({ ARGENT_SECRET_D: "5" })).toEqual(["A", "B", "C", "D"]);
|
|
});
|
|
});
|
|
|
|
describe("degradation", () => {
|
|
it("treats a missing file as an absent source", () => {
|
|
const sources = secretSources({ ...options, env: NO_ENV });
|
|
const project = sources.find((s) => s.label === projectSecrets())!;
|
|
expect(project.present).toBe(false);
|
|
expect(project.names).toEqual([]);
|
|
expect(describeSecretSources(sources)).toContain("not found");
|
|
});
|
|
|
|
it("survives a secrets file that is a directory", () => {
|
|
fs.mkdirSync(projectSecrets(), { recursive: true });
|
|
write(globalSecrets(), "APP_PASSWORD=from-home\n");
|
|
expect(get("APP_PASSWORD")).toBe("from-home");
|
|
});
|
|
|
|
it("skips project sources when the cwd is not inside a project", () => {
|
|
const detached = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-detached-")));
|
|
const sources = secretSources({ cwd: detached, homeDir, env: NO_ENV });
|
|
expect(sources.map((s) => s.label)).toEqual(["environment (ARGENT_SECRET_*)", globalSecrets()]);
|
|
fs.rmSync(detached, { recursive: true, force: true });
|
|
});
|
|
|
|
it("lists a file shared by both scopes once", () => {
|
|
// A project rooted at the home directory: its `.argent/secrets.env` and the
|
|
// global one are the same file.
|
|
fs.mkdirSync(path.join(homeDir, ".git"), { recursive: true });
|
|
write(globalSecrets(), "APP_PASSWORD=shared\n");
|
|
const sources = secretSources({ cwd: homeDir, homeDir, env: NO_ENV });
|
|
expect(sources.filter((s) => s.label === globalSecrets())).toHaveLength(1);
|
|
expect(lookupSecret("APP_PASSWORD", sources)).toBe("shared");
|
|
});
|
|
});
|
|
|
|
describe("diagnostics", () => {
|
|
it("describes each source without disclosing a value", () => {
|
|
write(projectSecrets(), "APP_PASSWORD=hunter2\n");
|
|
const description = describeSecretSources(secretSources({ ...options, env: NO_ENV }));
|
|
expect(description).toContain(`${projectSecrets()} — found, 1 secret`);
|
|
expect(description).toContain(`${globalSecrets()} — not found`);
|
|
expect(description).not.toContain("hunter2");
|
|
});
|
|
|
|
it("advises the global file first, since it needs no project discovery", () => {
|
|
const advice = secretPlacementAdvice("APP_PASSWORD", options);
|
|
expect(advice).toContain(globalSecrets());
|
|
expect(advice).toContain("ARGENT_SECRET_APP_PASSWORD");
|
|
expect(advice).toContain("never ask the user for the secret value itself");
|
|
});
|
|
});
|