@openlines/opl-pi-sht 0.2.12 → 0.3.1

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.
Files changed (83) hide show
  1. package/README.md +101 -85
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package-lock.json +3 -3
  12. package/extensions/opl-browser/package.json +4 -4
  13. package/extensions/opl-browser/validate.ts +313 -60
  14. package/extensions/opl-configurator/README.md +40 -0
  15. package/extensions/opl-configurator/configurator.ts +151 -0
  16. package/extensions/opl-configurator/index.ts +12 -0
  17. package/extensions/opl-ctxtrim/README.md +45 -14
  18. package/extensions/opl-ctxtrim/index.ts +33 -13
  19. package/extensions/opl-footer/README.md +31 -6
  20. package/extensions/opl-footer/config.ts +11 -10
  21. package/extensions/opl-footer/configure.ts +159 -115
  22. package/extensions/opl-footer/git-status.ts +58 -1
  23. package/extensions/opl-footer/icons.ts +3 -0
  24. package/extensions/opl-footer/index.ts +209 -105
  25. package/extensions/opl-footer/segments/caveman.ts +9 -1
  26. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  27. package/extensions/opl-footer/segments/compactions.ts +13 -0
  28. package/extensions/opl-footer/segments/context.ts +6 -5
  29. package/extensions/opl-footer/segments/index.ts +2 -0
  30. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  31. package/extensions/opl-footer/segments/path.ts +1 -1
  32. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  33. package/extensions/opl-footer/theme.ts +5 -2
  34. package/extensions/opl-footer/types.ts +4 -0
  35. package/extensions/opl-guardian/README.md +17 -5
  36. package/extensions/opl-guardian/config.ts +72 -6
  37. package/extensions/opl-guardian/guardian.ts +42 -10
  38. package/extensions/opl-guardian/index.ts +52 -17
  39. package/extensions/opl-guardian/policies.ts +52 -16
  40. package/extensions/opl-init/README.md +12 -4
  41. package/extensions/opl-init/index.ts +228 -27
  42. package/extensions/opl-input/README.md +17 -3
  43. package/extensions/opl-input/config.ts +59 -5
  44. package/extensions/opl-input/configure.ts +146 -0
  45. package/extensions/opl-input/index.ts +38 -30
  46. package/extensions/opl-input/mode-style.ts +6 -2
  47. package/extensions/opl-modes/README.md +22 -8
  48. package/extensions/opl-modes/config.ts +62 -8
  49. package/extensions/opl-modes/index.ts +87 -28
  50. package/extensions/opl-modes/state.ts +50 -2
  51. package/extensions/opl-modes/types.ts +6 -0
  52. package/extensions/opl-modes/utils.ts +18 -8
  53. package/extensions/opl-questionnaire/index.ts +10 -4
  54. package/extensions/opl-questionnaire/types.ts +4 -0
  55. package/extensions/opl-simplebench/README.md +21 -15
  56. package/extensions/opl-simplebench/artifact.ts +7 -4
  57. package/extensions/opl-simplebench/benchmark.ts +96 -35
  58. package/extensions/opl-simplebench/coding.ts +9 -4
  59. package/extensions/opl-simplebench/index.ts +19 -19
  60. package/extensions/opl-simplebench/llama-server.ts +3 -2
  61. package/extensions/opl-simplebench/report.ts +0 -6
  62. package/extensions/opl-simplebench/research.ts +5 -4
  63. package/extensions/opl-simplebench/scoring.ts +4 -2
  64. package/extensions/opl-simplebench/util/config.ts +15 -8
  65. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  66. package/extensions/opl-simplebench/util/format.ts +2 -2
  67. package/extensions/opl-simplebench/util/providers.ts +23 -194
  68. package/extensions/opl-todo/README.md +5 -5
  69. package/extensions/opl-todo/config.ts +7 -2
  70. package/extensions/opl-todo/index.ts +50 -9
  71. package/extensions/opl-webaccess/README.md +31 -5
  72. package/extensions/opl-webaccess/config.ts +36 -7
  73. package/extensions/opl-webaccess/extract.ts +37 -47
  74. package/extensions/opl-webaccess/http.ts +259 -0
  75. package/extensions/opl-webaccess/index.ts +12 -6
  76. package/extensions/opl-webaccess/package-lock.json +3 -3
  77. package/extensions/opl-webaccess/package.json +4 -9
  78. package/extensions/opl-webaccess/pdf.ts +4 -4
  79. package/extensions/opl-webaccess/search.ts +19 -8
  80. package/extensions/opl-webaccess/storage.ts +134 -2
  81. package/extensions/opl-webaccess/types.ts +4 -0
  82. package/extensions/opl-webaccess/utils.ts +221 -33
  83. package/package.json +19 -12
@@ -32,7 +32,7 @@ simplebench({ model: "global.openai.gpt-5.6-terra", coding_lite: true })
32
32
  simplebench({ model: "global.openai.gpt-5.6-terra", test_all: true })
33
33
  ```
34
34
 
35
- `--no-artifact` and `no_artifact: true` suppress the JSON file. Default runs use the provider's sampling and reasoning defaults and write one JSON report per model to Pi's current working directory. `--thinking-max` and `thinking_max: true` request `reasoning_effort: "max"` for OpenAI-compatible providers, or Pi's model-aware Bedrock max-thinking path for direct Bedrock models that advertise `reasoning: true` and `thinkingLevelMap.max`. Other providers reject that mode rather than silently using defaults.
35
+ `--no-artifact` and `no_artifact: true` suppress the JSON file. Default runs use the provider's sampling and reasoning defaults and write one JSON report per model to the current Pi session directory (`ExtensionContext.cwd`). Without an active session context, artifacts fall back to the harness working directory. `--thinking-max` and `thinking_max: true` request `reasoning_effort: "max"` for OpenAI-compatible providers, or Pi's model-aware Bedrock max-thinking path for direct Bedrock models that advertise `reasoning: true` and `thinkingLevelMap.max`. Other providers reject that mode rather than silently using defaults.
36
36
 
37
37
  ## Extension features
38
38
 
@@ -172,7 +172,7 @@ The stats endpoint is metadata-only and does not change inference routing. Its s
172
172
 
173
173
  ## Artifacts and privacy
174
174
 
175
- Baseline and coding-lite artifacts are written to `process.cwd()` as JSON. `--test-all` writes a bundle:
175
+ Baseline and coding-lite artifacts use the current Pi session directory (`ExtensionContext.cwd`), passed down explicitly; without a Pi session, they fall back to the harness working directory. `--test-all` writes a bundle:
176
176
 
177
177
  ```text
178
178
  simplebench--test-all-<sanitized-model>-<thinking>-<UTC-timestamp>/
@@ -211,6 +211,8 @@ The model receives weather and calculation tools. Simplebench validates expected
211
211
 
212
212
  Coding-lite is the execution-backed coding-agent suite. It runs model-directed edits in temporary directories. The model can list, search, read, write, and run the fixture's public tests, but cannot execute arbitrary commands, access the real repository, read hidden verification code, or use the network. The host runs hidden verification after the model stops.
213
213
 
214
+ **The verifier executes model-authored code.** Verification runs `node --eval` over the model's files, so that process is started with an allowlisted environment (`util/exec-env.ts`): `PATH`, `HOME`, temp-directory and locale variables, TLS trust paths, terminal capability flags, and the Windows equivalents. Everything else is absent — every `*_API_KEY`, `AWS_*`, `GH_*`, `PI_*`, and any other secret your shell happens to export — and `NODE_OPTIONS`/`NODE_PATH` are never forwarded because both can make Node load attacker-chosen files. Anything the model's code spawns inherits that environment, so a nested `node src/cli.mjs` sees the same allowlist. This limits what generated code can *read out of the process*; it is not sandboxing. The child still runs with your user's filesystem permissions and network reach, and a `HOME` it can see points at files it could read by path anyway. Use `--no-artifact` and a dedicated shell if that is not acceptable for your environment.
215
+
214
216
  The agent loop defaults to **5 turns** per task (reduced from 12). This tighter budget forces the model to converge on a solution quickly and makes turn count a meaningful quality signal. Results are scored on three dimensions:
215
217
 
216
218
  - **STRONG** — solved in 1–2 turns (first or second attempt correct)
@@ -230,17 +232,17 @@ Use `--coding-lite` for coding tasks only. Use `--test-all` to run the existing
230
232
 
231
233
  ### Recommendation policy
232
234
 
233
- The recommendation is based on the three (or four) displayed categories:
235
+ Four categories can be in play: the closed-answer contract, instruction following, tool usage, and (with `--coding-lite`) coding. A category counts as passed when its own rule succeeds - the closed-answer contract counts as passed at `STRONG` **or** `MODERATE`, and coding counts as passed on its efficiency-weighted average. The label then depends on how many passed, and on how many categories actually ran:
234
236
 
235
- | Result | Recommendation |
236
- |---|---|
237
- | Strong reasoning, JSON pass, tool pass, all coding STRONG | STRONG |
238
- | Strong reasoning, JSON pass, tool pass (coding not all STRONG) | GOOD |
239
- | Any three categories pass | USABLE |
240
- | Two categories pass | LIMITED |
241
- | One or zero categories pass | WEAK |
237
+ | Categories passed | Baseline (3 categories) | With coding (4 categories) |
238
+ |---|---|---|
239
+ | All | STRONG if the contract is `STRONG` and, when coding ran, every task is `STRONG`; GOOD otherwise | |
240
+ | All but one | GOOD | GOOD |
241
+ | Two | USABLE | LIMITED |
242
+ | One | LIMITED | LIMITED |
243
+ | None | WEAK | WEAK |
242
244
 
243
- When coding-lite is included, the coding category uses efficiency-weighted scoring (STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0). The category counts as passing when the weighted average ≥ 0.5 across all tasks.
245
+ `WEAK` therefore requires that **nothing** passed; one passing category is `LIMITED`. A run that omits coding can report `USABLE` where the same results with coding report `LIMITED`, because the denominator changes. For runs with no clean category counts (an error or a cancelled run), `report.ts` falls back to the average `tests[].score`: `>= 0.8` `GOOD`, `>= 0.5` `USABLE`, `>= 0.2` `LIMITED`, otherwise `WEAK`. The coding category is scored efficiency-weighted (STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0) and passes when that weighted average is >= 0.5 across all tasks.
244
246
 
245
247
  ## Provider authentication
246
248
 
@@ -260,6 +262,8 @@ Simplebench derives the region from the selected model's Bedrock base URL, signs
260
262
  aws configure export-credentials --profile "$AWS_PROFILE"
261
263
  ```
262
264
 
265
+ The CLI is invoked with an argument vector, never through a shell, so the profile value is never interpolated into a command string; and it runs asynchronously, so a slow SSO or assume-role chain cannot stall the TUI. A profile name that is not a plain token (`^[A-Za-z0-9][A-Za-z0-9_.:@+-]{0,127}$`) is refused before any process is spawned — `AWS_PROFILE` set by repo tooling such as `.envrc` or a Makefile is not trusted to reach the CLI. Set `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN`) to bypass the CLI entirely.
266
+
263
267
  AWS CLI v2 is therefore required when the selected profile uses SSO or an assume-role chain.
264
268
 
265
269
  ## Troubleshooting
@@ -270,7 +274,7 @@ AWS CLI v2 is therefore required when the selected profile uses SSO or an assume
270
274
  | OpenRouter returns 401 | Set `OPENROUTER_API_KEY`, configure the provider key, or authenticate Pi so `auth.json` has an API key. |
271
275
  | Bedrock says credentials are unavailable | Run `aws configure export-credentials --profile "$AWS_PROFILE"` and resolve profile login/role issues first. |
272
276
  | Bedrock rejects `--thinking-max` | The selected model does not advertise max thinking in Pi metadata, or the provider rejected its model-specific reasoning request. |
273
- | Artifact is absent | Check the report for `--no-artifact` or an artifact-write warning; the file is written to Pi's cwd. |
277
+ | Artifact is absent | Check the report for `--no-artifact` or an artifact-write warning; the file lands in the session directory, not where the shell was standing. |
274
278
  | Token or TTFT values are null | The provider response did not expose authoritative usage/timing values. |
275
279
 
276
280
  ## Architecture
@@ -279,13 +283,15 @@ AWS CLI v2 is therefore required when the selected profile uses SSO or an assume
279
283
  index.ts Pi registration, command parsing, tool schema
280
284
  benchmark.ts provider adapters and benchmark orchestration
281
285
  llama-server.ts direct llama-server /props and /metrics capture
286
+ coding.ts the six execution-backed coding tasks and their agent loop
287
+ research.ts grounded research fixtures and the deterministic verifier
282
288
  tests.ts benchmark fixtures
283
289
  scoring.ts extraction and scoring
284
290
  metrics.ts request/usage aggregation
285
- artifact.ts cwd JSON artifact writer
291
+ artifact.ts session-directory JSON artifact writer
286
292
  report.ts terminal output and recommendation policy
287
293
  types.ts shared benchmark types
288
- util/ local configuration, formatting, debugging, and provider helpers
294
+ util/ local configuration, formatting, debugging, exec-environment scrubbing, and provider helpers
289
295
  ```
290
296
 
291
- The extension is self-contained under `extensions/opl-simplebench/`, ready to copy into the `opl-pi-sht` extension stack after local verification.
297
+ Everything lives under `extensions/opl-simplebench/` because `install.sh` installs one extension directory at a time; the only environment boundary model-authored code gets is `util/exec-env.ts`, which is where a new child process must go through.
@@ -1,5 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+
4
+ // The output directory is always passed in: artifacts land in the session directory
5
+ // (`ExtensionContext.cwd`), not wherever the harness was started.
3
6
  import type { RunArtifact } from "./types";
4
7
 
5
8
  export function artifactFileName(model: string, suite: "baseline" | "coding-lite" | "test-all" = "baseline", thinking: "default" | "max" = "default", date = new Date(), tag?: string): string {
@@ -9,10 +12,10 @@ export function artifactFileName(model: string, suite: "baseline" | "coding-lite
9
12
  return `simplebench${tag ? `-${tag}-` : "--"}${suiteName}-${safeModel}-${thinking}-${stamp}.json`;
10
13
  }
11
14
 
12
- export function writeArtifactBundle(artifact: RunArtifact, files: Record<string, string>): string {
15
+ export function writeArtifactBundle(artifact: RunArtifact, files: Record<string, string>, cwd: string): string {
13
16
  const name = artifactFileName(artifact.benchmark.model, artifact.benchmark.suite || "baseline", artifact.benchmark.thinking.requested, new Date(), artifact.benchmark.tag).replace(/\.json$/, "");
14
17
  for (let suffix = 0; ; suffix += 1) {
15
- const output = path.resolve(process.cwd(), suffix ? `${name}-${suffix}` : name);
18
+ const output = path.resolve(cwd, suffix ? `${name}-${suffix}` : name);
16
19
  try {
17
20
  fs.mkdirSync(output);
18
21
  fs.writeFileSync(path.join(output, "result.json"), JSON.stringify(artifact, null, 2) + "\n", "utf8");
@@ -24,13 +27,13 @@ export function writeArtifactBundle(artifact: RunArtifact, files: Record<string,
24
27
  }
25
28
  }
26
29
 
27
- export function writeArtifact(artifact: RunArtifact): string {
30
+ export function writeArtifact(artifact: RunArtifact, cwd: string): string {
28
31
  const name = artifactFileName(artifact.benchmark.model, artifact.benchmark.suite || "baseline", artifact.benchmark.thinking.requested, new Date(), artifact.benchmark.tag);
29
32
  const extension = path.extname(name);
30
33
  const base = name.slice(0, -extension.length);
31
34
  const contents = JSON.stringify(artifact, null, 2) + "\n";
32
35
  for (let suffix = 0; ; suffix += 1) {
33
- const output = path.resolve(process.cwd(), suffix ? `${base}-${suffix}${extension}` : name);
36
+ const output = path.resolve(cwd, suffix ? `${base}-${suffix}${extension}` : name);
34
37
  try {
35
38
  fs.writeFileSync(output, contents, { encoding: "utf8", flag: "wx" });
36
39
  return output;
@@ -1,5 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
3
5
  import { section, ok, fail, warn, info, msHuman, truncate, sanitizeForReport } from "./util/format";
4
6
  import { getOllamaBaseUrl, detectProvider } from "./util/providers";
5
7
  import { debugLog } from "./util/debug";
@@ -60,6 +62,92 @@ export function openAiThinkingOptions(thinkingMax: boolean): Record<string, stri
60
62
  return thinkingMax ? { reasoning_effort: "max" } : {};
61
63
  }
62
64
 
65
+ const execFileAsync = promisify(execFile);
66
+
67
+ /** Credentials for the Bedrock Converse API. */
68
+ export interface AwsCredentials {
69
+ accessKeyId: string;
70
+ secretAccessKey: string;
71
+ sessionToken?: string;
72
+ }
73
+
74
+ export interface AwsCliCommand { file: string; args: string[] }
75
+ export type AwsCliRunner = (command: AwsCliCommand) => Promise<{ stdout: string }>;
76
+
77
+ /**
78
+ * A profile name reaches the AWS CLI as one argv element, never as part of a
79
+ * shell string. Anything that is not a plain profile name is refused before a
80
+ * process is spawned: metacharacters would only matter if the value were
81
+ * interpolated into a shell command, and a leading dash the CLI would read as
82
+ * another option.
83
+ */
84
+ const AWS_PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9_.:@+-]{0,127}$/;
85
+
86
+ export function isValidAwsProfileName(profile: string): boolean {
87
+ return AWS_PROFILE_RE.test(profile);
88
+ }
89
+
90
+ export function awsCredentialsCommand(profile: string): AwsCliCommand {
91
+ return { file: "aws", args: ["configure", "export-credentials", "--profile", profile] };
92
+ }
93
+
94
+ const defaultAwsCliRunner: AwsCliRunner = async (command) => {
95
+ const { stdout } = await execFileAsync(command.file, command.args, { timeout: 15_000, encoding: "utf8" });
96
+ return { stdout };
97
+ };
98
+
99
+ function parseAwsCredentials(stdout: string): AwsCredentials | null {
100
+ try {
101
+ const parsed = JSON.parse(stdout);
102
+ if (parsed?.AccessKeyId && parsed?.SecretAccessKey) {
103
+ return { accessKeyId: parsed.AccessKeyId, secretAccessKey: parsed.SecretAccessKey, sessionToken: parsed.SessionToken };
104
+ }
105
+ } catch { /* not JSON: missing CLI, unresolvable profile, or a stray warning line */ }
106
+ return null;
107
+ }
108
+
109
+ let _awsCredsCache: AwsCredentials | null | undefined;
110
+
111
+ /**
112
+ * Resolve AWS credentials: environment first, then the AWS CLI
113
+ * (`aws configure export-credentials`), which handles static keys, assume-role
114
+ * chains (source_profile + role_arn), and SSO. The CLI runs from argv and off
115
+ * the event loop, so a slow credential chain cannot stall the TUI. Injecting
116
+ * `env` or `runner` bypasses the per-process cache, which keeps tests from
117
+ * poisoning the real path.
118
+ * # ponytail: requires AWS CLI v2; cached per process
119
+ */
120
+ export async function resolveAwsCredentials(
121
+ source: { env?: Record<string, string | undefined>; runner?: AwsCliRunner } = {},
122
+ ): Promise<AwsCredentials | null> {
123
+ const env = source.env ?? process.env;
124
+ const runner = source.runner ?? defaultAwsCliRunner;
125
+ const cached = source.env === undefined && source.runner === undefined;
126
+ if (cached && _awsCredsCache !== undefined) return _awsCredsCache;
127
+
128
+ const lookup = async (): Promise<AwsCredentials | null> => {
129
+ if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY) {
130
+ return { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY, sessionToken: env.AWS_SESSION_TOKEN };
131
+ }
132
+ const profile = env.AWS_PROFILE || "default";
133
+ if (!isValidAwsProfileName(profile)) return null;
134
+ try {
135
+ return parseAwsCredentials((await runner(awsCredentialsCommand(profile))).stdout);
136
+ } catch {
137
+ return null; /* aws CLI missing or profile unresolvable */
138
+ }
139
+ };
140
+
141
+ const creds = await lookup();
142
+ if (cached) _awsCredsCache = creds;
143
+ return creds;
144
+ }
145
+
146
+ /** Drop the per-process credential cache (tests, credential rotation). */
147
+ export function resetAwsCredentialsCacheForTest(): void {
148
+ _awsCredsCache = undefined;
149
+ }
150
+
63
151
  export function createBenchmark() {
64
152
  // Use effective config (user overrides merged with defaults)
65
153
  const effectiveConfig = getEffectiveConfig();
@@ -174,34 +262,6 @@ function makeOpenAiChatFn(baseUrl: string, apiKey?: string, thinkingMax = false)
174
262
  };
175
263
  }
176
264
 
177
- /**
178
- * Resolve AWS credentials: env vars first, then the AWS CLI
179
- * (`aws configure export-credentials`), which handles static keys,
180
- * assume-role chains (source_profile + role_arn), and SSO.
181
- * # ponytail: requires AWS CLI v2; cached per process
182
- */
183
- let _awsCredsCache: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | null | undefined;
184
- function resolveAwsCredentials(): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | null {
185
- if (_awsCredsCache !== undefined) return _awsCredsCache;
186
- if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
187
- _awsCredsCache = { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, sessionToken: process.env.AWS_SESSION_TOKEN };
188
- return _awsCredsCache;
189
- }
190
- try {
191
- const prof = process.env.AWS_PROFILE || "default";
192
- const out = require("node:child_process")
193
- .execSync(`aws configure export-credentials --profile ${prof}`, { timeout: 15000 })
194
- .toString();
195
- const c = JSON.parse(out);
196
- if (c.AccessKeyId && c.SecretAccessKey) {
197
- _awsCredsCache = { accessKeyId: c.AccessKeyId, secretAccessKey: c.SecretAccessKey, sessionToken: c.SessionToken };
198
- return _awsCredsCache;
199
- }
200
- } catch { /* aws CLI missing or profile unresolvable */ }
201
- _awsCredsCache = null;
202
- return null;
203
- }
204
-
205
265
  /** Minimal SigV4 signer for a POST to the Bedrock Converse API. */
206
266
  function sigv4Headers(
207
267
  creds: { accessKeyId: string; secretAccessKey: string; sessionToken?: string },
@@ -241,7 +301,7 @@ function sigv4Headers(
241
301
  */
242
302
  function makeBedrockChatFn(providerInfo: { region?: string }): ChatFn {
243
303
  return async (model, messages, options) => {
244
- const creds = resolveAwsCredentials();
304
+ const creds = await resolveAwsCredentials();
245
305
  if (!creds) throw new Error("AWS credentials not found — set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or ~/.aws/credentials");
246
306
  const region = providerInfo.region || process.env.AWS_REGION || "us-east-1";
247
307
  const host = `bedrock-runtime.${region}.amazonaws.com`;
@@ -570,8 +630,7 @@ type ProgressFn = (msg: string) => void;
570
630
  async function testReasoningExtended(chatFn: ChatFn, model: string, onProgress?: ProgressFn): Promise<{ score: string; scores: string[]; answers: string[]; results: ReasoningTestResult[] }> {
571
631
  const results: ReasoningTestResult[] = [];
572
632
  const total = REASONING_TESTS.length;
573
- for (let i = 0; i < total; i++) {
574
- const test = REASONING_TESTS[i];
633
+ for (const [i, test] of REASONING_TESTS.entries()) {
575
634
  onProgress?.(`[1/3] Reasoning ${i + 1}/${total}: ${test.name} (${test.category})...`);
576
635
  try {
577
636
  const requestedAt = new Date().toISOString();
@@ -646,8 +705,7 @@ async function testToolUsageExtended(chatFn: ChatFn, model: string, useToolResul
646
705
 
647
706
  async function testCodingLite(chatFn: ChatFn, model: string, onProgress?: (message: string) => void): Promise<{ results: Awaited<ReturnType<typeof runCodingTask>>[]; passed: number; total: number }> {
648
707
  const results = [] as Awaited<ReturnType<typeof runCodingTask>>[];
649
- for (let index = 0; index < CODING_LITE_TASKS.length; index += 1) {
650
- const task = CODING_LITE_TASKS[index];
708
+ for (const [index, task] of CODING_LITE_TASKS.entries()) {
651
709
  const prefix = `[${index + 1}/${CODING_LITE_TASKS.length}] coding-lite: [${task.id}]`;
652
710
  onProgress?.(`${prefix} starting...`);
653
711
  const result = await runCodingTask(chatFn, model, task, {
@@ -696,6 +754,9 @@ function getCurrentModel(ctx: any): string | undefined {
696
754
 
697
755
  async function testModelExtended(model: string, ctx?: any, options: SimplebenchOptions = { allModels: false, writeArtifact: true }): Promise<string> {
698
756
  const lines: string[] = [];
757
+ // The artifact lands in the session directory when Pi invoked the tool. A bare
758
+ // programmatic run has no session, so the harness directory is all there is.
759
+ const artifactDir = ctx?.cwd ?? process.cwd();
699
760
  const totalStart = Date.now();
700
761
  const providerInfo = ctx ? detectProvider(ctx) : { kind: "ollama" as const, name: "ollama" };
701
762
  const resolvedModel = resolveBenchmarkModel(ctx, model);
@@ -784,7 +845,7 @@ async function testModelExtended(model: string, ctx?: any, options: SimplebenchO
784
845
  let artifactPath: string | null = null;
785
846
  const serverStats = await captureServerStats();
786
847
  if (options.writeArtifact) {
787
- try { artifactPath = writeArtifact({ schemaVersion: 1, benchmark: { name: "opl-simplebench", suite, ...(options.tag ? { tag: options.tag } : {}), model, provider: providerInfo.name, providerKind: providerInfo.kind, thinking: { ...thinking, modelMetadataSource: resolvedModel.source }, startedAt: new Date(totalStart).toISOString(), finishedAt: new Date().toISOString(), wallTimeMs: totalMs, artifactEnabled: true }, tests: codingTestRecords(codingSummary), summary: { coding: { passed: codingSummary.passed, total: codingSummary.total, efficiency: { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length, fail: codingSummary.results.filter(r => r.efficiency === "FAIL").length } }, ...(serverStats ? { serverStats } : {}) } }); }
848
+ try { artifactPath = writeArtifact({ schemaVersion: 1, benchmark: { name: "opl-simplebench", suite, ...(options.tag ? { tag: options.tag } : {}), model, provider: providerInfo.name, providerKind: providerInfo.kind, thinking: { ...thinking, modelMetadataSource: resolvedModel.source }, startedAt: new Date(totalStart).toISOString(), finishedAt: new Date().toISOString(), wallTimeMs: totalMs, artifactEnabled: true }, tests: codingTestRecords(codingSummary), summary: { coding: { passed: codingSummary.passed, total: codingSummary.total, efficiency: { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length, fail: codingSummary.results.filter(r => r.efficiency === "FAIL").length } }, ...(serverStats ? { serverStats } : {}) } }, artifactDir); }
788
849
  catch (e: any) { lines.push(warn(`Artifact could not be written: ${e?.message || e}`)); }
789
850
  }
790
851
  const effCounts = { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length };
@@ -855,7 +916,7 @@ async function testModelExtended(model: string, ctx?: any, options: SimplebenchO
855
916
  if (options.writeArtifact) {
856
917
  try {
857
918
  const artifact = { schemaVersion: 1 as const, benchmark: { name: "opl-simplebench" as const, suite, ...(options.tag ? { tag: options.tag } : {}), model, provider: providerInfo.name, providerKind: providerInfo.kind, thinking: { ...thinking, modelMetadataSource: resolvedModel.source }, startedAt: new Date(totalStart).toISOString(), finishedAt: new Date().toISOString(), wallTimeMs: totalMs, artifactEnabled: true }, tests: artifactTests, summary: { reasoning: { score: reasoning.score, passed: reasoning.results.filter(r => r.pass).length, total: reasoning.results.length }, instructions: instructions.score, tools: tools.score, ...(codingSummary ? { coding: { passed: codingSummary.passed, total: codingSummary.total, efficiency: { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length, fail: codingSummary.results.filter(r => r.efficiency === "FAIL").length } } } : {}), ...(researchGrounded ? { researchGrounded: researchGrounded.score } : {}), ...(researchArtifact ? { researchLive: researchArtifact.score } : {}), metrics: aggregate, ...(serverStats ? { serverStats } : {}) } };
858
- artifactPath = researchGrounded ? writeArtifactBundle(artifact, researchGrounded.files) : writeArtifact(artifact);
919
+ artifactPath = researchGrounded ? writeArtifactBundle(artifact, researchGrounded.files, artifactDir) : writeArtifact(artifact, artifactDir);
859
920
  } catch (e: any) { lines.push(warn(`Artifact could not be written: ${e?.message || e}`)); }
860
921
  }
861
922
 
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { scrubbedEnv } from "./util/exec-env";
5
6
  import { buildToolResultMessages, type ChatFn, type ChatMessage } from "./util/config";
6
7
  import { mergeRequestMetrics, metricsFromChat } from "./metrics";
7
8
  import type { RequestMetrics } from "./types";
@@ -29,7 +30,7 @@ export interface CodingTaskResult {
29
30
  outputTokens: number | null;
30
31
  error: string | null;
31
32
  metrics: RequestMetrics;
32
- /** STRONG = solved in 1 turn; MODERATE = 2–3 turns; WEAK = 4–5 turns; FAIL = not solved. */
33
+ /** Efficiency grade: STRONG = 1-2 turns, MODERATE = 3-4, WEAK = the 5-turn budget, FAIL = not solved. */
33
34
  efficiency: "STRONG" | "MODERATE" | "WEAK" | "FAIL";
34
35
  }
35
36
 
@@ -105,7 +106,9 @@ export function createCodingTaskDir(task: CodingTaskFixture): string {
105
106
  }
106
107
 
107
108
  export function runCodingVerifier(task: CodingTaskFixture, root: string, mode: "public" | "hidden") {
108
- const result = spawnSync(process.execPath, ["--eval", task.verify(root, mode === "hidden")], { cwd: root, encoding: "utf8", timeout: 10_000 });
109
+ // The verifier evaluates model-authored code, so it runs with an allowlisted
110
+ // environment (see util/exec-env.ts). Anything it spawns inherits that.
111
+ const result = spawnSync(process.execPath, ["--eval", task.verify(root, mode === "hidden")], { cwd: root, encoding: "utf8", timeout: 10_000, env: scrubbedEnv() });
109
112
  const rawOutput = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
110
113
  return { passed: result.status === 0, output: result.status === 0 ? rawOutput : truncateVerifierError(rawOutput) };
111
114
  }
@@ -175,8 +178,10 @@ async function runSingleShotCodingTask(chatFn: ChatFn, model: string, task: Codi
175
178
  ]);
176
179
  const raw = response.content.trim();
177
180
  const fenced = raw.match(/```(?:[a-z]*)\n([\s\S]*?)```/);
178
- const code = fenced ? fenced[1].trim() : raw;
179
- const target = resolveCodingPath(root, task.allowedFiles[0]);
181
+ const code = fenced ? (fenced[1] ?? "").trim() : raw;
182
+ const firstAllowed = task.allowedFiles[0];
183
+ if (!firstAllowed) throw new Error(`coding task ${task.id} declares no allowed file`);
184
+ const target = resolveCodingPath(root, firstAllowed);
180
185
  fs.mkdirSync(path.dirname(target), { recursive: true });
181
186
  fs.writeFileSync(target, code + "\n");
182
187
  const publicResult = runCodingVerifier(task, root, "public");
@@ -1,7 +1,9 @@
1
1
  import type { ExtensionAPI, AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, unlinkSync } from "node:fs";
3
+ import { Type } from "typebox";
2
4
  import { debugLog } from "./util/debug";
3
5
  import { detectProvider } from "./util/providers";
4
- import { readTestConfig, TOOL_SUPPORT_CACHE_PATH, type ModelTestUserConfig, type RunSequenceProfile } from "./util/config";
6
+ import { readTestConfig, toolSupportCachePath, type ModelTestUserConfig, type RunSequenceProfile } from "./util/config";
5
7
  import type { SimplebenchOptions } from "./types";
6
8
  import { createBenchmark } from "./benchmark";
7
9
 
@@ -107,9 +109,8 @@ pi.registerCommand("simplebench", {
107
109
 
108
110
  if (arg === "--clear-cache") {
109
111
  try {
110
- const fs = require("node:fs");
111
- if (fs.existsSync(TOOL_SUPPORT_CACHE_PATH)) {
112
- fs.unlinkSync(TOOL_SUPPORT_CACHE_PATH);
112
+ if (existsSync(toolSupportCachePath())) {
113
+ unlinkSync(toolSupportCachePath());
113
114
  ctx.ui.notify("Tool support cache cleared successfully", "info");
114
115
  } else {
115
116
  ctx.ui.notify("No cache file found to clear", "info");
@@ -141,7 +142,9 @@ pi.registerCommand("simplebench", {
141
142
  ctx.ui.notify(`Cooling down ${resolved.pauseMs / 1000}s before iteration ${i + 1}/${resolved.runs.length}...`, "info");
142
143
  await new Promise(resolve => setTimeout(resolve, resolved.pauseMs));
143
144
  }
144
- const { entry, options } = resolved.runs[i];
145
+ const run = resolved.runs[i];
146
+ if (!run) continue;
147
+ const { entry, options } = run;
145
148
  const model = options.model || sequenceModel;
146
149
  ctx.ui.notify(`Iteration ${i + 1}/${resolved.runs.length} (${entry}) on ${model}...`, "info");
147
150
  try {
@@ -236,20 +239,17 @@ pi.registerTool({
236
239
  promptGuidelines: [
237
240
  "When the user asks to test or evaluate a model, call simplebench with the model name.",
238
241
  ],
239
- parameters: {
240
- type: "object",
241
- properties: {
242
- model: { type: "string", description: "Model name to test. If omitted, tests the current model." },
243
- no_artifact: { type: "boolean", description: "If true, do not write the detailed JSON audit artifact to the current working directory." },
244
- thinking_max: { type: "boolean", description: "Request maximum reasoning on an OpenAI-compatible provider or a direct Bedrock model that advertises max thinking. Omit to use provider defaults." },
245
- coding_lite: { type: "boolean", description: "Run only the six execution-backed coding tasks in disposable directories." },
246
- test_all: { type: "boolean", description: "Run the existing baseline, coding-lite, and deterministic grounded research tests." },
247
- research_live: { type: "boolean", description: "Run configured live-search research as an integration smoke test; it does not affect recommendation." },
248
- llama_server: { type: "boolean", description: "Capture /props and /metrics from configured llamaServerUrl. Inference routing is unchanged." },
249
- llamagputop: { type: "boolean", description: "Capture configured llamagputopUrl /stats. The declared endpoint is authoritative; no Pi model match is required." },
250
- tag: { type: "string", description: "Optional single-word label (letters, digits, dot, dash, underscore). Stored under benchmark.tag and prefixed onto the artifact file or bundle name." },
251
- },
252
- } as any,
242
+ parameters: Type.Object({
243
+ model: Type.Optional(Type.String({ description: "Model name to test. If omitted, tests the current model." })),
244
+ no_artifact: Type.Optional(Type.Boolean({ description: "If true, do not write the detailed JSON audit artifact to the session directory." })),
245
+ thinking_max: Type.Optional(Type.Boolean({ description: "Request maximum reasoning on an OpenAI-compatible provider or a direct Bedrock model that advertises max thinking. Omit to use provider defaults." })),
246
+ coding_lite: Type.Optional(Type.Boolean({ description: "Run only the six execution-backed coding tasks in disposable directories." })),
247
+ test_all: Type.Optional(Type.Boolean({ description: "Run the existing baseline, coding-lite, and deterministic grounded research tests." })),
248
+ research_live: Type.Optional(Type.Boolean({ description: "Run configured live-search research as an integration smoke test; it does not affect recommendation." })),
249
+ llama_server: Type.Optional(Type.Boolean({ description: "Capture /props and /metrics from configured llamaServerUrl. Inference routing is unchanged." })),
250
+ llamagputop: Type.Optional(Type.Boolean({ description: "Capture configured llamagputopUrl /stats. The declared endpoint is authoritative; no Pi model match is required." })),
251
+ tag: Type.Optional(Type.String({ description: "Optional single-word label (letters, digits, dot, dash, underscore). Stored under benchmark.tag and prefixed onto the artifact file or bundle name." })),
252
+ }),
253
253
  execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
254
254
  const params = _params as any;
255
255
  if (params?.tag !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(params.tag))) {
@@ -115,14 +115,15 @@ export function parsePrometheusMetrics(text: string): Record<string, number> {
115
115
  if (!match) continue;
116
116
  const value = Number(match[2]);
117
117
  // llama.cpp namespaces every metric as `llamacpp:<name>`; key on the bare name.
118
- if (Number.isFinite(value)) out[match[1].replace(/^llamacpp:/, "")] = value;
118
+ const name = match[1];
119
+ if (name && Number.isFinite(value)) out[name.replace(/^llamacpp:/, "")] = value;
119
120
  }
120
121
  return out;
121
122
  }
122
123
 
123
124
  export function diffPrometheusMetrics(before: Record<string, number>, after: Record<string, number>): Record<string, number> {
124
125
  const out: Record<string, number> = {};
125
- for (const key of Object.keys(after)) out[key] = after[key] - (before[key] ?? 0);
126
+ for (const key of Object.keys(after)) out[key] = (after[key] ?? 0) - (before[key] ?? 0);
126
127
  return out;
127
128
  }
128
129
 
@@ -10,12 +10,6 @@ export function formatInstructionScore(instructions: { pass: boolean; score: str
10
10
  export function formatTestSummary(tests: Array<{ name: string; pass: boolean; score: string }>, totalMs: number): string[] {
11
11
  return [section("SUMMARY"), ...tests.map(t => t.pass ? ok(`${t.name}: ${t.score}`) : fail(`${t.name}: ${t.score}`)), info(`Total time: ${msHuman(totalMs)}`), info(`Score: ${tests.filter(t => t.pass).length}/${tests.length} tests passed`)];
12
12
  }
13
- /** Legacy-compatible category recommendation. New runner uses recommendation() above. */
14
- export function formatRecommendation(model: string, passed: number, total: number): string[] {
15
- const label = passed === total ? "STRONG" : passed >= total - 1 ? "GOOD" : passed >= total - 2 ? "USABLE" : "WEAK";
16
- return [section("RECOMMENDATION"), label === "WEAK" ? fail(`${model} is ${label}`) : ok(`${model} is ${label}`)];
17
- }
18
-
19
13
  export function recommendation(reasoning: string, instructionsPass: boolean, toolsPass: boolean, coding?: { passed: number; total: number; efficiency?: { strong: number; moderate: number; weak: number; fail: number } }) {
20
14
  const reasoningPass = reasoning === "STRONG" || reasoning === "MODERATE";
21
15
  // Efficiency-weighted coding pass: STRONG=1.0, MODERATE=0.7, WEAK=0.4, FAIL=0
@@ -95,15 +95,16 @@ export function verifyGroundedResearch(markdown: string, fixture: GroundedResear
95
95
  const findings = markdown.match(/##\s+Findings\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] ?? "";
96
96
  const sourceSection = markdown.match(/##\s+Sources\s*\n([\s\S]*?)(?=\n##\s|$)/i)?.[1] ?? "";
97
97
  const citedClaims = [...findings.matchAll(/^\s*[-*]\s+(.+?)\s+\[([A-Za-z0-9]+)\]\s*$/gm)];
98
- const unknownCitations = citedClaims.map(match => match[2]).filter(id => !knownSources.has(id));
98
+ const citationIds = citedClaims.map((match) => match[2] ?? "");
99
+ const unknownCitations = citationIds.filter((id) => !knownSources.has(id));
99
100
  const missingClaims: string[] = [];
100
101
  const wrongCitations: string[] = [];
101
102
  for (const claim of fixture.claims) {
102
- const match = citedClaims.find(item => normalizeClaim(item[1]) === normalizeClaim(claim.text));
103
+ const match = citedClaims.find((item) => normalizeClaim(item[1] ?? "") === normalizeClaim(claim.text));
103
104
  if (!match) missingClaims.push(claim.text);
104
- else if (match[2] !== claim.sourceId) wrongCitations.push(claim.text);
105
+ else if ((match[2] ?? "") !== claim.sourceId) wrongCitations.push(claim.text);
105
106
  }
106
- for (const id of new Set(citedClaims.map(match => match[2]).filter(id => knownSources.has(id)))) {
107
+ for (const id of new Set(citationIds.filter((id) => knownSources.has(id)))) {
107
108
  const source = knownSources.get(id)!;
108
109
  if (!new RegExp(`\\[${id}\\]\\(${source.url.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`).test(sourceSection)) wrongCitations.push(id);
109
110
  }
@@ -6,7 +6,7 @@ const NUMERIC_ANSWER = /^\d+$/;
6
6
 
7
7
  function lastNumericToken(line: string): string | null {
8
8
  const tokens = line.match(/\d+/g);
9
- return tokens && tokens.length ? tokens[tokens.length - 1] : null;
9
+ return tokens?.[tokens.length - 1] ?? null;
10
10
  }
11
11
 
12
12
  export function extractAnswer(response: string, expected?: string): { answer: string; method: string } {
@@ -16,7 +16,9 @@ export function extractAnswer(response: string, expected?: string): { answer: st
16
16
  // bare-number remnants that thinking templates leak after the real final line.
17
17
  if (expected && NUMERIC_ANSWER.test(expected)) {
18
18
  for (let i = lines.length - 1; i >= 0; i -= 1) {
19
- const token = lastNumericToken(lines[i]);
19
+ const line = lines[i];
20
+ if (line === undefined) continue;
21
+ const token = lastNumericToken(line);
20
22
  if (token === expected) {
21
23
  return { answer: token, method: i === lines.length - 1 ? "final-line" : "expected-last-numeric" };
22
24
  }
@@ -8,8 +8,8 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
- import * as os from "node:os";
12
11
  import * as path from "node:path";
12
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
 
14
14
  // ============================================================================
15
15
  // Configuration Constants
@@ -59,9 +59,15 @@ export const CONFIG = {
59
59
  // User Configuration Overrides
60
60
  // ============================================================================
61
61
 
62
- const TEST_CONFIG_DIR = process.env.PI_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
63
- export const TEST_CONFIG_PATH = path.join(TEST_CONFIG_DIR, "configs", "opl-simplebench.json");
64
- const LEGACY_TEST_CONFIG_PATH = path.join(TEST_CONFIG_DIR, "simplebench-config.json");
62
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
63
+ export function configPath(): string {
64
+ return path.join(getAgentDir(), "configs", "opl-simplebench.json");
65
+ }
66
+
67
+ /** Pre-rename location, still read when the current path is absent. */
68
+ function legacyConfigPath(): string {
69
+ return path.join(getAgentDir(), "simplebench-config.json");
70
+ }
65
71
 
66
72
  /** Shape of the user configuration file. */
67
73
  export interface ModelTestUserConfig {
@@ -107,8 +113,8 @@ export interface RunSequenceProfile {
107
113
  */
108
114
  export function readTestConfig(): ModelTestUserConfig {
109
115
  try {
110
- const configPath = fs.existsSync(TEST_CONFIG_PATH) ? TEST_CONFIG_PATH : LEGACY_TEST_CONFIG_PATH;
111
- if (fs.existsSync(configPath)) return JSON.parse(fs.readFileSync(configPath, "utf-8")) as ModelTestUserConfig;
116
+ const file = fs.existsSync(configPath()) ? configPath() : legacyConfigPath();
117
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf-8")) as ModelTestUserConfig;
112
118
  } catch { /* config read/parse failure is non-critical — defaults are used */ }
113
119
  return {};
114
120
  }
@@ -161,8 +167,9 @@ export const WEATHER_TOOL_DEFINITION = {
161
167
  // Tool Support Cache (path only — backing file is no longer written)
162
168
  // ============================================================================
163
169
 
164
- const TOOL_SUPPORT_CACHE_DIR = path.join(os.homedir(), ".pi", "agent", "cache");
165
- export const TOOL_SUPPORT_CACHE_PATH = path.join(TOOL_SUPPORT_CACHE_DIR, "tool_support.json");
170
+ export function toolSupportCachePath(): string {
171
+ return path.join(getAgentDir(), "cache", "tool_support.json");
172
+ }
166
173
 
167
174
  // ============================================================================
168
175
  // ChatFn Abstraction
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Environment for processes that run model-authored code.
3
+ *
4
+ * `coding_lite` and `test_all` write the model's answer to disk and execute it,
5
+ * so whatever the model wrote runs with this process's parent environment. The
6
+ * harness is normally started from a shell that holds provider keys, cloud
7
+ * credentials and agent paths, and none of those belong in front of generated
8
+ * code. This is therefore an allowlist, not a denylist: anything not listed here
9
+ * is absent from the child, including every `*_API_KEY`, `AWS_*`, `GH_*` and
10
+ * `PI_*` variable.
11
+ *
12
+ * `NODE_OPTIONS` and `NODE_PATH` are deliberately never forwarded: both can make
13
+ * Node load attacker-chosen files.
14
+ */
15
+ export const INHERITED_KEYS = [
16
+ // POSIX: node resolution, temp files, locale and timezone.
17
+ "PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ", "SHELL", "USER", "LOGNAME",
18
+ // TLS trust the child may legitimately need.
19
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
20
+ // Terminal capabilities only; no secrets live here.
21
+ "TERM", "NO_COLOR", "FORCE_COLOR", "COLORTERM",
22
+ // Windows: spawning and node resolution fail without these.
23
+ "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "USERPROFILE", "HOMEDRIVE", "HOMEPATH",
24
+ "APPDATA", "LOCALAPPDATA",
25
+ ];
26
+
27
+ /** Build the environment for a child that executes untrusted, model-authored code. */
28
+ export function scrubbedEnv(base: NodeJS.ProcessEnv = process.env): Record<string, string> {
29
+ const out: Record<string, string> = {};
30
+ for (const key of INHERITED_KEYS) {
31
+ const value = base[key];
32
+ // NUL-containing values are rejected by spawn(); drop them instead of throwing.
33
+ if (typeof value === "string" && value.length > 0 && !value.includes("\0")) out[key] = value;
34
+ }
35
+ return out;
36
+ }
@@ -258,7 +258,7 @@ export function sanitizeForReport(s: string, maxLines = 40): string {
258
258
  // Use strict detection: require a closing tag AND a common HTML opening tag to avoid
259
259
  // false positives on code that mentions HTML-like syntax (e.g., "use <Item> from 'react'")
260
260
  if (/<!DOCTYPE\b|<html[\s>]/i.test(cleaned) || (/<[a-z][\s\S]*>/i.test(cleaned) && cleaned.includes("</") && /<(?:div|span|p|head|body|html|table|form|script)\b/i.test(cleaned))) {
261
- const firstLine = cleaned.split("\n")[0];
261
+ const firstLine = cleaned.split("\n")[0] ?? "";
262
262
  return truncate(firstLine, 200) + "\n ℹ️ (HTML response truncated)";
263
263
  }
264
264
 
@@ -365,7 +365,7 @@ function parseParamCount(s: string): number | undefined {
365
365
  const match = str.match(/^([\d.]+)\s*([bmt]?|a(?:pple)?)$/);
366
366
  if (!match) return undefined;
367
367
 
368
- const num = parseFloat(match[1]);
368
+ const num = parseFloat(match[1] ?? "");
369
369
  if (isNaN(num) || num <= 0) return undefined;
370
370
 
371
371
  const suffix = match[2];