Skip to content

Commit daaaa41

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(cli): webhooks set-secret and generate-secret commands that never print the secret
Add `trigger webhooks set-secret` and `trigger webhooks generate-secret`, which store a webhook endpoint's signing secret without printing it. `set-secret` reads the secret from a pipe, an environment variable or a `.env` file; `generate-secret` saves the new secret to an env file, or prints only the secret with `--print` for piping. When the endpoint already has a secret, both ask how long the old one keeps working, or take `--previous-secret-ttl` (for example `24h`) in scripts. The MCP `set_webhook_secret` and `generate_webhook_secret` tool descriptions now point AI agents at these commands so secrets stay out of the agent's context. Mono-RevId: 5c50483e3999ee5c0233ff3c69910a107eccc908
1 parent 1a36c23 commit daaaa41

11 files changed

Lines changed: 737 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Add `trigger webhooks set-secret` and `trigger webhooks generate-secret`, which store a webhook endpoint's signing secret without printing it. `set-secret` reads the secret from a pipe, an environment variable or a `.env` file, and `generate-secret` saves the new secret to an env file. When the endpoint already has a secret, both ask how long the old one keeps working (or take `--previous-secret-ttl 24h` in scripts). The MCP server's `set_webhook_secret` and `generate_webhook_secret` descriptions now point AI agents at these commands, so the secret stays out of the agent's context.

‎docs/cli-webhooks-commands.mdx‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
---
2+
title: "CLI webhooks commands"
3+
sidebarTitle: "webhooks"
4+
description: "Set or generate the signing secret of a hosted webhook endpoint from your terminal, without printing it."
5+
---
6+
7+
import CommonOptions from "/snippets/cli-options-common.mdx";
8+
import ConfigFileOptions from "/snippets/cli-options-config-file.mdx";
9+
import ProjectRefOptions from "/snippets/cli-options-project-ref.mdx";
10+
11+
**These commands store a [webhook endpoint's](/webhooks/overview) signing secret without printing it, so the value stays out of your terminal history, logs and AI agent transcripts.** `endpoint` is the endpoint's declared id (the `id` passed to `webhooks.endpoint.define`) or its `wh_` id.
12+
13+
## Replacing an existing secret
14+
15+
When the endpoint already has a secret, both commands ask how long the old one keeps working, with the same choices as the dashboard: stop immediately, 1 hour, 24 hours (the default) or 7 days. In a script or an AI agent, where there's no prompt, pass `--previous-secret-ttl` instead; the command refuses to replace the secret without it, so a rotation can't cut the provider off by accident:
16+
17+
```bash
18+
npx trigger.dev@latest webhooks set-secret stripe --env prod --from-env STRIPE_WEBHOOK_SECRET --previous-secret-ttl 24h
19+
```
20+
21+
## webhooks set-secret
22+
23+
Stores the signing secret or public key a provider issued, such as the `whsec_` secret Stripe returns when you create a webhook endpoint. It replaces the endpoint's current secret.
24+
25+
Pipe the secret in from the provider's CLI or a file:
26+
27+
<CodeGroup>
28+
29+
```bash npm
30+
stripe webhook_endpoints create -d url="$WEBHOOK_URL" -d "enabled_events[]=checkout.session.completed" \
31+
| jq -r .secret \
32+
| npx trigger.dev@latest webhooks set-secret stripe --env prod
33+
```
34+
35+
```bash pnpm
36+
stripe webhook_endpoints create -d url="$WEBHOOK_URL" -d "enabled_events[]=checkout.session.completed" \
37+
| jq -r .secret \
38+
| pnpm dlx trigger.dev@latest webhooks set-secret stripe --env prod
39+
```
40+
41+
```bash bun
42+
stripe webhook_endpoints create -d url="$WEBHOOK_URL" -d "enabled_events[]=checkout.session.completed" \
43+
| jq -r .secret \
44+
| bunx trigger.dev@latest webhooks set-secret stripe --env prod
45+
```
46+
47+
</CodeGroup>
48+
49+
Or read it from an environment variable. Without `--from-file`, the command checks the shell environment, then the project's `.env`, `.env.development`, `.env.local` and `.env.development.local` files:
50+
51+
```bash
52+
npx trigger.dev@latest webhooks set-secret stripe --env prod --from-env STRIPE_WEBHOOK_SECRET
53+
```
54+
55+
Run it in a terminal without either option and it asks for the secret with a masked prompt.
56+
57+
<ParamField body="From env" type="--from-env">
58+
Read the secret from this environment variable.
59+
</ParamField>
60+
61+
<ParamField body="From file" type="--from-file">
62+
With `--from-env`, read the variable from this `.env` file only.
63+
</ParamField>
64+
65+
<ParamField body="Previous secret TTL" type="--previous-secret-ttl">
66+
When the endpoint already has a secret, how long the old one keeps working while the provider switches to the new one: seconds (`3600`) or a duration (`1h`, `24h`, `7d`), at most 7 days. `0` stops it straight away, for a secret that leaked.
67+
</ParamField>
68+
69+
## webhooks generate-secret
70+
71+
Generates a new signing secret, for providers that accept a secret you choose, such as GitHub. It replaces the endpoint's current secret.
72+
73+
Save it to an env file, then hand it to the provider by variable name:
74+
75+
```bash
76+
npx trigger.dev@latest webhooks generate-secret github --env prod --to-env GITHUB_WEBHOOK_SECRET
77+
```
78+
79+
The command writes `GITHUB_WEBHOOK_SECRET` to `.env.local`, creating the file if needed, and prints only where it saved the value. Or pipe it straight into the provider's CLI with `--print`, which outputs the secret and nothing else.
80+
81+
When the output isn't a terminal, the command refuses to print the secret unless you pass `--to-env` or `--print`, so an AI agent running it doesn't capture the value by accident.
82+
83+
<ParamField body="To env" type="--to-env">
84+
Save the secret as this variable in an env file.
85+
</ParamField>
86+
87+
<ParamField body="To file" type="--to-file">
88+
With `--to-env`, the file to save to. Defaults to `.env.local`.
89+
</ParamField>
90+
91+
<ParamField body="Print" type="--print">
92+
Print only the secret, for piping it into another command.
93+
</ParamField>
94+
95+
<ParamField body="Previous secret TTL" type="--previous-secret-ttl">
96+
When the endpoint already has a secret, how long the old one keeps working while the provider switches to the new one: seconds (`3600`) or a duration (`1h`, `24h`, `7d`), at most 7 days. `0` stops it straight away, for a secret that leaked.
97+
</ParamField>
98+
99+
## Options
100+
101+
<ParamField body="Environment" type="--env | -e">
102+
The environment to use: `dev`, `prod`, `staging` or `preview`. Defaults to `prod`.
103+
</ParamField>
104+
105+
<ConfigFileOptions />
106+
<ProjectRefOptions />
107+
<ParamField body="Branch" type="--branch | -b">
108+
The branch to use. Required with `--env preview`.
109+
</ParamField>
110+
111+
### Common options
112+
113+
These options are available on most commands.
114+
115+
<CommonOptions />

‎docs/docs.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@
281281
"cli-switch",
282282
"cli-tasks-commands",
283283
"cli-update-commands",
284+
"cli-webhooks-commands",
284285
"cli-whoami-commands"
285286
]
286287
}

‎docs/webhooks/connect.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ The prompt tells the agent to use the Trigger.dev MCP server (`npx trigger.dev@l
2121
| `generate_webhook_secret` | Generates a signing secret and returns it once, for providers where you choose the secret. Optionally keeps accepting the old secret (`previousSecretTtlSeconds`). |
2222
| `set_webhook_secret` | Stores a secret the provider issued. Optionally keeps accepting the old secret (`previousSecretTtlSeconds`). |
2323

24+
### Keep the secret out of the agent's context
25+
26+
A secret passed to `set_webhook_secret`, or returned by `generate_webhook_secret`, becomes part of the agent's conversation. The [CLI webhooks commands](/cli-webhooks-commands) store the secret without printing it, and the MCP tool descriptions point agents that can run shell commands to them:
27+
28+
```bash
29+
# Store a secret the provider issued, from an env file or a pipe
30+
npx trigger.dev@latest webhooks set-secret stripe --env prod --from-env STRIPE_WEBHOOK_SECRET
31+
32+
# Generate one and save it to .env.local for the agent to pass to the provider by name
33+
npx trigger.dev@latest webhooks generate-secret github --env prod --to-env GITHUB_WEBHOOK_SECRET
34+
```
35+
2436
Without the MCP server, the same operations are available on the REST API with the environment's secret key:
2537

2638
| Request | What it does |

‎packages/cli-v3/src/cli/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { configurePromoteCommand } from "../commands/promote.js";
1313
import { configureRunsCommand } from "../commands/runs/index.js";
1414
import { configureSwitchProfilesCommand } from "../commands/switch.js";
1515
import { configureTasksCommand } from "../commands/tasks/index.js";
16+
import { configureWebhooksCommand } from "../commands/webhooks/index.js";
1617
import { configureUpdateCommand } from "../commands/update.js";
1718
import { configureWhoamiCommand } from "../commands/whoami.js";
1819
import { configureMintTokenCommand } from "../commands/mint-token.js";
@@ -49,6 +50,7 @@ configureProjectsCommand(program);
4950
configureOrgsCommand(program);
5051
configureRunsCommand(program);
5152
configureTasksCommand(program);
53+
configureWebhooksCommand(program);
5254
configureAnalyzeCommand(program);
5355
configureMcpCommand(program);
5456
configureReportCommand(program);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import dotenv from "dotenv";
2+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { prepareEnvFile, readEnvVariable, trimSecret, upsertEnvLine } from "./envFile.js";
7+
import { parseGracePeriod } from "./gracePeriod.js";
8+
9+
describe("upsertEnvLine", () => {
10+
it("appends, adding a missing newline", () => {
11+
expect(upsertEnvLine("", "S", "abc123")).toBe("S=abc123\n");
12+
expect(upsertEnvLine("A=1", "S", "2")).toBe("A=1\nS=2\n");
13+
});
14+
15+
it("replaces an existing value in place without touching a variable that shares its prefix", () => {
16+
expect(upsertEnvLine("A=1\nB=old\nBB=keep\n", "B", "new")).toBe("A=1\nB=new\nBB=keep\n");
17+
expect(upsertEnvLine("export B=old\n", "B", "new")).toBe("B=new\n");
18+
});
19+
20+
it("removes every assignment, so dotenv doesn't keep reading an old duplicate", () => {
21+
const out = upsertEnvLine("S=old1\nA=1\nS=old2\n", "S", "whsec_new");
22+
expect(dotenv.parse(out)).toEqual({ S: "whsec_new", A: "1" });
23+
});
24+
25+
it("removes a quoted multi-line value without leaving its continuation lines behind", () => {
26+
const out = upsertEnvLine('S="old\nOTHER=inside\nend"\nA=1\n', "S", "whsec_new");
27+
expect(dotenv.parse(out)).toEqual({ S: "whsec_new", A: "1" });
28+
});
29+
30+
it("leaves an assignment-shaped line inside another variable's quoted value alone", () => {
31+
expect(
32+
dotenv.parse(upsertEnvLine('OTHER="hello\nS=inside\nworld"\nS=old\n', "S", "new"))
33+
).toEqual({
34+
OTHER: "hello\nS=inside\nworld",
35+
S: "new",
36+
});
37+
expect(dotenv.parse(upsertEnvLine('S=old\nA="before\nS=inside\nafter"\n', "S", "new"))).toEqual(
38+
{
39+
S: "new",
40+
A: "before\nS=inside\nafter",
41+
}
42+
);
43+
});
44+
45+
it("quotes values that dotenv would otherwise read back truncated", () => {
46+
expect(dotenv.parse(upsertEnvLine("", "S", "has space#hash"))).toEqual({ S: "has space#hash" });
47+
});
48+
});
49+
50+
describe("env files on disk", () => {
51+
let dir: string;
52+
53+
beforeEach(async () => {
54+
dir = await mkdtemp(join(tmpdir(), "webhook-secret-"));
55+
});
56+
57+
afterEach(async () => {
58+
delete process.env.TEST_WEBHOOK_SECRET_FROM_ENV;
59+
await rm(dir, { recursive: true, force: true });
60+
});
61+
62+
it("reads the process env first, then .env files with later files winning", async () => {
63+
await writeFile(join(dir, ".env"), "S=base\nTEST_WEBHOOK_SECRET_FROM_ENV=file\n");
64+
await writeFile(join(dir, ".env.local"), "S=local\n");
65+
process.env.TEST_WEBHOOK_SECRET_FROM_ENV = "process";
66+
expect(await readEnvVariable("TEST_WEBHOOK_SECRET_FROM_ENV", undefined, dir)).toBe("process");
67+
expect(await readEnvVariable("S", undefined, dir)).toBe("local");
68+
expect(await readEnvVariable("S", ".env", dir)).toBe("base");
69+
expect(await readEnvVariable("MISSING", undefined, dir)).toBeUndefined();
70+
});
71+
72+
it("rejects a destination in a missing directory before anything is rotated", async () => {
73+
await expect(prepareEnvFile("missing/secret.env", dir)).rejects.toThrow("doesn't exist");
74+
await expect(prepareEnvFile(".env.local", dir)).resolves.toBe(join(dir, ".env.local"));
75+
});
76+
77+
it("rejects a symlinked destination that could point at a tracked or shared file", async () => {
78+
await mkdir(join(dir, "shared"));
79+
await writeFile(join(dir, "shared", "config.env"), "");
80+
await symlink(join(dir, "shared", "config.env"), join(dir, ".env.local"));
81+
await expect(prepareEnvFile(".env.local", dir)).rejects.toThrow("symlink");
82+
});
83+
});
84+
85+
describe("parseGracePeriod", () => {
86+
it("accepts seconds and durations, and refuses anything past the 7 day cap", () => {
87+
expect(parseGracePeriod("0")).toBe(0);
88+
expect(parseGracePeriod("3600")).toBe(3600);
89+
expect(parseGracePeriod("24h")).toBe(86400);
90+
expect(parseGracePeriod("7d")).toBe(604800);
91+
expect(() => parseGracePeriod("8d")).toThrow("up to 7d");
92+
expect(parseGracePeriod("1d12h")).toBe(129600);
93+
expect(() => parseGracePeriod("soon")).toThrow("up to 7d");
94+
expect(() => parseGracePeriod("1h2h")).toThrow("up to 7d");
95+
expect(parseGracePeriod("1hr30m")).toBe(5400);
96+
expect(() => parseGracePeriod("1h1hr")).toThrow("up to 7d");
97+
});
98+
});
99+
100+
describe("trimSecret", () => {
101+
it("drops the newline a pipe adds, so it isn't stored as part of the secret", () => {
102+
expect(trimSecret("whsec_abc\r\n")).toBe("whsec_abc");
103+
});
104+
});

0 commit comments

Comments
 (0)