合并 tjwater-cli 到 main #1

Merged
jiang merged 73 commits from tjwater-cli into main 2026-08-18 17:56:45 +08:00
7 changed files with 291 additions and 19 deletions
Showing only changes of commit 2dc37e3fd8 - Show all commits
+6 -5
View File
@@ -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}`);
}
+8 -6
View File
@@ -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}`);
}
+25
View File
@@ -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;
};
+116
View File
@@ -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
View File
@@ -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,
+29
View File
@@ -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");
});
});