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 { tool } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { MemoryStore } from "../../src/memory/store.js";
|
import { MemoryStore } from "../../src/memory/store.js";
|
||||||
import {
|
import { readBridgedRuntimeSessionContext } from "../../src/runtime/internalSessionContextBridge.js";
|
||||||
getRuntimeSessionContext,
|
import { setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||||
setRuntimeSessionContext,
|
|
||||||
} from "../../src/runtime/sessionContext.js";
|
|
||||||
|
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
const initializePromise = memoryStore.initialize();
|
const initializePromise = memoryStore.initialize();
|
||||||
@@ -36,7 +35,9 @@ export default tool({
|
|||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
await initializePromise;
|
||||||
const sessionContext = getRuntimeSessionContext(context.sessionID);
|
const sessionContext = await readBridgedRuntimeSessionContext(
|
||||||
|
context.sessionID,
|
||||||
|
);
|
||||||
if (!sessionContext) {
|
if (!sessionContext) {
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
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 { SkillStore } from "../../src/skills/store.js";
|
||||||
import {
|
import {
|
||||||
getRuntimeSessionContext,
|
readBridgedRuntimeSessionContext,
|
||||||
type RuntimeSessionContext,
|
} from "../../src/runtime/internalSessionContextBridge.js";
|
||||||
} from "../../src/runtime/sessionContext.js";
|
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||||
|
|
||||||
type ToolContextReader = {
|
type ToolContextReader = {
|
||||||
read(sessionId: string): RuntimeSessionContext | null;
|
read(
|
||||||
|
sessionId: string,
|
||||||
|
): RuntimeSessionContext | null | Promise<RuntimeSessionContext | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeContextReader: ToolContextReader = {
|
const runtimeContextReader: ToolContextReader = {
|
||||||
read: getRuntimeSessionContext,
|
read: readBridgedRuntimeSessionContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSkillManagerTool = (
|
export const createSkillManagerTool = (
|
||||||
@@ -69,7 +71,7 @@ export const createSkillManagerTool = (
|
|||||||
},
|
},
|
||||||
async execute(args, context) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
await initializePromise;
|
||||||
const sessionContext = toolContextStore.read(context.sessionID);
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
||||||
if (!sessionContext) {
|
if (!sessionContext) {
|
||||||
throw new Error(`session context not found for ${context.sessionID}`);
|
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 express from "express";
|
||||||
|
|
||||||
import { requireAgentAuth } from "./auth/agentAuth.js";
|
import { requireAgentAuth } from "./auth/agentAuth.js";
|
||||||
|
import { buildBackendContextHeaders } from "./auth/backendContextHeaders.js";
|
||||||
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
import { buildChatRouter } from "./routes/chat.js";
|
import { buildChatRouter } from "./routes/chat.js";
|
||||||
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
||||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||||
|
import { serializeRuntimeSessionContext } from "./runtime/internalSessionContextBridge.js";
|
||||||
import {
|
import {
|
||||||
getRuntimeSessionContext,
|
getRuntimeSessionContext,
|
||||||
markRuntimeSessionAuthExpired,
|
markRuntimeSessionAuthExpired,
|
||||||
@@ -43,7 +45,7 @@ const resultReferenceStore = new ResultReferenceStore();
|
|||||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||||
|
|
||||||
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref)。
|
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
|
||||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||||
|
|
||||||
app.use(cors());
|
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) => {
|
app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||||
res.status(403).json({ message: "forbidden" });
|
res.status(403).json({ message: "forbidden" });
|
||||||
@@ -294,13 +316,7 @@ const callBackendJson = async (
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers = buildBackendContextHeaders(context);
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
if (context.accessToken) {
|
|
||||||
headers.Authorization = `Bearer ${context.accessToken}`;
|
|
||||||
}
|
|
||||||
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
|
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
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