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.
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<RuntimeSessionContext | null>;
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
|
||||
type BackendContext = Pick<
|
||||
RuntimeSessionContext,
|
||||
"accessToken" | "projectId" | "traceId"
|
||||
>;
|
||||
|
||||
export const buildBackendContextHeaders = (
|
||||
context: BackendContext,
|
||||
): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
setRuntimeSessionContext,
|
||||
type RuntimeSessionContext,
|
||||
} from "./sessionContext.js";
|
||||
|
||||
type FetchLike = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
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<Record<"user" | "workspace", boolean>>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
};
|
||||
+24
-8
@@ -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<string, string> = {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user