@catheadowl/dsh-eval 0.1.0 → 0.2.0

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,201 @@
1
+ /**
2
+ * The isolated-DSH_HOME sandbox shared by every headless dsh run this
3
+ * framework performs — the behavior eval runner (runner.mjs) and the dsh
4
+ * review adapter (adapters/dsh/review.mjs). Both need the identical
5
+ * isolation recipe, and this module is the single place it lives:
6
+ *
7
+ * - resolve where the REAL home is (`DSH_HOME` or `~/.dsh`);
8
+ * - stage a profile store into a temporary home (junction-aware copy, see
9
+ * `stageProfileStore`) and copy the managed credential document in;
10
+ * - spawn the compiled CLI with stdout/stderr capture and a SIGTERM timeout;
11
+ * - tear the temporary run directory down WITHOUT ever descending through
12
+ * a junction into the real store (see `teardownSandbox`).
13
+ *
14
+ * `DSH_EVAL_KEEP_TMP` / `DSH_REVIEW_KEEP_TMP` are caller concerns: each
15
+ * caller passes `{ keep }` so this module stays policy-free.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process'
19
+ import {
20
+ chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, symlinkSync, unlinkSync,
21
+ } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { join } from 'node:path'
24
+
25
+ /** The profile-local module-fallback directory, rebuilt fresh by boot and never staged. */
26
+ const MODULE_FALLBACK_DIR = '.dsh-module-fallback'
27
+
28
+ /**
29
+ * Where the real Harness home is: an ambient `DSH_HOME` when set (non-blank),
30
+ * otherwise the default `~/.dsh`. The sandbox overrides `DSH_HOME` per run;
31
+ * this resolves the store it stages FROM.
32
+ */
33
+ export function resolveRealDshHome(env = process.env) {
34
+ return (env.DSH_HOME ?? '').trim() !== '' ? env.DSH_HOME : join(homedir(), '.dsh')
35
+ }
36
+
37
+ /**
38
+ * Stage the profile store into a temporary DSH_HOME. The BOOTED profile's
39
+ * directory is COPIED (sans its own `node_modules` and `.dsh-module-fallback`):
40
+ * `prepareProfile` unconditionally rewrites the profile's root cordis.yml on
41
+ * every boot, and copying keeps that write inside the temporary home instead
42
+ * of leaking through a junction into the real store. Only the profile's own
43
+ * `node_modules` stays junctioned — out-of-tree plugin resolution needs it,
44
+ * and a task boot never writes it. The module-fallback directories (the
45
+ * store-level shared `profiles/node_modules` and the profile-local
46
+ * `.dsh-module-fallback`) are deliberately NOT staged: `healProfilesModuleFallback`
47
+ * rebuilds both fresh in the temporary home on every boot, and `.dsh-module-fallback`
48
+ * in particular is full of junctions that a naive recursive copy would follow
49
+ * into the real store. The copy is junction-aware — links are recreated as
50
+ * links, never descended (see `copyProfileEntry`). An absent profile copies
51
+ * nothing: boot initializes shipped templates inside the temporary home.
52
+ * @param {string} realHome - the real Harness home holding `profiles/`.
53
+ * @param {string} tmpHome - the temporary home (created up to `profiles/`).
54
+ * @param {string} profileName - the profile this run boots.
55
+ * @returns {string[]} every created junction path (see teardownSandbox).
56
+ */
57
+ export function stageProfileStore(realHome, tmpHome, profileName) {
58
+ const junctions = []
59
+ const tmpProfiles = join(tmpHome, 'profiles')
60
+ mkdirSync(tmpProfiles, { recursive: true })
61
+ const realProfiles = join(realHome, 'profiles')
62
+ if (!existsSync(realProfiles)) return junctions
63
+ const realProfileDir = join(realProfiles, profileName)
64
+ if (!existsSync(join(realProfileDir, 'package.json'))) return junctions
65
+ const tmpProfileDir = join(tmpProfiles, profileName)
66
+ mkdirSync(tmpProfileDir, { recursive: true })
67
+ for (const entry of readdirSync(realProfileDir, { withFileTypes: true })) {
68
+ if (entry.name === 'node_modules' || entry.name === MODULE_FALLBACK_DIR) continue
69
+ copyProfileEntry(join(realProfileDir, entry.name), join(tmpProfileDir, entry.name), junctions)
70
+ }
71
+ const profileModules = join(realProfileDir, 'node_modules')
72
+ if (existsSync(profileModules)) {
73
+ const target = join(tmpProfileDir, 'node_modules')
74
+ symlinkSync(profileModules, target, 'junction')
75
+ junctions.push(target)
76
+ }
77
+ return junctions
78
+ }
79
+
80
+ /**
81
+ * Copy one profile entry (file, directory, or link) into the staged profile.
82
+ * A link is recreated as a link (`'junction'` on Windows) instead of being
83
+ * followed: Node's `cpSync` dereferences Windows junctions in recursive mode
84
+ * (no cycle guard), so a junction-bearing tree would recurse into the real
85
+ * store and overflow the native stack. Recreated junctions are appended to
86
+ * `junctions` so callers can unlink them before `rmSync` (which would
87
+ * otherwise descend through them into the real store).
88
+ */
89
+ function copyProfileEntry(from, to, junctions) {
90
+ const stat = lstatSync(from)
91
+ if (stat.isSymbolicLink()) {
92
+ const target = readlinkSync(from)
93
+ symlinkSync(target, to, 'junction')
94
+ junctions.push(to)
95
+ return
96
+ }
97
+ if (stat.isDirectory()) {
98
+ mkdirSync(to, { recursive: true })
99
+ for (const child of readdirSync(from)) {
100
+ copyProfileEntry(join(from, child), join(to, child), junctions)
101
+ }
102
+ return
103
+ }
104
+ copyFileSync(from, to)
105
+ }
106
+
107
+ /**
108
+ * Copy the managed credential document into the temporary home (copy, not
109
+ * junction: single file, best-effort owner-only 0o600 — a no-op beyond the
110
+ * read-only bit on Windows). `dsh-credentials-local` resolves it per request.
111
+ */
112
+ function stageCredentials(realHome, dshHome) {
113
+ const realCredentials = join(realHome, '.credentials.yaml')
114
+ if (!existsSync(realCredentials)) return
115
+ const credentialsCopy = join(dshHome, '.credentials.yaml')
116
+ copyFileSync(realCredentials, credentialsCopy)
117
+ try {
118
+ chmodSync(credentialsCopy, 0o600)
119
+ } catch { /* permission tightening is best-effort */ }
120
+ }
121
+
122
+ /**
123
+ * Stage a complete isolated home: create `dshHome`, stage the profile store,
124
+ * copy credentials in. One call replaces the three steps both callers used
125
+ * to repeat inline.
126
+ * @returns {string[]} every created junction path (informational —
127
+ * `teardownSandbox` re-discovers them by walking, so callers need not
128
+ * track the list themselves).
129
+ */
130
+ export function stageSandboxHome(realHome, dshHome, profile) {
131
+ mkdirSync(dshHome, { recursive: true })
132
+ const junctions = stageProfileStore(realHome, dshHome, profile)
133
+ stageCredentials(realHome, dshHome)
134
+ return junctions
135
+ }
136
+
137
+ /**
138
+ * Remove a run directory without ever descending through a junction into
139
+ * the real profile store: first WALK the tree collecting every symlink
140
+ * (covers staged profile junctions and anything a case's `prepare` created),
141
+ * unlink them all, then `rmSync` the remainder. Robust to errors mid-run:
142
+ * a walk over a not-yet-created directory is a no-op. Skipped entirely when
143
+ * `keep` is true (caller passes its own KEEP_TMP policy flag).
144
+ */
145
+ export function teardownSandbox(runDir, { keep = false } = {}) {
146
+ if (keep) return
147
+ const junctions = []
148
+ const walk = (dir) => {
149
+ let entries
150
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
151
+ for (const entry of entries) {
152
+ const full = join(dir, entry.name)
153
+ if (entry.isSymbolicLink()) junctions.push(full)
154
+ else if (entry.isDirectory()) walk(full)
155
+ }
156
+ }
157
+ walk(runDir)
158
+ for (const junction of junctions) {
159
+ try { unlinkSync(junction) } catch { /* junction absent — nothing to drop */ }
160
+ }
161
+ rmSync(runDir, { recursive: true, force: true })
162
+ }
163
+
164
+ /**
165
+ * Spawn one headless dsh CLI run with unified stream capture and timeout.
166
+ * A spawn failure (missing binary, EPERM) resolves as `exitCode: 127` with
167
+ * the failure text appended to `stderr` — callers decide whether an exit
168
+ * code is fatal, so the spawn layer never rejects.
169
+ *
170
+ * @param {object} options
171
+ * @param {string} options.cli - the compiled CLI entry (bin.js).
172
+ * @param {string[]} options.cliArgs - CLI arguments (profile/patch/task...).
173
+ * @param {string} options.cwd - working directory for the child.
174
+ * @param {object} options.env - FULL child environment (caller assembles).
175
+ * @param {number} options.timeoutMs - SIGTERM deadline.
176
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
177
+ */
178
+ export async function spawnHeadlessDsh(options) {
179
+ const child = spawn(process.execPath, [options.cli, ...options.cliArgs], {
180
+ cwd: options.cwd,
181
+ env: options.env,
182
+ })
183
+
184
+ let stdout = ''
185
+ let stderr = ''
186
+ child.stdout.on('data', chunk => { stdout += chunk })
187
+ child.stderr.on('data', chunk => { stderr += chunk })
188
+
189
+ let timedOut = false
190
+ const timer = setTimeout(() => {
191
+ timedOut = true
192
+ child.kill('SIGTERM')
193
+ }, options.timeoutMs)
194
+
195
+ const exitCode = await new Promise(resolveExit => {
196
+ child.on('error', error => { stderr += `\ndsh sandbox: failed to spawn dsh CLI: ${error.message}\n`; resolveExit(127) })
197
+ child.on('exit', code => resolveExit(code ?? 1))
198
+ })
199
+ clearTimeout(timer)
200
+ return { stdout, stderr, exitCode, timedOut }
201
+ }
@@ -1,7 +0,0 @@
1
- /** Public dsh review adapter surface. */
2
-
3
- export {
4
- createDshHeadlessReviewExecutor,
5
- resolveDshCli,
6
- runDshReviewExperiment,
7
- } from './review.mjs'