fix(coding): log sanitized Pi initialization failures

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 14:50:49 +08:00
1 parent e3e26b6bd6
commit 5da90e4a06
6 files changed
+815 -7

No files matched your search

@@ -12,23 +12,31 @@
## Scope ## Scope
- Diagnose the current trial Code runtime-unavailable notice using source, immutable runtime artifacts and bounded application startup diagnostics; explain what is actually known and the evidence gap. - Continue the diagnosed Code initialization issue by adding bounded, sanitized local exception logs at the managed-worker preparation/opening boundary. Preserve existing public errors, runtime behavior and cleanup. Validate code and redaction using isolated fixtures; do not assume the original cause is fixed merely by adding logs.
## Intent And Constraints ## Intent And Constraints
- This request asks why; no product/source fix, app restart, user worker, paid model call, UI mutation, profile/credential/chat-history read or deployment. Isolated owned checkout based on the just-published f34e651. Read only startup diagnostics and runtime dependency metadata. Preserve current runtime and all peer/primary work. - User now explicitly requests sanitized exception logging. Same owned Feature task/worktree/basef34 resumed through officialstart/status. Product source change is authorized; do not capture raw arbitrary error text, prompt/title, credential/env/config payload, URLs or absolute private paths. Keep only a fixed technical classification, allowlisted error name/code, safe code-frame locations and bounded correlation/fingerprint. Logger/formatter failures must not replace the product exception.
- Actual temporary app is undergoing parallel Canvas/Main network recovery and Renderer changes in other source chats. This task does not restart, inspect via private protocol, reset NetworkService or activate source files during that work. Preserve current Main/profile/project data and all peers; decide runtime delivery from a fresh coordinated source handoff rather than stale old authorization.
## Outcome ## Outcome
- Direct failure stage identified: the Pi Agent Server starts and reaches server.ready, then the managed session-open path fails and cleans up with open_failure. The original exception is not retained by current diagnostics, so the specific underlying cause cannot honestly be identified from existing evidence. No runtime or product changes made. - Initial read-only diagnosis: Pi Agent Server reaches server.ready, then managed session opening fails and cleans up with open_failure. Existing logs do not retain the original exception, so they cannot prove the specific underlying cause.
- Logging continuation adds one local WARN tagged PiWorkerInitializationFailed per failed managed-worker opening. Fixed stage markers cover registry/provider/resources/credential/extension/worker startup/initial hydration/session binding; the original failure is logged before existing cleanup and never replaces public errors, rejection identity or existing cleanup propagation.
- Diagnostic output contains only allowlisted exception/code, fixed classification, bounded cryptographic text fingerprint, hashed conversation reference, worker generation and safe relative code locations when structurally available. Arbitrary error messages, function names, user content/titles, credential/env/config values, URLs and absolute private paths are not logged. Cause depth, code frames and serialized UTF-8 diagnostic size are limited to2 levels/6 frames per error/4096 bytes. A controlled realpath alias preserves official Pi package locations behind pnpm dependency links.
- Pi RPC responses currently preserve only remote error text. Fixed operation/signature projection helps recognize known remote failure patterns without copying their property/function/path suffixes; it does not claim the actual remote exception type or restore its lost stack. Native lazy stack accessors and custom formatters fail closed rather than execute formatting hooks.
- No actual application activation/restart, source publication or model/provider call in this logging continuation. Current history still does not prove the original runtime failure is fixed.
## Verification ## Verification
- No tests or paid/provider invocation required for this read-only diagnosis. Source trace independently reviewed; current native-launch.log has three server.ready→open_failure cycles, no diagnostic field/original stack, and later background_sleep events do not prove recovery. Actual Main/server artifacts match active436 manifest. Pi0.84.2 files/modules/required API exports are present and resolve in the actual retained runtime; Node version meets its engine. E2E=1 is real but does not disable coding composition/Pi. No secrets/profile state read. - Read-only diagnosis independently reviewed: native-launch.log has three server.ready→open_failure cycles, no diagnostic field/original stack; later background_sleep does not prove recovery. At that observation, actual Main/server matched436. Pi0.84.2 required modules/exports exist and resolve; Node meets its engine. E2E=1 does not disable coding composition/Pi. No secrets/profile state read.
- Final logging validation: nine relevant unit suites/119 tests passed, including19 diagnostic privacy/RPC cases,6 managed-opener failure regressions and2 isolated native-stack safety probes. Standard typecheck and scoped ESLint passed. Default full build includes Renderer/Main/preload/release utility and passed; existing Browserslist/chunk/dynamic-import notices remain unrelated. No paid/provider invocation or actual user runtime session was used.
- Independent final source review PASS. It first proved preinstalled/inherited custom formatter execution in the intermediate implementation; final data-only code and two independently rerun isolated probes now show zero formatting-hook calls, omitted lazy frames and unchanged original stack descriptors. Logger failures, original public error/rejection identity, logging before cleanup, existing cleanup-error propagation and single failure logging are covered. Official package symlink regression uses the actual canonical temp path, matching Node SDK stack behavior. Whitespace and task-aware drift checks passed.
## Follow-ups ## Follow-ups
- For a repair continuation, capture a bounded sanitized original exception at the managed session-open boundary, reproduce the failing initialization without a paid prompt, then fix its actual cause and verify the real Code session. Avoid guessing a model/network cause, blindly reinstalling dependencies or treating E2E as a disable flag. This diagnostic checkout is retained ready for that same issue; no source fix or publication was requested. - Activate the validated logging Main artifact during the next coordinated update, then reproduce the failing initialization without a paid prompt and use new sanitized evidence to fix its actual cause. Avoid guessing a model/network cause, blindly reinstalling dependencies or treating E2E as a disable flag.
- Latest parallel Canvas handoff ea5d47787e6e43c8782ba159798bf05b93a16d27 supersedesca546 (adds Canvas-only Gallery removal; preserves yellow sidebar/no old explanation/stable create retry). The source thread also reports an actual WeChat Renderer alias atf1dabdb358ff5f2228fbef014f9e659c156ccc10 while current.json remains436. These are peer-reported delivery facts, not this task's native acceptance. A later update must freeze fresh actual sources/alias/dirty-input state and preserve WeChat changes; do not blindly overwrite using old436 metadata. This task performs no runtime actions while source coordination is incomplete.
## Promotion Candidates ## Promotion Candidates
@@ -46,3 +54,9 @@
- `electron/coding-runtime/pi/runtime.ts:238` managed opener includes provider projection/resources/extensions and SDK session opening;487–491 cleanup reasonopen_failure rethrows without preserving the raw error in current application logs. Existing server.ready events prove server startup/import but not successful thread/session initialization. - `electron/coding-runtime/pi/runtime.ts:238` managed opener includes provider projection/resources/extensions and SDK session opening;487–491 cleanup reasonopen_failure rethrows without preserving the raw error in current application logs. Existing server.ready events prove server startup/import but not successful thread/session initialization.
- Current stage native-launch.log contains E2E startup atline4; Main575 creates coding composition regardless. E2E alters startup effects/background idle timing, not a blanket Pi disable. Actual runtime/server/Main hashes match the active436 source; development resolves Pi CLI via snapshot node_modules→primary, not packaged resources/pi-runtime. Pi package/version,7server module paths and6required APIs are present. These checks do not prove every generated session/resource/configuration is valid. - Current stage native-launch.log contains E2E startup atline4; Main575 creates coding composition regardless. E2E alters startup effects/background idle timing, not a blanket Pi disable. Actual runtime/server/Main hashes match the active436 source; development resolves Pi CLI via snapshot node_modules→primary, not packaged resources/pi-runtime. Pi package/version,7server module paths and6required APIs are present. These checks do not prove every generated session/resource/configuration is valid.
- Read-only independent source and artifact reviewers agree: log evidence does not distinguish the original provider/configuration/extension/session exception. Specific diagnosis requires new sanitized exception evidence; do not call a hypothesis the proven root cause. - Read-only independent source and artifact reviewers agree: log evidence does not distinguish the original provider/configuration/extension/session exception. Specific diagnosis requires new sanitized exception evidence; do not call a hypothesis the proven root cause.
## Logging Resume Gate And Plan
- Same task official ownership exactly verified; startup and prior own diagnosis reloaded. Canonical positioning/decision/architecture/evidence/commitment context unchanged from previously loaded350 tree. Fresh74 and then75 peer scopes/intents/promotions independently assessed read-only; new Code gallery/old child-language placeholders remain unknown, without a concrete Pi logging dependency. Canvas is actively working Main shared networking/creation recovery, so this task performs only isolated source/test/build work. Gate Passed.
- Single-opener observation spans registry/provider/resources/credential/extension/process/initial hydration/session bind. Record original exception BEFORE cleanup inside existing catches, with a once-per-open sentinel; outer catch covers earlier failures and preserves the same rejection object and current cleanup propagation. No renderer message change or error-response expansion.
- Implement a fail-closed diagnostic formatter and focused privacy tests; wire fixed stage markers and known sensitive context in runtime.ts; add meaningful managed-opener regressions for failures before process creation, session open and cleanup/logger failure without changing public behavior. Run scoped tests/type/lint/default full build and independent review.
@@ -0,0 +1,267 @@
import { createHash } from 'node:crypto';
import { sanitizePiDiagnostic } from './worker-process';
const STAGES = new Set([
'registry', 'provider-input', 'provider-catalog', 'provider-credentials',
'worker-resources', 'device-resources', 'resource-materialization',
'extension-registration', 'worker-start', 'session-bind', 'initial-hydration',
'snapshot', 'prepare',
]);
const EXCEPTIONS = new Set([
'Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError',
'URIError', 'AggregateError', 'AbortError', 'PiProcessError',
'CodingRuntimeContractError', 'PiProviderConfigError', 'PiSessionProjectionError',
'CodingConversationServiceError', 'CodingProjectServiceError',
]);
const CODES = new Set([
'ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EINVAL', 'EEXIST', 'EMFILE', 'ENFILE',
'ENOSPC', 'EROFS', 'EIO', 'ENAMETOOLONG', 'ETIMEDOUT', 'EPIPE', 'ECONNRESET',
'ECONNREFUSED', 'MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_REQUIRE_ESM',
'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_INVALID_ARG_TYPE', 'ERR_INVALID_URL',
'ERR_IMPORT_ATTRIBUTE_MISSING', 'ERR_UNKNOWN_FILE_EXTENSION',
'ERR_DLOPEN_FAILED', 'ERR_WORKER_INIT_FAILED', 'ABORT_ERR',
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_SOCKET',
'PI_RPC_PROTOCOL_ERROR', 'PI_RPC_TIMEOUT', 'PI_RPC_ABORTED',
'PI_RPC_RESPONSE_ERROR', 'PI_RPC_WRITE_FAILED', 'PI_RPC_EXITED',
'PI_WORKER_START_FAILED', 'PI_WORKER_STOPPED', 'PI_WORKER_STOP_FAILED',
'CODING_RUNTIME_START_FAILED', 'CODING_RUNTIME_READY_TIMEOUT',
'CODING_RUNTIME_PROTOCOL_ERROR', 'CODING_RUNTIME_UNAVAILABLE',
'CODING_CONVERSATION_NOT_FOUND', 'CODING_SESSION_UNREADABLE',
'CODING_STORAGE_WRITE_FAILED', 'CODING_MIGRATION_MODEL_REQUIRED',
'CODING_MODEL_UNAVAILABLE', 'CODING_PROVIDER_AUTH_REQUIRED',
'CODING_AGENT_NOT_FOUND', 'CODING_REQUEST_UNCERTAIN',
'PROVIDER_INVALID', 'PROVIDER_AUTH_REQUIRED', 'MODEL_UNAVAILABLE',
]);
const MAX_BYTES = 4_096;
const MAX_TEXT_BYTES = 32_768;
const MAX_FRAME_BYTES = 160;
const MAX_FRAMES = 6;
const MAX_CAUSE_DEPTH = 2;
const REDACTED_FRAME = '[REDACTED_FRAME]';
const MAX_RPC_TAIL_BYTES = 2_048;
// Server control commands plus PiRpcClient's read-only initialization commands.
const RPC_OPERATIONS = [
'server_initialize', 'thread_open', 'thread_close', 'server_shutdown',
'get_available_models', 'get_available_thinking_levels', 'get_commands',
'get_entries', 'get_fork_messages', 'get_last_assistant_text', 'get_messages',
'get_session_stats', 'get_state', 'get_tree',
] as const;
export interface PiInitializationDiagnostic {
version: 1;
stage: string;
exception: string;
code?: string;
category: string;
message: string;
fingerprint?: string;
rpcOperation?: (typeof RPC_OPERATIONS)[number];
rpcFailureSignature?: 'undefined-property' | 'null-property' | 'not-callable' | 'module-not-found' | 'missing-local-resource';
frames?: string[];
causes?: PiInitializationDiagnostic[];
truncated?: true;
}
export interface PiInitializationDiagnosticOptions {
stage: string;
sensitiveValues?: readonly string[];
/** Bundled application/package roots only; never user project roots. */
trustedRoots?: readonly string[];
}
function own(value: unknown, key: string): PropertyDescriptor | undefined {
if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined;
try { return Object.getOwnPropertyDescriptor(value, key); } catch { return undefined; }
}
function data(value: unknown, key: string): unknown {
const descriptor = own(value, key);
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
}
function strings(value: unknown): string[] {
try {
if (!Array.isArray(value)) return [];
const length = data(value, 'length');
if (typeof length !== 'number' || !Number.isSafeInteger(length)) return [];
const result: string[] = [];
for (let index = 0; index < Math.min(length, 128); index++) {
const item = data(value, String(index));
if (typeof item === 'string' && item && item.length <= MAX_TEXT_BYTES && Buffer.byteLength(item) <= MAX_TEXT_BYTES) result.push(item);
}
return result;
} catch { return []; }
}
function exceptionName(error: unknown): string {
let value = error;
for (let depth = 0; depth < 3; depth++) {
const name = data(value, 'name');
if (typeof name === 'string') return EXCEPTIONS.has(name) ? name : 'UnknownError';
if (own(value, 'name')) return 'UnknownError';
try {
if ((typeof value !== 'object' || value === null) && typeof value !== 'function') break;
value = Object.getPrototypeOf(value);
} catch { break; }
}
return 'UnknownError';
}
function knownCode(error: unknown): string | undefined {
const direct = data(error, 'code');
if (typeof direct === 'string' && CODES.has(direct)) return direct;
const publicCode = data(data(error, 'publicError'), 'code');
return typeof publicCode === 'string' && CODES.has(publicCode) ? publicCode : undefined;
}
function rpcProjection(error: unknown): Pick<PiInitializationDiagnostic, 'rpcOperation' | 'rpcFailureSignature'> {
if (data(error, 'code') !== 'PI_RPC_RESPONSE_ERROR') return {};
const message = data(error, 'message');
if (typeof message !== 'string') return {};
// Inspect a bounded prefix first; never scan/copy an unbounded response tail.
const wrapper = message.slice(0, 128).match(/^Pi RPC ([a-z_]{1,48}) failed: /);
const operation = wrapper ? RPC_OPERATIONS.find(candidate => candidate === wrapper[1]) : undefined;
if (!wrapper || !operation) return {};
const result: Pick<PiInitializationDiagnostic, 'rpcOperation' | 'rpcFailureSignature'> = { rpcOperation: operation };
if (message.length - wrapper[0].length > MAX_RPC_TAIL_BYTES) return result;
const tail = message.slice(wrapper[0].length);
if (Buffer.byteLength(tail) > MAX_RPC_TAIL_BYTES) return result;
// Match known runtime shapes, but never retain their private property names,
// function expressions, package names, paths, URLs or other response suffixes.
const property = tail.match(/^(?:TypeError: )?Cannot read properties of (undefined|null) \(reading (?:'[^'\r\n]{1,256}'|"[^"\r\n]{1,256}")\)$/);
if (property) result.rpcFailureSignature = property[1] === 'undefined' ? 'undefined-property' : 'null-property';
else if (/^(?:TypeError: )?[^\r\n]{1,256} is not a function$/.test(tail)) result.rpcFailureSignature = 'not-callable';
else if (/^(?:Error: )?Cannot find module '[^'\r\n]{1,512}'(?:\nRequire stack:(?:\n- [^\r\n]{1,512}){1,8})?$/.test(tail)
|| /^(?:Error: )?Cannot find package '[^'\r\n]{1,256}' imported from [^\r\n]{1,1024}$/.test(tail)) result.rpcFailureSignature = 'module-not-found';
else if (/^(?:Error: )?ENOENT: no such file or directory(?:, (?:open|access|stat|lstat|scandir|readdir|realpath|readlink) '[^'\r\n]{1,1024}')?$/.test(tail)) result.rpcFailureSignature = 'missing-local-resource';
return result;
}
function classify(exception: string, code: string | undefined): Pick<PiInitializationDiagnostic, 'category' | 'message'> {
if (code && ['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_REQUIRE_ESM', 'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_UNKNOWN_FILE_EXTENSION', 'ERR_DLOPEN_FAILED'].includes(code))
return { category: 'module-loading', message: 'A required runtime module could not be loaded.' };
if (code && ['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EINVAL', 'EEXIST', 'EMFILE', 'ENFILE', 'ENOSPC', 'EROFS', 'EIO', 'ENAMETOOLONG', 'CODING_STORAGE_WRITE_FAILED'].includes(code))
return { category: 'local-storage', message: 'A local runtime resource could not be read or written.' };
if (code && ['PROVIDER_AUTH_REQUIRED', 'CODING_PROVIDER_AUTH_REQUIRED'].includes(code))
return { category: 'provider-authentication', message: 'Provider authentication preparation failed.' };
if (code && ['PROVIDER_INVALID', 'MODEL_UNAVAILABLE', 'CODING_MODEL_UNAVAILABLE', 'CODING_MIGRATION_MODEL_REQUIRED'].includes(code))
return { category: 'provider-configuration', message: 'The runtime model configuration could not be prepared.' };
if (code && ['PI_RPC_TIMEOUT', 'CODING_RUNTIME_READY_TIMEOUT', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT'].includes(code))
return { category: 'initialization-timeout', message: 'Runtime preparation or communication timed out.' };
if (code && ['PI_RPC_PROTOCOL_ERROR', 'PI_RPC_RESPONSE_ERROR', 'PI_RPC_WRITE_FAILED', 'CODING_RUNTIME_PROTOCOL_ERROR'].includes(code))
return { category: 'worker-protocol', message: 'The runtime worker communication failed.' };
if (code && ['PI_RPC_EXITED', 'PI_WORKER_START_FAILED', 'PI_WORKER_STOPPED', 'PI_WORKER_STOP_FAILED', 'CODING_RUNTIME_START_FAILED', 'ERR_WORKER_INIT_FAILED'].includes(code))
return { category: 'worker-lifecycle', message: 'The runtime worker could not start or remain available.' };
if (code === 'CODING_SESSION_UNREADABLE')
return { category: 'session-projection', message: 'Stored runtime session data could not be projected.' };
if (exception === 'TypeError' || exception === 'ReferenceError' || code === 'ERR_INVALID_ARG_TYPE')
return { category: 'runtime-contract', message: 'Runtime initialization encountered a type or reference mismatch.' };
if (exception === 'SyntaxError')
return { category: 'invalid-runtime-data', message: 'Runtime initialization encountered invalid syntax or data.' };
return { category: 'unclassified-initialization', message: 'Runtime initialization failed without a recognized error classification.' };
}
function secretAliases(values: string[]): string[] {
const result = new Set<string>();
for (const value of values) {
result.add(value);
try {
result.add(encodeURIComponent(value));
result.add(encodeURI(value));
result.add(encodeURIComponent(value).replace(/%20/g, '+'));
} catch { /* Invalid UTF-16 is still covered by its literal value. */ }
result.add(JSON.stringify(value).slice(1, -1));
result.add(Buffer.from(value).toString('base64'));
result.add(Buffer.from(value).toString('base64url'));
}
return [...result];
}
function decodedLocation(value: string): string | null {
if (Buffer.byteLength(value) > 2_048) return null;
let result = value;
try {
for (let depth = 0; depth < 2; depth++) {
const next = decodeURIComponent(result);
if (next === result) break;
result = next;
}
} catch { return null; }
if (result.startsWith('file:///')) result = result.slice(7);
else if (result.startsWith('file://localhost/')) result = result.slice(16);
else if (result.includes('://')) return null;
if (/^\/[A-Za-z]:\//.test(result)) result = result.slice(1);
result = result.replace(/\\/g, '/');
if (result.split('/').some(segment => segment === '..' || segment === '.')) return null;
return result;
}
function frameLocation(line: string, roots: string[], sensitive: string[]): string {
const match = line.match(/^\s*at\s+(?:.*?\()?([^()\r\n]+):(\d{1,9}):(\d{1,9})\)?\s*$/);
if (!match) return REDACTED_FRAME;
const location = decodedLocation(match[1]);
if (!location) return REDACTED_FRAME;
let relative: string | undefined;
if (/^node:internal\/[A-Za-z0-9_./-]+$/.test(location)) relative = location;
else {
for (let index = 0; index < roots.length; index++) {
const root = decodedLocation(roots[index])?.replace(/\/+$/, '');
if (!root || !/^(?:\/|[A-Za-z]:\/)/.test(root)) continue;
const windows = /^[A-Za-z]:\//.test(root);
const candidate = windows ? location.toLowerCase() : location;
const prefix = windows ? root.toLowerCase() : root;
if (candidate.startsWith(prefix + '/')) {
const suffix = location.slice(root.length + 1);
// Drop function names and absolute roots; retain only code locations.
if (/^[A-Za-z0-9_@+.,$-]+(?:\/[A-Za-z0-9_@+.,$-]+)*\.(?:[cm]?[jt]sx?|mjs|cjs)$/.test(suffix)) relative = `root${index}/${suffix}`;
break;
}
}
}
if (!relative || /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(relative)) return REDACTED_FRAME;
if (sanitizePiDiagnostic(relative, sensitive) !== relative) return REDACTED_FRAME;
const frame = `${relative}:${match[2]}:${match[3]}`;
return Buffer.byteLength(frame) <= MAX_FRAME_BYTES ? frame : REDACTED_FRAME;
}
/** Produce bounded technical evidence only; arbitrary exception text is never logged. */
export function buildPiInitializationDiagnostic(
error: unknown,
options: PiInitializationDiagnosticOptions,
): PiInitializationDiagnostic {
const requestedStage = data(options, 'stage');
const stage = typeof requestedStage === 'string' && STAGES.has(requestedStage) ? requestedStage : 'unknown';
const roots = strings(data(options, 'trustedRoots'));
const sensitive = secretAliases(strings(data(options, 'sensitiveValues')));
const seen = new Set<unknown>();
const build = (value: unknown, depth: number): PiInitializationDiagnostic => {
const exception = exceptionName(value);
const code = knownCode(value);
const result: PiInitializationDiagnostic = {
version: 1, stage, exception, ...(code ? { code } : {}), ...classify(exception, code), ...rpcProjection(value),
};
const message = typeof value === 'string' ? value : data(value, 'message');
if (typeof message === 'string' && message) {
const bytes = Buffer.from(message.slice(0, MAX_TEXT_BYTES));
result.fingerprint = createHash('sha256').update(bytes.subarray(0, MAX_TEXT_BYTES)).digest('hex').slice(0, 16);
if (message.length > MAX_TEXT_BYTES || bytes.length > MAX_TEXT_BYTES) result.truncated = true;
}
// Even native lazy stack accessors can call global formatting hooks. Only
// inspect an already available string data property; never invoke a getter.
const stack = data(value, 'stack');
if (typeof stack === 'string') {
const bytes = Buffer.from(stack.slice(0, MAX_TEXT_BYTES));
const bounded = bytes.subarray(0, MAX_TEXT_BYTES).toString('utf8');
const lines = bounded.split(/\r?\n/).filter(line => /^\s*at\s/.test(line));
result.frames = lines.slice(0, MAX_FRAMES).map(line => frameLocation(line, roots, sensitive));
if (lines.length > MAX_FRAMES || stack.length > MAX_TEXT_BYTES || bytes.length > MAX_TEXT_BYTES) result.truncated = true;
}
seen.add(value);
const cause = data(value, 'cause');
if (cause !== undefined) {
if (depth < MAX_CAUSE_DEPTH && !seen.has(cause)) result.causes = [build(cause, depth + 1)];
else result.truncated = true;
}
return result;
};
const diagnostic = build(error, 0);
const nodes = [diagnostic, ...(diagnostic.causes ?? []), ...(diagnostic.causes?.[0]?.causes ?? [])];
while (Buffer.byteLength(JSON.stringify(diagnostic)) > MAX_BYTES) {
diagnostic.truncated = true;
const removable = [...nodes].reverse().find(node => node.frames?.length);
if (removable?.frames) removable.frames.pop();
else { delete diagnostic.causes; break; }
}
return diagnostic;
}
+73 -2
View File
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { buildPiInitializationDiagnostic } from './initialization-diagnostic';
import type { ModelSummary, ProviderAccount } from '../../shared/providers/types'; import type { ModelSummary, ProviderAccount } from '../../shared/providers/types';
import { validateSessionKey } from '../../coding-projects/conversation-store'; import { validateSessionKey } from '../../coding-projects/conversation-store';
import { import {
@@ -233,9 +235,18 @@ export function createPiManagedWorkerOpener(
const createProcess = options.createProcess ?? ((processOptions) => new PiWorkerProcess(processOptions)); const createProcess = options.createProcess ?? ((processOptions) => new PiWorkerProcess(processOptions));
const createSessionKey = options.createSessionKey ?? randomUUID; const createSessionKey = options.createSessionKey ?? randomUUID;
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
return async (input) => { const openWorker = async (
input: PiWorkerOpenInput,
observation: {
stage(value: string): void;
sensitive(...values: Array<string | undefined>): void;
failed(error: unknown): void;
},
): Promise<PiWorkerOpenResult> => {
const resourcesStartedAt = now(); const resourcesStartedAt = now();
const registered = await options.registry.prepare(input.conversation); const registered = await options.registry.prepare(input.conversation);
observation.sensitive(registered.projectPath, registered.agent.prompt, registered.agent.name,
registered.agent.roleName, registered.conversation.title);
const existingSession = input.existingSession ?? registered.session ?? undefined; const existingSession = input.existingSession ?? registered.session ?? undefined;
const model = registered.conversation.model; const model = registered.conversation.model;
if (!model || registered.conversation.modelResolution !== 'resolved') { if (!model || registered.conversation.modelResolution !== 'resolved') {
@@ -245,7 +256,9 @@ export function createPiManagedWorkerOpener(
true, true,
); );
} }
observation.stage('provider-input');
const providerInput = await options.loadProviderInput(); const providerInput = await options.loadProviderInput();
observation.stage('provider-catalog');
const catalog = buildPiProviderCatalog(providerInput); const catalog = buildPiProviderCatalog(providerInput);
const selection = selectPiProviderModel(catalog, model); const selection = selectPiProviderModel(catalog, model);
const account = providerInput.accounts.find((candidate) => candidate.id === selection.accountId); const account = providerInput.accounts.find((candidate) => candidate.id === selection.accountId);
@@ -253,6 +266,7 @@ export function createPiManagedWorkerOpener(
if (!account || !descriptor) throw new Error('Selected Provider account is unavailable'); if (!account || !descriptor) throw new Error('Selected Provider account is unavailable');
const managedPaths = await ensurePiManagedPaths(options.userDataDir); const managedPaths = await ensurePiManagedPaths(options.userDataDir);
await writePiProviderCatalog(managedPaths.modelsFile, catalog, model); await writePiProviderCatalog(managedPaths.modelsFile, catalog, model);
observation.stage('worker-resources');
const workerResources = options.capabilityRegistry const workerResources = options.capabilityRegistry
? await options.capabilityRegistry.resolveWorkerResources({ ? await options.capabilityRegistry.resolveWorkerResources({
projectPath: registered.projectPath, projectPath: registered.projectPath,
@@ -261,6 +275,7 @@ export function createPiManagedWorkerOpener(
role: 'parent', role: 'parent',
}) })
: fallbackWorkerResources(registered.agent.skillIds); : fallbackWorkerResources(registered.agent.skillIds);
observation.stage('device-resources');
const deviceResources = options.devicePackageManager const deviceResources = options.devicePackageManager
? await options.devicePackageManager.resolveEnabledResources() ? await options.devicePackageManager.resolveEnabledResources()
: undefined; : undefined;
@@ -268,6 +283,7 @@ export function createPiManagedWorkerOpener(
...workerResources.skillEntries, ...workerResources.skillEntries,
...(deviceResources?.skillEntries ?? []), ...(deviceResources?.skillEntries ?? []),
]; ];
observation.stage('resource-materialization');
const resources = await materializePiAgentResources({ const resources = await materializePiAgentResources({
userDataDir: options.userDataDir, userDataDir: options.userDataDir,
projectId: input.conversation.projectId, projectId: input.conversation.projectId,
@@ -290,6 +306,8 @@ export function createPiManagedWorkerOpener(
: {}), : {}),
revision: input.revision, revision: input.revision,
}); });
observation.sensitive(resources.paths.configDir, resources.projectSessionsDir, managedPaths.rootDir);
observation.stage('provider-credentials');
const credential = await buildPiWorkerCredentialProjection({ const credential = await buildPiWorkerCredentialProjection({
account, account,
descriptor, descriptor,
@@ -298,6 +316,8 @@ export function createPiManagedWorkerOpener(
? { localProxyCredential: await options.getLocalProxyCredential() } ? { localProxyCredential: await options.getLocalProxyCredential() }
: {}), : {}),
}); });
observation.sensitive(...credential.sensitiveValues);
observation.stage('extension-registration');
const modelTools = options.modelToolRegistry?.registerWorker({ const modelTools = options.modelToolRegistry?.registerWorker({
conversationId: input.conversation.conversationId, conversationId: input.conversation.conversationId,
generation: input.generation, generation: input.generation,
@@ -335,6 +355,7 @@ export function createPiManagedWorkerOpener(
extensionsDir: managedPaths.extensionsDir, extensionsDir: managedPaths.extensionsDir,
}); });
} catch (error) { } catch (error) {
observation.failed(error);
modelTools?.dispose(); modelTools?.dispose();
throw error; throw error;
} }
@@ -346,12 +367,15 @@ export function createPiManagedWorkerOpener(
now(), now(),
!existingSession, !existingSession,
); );
observation.sensitive(extension.extensionPath, ...extension.sensitiveValues);
observation.stage('session-bind');
const sessionKey = validateSessionKey( const sessionKey = validateSessionKey(
existingSession?.sessionKey ?? createSessionKey(), existingSession?.sessionKey ?? createSessionKey(),
); );
if (existingSession && input.fork) { if (existingSession && input.fork) {
throw new Error('Pi worker cannot reopen and fork a session at the same time'); throw new Error('Pi worker cannot reopen and fork a session at the same time');
} }
observation.stage('worker-start');
const process = createProcess({ const process = createProcess({
executablePath: options.executablePath, executablePath: options.executablePath,
cliPath: options.cliPath, cliPath: options.cliPath,
@@ -417,6 +441,7 @@ export function createPiManagedWorkerOpener(
if (input.fork?.sourceEntryId) { if (input.fork?.sourceEntryId) {
await process.request({ type: 'fork', entryId: input.fork.sourceEntryId }); await process.request({ type: 'fork', entryId: input.fork.sourceEntryId });
} }
observation.stage('initial-hydration');
const readyStartedAt = now(); const readyStartedAt = now();
const response = await process.request<PiRpcSessionStateProjection>( const response = await process.request<PiRpcSessionStateProjection>(
{ type: 'get_state' }, { type: 'get_state' },
@@ -451,6 +476,7 @@ export function createPiManagedWorkerOpener(
); );
} }
} }
observation.stage('session-bind');
const sessionStartedAt = now(); const sessionStartedAt = now();
const bound = await options.registry.ensureBinding(input.conversation, async () => ({ const bound = await options.registry.ensureBinding(input.conversation, async () => ({
piSessionId, piSessionId,
@@ -485,12 +511,57 @@ export function createPiManagedWorkerOpener(
session: clone(bound.session), session: clone(bound.session),
}; };
} catch (error) { } catch (error) {
observation.failed(error);
unsubscribeExtensionInvalidation(); unsubscribeExtensionInvalidation();
await process.stop('open_failure').catch(() => undefined); await process.stop('open_failure').catch(() => undefined);
await disposeManagedResources(); await disposeManagedResources();
throw error; throw error;
} }
}; };
return async (input) => {
let stage = 'registry';
let failureObserved = false;
const sensitiveValues = [input.conversation.title, options.userDataDir,
options.executablePath, options.cliPath, options.bundledSkillsDir];
const observation = {
stage: (value: string) => { stage = value; },
sensitive: (...values: Array<string | undefined>) => {
sensitiveValues.push(...values.filter((value): value is string => typeof value === 'string'));
},
failed: (error: unknown) => {
if (failureObserved) return;
failureObserved = true;
try {
const piPackageRoot = path.resolve(path.dirname(options.cliPath), '..');
const trustedRoots = [piPackageRoot, path.resolve(__dirname, '..', '..'), options.bundledSkillsDir];
try {
// pnpm package stacks use the real path behind the bundled dependency link.
const resolvedPiRoot = realpathSync.native(piPackageRoot);
if (!trustedRoots.includes(resolvedPiRoot)) trustedRoots.push(resolvedPiRoot);
} catch {
// Missing resources must still produce a diagnostic using known roots.
}
logger.warn('[PiWorkerInitializationFailed]', {
conversationRef: createHash('sha256').update(input.conversation.conversationId).digest('hex').slice(0, 16),
workerGeneration: Number.isSafeInteger(input.generation) ? input.generation : 0,
diagnostic: buildPiInitializationDiagnostic(error, {
stage,
sensitiveValues,
trustedRoots,
}),
});
} catch {
// Diagnostic construction/output must never change initialization or cleanup.
}
},
};
try {
return await openWorker(input, observation);
} catch (error) {
observation.failed(error);
throw error;
}
};
} }
function recordManagedMilestone( function recordManagedMilestone(
@@ -0,0 +1,60 @@
// @vitest-environment node
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
// Isolate global Error hooks from Vitest. Load the actual formatter source;
// the sanitizer is unreachable here because lazy stack frames are omitted.
const probe = `
import { readFileSync } from 'node:fs';
import { registerHooks, stripTypeScriptTypes } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const sourceUrl = pathToFileURL(process.argv[1]).href;
registerHooks({
resolve(specifier, context, next) {
if (specifier === './worker-process' && context.parentURL === sourceUrl)
return { url: 'diagnostic-fixture:sanitizer', shortCircuit: true };
return next(specifier, context);
},
load(url, context, next) {
if (url === 'diagnostic-fixture:sanitizer') return { format: 'module',
source: 'export function sanitizePiDiagnostic() { throw new Error("Unexpected frame processing"); }', shortCircuit: true };
if (url === sourceUrl) return { format: 'module',
source: stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8')), shortCircuit: true };
return next(url, context);
},
});
let calls = 0;
if (process.argv[2] === 'preinstalled') {
Object.defineProperty(Error, 'prepareStackTrace', { configurable: true,
value() { calls++; return 'private custom formatter result'; } });
} else {
Reflect.deleteProperty(Error, 'prepareStackTrace');
}
const { buildPiInitializationDiagnostic } = await import(sourceUrl);
if (process.argv[2] === 'inherited') {
Object.defineProperty(Function.prototype, 'prepareStackTrace', { configurable: true,
get() { calls++; return () => { calls++; return 'private inherited formatter result'; }; } });
}
const error = new Error('private message');
const prior = Object.getOwnPropertyDescriptor(error, 'stack');
calls = 0;
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' });
const after = Object.getOwnPropertyDescriptor(error, 'stack');
process.stdout.write(JSON.stringify({ calls, diagnostic,
stackUnchanged: prior?.get === after?.get && prior?.value === after?.value }));
`;
describe('initialization diagnostic isolated native stack safety', () => {
it.each(['preinstalled', 'inherited'])('does not execute %s global formatting hooks or modify the error', mode => {
const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', probe,
path.resolve('electron/coding-runtime/pi/initialization-diagnostic.ts'), mode],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 }).trim());
expect(result.calls).toBe(0);
expect(result.stackUnchanged).toBe(true);
expect(result.diagnostic).toMatchObject({ stage: 'worker-start', exception: 'Error' });
expect(result.diagnostic.frames).toBeUndefined();
expect(JSON.stringify(result.diagnostic)).not.toContain('private');
});
});
@@ -0,0 +1,237 @@
import { describe, expect, it } from 'vitest';
import { buildPiInitializationDiagnostic } from '../../electron/coding-runtime/pi/initialization-diagnostic';
const root = '/private/bundled-app/resources';
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwcml2YXRlIn0.private-signature';
const credential = 'private-key-S3cr3t-12345';
function withStack<T extends Error>(error: T, stack: string): T {
Object.defineProperty(error, 'stack', { configurable: true, writable: true, value: stack });
return error;
}
describe('Pi initialization diagnostic privacy and technical evidence', () => {
it('retains the technical class and trusted source location without the exception text or absolute root', () => {
const error = withStack(Object.assign(new TypeError(`Cannot read ${credential} from https://private.example/api`),
{ code: 'ERR_INVALID_ARG_TYPE' }), [
`TypeError: ${credential}`,
` at secretFunctionName (${root}/pi/runtime.ts:253:20)`,
' at processTicksAndRejections (node:internal/process/task_queues:95:5)',
' at execute (/Users/private-user/project/private-module.ts:8:1)',
].join('\n'));
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start', trustedRoots: [root], sensitiveValues: [credential] });
expect(diagnostic).toMatchObject({
version: 1, stage: 'worker-start', exception: 'TypeError', code: 'ERR_INVALID_ARG_TYPE',
category: 'runtime-contract', frames: ['root0/pi/runtime.ts:253:20', 'node:internal/process/task_queues:95:5', '[REDACTED_FRAME]'],
});
expect(diagnostic.fingerprint).toMatch(/^[a-f0-9]{16}$/);
const serialized = JSON.stringify(diagnostic);
for (const privateValue of [credential, 'Cannot read', 'secretFunctionName', root, 'private-user', 'https://private.example/api'])
expect(serialized).not.toContain(privateValue);
});
it('classifies a failed worker with its filesystem cause while ignoring arbitrary custom error fields', () => {
const cause = Object.assign(new Error(`open /Users/alice/${credential}.json`), { code: 'ENOENT', path: `/Users/alice/${credential}.json` });
const error = Object.assign(new Error(`worker failed: ${jwt}`, { cause }), {
name: 'PiProcessError', code: 'PI_WORKER_START_FAILED', diagnostic: `authorization: Bearer ${credential}`, token: credential,
});
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' });
expect(diagnostic).toMatchObject({
exception: 'PiProcessError', code: 'PI_WORKER_START_FAILED', category: 'worker-lifecycle',
causes: [{ exception: 'Error', code: 'ENOENT', category: 'local-storage' }],
});
const serialized = JSON.stringify(diagnostic);
for (const value of [credential, jwt, '/Users/alice', 'authorization', 'token']) expect(serialized).not.toContain(value);
});
it('never records free-form headers, quoted JSON, JWTs, URLs or private paths', () => {
const input = [
`Authorization: "Bearer ${credential}"`, `{"api_key":"${credential}","prompt":"用户的私密问题"}`,
jwt, 'https://user:password@example.test/private?q=secret', '/Users/alice/private-folder/a.txt',
'C:\\Users\\Alice\\private-folder\\a.txt', 'file:///Users/alice/private-folder/a.txt', '../private-folder/a.txt',
].join('\n');
const diagnostic = buildPiInitializationDiagnostic(new Error(input), { stage: input });
const serialized = JSON.stringify(diagnostic);
expect(diagnostic.stage).toBe('unknown');
expect(diagnostic.fingerprint).toMatch(/^[a-f0-9]{16}$/);
for (const value of [credential, jwt, '用户的私密问题', 'private-folder', 'example.test', 'Authorization', 'api_key', 'password'])
expect(serialized).not.toContain(value);
});
it('redacts literal and encoded sensitive values even inside trusted stack locations', () => {
const privateName = '私密配置-Alpha42';
const encodings = [
privateName, encodeURIComponent(privateName), encodeURIComponent(encodeURIComponent(privateName)),
Buffer.from(privateName).toString('base64url'),
];
const error = withStack(new Error(privateName), encodings.map(value => ` at fn (${root}/${value}.ts:11:2)`).join('\n'));
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'resource-materialization', trustedRoots: [root], sensitiveValues: [privateName] });
expect(diagnostic.frames).toEqual(encodings.map(() => '[REDACTED_FRAME]'));
const serialized = JSON.stringify(diagnostic);
for (const value of encodings) expect(serialized).not.toContain(value);
});
it('keeps file-URL and Windows bundled code locations relative without logging their parent names', () => {
const error = withStack(new Error('private text'), [
' at load (file:///Users/private-owner/My%20App/resources/pi/loader.mjs:42:9)',
' at load (C:\\Users\\private-owner\\App\\resources\\pi\\loader.cjs:48:2)',
' at load (file://remote-host/private/a.js:8:1)',
' at load (./private/a.js:8:1)',
' at load (/Users/private-owner/My App/resources/pi/../secret.js:8:1)',
].join('\n'));
const diagnostic = buildPiInitializationDiagnostic(error, {
stage: 'worker-resources', trustedRoots: ['/Users/private-owner/My App/resources', 'C:\\Users\\private-owner\\App\\resources'],
});
expect(diagnostic.frames).toEqual(['root0/pi/loader.mjs:42:9', 'root1/pi/loader.cjs:48:2', '[REDACTED_FRAME]', '[REDACTED_FRAME]', '[REDACTED_FRAME]']);
const serialized = JSON.stringify(diagnostic);
for (const value of ['private-owner', 'Users', 'file:', 'remote-host', 'My%20App', 'C:']) expect(serialized).not.toContain(value);
});
it('rejects forged exception names and non-whitelisted codes without falling back to their raw values', () => {
const error = { name: credential, code: 'ERR_PRIVATE_API_KEY_' + credential, message: credential, publicError: { code: credential, message: jwt } };
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'provider-catalog' });
expect(diagnostic.exception).toBe('UnknownError');
expect(diagnostic.code).toBeUndefined();
expect(diagnostic.category).toBe('unclassified-initialization');
expect(JSON.stringify(diagnostic)).not.toContain(credential);
expect(JSON.stringify(diagnostic)).not.toContain(jwt);
});
it('does not execute error getters, coercion hooks or lazy stack formatting through hostile fields', () => {
const accessed: string[] = [];
const error = new Error('ordinary placeholder');
for (const key of ['name', 'message', 'stack', 'code', 'cause', 'publicError']) {
Object.defineProperty(error, key, { configurable: true, get() { accessed.push(key); throw new Error(credential); } });
}
Object.assign(error, {
toString() { accessed.push('toString'); return credential; },
[Symbol.toPrimitive]() { accessed.push('toPrimitive'); return credential; },
});
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'snapshot' });
expect(accessed).toEqual([]);
expect(diagnostic).toMatchObject({ stage: 'snapshot', exception: 'UnknownError' });
expect(JSON.stringify(diagnostic)).not.toContain(credential);
});
it('survives proxies which throw during structural inspection', () => {
const error = new Proxy({}, {
get() { throw new Error(credential); },
getOwnPropertyDescriptor() { throw new Error(credential); },
getPrototypeOf() { throw new Error(credential); },
});
expect(() => buildPiInitializationDiagnostic(error, { stage: 'registry' })).not.toThrow();
expect(JSON.stringify(buildPiInitializationDiagnostic(error, { stage: 'registry' }))).not.toContain(credential);
});
it('does not invoke inherited formatting getters or a replaced global stack formatter', () => {
let calls = 0;
const prototype = Object.create(Error.prototype);
Object.defineProperty(prototype, 'name', { get() { calls++; return credential; } });
const inherited = new Error('placeholder');
Object.setPrototypeOf(inherited, prototype);
expect(buildPiInitializationDiagnostic(inherited, { stage: 'snapshot' }).frames).toBeUndefined();
const prior = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace');
try {
Object.defineProperty(Error, 'prepareStackTrace', { configurable: true, value() { calls++; return credential; } });
const diagnostic = buildPiInitializationDiagnostic(new Error('placeholder'), { stage: 'snapshot' });
expect(diagnostic.frames).toBeUndefined();
expect(JSON.stringify(diagnostic)).not.toContain(credential);
expect(calls).toBe(0);
} finally {
if (prior) Object.defineProperty(Error, 'prepareStackTrace', prior);
else Reflect.deleteProperty(Error, 'prepareStackTrace');
}
});
it('bounds UTF-8 output, stack frames and cause chains and terminates cause cycles', () => {
const make = () => withStack(new Error('长'.repeat(40_000)),
Array.from({ length: 50 }, (_, index) => ` at initialize (${root}/pi/${'a'.repeat(120)}${index}.ts:999999:123)`).join('\n'));
const error = make();
const cause = make();
const third = make();
Object.assign(error, { cause });
Object.assign(cause, { cause: third });
Object.assign(third, { cause: error });
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'initial-hydration', trustedRoots: [root] });
expect(Buffer.byteLength(JSON.stringify(diagnostic))).toBeLessThanOrEqual(4_096);
expect(diagnostic.truncated).toBe(true);
let current = diagnostic;
for (let depth = 0; depth < 3; depth++) {
expect(current.frames?.length ?? 0).toBeLessThanOrEqual(6);
if (!current.causes?.[0]) break;
expect(depth).toBeLessThan(2);
current = current.causes[0];
}
expect(JSON.stringify(diagnostic)).not.toContain('长');
});
it('projects known public contract codes and primitive failures without error content', () => {
const diagnostic = buildPiInitializationDiagnostic({ publicError: { code: 'CODING_MIGRATION_MODEL_REQUIRED', message: credential } }, { stage: 'provider-input' });
expect(diagnostic).toMatchObject({ code: 'CODING_MIGRATION_MODEL_REQUIRED', category: 'provider-configuration' });
expect(JSON.stringify(diagnostic)).not.toContain(credential);
expect(buildPiInitializationDiagnostic(credential, { stage: 'prepare' })).toMatchObject({ exception: 'UnknownError', fingerprint: expect.stringMatching(/^[a-f0-9]{16}$/) });
});
it('locates a wrapped thread_open SDK failure without turning its private property into a logged field', () => {
const error = Object.assign(new Error(`Pi RPC thread_open failed: Cannot read properties of undefined (reading '${credential}')`), {
name: 'PiProcessError', code: 'PI_RPC_RESPONSE_ERROR',
});
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' });
expect(diagnostic).toMatchObject({
exception: 'PiProcessError', code: 'PI_RPC_RESPONSE_ERROR', category: 'worker-protocol',
rpcOperation: 'thread_open', rpcFailureSignature: 'undefined-property',
});
expect(JSON.stringify(diagnostic)).not.toContain(credential);
expect(JSON.stringify(diagnostic)).not.toContain('Cannot read properties');
});
it.each([
{ tail: `TypeError: Cannot read properties of null (reading '${credential}')`, signature: 'null-property' },
{ tail: `${credential}.request is not a function`, signature: 'not-callable' },
{ tail: `Cannot find module '/Users/private-user/${credential}.js'\nRequire stack:\n- /Users/private-user/${jwt}.js`, signature: 'module-not-found' },
{ tail: `Cannot find package '${credential}' imported from https://private.example/${jwt}`, signature: 'module-not-found' },
{ tail: `ENOENT: no such file or directory, open 'C:\\Users\\private-user\\${credential}.json'`, signature: 'missing-local-resource' },
])('projects only a fixed signature from private RPC content: $signature', ({ tail, signature }) => {
const error = Object.assign(new Error(`Pi RPC get_state failed: ${tail}`), { name: 'PiProcessError', code: 'PI_RPC_RESPONSE_ERROR' });
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'initial-hydration' });
expect(diagnostic).toMatchObject({ rpcOperation: 'get_state', rpcFailureSignature: signature, exception: 'PiProcessError' });
const serialized = JSON.stringify(diagnostic);
for (const value of [credential, jwt, 'private-user', 'private.example', 'C:', 'Users', '.request']) expect(serialized).not.toContain(value);
});
it('does not interpret user text, unknown commands, embedded wrappers or unknown response patterns as RPC signatures', () => {
const wrapped = `Pi RPC thread_open failed: Cannot read properties of undefined (reading '${credential}')`;
for (const error of [
new Error(wrapped),
Object.assign(new Error('private prefix\n' + wrapped), { code: 'PI_RPC_RESPONSE_ERROR' }),
Object.assign(new Error(`Pi RPC ${credential} failed: ${wrapped}`), { code: 'PI_RPC_RESPONSE_ERROR' }),
{ publicError: { code: 'PI_RPC_RESPONSE_ERROR' }, message: wrapped },
]) {
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' });
expect(diagnostic.rpcOperation).toBeUndefined();
expect(diagnostic.rpcFailureSignature).toBeUndefined();
expect(JSON.stringify(diagnostic)).not.toContain(credential);
}
const unknown = Object.assign(new Error(`Pi RPC thread_open failed: Authorization: Bearer ${credential} ${jwt}`), { code: 'PI_RPC_RESPONSE_ERROR' });
const diagnostic = buildPiInitializationDiagnostic(unknown, { stage: 'worker-start' });
expect(diagnostic.rpcOperation).toBe('thread_open');
expect(diagnostic.rpcFailureSignature).toBeUndefined();
expect(JSON.stringify(diagnostic)).not.toContain(credential);
expect(JSON.stringify(diagnostic)).not.toContain(jwt);
});
it('rejects trailing additions and overlong UTF-8 response tails while retaining only a recognized operation', () => {
for (const tail of [
`Cannot read properties of undefined (reading '${credential}')\n${jwt}`,
`${credential}${'a'.repeat(4_096)} is not a function`,
`${'私'.repeat(1_000)} is not a function`,
]) {
const error = Object.assign(new Error(`Pi RPC thread_open failed: ${tail}`), { code: 'PI_RPC_RESPONSE_ERROR' });
const diagnostic = buildPiInitializationDiagnostic(error, { stage: 'worker-start' });
expect(diagnostic.rpcOperation).toBe('thread_open');
expect(diagnostic.rpcFailureSignature).toBeUndefined();
expect(Buffer.byteLength(JSON.stringify(diagnostic))).toBeLessThanOrEqual(4_096);
for (const value of [credential, jwt, '私']) expect(JSON.stringify(diagnostic)).not.toContain(value);
}
});
});
@@ -0,0 +1,159 @@
// @vitest-environment node
import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { logger } from '../../electron/utils/logger';
import { createPiManagedWorkerOpener, type PiWorkerProcessAdapter } from '../../electron/coding-runtime/pi/runtime';
import type { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
import type { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
import type { ModelToolRegistryPort } from '../../electron/coding-runtime/pi/model-tools/model-tool-registry';
import type { PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process';
const roots: string[] = [];
const TITLE = 'private conversation title';
const PROMPT = 'private project instructions';
const SECRET = 'private-provider-credential';
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
});
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-init-diagnostic-'));
roots.push(root);
const projectPath = path.join(root, 'private-project');
await mkdir(projectPath);
const model = { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'off' as const };
const input = {
conversation: { conversationId: 'conversation-a', projectId: 'project-a', agentId: 'agent-a', title: TITLE,
model: { model, modelResolution: 'resolved' as const } },
generation: 7, revision: { provider: 1, resources: 1 },
};
const registry = {
prepare: vi.fn(async () => ({ projectPath,
conversation: { model, modelResolution: 'resolved', title: TITLE },
agent: { id: 'agent-a', name: 'private agent name', roleName: 'private role', prompt: PROMPT, skillIds: [] } })),
ensureBinding: vi.fn(async () => ({ session: { piSessionId: 'session-a', sessionKey: 'session-a' } })),
};
const dispose = vi.fn(async () => undefined);
const extensionHost = { registerWorker: vi.fn(async () => ({
allowedToolNames: [], extensionPath: path.join(root, 'extension.mjs'),
env: { PRIVATE_EXTENSION_SECRET: SECRET }, sensitiveValues: [SECRET], dispose,
})) };
const process = {
generation: 7, start: vi.fn(async () => undefined),
request: vi.fn(async () => ({ type: 'response', success: true, data: { sessionId: 'session-a' } })),
send: vi.fn(async () => undefined), subscribe: vi.fn(() => () => undefined),
subscribeInvalidation: vi.fn(() => () => undefined),
stop: vi.fn(async () => ({ mode: 'stdin-close' as const, code: 0, signal: null })),
};
const loadProviderInput = vi.fn(async () => ({ accounts: [{
id: 'account-a', vendorId: 'custom', label: 'fixture', authMode: 'api_key' as const,
apiProtocol: 'openai-completions' as const, baseUrl: 'https://provider.example/v1', model: 'model-a',
enabled: true, isDefault: true, createdAt: '2026-10-14T00:00:00Z', updatedAt: '2026-10-14T00:00:00Z',
}], modelSummaries: [] }));
const createProcess = vi.fn((_options: PiWorkerProcessOptions) => process as unknown as PiWorkerProcessAdapter);
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
const options = { registry: registry as unknown as PiSessionRegistry,
executablePath: 'electron', cliPath: 'pi-cli.js', userDataDir: path.join(root, 'user-data'),
bundledSkillsDir: path.resolve('resources/coding-skills'),
extensionHost: extensionHost as unknown as PiManagedExtensionHost,
loadProviderInput, resolveCredential: async () => SECRET,
createSessionKey: () => 'session-a', createProcess };
const failures = () => warn.mock.calls.filter(([tag]) => tag === '[PiWorkerInitializationFailed]');
return { root, input, registry, extensionHost, dispose, process, loadProviderInput, createProcess, warn, failures, options };
}
describe('managed worker initialization failure logs', () => {
it('records an early registry failure once without changing its public error or logging private input', async () => {
const f = await fixture();
const error = Object.assign(new Error(`prompt=${PROMPT} token=${SECRET} path=${f.root}`), {
publicError: { code: 'CODING_SESSION_UNREADABLE', message: 'Safe public message' },
});
f.registry.prepare.mockRejectedValue(error);
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error);
expect(error.publicError).toEqual({ code: 'CODING_SESSION_UNREADABLE', message: 'Safe public message' });
expect(f.loadProviderInput).not.toHaveBeenCalled();
expect(f.createProcess).not.toHaveBeenCalled();
expect(f.failures()).toHaveLength(1);
expect(f.failures()[0][1]).toMatchObject({ workerGeneration: 7, diagnostic: { stage: 'registry', exception: 'Error' } });
const log = JSON.stringify(f.failures());
for (const privateValue of [TITLE, PROMPT, SECRET, f.root, 'conversation-a']) expect(log).not.toContain(privateValue);
});
it('captures provider preparation failure before any worker exists', async () => {
const f = await fixture();
const error = new TypeError('Cannot read properties of undefined (reading "providers")');
f.loadProviderInput.mockRejectedValue(error);
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error);
expect(f.createProcess).not.toHaveBeenCalled();
expect(f.failures()).toHaveLength(1);
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'provider-input', exception: 'TypeError' } });
});
it('retains code locations behind a dependency symlink without exposing its absolute path', async () => {
const f = await fixture();
const packageRoot = path.join(f.root, 'private-install', 'pi-package');
const packageLink = path.join(f.root, 'bundled-pi');
await mkdir(path.join(packageRoot, 'dist'), { recursive: true });
await symlink(packageRoot, packageLink, 'dir');
const actualPackageRoot = await realpath(packageRoot);
const error = new Error(SECRET);
Object.defineProperty(error, 'stack', {
configurable: true, value: `Error: ${SECRET}\n at load (${actualPackageRoot}/dist/session.js:22:4)`,
});
f.registry.prepare.mockRejectedValue(error);
await expect(createPiManagedWorkerOpener({ ...f.options,
cliPath: path.join(packageLink, 'dist', 'cli.js') })(f.input)).rejects.toBe(error);
expect(f.failures()).toHaveLength(1);
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { frames: ['root3/dist/session.js:22:4'] } });
const log = JSON.stringify(f.failures());
for (const privateValue of [SECRET, f.root, packageRoot, actualPackageRoot, packageLink]) expect(log).not.toContain(privateValue);
});
it('logs initial state failure before cleanup, preserving the rejection and cleanup calls', async () => {
const f = await fixture();
const order: string[] = [];
const error = new TypeError(`secret=${SECRET} ${PROMPT} ${f.root}`);
f.warn.mockImplementation(() => { order.push('log'); });
f.process.request.mockRejectedValue(error);
f.process.stop.mockImplementation(async () => { order.push('stop'); return { mode: 'stdin-close', code: 0, signal: null }; });
f.dispose.mockImplementation(async () => { order.push('dispose'); });
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(error);
expect(f.process.start).toHaveBeenCalledOnce();
expect(f.process.stop).toHaveBeenCalledWith('open_failure');
expect(f.dispose).toHaveBeenCalledOnce();
expect(order).toEqual(['log', 'stop', 'dispose']);
expect(f.failures()).toHaveLength(1);
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'initial-hydration', exception: 'TypeError' } });
const log = JSON.stringify(f.failures());
for (const privateValue of [TITLE, PROMPT, SECRET, f.root]) expect(log).not.toContain(privateValue);
});
it('retains the original diagnostic when extension cleanup masks an initialization error', async () => {
const f = await fixture();
const original = new TypeError('Required extension function is not a function');
const cleanup = new RangeError('cleanup failure');
const modelDispose = vi.fn(() => { throw cleanup; });
f.extensionHost.registerWorker.mockRejectedValue(original);
const opener = createPiManagedWorkerOpener({ ...f.options,
modelToolRegistry: { registerWorker: () => ({ tools: [], dispose: modelDispose }) } as unknown as ModelToolRegistryPort });
await expect(opener(f.input)).rejects.toBe(cleanup);
expect(modelDispose).toHaveBeenCalledOnce();
expect(f.failures()).toHaveLength(1);
expect(f.failures()[0][1]).toMatchObject({ diagnostic: { stage: 'extension-registration', exception: 'TypeError' } });
expect(f.createProcess).not.toHaveBeenCalled();
});
it('keeps the original initialization error even if diagnostic output throws', async () => {
const f = await fixture();
const original = new Error('original failure');
f.registry.prepare.mockRejectedValue(original);
f.warn.mockImplementation(() => { throw new Error('logger failed'); });
await expect(createPiManagedWorkerOpener(f.options)(f.input)).rejects.toBe(original);
expect(f.failures()).toHaveLength(1);
});
});