Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dev-rules/pi-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Claude Code 仍用独立百分比。env:`CINDY_PI_API_KEY`、

## 6. 上线门禁

- [x] **平台分发**:pin 已升级到 Pi `v0.83.0`,darwin arm64/x64、linux arm64/x64、
- [x] **平台分发**:pin 已升级到 Pi `v0.84.3`,darwin arm64/x64、linux arm64/x64、
win32 arm64/x64 六份官方资产都进入 digest pin;下载器兼容 Unix `pi/` 嵌套包与
Windows 根目录平铺 zip。当前 Mac 已完成六资产 SHA-256 下载验收;非本机 OS 的
最终启动 smoke 仍由对应发布 runner 执行。2026-08 起 pi 与 cc/codex 一样只走
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,8 @@ describe('cindy-bridge extension source', () => {
hasUnresolvedTarget: false,
});
expect(source).toContain("event.toolName === 'bash'\n ? bashInputReadEvidence(event.input)");
expect(source).toContain('bashReadEvidence.unresolved || touchesCredentialPath(bashReadTargets)');
expect(source).toContain("event.toolName === 'powershell'\n ? powershellInputReadEvidence(event.input)");
expect(source).toContain('isCindyShellTool(event.toolName) && (bashReadEvidence.unresolved || touchesCredentialPath(bashReadTargets))');
expect(source).toContain('resolvedCredentialPaths: credentialEvidenceForHost');
} finally {
rmSync(tempRoot, { recursive: true, force: true });
Expand All @@ -933,6 +934,10 @@ describe('cindy-bridge extension source', () => {
for (const tool of ['createBashTool', 'createFindTool', 'createGrepTool', 'createLsTool']) {
expect(source).toContain(tool + ',');
}
expect(source).toContain('import * as piCodingAgent from');
expect(source).toContain('createPowerShellTool');
expect(source).toContain('function isCindyShellTool');
expect(source).toContain('function powershellInputReadEvidence');
expect(source).toContain("const args = ['--files', '--hidden', '--no-require-git']");
expect(source).toContain("if (pattern.includes('/')) {");
expect(source).toContain('path.basename(relative)');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,13 @@ describe.skipIf(!piAvailable)('PiAgent integration (real pi binary + fake gatewa
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
});
res.end(scriptedResponses.shift() ?? anthropicStreamBody('pong from fake gateway'));
const url = req.url ?? '';
const fallback = url.includes('/responses')
? responsesStreamBody('pong from fake gateway', 'pi-test-model')
: url.includes('/chat/completions')
? chatCompletionsStreamBody('pong from fake gateway', 'pi-test-model')
: anthropicStreamBody('pong from fake gateway');
res.end(scriptedResponses.shift() ?? fallback);
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
Expand Down Expand Up @@ -761,11 +767,13 @@ describe.skipIf(!piAvailable)('PiAgent integration (real pi binary + fake gatewa
responsesStreamBody('pong from xai responses', 'grok-4.5'),
'/v1/responses',
);
// Pi v0.84.3 moved bundled xAI models onto Responses with encrypted
// reasoning replay. grok-build-0.1 is no longer Chat Completions.
await run(
'itest-native-xai-completions',
'xai/grok-build-0.1',
chatCompletionsStreamBody('pong from xai completions', 'grok-build-0.1'),
'/v1/chat/completions',
responsesStreamBody('pong from xai completions', 'grok-build-0.1'),
'/v1/responses',
);
},
);
Expand Down Expand Up @@ -1318,7 +1326,18 @@ describe.skipIf(!piAvailable)('PiAgent integration (real pi binary + fake gatewa
const activeConfigHomes = readdirSync(runTmp, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(runTmp, entry.name))
.filter((candidate) => existsSync(path.join(candidate, 'models.json')));
.filter((candidate) => {
const modelsPath = path.join(candidate, 'models.json');
if (!existsSync(modelsPath)) return false;
try {
const parsed = JSON.parse(readFileSync(modelsPath, 'utf8')) as {
providers?: Record<string, unknown>;
};
return Boolean(parsed.providers?.localbyom);
} catch {
return false;
}
});
expect(activeConfigHomes).toHaveLength(1);
const configHome = activeConfigHomes[0];
if (!configHome) throw new Error('active Pi config home missing');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ describe('classifyPiToolForAutoReview', () => {
expect(verdict('bash', {})).not.toBe('auto-approve');
});

it('routes Pi 0.84.3 powershell through the same shell classifier, not unknown-tool gray', () => {
expect(verdict('powershell', { command: 'git status' })).toBe('auto-approve');
expect(verdict('powershell', { command: 'sudo whoami' })).toBe('prompt-each-time');
expect(verdict('powershell', { command: 'rm -rf /' })).toBe('prompt-each-time');
expect(verdict('powershell', {})).not.toBe('auto-approve');
expect(verdict(
'powershell',
{ command: 'Get-Content innocent.txt' },
['/Users/t/.ssh/id_rsa'],
)).toBe('prompt-each-time');
});

it('approves plain reads but always prompts for credential paths (bridge-drift defense)', () => {
expect(verdict('read', { path: `${WS}/src/a.ts` })).toBe('auto-approve');
expect(verdict('read', { path: '/Users/t/.ssh/id_rsa' })).toBe('prompt-each-time');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,23 @@ describe('pi translator', () => {
expect(full).toMatchObject({ data: { fullText: stale } });
});

it('ignores Pi 0.84.3 session_compact_failed RPC leaks without hanging compaction UI', () => {
const ctx = createPiTranslateContext(noopLogger);
const { queue, events } = makeQueue();
translatePiEvent(
ev({
type: 'session_compact_failed',
reason: 'overflow',
aborted: false,
errorMessage: 'quota',
}),
queue,
ctx,
);
expect(events.some((event) => event.type === 'compact_boundary')).toBe(false);
expect(events.some((event) => event.type === 'error')).toBe(false);
});

it('maps compaction_end (threshold) → compact_boundary with token deltas + updates contextTokens', () => {
const ctx = createPiTranslateContext(noopLogger);
const { queue, events } = makeQueue();
Expand Down
7 changes: 5 additions & 2 deletions packages/maker-core/src/agents/pi/auto-review-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* pi 侧的到达面与 CC 不同:bridge 在 pi 进程内直通「只读内置四件套且入参不碰凭证
* 路径」(见 cindy-bridge-source.ts READONLY_BUILTINS + touchesCredentialPath),
* `bypassPermissions` 全放行 —— 能到这里的是 bash / edit / write / 桥接 MCP 工具 /
* `bypassPermissions` 全放行 —— 能到这里的是 bash / powershell / edit / write / 桥接 MCP 工具 /
* 凭证路径的只读调用 / 未来新增内置工具。只读分支扫全部字符串入参判凭证,与 bridge
* 同判定:bridge 升级上来的凭证读在 auto 档必须落弹窗,不能被 path 字段缺失反向放行。
*
Expand Down Expand Up @@ -65,6 +65,9 @@ const READ_ONLY_TOOLS: ReadonlySet<string> = new Set(['read', 'grep', 'find', 'l
/** 会改文件、带结构化 `path` 入参的 pi 内置工具。 */
const FILE_WRITE_TOOLS: ReadonlySet<string> = new Set(['write', 'edit']);

/** Pi v0.84.3 起 Windows 可选 powershell 与 bash 同为 shell 执行面,入参都是 `command`。 */
const SHELL_TOOLS: ReadonlySet<string> = new Set(['bash', 'powershell']);

function stringField(input: Record<string, unknown>, key: string): string | undefined {
const v = input[key];
return typeof v === 'string' && v.length > 0 ? v : undefined;
Expand Down Expand Up @@ -141,7 +144,7 @@ export function normalizePiToolForAutoReview(ctx: PiAutoReviewContext): Reviewab
if (FILE_WRITE_TOOLS.has(toolName)) {
return { kind: 'file-write', path: stringField(input, 'path') };
}
if (toolName === 'bash') {
if (SHELL_TOOLS.has(toolName)) {
const evidenceAction = canonicalCredentialEvidenceAction(ctx.resolvedCredentialPaths);
if (evidenceAction) return evidenceAction;
return { kind: 'exec', command: stringField(input, 'command') ?? '' };
Comment thread
zqchris marked this conversation as resolved.
Expand Down
68 changes: 64 additions & 4 deletions packages/maker-core/src/agents/pi/cindy-bridge-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
createGrepTool,
createLsTool,
} from '@earendil-works/pi-coding-agent';
import * as piCodingAgent from '@earendil-works/pi-coding-agent';

const PERMISSION_TITLE = 'cindy:permission';
const TURN_CHANGE_CAPTURE_TITLE = 'cindy:turn-change-capture';
Expand All @@ -76,6 +77,9 @@ const PERMISSION_USER_DENY = 'user-deny';
const PERMISSION_AUTO_REVIEW_DENY = 'auto-review-deny';
const READONLY_BUILTINS = new Set(['read', 'grep', 'find', 'ls']);
const FILE_WRITE_BUILTINS = new Set(['edit', 'write']);
function isCindyShellTool(toolName: unknown): boolean {
return toolName === 'bash' || toolName === 'powershell';
}
const MANAGED_RG_PATH_ENV = 'CINDY_PI_MANAGED_RG_PATH';
const SUBAGENT_RUN_DIR_ENV = 'CINDY_PI_SUBAGENT_RUN_DIR';
const PI_PACKAGE_MANAGEMENT_ENV = 'CINDY_PI_PACKAGE_MANAGEMENT';
Expand Down Expand Up @@ -730,6 +734,14 @@ function bashExpandedPathCandidates(
}
}

function powershellInputReadEvidence(input: unknown): BashInputReadEvidence {
if (!input || typeof input !== 'object') return { targets: [], unresolved: false };
const command = (input as Record<string, unknown>).command;
if (typeof command !== 'string' || !command) return { targets: [], unresolved: false };
// PowerShell 不走 bash 重定向解析器;整条命令当字符串叶子扫凭证特征。
return { targets: [command], unresolved: false };
}

function bashInputReadEvidence(input: unknown): BashInputReadEvidence {
if (!input || typeof input !== 'object') return { targets: [], unresolved: false };
const command = (input as Record<string, unknown>).command;
Expand Down Expand Up @@ -2911,6 +2923,52 @@ export default async function cindyBridge(pi: any) {
},
});

// Pi v0.84.3 Windows powershell is the same spawn family as bash. Overlay only
// when the runtime exports the factory so 0.83.0 sessions keep loading.
const createPowerShellTool = (piCodingAgent as { createPowerShellTool?: typeof createBashTool }).createPowerShellTool;
if (typeof createPowerShellTool === 'function') {
const powershellTool = createPowerShellTool(process.cwd(), {
exposeSessionEnvironment: false,
spawnHook: ({ command, cwd, env }) => ({
command,
cwd,
env: isolatedBashEnvironment(env, bashPackageHome),
}),
});
var powershellParameters = powershellTool.parameters;
if (
powershellParameters &&
typeof powershellParameters === 'object' &&
powershellParameters.properties &&
typeof powershellParameters.properties === 'object' &&
powershellParameters.properties.timeout &&
typeof powershellParameters.properties.timeout === 'object'
) {
powershellParameters.properties.timeout.description = cindyBashTimeoutDescription();
}
if (typeof powershellTool.description === 'string') {
powershellTool.description =
powershellTool.description +
' Cindy enforces a ' +
CINDY_PI_BASH_DEFAULT_TIMEOUT_SECONDS +
's default and a ' +
CINDY_PI_BASH_MAX_TIMEOUT_SECONDS +
's maximum.';
}
pi.registerTool({
...powershellTool,
execute: async (id: string, params: unknown, signal: AbortSignal, onUpdate: unknown) => {
const nextParams = applyCindyBashTimeoutParams(params);
if (bashCommandMutatesPiPackages(nextParams)) {
Comment thread
zqchris marked this conversation as resolved.
throw new Error(
'Direct Pi extension changes are unavailable through bash. Use cindy_pi_extension so Cindy can request confirmation.',
);
}
return powershellTool.execute(id, nextParams as any, signal, onUpdate as any);
},
});
}

// Cindy owns a separate Pi extension store. Directly running the bundled Pi
// CLI from bash writes to Pi's default user home and bypasses Cindy's
// compatibility/approval state. Normal local tasks therefore receive one
Expand Down Expand Up @@ -3128,26 +3186,28 @@ export default async function cindyBridge(pi: any) {
// bash 读取任意进程的初始环境(/proc/<pid|self>/environ)是绕过密钥剥离的旁路:
// spawn 边界虽删了子进程 env 的私密变量,父 pi 进程仍持有,cat /proc/PPID/environ
// 同 UID 直取代理 token / 网关 / BYOM key(codex 报)→ 一律硬拦,含 Full access。
if (event.toolName === 'bash' && commandReadsProcessEnviron(event.input?.command)) {
if (isCindyShellTool(event.toolName) && commandReadsProcessEnviron(event.input?.command)) {
return { block: true, reason: 'Cindy blocks reading process environment (/proc/*/environ), even with Full access.' };
}
// 凭证/密钥路径的内置只读工具与 bash 输入重定向都必须携带 canonical
// 证据。bash 的原始 input 是整条命令,不能直接 realpath;先用与 Host 相同的
// parser 提取真实输入目标,才能识别工作区 symlink 指向的凭证文件。
const bashReadEvidence = event.toolName === 'bash'
? bashInputReadEvidence(event.input)
: { targets: [], unresolved: false };
: event.toolName === 'powershell'
? powershellInputReadEvidence(event.input)
: { targets: [], unresolved: false };
const bashReadTargets = bashReadEvidence.targets;
const readonlyCredentialEvidence = READONLY_BUILTINS.has(event.toolName)
? collectReadonlyCredentialEvidence(event.toolName, event.input)
: null;
const resolvedCredentialReadPaths = readonlyCredentialEvidence
? [...new Set(collectResolvedCredentialPaths(readonlyCredentialEvidence.paths))]
: event.toolName === 'bash'
: isCindyShellTool(event.toolName)
? [...new Set(collectResolvedCredentialPaths(bashReadTargets))]
: [];
const credentialRead = readonlyCredentialEvidence?.touchesCredential === true
|| (event.toolName === 'bash' && (bashReadEvidence.unresolved || touchesCredentialPath(bashReadTargets)))
|| (isCindyShellTool(event.toolName) && (bashReadEvidence.unresolved || touchesCredentialPath(bashReadTargets)))
|| resolvedCredentialReadPaths.length > 0;
const credentialEvidenceForHost = resolvedCredentialEvidenceForHost(
resolvedCredentialReadPaths,
Expand Down
3 changes: 3 additions & 0 deletions packages/maker-core/src/agents/pi/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,9 @@ export function translatePiEvent(
case 'summarization_retry_attempt_start':
case 'summarization_retry_finished':
case 'bash_execution_update':
// Pi v0.84.3 extension telemetry. If it ever leaks onto the RPC stream,
// ignore it: compaction_end already carries aborted/errorMessage.
case 'session_compact_failed':
return;

case 'extension_error': {
Expand Down
Loading