@yagni-app/code-staging 1.2.3-staging.1700.1 → 1.2.3-staging.1704.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.
@@ -24,7 +24,7 @@
24
24
  * pattern) so tests run against plain text. Renderer exceptions are swallowed
25
25
  * by pi (degrading to the built-in fallback), so everything stays total.
26
26
  */
27
- import { SettingsManager, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
27
+ import { SettingsManager, type BashSpawnHook, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
28
28
  import type { RenderTheme } from "./subagentRender.js";
29
29
  import { ToolRunTracker } from "./toolRuns.js";
30
30
  /** Lines of write content shown collapsed (mirrors Claude Code's preview). */
@@ -96,6 +96,13 @@ export interface RegisterCondensedToolsDeps {
96
96
  * model's tool calls.
97
97
  */
98
98
  wrapBash?: (def: ToolDefinition) => ToolDefinition;
99
+ /**
100
+ * A spawn hook on the base bash definition (the build sandbox's toolchain
101
+ * hook), so the model's commands get the repository's toolchain even when
102
+ * the sandbox composition is identity. The sandbox, when on, builds its own
103
+ * definition and composes the same hook before its proxy hook.
104
+ */
105
+ bashSpawnHook?: BashSpawnHook;
99
106
  }
100
107
  /**
101
108
  * Re-register the seven built-ins with condensed renderers. Returns the
@@ -357,6 +357,8 @@ export function registerCondensedTools(pi, deps = {}) {
357
357
  catch {
358
358
  toolOptions = { bash: {}, read: {} };
359
359
  }
360
+ if (deps.bashSpawnHook)
361
+ toolOptions.bash = { ...toolOptions.bash, spawnHook: deps.bashSpawnHook };
360
362
  const buildDefinitions = (cwd) => {
361
363
  const defs = {
362
364
  read: createReadToolDefinition(cwd, toolOptions.read),
@@ -44,6 +44,7 @@ import { isSandboxDenialOutput, sandboxAutoAllowDecision, sandboxDenialSignature
44
44
  import { sandboxFailureKey } from "./permission/approvedPrefixes.js";
45
45
  import { createEscapeTally } from "./sandbox/escapeTally.js";
46
46
  import { createGrantCensus } from "./permission/grantCensus.js";
47
+ import { toolchainSpawnHookFromEnv, toolchainUserBashOperations } from "./toolchain/spawnHook.js";
47
48
  import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
48
49
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
49
50
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
@@ -900,11 +901,17 @@ export async function registerYagni(pi, deps = {}) {
900
901
  // surfacing. Nothing is pruned, nothing is written back to rules.json.
901
902
  const grantCensus = createGrantCensus();
902
903
  grantCensus.seed(sessionGrants);
904
+ // A build sandbox's toolchain (spec 2026-10-08 §1): when the harness names a
905
+ // readable toolchain file, every bash spawn and `!` command runs on the
906
+ // repository's PATH and variables while this process stays on the image's
907
+ // Node. Composed before the sandbox's proxy hook. Absent everywhere else.
908
+ const toolchainHook = evalMode ? undefined : toolchainSpawnHookFromEnv(env);
903
909
  const sandboxHandle = evalMode
904
910
  ? null
905
911
  : registerSandbox(pi, {
906
912
  cwd: process.cwd(),
907
913
  env,
914
+ ...(toolchainHook ? { baseSpawnHook: toolchainHook } : {}),
908
915
  hasUI: (ctx) => ctx.hasUI,
909
916
  onShellResolutionRetry: (outcome) => telemetry.sandboxShellResolutionRetry(outcome),
910
917
  escapeBreakdown: () => escapeTally.breakdown(),
@@ -918,6 +925,13 @@ export async function registerYagni(pi, deps = {}) {
918
925
  });
919
926
  if (sandboxHandle)
920
927
  sandboxHandle.setRules(loadedRules.rules);
928
+ if (toolchainHook) {
929
+ // After the sandbox's own handler: a sandboxed `!` command is answered
930
+ // there (with this hook applied); any other runs here on the toolchain.
931
+ pi.on("user_bash", () => ({
932
+ operations: toolchainUserBashOperations(toolchainHook, process.cwd()),
933
+ }));
934
+ }
921
935
  const permissionGate = registerPermissionGate(pi, {
922
936
  ...(sandboxHandle
923
937
  ? {
@@ -1528,6 +1542,7 @@ export async function registerYagni(pi, deps = {}) {
1528
1542
  // built-ins last), so the sandbox composes INTO it here. Identity when
1529
1543
  // the sandbox is off.
1530
1544
  ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
1545
+ ...(toolchainHook ? { bashSpawnHook: toolchainHook } : {}),
1531
1546
  });
1532
1547
  condensedToolsRegistered = true;
1533
1548
  }
@@ -11,7 +11,7 @@
11
11
  * some providers). /sandbox (M5) toggles by rewriting settings and asking
12
12
  * for a session restart, the same way Claude persists the toggle.
13
13
  */
14
- import { type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
14
+ import { type BashSpawnHook, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
15
15
  import { type SandboxSettings } from "./config.js";
16
16
  import { YagniSandboxManager } from "./manager.js";
17
17
  import { type SandboxNetworkChoice } from "./panel.js";
@@ -31,7 +31,7 @@ import type { PermissionRule } from "../permissionRules/loadConfig.js";
31
31
  export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void, fsScope?: () => {
32
32
  allowWrite: readonly string[];
33
33
  denyWrite: readonly string[];
34
- } | null): (def: ToolDefinition) => ToolDefinition;
34
+ } | null, baseSpawnHook?: BashSpawnHook): (def: ToolDefinition) => ToolDefinition;
35
35
  export interface SandboxSessionHandle {
36
36
  manager: YagniSandboxManager;
37
37
  settings: () => SandboxSettings;
@@ -94,6 +94,9 @@ export interface RegisterSandboxOptions {
94
94
  * the /sandbox panel's grants section — the never-matched surfacing.
95
95
  * Absent ⇒ no grants section. */
96
96
  grantCensus?: () => import("../permission/grantCensus.js").GrantCensusEntry[];
97
+ /** A spawn hook every bash spawn runs before the sandbox's own (the
98
+ * toolchain hook in a build sandbox). Absent: the sandbox hook alone. */
99
+ baseSpawnHook?: BashSpawnHook;
97
100
  }
98
101
  /**
99
102
  * Register the sandbox surfaces on the ExtensionAPI. Returns the session
@@ -19,6 +19,7 @@ import { Key } from "@earendil-works/pi-tui";
19
19
  import { createBashToolDefinition, getShellConfig, SettingsManager, } from "@earendil-works/pi-coding-agent";
20
20
  import { bgLaunchResult, startBackgroundJob } from "../bgJobs.js";
21
21
  import { logEvent } from "../errorSink.js";
22
+ import { composeSpawnHooks } from "../toolchain/spawnHook.js";
22
23
  import { codeStateHome } from "../stateHome.js";
23
24
  import { isDebug } from "../diagnostics.js";
24
25
  import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
@@ -40,13 +41,14 @@ import { effectiveRules } from "../permissionRules/loadConfig.js";
40
41
  * here would be overwritten and the sandbox would silently never reach
41
42
  * model-driven tool calls.
42
43
  */
43
- export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry, fsScope) {
44
+ export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry, fsScope, baseSpawnHook) {
44
45
  return (def) => {
45
46
  if (!settings().enabled)
46
47
  return def;
47
48
  const userShellPath = SettingsManager.create(cwd).getShellPath();
48
49
  const { shell: binShell } = getShellConfig(userShellPath);
49
- const spawnHook = makeSandboxSpawnHook(manager);
50
+ // The toolchain hook (when the session has one) first, then the proxy env on top.
51
+ const spawnHook = composeSpawnHooks(baseSpawnHook, makeSandboxSpawnHook(manager));
50
52
  const sandboxBash = createBashToolDefinition(cwd, {
51
53
  shellPath: userShellPath,
52
54
  spawnHook,
@@ -460,10 +462,17 @@ export function registerSandbox(pi, opts) {
460
462
  logShellResolutionRetry(outcome);
461
463
  opts.onShellResolutionRetry?.(outcome);
462
464
  };
463
- const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry, fsScope);
465
+ const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry, fsScope, opts.baseSpawnHook);
464
466
  const registerOwnBash = () => {
465
- if (!currentSettings.enabled)
467
+ if (!currentSettings.enabled) {
468
+ // Sandbox off: stock bash stays, except that a base hook (the build
469
+ // sandbox's toolchain) still has to reach the model's spawns.
470
+ if (!opts.baseSpawnHook)
471
+ return;
472
+ const userShellPath = SettingsManager.create(opts.cwd).getShellPath();
473
+ pi.registerTool(createBashToolDefinition(opts.cwd, { shellPath: userShellPath, spawnHook: opts.baseSpawnHook }));
466
474
  return;
475
+ }
467
476
  const userShellPath = SettingsManager.create(opts.cwd).getShellPath();
468
477
  const stockBash = createBashToolDefinition(opts.cwd, { shellPath: userShellPath });
469
478
  pi.registerTool(composeBash(stockBash));
@@ -500,9 +509,11 @@ export function registerSandbox(pi, opts) {
500
509
  signal,
501
510
  onRetry: onShellResolutionRetry,
502
511
  });
512
+ const baseEnv = env ?? process.env;
503
513
  const child = spawn(shell, [...args, wrapped], {
504
514
  cwd,
505
- env: env ?? process.env,
515
+ // The toolchain's PATH and variables when the session has a base hook.
516
+ env: opts.baseSpawnHook ? opts.baseSpawnHook({ command, cwd, env: baseEnv }).env : baseEnv,
506
517
  detached: true,
507
518
  stdio: ["ignore", "pipe", "pipe"],
508
519
  });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The toolchain spawn hook (spec 2026-10-08 §1, "Two runtimes, one hand-off").
3
+ *
4
+ * In Wright's sandbox our CLI and pi run on the image's own Node (the harness
5
+ * puts `/opt/yagni/runtime-bin` first on their PATH), while the commands the
6
+ * model runs belong on the repository's toolchain: its Node 20, its Go 1.24.
7
+ * The backend writes the plan's PATH and environment to a toolchain file
8
+ * before setup, and the harness names it in `YAGNI_TOOLCHAIN_FILE`. This hook
9
+ * hands that PATH and environment to every bash spawn (and `user_bash`); the
10
+ * command string is never touched, so the Guardian reviews exactly what the
11
+ * model wrote. It composes before the sandbox's proxy hook, so the proxy
12
+ * variables still layer on top.
13
+ *
14
+ * Without the variable (a developer's own terminal) nothing changes.
15
+ *
16
+ * The format's producer is `toolchainFileContents` in
17
+ * `packages/shared/src/toolchainFile.ts`; this package does not depend on
18
+ * @yagni/shared, so it reads the same `{ version: 1, path, env, tools }` with
19
+ * its own parser. A change there is a change here.
20
+ */
21
+ import { type BashOperations, type BashSpawnHook } from "@earendil-works/pi-coding-agent";
22
+ import { type SinkEvent } from "../errorSink.js";
23
+ export interface ToolchainFile {
24
+ version: 1;
25
+ /** The plan's whole PATH, system directories included. */
26
+ path: string;
27
+ /** Every other toolchain variable; never PATH. */
28
+ env: Record<string, string>;
29
+ tools: Array<{
30
+ name: string;
31
+ version: string;
32
+ origin: string;
33
+ }>;
34
+ }
35
+ type Log = (event: SinkEvent) => void;
36
+ /** The file, or null when the text is not a well-formed version 1 file. */
37
+ export declare function parseToolchainFile(text: string): ToolchainFile | null;
38
+ /**
39
+ * Read the toolchain file synchronously (activation is sync). Fail-soft: a
40
+ * missing, unreadable or malformed file is one `toolchain.file_unreadable`
41
+ * line and null, and the session runs exactly as it would without the file.
42
+ */
43
+ export declare function readToolchainFile(path: string, log?: Log): ToolchainFile | null;
44
+ /**
45
+ * `env.PATH` becomes the file's PATH and the file's other variables merge
46
+ * beneath the spawn's own environment. `command` and `cwd` pass through.
47
+ */
48
+ export declare function makeToolchainSpawnHook(file: ToolchainFile): BashSpawnHook;
49
+ /** The hooks applied in order (first to last), skipping absent ones; undefined when none is present. */
50
+ export declare function composeSpawnHooks(...hooks: Array<BashSpawnHook | undefined>): BashSpawnHook | undefined;
51
+ /**
52
+ * The hook for this process: present only when `YAGNI_TOOLCHAIN_FILE` names a
53
+ * readable, well-formed file. Unset is silent (not a sandbox); set but
54
+ * unreadable logs once.
55
+ */
56
+ export declare function toolchainSpawnHookFromEnv(env?: NodeJS.ProcessEnv, log?: Log): BashSpawnHook | undefined;
57
+ /**
58
+ * `user_bash` (`!` commands) through pi's own local backend, with the hook's
59
+ * environment: the same toolchain the model's bash tool gets.
60
+ */
61
+ export declare function toolchainUserBashOperations(hook: BashSpawnHook, cwd: string): BashOperations;
62
+ export {};
63
+ //# sourceMappingURL=spawnHook.d.ts.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The toolchain spawn hook (spec 2026-10-08 §1, "Two runtimes, one hand-off").
3
+ *
4
+ * In Wright's sandbox our CLI and pi run on the image's own Node (the harness
5
+ * puts `/opt/yagni/runtime-bin` first on their PATH), while the commands the
6
+ * model runs belong on the repository's toolchain: its Node 20, its Go 1.24.
7
+ * The backend writes the plan's PATH and environment to a toolchain file
8
+ * before setup, and the harness names it in `YAGNI_TOOLCHAIN_FILE`. This hook
9
+ * hands that PATH and environment to every bash spawn (and `user_bash`); the
10
+ * command string is never touched, so the Guardian reviews exactly what the
11
+ * model wrote. It composes before the sandbox's proxy hook, so the proxy
12
+ * variables still layer on top.
13
+ *
14
+ * Without the variable (a developer's own terminal) nothing changes.
15
+ *
16
+ * The format's producer is `toolchainFileContents` in
17
+ * `packages/shared/src/toolchainFile.ts`; this package does not depend on
18
+ * @yagni/shared, so it reads the same `{ version: 1, path, env, tools }` with
19
+ * its own parser. A change there is a change here.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { createLocalBashOperations, SettingsManager, } from "@earendil-works/pi-coding-agent";
23
+ import { logEvent } from "../errorSink.js";
24
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+ /** The file, or null when the text is not a well-formed version 1 file. */
26
+ export function parseToolchainFile(text) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(text);
30
+ }
31
+ catch {
32
+ // Not JSON: the caller logs the null.
33
+ return null;
34
+ }
35
+ if (!isRecord(parsed) || parsed.version !== 1)
36
+ return null;
37
+ const { path, env, tools } = parsed;
38
+ if (typeof path !== "string" || path.length === 0)
39
+ return null;
40
+ if (!isRecord(env) || "PATH" in env)
41
+ return null;
42
+ const parsedEnv = {};
43
+ for (const [name, value] of Object.entries(env)) {
44
+ if (typeof value !== "string")
45
+ return null;
46
+ parsedEnv[name] = value;
47
+ }
48
+ if (!Array.isArray(tools))
49
+ return null;
50
+ const parsedTools = [];
51
+ for (const tool of tools) {
52
+ if (!isRecord(tool))
53
+ return null;
54
+ const { name, version, origin } = tool;
55
+ if (typeof name !== "string" || typeof version !== "string" || typeof origin !== "string")
56
+ return null;
57
+ parsedTools.push({ name, version, origin });
58
+ }
59
+ return { version: 1, path, env: parsedEnv, tools: parsedTools };
60
+ }
61
+ /**
62
+ * Read the toolchain file synchronously (activation is sync). Fail-soft: a
63
+ * missing, unreadable or malformed file is one `toolchain.file_unreadable`
64
+ * line and null, and the session runs exactly as it would without the file.
65
+ */
66
+ export function readToolchainFile(path, log = logEvent) {
67
+ let text;
68
+ try {
69
+ text = readFileSync(path, "utf8");
70
+ }
71
+ catch (err) {
72
+ const code = isRecord(err) && typeof err.code === "string" ? err.code : "read_failed";
73
+ log({ source: "toolchain", level: "warn", event: "file_unreadable", fields: { path, reason: code } });
74
+ return null;
75
+ }
76
+ const file = parseToolchainFile(text);
77
+ if (!file)
78
+ log({ source: "toolchain", level: "warn", event: "file_unreadable", fields: { path, reason: "malformed" } });
79
+ return file;
80
+ }
81
+ /**
82
+ * `env.PATH` becomes the file's PATH and the file's other variables merge
83
+ * beneath the spawn's own environment. `command` and `cwd` pass through.
84
+ */
85
+ export function makeToolchainSpawnHook(file) {
86
+ return (ctx) => ({
87
+ ...ctx,
88
+ env: { ...file.env, ...ctx.env, PATH: file.path },
89
+ });
90
+ }
91
+ /** The hooks applied in order (first to last), skipping absent ones; undefined when none is present. */
92
+ export function composeSpawnHooks(...hooks) {
93
+ const present = hooks.filter((hook) => hook !== undefined);
94
+ if (present.length === 0)
95
+ return undefined;
96
+ if (present.length === 1)
97
+ return present[0];
98
+ return (ctx) => present.reduce((acc, hook) => hook(acc), ctx);
99
+ }
100
+ /**
101
+ * The hook for this process: present only when `YAGNI_TOOLCHAIN_FILE` names a
102
+ * readable, well-formed file. Unset is silent (not a sandbox); set but
103
+ * unreadable logs once.
104
+ */
105
+ export function toolchainSpawnHookFromEnv(env = process.env, log = logEvent) {
106
+ const path = env.YAGNI_TOOLCHAIN_FILE?.trim();
107
+ if (!path)
108
+ return undefined;
109
+ const file = readToolchainFile(path, log);
110
+ return file ? makeToolchainSpawnHook(file) : undefined;
111
+ }
112
+ /**
113
+ * `user_bash` (`!` commands) through pi's own local backend, with the hook's
114
+ * environment: the same toolchain the model's bash tool gets.
115
+ */
116
+ export function toolchainUserBashOperations(hook, cwd) {
117
+ // The user's configured shell, as the sandbox's own `!` path resolves it.
118
+ const shellPath = SettingsManager.create(cwd).getShellPath();
119
+ const local = createLocalBashOperations(shellPath ? { shellPath } : undefined);
120
+ return {
121
+ exec: (command, cwd, options) => local.exec(command, cwd, {
122
+ ...options,
123
+ env: hook({ command, cwd, env: options.env ?? process.env }).env,
124
+ }),
125
+ };
126
+ }
127
+ //# sourceMappingURL=spawnHook.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.3-staging.1700.1",
3
+ "version": "1.2.3-staging.1704.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "ca2b75c6072f31b7a6c2ee58fa166ce63b0cefca"
61
+ "yagniSourceSha": "3f84bd363ca5a3acb05e96fa4e413c11f1920886"
62
62
  }