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.
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { secrets } from "../src/secrets.js";
|
|
|
|
// Real-filesystem integration test, mirroring config-command.test.ts: drives the
|
|
// actual `secrets()` entry point against a sandboxed global home (HOME) and
|
|
// project cwd (a tmp dir with a `.git` marker so the project root resolves
|
|
// there). The point of this command is that it never prints a value, so most
|
|
// cases assert on what the output does NOT contain.
|
|
|
|
// A linked remote tool-server would add a caveat line; the routing lookup reads
|
|
// real state on the machine running the tests, so pin it to "not linked".
|
|
vi.mock("@argent/tools-client", () => ({
|
|
getResolvedToolsUrl: async () => ({ url: null, token: null }),
|
|
}));
|
|
|
|
let homeDir: string;
|
|
let projectDir: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
let originalCwd: string;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
class ExitError extends Error {
|
|
constructor(public code: number) {
|
|
super(`process.exit(${code})`);
|
|
}
|
|
}
|
|
|
|
const output = () => logSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n");
|
|
|
|
function write(filePath: string, content: string): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-sec-home-")));
|
|
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-sec-proj-")));
|
|
fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
originalCwd = process.cwd();
|
|
process.env.HOME = homeDir;
|
|
process.env.USERPROFILE = homeDir;
|
|
process.chdir(projectDir);
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new ExitError(code ?? 0);
|
|
}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = originalHome;
|
|
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = originalUserProfile;
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("argent secrets", () => {
|
|
it("lists names per source without printing a value", async () => {
|
|
write(path.join(projectDir, ".argent", "secrets.env"), "APP_PASSWORD=hunter2\n");
|
|
write(path.join(homeDir, ".argent", "secrets.env"), "TOTP_SEED=JBSWY3DP\n");
|
|
|
|
await secrets([]);
|
|
|
|
const out = output();
|
|
expect(out).toContain("APP_PASSWORD");
|
|
expect(out).toContain("TOTP_SEED");
|
|
expect(out).toContain(path.join(projectDir, ".argent", "secrets.env"));
|
|
expect(out).not.toContain("hunter2");
|
|
expect(out).not.toContain("JBSWY3DP");
|
|
});
|
|
|
|
it("marks a name a nearer source already claimed", async () => {
|
|
write(path.join(projectDir, ".argent", "secrets.env"), "APP_USER=project\n");
|
|
write(path.join(projectDir, ".env"), "ARGENT_SECRET_APP_USER=dotenv\n");
|
|
|
|
await secrets([]);
|
|
|
|
const out = output();
|
|
expect(out).toContain("APP_USER (shadowed above)");
|
|
expect(out).toContain("1 name in effect: APP_USER");
|
|
expect(out).not.toContain("dotenv");
|
|
});
|
|
|
|
it("marks sources that are absent, and explains one that needs the prefix", async () => {
|
|
write(path.join(projectDir, ".env"), "STRIPE_SECRET_KEY=sk_live_x\n");
|
|
|
|
await secrets(["list"]);
|
|
|
|
const out = output();
|
|
expect(out).toContain("not found");
|
|
expect(out).toContain("only prefixed keys are exposed");
|
|
expect(out).toContain("No secrets are defined.");
|
|
expect(out).not.toContain("sk_live_x");
|
|
});
|
|
|
|
it("emits machine-readable names and sources with --json", async () => {
|
|
write(path.join(projectDir, ".argent", "secrets.env"), "APP_PASSWORD=hunter2\n");
|
|
|
|
await secrets(["--json"]);
|
|
|
|
const parsed = JSON.parse(output()) as {
|
|
secrets: string[];
|
|
sources: Array<{ source: string; present: boolean; names: string[] }>;
|
|
};
|
|
expect(parsed.secrets).toEqual(["APP_PASSWORD"]);
|
|
expect(parsed.sources.some((s) => s.present && s.names.includes("APP_PASSWORD"))).toBe(true);
|
|
expect(JSON.stringify(parsed)).not.toContain("hunter2");
|
|
});
|
|
|
|
it("documents where to add a secret in --help", async () => {
|
|
await secrets(["--help"]);
|
|
const out = output();
|
|
expect(out).toContain("~/.argent/secrets.env");
|
|
expect(out).toContain("ARGENT_SECRET_");
|
|
expect(out).toContain("gitignore this file");
|
|
});
|
|
|
|
it("rejects an unknown subcommand", async () => {
|
|
await expect(secrets(["frobnicate"])).rejects.toThrow(ExitError);
|
|
expect(errSpy.mock.calls.join(" ")).toContain('unknown subcommand "secrets frobnicate"');
|
|
});
|
|
});
|