Files
software-mansion__argent/packages/argent-cli/test/secrets-command.test.ts
filip131311 19782f4e1a feat(secrets): resolve {{secret:…}} from .env files, not just the environment (#734)
## 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.
2026-08-04 15:23:12 +02:00

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"');
});
});