Skip to content

Commit 83c119a

Browse files
coleam00claude
andauthored
fix(providers/pi): wire env injection + harden silent-failure paths (#1296)
Four defensive fixes to the Pi community provider to match the Claude/Codex contract and eliminate silent error swallowing. 1. envInjection now actually wired (capability was declared but unused) Pi's SDK has no top-level `env` option on createAgentSession, so per-project env vars were being dropped. Routes requestOptions.env through a BashSpawnHook that merges caller env over the inherited baseline (caller wins, matching Claude/Codex semantics). When env is present with no allow/deny, resolvePiTools now explicitly returns Pi's 4 default tools so the pre-constructed default bashTool is replaced with an env-aware one. 2. AsyncQueue no longer leaks on consumer abort. Added close() that drains pending waiters with { done: true } so iterate() exits instead of hanging forever when the producer's finally fires before the next push. bridgeSession calls queue.close() in its finally block. 3. buildResultChunk no longer reports silent success when agent_end fires with no assistant message. Now returns { isError: true, errorSubtype: 'missing_assistant_message' } and logs a warn event so broken Pi sessions don't masquerade as clean completions. 4. session-resolver no longer swallows arbitrary errors from SessionManager.list(). Narrowed the catch to ENOENT/ENOTDIR (the only "session dir doesn't exist yet" signals); permission errors, parse failures, and other unexpected errors now propagate. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 60eeb00 commit 83c119a

9 files changed

Lines changed: 277 additions & 26 deletions

File tree

packages/providers/src/community/pi/capabilities.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { ProviderCapabilities } from '../../types';
22

33
/**
4-
* Pi v1 capabilities — intentionally conservative. Declared flags must reflect
4+
* Pi capabilities — intentionally conservative. Declared flags must reflect
55
* wired-up behavior, not potential support. The dag-executor uses these to
66
* warn users when a workflow node specifies a feature the provider ignores.
77
*
8-
* Roadmap (v2+): thinkingControl, skills, envInjection can be flipped once
9-
* the corresponding nodeConfig fields are intentionally translated to Pi's
10-
* runtime options.
8+
* envInjection covers both auth-key passthrough (setRuntimeApiKey for mapped
9+
* provider env vars) and bash tool subprocess env (BashSpawnHook merges the
10+
* caller's env over Pi's inherited baseline), matching Claude/Codex semantics.
1111
*/
1212
export const PI_CAPABILITIES: ProviderCapabilities = {
1313
sessionResume: true,

packages/providers/src/community/pi/event-bridge.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,40 @@ describe('AsyncQueue', () => {
5555
q[Symbol.asyncIterator]();
5656
expect(() => q[Symbol.asyncIterator]()).toThrow(/single-consumer/);
5757
});
58+
59+
test('close() terminates pending waiter so consumer exits loop', async () => {
60+
const q = new AsyncQueue<number>();
61+
const iter = q[Symbol.asyncIterator]();
62+
const pending = iter.next();
63+
queueMicrotask(() => q.close());
64+
const result = await pending;
65+
expect(result.done).toBe(true);
66+
});
67+
68+
test('close() drains buffered items before terminating', async () => {
69+
const q = new AsyncQueue<number>();
70+
q.push(1);
71+
q.push(2);
72+
q.close();
73+
const received: number[] = [];
74+
for await (const n of q) received.push(n);
75+
expect(received).toEqual([1, 2]);
76+
});
77+
78+
test('push after close is a no-op (does not leak past close)', async () => {
79+
const q = new AsyncQueue<number>();
80+
const iter = q[Symbol.asyncIterator]();
81+
q.close();
82+
q.push(42); // Must not resurrect the closed queue.
83+
const r = await iter.next();
84+
expect(r.done).toBe(true);
85+
});
86+
87+
test('close() is idempotent', () => {
88+
const q = new AsyncQueue<number>();
89+
q.close();
90+
expect(() => q.close()).not.toThrow();
91+
});
5892
});
5993

6094
// ─── serializeToolResult ───────────────────────────────────────────────────
@@ -114,9 +148,17 @@ describe('buildResultChunk', () => {
114148
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.01 },
115149
};
116150

117-
test('returns bare result chunk if no assistant message', () => {
118-
expect(buildResultChunk([])).toEqual({ type: 'result' });
119-
expect(buildResultChunk([{ role: 'user', content: [] }])).toEqual({ type: 'result' });
151+
test('flags isError when no assistant message is present', () => {
152+
// agent_end with no assistant message in the transcript is anomalous —
153+
// must surface as an error so the orchestrator doesn't treat a broken
154+
// session as a clean success.
155+
const expected = {
156+
type: 'result',
157+
isError: true,
158+
errorSubtype: 'missing_assistant_message',
159+
};
160+
expect(buildResultChunk([])).toEqual(expected);
161+
expect(buildResultChunk([{ role: 'user', content: [] }])).toEqual(expected);
120162
});
121163

122164
test('extracts usage from last assistant message', () => {

packages/providers/src/community/pi/event-bridge.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,32 @@ function getLog(): ReturnType<typeof createLogger> {
2828
*/
2929
export class AsyncQueue<T> implements AsyncIterable<T> {
3030
private readonly buffer: T[] = [];
31-
private readonly waiters: ((item: T) => void)[] = [];
31+
private readonly waiters: ((result: IteratorResult<T>) => void)[] = [];
3232
private consumed = false;
33+
private closed = false;
3334

3435
push(item: T): void {
36+
if (this.closed) return;
3537
const waiter = this.waiters.shift();
36-
if (waiter) waiter(item);
38+
if (waiter) waiter({ value: item, done: false });
3739
else this.buffer.push(item);
3840
}
3941

42+
/**
43+
* Terminate iteration cleanly. Drains any pending waiters with
44+
* `{ done: true }` so the consumer exits the `for await` loop instead of
45+
* hanging forever when the producer's finally block fires before a new
46+
* item arrives (e.g. consumer abort mid-iteration).
47+
*/
48+
close(): void {
49+
if (this.closed) return;
50+
this.closed = true;
51+
while (this.waiters.length > 0) {
52+
const waiter = this.waiters.shift();
53+
if (waiter) waiter({ value: undefined, done: true });
54+
}
55+
}
56+
4057
[Symbol.asyncIterator](): AsyncIterator<T> {
4158
if (this.consumed) {
4259
// Throw synchronously at the call site (not lazily on first .next())
@@ -56,10 +73,12 @@ export class AsyncQueue<T> implements AsyncIterable<T> {
5673
yield next;
5774
continue;
5875
}
59-
const item = await new Promise<T>(resolve => {
76+
if (this.closed) return;
77+
const result = await new Promise<IteratorResult<T>>(resolve => {
6078
this.waiters.push(resolve);
6179
});
62-
yield item;
80+
if (result.done) return;
81+
yield result.value;
6382
}
6483
}
6584
}
@@ -111,7 +130,12 @@ function isAssistantMessage(m: unknown): m is AssistantMessage {
111130
export function buildResultChunk(messages: readonly unknown[]): MessageChunk {
112131
const last = [...messages].reverse().find(isAssistantMessage);
113132
if (!last) {
114-
return { type: 'result' };
133+
// agent_end fired with no assistant message in the transcript. This
134+
// shouldn't happen in healthy Pi runs — surface it as a loud error
135+
// rather than a silent success so orchestrators don't treat a broken
136+
// session as a clean completion.
137+
getLog().warn('pi.event-bridge.result_missing_assistant_message');
138+
return { type: 'result', isError: true, errorSubtype: 'missing_assistant_message' };
115139
}
116140

117141
const tokens = usageToTokens(last.usage);
@@ -274,6 +298,10 @@ export async function* bridgeSession(
274298
}
275299
}
276300
} finally {
301+
// Close the queue first so any producer push() still in flight becomes
302+
// a no-op and pending iterate() waiters resolve — otherwise a consumer
303+
// abort mid-iteration would leak this generator on the promise forever.
304+
queue.close();
277305
unsubscribe();
278306
if (abortSignal) {
279307
abortSignal.removeEventListener('abort', onAbort);

packages/providers/src/community/pi/options-translator.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,23 @@ describe('resolvePiTools', () => {
133133
expect(result.tools).toHaveLength(1); // only 'read'
134134
expect(result.unknownTools).toEqual(['UnknownA', 'UnknownB']);
135135
});
136+
137+
test('no allow/deny with non-empty env → returns Pi default 4-tool set with env-aware bash', () => {
138+
const result = resolvePiTools(cwd, undefined, { DATABASE_URL: 'postgres://x' });
139+
expect(result.tools).toHaveLength(4); // read/bash/edit/write
140+
expect(result.unknownTools).toEqual([]);
141+
});
142+
143+
test('no allow/deny with empty env → still returns undefined (Pi defaults)', () => {
144+
expect(resolvePiTools(cwd, undefined, {})).toEqual({ tools: undefined, unknownTools: [] });
145+
expect(resolvePiTools(cwd, {}, {})).toEqual({ tools: undefined, unknownTools: [] });
146+
});
147+
148+
test('env passthrough does not affect unknown tool reporting', () => {
149+
const result = resolvePiTools(cwd, { allowed_tools: ['read', 'WebFetch'] }, { FOO: 'bar' });
150+
expect(result.tools).toHaveLength(1);
151+
expect(result.unknownTools).toEqual(['WebFetch']);
152+
});
136153
});
137154

138155
// ─── resolvePiSkills ───────────────────────────────────────────────────────

packages/providers/src/community/pi/options-translator.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
createLsTool,
1212
createReadTool,
1313
createWriteTool,
14+
type BashSpawnContext,
15+
type BashSpawnHook,
1416
} from '@mariozechner/pi-coding-agent';
1517
import type { ThinkingLevel } from '@mariozechner/pi-ai';
1618

@@ -113,13 +115,27 @@ export function resolvePiThinkingLevel(nodeConfig?: NodeConfig): ResolvedThinkin
113115
const PI_TOOL_NAMES = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'] as const;
114116
export type PiToolName = (typeof PI_TOOL_NAMES)[number];
115117

118+
/**
119+
* Build a Pi `spawnHook` that merges managed env vars into every bash
120+
* subprocess. Matches Claude/Codex precedence: caller-provided env keys
121+
* override Pi's inherited baseline. Returns undefined when `env` is empty
122+
* so bash spawns without an unnecessary hook allocation.
123+
*/
124+
function buildBashSpawnHook(env: Record<string, string> | undefined): BashSpawnHook | undefined {
125+
if (!env || Object.keys(env).length === 0) return undefined;
126+
return (context: BashSpawnContext): BashSpawnContext => ({
127+
...context,
128+
env: { ...context.env, ...env },
129+
});
130+
}
131+
116132
/** Map a normalized (lowercase) Pi tool name to its Pi-internal factory. */
117-
function buildPiTool(name: PiToolName, cwd: string): PiTool {
133+
function buildPiTool(name: PiToolName, cwd: string, spawnHook: BashSpawnHook | undefined): PiTool {
118134
switch (name) {
119135
case 'read':
120136
return createReadTool(cwd);
121137
case 'bash':
122-
return createBashTool(cwd);
138+
return spawnHook ? createBashTool(cwd, { spawnHook }) : createBashTool(cwd);
123139
case 'edit':
124140
return createEditTool(cwd);
125141
case 'write':
@@ -144,24 +160,51 @@ export interface ResolvedTools {
144160
unknownTools: string[];
145161
}
146162

163+
/** Pi's default coding-tool set (mirrors `codingTools` export: read/bash/edit/write). */
164+
const PI_DEFAULT_TOOL_NAMES = [
165+
'read',
166+
'bash',
167+
'edit',
168+
'write',
169+
] as const satisfies readonly PiToolName[];
170+
147171
/**
148172
* Filter Pi's built-in tool set against Archon's `allowed_tools` /
149-
* `denied_tools` node config.
173+
* `denied_tools` node config, with managed env injected into any bash tool.
150174
*
151175
* Semantics:
152-
* - neither set → return undefined (Pi's default tools)
176+
* - neither allow/deny set, no env → return undefined (Pi's default tools)
177+
* - neither allow/deny set, env present → return Pi's default 4 tools with
178+
* an env-aware bash, so codebase env vars reach bash subprocesses
153179
* - allowed_tools: [] → return [] (explicit no-tools; valid Archon idiom)
154180
* - allowed_tools: [X, Y] → only X, Y (normalized to lowercase)
155181
* - denied_tools subtracts from allowed_tools (or full set if allowed_tools absent)
156182
* - tool names not in Pi's built-in set are silently dropped but reported
157183
* via `unknownTools` so the caller can surface a warning.
184+
*
185+
* The `env` parameter is the caller's `requestOptions.env` merged with any
186+
* relevant defaults; when non-empty, it is injected into every bash spawn via
187+
* a `BashSpawnHook`, matching Claude's `options.env` and Codex's constructor
188+
* `env` behavior so codebase-scoped env vars reach tool subprocesses.
158189
*/
159-
export function resolvePiTools(cwd: string, nodeConfig?: NodeConfig): ResolvedTools {
190+
export function resolvePiTools(
191+
cwd: string,
192+
nodeConfig?: NodeConfig,
193+
env?: Record<string, string>
194+
): ResolvedTools {
160195
const allowed = nodeConfig?.allowed_tools;
161196
const denied = nodeConfig?.denied_tools;
197+
const spawnHook = buildBashSpawnHook(env);
162198

163199
if (allowed === undefined && denied === undefined) {
164-
return { tools: undefined, unknownTools: [] };
200+
// No restrictions. Match Pi's default tool set unless env injection forces
201+
// a custom bash tool (Pi's default bashTool is pre-constructed with no
202+
// spawnHook and there's no way to retrofit env onto it).
203+
if (!spawnHook) return { tools: undefined, unknownTools: [] };
204+
return {
205+
tools: PI_DEFAULT_TOOL_NAMES.map(n => buildPiTool(n, cwd, spawnHook)),
206+
unknownTools: [],
207+
};
165208
}
166209

167210
const knownSet = new Set<PiToolName>(PI_TOOL_NAMES);
@@ -199,7 +242,7 @@ export function resolvePiTools(cwd: string, nodeConfig?: NodeConfig): ResolvedTo
199242
});
200243

201244
return {
202-
tools: unique.map(n => buildPiTool(n, cwd)),
245+
tools: unique.map(n => buildPiTool(n, cwd, spawnHook)),
203246
unknownTools,
204247
};
205248
}

packages/providers/src/community/pi/provider.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const MockDefaultResourceLoader = mock(function (_opts: unknown) {
9393
// Tool factory mocks — each returns an opaque object tagged with the tool
9494
// name so assertions can verify which tools the provider selected.
9595
const mockCreateReadTool = mock((_cwd: string) => ({ __piTool: 'read' }));
96-
const mockCreateBashTool = mock((_cwd: string) => ({ __piTool: 'bash' }));
96+
const mockCreateBashTool = mock((_cwd: string, _options?: unknown) => ({ __piTool: 'bash' }));
9797
const mockCreateEditTool = mock((_cwd: string) => ({ __piTool: 'edit' }));
9898
const mockCreateWriteTool = mock((_cwd: string) => ({ __piTool: 'write' }));
9999
const mockCreateGrepTool = mock((_cwd: string) => ({ __piTool: 'grep' }));
@@ -765,6 +765,80 @@ describe('PiProvider', () => {
765765
expect('tools' in callArgs).toBe(false);
766766
});
767767

768+
test('requestOptions.env with no tool restrictions overrides Pi defaults with env-aware bash', async () => {
769+
process.env.GEMINI_API_KEY = 'sk-test';
770+
resetScript(scriptedAgentEnd());
771+
772+
await consume(
773+
new PiProvider().sendQuery('hi', '/tmp', undefined, {
774+
model: 'google/gemini-2.5-pro',
775+
env: { DATABASE_URL: 'postgres://managed' },
776+
})
777+
);
778+
779+
const [callArgs] = mockCreateAgentSession.mock.calls[0] as [Record<string, unknown>];
780+
// Env present → we override Pi's built-in codingTools so bash sees the env.
781+
const tools = callArgs.tools as Array<{ __piTool: string }>;
782+
expect(Array.isArray(tools)).toBe(true);
783+
expect(tools.map(t => t.__piTool).sort()).toEqual(['bash', 'edit', 'read', 'write']);
784+
785+
const bashCall = mockCreateBashTool.mock.calls.find(call => call[1] !== undefined);
786+
expect(bashCall).toBeDefined();
787+
const bashOptions = bashCall![1] as { spawnHook: (c: unknown) => unknown };
788+
expect(typeof bashOptions.spawnHook).toBe('function');
789+
790+
// The spawnHook must merge caller env OVER Pi's inherited baseline, matching
791+
// Claude's { ...subprocessEnv, ...requestOptions.env } and Codex's buildCodexEnv.
792+
const merged = bashOptions.spawnHook({
793+
command: 'echo',
794+
cwd: '/tmp',
795+
env: { PATH: '/usr/bin', DATABASE_URL: 'postgres://stale' },
796+
}) as { env: Record<string, string> };
797+
expect(merged.env.PATH).toBe('/usr/bin');
798+
expect(merged.env.DATABASE_URL).toBe('postgres://managed');
799+
});
800+
801+
test('requestOptions.env threads through to bash tool when allowed_tools includes bash', async () => {
802+
process.env.GEMINI_API_KEY = 'sk-test';
803+
resetScript(scriptedAgentEnd());
804+
805+
await consume(
806+
new PiProvider().sendQuery('hi', '/tmp', undefined, {
807+
model: 'google/gemini-2.5-pro',
808+
nodeConfig: { allowed_tools: ['read', 'bash'] },
809+
env: { STRIPE_KEY: 'sk_test_abc' },
810+
})
811+
);
812+
813+
const bashCall = mockCreateBashTool.mock.calls.find(call => call[1] !== undefined);
814+
expect(bashCall).toBeDefined();
815+
const bashOptions = bashCall![1] as { spawnHook: (c: unknown) => unknown };
816+
const merged = bashOptions.spawnHook({
817+
command: 'echo',
818+
cwd: '/tmp',
819+
env: { PATH: '/usr/bin' },
820+
}) as { env: Record<string, string> };
821+
expect(merged.env.STRIPE_KEY).toBe('sk_test_abc');
822+
expect(merged.env.PATH).toBe('/usr/bin');
823+
});
824+
825+
test('empty requestOptions.env does NOT construct a spawnHook', async () => {
826+
process.env.GEMINI_API_KEY = 'sk-test';
827+
resetScript(scriptedAgentEnd());
828+
829+
await consume(
830+
new PiProvider().sendQuery('hi', '/tmp', undefined, {
831+
model: 'google/gemini-2.5-pro',
832+
env: {},
833+
})
834+
);
835+
836+
// Every createBashTool call in this test path is either (cwd) or (cwd, undefined).
837+
for (const call of mockCreateBashTool.mock.calls) {
838+
expect(call[1]).toBeUndefined();
839+
}
840+
});
841+
768842
test('requestOptions.systemPrompt threads through to DefaultResourceLoader', async () => {
769843
process.env.GEMINI_API_KEY = 'sk-test';
770844
resetScript(scriptedAgentEnd());

packages/providers/src/community/pi/provider.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,14 @@ export class PiProvider implements IAgentProvider {
169169
// 4b. tools: covers allowed_tools / denied_tools. `undefined` leaves Pi
170170
// defaults; an explicit empty array means "no tools" (valid idiom
171171
// matching e2e-claude-smoke's `allowed_tools: []`).
172-
const { tools: filteredTools, unknownTools } = resolvePiTools(cwd, nodeConfig);
172+
// requestOptions.env (codebase-scoped env vars from .archon/config.yaml)
173+
// is injected into bash subprocesses via a BashSpawnHook, mirroring
174+
// Claude's options.env and Codex's constructor env.
175+
const { tools: filteredTools, unknownTools } = resolvePiTools(
176+
cwd,
177+
nodeConfig,
178+
requestOptions?.env
179+
);
173180
if (unknownTools.length > 0) {
174181
yield {
175182
type: 'system',

0 commit comments

Comments
 (0)