Merge pull request #2147: fix(coding-agent): hide secrets in provider requests
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
type Message,
|
||||
type MessageAttribution,
|
||||
type Model,
|
||||
type Tool,
|
||||
type Usage,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
@@ -19,7 +20,7 @@ import { countTokens } from "@oh-my-pi/pi-natives";
|
||||
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
||||
import { ThinkingLevel } from "../thinking";
|
||||
import type { AgentMessage, AgentTool } from "../types";
|
||||
import type { AgentMessage } from "../types";
|
||||
import type { CompactionEntry, SessionEntry } from "./entries";
|
||||
import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages";
|
||||
import {
|
||||
@@ -690,7 +691,7 @@ export interface HandoffOptions {
|
||||
/** Live agent system prompt — passed verbatim so providers hit the cached prefix. */
|
||||
systemPrompt: string[];
|
||||
/** Live agent tool list — same purpose. Forced to `toolChoice: "none"`. */
|
||||
tools?: AgentTool<any>[];
|
||||
tools?: Tool[];
|
||||
customInstructions?: string;
|
||||
convertToLlm?: ConvertToLlm;
|
||||
initiatorOverride?: MessageAttribution;
|
||||
|
||||
@@ -157,6 +157,10 @@
|
||||
- Fixed MCP OAuth fallback rendering to show a short terminal hyperlink and keep the raw authorization URL on one unwrapped copy line ([#2121](https://github.com/can1357/oh-my-pi/issues/2121)).
|
||||
- Fixed `omp` startup blocking 25–30 s on a single unresponsive MCP server when no cached tools were available for it. `MCPManager.connectServers` used to fall through to an unbounded `Promise.allSettled` over every still-pending server without a cached tool list, so one server stuck waiting on the per-request MCP timeout (`OMP_MCP_TIMEOUT_MS`, default 30 000 ms) gated the entire UI ready signal. Pending-without-cache servers are now left in flight: their tools surface via the existing background `#onToolsChanged` → `refreshMCPTools` path the moment the connect completes, and failures continue to log through the background catch handler ([#2100](https://github.com/can1357/oh-my-pi/issues/2100)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed hide-secrets redaction so configured secrets are scrubbed from provider-facing system prompts, tool definitions, developer/system-reminder messages, and assistant tool-call arguments before model requests ([#2146](https://github.com/can1357/oh-my-pi/issues/2146)).
|
||||
|
||||
## [15.10.6] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
deobfuscateSessionContext,
|
||||
loadSecrets,
|
||||
obfuscateMessages,
|
||||
obfuscateProviderContext,
|
||||
SecretObfuscator,
|
||||
} from "./secrets";
|
||||
import { AgentSession } from "./session/agent-session";
|
||||
@@ -2138,6 +2139,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
if (!obfuscator?.hasSecrets()) return converted;
|
||||
return obfuscateMessages(obfuscator, converted);
|
||||
};
|
||||
|
||||
const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => {
|
||||
const withContext = await extensionRunner.emitContext(messages);
|
||||
return wrapSteeringForModel(withContext);
|
||||
@@ -2215,7 +2217,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const openrouterRoutingPreset = settings.get("providers.openrouterVariant");
|
||||
const openrouterVariant =
|
||||
openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined;
|
||||
return streamSimple(streamModel, context, {
|
||||
return streamSimple(streamModel, obfuscator ? obfuscateProviderContext(obfuscator, context) : context, {
|
||||
...streamOptions,
|
||||
openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,14 @@ import { YAML } from "bun";
|
||||
import type { SecretEntry } from "./obfuscator";
|
||||
import { compileSecretRegex } from "./regex";
|
||||
|
||||
export { deobfuscateSessionContext, obfuscateMessages, type SecretEntry, SecretObfuscator } from "./obfuscator";
|
||||
export {
|
||||
deobfuscateSessionContext,
|
||||
obfuscateMessages,
|
||||
obfuscateProviderContext,
|
||||
obfuscateProviderTools,
|
||||
type SecretEntry,
|
||||
SecretObfuscator,
|
||||
} from "./obfuscator";
|
||||
|
||||
/**
|
||||
* Load secrets from project-local and global secrets.yml files.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Message, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
|
||||
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import type { SessionContext } from "../session/session-manager";
|
||||
import { compileSecretRegex } from "./regex";
|
||||
|
||||
@@ -184,6 +185,12 @@ export class SecretObfuscator {
|
||||
return deepWalkStrings(obj, s => this.deobfuscate(s));
|
||||
}
|
||||
|
||||
/** Deep-walk an object, obfuscating all string values. */
|
||||
obfuscateObject<T>(obj: T): T {
|
||||
if (!this.#hasAny) return obj;
|
||||
return deepWalkStrings(obj, s => this.obfuscate(s));
|
||||
}
|
||||
|
||||
/** Find the obfuscate index for a known secret value. */
|
||||
#findObfuscateIndex(secret: string): number | undefined {
|
||||
// Check plain mappings first
|
||||
@@ -211,25 +218,34 @@ export function deobfuscateSessionContext(
|
||||
// Message obfuscation (outbound to LLM)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Obfuscate all text content in LLM messages (for outbound interception). */
|
||||
/** Obfuscate all string content in LLM messages (for outbound interception). */
|
||||
export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
|
||||
return messages.map(msg => {
|
||||
if (!Array.isArray(msg.content)) return msg;
|
||||
return obfuscator.obfuscateObject(messages);
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const content = msg.content.map(block => {
|
||||
if (block.type === "text") {
|
||||
const obfuscated = obfuscator.obfuscate(block.text);
|
||||
if (obfuscated !== block.text) {
|
||||
changed = true;
|
||||
return { ...block, text: obfuscated } as TextContent;
|
||||
}
|
||||
}
|
||||
return block;
|
||||
});
|
||||
/** Obfuscate provider request context without walking live tool schema instances. */
|
||||
export function obfuscateProviderContext(obfuscator: SecretObfuscator | undefined, context: Context): Context {
|
||||
if (!obfuscator?.hasSecrets()) return context;
|
||||
return {
|
||||
...context,
|
||||
systemPrompt: obfuscator.obfuscateObject(context.systemPrompt),
|
||||
messages: obfuscator.obfuscateObject(context.messages),
|
||||
tools: obfuscateProviderTools(obfuscator, context.tools),
|
||||
};
|
||||
}
|
||||
|
||||
return changed ? ({ ...msg, content } as typeof msg) : msg;
|
||||
});
|
||||
/** Convert tool schemas to wire JSON Schema before obfuscating provider-visible strings. */
|
||||
export function obfuscateProviderTools(
|
||||
obfuscator: SecretObfuscator | undefined,
|
||||
tools: Tool[] | undefined,
|
||||
): Tool[] | undefined {
|
||||
if (!tools || !obfuscator?.hasSecrets()) return tools;
|
||||
return tools.map(tool => ({
|
||||
...tool,
|
||||
description: obfuscator.obfuscate(tool.description),
|
||||
parameters: obfuscator.obfuscateObject(toolWireSchema(tool)),
|
||||
customFormat: tool.customFormat ? obfuscator.obfuscateObject(tool.customFormat) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -262,7 +278,7 @@ function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
|
||||
});
|
||||
return (changed ? result : obj) as unknown as T;
|
||||
}
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
|
||||
let changed = false;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
@@ -275,3 +291,8 @@ function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function isPlainRecord(obj: object): obj is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(obj);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
@@ -184,7 +184,12 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
|
||||
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
||||
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
|
||||
import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
|
||||
import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
|
||||
import {
|
||||
deobfuscateSessionContext,
|
||||
obfuscateProviderContext,
|
||||
obfuscateProviderTools,
|
||||
type SecretObfuscator,
|
||||
} from "../secrets/obfuscator";
|
||||
import { invalidateHostMetadata } from "../ssh/connection-manager";
|
||||
import {
|
||||
AUTO_THINKING,
|
||||
@@ -3999,6 +4004,39 @@ export class AgentSession {
|
||||
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
|
||||
}
|
||||
|
||||
#obfuscateForProvider<T>(value: T): T {
|
||||
if (!this.#obfuscator?.hasSecrets()) return value;
|
||||
return this.#obfuscator.obfuscateObject(value);
|
||||
}
|
||||
|
||||
#obfuscateTextForProvider(text: string | undefined): string | undefined {
|
||||
if (!text || !this.#obfuscator?.hasSecrets()) return text;
|
||||
return this.#obfuscator.obfuscate(text);
|
||||
}
|
||||
|
||||
#obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
|
||||
if (!this.#obfuscator?.hasSecrets()) return preparation;
|
||||
if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation;
|
||||
return {
|
||||
...preparation,
|
||||
previousSummary: preparation.previousSummary
|
||||
? this.#obfuscator.obfuscate(preparation.previousSummary)
|
||||
: preparation.previousSummary,
|
||||
previousPreserveData: preparation.previousPreserveData
|
||||
? this.#obfuscator.obfuscateObject(preparation.previousPreserveData)
|
||||
: preparation.previousPreserveData,
|
||||
};
|
||||
}
|
||||
|
||||
#deobfuscateFromProvider(text: string): string {
|
||||
if (!this.#obfuscator?.hasSecrets()) return text;
|
||||
return this.#obfuscator.deobfuscate(text);
|
||||
}
|
||||
|
||||
#convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
|
||||
return this.#obfuscateForProvider(convertToLlm(messages));
|
||||
}
|
||||
|
||||
/** Convert session messages using the same pre-LLM pipeline as the active session. */
|
||||
async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
|
||||
const transformedMessages = await this.#transformContext(messages, signal);
|
||||
@@ -6185,10 +6223,10 @@ export class AgentSession {
|
||||
customInstructions,
|
||||
compactionAbortController.signal,
|
||||
{
|
||||
promptOverride: compactionPrep.hookPrompt,
|
||||
extraContext: compactionPrep.hookContext,
|
||||
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
||||
convertToLlm,
|
||||
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
|
||||
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
|
||||
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
},
|
||||
);
|
||||
summary = result.summary;
|
||||
@@ -6371,15 +6409,15 @@ export class AgentSession {
|
||||
throw new Error(`No API key for ${model.provider}`);
|
||||
}
|
||||
|
||||
const handoffText = await generateHandoff(
|
||||
const rawHandoffText = await generateHandoff(
|
||||
this.agent.state.messages,
|
||||
model,
|
||||
apiKey,
|
||||
{
|
||||
systemPrompt: this.#baseSystemPrompt,
|
||||
tools: this.agent.state.tools,
|
||||
customInstructions,
|
||||
convertToLlm,
|
||||
systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
|
||||
tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
|
||||
customInstructions: this.#obfuscateTextForProvider(customInstructions),
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
initiatorOverride: "agent",
|
||||
metadata: this.agent.metadataForProvider(model.provider),
|
||||
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
||||
@@ -6391,6 +6429,7 @@ export class AgentSession {
|
||||
},
|
||||
handoffSignal,
|
||||
);
|
||||
const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
|
||||
|
||||
if (handoffSignal.aborted) {
|
||||
throw new Error("Handoff cancelled");
|
||||
@@ -7352,17 +7391,24 @@ export class AgentSession {
|
||||
if (!apiKey) continue;
|
||||
|
||||
try {
|
||||
return await compact(preparation, candidate, apiKey, customInstructions, signal, {
|
||||
...options,
|
||||
metadata: this.agent.metadataForProvider(candidate.provider),
|
||||
convertToLlm,
|
||||
telemetry,
|
||||
// Honor the user's /model thinking selection (incl. `off`) on
|
||||
// the manual `/compact` path. Clamped per-model inside compact()
|
||||
// via resolveCompactionEffort so unsupported-effort models
|
||||
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
});
|
||||
return await compact(
|
||||
this.#obfuscatePreparationForProvider(preparation),
|
||||
candidate,
|
||||
apiKey,
|
||||
this.#obfuscateTextForProvider(customInstructions),
|
||||
signal,
|
||||
{
|
||||
...options,
|
||||
metadata: this.agent.metadataForProvider(candidate.provider),
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
telemetry,
|
||||
// Honor the user's /model thinking selection (incl. `off`) on
|
||||
// the manual `/compact` path. Clamped per-model inside compact()
|
||||
// via resolveCompactionEffort so unsupported-effort models
|
||||
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.#isCompactionAuthFailure(error)) {
|
||||
throw error;
|
||||
@@ -7642,20 +7688,27 @@ export class AgentSession {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
|
||||
promptOverride: compactionPrep.hookPrompt,
|
||||
extraContext: compactionPrep.hookContext,
|
||||
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
||||
metadata: this.agent.metadataForProvider(candidate.provider),
|
||||
initiatorOverride: "agent",
|
||||
convertToLlm,
|
||||
telemetry,
|
||||
// Honor the user's /model thinking selection on the
|
||||
// auto-compaction path — the most-fired compaction
|
||||
// site. Clamped per-model inside compact() via
|
||||
// resolveCompactionEffort.
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
});
|
||||
compactResult = await compact(
|
||||
this.#obfuscatePreparationForProvider(preparation),
|
||||
candidate,
|
||||
apiKey,
|
||||
undefined,
|
||||
autoCompactionSignal,
|
||||
{
|
||||
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
|
||||
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
|
||||
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
|
||||
metadata: this.agent.metadataForProvider(candidate.provider),
|
||||
initiatorOverride: "agent",
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
telemetry,
|
||||
// Honor the user's /model thinking selection on the
|
||||
// auto-compaction path — the most-fired compaction
|
||||
// site. Clamped per-model inside compact() via
|
||||
// resolveCompactionEffort.
|
||||
thinkingLevel: this.thinkingLevel,
|
||||
},
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (autoCompactionSignal.aborted) {
|
||||
@@ -9000,7 +9053,7 @@ export class AgentSession {
|
||||
|
||||
let replyText = "";
|
||||
let assistantMessage: AssistantMessage | undefined;
|
||||
const stream = streamSimple(model, context, options);
|
||||
const stream = streamSimple(model, obfuscateProviderContext(this.#obfuscator, context), options);
|
||||
for await (const event of stream) {
|
||||
if (event.type === "text_delta") {
|
||||
replyText += event.delta;
|
||||
@@ -9519,10 +9572,10 @@ export class AgentSession {
|
||||
model,
|
||||
apiKey,
|
||||
signal: this.#branchSummaryAbortController.signal,
|
||||
customInstructions: options.customInstructions,
|
||||
customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
|
||||
reserveTokens: branchSummarySettings.reserveTokens,
|
||||
metadata: this.agent.metadataForProvider(model.provider),
|
||||
convertToLlm,
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
||||
});
|
||||
this.#branchSummaryAbortController = undefined;
|
||||
|
||||
@@ -8,11 +8,14 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { ExtensionRunner, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
|
||||
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets";
|
||||
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const HANDOFF_SECRET = "HANDOFF_SECRET_TOKEN_12345";
|
||||
|
||||
describe("AgentSession handoff", () => {
|
||||
// Immutable across the whole file: the model registry's synchronous bundled-model
|
||||
// load dominates per-test setup (~100ms each), and the auth store + bundled model
|
||||
@@ -27,6 +30,7 @@ describe("AgentSession handoff", () => {
|
||||
let session: AgentSession;
|
||||
let sessionManager: SessionManager;
|
||||
let events: AgentSessionEvent[];
|
||||
let obfuscator: SecretObfuscator;
|
||||
|
||||
/** Poll `predicate` until it holds (returns as soon as the state is reached) or the
|
||||
* deadline elapses. Replaces blind settle sleeps for tests with a positive signal. */
|
||||
@@ -74,6 +78,7 @@ describe("AgentSession handoff", () => {
|
||||
tempDir = TempDir.createSync("@pi-handoff-");
|
||||
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
|
||||
events = [];
|
||||
obfuscator = new SecretObfuscator([{ type: "plain", content: HANDOFF_SECRET }]);
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
@@ -92,6 +97,7 @@ describe("AgentSession handoff", () => {
|
||||
"compaction.autoContinue": false,
|
||||
}),
|
||||
modelRegistry,
|
||||
obfuscator,
|
||||
});
|
||||
|
||||
session.subscribe(event => {
|
||||
@@ -141,11 +147,70 @@ describe("AgentSession handoff", () => {
|
||||
|
||||
expect(generateHandoffSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result?.document).toBe(handoffText);
|
||||
|
||||
expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0);
|
||||
expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0);
|
||||
expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("obfuscates custom instructions before generating a handoff", async () => {
|
||||
const placeholder = obfuscator.obfuscate(HANDOFF_SECRET);
|
||||
const generateHandoffSpy = vi
|
||||
.spyOn(compactionModule, "generateHandoff")
|
||||
.mockResolvedValue(`## Goal\nKeep ${placeholder}`);
|
||||
|
||||
const result = await session.handoff(`preserve ${HANDOFF_SECRET}`);
|
||||
|
||||
const handoffCall = generateHandoffSpy.mock.calls[0];
|
||||
if (!handoffCall) throw new Error("Expected generateHandoff call");
|
||||
expect(handoffCall[3].customInstructions).toBe(`preserve ${placeholder}`);
|
||||
expect(handoffCall[3].customInstructions).not.toContain(HANDOFF_SECRET);
|
||||
expect(result?.document).toContain(HANDOFF_SECRET);
|
||||
expect(result?.document).not.toContain(placeholder);
|
||||
});
|
||||
|
||||
it("obfuscates previous compaction summary and preserve data before forwarding to compact()", async () => {
|
||||
const placeholder = obfuscator.obfuscate(HANDOFF_SECRET);
|
||||
const entries = sessionManager.getBranch();
|
||||
const lastEntryId = entries[entries.length - 1]?.id;
|
||||
if (!lastEntryId) throw new Error("Expected a seeded entry id");
|
||||
const fixedPreparation: compactionModule.CompactionPreparation = {
|
||||
firstKeptEntryId: lastEntryId,
|
||||
messagesToSummarize: [{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }],
|
||||
turnPrefixMessages: [],
|
||||
recentMessages: [],
|
||||
isSplitTurn: false,
|
||||
tokensBefore: 100,
|
||||
previousSummary: `summary ${HANDOFF_SECRET}`,
|
||||
previousPreserveData: {
|
||||
openaiRemoteCompaction: {
|
||||
replacementHistory: [{ role: "user", content: `history ${HANDOFF_SECRET}` }],
|
||||
},
|
||||
},
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: compactionModule.DEFAULT_COMPACTION_SETTINGS,
|
||||
};
|
||||
vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation);
|
||||
|
||||
const compactSpy = vi.spyOn(compactionModule, "compact").mockResolvedValue({
|
||||
summary: "new summary",
|
||||
shortSummary: undefined,
|
||||
firstKeptEntryId: lastEntryId,
|
||||
tokensBefore: 100,
|
||||
details: {},
|
||||
});
|
||||
|
||||
await session.compact();
|
||||
|
||||
const call = compactSpy.mock.calls[0];
|
||||
if (!call) throw new Error("Expected compact call");
|
||||
expect(call[0].previousSummary).toBe(`summary ${placeholder}`);
|
||||
expect(call[0].previousSummary).not.toContain(HANDOFF_SECRET);
|
||||
const preserveData = JSON.stringify(call[0].previousPreserveData);
|
||||
expect(preserveData).toContain(placeholder);
|
||||
expect(preserveData).not.toContain(HANDOFF_SECRET);
|
||||
});
|
||||
|
||||
it("runs context maintenance before sending an oversized pending prompt", async () => {
|
||||
session.settings.set("compaction.thresholdTokens", 50);
|
||||
session.settings.set("compaction.keepRecentTokens", 1);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import {
|
||||
type Api,
|
||||
clearCustomApis,
|
||||
type Context,
|
||||
type Message,
|
||||
type Model,
|
||||
type ModelSpec,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets";
|
||||
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { convertToLlm, wrapSteeringForModel } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
@@ -273,6 +275,58 @@ describe("AgentSession message pipeline", () => {
|
||||
expect(capturedOptions?.openrouterVariant).toBe("nitro");
|
||||
});
|
||||
|
||||
it("obfuscates the system prompt and messages on ephemeral side-channel requests", async () => {
|
||||
const api = "test-ephemeral-secret-redaction";
|
||||
const secret = "EPHEMERAL_SECRET_TOKEN_12345";
|
||||
let capturedContext: Context | undefined;
|
||||
registerCustomApi(api, (_model, context, _options) => {
|
||||
capturedContext = context;
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
const message = createAssistantMessage("Answer");
|
||||
stream.push({ type: "text_delta", contentIndex: 0, delta: "Answer", partial: message });
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
});
|
||||
return stream;
|
||||
});
|
||||
|
||||
const model = buildModel({
|
||||
id: "side-model-secrets",
|
||||
name: "Side Model Secrets",
|
||||
api,
|
||||
provider: "test-provider",
|
||||
baseUrl: "",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 1024,
|
||||
} as ModelSpec<Api>) as Model<Api>;
|
||||
const session = new AgentSession({
|
||||
agent: new Agent({
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: [`system prompt with ${secret}`],
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings: Settings.isolated({ "compaction.enabled": false }),
|
||||
modelRegistry: {
|
||||
getApiKey: vi.fn(async () => "key"),
|
||||
} as never,
|
||||
obfuscator: new SecretObfuscator([{ type: "plain", content: secret }]),
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
const result = await session.runEphemeralTurn({ promptText: `question about ${secret}` });
|
||||
|
||||
expect(result.replyText).toBe("Answer");
|
||||
expect(capturedContext).toBeDefined();
|
||||
expect(JSON.stringify(capturedContext)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("records raw SSE diagnostics into the session buffer before request hooks", async () => {
|
||||
const requestOnSseEvent = vi.fn();
|
||||
const session = new AgentSession({
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator";
|
||||
import type { Context, Message } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
obfuscateMessages,
|
||||
obfuscateProviderContext,
|
||||
SecretObfuscator,
|
||||
} from "@oh-my-pi/pi-coding-agent/secrets/obfuscator";
|
||||
import { compileSecretRegex } from "@oh-my-pi/pi-coding-agent/secrets/regex";
|
||||
import { z } from "zod";
|
||||
|
||||
describe("compileSecretRegex", () => {
|
||||
it("adds global flag when not provided", () => {
|
||||
@@ -58,4 +64,92 @@ describe("SecretObfuscator regex behavior", () => {
|
||||
status: original.status,
|
||||
});
|
||||
});
|
||||
|
||||
it("obfuscates nested provider request payloads", () => {
|
||||
const secret = "SUPER_SECRET_TOKEN_12345";
|
||||
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
||||
const payload = {
|
||||
systemPrompt: [`workspace contains ${secret}`],
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "handoff",
|
||||
description: `preserve ${secret}`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { note: { type: "string", description: `write ${secret}` } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const obfuscated = obfuscateProviderContext(obfuscator, payload);
|
||||
const serialized = JSON.stringify(obfuscated);
|
||||
|
||||
expect(serialized).not.toContain(secret);
|
||||
expect(obfuscator.deobfuscateObject(obfuscated).tools?.[0]?.description).toEqual(payload.tools[0]?.description);
|
||||
});
|
||||
|
||||
it("redacts Zod tool schemas without cloning the live schema instance", () => {
|
||||
const secret = "SUPER_SECRET_TOKEN_12345";
|
||||
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
||||
const parameters = z.object({
|
||||
note: z.string().describe(`write ${secret}`),
|
||||
});
|
||||
const context: Context = {
|
||||
messages: [],
|
||||
tools: [
|
||||
{
|
||||
name: "extension_tool",
|
||||
description: `preserve ${secret}`,
|
||||
parameters,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const obfuscated = obfuscateProviderContext(obfuscator, context);
|
||||
|
||||
expect(obfuscator.obfuscateObject(parameters)).toBe(parameters);
|
||||
expect(context.tools?.[0]?.parameters).toBe(parameters);
|
||||
expect(obfuscated.tools?.[0]?.parameters).not.toBe(parameters);
|
||||
expect(JSON.stringify(obfuscated)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("obfuscates system reminders and assistant tool calls in messages", () => {
|
||||
const secret = "SUPER_SECRET_TOKEN_12345";
|
||||
const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]);
|
||||
const messages: Message[] = [
|
||||
{ role: "developer", content: `system reminder ${secret}`, timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1",
|
||||
name: "handoff",
|
||||
arguments: { note: secret },
|
||||
intent: `handoff ${secret}`,
|
||||
},
|
||||
],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const obfuscated = obfuscateMessages(obfuscator, messages);
|
||||
|
||||
expect(JSON.stringify(obfuscated)).not.toContain(secret);
|
||||
expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(messages);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user