pi-roundtable-coding 0.32.1 → 0.34.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.0] - 2026-11-04
6
+
7
+ ### Changed
8
+
9
+ - A worker's `bash` follows the core's empty `DEFAULT_SHELL_PASS`: it gets no `GH_TOKEN` unless the host's `shellEnv.pass` names it. The shelf's git and gh calls keep getting `githubToken` as `GH_TOKEN`.
10
+
11
+ ## [0.33.0] - 2026-11-04
12
+
13
+ ### Changed
14
+
15
+ - The worker process no longer inherits the host's environment: it gets the shell's whitelist (`SHELL_ENV_NAMES`), `PI_CODING_AGENT_DIR`, `TMPDIR`, the variables its model may read, found by the new `modelEnvNames(agentDir)` (each `$VAR` a `models.json` in Pi's agent dir names, and `CLAUDE_*` and `ANTHROPIC_*`), and the names the new option `workerEnv: { pass? }` lists (type `WorkerEnv`). Its `bash` gets only the whitelist and the host's `shellEnv`, never the model's credentials, and its tool results are masked like the host's. **Migration:** a worker on Pi's `auth.json` login, a `models.json` `$VAR` key, or a Claude Code or Anthropic variable needs nothing; a model that reads another variable needs it named, as in `workerEnv: { pass: ["OPENAI_API_KEY"] }`.
16
+ - The shelf's git and gh calls (clone, fetch, push, `repo_list` fetch) get the whitelist, git's config, identity and ssh variables, and a GitHub token as `GH_TOKEN` from the new option `githubToken` (a string or a function; type `GithubToken`), by default the host's `GH_TOKEN` read when the plugin sets up, so existing hosts keep working. No other host secret reaches them.
17
+
5
18
  ## [0.32.1] - 2026-11-04
6
19
 
7
20
  - Release in lockstep with pi-roundtable 0.32.1 (optimistic mark-as-read, a Home button at the foot of the room drawer, Discord's -# subtext and line breaks in messages, the shared prompt's description in zh-TW, and the push device list centred); no package-specific behavior changes.
package/README.md CHANGED
@@ -7,18 +7,19 @@ This package is a reference for the plugin guide's [Helpers for a Pi session of
7
7
  ## Requirements
8
8
 
9
9
  - Bun 1.4.4 or newer on a POSIX host (Linux or macOS).
10
- - pi-roundtable `>=0.32.0 <0.33.0` as a peer dependency; this package uses only its public main, kit and testing entries.
10
+ - pi-roundtable `>=0.34.0 <0.35.0` as a peer dependency; this package uses only its public main, kit and testing entries.
11
11
  - Pi `>=1.0.0 <2`, shared with the host's core dependencies.
12
12
  A core-only host still pinned to Pi 0.99.x must update its Pi dependencies before adding coding, so cross-package extension and session types resolve to one Pi version.
13
13
  - Git, plus GitHub CLI (`gh`) for the default clone implementation.
14
14
  - A Git host token with read access to repositories being cloned and write access to those being pushed, or a host login holding that token.
15
- - Pi model credentials available through the host's Pi login or the provider's environment variables.
15
+ - Pi model credentials available through the host's Pi login, a `models.json` `$VAR`, a `CLAUDE_*` or `ANTHROPIC_*` variable, or another variable named in `workerEnv.pass`.
16
16
 
17
- The default clone uses `gh repo clone owner/repo` and inherits `GH_TOKEN` or the host's `gh auth login` credentials.
18
- Configure Git's credential helper for later `git fetch` and `git push` (for GitHub, `gh auth setup-git`).
19
- Use a least-privilege token and restrict its repositories; tokens are never package options, tool arguments or remote URLs.
17
+ The default clone uses `gh repo clone owner/repo`, which authenticates with `githubToken` given to it as `GH_TOKEN` (by default the host's `GH_TOKEN`, read when the plugin sets up) or with the host's `gh auth login` credentials.
18
+ Configure Git's credential helper for later `git fetch` and `git push` (for GitHub, `gh auth setup-git`); those git calls get the same `GH_TOKEN`.
19
+ The shelf's git and gh calls get the shell's environment whitelist (`SHELL_ENV_NAMES`), git's own config, identity and ssh variables, and that token, and no other host secret.
20
+ Use a least-privilege token and restrict its repositories; tokens are never tool arguments or remote URLs, and prefer a `githubToken` function that reads it from where you keep it to a literal string.
20
21
  The package does not print subprocess output or Git authentication diagnostics and never logs credentials.
21
- The coding worker inherits the host environment and can read files accessible to that host user: do not give it credentials you would not give a trusted coding agent.
22
+ The coding worker process gets the shell's whitelist, `PI_CODING_AGENT_DIR`, the variables its model may read (`modelEnvNames`: each `$VAR` its agent dir's `models.json` names, `CLAUDE_*` and `ANTHROPIC_*`) and the names in `workerEnv.pass`; its `bash` gets only the whitelist and the host's `shellEnv`, never the model's credentials. It can still read files accessible to that host user: do not give it credentials you would not give a trusted coding agent.
22
23
  A custom `clone(repo, destination)` can use another Git host's CLI, credential helper or SSH login, and must create a Git clone with an `origin` remote.
23
24
  No network calls are needed by the offline test suite.
24
25
 
@@ -218,6 +219,8 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
218
219
  | `skipUnavailableCarriedSkills` | `false` | Skip and disclose unavailable implicit skills; explicit skill requests still refuse them. |
219
220
  | `workerPackages` | `[]` | Absolute installed extension paths. |
220
221
  | `agentDir` | Pi host directory | Pi credentials and model configuration. |
222
+ | `workerEnv` | `{}` | `{ pass? }`: more host variables the worker process gets for its model, beyond its `models.json` `$VAR`s, `CLAUDE_*` and `ANTHROPIC_*`; the worker's shell never gets them. |
223
+ | `githubToken` | host `GH_TOKEN`, read at setup | A string, or a function read at each call, given as `GH_TOKEN` to the shelf's git and gh calls only (clone, fetch, push); a function returning undefined leaves gh to its own login. |
221
224
  | `timeoutMs` | `3600000` | Work-time limit in ms, excluding owner wait; 0 < value <= 2147483647. |
222
225
  | `maxWorkers` | `4` | Workers running at once on the host; a whole number of at least 1. |
223
226
  | `maxQueued` | `16` | Tasks waiting for a worker slot; a whole number of at least 0. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-coding",
3
- "version": "0.32.1",
3
+ "version": "0.34.0",
4
4
  "description": "Repository shelves and owner-approved Pi coding workers for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,13 +38,13 @@
38
38
  "typebox": "1.3.37"
39
39
  },
40
40
  "peerDependencies": {
41
- "pi-roundtable": ">=0.32.0 <0.33.0"
41
+ "pi-roundtable": ">=0.34.0 <0.35.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@earendil-works/pi-ai": ">=1.0.0 <2",
45
45
  "@biomejs/biome": "2.5.15",
46
46
  "@types/bun": "1.4.3",
47
- "pi-roundtable": "0.32.1",
47
+ "pi-roundtable": "0.34.0",
48
48
  "typescript": "7.0.2"
49
49
  }
50
50
  }
@@ -34,10 +34,11 @@ import {
34
34
  codingReport,
35
35
  MAX_CODING_TASK_CHARS,
36
36
  } from "./coding-desk.ts";
37
- import { PiCodingWorker } from "./pi-coding-worker.ts";
37
+ import { PiCodingWorker, type WorkerEnv } from "./pi-coding-worker.ts";
38
38
  import {
39
39
  type ChangeReport,
40
40
  type CloneCommand,
41
+ type GithubToken,
41
42
  RepoShelf,
42
43
  type RepoSummary,
43
44
  reportPost,
@@ -129,6 +130,18 @@ export interface CodingOptions {
129
130
  /** Absolute installed extension package paths. Default: none. */
130
131
  workerPackages?: string[];
131
132
  agentDir?: string;
133
+ /**
134
+ * More host variables the worker process gets for its model, by name; its shell never gets them.
135
+ * Without it the worker already gets the shell's whitelist, `PI_CODING_AGENT_DIR`, the `$VAR`s
136
+ * its agent dir's `models.json` names, and `CLAUDE_*` and `ANTHROPIC_*`.
137
+ */
138
+ workerEnv?: WorkerEnv;
139
+ /**
140
+ * The token git and gh calls of the shelf (clone, fetch, push) get as GH_TOKEN, or a function
141
+ * read at each call; no other host secret reaches them. Default: the host's GH_TOKEN, read when
142
+ * the plugin sets up.
143
+ */
144
+ githubToken?: GithubToken;
132
145
  timeoutMs?: number;
133
146
  /** Workers running at once on the host; default 4. More tasks wait in a queue. */
134
147
  maxWorkers?: number;
@@ -230,6 +243,9 @@ export function coding(options: CodingOptions) {
230
243
  setup(context) {
231
244
  const shelf = new RepoShelf(options.shelfDir, options.clone, {
232
245
  diagnosticChars: options.diagnosticChars,
246
+ ...(options.githubToken !== undefined
247
+ ? { githubToken: options.githubToken }
248
+ : {}),
233
249
  });
234
250
  for (const adoption of options.adoptClones ?? [])
235
251
  shelf.adopt(adoption.from, adoption.repo);
@@ -243,6 +259,8 @@ export function coding(options: CodingOptions) {
243
259
  ...(context.env.scratchDir
244
260
  ? { scratchDir: context.env.scratchDir }
245
261
  : {}),
262
+ ...(options.workerEnv ? { workerEnv: options.workerEnv } : {}),
263
+ ...(context.env.shellEnv ? { shellEnv: context.env.shellEnv } : {}),
246
264
  prompt: options.workerPrompt,
247
265
  blockText: options.workerBlockText,
248
266
  diagnosticChars: options.diagnosticChars,
package/src/index.ts CHANGED
@@ -23,11 +23,12 @@ export type {
23
23
  RepoToolName,
24
24
  } from "./coding-plugin.ts";
25
25
  export { CODING, coding } from "./coding-plugin.ts";
26
- export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
27
- export { PiCodingWorker } from "./pi-coding-worker.ts";
26
+ export type { PiCodingWorkerOptions, WorkerEnv } from "./pi-coding-worker.ts";
27
+ export { modelEnvNames, PiCodingWorker } from "./pi-coding-worker.ts";
28
28
  export type {
29
29
  ChangeReport,
30
30
  CloneCommand,
31
+ GithubToken,
31
32
  Integration,
32
33
  RepoState,
33
34
  RepoSummary,
@@ -1,9 +1,12 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { fileURLToPath } from "node:url";
2
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import type { HeldCall, HoldCheck } from "pi-roundtable";
5
+ import type { HeldCall, HoldCheck, ShellEnv } from "pi-roundtable";
4
6
  import {
5
7
  AgentError,
6
8
  canonicalJson,
9
+ childEnv,
7
10
  holdChain,
8
11
  shellHoldRule,
9
12
  } from "pi-roundtable/kit";
@@ -38,6 +41,57 @@ export interface PiCodingWorkerOptions {
38
41
  blockText?: (answer: "declined" | "held", action: string) => string;
39
42
  /** Longest worker error text kept for the report, in characters; default 600. */
40
43
  diagnosticChars?: number;
44
+ /**
45
+ * Host variables the worker process gets besides `SHELL_ENV_NAMES`, `PI_CODING_AGENT_DIR` and
46
+ * `modelEnvNames` (its `models.json` `$VAR`s, `CLAUDE_*`, `ANTHROPIC_*`), for a model reading
47
+ * another name. Its shell never gets them.
48
+ */
49
+ workerEnv?: WorkerEnv;
50
+ /** What the worker's shell gets besides `SHELL_ENV_NAMES`, as the agents' shell; default the host's `env.shellEnv`. */
51
+ shellEnv?: ShellEnv;
52
+ }
53
+
54
+ /** Host variables a coding worker's process gets for its model and Pi runtime, by name. */
55
+ export interface WorkerEnv {
56
+ pass?: readonly string[];
57
+ }
58
+
59
+ /**
60
+ * The host variables a worker's model may read, found without the host listing them: the names a
61
+ * `models.json` in Pi's agent dir writes as `$NAME` (an API key a custom provider resolves), and
62
+ * the Claude Code and Anthropic families (`CLAUDE_*`, `ANTHROPIC_*`) that Anthropic providers and
63
+ * Claude Code bridges read. A missing or unreadable `models.json` adds nothing.
64
+ */
65
+ export function modelEnvNames(agentDir: string): string[] {
66
+ const names = ["CLAUDE_*", "ANTHROPIC_*"];
67
+ try {
68
+ const text = readFileSync(join(agentDir, "models.json"), "utf8");
69
+ for (const match of text.matchAll(/"\$([A-Za-z_][A-Za-z0-9_]*)"/g))
70
+ if (match[1] && !names.includes(match[1])) names.push(match[1]);
71
+ } catch {
72
+ // No models.json: the providers' own variables above are all.
73
+ }
74
+ return names;
75
+ }
76
+
77
+ /**
78
+ * The worker process's environment: the shell's whitelist, Pi's agent dir, the variables its model
79
+ * may read (`modelEnvNames`), `workerEnv.pass`, and TMPDIR. Its bash cuts this again to the shell's.
80
+ */
81
+ export function workerProcessEnv(
82
+ source: Readonly<Record<string, string | undefined>>,
83
+ workerEnv: WorkerEnv = {},
84
+ scratchDir?: string,
85
+ agentDir: string = getAgentDir(),
86
+ ): Record<string, string> {
87
+ return childEnv(source, {
88
+ pass: [
89
+ "PI_CODING_AGENT_DIR",
90
+ ...modelEnvNames(agentDir),
91
+ ...(workerEnv.pass ?? []),
92
+ ],
93
+ ...(scratchDir ? { set: { TMPDIR: scratchDir } } : {}),
94
+ });
41
95
  }
42
96
  interface WorkerMessage {
43
97
  type: string;
@@ -107,9 +161,12 @@ export class PiCodingWorker implements CodingWorker {
107
161
  ],
108
162
  {
109
163
  cwd: job.dir,
110
- ...(this.#options.scratchDir
111
- ? { env: { ...process.env, TMPDIR: this.#options.scratchDir } }
112
- : {}),
164
+ env: workerProcessEnv(
165
+ process.env,
166
+ this.#options.workerEnv,
167
+ this.#options.scratchDir,
168
+ this.#options.agentDir ?? getAgentDir(),
169
+ ),
113
170
  stdin: "ignore",
114
171
  stdout: "ignore",
115
172
  stderr: "ignore",
@@ -128,6 +185,9 @@ export class PiCodingWorker implements CodingWorker {
128
185
  ...(this.#options.scratchDir
129
186
  ? { scratchDir: this.#options.scratchDir }
130
187
  : {}),
188
+ ...(this.#options.shellEnv
189
+ ? { shellEnv: this.#options.shellEnv }
190
+ : {}),
131
191
  });
132
192
  } else if (
133
193
  value.type === "report" &&
package/src/repo-shelf.ts CHANGED
@@ -13,7 +13,12 @@ import {
13
13
  rmSync,
14
14
  } from "node:fs";
15
15
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
- import { AgentError, checkRepoName, scrubDiagnostic } from "pi-roundtable/kit";
16
+ import {
17
+ AgentError,
18
+ checkRepoName,
19
+ childEnv,
20
+ scrubDiagnostic,
21
+ } from "pi-roundtable/kit";
17
22
 
18
23
  /** What a clone would push to its default branch (repos-and-skills spec behavior 10). */
19
24
  export interface ChangeReport {
@@ -53,6 +58,12 @@ export interface RepoState {
53
58
  uncommitted: string[];
54
59
  }
55
60
 
61
+ /**
62
+ * The GitHub token git and gh calls get as GH_TOKEN, or a function read at each call; undefined
63
+ * leaves them to gh's own login.
64
+ */
65
+ export type GithubToken = string | undefined | (() => string | undefined);
66
+
56
67
  export type CloneCommand = (repo: string, dir: string) => Promise<void>;
57
68
 
58
69
  /** A coding worker's own checkout: a linked worktree on its own branch, cut from the clone's HEAD. */
@@ -89,9 +100,39 @@ const GIT_TIMEOUT_MS = 60_000;
89
100
  const MAX_STAT_FILES = 40;
90
101
  const MAX_SUMMARY_CHARS = 120;
91
102
 
103
+ /**
104
+ * Host variables git and gh need besides the shell's: their config files and homes, the identity a
105
+ * rebase commits as, ssh agent and ssh command, and gh's host; never a token, which comes from
106
+ * `githubToken`.
107
+ */
108
+ const GIT_ENV_NAMES = [
109
+ "XDG_CONFIG_HOME",
110
+ "GIT_CONFIG_GLOBAL",
111
+ "GIT_CONFIG_SYSTEM",
112
+ "GIT_CONFIG_NOSYSTEM",
113
+ "GIT_AUTHOR_NAME",
114
+ "GIT_AUTHOR_EMAIL",
115
+ "GIT_COMMITTER_NAME",
116
+ "GIT_COMMITTER_EMAIL",
117
+ "GH_CONFIG_DIR",
118
+ "GH_HOST",
119
+ "SSH_AUTH_SOCK",
120
+ "GIT_SSH",
121
+ "GIT_SSH_COMMAND",
122
+ ];
123
+
124
+ /** The environment of a git or gh call: the shell's whitelist, git's own names, and the token as GH_TOKEN. */
125
+ function gitEnv(token: string | undefined): Record<string, string> {
126
+ return childEnv(process.env, {
127
+ pass: GIT_ENV_NAMES,
128
+ ...(token ? { set: { GH_TOKEN: token } } : {}),
129
+ });
130
+ }
131
+
92
132
  async function run(
93
133
  command: string[],
94
- cwd?: string,
134
+ cwd: string | undefined,
135
+ env: Record<string, string>,
95
136
  ): Promise<{ out: string; err: string; code: number }> {
96
137
  const safeCommand =
97
138
  command[0] === "git"
@@ -106,6 +147,7 @@ async function run(
106
147
  : command;
107
148
  const child = Bun.spawn(safeCommand, {
108
149
  ...(cwd ? { cwd } : {}),
150
+ env,
109
151
  stdout: "pipe",
110
152
  stderr: "pipe",
111
153
  timeout: GIT_TIMEOUT_MS,
@@ -118,22 +160,21 @@ async function run(
118
160
  return { out: out.trimEnd(), err: err.trim(), code };
119
161
  }
120
162
 
121
- /** Clones with gh, which authenticates with the host's GH_TOKEN; a failure's stderr is cut at `diagnosticChars`. */
163
+ /**
164
+ * Clones with gh, which authenticates with `token` as GH_TOKEN (default the host's GH_TOKEN, read
165
+ * at each clone) or its own login; a failure's stderr is cut at `diagnosticChars`.
166
+ */
122
167
  const ghCloneWith =
123
- (diagnosticChars?: number): CloneCommand =>
168
+ (diagnosticChars?: number, token?: () => string | undefined): CloneCommand =>
124
169
  async (repo, dir) => {
125
170
  checkRepoName(repo);
126
171
  if (repo.startsWith("-"))
127
172
  throw new AgentError("Repository owner cannot start with a dash.");
128
- const { err, code } = await run([
129
- "gh",
130
- "repo",
131
- "clone",
132
- repo,
133
- dir,
134
- "--",
135
- "--quiet",
136
- ]);
173
+ const { err, code } = await run(
174
+ ["gh", "repo", "clone", repo, dir, "--", "--quiet"],
175
+ undefined,
176
+ gitEnv(token ? token() : process.env.GH_TOKEN),
177
+ );
137
178
  if (code !== 0)
138
179
  throw new AgentError(
139
180
  `Cloning ${repo} failed: ${scrubDiagnostic(err, diagnosticChars) || `exit ${code}`}`,
@@ -211,14 +252,30 @@ export class RepoShelf {
211
252
 
212
253
  readonly #diagnosticChars: number | undefined;
213
254
 
255
+ readonly #token: () => string | undefined;
256
+
214
257
  constructor(
215
258
  dir: string,
216
259
  clone?: CloneCommand,
217
- options: { diagnosticChars?: number | undefined } = {},
260
+ options: {
261
+ diagnosticChars?: number | undefined;
262
+ githubToken?: GithubToken | undefined;
263
+ } = {},
218
264
  ) {
219
265
  this.dir = resolve(dir);
220
266
  this.#diagnosticChars = options.diagnosticChars;
221
- this.#clone = clone ?? ghCloneWith(options.diagnosticChars);
267
+ const { githubToken = process.env.GH_TOKEN } = options;
268
+ this.#token =
269
+ typeof githubToken === "function" ? githubToken : () => githubToken;
270
+ this.#clone = clone ?? ghCloneWith(options.diagnosticChars, this.#token);
271
+ }
272
+
273
+ /** A git or gh call with the shelf's token and no other host secret. */
274
+ #run(
275
+ command: string[],
276
+ cwd?: string,
277
+ ): Promise<{ out: string; err: string; code: number }> {
278
+ return run(command, cwd, gitEnv(this.#token()));
222
279
  }
223
280
 
224
281
  /** Adopt an existing standalone clone at startup; an existing destination is never replaced. */
@@ -450,7 +507,7 @@ export class RepoShelf {
450
507
  const taken =
451
508
  existsSync(dir) ||
452
509
  (
453
- await run([
510
+ await this.#run([
454
511
  "git",
455
512
  "-C",
456
513
  clone,
@@ -507,9 +564,16 @@ export class RepoShelf {
507
564
  ...kept,
508
565
  reason: `the worker left uncommitted changes in its worktree:\n${treeDirty}`,
509
566
  };
510
- const rebase = await run(["git", "-C", treeDir, "rebase", "--quiet", head]);
567
+ const rebase = await this.#run([
568
+ "git",
569
+ "-C",
570
+ treeDir,
571
+ "rebase",
572
+ "--quiet",
573
+ head,
574
+ ]);
511
575
  if (rebase.code !== 0) {
512
- const conflicted = await run([
576
+ const conflicted = await this.#run([
513
577
  "git",
514
578
  "-C",
515
579
  treeDir,
@@ -517,14 +581,14 @@ export class RepoShelf {
517
581
  "--name-only",
518
582
  "--diff-filter=U",
519
583
  ]);
520
- await run(["git", "-C", treeDir, "rebase", "--abort"]);
584
+ await this.#run(["git", "-C", treeDir, "rebase", "--abort"]);
521
585
  return {
522
586
  status: "conflict",
523
587
  ...kept,
524
588
  files: conflicted.out ? conflicted.out.split("\n") : [],
525
589
  };
526
590
  }
527
- const merge = await run([
591
+ const merge = await this.#run([
528
592
  "git",
529
593
  "-C",
530
594
  clone,
@@ -581,11 +645,27 @@ export class RepoShelf {
581
645
  async removeWorktree(tree: WorkerTree): Promise<void> {
582
646
  const clone = this.dirOf(tree.repo);
583
647
  this.#treeDir(tree, false);
584
- await run(["git", "-C", clone, "worktree", "remove", "--force", tree.dir]);
648
+ await this.#run([
649
+ "git",
650
+ "-C",
651
+ clone,
652
+ "worktree",
653
+ "remove",
654
+ "--force",
655
+ tree.dir,
656
+ ]);
585
657
  if (existsSync(tree.dir))
586
658
  rmSync(tree.dir, { recursive: true, force: true });
587
- await run(["git", "-C", clone, "worktree", "prune"]);
588
- await run(["git", "-C", clone, "branch", "-D", "--quiet", tree.branch]);
659
+ await this.#run(["git", "-C", clone, "worktree", "prune"]);
660
+ await this.#run([
661
+ "git",
662
+ "-C",
663
+ clone,
664
+ "branch",
665
+ "-D",
666
+ "--quiet",
667
+ tree.branch,
668
+ ]);
589
669
  }
590
670
 
591
671
  /** A worktree path, refused unless it lies in this repository's worktree folder. */
@@ -614,7 +694,7 @@ export class RepoShelf {
614
694
  if (fetch) await this.#git(dir, "fetch", "--quiet", "origin");
615
695
  const branch = await this.#git(dir, "rev-parse", "--abbrev-ref", "HEAD");
616
696
  const status = await this.#git(dir, "status", "--porcelain");
617
- const last = await run([
697
+ const last = await this.#run([
618
698
  "git",
619
699
  "-C",
620
700
  dir,
@@ -623,7 +703,7 @@ export class RepoShelf {
623
703
  "--format=%h %s (%cs)",
624
704
  "--abbrev=12",
625
705
  ]);
626
- const counts = await run([
706
+ const counts = await this.#run([
627
707
  "git",
628
708
  "-C",
629
709
  dir,
@@ -678,7 +758,7 @@ export class RepoShelf {
678
758
  async #defaultBranch(dir: string): Promise<string> {
679
759
  await this.#git(dir, "remote", "set-head", "origin", "--auto");
680
760
  const read = () =>
681
- run([
761
+ this.#run([
682
762
  "git",
683
763
  "-C",
684
764
  dir,
@@ -693,7 +773,7 @@ export class RepoShelf {
693
773
  }
694
774
 
695
775
  async #git(dir: string, ...args: string[]): Promise<string> {
696
- const { out, err, code } = await run(["git", "-C", dir, ...args]);
776
+ const { out, err, code } = await this.#run(["git", "-C", dir, ...args]);
697
777
  if (code !== 0)
698
778
  throw new AgentError(
699
779
  `git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err, this.#diagnosticChars) || `exit ${code}`}`,
@@ -2,15 +2,20 @@ import { lstatSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, join, relative } from "node:path";
3
3
  import {
4
4
  createAgentSession,
5
+ createBashToolDefinition,
5
6
  DefaultResourceLoader,
6
7
  ModelRuntime,
7
8
  SessionManager,
8
9
  SettingsManager,
10
+ type ToolDefinition,
9
11
  } from "@earendil-works/pi-coding-agent";
12
+ import type { ShellEnv } from "pi-roundtable";
10
13
  import {
11
14
  activeToolsExtension,
12
15
  runWorkerTask,
13
16
  SHELL_TOOLS,
17
+ secretMaskExtension,
18
+ shellChildEnv,
14
19
  textOf,
15
20
  } from "pi-roundtable/kit";
16
21
  import type { CodingJob, HeldCallAnswer } from "./coding-desk.ts";
@@ -25,6 +30,29 @@ interface StartMessage {
25
30
  progress?: boolean;
26
31
  /** The host's scratch dir, the worker's TMPDIR, where writes and removals need no approval. */
27
32
  scratchDir?: string;
33
+ /** What the worker's shell gets besides `SHELL_ENV_NAMES`. */
34
+ shellEnv?: ShellEnv;
35
+ }
36
+
37
+ /**
38
+ * Pi's bash with the worker's environment cut to the shell's whitelist and `shellEnv`: the model's
39
+ * credentials the worker process holds never reach a command.
40
+ */
41
+ export function workerBash(
42
+ cwd: string,
43
+ scratchDir: string | undefined,
44
+ shellEnv: ShellEnv = {},
45
+ ): ToolDefinition {
46
+ // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
47
+ return createBashToolDefinition(cwd, {
48
+ spawnHook: (context) => ({
49
+ ...context,
50
+ env: {
51
+ ...shellChildEnv(context.env, shellEnv),
52
+ ...(scratchDir === undefined ? {} : { TMPDIR: scratchDir }),
53
+ },
54
+ }),
55
+ }) as unknown as ToolDefinition;
28
56
  }
29
57
  /** Said to every worker whose host has a scratch dir, after the host's own prompt or the default. */
30
58
  export function scratchDirPrompt(scratchDir: string): string {
@@ -54,6 +82,7 @@ async function run({
54
82
  prompt,
55
83
  progress,
56
84
  scratchDir,
85
+ shellEnv,
57
86
  }: StartMessage): Promise<string> {
58
87
  const modelRuntime = await ModelRuntime.create({
59
88
  authPath: join(agentDir, "auth.json"),
@@ -86,6 +115,7 @@ async function run({
86
115
  name: "coding-shell",
87
116
  factory: activeToolsExtension(() => SHELL_TOOLS),
88
117
  },
118
+ { name: "secret-mask", factory: secretMaskExtension() },
89
119
  {
90
120
  name: "coding-approvals",
91
121
  factory: (pi) => {
@@ -127,6 +157,7 @@ async function run({
127
157
  modelRuntime,
128
158
  thinkingLevel: job.thinking,
129
159
  resourceLoader: loader,
160
+ customTools: [workerBash(job.dir, scratchDir, shellEnv)],
130
161
  sessionManager: SessionManager.inMemory(job.dir),
131
162
  settingsManager: SettingsManager.inMemory({}),
132
163
  });