Skip to content

Commit 7828823

Browse files
committed
fix: harden client validation and secret stdin handling
Validate SDK auth, billing, email, and project inputs before network requests. Stream MCP blob local_path uploads through upload sessions and map local file errors. Support run402 secrets set from stdin sources and document the safer flow.
1 parent 6b4ff3b commit 7828823

22 files changed

Lines changed: 1203 additions & 115 deletions

cli-argv.test.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,65 @@ describe("2026-05 CLI bug backlog argv validation", () => {
578578
assert.ok(call, `expected secrets set request, got ${JSON.stringify(calls)}`);
579579
assert.equal(JSON.parse(call.init.body).value, "");
580580
});
581+
582+
it("GH-336: secrets set --stdin reads the secret from stdin", async () => {
583+
const { readSecretValueForSet } = await import("./cli/lib/secrets.mjs");
584+
const value = readSecretValueForSet(["--stdin"], [], {
585+
readStdin: () => "pipe_secret",
586+
});
587+
588+
assert.equal(value, "pipe_secret");
589+
assert.equal(calls.length, 0, "stdin value resolution must not hit the network");
590+
});
591+
592+
it("GH-336: secrets set --file stdin aliases read stdin", async () => {
593+
const { readSecretValueForSet } = await import("./cli/lib/secrets.mjs");
594+
const seen = [];
595+
const readers = {
596+
readStdin: () => "pipe_secret",
597+
readFile: (path) => {
598+
seen.push(["readFile", path]);
599+
return "file_secret";
600+
},
601+
validateFile: (path) => {
602+
seen.push(["validateFile", path]);
603+
},
604+
};
605+
606+
assert.equal(readSecretValueForSet(["--file", "/dev/stdin"], [], readers), "pipe_secret");
607+
assert.equal(readSecretValueForSet(["--file", "-"], [], readers), "pipe_secret");
608+
assert.deepEqual(seen, []);
609+
assert.equal(calls.length, 0, "stdin value resolution must not hit the network");
610+
});
611+
612+
it("GH-336: secrets set normal --file still validates and reads the file", async () => {
613+
const { readSecretValueForSet } = await import("./cli/lib/secrets.mjs");
614+
const seen = [];
615+
const value = readSecretValueForSet(["--file", "./secret.txt"], [], {
616+
validateFile: (path, flag) => seen.push(["validateFile", path, flag]),
617+
readFile: (path) => {
618+
seen.push(["readFile", path]);
619+
return "file_secret";
620+
},
621+
});
622+
623+
assert.equal(value, "file_secret");
624+
assert.deepEqual(seen, [
625+
["validateFile", "./secret.txt", "--file"],
626+
["readFile", "./secret.txt"],
627+
]);
628+
assert.equal(calls.length, 0, "file value resolution must not hit the network");
629+
});
630+
631+
it("GH-336: secrets set rejects inline values combined with --stdin", async () => {
632+
const { readSecretValueForSet } = await import("./cli/lib/secrets.mjs");
633+
const err = await expectExit1(() =>
634+
readSecretValueForSet(["--stdin"], ["inline"], { readStdin: () => "pipe_secret" }));
635+
636+
assert.equal(err.code, "BAD_USAGE");
637+
assert.match(err.message, /exactly one secret value source/);
638+
assert.equal(calls.length, 0, "invalid value source selection must not hit the network");
639+
});
581640
});
582641

583642
describe("--flag=value", () => {

cli-e2e.test.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2876,6 +2876,7 @@ describe("CLI e2e happy path", () => {
28762876

28772877
it("secrets set", async () => {
28782878
const { run } = await import("./cli/lib/secrets.mjs");
2879+
await seedTestProject();
28792880
captureStart();
28802881
await run("set", ["prj_test123", "TEST_KEY", "secret_value"]);
28812882
captureStop();
@@ -2885,6 +2886,7 @@ describe("CLI e2e happy path", () => {
28852886
it("secrets set --file", async () => {
28862887
const { run } = await import("./cli/lib/secrets.mjs");
28872888
const { writeFileSync: wf } = await import("node:fs");
2889+
await seedTestProject();
28882890
const valPath = join(tempDir, "secret.txt");
28892891
wf(valPath, "file_secret_value");
28902892
captureStart();

cli/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ import { db, adminDb, getUser, email, ai } from "@run402/functions";
136136

137137
```bash
138138
run402 secrets set <id> OPENAI_API_KEY --file ./.secrets/openai-key
139+
printf %s "$OPENAI_API_KEY" | run402 secrets set <id> OPENAI_API_KEY --stdin
139140
run402 secrets list <id>
140141
run402 deploy apply --manifest run402.deploy.json # manifest uses secrets.require, not values
141142
```

cli/lib/secrets.mjs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ Usage:
99
run402 secrets <subcommand> [args...]
1010
1111
Subcommands:
12-
set <id> <key> <value> [--file <path>] Set a secret on a project
12+
set <id> <key> <value> [--file <path>|--stdin] Set a secret on a project
1313
list <id> List all secrets for a project
1414
delete <id> <key> Delete a secret from a project
1515
1616
Examples:
1717
run402 secrets set prj_abc123 STRIPE_KEY --file ./.secrets/stripe-key
18+
printf %s "$STRIPE_KEY" | run402 secrets set prj_abc123 STRIPE_KEY --stdin
1819
run402 secrets set prj_abc123 TLS_CERT --file cert.pem
1920
run402 secrets list prj_abc123
2021
run402 secrets delete prj_abc123 STRIPE_KEY
@@ -31,22 +32,25 @@ const SUB_HELP = {
3132
Usage:
3233
run402 secrets set <id> <key> <value> [--file <path>]
3334
run402 secrets set <id> <key> --file <path>
35+
run402 secrets set <id> <key> --stdin
3436
3537
Arguments:
3638
<id> Project ID (from 'run402 projects list')
3739
<key> Secret key name (exposed as process.env.<key>)
3840
<value> Inline secret value (omit if using --file)
3941
4042
Options:
41-
--file <path> Read the secret value from a file instead of inline
43+
--file <path> Read the secret value from a file instead of inline. Use - or /dev/stdin to read stdin.
44+
--stdin Read the secret value from stdin until EOF
4245
4346
Notes:
4447
- Secrets are injected as process.env in serverless functions
4548
- Values are write-only; 'list' cannot verify values by hash
46-
- Prefer --file for real secrets so values do not land in shell history
49+
- Prefer --file or --stdin for real secrets so values do not land in shell history
4750
4851
Examples:
4952
run402 secrets set prj_abc123 STRIPE_KEY --file ./.secrets/stripe-key
53+
printf %s "$STRIPE_KEY" | run402 secrets set prj_abc123 STRIPE_KEY --stdin
5054
run402 secrets set prj_abc123 TLS_CERT --file cert.pem
5155
`,
5256
list: `run402 secrets list — List all secrets for a project
@@ -77,25 +81,44 @@ Examples:
7781
`,
7882
};
7983

84+
export function readSecretValueForSet(parsedArgs, values, readers = {}) {
85+
const readStdin = readers.readStdin ?? (() => readFileSync(0, "utf-8"));
86+
const readFile = readers.readFile ?? ((path) => readFileSync(path, "utf-8"));
87+
const validateFile = readers.validateFile ?? validateRegularFile;
88+
const file = flagValue(parsedArgs, "--file");
89+
const stdinRequested = parsedArgs.includes("--stdin");
90+
const stdinFile = file === "-" || file === "/dev/stdin";
91+
const valueSources = [
92+
values.length === 1 ? "inline value" : null,
93+
file ? "--file" : null,
94+
stdinRequested ? "--stdin" : null,
95+
].filter(Boolean);
96+
97+
if (valueSources.length > 1) {
98+
fail({ code: "BAD_USAGE", message: `Provide exactly one secret value source, got: ${valueSources.join(", ")}.` });
99+
}
100+
if (file && !stdinFile) validateFile(file, "--file");
101+
102+
if (stdinRequested || stdinFile) return readStdin();
103+
if (file) return readFile(file);
104+
if (values.length === 1) return values[0];
105+
return undefined;
106+
}
107+
80108
async function set(projectId, key, args = []) {
81109
const parsedArgs = normalizeArgv(args);
82110
const valueFlags = ["--file"];
83-
assertKnownFlags(parsedArgs, [...valueFlags, "--help", "-h"], valueFlags);
111+
assertKnownFlags(parsedArgs, [...valueFlags, "--stdin", "--help", "-h"], valueFlags);
84112
const values = positionalArgs(parsedArgs, valueFlags);
85113
if (values.length > 1) {
86114
fail({ code: "BAD_USAGE", message: `Unexpected argument for secrets set: ${values[1]}` });
87115
}
88-
const file = flagValue(parsedArgs, "--file");
89-
if (file && values.length > 0) {
90-
fail({ code: "BAD_USAGE", message: "Provide either an inline value or --file, not both." });
91-
}
92-
if (file) validateRegularFile(file, "--file");
93-
const val = file ? readFileSync(file, "utf-8") : values.length === 1 ? values[0] : undefined;
116+
const val = readSecretValueForSet(parsedArgs, values);
94117
if (val === undefined) {
95118
fail({
96119
code: "BAD_USAGE",
97120
message: "Missing secret value.",
98-
hint: "Provide inline or use --file <path>",
121+
hint: "Provide inline, use --file <path>, or pipe with --stdin.",
99122
});
100123
}
101124
try {

cli/llms-cli.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,9 +753,9 @@ Deploying a scheduled function beyond your tier's limit returns a 403 error. Upg
753753
Secrets available as `process.env` (see secrets below).
754754

755755
### secrets
756-
Injected as `process.env` in functions. Values are write-only — `list` returns keys and timestamps only, never values or value-derived hashes. Prefer `--file` for real values so they do not land in shell history.
756+
Injected as `process.env` in functions. Values are write-only — `list` returns keys and timestamps only, never values or value-derived hashes. Prefer `--file` or `--stdin` for real values so they do not land in shell history. `--file -` and `--file /dev/stdin` read stdin too.
757757

758-
- `run402 secrets set <id> <KEY> [<VALUE>] [--file <path>]`
758+
- `run402 secrets set <id> <KEY> [<VALUE>] [--file <path>|--stdin]`
759759
- `run402 secrets <list|delete> <id> [<KEY>]`
760760

761761
### blob (primary storage API)

openclaw/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,7 @@ Injected as `process.env.<KEY>` inside every function. Values are write-only —
490490

491491
```bash
492492
run402 secrets set <id> STRIPE_KEY --file ./.secrets/stripe-key
493+
printf %s "$STRIPE_KEY" | run402 secrets set <id> STRIPE_KEY --stdin
493494
run402 secrets set <id> JWT_PRIVATE --file ./private.pem
494495
run402 secrets list <id>
495496
run402 secrets delete <id> STALE_KEY

sdk/src/namespaces/auth.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import assert from "node:assert/strict";
1111
import { Run402 } from "../index.js";
1212
import { LocalError, ProjectNotFound } from "../errors.js";
1313
import type { CredentialsProvider } from "../credentials.js";
14+
import type {
15+
CreateAuthUserOptions,
16+
MagicLinkOptions,
17+
SetPasswordOptions,
18+
} from "./auth.js";
1419

1520
interface FetchCall {
1621
url: string;
@@ -129,6 +134,22 @@ describe("auth.settings", () => {
129134
assert.equal(calls.length, 0);
130135
});
131136

137+
it("rejects unknown settings keys before requesting", async () => {
138+
const { fetch, calls } = mockFetch(() => {
139+
throw new Error("unexpected fetch for unknown auth setting");
140+
});
141+
const sdk = makeSdk(makeCreds(), fetch);
142+
143+
await assert.rejects(
144+
sdk.auth.settings("prj_known", { allow_passwrod_login: true } as any),
145+
(err: unknown) =>
146+
err instanceof LocalError &&
147+
err.context === "updating auth settings" &&
148+
/Unknown auth settings field: allow_passwrod_login/.test(err.message),
149+
);
150+
assert.equal(calls.length, 0);
151+
});
152+
132153
it("throws ProjectNotFound for unknown ids before hitting the network", async () => {
133154
const { fetch, calls } = mockFetch(() => jsonResponse({}));
134155
const sdk = makeSdk(makeCreds(), fetch);
@@ -211,6 +232,28 @@ describe("auth.requestMagicLink", () => {
211232
);
212233
assert.equal(calls.length, 0);
213234
});
235+
236+
it("rejects malformed email, redirect URL, and intent before requesting", async () => {
237+
const invalid: MagicLinkOptions[] = [
238+
{ email: "not an email", redirectUrl: "https://app.example.com/callback" },
239+
{ email: "user@example.com", redirectUrl: "javascript:alert(1)" },
240+
{ email: "user@example.com", redirectUrl: "https://app.example.com/callback", intent: "bogus" as any },
241+
];
242+
243+
for (const opts of invalid) {
244+
const { fetch, calls } = mockFetch(() => {
245+
throw new Error("unexpected fetch for invalid magic-link options");
246+
});
247+
const sdk = makeSdk(makeCreds(), fetch);
248+
await assert.rejects(
249+
sdk.auth.requestMagicLink("prj_known", opts),
250+
(err: unknown) =>
251+
err instanceof LocalError &&
252+
err.context === "requesting magic link",
253+
);
254+
assert.equal(calls.length, 0);
255+
}
256+
});
214257
});
215258

216259
describe("auth.createUser", () => {
@@ -245,6 +288,43 @@ describe("auth.createUser", () => {
245288
client_state: "state-1",
246289
});
247290
});
291+
292+
it("rejects malformed email and redirect URL before requesting", async () => {
293+
const invalid: CreateAuthUserOptions[] = [
294+
{ email: "not an email" },
295+
{ email: "admin@example.com", redirectUrl: "javascript:alert(1)" },
296+
];
297+
298+
for (const opts of invalid) {
299+
const { fetch, calls } = mockFetch(() => {
300+
throw new Error("unexpected fetch for invalid admin user options");
301+
});
302+
const sdk = makeSdk(makeCreds(), fetch);
303+
await assert.rejects(
304+
sdk.auth.createUser("prj_known", opts),
305+
(err: unknown) =>
306+
err instanceof LocalError &&
307+
err.context === "creating auth user",
308+
);
309+
assert.equal(calls.length, 0);
310+
}
311+
});
312+
313+
it("inviteUser reuses createUser validation", async () => {
314+
const { fetch, calls } = mockFetch(() => {
315+
throw new Error("unexpected fetch for invalid invite user options");
316+
});
317+
const sdk = makeSdk(makeCreds(), fetch);
318+
319+
await assert.rejects(
320+
sdk.auth.inviteUser("prj_known", {
321+
email: "bad",
322+
redirectUrl: "javascript:alert(1)",
323+
}),
324+
LocalError,
325+
);
326+
assert.equal(calls.length, 0);
327+
});
248328
});
249329

250330
describe("auth.passkeys", () => {
@@ -375,4 +455,46 @@ describe("auth.setUserPassword", () => {
375455
);
376456
assert.equal(calls.length, 0);
377457
});
458+
459+
it("rejects empty access token, new password, and current password before requesting", async () => {
460+
const invalid: SetPasswordOptions[] = [
461+
{ accessToken: "", newPassword: "new-password" },
462+
{ accessToken: "user_jwt", newPassword: "" },
463+
{ accessToken: "user_jwt", newPassword: "new-password", currentPassword: "" },
464+
];
465+
466+
for (const opts of invalid) {
467+
const { fetch, calls } = mockFetch(() => {
468+
throw new Error("unexpected fetch for invalid password options");
469+
});
470+
const sdk = makeSdk(makeCreds(), fetch);
471+
await assert.rejects(
472+
sdk.auth.setUserPassword("prj_known", opts),
473+
(err: unknown) =>
474+
err instanceof LocalError &&
475+
err.context === "setting user password",
476+
);
477+
assert.equal(calls.length, 0);
478+
}
479+
});
480+
});
481+
482+
describe("auth.verifyMagicLink", () => {
483+
it("rejects empty or non-string tokens before requesting", async () => {
484+
const invalid = ["", null, 42];
485+
486+
for (const token of invalid) {
487+
const { fetch, calls } = mockFetch(() => {
488+
throw new Error("unexpected fetch for invalid magic-link token");
489+
});
490+
const sdk = makeSdk(makeCreds(), fetch);
491+
await assert.rejects(
492+
sdk.auth.verifyMagicLink("prj_known", token as any),
493+
(err: unknown) =>
494+
err instanceof LocalError &&
495+
err.context === "verifying magic link",
496+
);
497+
assert.equal(calls.length, 0);
498+
}
499+
});
378500
});

0 commit comments

Comments
 (0)