Skip to content
Merged
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
54 changes: 54 additions & 0 deletions electron/agent/event-translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,35 @@ test("tool events preserve title, metadata and timing for renderer summaries", (
})
})

test("tool events redact connector credential fields before they reach the renderer", () => {
const out = translateOpencodeEvent({
type: "message.part.updated",
properties: {
part: {
id: "p2",
sessionID: "s1",
messageID: "m1",
type: "tool",
callID: "c1",
tool: "bash",
state: {
status: "completed",
input: { command: "oo connector run posthog --action list_projects" },
output: JSON.stringify({ data: { api_token: "secret", id: 173107, name: "CLI" } }),
},
},
},
})

const event = out[0]
assert.equal(event?.event, "toolCallResult")
assert.ok(event)
const output = (event.data as { output?: string }).output
assert.deepEqual(JSON.parse(output ?? "{}"), {
data: { api_token: "[redacted]", id: 173107, name: "CLI" },
})
})

test("tool events use input description as title fallback", () => {
const out = translateOpencodeEvent({
type: "message.part.updated",
Expand Down Expand Up @@ -749,6 +778,31 @@ test("normalizeMessage builds ChatMessage with text + reasoning + tool parts in
assert.equal(msg.parts[2].kind, "tool")
})

test("normalizeMessage redacts credential fields from persisted historical tool output", () => {
const message = normalizeMessage({
info: { id: "m1", role: "assistant", time: { created: 123 } },
parts: [
{
id: "p2",
type: "tool",
callID: "c1",
tool: "bash",
state: {
status: "completed",
input: {},
output: JSON.stringify({ data: { api_token: "secret", project: "CLI" } }),
},
},
],
})

const part = message?.parts[0]
assert.equal(part?.kind, "tool")
assert.deepEqual(JSON.parse(part?.kind === "tool" ? (part.output ?? "{}") : "{}"), {
data: { api_token: "[redacted]", project: "CLI" },
})
})

test("normalizeMessage preserves assistant token usage", () => {
const msg = normalizeMessage({
info: {
Expand Down
29 changes: 22 additions & 7 deletions electron/agent/event-translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {

import { parseAuthorizationSignal } from "../chat/authorization-signal.ts"
import { logDiagnostic } from "../diagnostics-log.ts"
import { redactConnectorOutput } from "./oo-guard-core.ts"

// OpenCode SSE 事件经此翻译为 ChatService ServerEvents。无状态:每个 OpenCode 事件
// 直接映射为 0..n 个 {event, data},node.ts 据此 this.send(event, data)。
Expand Down Expand Up @@ -582,28 +583,39 @@ function translatePart(part: OpencodePart, delta?: string): ChatEmit[] {
const state = part.state
const context = toolContext(state)
if (state.error && state.status !== "completed") {
return [{ event: "toolCallResult", data: { ...base, ...context, status: "error", error: state.error } }]
return [
{
event: "toolCallResult",
data: { ...base, ...context, status: "error", error: redactConnectorOutput(state.error) },
},
]
}
if (state.status === "pending" || state.status === "running") {
return [{ event: "toolCallStarted", data: { ...base, ...context, status: state.status } }]
}
if (state.status === "completed") {
const auth = parseToolAuthorization(part.tool, state.output)
const output = redactConnectorOutput(state.output ?? "")
const auth = parseToolAuthorization(part.tool, output)
return [
{
event: "toolCallResult",
data: {
...base,
...context,
status: "completed",
output: state.output,
output,
...(auth ? { authorization: auth } : {}),
},
},
]
}
if (state.status === "error") {
return [{ event: "toolCallResult", data: { ...base, ...context, status: "error", error: state.error } }]
return [
{
event: "toolCallResult",
data: { ...base, ...context, status: "error", error: redactConnectorOutput(state.error ?? "") },
},
]
}
}
return []
Expand Down Expand Up @@ -724,15 +736,18 @@ export function normalizeMessage(message: { info?: unknown; parts?: unknown }):
tool: part.tool,
status: state.error && state.status !== "completed" ? "error" : state.status,
input: state.input ?? {},
output: state.output,
error: state.error,
output: typeof state.output === "string" ? redactConnectorOutput(state.output) : state.output,
error: typeof state.error === "string" ? redactConnectorOutput(state.error) : state.error,
title: state.title ?? (typeof state.input?.description === "string" ? state.input.description : undefined),
metadata: state.metadata,
timing: state.time ? { start: state.time.start, end: state.time.end } : undefined,
attachmentsCount: Array.isArray(state.attachments) ? state.attachments.length : undefined,
}
if (state.status === "completed") {
const auth = parseToolAuthorization(part.tool, state.output)
const auth = parseToolAuthorization(
part.tool,
typeof state.output === "string" ? redactConnectorOutput(state.output) : state.output,
)
if (auth) {
tool.authorization = auth
}
Expand Down
54 changes: 54 additions & 0 deletions electron/agent/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildAgentSidecarEnv,
buildArtifactSystem,
buildWorkspaceIdentitySystem,
isPersistedConnectorToolPart,
isUserVisibleSession,
} from "./manager.ts"

Expand All @@ -16,6 +17,59 @@ afterEach(() => {
})

describe("AgentManager", () => {
it("limits persisted redaction to connector executions", () => {
expect(isPersistedConnectorToolPart({ tool: "call_action" })).toBe(true)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: "oo --lang zh connector run posthog --action list_projects --json" } },
}),
).toBe(true)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: "printf 'password = \"example\"\\n'" } },
}),
).toBe(false)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: "printf 'oo connector run demo'" } },
}),
).toBe(false)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: "bash -lc 'oo connector apps posthog --json'" } },
}),
).toBe(true)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: `printf '%s\\n' "$(oo connector run demo)"` } },
}),
).toBe(true)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: `printf '%s\\n' '$(oo connector run demo)'` } },
}),
).toBe(false)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: `connector_data="$(oo connector apps posthog --json)"` } },
}),
).toBe(true)
expect(
isPersistedConnectorToolPart({
tool: "bash",
state: { input: { command: "# $(oo connector run demo)" } },
}),
).toBe(false)
expect(isPersistedConnectorToolPart({ tool: "read", state: { input: { filePath: "README.md" } } })).toBe(false)
})

it("pins raw connector CLI guidance to the current team", () => {
const system = buildWorkspaceIdentitySystem('team "quoted"')
expect(system).toContain('team "team \\"quoted\\""')
Expand Down
Loading
Loading