@dench.com/cli 3.7.0-staging.387 → 3.8.1-staging.390
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -44
- package/agent-config.ts +5 -55
- package/app-sdk.d.ts +253 -43
- package/campaign.ts +335 -54
- package/chat-spawn.ts +11 -2
- package/chat.ts +3 -5
- package/context-settings.ts +86 -0
- package/cron.ts +19 -0
- package/dench.ts +202 -83
- package/environments.ts +694 -0
- package/lib/api-schemas.ts +190 -20
- package/lib/app-sdk-catalog.generated.ts +34 -20
- package/lib/campaign-steps.ts +474 -12
- package/lib/campaign-timeline.ts +69 -4
- package/lib/command-args.ts +19 -4
- package/lib/command-help.ts +17 -2
- package/lib/command-registry.ts +225 -11
- package/lib/environment-archive.ts +255 -0
- package/lib/environment-cli-args.ts +96 -0
- package/lib/environment-contract.ts +237 -0
- package/lib/environment-execution.ts +769 -0
- package/lib/environment-source.ts +165 -0
- package/lib/load-dev-env.mjs +3 -1
- package/lib/load-dev-env.test.mjs +26 -9
- package/lib/parent-argv.ts +20 -0
- package/lib/references.generated.json +26 -18
- package/package.json +5 -3
- package/workspace-secrets.ts +231 -0
- package/agent.ts +0 -37
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, link, mkdir, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, posix } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
export const MAX_PREPARED_ARCHIVE_BYTES = 128 * 1024 * 1024;
|
|
7
|
+
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
|
8
|
+
const MAX_ARCHIVE_FILES = 40_000;
|
|
9
|
+
|
|
10
|
+
export function sha256(bytes: Uint8Array | string): string {
|
|
11
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
function fail(message: string): never {
|
|
14
|
+
throw new Error(`invalid_environment_artifact: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function fetchEnvironmentBytes(
|
|
18
|
+
url: string,
|
|
19
|
+
limit: number,
|
|
20
|
+
expectedHash?: string,
|
|
21
|
+
): Promise<Buffer> {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
if (
|
|
24
|
+
parsed.protocol !== "https:" &&
|
|
25
|
+
!(
|
|
26
|
+
parsed.protocol === "http:" &&
|
|
27
|
+
["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname)
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
fail("Artifact URL must use HTTPS.");
|
|
31
|
+
const response = await fetch(url, {
|
|
32
|
+
redirect: "error",
|
|
33
|
+
signal: AbortSignal.timeout(120_000),
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok || !response.body)
|
|
36
|
+
fail(
|
|
37
|
+
"Artifact download failed; retry preparation, not the business operation.",
|
|
38
|
+
);
|
|
39
|
+
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
40
|
+
if (declared > limit) fail("Artifact exceeds the download limit.");
|
|
41
|
+
const chunks: Buffer[] = [];
|
|
42
|
+
let size = 0;
|
|
43
|
+
const reader = response.body.getReader();
|
|
44
|
+
try {
|
|
45
|
+
for (;;) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
size += value.byteLength;
|
|
49
|
+
if (size > limit) {
|
|
50
|
+
await reader.cancel();
|
|
51
|
+
fail("Artifact exceeds the download limit.");
|
|
52
|
+
}
|
|
53
|
+
chunks.push(Buffer.from(value));
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
reader.releaseLock();
|
|
57
|
+
}
|
|
58
|
+
const bytes = Buffer.concat(chunks);
|
|
59
|
+
if (expectedHash && sha256(bytes) !== expectedHash)
|
|
60
|
+
fail("Artifact checksum does not match its saved revision.");
|
|
61
|
+
return bytes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tarString(header: Buffer, start: number, length: number): string {
|
|
65
|
+
const bytes = header.subarray(start, start + length);
|
|
66
|
+
return bytes
|
|
67
|
+
.subarray(0, bytes.indexOf(0) < 0 ? bytes.length : bytes.indexOf(0))
|
|
68
|
+
.toString("utf8");
|
|
69
|
+
}
|
|
70
|
+
function tarNumber(header: Buffer, start: number, length: number): number {
|
|
71
|
+
const value = tarString(header, start, length).trim();
|
|
72
|
+
if (value && !/^[0-7]+$/.test(value))
|
|
73
|
+
fail("Unsupported tar numeric encoding.");
|
|
74
|
+
const number = value ? Number.parseInt(value, 8) : 0;
|
|
75
|
+
if (!Number.isSafeInteger(number) || number < 0) fail("Invalid tar size.");
|
|
76
|
+
return number;
|
|
77
|
+
}
|
|
78
|
+
function archivePath(raw: string): string {
|
|
79
|
+
const path = raw.replace(/^(\.\/)+/, "").replace(/\/$/, "");
|
|
80
|
+
if (
|
|
81
|
+
!path ||
|
|
82
|
+
path.length > 1024 ||
|
|
83
|
+
path.startsWith("/") ||
|
|
84
|
+
path.includes("\\") ||
|
|
85
|
+
[...path].some((char) => char.charCodeAt(0) < 32) ||
|
|
86
|
+
path.split("/").some((p) => !p || p === "." || p === "..")
|
|
87
|
+
)
|
|
88
|
+
fail("Archive contains an unsafe path.");
|
|
89
|
+
return path;
|
|
90
|
+
}
|
|
91
|
+
function parsePax(data: Buffer): Record<string, string> {
|
|
92
|
+
const output: Record<string, string> = {};
|
|
93
|
+
let offset = 0;
|
|
94
|
+
while (offset < data.length) {
|
|
95
|
+
const space = data.indexOf(32, offset);
|
|
96
|
+
if (space < 0) fail("Invalid tar metadata.");
|
|
97
|
+
const rawLength = data.subarray(offset, space).toString("ascii");
|
|
98
|
+
const length = Number(rawLength);
|
|
99
|
+
if (
|
|
100
|
+
!/^\d+$/.test(rawLength) ||
|
|
101
|
+
length <= space - offset + 1 ||
|
|
102
|
+
offset + length > data.length
|
|
103
|
+
)
|
|
104
|
+
fail("Invalid tar metadata length.");
|
|
105
|
+
const item = data.subarray(space + 1, offset + length).toString("utf8");
|
|
106
|
+
const equals = item.indexOf("=");
|
|
107
|
+
if (equals < 1 || !item.endsWith("\n")) fail("Invalid tar metadata value.");
|
|
108
|
+
const key = item.slice(0, equals);
|
|
109
|
+
if (key.startsWith("GNU.sparse") || key === "size")
|
|
110
|
+
fail("Sparse or extended-size archives are unsupported.");
|
|
111
|
+
output[key] = item.slice(equals + 1, -1);
|
|
112
|
+
offset += length;
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type Entry = {
|
|
118
|
+
path: string;
|
|
119
|
+
kind: "file" | "dir" | "symlink" | "hardlink";
|
|
120
|
+
mode: number;
|
|
121
|
+
content: Buffer;
|
|
122
|
+
target?: string;
|
|
123
|
+
};
|
|
124
|
+
/** Validate the entire archive before writing anything, including every link ancestor. */
|
|
125
|
+
export function inspectPreparedArchive(compressed: Buffer): Entry[] {
|
|
126
|
+
if (compressed.length > MAX_PREPARED_ARCHIVE_BYTES)
|
|
127
|
+
fail("Compressed archive is too large.");
|
|
128
|
+
const bytes = gunzipSync(compressed, { maxOutputLength: MAX_UNPACKED_BYTES });
|
|
129
|
+
const entries: Entry[] = [];
|
|
130
|
+
const seen = new Map<string, Entry>();
|
|
131
|
+
let pax: Record<string, string> = {};
|
|
132
|
+
let longName: string | undefined;
|
|
133
|
+
let longLink: string | undefined;
|
|
134
|
+
for (let offset = 0; offset + 512 <= bytes.length; ) {
|
|
135
|
+
const header = bytes.subarray(offset, offset + 512);
|
|
136
|
+
if (header.every((value) => value === 0)) break;
|
|
137
|
+
let checksum = 0;
|
|
138
|
+
for (let i = 0; i < 512; i++)
|
|
139
|
+
checksum += i >= 148 && i < 156 ? 32 : header[i];
|
|
140
|
+
if (checksum !== tarNumber(header, 148, 8))
|
|
141
|
+
fail("Invalid tar header checksum.");
|
|
142
|
+
const size = tarNumber(header, 124, 12);
|
|
143
|
+
if (offset + 512 + size > bytes.length) fail("Truncated archive.");
|
|
144
|
+
const content = bytes.subarray(offset + 512, offset + 512 + size);
|
|
145
|
+
offset += 512 + Math.ceil(size / 512) * 512;
|
|
146
|
+
const type = tarString(header, 156, 1);
|
|
147
|
+
if (type === "x") {
|
|
148
|
+
pax = parsePax(content);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (type === "L" || type === "K") {
|
|
152
|
+
const value = content
|
|
153
|
+
.toString("utf8")
|
|
154
|
+
.replace(/\0.*$/, "")
|
|
155
|
+
.replace(/\n$/, "");
|
|
156
|
+
if (type === "L") longName = value;
|
|
157
|
+
else longLink = value;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (type === "g") fail("Global tar metadata is unsupported.");
|
|
161
|
+
const prefix = tarString(header, 345, 155);
|
|
162
|
+
const rawPath =
|
|
163
|
+
pax.path ??
|
|
164
|
+
longName ??
|
|
165
|
+
[prefix, tarString(header, 0, 100)].filter(Boolean).join("/");
|
|
166
|
+
const rawTarget = pax.linkpath ?? longLink ?? tarString(header, 157, 100);
|
|
167
|
+
pax = {};
|
|
168
|
+
longName = undefined;
|
|
169
|
+
longLink = undefined;
|
|
170
|
+
if ((rawPath === "." || rawPath === "./") && type === "5") continue;
|
|
171
|
+
const path = archivePath(rawPath);
|
|
172
|
+
const kind =
|
|
173
|
+
type === "" || type === "0"
|
|
174
|
+
? "file"
|
|
175
|
+
: type === "5"
|
|
176
|
+
? "dir"
|
|
177
|
+
: type === "2"
|
|
178
|
+
? "symlink"
|
|
179
|
+
: type === "1"
|
|
180
|
+
? "hardlink"
|
|
181
|
+
: undefined;
|
|
182
|
+
if (!kind)
|
|
183
|
+
fail("Only files, directories and internal links are supported.");
|
|
184
|
+
if (seen.has(path)) fail("Duplicate archive path.");
|
|
185
|
+
const mode =
|
|
186
|
+
kind === "dir" || tarNumber(header, 100, 8) & 0o111 ? 0o755 : 0o644;
|
|
187
|
+
const entry: Entry = { path, kind, mode, content };
|
|
188
|
+
if (kind === "symlink") {
|
|
189
|
+
if (
|
|
190
|
+
!rawTarget ||
|
|
191
|
+
rawTarget.startsWith("/") ||
|
|
192
|
+
rawTarget.includes("\\") ||
|
|
193
|
+
[...rawTarget].some((char) => char.charCodeAt(0) < 32)
|
|
194
|
+
)
|
|
195
|
+
fail("Archive symlink must stay inside its environment.");
|
|
196
|
+
const destination = posix.normalize(
|
|
197
|
+
posix.join(posix.dirname(path), rawTarget),
|
|
198
|
+
);
|
|
199
|
+
if (destination === ".." || destination.startsWith("../"))
|
|
200
|
+
fail("Archive symlink escapes its environment.");
|
|
201
|
+
entry.target = rawTarget;
|
|
202
|
+
} else if (kind === "hardlink") entry.target = archivePath(rawTarget);
|
|
203
|
+
entries.push(entry);
|
|
204
|
+
seen.set(path, entry);
|
|
205
|
+
if (entries.length > MAX_ARCHIVE_FILES) fail("Archive has too many files.");
|
|
206
|
+
}
|
|
207
|
+
if (pax.path || longName || longLink)
|
|
208
|
+
fail("Archive ended in incomplete metadata.");
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
for (
|
|
211
|
+
let parent = posix.dirname(entry.path);
|
|
212
|
+
parent !== ".";
|
|
213
|
+
parent = posix.dirname(parent)
|
|
214
|
+
) {
|
|
215
|
+
const ancestor = seen.get(parent);
|
|
216
|
+
if (ancestor && ancestor.kind !== "dir")
|
|
217
|
+
fail("Archive path traverses a link or file.");
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
entry.kind === "hardlink" &&
|
|
221
|
+
(!entry.target || seen.get(entry.target)?.kind !== "file")
|
|
222
|
+
)
|
|
223
|
+
fail("Hard links must refer to a regular archive file.");
|
|
224
|
+
}
|
|
225
|
+
return entries;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function extractPreparedArchive(
|
|
229
|
+
compressed: Buffer,
|
|
230
|
+
destination: string,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
const entries = inspectPreparedArchive(compressed);
|
|
233
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
234
|
+
for (const entry of entries.filter(
|
|
235
|
+
(entry) => entry.kind === "dir" || entry.kind === "file",
|
|
236
|
+
)) {
|
|
237
|
+
const path = join(destination, entry.path);
|
|
238
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o755 });
|
|
239
|
+
if (entry.kind === "dir")
|
|
240
|
+
await mkdir(path, { recursive: true, mode: entry.mode });
|
|
241
|
+
else {
|
|
242
|
+
await writeFile(path, entry.content, { mode: entry.mode, flag: "wx" });
|
|
243
|
+
await chmod(path, entry.mode);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const entry of entries.filter(
|
|
247
|
+
(entry) => entry.kind === "symlink" || entry.kind === "hardlink",
|
|
248
|
+
)) {
|
|
249
|
+
const path = join(destination, entry.path);
|
|
250
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o755 });
|
|
251
|
+
if (!entry.target) fail("Archive link has no destination.");
|
|
252
|
+
if (entry.kind === "symlink") await symlink(entry.target, path);
|
|
253
|
+
else await link(join(destination, entry.target), path);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { CliArgError } from "./cli-args";
|
|
2
|
+
import { validateSecretName } from "./environment-contract";
|
|
3
|
+
|
|
4
|
+
/** Parse only this command's options; everything after -- belongs to the child. */
|
|
5
|
+
export function parseEnvironmentOptions(
|
|
6
|
+
input: readonly string[],
|
|
7
|
+
values: readonly string[],
|
|
8
|
+
booleans: readonly string[] = [],
|
|
9
|
+
): {
|
|
10
|
+
positional: string[];
|
|
11
|
+
flags: Record<string, string | boolean>;
|
|
12
|
+
child: string[];
|
|
13
|
+
} {
|
|
14
|
+
const positional: string[] = [];
|
|
15
|
+
const flags: Record<string, string | boolean> = {};
|
|
16
|
+
let child: string[] = [];
|
|
17
|
+
for (let i = 0; i < input.length; i++) {
|
|
18
|
+
const arg = input[i];
|
|
19
|
+
if (arg === "--") {
|
|
20
|
+
child = input.slice(i + 1);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
if (!arg.startsWith("-")) {
|
|
24
|
+
positional.push(arg);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!(values.includes(arg) || booleans.includes(arg))) {
|
|
28
|
+
throw new CliArgError(
|
|
29
|
+
`Unknown flag: ${arg}. Use --help for supported options.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (Object.hasOwn(flags, arg))
|
|
33
|
+
throw new CliArgError(`Duplicate flag: ${arg}`);
|
|
34
|
+
if (booleans.includes(arg)) {
|
|
35
|
+
flags[arg] = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const value = input[++i];
|
|
39
|
+
if (!value || value.startsWith("-"))
|
|
40
|
+
throw new CliArgError(`${arg} requires a value.`);
|
|
41
|
+
flags[arg] = value;
|
|
42
|
+
}
|
|
43
|
+
return { positional, flags, child };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isEnvironmentHelp(args: readonly string[]): boolean {
|
|
47
|
+
const boundary = args.indexOf("--");
|
|
48
|
+
return (
|
|
49
|
+
args
|
|
50
|
+
.slice(0, boundary < 0 ? undefined : boundary)
|
|
51
|
+
.some((arg) => arg === "--help" || arg === "-h") ||
|
|
52
|
+
args[0] === "help" ||
|
|
53
|
+
args.length === 0
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function requireEnvironmentName(value: string | undefined): string {
|
|
58
|
+
if (!value || !/^[a-z][a-z0-9-]{0,63}$/.test(value)) {
|
|
59
|
+
throw new CliArgError(
|
|
60
|
+
"Environment name must use lowercase letters, numbers and hyphens (1–64 characters), beginning with a letter.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function requireSecretName(value: string | undefined): string {
|
|
67
|
+
if (!value || !/^[A-Z][A-Z0-9_]{0,127}$/.test(value)) {
|
|
68
|
+
throw new CliArgError(
|
|
69
|
+
"Secret name must use uppercase letters, numbers and underscores (1–128 characters).",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return validateSecretName(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function integerOption(
|
|
76
|
+
value: string | boolean | undefined,
|
|
77
|
+
name: string,
|
|
78
|
+
fallback: number,
|
|
79
|
+
min: number,
|
|
80
|
+
max: number,
|
|
81
|
+
): number {
|
|
82
|
+
if (value === undefined) return fallback;
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
if (
|
|
85
|
+
typeof value !== "string" ||
|
|
86
|
+
!/^\d+$/.test(value) ||
|
|
87
|
+
!Number.isSafeInteger(parsed) ||
|
|
88
|
+
parsed < min ||
|
|
89
|
+
parsed > max
|
|
90
|
+
) {
|
|
91
|
+
throw new CliArgError(
|
|
92
|
+
`${name} must be an integer between ${min} and ${max}.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/** Shared, side-effect-free saved-environment wire contract. */
|
|
2
|
+
export const ENVIRONMENT_MANIFEST_FILE = "dench-env.json";
|
|
3
|
+
export const MAX_ENVIRONMENT_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
export const MAX_ENVIRONMENT_PREPARED_BYTES = 128 * 1024 * 1024;
|
|
5
|
+
export const MAX_ENVIRONMENT_FILES = 512;
|
|
6
|
+
export const MAX_WORKSPACE_SECRET_BYTES = 64 * 1024;
|
|
7
|
+
export const ENVIRONMENT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
8
|
+
export const SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
9
|
+
|
|
10
|
+
export type EnvironmentManifest = {
|
|
11
|
+
version: 1;
|
|
12
|
+
name: string;
|
|
13
|
+
purpose: string;
|
|
14
|
+
files: string[];
|
|
15
|
+
prepare: string[];
|
|
16
|
+
verify: string[];
|
|
17
|
+
env: Record<string, string>;
|
|
18
|
+
secretEnv: Record<string, string>;
|
|
19
|
+
secretFiles: { path: string; secret: string; mutable?: boolean }[];
|
|
20
|
+
statePaths: string[];
|
|
21
|
+
portable: boolean;
|
|
22
|
+
};
|
|
23
|
+
export type EnvironmentSourceBundle = {
|
|
24
|
+
version: 1;
|
|
25
|
+
files: { path: string; contentBase64: string; mode: 420 | 493 }[];
|
|
26
|
+
};
|
|
27
|
+
export type EnvironmentArtifacts = {
|
|
28
|
+
snapshotId?: string;
|
|
29
|
+
bundleStorageId?: string;
|
|
30
|
+
bundleSha256?: string;
|
|
31
|
+
bundleBytes?: number;
|
|
32
|
+
prefix: string;
|
|
33
|
+
runtimeAbi: string;
|
|
34
|
+
platform: "linux/amd64" | "linux/arm64";
|
|
35
|
+
verifiedAt: number;
|
|
36
|
+
};
|
|
37
|
+
export type EnvironmentRevision = {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
revision: string;
|
|
41
|
+
status: "pending" | "building" | "ready" | "failed";
|
|
42
|
+
manifest: EnvironmentManifest;
|
|
43
|
+
source: { storageId: string; sha256: string; size: number };
|
|
44
|
+
sourcePrefix: string;
|
|
45
|
+
artifacts?: EnvironmentArtifacts;
|
|
46
|
+
errorCode?: string;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function fail(message: string): never {
|
|
51
|
+
throw new Error(`validation_failed: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
export function validateEnvironmentName(value: string): string {
|
|
54
|
+
if (!ENVIRONMENT_NAME_PATTERN.test(value))
|
|
55
|
+
fail("Environment name must be a lowercase slug of at most 64 characters.");
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export function validateSecretName(value: string): string {
|
|
59
|
+
if (!SECRET_NAME_PATTERN.test(value))
|
|
60
|
+
fail("Secret names must use uppercase letters, digits and underscores.");
|
|
61
|
+
if (
|
|
62
|
+
/^(DENCH_|HARNESS_|CONVEX_|VERCEL_|DAYTONA_|NODE_OPTIONS$|NODE_PATH$|LD_|DYLD_|PATH$|HOME$|SHELL$|BASH_ENV$|ENV$|PYTHONPATH$|PYTHONHOME$)/.test(
|
|
63
|
+
value,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
fail("Platform and process-control environment names are reserved.");
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
export function validateEnvironmentPath(value: string): string {
|
|
70
|
+
if (
|
|
71
|
+
!value ||
|
|
72
|
+
value.length > 512 ||
|
|
73
|
+
value.startsWith("/") ||
|
|
74
|
+
value.includes("\\") ||
|
|
75
|
+
[...value].some((character) => character.charCodeAt(0) < 32) ||
|
|
76
|
+
value.split("/").some((part) => !part || part === "." || part === "..")
|
|
77
|
+
)
|
|
78
|
+
fail(
|
|
79
|
+
"Environment paths must be normalized relative paths without traversal.",
|
|
80
|
+
);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
export function isForbiddenEnvironmentSourcePath(value: string): boolean {
|
|
84
|
+
return value
|
|
85
|
+
.split("/")
|
|
86
|
+
.some((part) =>
|
|
87
|
+
/^(\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.ssh|\.aws|\.config|\.git|node_modules|__pycache__|\.venv|credentials(?:\..*)?)$/i.test(
|
|
88
|
+
part,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
94
|
+
fail(`${label} must be an object.`);
|
|
95
|
+
return value as Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
function strings(value: unknown, label: string, max = 64): string[] {
|
|
98
|
+
if (
|
|
99
|
+
!Array.isArray(value) ||
|
|
100
|
+
value.length > max ||
|
|
101
|
+
value.some((x) => typeof x !== "string" || !x || x.length > 8192)
|
|
102
|
+
)
|
|
103
|
+
fail(`${label} must be a bounded list of nonempty strings.`);
|
|
104
|
+
return value as string[];
|
|
105
|
+
}
|
|
106
|
+
export function parseEnvironmentManifest(value: unknown): EnvironmentManifest {
|
|
107
|
+
const input = record(value, "Manifest");
|
|
108
|
+
const keys = new Set([
|
|
109
|
+
"version",
|
|
110
|
+
"name",
|
|
111
|
+
"purpose",
|
|
112
|
+
"files",
|
|
113
|
+
"prepare",
|
|
114
|
+
"verify",
|
|
115
|
+
"env",
|
|
116
|
+
"secretEnv",
|
|
117
|
+
"secretFiles",
|
|
118
|
+
"statePaths",
|
|
119
|
+
"portable",
|
|
120
|
+
]);
|
|
121
|
+
if (Object.keys(input).some((key) => !keys.has(key)))
|
|
122
|
+
fail("Unknown environment manifest field.");
|
|
123
|
+
if (
|
|
124
|
+
input.version !== 1 ||
|
|
125
|
+
typeof input.name !== "string" ||
|
|
126
|
+
typeof input.purpose !== "string" ||
|
|
127
|
+
!input.purpose.trim() ||
|
|
128
|
+
input.purpose.length > 2000
|
|
129
|
+
)
|
|
130
|
+
fail("Manifest requires version 1, name and purpose.");
|
|
131
|
+
validateEnvironmentName(input.name);
|
|
132
|
+
const files = strings(input.files, "files", MAX_ENVIRONMENT_FILES);
|
|
133
|
+
for (const path of files) {
|
|
134
|
+
validateEnvironmentPath(path);
|
|
135
|
+
if (isForbiddenEnvironmentSourcePath(path))
|
|
136
|
+
fail("Credential/cache paths cannot be saved as source files.");
|
|
137
|
+
}
|
|
138
|
+
const map = (raw: unknown, label: string, secret: boolean) => {
|
|
139
|
+
const values = record(raw ?? {}, label);
|
|
140
|
+
if (Object.keys(values).length > 64)
|
|
141
|
+
fail(`${label} has too many bindings.`);
|
|
142
|
+
const result: Record<string, string> = {};
|
|
143
|
+
for (const [key, val] of Object.entries(values)) {
|
|
144
|
+
validateSecretName(key);
|
|
145
|
+
if (
|
|
146
|
+
!secret &&
|
|
147
|
+
/(?:^|_)(?:API_KEY|ACCESS_KEY|PRIVATE_KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS?)(?:_|$)/.test(
|
|
148
|
+
key,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
fail(
|
|
152
|
+
"Credential-looking variables must use secretEnv bindings, never public env values.",
|
|
153
|
+
);
|
|
154
|
+
if (typeof val !== "string" || val.length > 8192)
|
|
155
|
+
fail(`${label} values must be strings of at most 8192 characters.`);
|
|
156
|
+
if (secret) validateSecretName(val);
|
|
157
|
+
result[key] = val;
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
161
|
+
const secretFilesRaw = input.secretFiles ?? [];
|
|
162
|
+
if (!Array.isArray(secretFilesRaw) || secretFilesRaw.length > 32)
|
|
163
|
+
fail("secretFiles must be a bounded list.");
|
|
164
|
+
const secretFiles = secretFilesRaw.map((item) => {
|
|
165
|
+
const binding = record(item, "secretFiles binding");
|
|
166
|
+
if (
|
|
167
|
+
Object.keys(binding).some(
|
|
168
|
+
(key) => !["path", "secret", "mutable"].includes(key),
|
|
169
|
+
) ||
|
|
170
|
+
typeof binding.path !== "string" ||
|
|
171
|
+
typeof binding.secret !== "string" ||
|
|
172
|
+
(binding.mutable !== undefined && typeof binding.mutable !== "boolean")
|
|
173
|
+
)
|
|
174
|
+
fail("Invalid secretFiles binding.");
|
|
175
|
+
return {
|
|
176
|
+
path: validateEnvironmentPath(binding.path as string),
|
|
177
|
+
secret: validateSecretName(binding.secret as string),
|
|
178
|
+
...(binding.mutable !== undefined
|
|
179
|
+
? { mutable: binding.mutable as boolean }
|
|
180
|
+
: {}),
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
const statePaths = strings(input.statePaths ?? [], "statePaths", 32).map(
|
|
184
|
+
validateEnvironmentPath,
|
|
185
|
+
);
|
|
186
|
+
const output: EnvironmentManifest = {
|
|
187
|
+
version: 1,
|
|
188
|
+
name: input.name,
|
|
189
|
+
purpose: input.purpose.trim(),
|
|
190
|
+
files,
|
|
191
|
+
prepare: strings(input.prepare ?? [], "prepare", 32),
|
|
192
|
+
verify: strings(input.verify ?? [], "verify", 32),
|
|
193
|
+
env: map(input.env, "env", false),
|
|
194
|
+
secretEnv: map(input.secretEnv, "secretEnv", true),
|
|
195
|
+
secretFiles,
|
|
196
|
+
statePaths,
|
|
197
|
+
portable: input.portable !== false,
|
|
198
|
+
};
|
|
199
|
+
if (input.portable !== undefined && typeof input.portable !== "boolean")
|
|
200
|
+
fail("portable must be a boolean.");
|
|
201
|
+
if (!output.verify.length)
|
|
202
|
+
fail("At least one verification command is required before publication.");
|
|
203
|
+
if (Object.keys(output.env).some((key) => key in output.secretEnv))
|
|
204
|
+
fail("A variable cannot be both public and secret.");
|
|
205
|
+
const destinations = [
|
|
206
|
+
...secretFiles.map((binding) => binding.path),
|
|
207
|
+
...statePaths,
|
|
208
|
+
];
|
|
209
|
+
if (new Set(destinations).size !== destinations.length)
|
|
210
|
+
fail("Secret/state destinations must be distinct.");
|
|
211
|
+
if (
|
|
212
|
+
destinations.some((path, index) =>
|
|
213
|
+
destinations.some(
|
|
214
|
+
(other, otherIndex) =>
|
|
215
|
+
index !== otherIndex && path.startsWith(`${other}/`),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
fail("Secret and state paths must not contain one another.");
|
|
220
|
+
if (JSON.stringify(output).length > 64 * 1024) fail("Manifest is too large.");
|
|
221
|
+
return output;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function environmentRequiredSecrets(
|
|
225
|
+
manifest: EnvironmentManifest,
|
|
226
|
+
): string[] {
|
|
227
|
+
return [
|
|
228
|
+
...new Set([
|
|
229
|
+
...Object.values(manifest.secretEnv),
|
|
230
|
+
...manifest.secretFiles.map((item) => item.secret),
|
|
231
|
+
]),
|
|
232
|
+
].sort();
|
|
233
|
+
}
|
|
234
|
+
export function environmentPrefix(revision: string): string {
|
|
235
|
+
if (!/^[a-f0-9]{64}$/.test(revision)) fail("Invalid environment revision.");
|
|
236
|
+
return `/opt/dench/environments/${revision}`;
|
|
237
|
+
}
|