@dench.com/cli 3.7.0-staging.387 → 3.8.0-staging.389

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.
@@ -0,0 +1,769 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ link,
5
+ lstat,
6
+ mkdir,
7
+ mkdtemp,
8
+ readdir,
9
+ readFile,
10
+ readlink,
11
+ rename,
12
+ rm,
13
+ symlink,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { arch, homedir, hostname, platform, release, tmpdir } from "node:os";
17
+ import { delimiter, dirname, join, resolve } from "node:path";
18
+ import {
19
+ extractPreparedArchive,
20
+ fetchEnvironmentBytes,
21
+ MAX_PREPARED_ARCHIVE_BYTES,
22
+ sha256,
23
+ } from "./environment-archive";
24
+ import type {
25
+ EnvironmentRevision,
26
+ EnvironmentSourceBundle,
27
+ } from "./environment-contract";
28
+ import {
29
+ environmentPrefix,
30
+ MAX_ENVIRONMENT_SOURCE_BYTES,
31
+ parseEnvironmentManifest,
32
+ validateEnvironmentPath,
33
+ } from "./environment-contract";
34
+ import { materializeSource, validateSourceBundle } from "./environment-source";
35
+
36
+ export type ResolvedEnvironment = EnvironmentRevision & {
37
+ organizationId: string;
38
+ sourceUrl: string;
39
+ sourcePrefix: string;
40
+ bundleUrl?: string | null;
41
+ credentialEnv: Record<
42
+ string,
43
+ { value: string; secretName: string; version: number }
44
+ >;
45
+ credentialFiles: {
46
+ path: string;
47
+ value: string;
48
+ secretName: string;
49
+ version: number;
50
+ mutable?: boolean;
51
+ }[];
52
+ state?: {
53
+ key: string;
54
+ version: number;
55
+ url?: string | null;
56
+ sha256?: string;
57
+ prefix?: string;
58
+ };
59
+ };
60
+ export type PreparedEnvironment = {
61
+ prefix: string;
62
+ workspace: string;
63
+ cacheHit: boolean;
64
+ preparation: "snapshot" | "bundle" | "local";
65
+ platform: string;
66
+ };
67
+ const DEPENDENCY_DIRECTORIES = new Set(["node_modules", ".venv", "venv"]);
68
+ const MAX_CAPTURE_BYTES = 256 * 1024;
69
+
70
+ export function currentEnvironmentPlatform(): string {
71
+ return `${platform()}/${arch() === "x64" ? "amd64" : arch()}`;
72
+ }
73
+ export function environmentLocalCachePath(
74
+ environment: ResolvedEnvironment,
75
+ root = join(homedir(), ".cache", "dench", "environments"),
76
+ runtimeAbi = `node${process.versions.node.split(".")[0]}`,
77
+ ): string {
78
+ return join(
79
+ root,
80
+ sha256(environment.organizationId).slice(0, 24),
81
+ environment.revision,
82
+ `${currentEnvironmentPlatform().replace("/", "-")}-${runtimeAbi}`,
83
+ );
84
+ }
85
+
86
+ async function localRuntimeAbi(): Promise<string> {
87
+ const python = await new Promise<string>((done) => {
88
+ execFile(
89
+ "python3",
90
+ [
91
+ "-I",
92
+ "-c",
93
+ "import sys,sysconfig; print(sys.version); print(sys.executable); print(sysconfig.get_platform())",
94
+ ],
95
+ {
96
+ timeout: 3000,
97
+ env: { PATH: process.env.PATH, HOME: tmpdir(), NODE_ENV: "production" },
98
+ maxBuffer: 8192,
99
+ },
100
+ (error, stdout) => done(error ? "unavailable" : stdout.trim()),
101
+ );
102
+ });
103
+ return `local-${sha256(JSON.stringify({ node: process.versions.modules, platform: currentEnvironmentPlatform(), os: release(), python })).slice(0, 24)}`;
104
+ }
105
+ async function exists(path: string) {
106
+ try {
107
+ await lstat(path);
108
+ return true;
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ /** A job-local filesystem lock, only for mutable setup state and credential files. */
116
+ export async function acquireEnvironmentWorkspaceLock(
117
+ workspace: string,
118
+ purpose: "execution" | "seed" | "prepare" = "execution",
119
+ ): Promise<() => Promise<void>> {
120
+ const lock = `${resolve(workspace)}.dench-environment-${purpose}-lock`;
121
+ await mkdir(dirname(lock), { recursive: true, mode: 0o700 });
122
+ const owner = { pid: process.pid, host: hostname(), token: randomUUID() };
123
+ const candidate = `${lock}.${owner.token}`;
124
+ await writeFile(candidate, JSON.stringify(owner), {
125
+ flag: "wx",
126
+ mode: 0o600,
127
+ });
128
+ try {
129
+ for (;;) {
130
+ try {
131
+ // Publish a fully written owner record atomically; crashes cannot leave an
132
+ // empty lock with no process identity to reconcile.
133
+ await link(candidate, lock);
134
+ return async () => {
135
+ try {
136
+ const current = JSON.parse(await readFile(lock, "utf8"));
137
+ if (current.token === owner.token) await rm(lock, { force: true });
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
140
+ }
141
+ };
142
+ } catch (error) {
143
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
144
+ // PID liveness, never wall-clock age: a legitimate command may run 12h+.
145
+ try {
146
+ const before = await lstat(lock);
147
+ if (!before.isFile() || before.isSymbolicLink())
148
+ throw new Error(
149
+ "environment_workspace_lock_invalid: Inspect the workspace lock before continuing.",
150
+ );
151
+ const prior = JSON.parse(await readFile(lock, "utf8"));
152
+ if (
153
+ prior.host === owner.host &&
154
+ Number.isSafeInteger(prior.pid) &&
155
+ prior.pid > 0
156
+ ) {
157
+ try {
158
+ process.kill(prior.pid, 0);
159
+ } catch (failure) {
160
+ if (
161
+ (failure as NodeJS.ErrnoException).code === "ESRCH" &&
162
+ (await lstat(lock)).ino === before.ino
163
+ )
164
+ await rm(lock, { force: true });
165
+ }
166
+ }
167
+ } catch (failure) {
168
+ if ((failure as NodeJS.ErrnoException).code === "ENOENT") continue;
169
+ throw new Error(
170
+ "environment_workspace_lock_invalid: Inspect the workspace lock before continuing.",
171
+ );
172
+ }
173
+ await new Promise((done) => setTimeout(done, 250));
174
+ }
175
+ }
176
+ } finally {
177
+ await rm(candidate, { force: true });
178
+ }
179
+ }
180
+
181
+ async function seedPreparedEntry(
182
+ source: string,
183
+ target: string,
184
+ ): Promise<void> {
185
+ const sourceStat = await lstat(source);
186
+ let targetStat: Awaited<ReturnType<typeof lstat>> | undefined;
187
+ try {
188
+ targetStat = await lstat(target);
189
+ } catch (error) {
190
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
191
+ }
192
+ const conflict = () => {
193
+ throw new Error(
194
+ "environment_workspace_conflict: Saved setup conflicts with an existing workspace file; existing files were preserved.",
195
+ );
196
+ };
197
+ if (sourceStat.isDirectory()) {
198
+ if (
199
+ targetStat &&
200
+ (!targetStat.isDirectory() || targetStat.isSymbolicLink())
201
+ )
202
+ conflict();
203
+ await mkdir(target, { recursive: true, mode: sourceStat.mode & 0o777 });
204
+ for (const entry of await readdir(source))
205
+ await seedPreparedEntry(join(source, entry), join(target, entry));
206
+ } else if (sourceStat.isSymbolicLink()) {
207
+ const destination = await readlink(source);
208
+ if (targetStat) {
209
+ if (
210
+ !targetStat.isSymbolicLink() ||
211
+ (await readlink(target)) !== destination
212
+ )
213
+ conflict();
214
+ } else await symlink(destination, target);
215
+ } else if (sourceStat.isFile()) {
216
+ const data = await readFile(source);
217
+ if (targetStat) {
218
+ if (
219
+ !targetStat.isFile() ||
220
+ sha256(await readFile(target)) !== sha256(data)
221
+ )
222
+ conflict();
223
+ return;
224
+ }
225
+ const temporary = `${target}.dench-seed-${randomUUID()}`;
226
+ try {
227
+ await writeFile(temporary, data, {
228
+ mode: sourceStat.mode & 0o777,
229
+ flag: "wx",
230
+ });
231
+ await link(temporary, target);
232
+ } finally {
233
+ await rm(temporary, { force: true });
234
+ }
235
+ } else conflict();
236
+ }
237
+ async function sourceFor(
238
+ environment: ResolvedEnvironment,
239
+ ): Promise<EnvironmentSourceBundle> {
240
+ const bytes = await fetchEnvironmentBytes(
241
+ environment.sourceUrl,
242
+ MAX_ENVIRONMENT_SOURCE_BYTES + 512,
243
+ );
244
+ const prefix = Buffer.from(environment.sourcePrefix, "utf8");
245
+ if (!bytes.subarray(0, prefix.length).equals(prefix))
246
+ throw new Error(
247
+ "invalid_environment_artifact: Source ownership prefix does not match.",
248
+ );
249
+ const data = bytes.subarray(prefix.length);
250
+ if (sha256(data) !== environment.source.sha256)
251
+ throw new Error(
252
+ "invalid_environment_artifact: Source checksum does not match.",
253
+ );
254
+ return validateSourceBundle(JSON.parse(data.toString("utf8")));
255
+ }
256
+ async function readyAt(
257
+ prefix: string,
258
+ environment: ResolvedEnvironment,
259
+ currentPlatform: string,
260
+ runtimeAbi: string,
261
+ ): Promise<boolean> {
262
+ try {
263
+ const marker = JSON.parse(
264
+ await readFile(join(prefix, ".dench-ready.json"), "utf8"),
265
+ );
266
+ return (
267
+ marker.revision === environment.revision &&
268
+ marker.platform === currentPlatform &&
269
+ marker.runtimeAbi === runtimeAbi
270
+ );
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ function publicBuildEnvironment(
277
+ directory: string,
278
+ variables: Record<string, string>,
279
+ ): NodeJS.ProcessEnv {
280
+ const clean: NodeJS.ProcessEnv = {
281
+ NODE_ENV: process.env.NODE_ENV ?? "production",
282
+ };
283
+ for (const name of ["PATH", "LANG", "LC_ALL", "TERM", "TMPDIR", "SystemRoot"])
284
+ if (process.env[name]) clean[name] = process.env[name];
285
+ return {
286
+ ...clean,
287
+ ...variables,
288
+ HOME: directory,
289
+ XDG_CONFIG_HOME: join(directory, ".config"),
290
+ };
291
+ }
292
+
293
+ export async function prepareEnvironment(
294
+ environment: ResolvedEnvironment,
295
+ opts: {
296
+ workspace: string;
297
+ prepareLocal?: boolean;
298
+ cacheRoot?: string;
299
+ report?: (message: string) => void;
300
+ },
301
+ ): Promise<PreparedEnvironment> {
302
+ if (environment.status !== "ready" || !environment.artifacts)
303
+ throw new Error(
304
+ "environment_not_ready: Use dench env show to inspect the preparation receipt.",
305
+ );
306
+ environment.manifest = parseEnvironmentManifest(environment.manifest);
307
+ if (
308
+ !environment.organizationId ||
309
+ environment.artifacts.prefix !== environmentPrefix(environment.revision)
310
+ )
311
+ throw new Error(
312
+ "invalid_environment_artifact: Environment ownership or prefix does not match.",
313
+ );
314
+ const targetPlatform = currentEnvironmentPlatform();
315
+ let installedAbi: string | undefined;
316
+ try {
317
+ installedAbi = (await readFile("/opt/dench/runtime-abi", "utf8")).trim();
318
+ } catch {
319
+ /* Native clients require their own explicit preparation. */
320
+ }
321
+ const matchesArtifact =
322
+ targetPlatform === environment.artifacts.platform &&
323
+ installedAbi === environment.artifacts.runtimeAbi;
324
+ const useLocal = Boolean(opts.prepareLocal) || !matchesArtifact;
325
+ const localAbi = useLocal ? await localRuntimeAbi() : "";
326
+ const localPrefix = environmentLocalCachePath(
327
+ environment,
328
+ opts.cacheRoot,
329
+ localAbi,
330
+ );
331
+ const localReady =
332
+ useLocal &&
333
+ (await readyAt(localPrefix, environment, targetPlatform, localAbi));
334
+ if (useLocal && !opts.prepareLocal && !localReady)
335
+ throw new Error(
336
+ `environment_platform_mismatch: This revision targets ${environment.artifacts.platform}. Run once with --prepare-local to build a separate ${targetPlatform} cache.`,
337
+ );
338
+ const prefix = useLocal ? localPrefix : environment.artifacts.prefix;
339
+ const runtimeAbi = useLocal ? localAbi : environment.artifacts.runtimeAbi;
340
+ let cacheHit = await readyAt(prefix, environment, targetPlatform, runtimeAbi);
341
+ let preparation: PreparedEnvironment["preparation"] = useLocal
342
+ ? "local"
343
+ : "snapshot";
344
+ if (!cacheHit) {
345
+ const releasePreparation = await acquireEnvironmentWorkspaceLock(
346
+ prefix,
347
+ "prepare",
348
+ );
349
+ try {
350
+ if (await readyAt(prefix, environment, targetPlatform, runtimeAbi))
351
+ cacheHit = true;
352
+ else if (!useLocal) {
353
+ if (
354
+ !environment.manifest.portable ||
355
+ !environment.bundleUrl ||
356
+ !environment.artifacts.bundleSha256
357
+ )
358
+ throw new Error(
359
+ "environment_requires_new_sandbox: Start a new job bound to this environment; it cannot be activated in the current sandbox.",
360
+ );
361
+ opts.report?.("Preparing saved environment from its verified bundle.");
362
+ const bytes = await fetchEnvironmentBytes(
363
+ environment.bundleUrl,
364
+ MAX_PREPARED_ARCHIVE_BYTES,
365
+ environment.artifacts.bundleSha256,
366
+ );
367
+ if (
368
+ environment.artifacts.bundleBytes !== undefined &&
369
+ bytes.length !== environment.artifacts.bundleBytes
370
+ )
371
+ throw new Error(
372
+ "invalid_environment_artifact: Prepared bundle size does not match.",
373
+ );
374
+ const stage = `${prefix}.extract-${randomUUID()}`;
375
+ try {
376
+ await extractPreparedArchive(bytes, stage);
377
+ if (!(await readyAt(stage, environment, targetPlatform, runtimeAbi)))
378
+ throw new Error(
379
+ "invalid_environment_artifact: Prepared bundle is missing its verified readiness marker.",
380
+ );
381
+ if (await exists(prefix))
382
+ throw new Error(
383
+ "environment_cache_incomplete: An incomplete environment already occupies the fixed prefix; start a new sandbox.",
384
+ );
385
+ await rename(stage, prefix);
386
+ } finally {
387
+ await rm(stage, { recursive: true, force: true });
388
+ }
389
+ preparation = "bundle";
390
+ } else {
391
+ opts.report?.(
392
+ "Preparing this environment for the local platform once. Business execution starts after verification.",
393
+ );
394
+ // Build at its final prefix because Python environments and native launchers can embed it.
395
+ if (await exists(prefix))
396
+ await rm(prefix, { recursive: true, force: true });
397
+ await materializeSource(await sourceFor(environment), prefix);
398
+ const buildHome = await mkdtemp(join(tmpdir(), "dench-env-build-"));
399
+ try {
400
+ const buildEnv = publicBuildEnvironment(
401
+ buildHome,
402
+ environment.manifest.env,
403
+ );
404
+ for (const command of [
405
+ ...environment.manifest.prepare,
406
+ ...environment.manifest.verify,
407
+ ]) {
408
+ const result = await runEnvironmentProcess(
409
+ [
410
+ process.platform === "win32" ? "cmd.exe" : "/bin/sh",
411
+ process.platform === "win32" ? "/c" : "-c",
412
+ command,
413
+ ],
414
+ {
415
+ cwd: prefix,
416
+ env: buildEnv,
417
+ secretValues: [],
418
+ timeoutMs: 20 * 60_000,
419
+ },
420
+ );
421
+ if (result.exitCode !== 0)
422
+ throw new Error(
423
+ `environment_preparation_failed: Local preparation or verification exited ${result.exitCode}. ${result.stderr.slice(-2000)}`,
424
+ );
425
+ }
426
+ await writeFile(
427
+ join(prefix, ".dench-ready.json"),
428
+ JSON.stringify({
429
+ revision: environment.revision,
430
+ platform: targetPlatform,
431
+ runtimeAbi: localAbi,
432
+ }),
433
+ { mode: 0o600 },
434
+ );
435
+ } finally {
436
+ await rm(buildHome, { recursive: true, force: true });
437
+ }
438
+ }
439
+ } finally {
440
+ await releasePreparation();
441
+ }
442
+ }
443
+ const workspace = resolve(opts.workspace);
444
+ if (
445
+ workspace === prefix ||
446
+ workspace.startsWith(`${prefix}/`) ||
447
+ prefix.startsWith(`${workspace}/`)
448
+ )
449
+ throw new Error(
450
+ "validation_failed: Writable workspace must be separate from the prepared dependency prefix.",
451
+ );
452
+ await mkdir(workspace, { recursive: true, mode: 0o700 });
453
+ const markerPath = join(workspace, ".dench-workspace.json");
454
+ const marker = {
455
+ revision: environment.revision,
456
+ organizationId: environment.organizationId,
457
+ };
458
+ const releaseSeed = await acquireEnvironmentWorkspaceLock(workspace, "seed");
459
+ try {
460
+ if (await exists(markerPath)) {
461
+ const stored = JSON.parse(await readFile(markerPath, "utf8"));
462
+ if (
463
+ stored.revision !== marker.revision ||
464
+ stored.organizationId !== marker.organizationId
465
+ )
466
+ throw new Error(
467
+ "environment_workspace_mismatch: This workspace belongs to another environment or organization.",
468
+ );
469
+ } else {
470
+ for (const item of await readdir(prefix)) {
471
+ if (item === ".dench-ready.json") continue;
472
+ const target = join(workspace, item);
473
+ if (DEPENDENCY_DIRECTORIES.has(item)) {
474
+ if (await exists(target)) {
475
+ const stat = await lstat(target);
476
+ if (
477
+ !stat.isSymbolicLink() ||
478
+ (await readlink(target)) !== join(prefix, item)
479
+ )
480
+ throw new Error(
481
+ "environment_workspace_conflict: A dependency path already exists; existing files were preserved.",
482
+ );
483
+ } else await symlink(join(prefix, item), target, "dir");
484
+ } else await seedPreparedEntry(join(prefix, item), target);
485
+ }
486
+ await writeFile(markerPath, JSON.stringify(marker), {
487
+ mode: 0o600,
488
+ flag: "wx",
489
+ });
490
+ }
491
+ } finally {
492
+ await releaseSeed();
493
+ }
494
+ return { prefix, workspace, cacheHit, preparation, platform: targetPlatform };
495
+ }
496
+
497
+ /** Keep persisted state and output guarded by the same known credential set. */
498
+ export function environmentSecretMatches(values: readonly string[]): string[] {
499
+ const expanded = values.flatMap((value) => {
500
+ const strings: string[] = [value];
501
+ // Credential files often wrap the actual token inside JSON. Match their
502
+ // string values too, rather than only matching the complete file bytes.
503
+ try {
504
+ const visit = (item: unknown, depth: number): void => {
505
+ if (depth > 12) return;
506
+ if (typeof item === "string") {
507
+ if (item.length >= 4) strings.push(item);
508
+ } else if (Array.isArray(item))
509
+ item.forEach((child) => {
510
+ visit(child, depth + 1);
511
+ });
512
+ else if (item && typeof item === "object")
513
+ Object.values(item).forEach((child) => {
514
+ visit(child, depth + 1);
515
+ });
516
+ };
517
+ visit(JSON.parse(value), 0);
518
+ } catch {
519
+ /* A plain env value is not JSON. */
520
+ }
521
+ return strings;
522
+ });
523
+ return [
524
+ ...new Set(
525
+ expanded
526
+ .filter(Boolean)
527
+ .flatMap((value) => [
528
+ value,
529
+ encodeURIComponent(value),
530
+ Buffer.from(value).toString("base64"),
531
+ ]),
532
+ ),
533
+ ].sort((a, b) => b.length - a.length);
534
+ }
535
+
536
+ /** A bounded-delay redactor preserves matches split across stdout/stderr chunks. */
537
+ export class EnvironmentOutputRedactor {
538
+ private pending = "";
539
+ private readonly values: string[];
540
+ private readonly overlap: number;
541
+ constructor(values: string[]) {
542
+ this.values = environmentSecretMatches(values);
543
+ this.overlap = Math.max(1, ...this.values.map((value) => value.length));
544
+ }
545
+ push(chunk: string, final = false): string {
546
+ this.pending += chunk;
547
+ let boundary = final
548
+ ? this.pending.length
549
+ : Math.max(0, this.pending.length - this.overlap);
550
+ // Do not cut through a complete match straddling the flush boundary.
551
+ for (const value of this.values) {
552
+ for (
553
+ let at = this.pending.indexOf(value);
554
+ at >= 0;
555
+ at = this.pending.indexOf(value, at + value.length)
556
+ )
557
+ if (at < boundary && at + value.length > boundary) boundary = at;
558
+ }
559
+ let output = this.pending.slice(0, boundary);
560
+ this.pending = this.pending.slice(boundary);
561
+ for (const value of this.values)
562
+ output = output.split(value).join("[REDACTED]");
563
+ return output;
564
+ }
565
+ }
566
+
567
+ export async function runEnvironmentProcess(
568
+ command: string[],
569
+ opts: {
570
+ cwd: string;
571
+ env: NodeJS.ProcessEnv;
572
+ secretValues: string[];
573
+ timeoutMs?: number;
574
+ stdout?: (text: string) => void;
575
+ stderr?: (text: string) => void;
576
+ },
577
+ ): Promise<{
578
+ exitCode: number;
579
+ signal: string | null;
580
+ stdout: string;
581
+ stderr: string;
582
+ outputTruncated: boolean;
583
+ }> {
584
+ if (!command.length || command.some((part) => part.includes("\0")))
585
+ throw new Error(
586
+ "validation_failed: A nonempty command without NUL is required.",
587
+ );
588
+ if (
589
+ opts.secretValues.some(
590
+ (value) => value && command.some((part) => part.includes(value)),
591
+ )
592
+ )
593
+ throw new Error(
594
+ "validation_failed: Credentials must be passed through bindings, never command arguments.",
595
+ );
596
+ const child = spawn(command[0], command.slice(1), {
597
+ cwd: opts.cwd,
598
+ env: opts.env,
599
+ stdio: ["inherit", "pipe", "pipe"],
600
+ shell: false,
601
+ detached: process.platform !== "win32",
602
+ });
603
+ const stdout = new EnvironmentOutputRedactor(opts.secretValues);
604
+ const stderr = new EnvironmentOutputRedactor(opts.secretValues);
605
+ let out = "";
606
+ let err = "";
607
+ let outputTruncated = false;
608
+ const capture = (text: string, kind: "stdout" | "stderr") => {
609
+ opts[kind]?.(text);
610
+ const current = kind === "stdout" ? out : err;
611
+ const remaining = MAX_CAPTURE_BYTES - Buffer.byteLength(current, "utf8");
612
+ const data = Buffer.from(text, "utf8");
613
+ const next =
614
+ current + data.subarray(0, Math.max(0, remaining)).toString("utf8");
615
+ if (data.length > remaining) outputTruncated = true;
616
+ if (kind === "stdout") out = next;
617
+ else err = next;
618
+ };
619
+ child.stdout.setEncoding("utf8");
620
+ child.stderr.setEncoding("utf8");
621
+ child.stdout.on("data", (data: string) =>
622
+ capture(stdout.push(data), "stdout"),
623
+ );
624
+ child.stderr.on("data", (data: string) =>
625
+ capture(stderr.push(data), "stderr"),
626
+ );
627
+ let canceled = false;
628
+ let escalation: ReturnType<typeof setTimeout> | undefined;
629
+ const killTree = (signal: NodeJS.Signals) => {
630
+ try {
631
+ if (process.platform !== "win32" && child.pid)
632
+ process.kill(-child.pid, signal);
633
+ else child.kill(signal);
634
+ } catch (error) {
635
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") child.kill(signal);
636
+ }
637
+ };
638
+ const terminate = () => {
639
+ canceled = true;
640
+ killTree("SIGTERM");
641
+ escalation ??= setTimeout(() => killTree("SIGKILL"), 5000);
642
+ };
643
+ process.once("SIGINT", terminate);
644
+ process.once("SIGTERM", terminate);
645
+ const timeout = opts.timeoutMs
646
+ ? setTimeout(terminate, opts.timeoutMs)
647
+ : undefined;
648
+ try {
649
+ const result = await new Promise<{
650
+ exitCode: number;
651
+ signal: string | null;
652
+ }>((done, reject) => {
653
+ child.once("error", () =>
654
+ reject(
655
+ new Error(
656
+ "environment_command_failed: Could not start the requested executable.",
657
+ ),
658
+ ),
659
+ );
660
+ child.once("close", (code, signal) =>
661
+ done({
662
+ exitCode: canceled ? 130 : (code ?? 128),
663
+ signal: canceled ? "SIGTERM" : signal,
664
+ }),
665
+ );
666
+ });
667
+ capture(stdout.push("", true), "stdout");
668
+ capture(stderr.push("", true), "stderr");
669
+ return { ...result, stdout: out, stderr: err, outputTruncated };
670
+ } finally {
671
+ if (timeout) clearTimeout(timeout);
672
+ if (escalation) clearTimeout(escalation);
673
+ process.removeListener("SIGINT", terminate);
674
+ process.removeListener("SIGTERM", terminate);
675
+ }
676
+ }
677
+
678
+ /** Create a private binding destination without ever walking an existing symlink. */
679
+ export async function environmentPrivatePath(
680
+ workspace: string,
681
+ relative: string,
682
+ ): Promise<string> {
683
+ validateEnvironmentPath(relative);
684
+ let at = workspace;
685
+ const parts = relative.split("/");
686
+ for (let i = 0; i < parts.length; i++) {
687
+ at = join(at, parts[i]);
688
+ try {
689
+ const stat = await lstat(at);
690
+ if (
691
+ stat.isSymbolicLink() ||
692
+ (i < parts.length - 1 && !stat.isDirectory())
693
+ )
694
+ throw new Error(
695
+ "environment_binding_unsafe: A credential/state path traverses a link or file.",
696
+ );
697
+ } catch (error) {
698
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
699
+ if (i < parts.length - 1) await mkdir(at, { mode: 0o700 });
700
+ }
701
+ }
702
+ return at;
703
+ }
704
+
705
+ export function activatedEnvironment(
706
+ prepared: PreparedEnvironment,
707
+ environment: ResolvedEnvironment,
708
+ context?: { sessionToken?: string; convexUrl?: string; apiUrl?: string },
709
+ ): NodeJS.ProcessEnv {
710
+ const values = Object.fromEntries(
711
+ Object.entries(environment.credentialEnv).map(([name, entry]) => [
712
+ name,
713
+ entry.value,
714
+ ]),
715
+ );
716
+ const inherited: NodeJS.ProcessEnv = {
717
+ NODE_ENV: process.env.NODE_ENV ?? "production",
718
+ };
719
+ for (const name of [
720
+ "PATH",
721
+ "LANG",
722
+ "LC_ALL",
723
+ "TERM",
724
+ "TMPDIR",
725
+ "SystemRoot",
726
+ "DENCH_HARNESS_A_JOB_ID",
727
+ "DENCH_HARNESS_JOB_ID",
728
+ "DENCH_RUN_ID",
729
+ "DENCH_CHAT_ID",
730
+ "DENCH_CHAT_ARTIFACTS",
731
+ "DENCH_ORG_ID",
732
+ "DENCH_ORG_SLUG",
733
+ "DENCH_API_URL",
734
+ "DENCH_HOST",
735
+ "DENCH_GATEWAY_URL",
736
+ "CONVEX_URL",
737
+ "NEXT_PUBLIC_CONVEX_URL",
738
+ "DENCH_ENVIRONMENT_WORKSPACE",
739
+ ])
740
+ if (process.env[name]) inherited[name] = process.env[name];
741
+ // Only the currently authenticated Dench session is forwarded, never another
742
+ // provider's ambient credentials or the native user's home/config directory.
743
+ const session =
744
+ context?.sessionToken ??
745
+ process.env.DENCH_AGENT_SESSION_TOKEN ??
746
+ process.env.DENCH_API_KEY;
747
+ return {
748
+ ...inherited,
749
+ ...environment.manifest.env,
750
+ ...values,
751
+ HOME: prepared.workspace,
752
+ XDG_CONFIG_HOME: join(prepared.workspace, ".config"),
753
+ DENCH_CONFIG_DIR: join(prepared.workspace, ".dench"),
754
+ ...(session ? { DENCH_AGENT_SESSION_TOKEN: session } : {}),
755
+ ...(context?.convexUrl ? { CONVEX_URL: context.convexUrl } : {}),
756
+ ...(context?.apiUrl
757
+ ? { DENCH_API_URL: context.apiUrl, DENCH_HOST: context.apiUrl }
758
+ : {}),
759
+ DENCH_ENVIRONMENT_PREFIX: prepared.prefix,
760
+ DENCH_ENVIRONMENT_ROOT: prepared.workspace,
761
+ DENCH_ENVIRONMENT_REVISION: environment.revision,
762
+ PATH: [
763
+ join(prepared.workspace, "node_modules", ".bin"),
764
+ join(prepared.prefix, ".venv", "bin"),
765
+ join(prepared.prefix, "venv", "bin"),
766
+ process.env.PATH ?? "",
767
+ ].join(delimiter),
768
+ };
769
+ }