feat(agent): migrate OpenCode 2 runtime and sandbox tools

Upgrade the runtime and native plugin API to OpenCode 2.0.24. Restore
session-scoped learning, credential renewal, typed project queries and
controlled frontend actions from the legacy Agent.

Replace host Shell execution with an approved Landlock/seccomp executor
and persistent per-session workspaces. Add typed Bocha search and Tianditu
geocoding bridges using the owning session credential. Require a working
kernel sandbox before startup and persist workspace files in deployments.

Validation completed before this commit: TypeScript checks, 150 Bun tests,
Docker build and UID 10001 sandbox execution, native Shell approval and
session recovery. Tianditu succeeded; Bocha currently reports exhausted
account quota. Unrelated investigation-report contract work is excluded.
This commit is contained in:
2026-10-08 15:46:37 +08:00
parent 1a9b0cafc1
commit 7383ed6d13
93 changed files with 8065 additions and 1098 deletions
+283
View File
@@ -0,0 +1,283 @@
import { randomUUID } from "node:crypto";
import { type ProductCredential } from "./server-auth-client.js";
export type CredentialRefreshReason =
| "access_token_expired"
| "access_token_rejected";
export type CredentialRefreshEvent =
| {
type: "credential_refresh_required";
requestId: string;
reason: CredentialRefreshReason;
timeoutMs: number;
}
| {
type: "credential_refreshed";
requestId: string;
}
| {
type: "credential_refresh_failed";
requestId: string;
message: string;
};
type PendingRefresh = {
deadlineAt: number;
promise: Promise<ProductCredential>;
reason: CredentialRefreshReason;
reject: (error: Error) => void;
requestId: string;
resolve: (context: ProductCredential) => void;
timer: ReturnType<typeof setTimeout>;
};
type CredentialRefreshListener = (event: CredentialRefreshEvent) => void;
export class CredentialRefreshError extends Error {
override readonly name = "CredentialRefreshError";
constructor(
message: string,
readonly code: "cancelled" | "failed" | "timeout" | "unavailable" = "failed",
) {
super(message);
}
}
const AUTH_EXPIRY_SKEW_MS = 30_000;
export const isRuntimeCredentialExpired = (
context: ProductCredential,
now = Date.now(),
) => {
if (!context.expiresAt) {
return false;
}
const expiresAt = Date.parse(context.expiresAt);
return Number.isFinite(expiresAt) && now >= expiresAt - AUTH_EXPIRY_SKEW_MS;
};
export class CredentialRefreshCoordinator {
private readonly listeners = new Map<
string,
Set<CredentialRefreshListener>
>();
private readonly background = new Set<string>();
enableBackground(sessionId: string) { this.background.add(sessionId); }
disableBackground(sessionId: string) { this.background.delete(sessionId); }
notifyFailure(sessionId: string, message: string) {
this.emit(sessionId, { type: "credential_refresh_failed", requestId: `credential-${randomUUID()}`, message });
}
private readonly pending = new Map<string, PendingRefresh>();
constructor(private readonly timeoutMs = 30_000) {}
subscribe(sessionId: string, listener: CredentialRefreshListener) {
const listeners =
this.listeners.get(sessionId) ?? new Set<CredentialRefreshListener>();
listeners.add(listener);
this.listeners.set(sessionId, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
this.listeners.delete(sessionId);
}
};
}
request(sessionId: string, reason: CredentialRefreshReason) {
const existing = this.pending.get(sessionId);
if (existing) {
return existing.promise;
}
if (!this.listeners.get(sessionId)?.size && !this.background.has(sessionId)) {
return Promise.reject(
new CredentialRefreshError(
"credential refresh channel is unavailable",
"unavailable",
),
);
}
const requestId = `credential-${randomUUID()}`;
let resolvePromise!: (context: ProductCredential) => void;
let rejectPromise!: (error: Error) => void;
const promise = new Promise<ProductCredential>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
const timer = setTimeout(() => {
this.fail(sessionId, requestId, "credential refresh timed out", "timeout");
}, this.timeoutMs);
this.pending.set(sessionId, {
deadlineAt: Date.now() + this.timeoutMs,
promise,
reason,
reject: rejectPromise,
requestId,
resolve: resolvePromise,
timer,
});
this.emit(sessionId, {
type: "credential_refresh_required",
requestId,
reason,
timeoutMs: this.timeoutMs,
});
return promise;
}
resolve(
sessionId: string,
requestId: string,
context: ProductCredential,
) {
const pending = this.pending.get(sessionId);
if (!pending || pending.requestId !== requestId) {
return false;
}
clearTimeout(pending.timer);
this.pending.delete(sessionId);
pending.resolve(context);
this.emit(sessionId, {
type: "credential_refreshed",
requestId,
});
return true;
}
fail(
sessionId: string,
requestId: string,
message: string,
code: CredentialRefreshError["code"] = "failed",
emitFailureEvent = true,
) {
const pending = this.pending.get(sessionId);
if (!pending || pending.requestId !== requestId) {
return false;
}
clearTimeout(pending.timer);
this.pending.delete(sessionId);
pending.reject(new CredentialRefreshError(message, code));
if (emitFailureEvent) {
this.emit(sessionId, {
type: "credential_refresh_failed",
requestId,
message,
});
}
return true;
}
cancelSession(sessionId: string, message = "credential refresh cancelled") {
const pending = this.pending.get(sessionId);
if (!pending) {
return false;
}
return this.fail(
sessionId,
pending.requestId,
message,
"cancelled",
false,
);
}
getPendingRequestId(sessionId: string) {
return this.pending.get(sessionId)?.requestId;
}
getPendingEvent(
sessionId: string,
): Extract<CredentialRefreshEvent, { type: "credential_refresh_required" }> | null {
const pending = this.pending.get(sessionId);
if (!pending) return null;
return {
type: "credential_refresh_required",
requestId: pending.requestId,
reason: pending.reason,
timeoutMs: Math.max(0, pending.deadlineAt - Date.now()),
};
}
private emit(sessionId: string, event: CredentialRefreshEvent) {
for (const listener of this.listeners.get(sessionId) ?? []) {
listener(event);
}
}
}
export const runWithCredentialRefresh = async <T extends { status: number }>(
coordinator: CredentialRefreshCoordinator,
sessionId: string,
context: ProductCredential,
execute: (context: ProductCredential) => Promise<T>,
getCurrent?: () => ProductCredential | undefined,
) => {
let activeContext = getCurrent?.() ?? context;
let refreshed = false;
if (isRuntimeCredentialExpired(activeContext)) {
activeContext = await coordinator.request(
sessionId,
"access_token_expired",
);
refreshed = true;
}
let result = await execute(activeContext);
if (result.status !== 401 || refreshed) {
return result;
}
if (result instanceof Response) await result.body?.cancel();
const latest = getCurrent?.();
activeContext = latest && latest.authorization !== activeContext.authorization
? latest : await coordinator.request(sessionId, "access_token_rejected");
result = await execute(activeContext);
return result;
};
const credentialVault = new Map<string, ProductCredential>();
const credentialFailures = new Set<string>();
export const credentialRefreshCoordinator = new CredentialRefreshCoordinator(45_000);
export const bindSessionCredential = (sessionId: string, credential: ProductCredential | null) => {
if (credential) { credentialVault.set(sessionId, { ...credential }); credentialFailures.delete(sessionId); }
};
export const releaseSessionCredential = (sessionId: string) => {
credentialRefreshCoordinator.cancelSession(sessionId);
credentialRefreshCoordinator.disableBackground(sessionId);
credentialVault.delete(sessionId);
credentialFailures.delete(sessionId);
};
export const refreshSessionCredential = (sessionId: string, credential: ProductCredential, requestId?: string) => {
const existing = credentialVault.get(sessionId);
if (!existing || existing.projectId !== credential.projectId) return false;
const pendingId = credentialRefreshCoordinator.getPendingRequestId(sessionId);
if (requestId && requestId !== pendingId) return false;
bindSessionCredential(sessionId, credential);
if (pendingId) credentialRefreshCoordinator.resolve(sessionId, pendingId, credential);
return true;
};
export const requestWithSessionCredential = async (
sessionId: string, execute: (credential: ProductCredential) => Promise<Response>,
) => {
const credential = credentialVault.get(sessionId);
if (!credential) throw Object.assign(new Error("task access requires a current product credential"), { status: 401 });
if (credentialFailures.has(sessionId)) throw Object.assign(new CredentialRefreshError("credential renewal failed"), { status: 401 });
try {
const response = await runWithCredentialRefresh(credentialRefreshCoordinator, sessionId, credential, execute, () => credentialVault.get(sessionId));
if (response.status === 401) {
credentialFailures.add(sessionId);
credentialRefreshCoordinator.notifyFailure(sessionId, "renewed credential was rejected");
}
return response;
} catch (error) {
if (error instanceof CredentialRefreshError && error.code !== "cancelled") {
credentialFailures.add(sessionId);
if (error.code === "unavailable") credentialRefreshCoordinator.notifyFailure(sessionId, error.message);
}
throw error;
}
};
@@ -0,0 +1,64 @@
import { z } from "zod";
import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { components } from "../generated/server-api.js";
const domain = z.string().trim().min(1).max(253).regex(/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/);
export const webSearchInput = z.object({
query: z.string().trim().min(1).max(2000),
freshness: z.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]).default("no_limit"),
summary: z.boolean().default(true),
count: z.number().int().min(1).max(50).default(10),
include: z.array(domain).max(20).optional(),
exclude: z.array(domain).max(20).optional(),
}).strict();
export const geocodeInput = z.object({ keyword: z.string().trim().min(1).max(500) }).strict();
const freshness = { no_limit: "noLimit", one_day: "oneDay", one_week: "oneWeek", one_month: "oneMonth", one_year: "oneYear" } as const;
export class ExternalInformationClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async webSearch(sessionId: string, input: z.infer<typeof webSearchInput>, signal?: AbortSignal) {
const parsed = webSearchInput.parse(input);
const body = { ...parsed, freshness: freshness[parsed.freshness] } satisfies components["schemas"]["WebSearchRequest"];
return this.request(sessionId, "/api/v1/web-searches", body, signal);
}
async geocode(sessionId: string, input: z.infer<typeof geocodeInput>, signal?: AbortSignal) {
const body = geocodeInput.parse(input) satisfies components["schemas"]["TiandituGeocodeRequest"];
const response = await this.request(sessionId, "/api/v1/geocoding-requests", body, signal);
if (!response || typeof response !== "object") throw Object.assign(new Error("geocoding returned no valid coordinate"), { status: 502 });
const data = response as Record<string, unknown>;
const location = data.location as { lon?: unknown; lat?: unknown } | undefined;
const coordinate = (value: unknown) => typeof value === "number" || (typeof value === "string" && value.trim()) ? Number(value) : NaN;
const lon = coordinate(location?.lon);
const lat = coordinate(location?.lat);
if (String(data.status) !== "0" || !Number.isFinite(lon) || !Number.isFinite(lat) || Math.abs(lon) > 180 || Math.abs(lat) > 90) {
throw Object.assign(new Error("geocoding returned no valid coordinate"), { status: 502 });
}
return { ...data, location: { ...location, lon, lat }, source_crs: "EPSG:4326", center: [lon, lat] };
}
private async request(sessionId: string, path: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
body: JSON.stringify(body),
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
}));
if (!response.ok) {
let detail = "";
try {
const data = await response.json() as { detail?: unknown };
if (typeof data.detail === "string") detail = data.detail;
else if (data.detail && typeof data.detail === "object") {
const message = (data.detail as { message?: unknown; msg?: unknown }).message ?? (data.detail as { msg?: unknown }).msg;
if (typeof message === "string") detail = message;
}
} catch { /* Preserve the HTTP status if the upstream error has no JSON. */ }
throw Object.assign(new Error(`external information query failed (${response.status})${detail ? `: ${detail.slice(0, 500)}` : ""}`), { status: response.status });
}
try { return await response.json(); }
catch { throw Object.assign(new Error("external information returned invalid JSON"), { status: 502 }); }
}
}
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod";
const historyResponse = z.object({ series: z.array(z.object({
element_id: z.string(), metric: z.string(), source: z.string(), source_unit: z.string(),
device_id: z.string().nullable().optional(),
points: z.array(z.object({ time: z.string(), value: z.number().finite().nullable() })),
})) });
export const historyChart = (value: unknown, elementId: string) => {
const series = historyResponse.parse(value).series.filter((item) => item.element_id === elementId && item.points.some((point) => point.value !== null));
const first = series[0];
if (!first) throw new Error("NO_HISTORY_DATA");
const compatible = series.filter((item) => item.metric === first.metric && item.source_unit === first.source_unit).slice(0, 8);
const points = first.points.filter((point) => point.value !== null).slice(0, 100);
const aligned = compatible.map((item) => ({ item, values: new Map(item.points.map((point) => [point.time, point.value])) }))
.filter(({ values }) => points.every((point) => typeof values.get(point.time) === "number"));
return { title: `${elementId} ${first.metric} (${first.source_unit}) · 最多100点`, chart_type: "line" as const,
x_data: points.map((point) => point.time), series: aligned.map(({ item, values }) => ({ name: `${item.device_id ?? elementId} ${item.source}`, data: points.map((point) => values.get(point.time) as number) })) };
};
+19 -22
View File
@@ -1,3 +1,4 @@
import { bindSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "./credential-refresh.js";
import { z } from "zod";
import { config } from "../config.js";
@@ -179,7 +180,6 @@ export const taskArtifactBridgeSchema = z.object({
{ message: "at least one task artifact is required" },
);
const credentialVault = new Map<string, ProductCredential>();
const taskRunStates = new Map<string, { runId: string; revision: number }>();
const taskRunMutationQueues = new Map<string, Promise<void>>();
@@ -187,11 +187,11 @@ export const bindOperationalTaskCredential = (
runtimeSessionId: string,
credential: ProductCredential | null,
) => {
if (credential) credentialVault.set(runtimeSessionId, { ...credential });
bindSessionCredential(runtimeSessionId, credential);
};
export const releaseOperationalTaskCredential = (runtimeSessionId: string) => {
credentialVault.delete(runtimeSessionId);
releaseSessionCredential(runtimeSessionId);
taskRunStates.delete(runtimeSessionId);
taskRunMutationQueues.delete(runtimeSessionId);
};
@@ -393,25 +393,22 @@ export class OperationalTaskClient {
}
private async request(runtimeSessionId: string, path: string, init: RequestInit) {
const credential = credentialVault.get(runtimeSessionId);
if (!credential) {
throw new OperationalTaskClientError(401, "task access requires a current product credential");
}
let response: Response;
try {
response = await fetch(new URL(path, this.baseUrl), {
...init,
signal: init.signal ?? AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: {
Accept: "application/json",
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
});
} catch {
throw new OperationalTaskClientError(503, "TJWater Server task API unavailable");
}
const response = await requestWithSessionCredential(runtimeSessionId, async (credential) => {
try {
return await fetch(new URL(path, this.baseUrl), {
...init,
signal: init.signal ?? AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: {
Accept: "application/json",
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
});
} catch {
throw new OperationalTaskClientError(503, "TJWater Server task API unavailable");
}
});
const text = await response.text();
const value = parseJson(text);
if (!response.ok) {
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { components } from "../generated/server-api.js";
const id = z.string().trim().min(1).max(128);
const windowFields = { start: z.string().datetime({ offset: true }), end: z.string().datetime({ offset: true }) };
export const projectDataQuerySchema = z.discriminatedUnion("source", [
z.object({ source: z.literal("element_properties"), element_type: z.enum(["junction", "pipe", "valve", "pump", "tank", "reservoir"]), element_id: id }).strict(),
z.object({ source: z.literal("scada_devices") }).strict(),
z.object({ source: z.literal("scada_history"), device_ids: z.array(id).min(1).max(20), field: z.enum(["monitored_value", "cleaned_value"]), ...windowFields }).strict(),
z.object({ source: z.literal("element_history"), element_type: z.enum(["junction", "pipe"]), element_id: id, ...windowFields }).strict(),
z.object({ source: z.literal("analysis_runs"), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
z.object({ source: z.literal("analysis_run"), run_id: z.string().uuid() }).strict(),
z.object({ source: z.literal("analysis_results"), run_id: z.string().uuid(), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
]).superRefine((query, context) => {
if (!("start" in query)) return;
const duration = Date.parse(query.end) - Date.parse(query.start);
if (duration <= 0 || duration > 24 * 60 * 60 * 1000) context.addIssue({ code: z.ZodIssueCode.custom, message: "time window must be positive and at most 24 hours" });
});
export type ProjectDataQuery = z.infer<typeof projectDataQuerySchema>;
export class ProjectDataClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async query(sessionId: string, input: ProjectDataQuery) {
const query = projectDataQuerySchema.parse(input);
let path: string;
let init: RequestInit = { method: "GET" };
switch (query.source) {
case "element_properties":
path = `/api/v1/${query.element_type}s/properties?${new URLSearchParams({ [query.element_type]: query.element_id })}`;
break;
case "scada_devices": path = "/api/v1/scada-devices"; break;
case "scada_history":
path = `/api/v1/timeseries/scada-readings/fields?${new URLSearchParams({ device_ids: query.device_ids.join(","), field: query.field, start_time: query.start, end_time: query.end })}`;
break;
case "element_history": {
const body = { start_time: query.start, end_time: query.end, mode: "observed", elements: [{ element_type: query.element_type, element_id: query.element_id }] } satisfies components["schemas"]["ElementHistoryQuery"];
path = "/api/v1/timeseries/views/element-history/query";
init = { method: "POST", body: JSON.stringify(body) };
break;
}
case "analysis_runs": path = `/api/v1/analysis/runs?limit=${query.limit}&offset=${query.offset}`; break;
case "analysis_run": path = `/api/v1/analysis/runs/${query.run_id}`; break;
case "analysis_results": path = `/api/v1/analysis/runs/${query.run_id}/results?limit=${query.limit}&offset=${query.offset}`; break;
}
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
...init,
signal: AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
}));
if (!response.ok) throw Object.assign(new Error(`project data query rejected (${response.status})`), { status: response.status });
return response.json() as Promise<unknown>;
}
}
+1
View File
@@ -4,6 +4,7 @@ import type { components } from "../generated/server-api.js";
export type ProductCredential = {
authorization: string;
projectId: string;
expiresAt?: string | null;
};
export type AgentAuthContext = components["schemas"]["AgentAuthContextResponse"];