From 2dc37e3fd81fecc2b1b5f257de2b036627357d71 Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 5 Aug 2026 18:39:09 +0800 Subject: [PATCH] fix(agent): preserve runtime request context OpenCode tools run in a child service, so they cannot read the Agent process-local session map. Hydrate a sanitized context through the authenticated internal bridge and centralize backend project headers to prevent context loss across both boundaries. --- .opencode/tools/memory_manager.ts | 11 +- .opencode/tools/skill_manager.ts | 14 ++- src/auth/backendContextHeaders.ts | 25 ++++ src/runtime/internalSessionContextBridge.ts | 116 ++++++++++++++++++ src/server.ts | 32 +++-- tests/auth/backendContextHeaders.test.ts | 29 +++++ .../internalSessionContextBridge.test.ts | 83 +++++++++++++ 7 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 src/auth/backendContextHeaders.ts create mode 100644 src/runtime/internalSessionContextBridge.ts create mode 100644 tests/auth/backendContextHeaders.test.ts create mode 100644 tests/runtime/internalSessionContextBridge.test.ts diff --git a/.opencode/tools/memory_manager.ts b/.opencode/tools/memory_manager.ts index 6de8faf..f041d4a 100644 --- a/.opencode/tools/memory_manager.ts +++ b/.opencode/tools/memory_manager.ts @@ -1,9 +1,8 @@ import { tool } from "@opencode-ai/plugin"; + import { MemoryStore } from "../../src/memory/store.js"; -import { - getRuntimeSessionContext, - setRuntimeSessionContext, -} from "../../src/runtime/sessionContext.js"; +import { readBridgedRuntimeSessionContext } from "../../src/runtime/internalSessionContextBridge.js"; +import { setRuntimeSessionContext } from "../../src/runtime/sessionContext.js"; const memoryStore = new MemoryStore(); const initializePromise = memoryStore.initialize(); @@ -36,7 +35,9 @@ export default tool({ }, async execute(args, context) { await initializePromise; - const sessionContext = getRuntimeSessionContext(context.sessionID); + const sessionContext = await readBridgedRuntimeSessionContext( + context.sessionID, + ); if (!sessionContext) { throw new Error(`session context not found for ${context.sessionID}`); } diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index 82cc8e7..c36791e 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -2,16 +2,18 @@ import { tool } from "@opencode-ai/plugin"; import { SkillStore } from "../../src/skills/store.js"; import { - getRuntimeSessionContext, - type RuntimeSessionContext, -} from "../../src/runtime/sessionContext.js"; + readBridgedRuntimeSessionContext, +} from "../../src/runtime/internalSessionContextBridge.js"; +import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js"; type ToolContextReader = { - read(sessionId: string): RuntimeSessionContext | null; + read( + sessionId: string, + ): RuntimeSessionContext | null | Promise; }; const runtimeContextReader: ToolContextReader = { - read: getRuntimeSessionContext, + read: readBridgedRuntimeSessionContext, }; export const createSkillManagerTool = ( @@ -69,7 +71,7 @@ export const createSkillManagerTool = ( }, async execute(args, context) { await initializePromise; - const sessionContext = toolContextStore.read(context.sessionID); + const sessionContext = await toolContextStore.read(context.sessionID); if (!sessionContext) { throw new Error(`session context not found for ${context.sessionID}`); } diff --git a/src/auth/backendContextHeaders.ts b/src/auth/backendContextHeaders.ts new file mode 100644 index 0000000..d6c58d3 --- /dev/null +++ b/src/auth/backendContextHeaders.ts @@ -0,0 +1,25 @@ +import type { RuntimeSessionContext } from "../runtime/sessionContext.js"; + +type BackendContext = Pick< + RuntimeSessionContext, + "accessToken" | "projectId" | "traceId" +>; + +export const buildBackendContextHeaders = ( + context: BackendContext, +): Record => { + const headers: Record = { + Accept: "application/json", + "Content-Type": "application/json", + "X-Trace-Id": context.traceId, + }; + + if (context.accessToken) { + headers.Authorization = `Bearer ${context.accessToken}`; + } + if (context.projectId) { + headers["X-Project-Id"] = context.projectId; + } + + return headers; +}; diff --git a/src/runtime/internalSessionContextBridge.ts b/src/runtime/internalSessionContextBridge.ts new file mode 100644 index 0000000..c83afee --- /dev/null +++ b/src/runtime/internalSessionContextBridge.ts @@ -0,0 +1,116 @@ +import { + getRuntimeSessionContext, + setRuntimeSessionContext, + type RuntimeSessionContext, +} from "./sessionContext.js"; + +type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +type InternalSessionContextClientOptions = { + baseUrl?: string; + internalToken?: string; + fetchImpl?: FetchLike; +}; + +type InternalSessionContextPayload = { + actor_key: string; + allow_learning_write?: boolean; + client_session_id: string; + memory_list_read_scopes?: Partial>; + project_key: string; + session_id: string; + trace_id: string; +}; + +export const serializeRuntimeSessionContext = ( + context: RuntimeSessionContext, +): InternalSessionContextPayload => ({ + actor_key: context.actorKey, + allow_learning_write: context.allowLearningWrite, + client_session_id: context.clientSessionId, + memory_list_read_scopes: context.memoryListReadScopes, + project_key: context.projectKey, + session_id: context.sessionId, + trace_id: context.traceId, +}); + +const requireString = ( + value: unknown, + field: keyof InternalSessionContextPayload, +) => { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`invalid internal session context field: ${field}`); + } + return value; +}; + +const parseRuntimeSessionContext = ( + value: unknown, +): RuntimeSessionContext => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid internal session context response"); + } + const payload = value as Record; + const readScopes = payload.memory_list_read_scopes; + return { + actorKey: requireString(payload.actor_key, "actor_key"), + allowLearningWrite: + typeof payload.allow_learning_write === "boolean" + ? payload.allow_learning_write + : undefined, + clientSessionId: requireString( + payload.client_session_id, + "client_session_id", + ), + memoryListReadScopes: + readScopes && typeof readScopes === "object" && !Array.isArray(readScopes) + ? (readScopes as RuntimeSessionContext["memoryListReadScopes"]) + : undefined, + projectKey: requireString(payload.project_key, "project_key"), + sessionId: requireString(payload.session_id, "session_id"), + traceId: requireString(payload.trace_id, "trace_id"), + }; +}; + +export const readBridgedRuntimeSessionContext = async ( + sessionId: string, + options: InternalSessionContextClientOptions = {}, +) => { + const localContext = getRuntimeSessionContext(sessionId); + if (localContext) { + return localContext; + } + + const baseUrl = ( + options.baseUrl ?? + process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? + "http://127.0.0.1:8787" + ).replace(/\/+$/, ""); + const internalToken = + options.internalToken ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + const response = await (options.fetchImpl ?? fetch)( + `${baseUrl}/internal/tools/session-context`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-agent-internal-token": internalToken, + }, + body: JSON.stringify({ session_id: sessionId }), + }, + ); + const text = await response.text(); + if (!response.ok) { + throw new Error(text || `session context bridge failed: ${response.status}`); + } + + const context = parseRuntimeSessionContext(JSON.parse(text)); + if (context.sessionId !== sessionId) { + throw new Error("internal session context id mismatch"); + } + setRuntimeSessionContext(context); + return context; +}; diff --git a/src/server.ts b/src/server.ts index 3bda2f1..8178090 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import cors from "cors"; import express from "express"; import { requireAgentAuth } from "./auth/agentAuth.js"; +import { buildBackendContextHeaders } from "./auth/backendContextHeaders.js"; import { SessionTranscriptStore } from "./sessions/transcriptStore.js"; import { ChatSessionBridge } from "./chat/sessionBridge.js"; import { config } from "./config.js"; @@ -20,6 +21,7 @@ import { import { buildChatRouter } from "./routes/chat.js"; import { buildAgentPublicRouter } from "./routes/publicApi.js"; import { opencodeRuntime } from "./runtime/opencode.js"; +import { serializeRuntimeSessionContext } from "./runtime/internalSessionContextBridge.js"; import { getRuntimeSessionContext, markRuntimeSessionAuthExpired, @@ -43,7 +45,7 @@ const resultReferenceStore = new ResultReferenceStore(); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); -// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。 +// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。 process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; app.use(cors()); @@ -72,6 +74,26 @@ app.get("/health", async (_req, res) => { } }); +app.post("/internal/tools/session-context", (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const context = sessionId ? getRuntimeSessionContext(sessionId) : null; + if (!context) { + res.status(404).json({ + message: "session context not found", + detail: sessionId, + }); + return; + } + + res.json(serializeRuntimeSessionContext(context)); +}); + app.post("/internal/tools/tjwater-cli-call", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); @@ -294,13 +316,7 @@ const callBackendJson = async ( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS); try { - const headers: Record = { - Accept: "application/json", - "Content-Type": "application/json", - }; - if (context.accessToken) { - headers.Authorization = `Bearer ${context.accessToken}`; - } + const headers = buildBackendContextHeaders(context); const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), { method: "POST", headers, diff --git a/tests/auth/backendContextHeaders.test.ts b/tests/auth/backendContextHeaders.test.ts new file mode 100644 index 0000000..4142752 --- /dev/null +++ b/tests/auth/backendContextHeaders.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; + +import { buildBackendContextHeaders } from "../../src/auth/backendContextHeaders.js"; + +describe("buildBackendContextHeaders", () => { + it("forwards authenticated project and trace context to the backend", () => { + expect( + buildBackendContextHeaders({ + accessToken: "access-token-1", + projectId: "project-id-1", + traceId: "trace-id-1", + }), + ).toEqual({ + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer access-token-1", + "X-Project-Id": "project-id-1", + "X-Trace-Id": "trace-id-1", + }); + }); + + it("omits optional authentication and project headers when unavailable", () => { + expect(buildBackendContextHeaders({ traceId: "trace-id-2" })).toEqual({ + Accept: "application/json", + "Content-Type": "application/json", + "X-Trace-Id": "trace-id-2", + }); + }); +}); diff --git a/tests/runtime/internalSessionContextBridge.test.ts b/tests/runtime/internalSessionContextBridge.test.ts new file mode 100644 index 0000000..9acbb96 --- /dev/null +++ b/tests/runtime/internalSessionContextBridge.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import { + readBridgedRuntimeSessionContext, + serializeRuntimeSessionContext, +} from "../../src/runtime/internalSessionContextBridge.js"; +import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js"; + +describe("readBridgedRuntimeSessionContext", () => { + const sessionId = "remote-opencode-session"; + + afterEach(() => { + removeRuntimeSessionContext(sessionId); + }); + + it("hydrates a child-process context through the authenticated internal bridge", async () => { + const calls: Array<{ input: string; init?: RequestInit }> = []; + const context = await readBridgedRuntimeSessionContext(sessionId, { + baseUrl: "http://127.0.0.1:8787", + internalToken: "internal-secret", + fetchImpl: async (input, init) => { + calls.push({ input: String(input), init }); + return Response.json({ + actor_key: "actor-1", + allow_learning_write: true, + client_session_id: "client-session-1", + project_key: "project-1", + session_id: sessionId, + trace_id: "trace-1", + }); + }, + }); + + expect(context).toMatchObject({ + actorKey: "actor-1", + allowLearningWrite: true, + clientSessionId: "client-session-1", + projectKey: "project-1", + sessionId, + traceId: "trace-1", + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.input).toBe( + "http://127.0.0.1:8787/internal/tools/session-context", + ); + expect(calls[0]?.init?.headers).toEqual({ + "Content-Type": "application/json", + "x-agent-internal-token": "internal-secret", + }); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ + session_id: sessionId, + }); + }); + + it("does not expose backend credentials to the opencode child process", () => { + const context = { + accessToken: "backend-secret", + actorKey: "actor-1", + clientSessionId: "client-session-1", + network: "network-1", + projectId: "project-id-1", + projectKey: "project-key-1", + sessionId, + traceId: "trace-1", + }; + expect(context).toHaveProperty("accessToken", "backend-secret"); + + const payload = serializeRuntimeSessionContext(context); + + expect(payload).toEqual({ + actor_key: "actor-1", + allow_learning_write: undefined, + client_session_id: "client-session-1", + memory_list_read_scopes: undefined, + project_key: "project-key-1", + session_id: sessionId, + trace_id: "trace-1", + }); + expect(payload).not.toHaveProperty("access_token"); + expect(payload).not.toHaveProperty("project_id"); + expect(payload).not.toHaveProperty("network"); + }); +});