Author SHA1 Message Date
jiang 109abee0ca fix(agent): describe typed historical comparison charts
Generic Container CI/CD / test-build-publish (push) Successful in 1m21s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m21s
Use time_comparison for current curves and historical point sets. Document shared time slots and aligned real sample timestamps; synchronize the generated chart contracts.
2026-11-08 03:04:14 +08:00
jiang e476569ebc fix(agent): auto-approve scoped investigation edits
Generic Container CI/CD / test-build-publish (push) Successful in 1m19s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m19s
2026-11-08 02:02:58 +08:00
jiang a7ddff7b64 fix(agent): keep background sandbox tasks running
Generic Container CI/CD / test-build-publish (push) Successful in 1m47s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m48s
Auto-approve sandbox Shell only for host-bound task execution, stage native results within their owning session, and preserve active lease state on response stream loss. The repeated failures came from interactive approval in unattended runs and transport cleanup invalidating execution state; focused regressions cover both boundaries.
2026-11-08 01:54:48 +08:00
19 changed files with 261 additions and 46 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ SCADA地图定位、等级标记、表格与图表采用同一目标设备集合
两个及以上有效时刻用折线图,同指标对象比较用柱状图,单值或缺少可比数据时用文字或指标。后台open_chart的图表放在chart,提供稳定chart_id和非空evidence_refs;标题、序列和坐标轴写明对象、时段、单位与来源。变化量采用同口径基准计算。 两个及以上有效时刻用折线图,同指标对象比较用柱状图,单值或缺少可比数据时用文字或指标。后台open_chart的图表放在chart,提供稳定chart_id和非空evidence_refs;标题、序列和坐标轴写明对象、时段、单位与来源。变化量采用同口径基准计算。
同图最多100横轴槽、8组序列,每组与x_data对齐。历史与当前叠加时,当前用line,历史用scatter,chart_type=line。缺测和空槽填null,sample_times与数据等长并保留真实带时区采样时间;历史点投影到对比轴时保留原日期。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。 同图最多100横轴槽、8组序列,每组与x_data对齐。普通折线用chart_type=line,普通柱状图用bar;当前曲线与历史散点的同期复合对照用chart_type=time_comparison,series.type分别为line和scatter。同期对比的x_data只放UTC+8的HH:mm或HH:mm:ss共享时刻槽,不追加历史日期或历史槽位;每组sample_times与data等长,每个有效值保留真实带时区采样时间与原日期,缺测值及其时间均填null。当前曲线使用同一窗口,历史每组来自此前某一天的对应窗口,历史点填到同一时刻槽;跨午夜时按窗口时间顺序排列。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。
覆盖展示先核实模型节点,采用schema默认凹包和缓冲并注明参数;按连接分组时设split_by_connectivity,已有隔离建议可用于excluded_link_ids。只有result.map_payload.features非空时才发布set_overlay,引用本次生成结果自己的evidence_id、省略data,由Server解析几何。返回的data.presentation.command可直接作为command,另生成稳定request_id。缺少部分几何时展示有效部分并说明覆盖;全部缺失时展示可用点位或记录限制。 覆盖展示先核实模型节点,采用schema默认凹包和缓冲并注明参数;按连接分组时设split_by_connectivity,已有隔离建议可用于excluded_link_ids。只有result.map_payload.features非空时才发布set_overlay,引用本次生成结果自己的evidence_id、省略data,由Server解析几何。返回的data.presentation.command可直接作为command,另生成稳定request_id。缺少部分几何时展示有效部分并说明覆盖;全部缺失时展示可用点位或记录限制。
+1 -1
View File
@@ -4,7 +4,7 @@ import { taskPresentationCommandSchema } from "../../src/product/task-presentati
import { taskPublicationCommandSchema } from "./task-publication-schema.js"; import { taskPublicationCommandSchema } from "./task-publication-schema.js";
export default tool({ export default tool({
description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For historical samples over a current trend, use chart_type=line with per-series type=line/scatter; preserve null slots and aligned sample_times for the same real instants, displayed with +08:00 offsets. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.", description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.",
args: { args: {
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"), request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"),
command: taskPublicationCommandSchema, command: taskPublicationCommandSchema,
+3 -1
View File
@@ -269,7 +269,9 @@ OpenCode 的 `location.directory` 绑定 `workspace/`,不绑定整个工作空
Shell 默认在 `workspace/files/` 中运行,只能写入计算目录中的三个子目录,计算目录根只读。`chat/` 只由 Agent 服务读写,目录权限为 `0700`、记录文件为 `0600`,模型文件工具及 Shell 不能访问。OpenCode 仅为受管理的计算目录增加文件权限,插件还验证目录属于当前原生会话;其他业务记录、聊天记录和 `logs/` 继续禁止访问。目录隔离配合 Landlock 和 seccomp 限制文件访问、网络与进程行为;目录本身不等于 OS 沙箱。限制覆盖 Shell 及其子进程,OpenCode 服务和类型化业务工具仍由可信宿主进程运行。本地 stdio MCP 也不在这个沙箱中。 Shell 默认在 `workspace/files/` 中运行,只能写入计算目录中的三个子目录,计算目录根只读。`chat/` 只由 Agent 服务读写,目录权限为 `0700`、记录文件为 `0600`,模型文件工具及 Shell 不能访问。OpenCode 仅为受管理的计算目录增加文件权限,插件还验证目录属于当前原生会话;其他业务记录、聊天记录和 `logs/` 继续禁止访问。目录隔离配合 Landlock 和 seccomp 限制文件访问、网络与进程行为;目录本身不等于 OS 沙箱。限制覆盖 Shell 及其子进程,OpenCode 服务和类型化业务工具仍由可信宿主进程运行。本地 stdio MCP 也不在这个沙箱中。
Shell 和文件修改仍经过原生权限审批。聊天选择“请求批准”(`approval_mode=request`)时逐次询问;选择“始终允许”(`approval_mode=always`)后,Agent 自动批准本轮进入审批流程的请求,包括 Shell、文件修改和 MCP 请求。每次仅回复原生 `once`,不把本轮选择保存为后续轮次授权;明确拒绝的操作不会进入自动批准,业务权限、MCP 会话策略和沙箱限制继续执行。只读白名单工具可直接执行,不因选择“请求批准”而增加询问。 Shell 和文件修改仍经过原生权限审批。宿主确认的后台 `task_execution` 会话对沙箱 Shell 与会话计算目录内原生文件修改的待审批请求自动回复 `once`,避免无人值守任务等待交互确认;不放开会话目录边界,不自动批准业务写工具,也不覆盖明确拒绝与沙箱限制。聊天选择“请求批准”(`approval_mode=request`)时逐次询问;选择“始终允许”(`approval_mode=always`)后,Agent 自动批准本轮进入审批流程的请求,包括 Shell、文件修改和 MCP 请求。每次仅回复原生 `once`,不把本轮选择保存为后续轮次授权;明确拒绝的操作不会进入自动批准,业务权限、MCP 会话策略和沙箱限制继续执行。只读白名单工具可直接执行,不因选择“请求批准”而增加询问。
OpenCode 原生大结果仍保存在其全局工具输出目录。Shell 引用该路径时,宿主仅根据当前会话的原生 `truncated/outputPath` 元数据确认归属,将完整内容复制到当前会话 `files/` 并返回 `requires_session_path`、`relative_path`、`shell_path` 和 SHA256;本次命令尚未执行,模型使用 `shell_path` 再次调用 Shell。全局目录、其他会话结果和符号链接不会因此开放。
Shell 不能联网或访问源码、密钥、其他会话;业务查询和地址解析使用 MCP,网页搜索使用原生 `websearch`。超时单位为秒,默认 120 秒、最大 1800 秒;stdout 默认限制 1 MiB、stderr 最多 256 KiB、单文件最多 128 MiB。较长输出以文件保存在 `files/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。 Shell 不能联网或访问源码、密钥、其他会话;业务查询和地址解析使用 MCP,网页搜索使用原生 `websearch`。超时单位为秒,默认 120 秒、最大 1800 秒;stdout 默认限制 1 MiB、stderr 最多 256 KiB、单文件最多 128 MiB。较长输出以文件保存在 `files/`,工具返回预览和路径;取消、超时、超限和命令结束均清理该进程组,不保留后台进程。
+2
View File
@@ -36,6 +36,8 @@ Task display is asynchronous: Server verifies the task/run/generation, active le
Task open_chart requires persisted evidence references. Task set_overlay can omit data; Server resolves immutable evidence into bounded GeoJSON. Supplied data must match that resolution and never overrides the stored snapshot. Chat and Dev supply resolved data for structural validation before rendering. Task open_chart requires persisted evidence references. Task set_overlay can omit data; Server resolves immutable evidence into bounded GeoJSON. Supplied data must match that resolution and never overrides the stored snapshot. Chat and Dev supply resolved data for structural validation before rendering.
Same-time-of-day current/history comparisons use `chart_type=time_comparison`; ordinary charts retain `line` or `bar`. The comparison axis contains shared UTC+8 `HH:mm` or `HH:mm:ss` slots, with current `line` and historical `scatter` series and aligned real `sample_times`. The Server automatically validates new task presentation and report chart publications, rejecting date-appended axes, missing timestamps, misplaced points and nonhistorical reference dates with `TIME_COMPARISON_INVALID`. Ordinary charts skip this semantic check. Historical stored chart read contracts remain unchanged.
Chat, task following and Dev previews share the frontend command dispatcher but own independent map sources, SCADA layers and chart state. Clearing or switching one owner does not clear another. Dev JSONL is an explicit local preview/import/export container, not an alternative online business protocol. Plain data records and ui_block records do not execute embedded actions. The earlier agent-ui@1 registry has no active tool mappings and is not an action entry point. Chat, task following and Dev previews share the frontend command dispatcher but own independent map sources, SCADA layers and chart state. Clearing or switching one owner does not clear another. Dev JSONL is an explicit local preview/import/export container, not an alternative online business protocol. Plain data records and ui_block records do not execute embedded actions. The earlier agent-ui@1 registry has no active tool mappings and is not an action entry point.
## Running and waiting input delivery ## Running and waiting input delivery
+2 -1
View File
@@ -10211,7 +10211,8 @@
"chart_type": { "chart_type": {
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
], ],
"title": "Chart Type", "title": "Chart Type",
"type": "string" "type": "string"
+2 -1
View File
@@ -310,7 +310,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+1 -1
View File
@@ -16,7 +16,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
const chartSchema = z const chartSchema = z
.object({ .object({
title: z.string().max(200), title: z.string().max(200),
chart_type: z.enum(["line", "bar"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(), x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100), x_data: z.array(z.string().max(128)).min(1).max(100),
+2 -1
View File
@@ -211,7 +211,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+1 -1
View File
@@ -9600,7 +9600,7 @@ export interface components {
* Chart Type * Chart Type
* @enum {string} * @enum {string}
*/ */
chart_type: "line" | "bar"; chart_type: "line" | "bar" | "time_comparison";
/** Series */ /** Series */
series: components["schemas"]["TaskSafeChartSeries"][]; series: components["schemas"]["TaskSafeChartSeries"][];
/** Title */ /** Title */
+2 -2
View File
@@ -1,5 +1,5 @@
// Called only for native "asked" events. Explicit denials and host isolation // Called only for native "asked" events. Explicit denials and host isolation
// still run before this point; the selected round mode answers pending requests. // still run before this point; the selected round mode answers pending requests.
export function canAutomaticallyApprove(mode: string, _action: string) { export function canAutomaticallyApprove(mode: string, action: string, backgroundSandboxPermissions = false) {
return mode === "always"; return mode === "always" || (backgroundSandboxPermissions && (action === "shell" || action === "edit"));
} }
+43 -29
View File
@@ -116,6 +116,47 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
const toSse = (event: string, data: Record<string, unknown>) => const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
export async function runBackgroundTaskExecution(input: {
sessionId: string;
credential: ProductCredential;
model: string;
workflow?: string;
isRunning: () => boolean;
}) {
const { sessionId, credential, model, workflow } = input;
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
await fetch(new URL("/api/v1/responses", baseUrl), {
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
conversation: sessionId,
model,
stream: true,
input: buildTaskExecutionPrompt(workflow),
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
logger.error({ sessionId, status: response.status, executionContinues: input.isRunning() }, "background task execution failed");
}
}).catch((error) => {
// A lost response stream does not end the host-owned execution. Its route
// still renews/finalizes the lease and owns cleanup of the cached revision.
if (!input.isRunning()) clearOperationalTaskLease(sessionId);
logger.warn({ err: error, sessionId, executionContinues: input.isRunning() }, "background task response stream failed");
});
}
export const registerChatSessionRoutes = ( export const registerChatSessionRoutes = (
chatRouter: Router, chatRouter: Router,
options: RegisterChatSessionRoutesOptions, options: RegisterChatSessionRoutesOptions,
@@ -183,35 +224,8 @@ export const registerChatSessionRoutes = (
workflow?: string, workflow?: string,
) => { ) => {
queueMicrotask(() => { queueMicrotask(() => {
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`; void runBackgroundTaskExecution({ sessionId: record.sessionId, credential, model, workflow,
void fetch(new URL("/api/v1/responses", baseUrl), { isRunning: () => activeRuns.get(record.sessionId)?.status === "running" });
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
conversation: record.sessionId,
model,
stream: true,
input: buildTaskExecutionPrompt(workflow),
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
clearOperationalTaskLease(record.sessionId);
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
}
}).catch((error) => {
clearOperationalTaskLease(record.sessionId);
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
});
}); });
}; };
+8 -2
View File
@@ -84,6 +84,7 @@ type StreamPromptOptions = {
message: string; message: string;
model?: SupportedModel; model?: SupportedModel;
approvalMode?: ApprovalMode; approvalMode?: ApprovalMode;
backgroundSandboxPermissions?: boolean;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
signal?: AbortSignal; signal?: AbortSignal;
@@ -148,6 +149,7 @@ export const streamPromptResponse = async ({
message, message,
model, model,
approvalMode = "request", approvalMode = "request",
backgroundSandboxPermissions = false,
traceId, traceId,
projectId, projectId,
signal, signal,
@@ -481,7 +483,7 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns, patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission); const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.permission, backgroundSandboxPermissions);
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
@@ -489,7 +491,9 @@ export const streamPromptResponse = async ({
title: autoApprove ? "已自动允许权限请求" : "等待权限确认", title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail: detail:
autoApprove autoApprove
? approvalMode === "always"
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。" ? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
: "后台任务的会话内 Shell / 文件修改已自动批准;目录边界与明确拒绝规则仍生效。"
: buildPermissionDetail(event), : buildPermissionDetail(event),
}); });
if (autoApprove) { if (autoApprove) {
@@ -529,7 +533,7 @@ export const streamPromptResponse = async ({
resources: event.properties.resources, resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action); const autoApprove = canAutomaticallyApprove(approvalMode, event.properties.action, backgroundSandboxPermissions);
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
@@ -537,7 +541,9 @@ export const streamPromptResponse = async ({
title: autoApprove ? "已自动允许权限请求" : "等待权限确认", title: autoApprove ? "已自动允许权限请求" : "等待权限确认",
detail: detail:
autoApprove autoApprove
? approvalMode === "always"
? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。" ? "当前模式为本轮自动批准,已允许本次请求,不保存后续轮次授权。"
: "后台任务的会话内 Shell / 文件修改已自动批准;目录边界与明确拒绝规则仍生效。"
: buildPermissionV2Detail(event), : buildPermissionV2Detail(event),
}); });
if (autoApprove) { if (autoApprove) {
+1
View File
@@ -997,6 +997,7 @@ export const registerChatStreamRoute = (
message: preparedMessage, message: preparedMessage,
model: parsed.data.model, model: parsed.data.model,
approvalMode: parsed.data.approval_mode, approvalMode: parsed.data.approval_mode,
backgroundSandboxPermissions: sessionPurpose === "task_execution",
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
signal: abortController.signal, signal: abortController.signal,
+12
View File
@@ -8,6 +8,8 @@ import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.
import { getRuntimeSessionContext } from "../runtime/sessionContext.js"; import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { executeSandboxCommand } from "../sandbox/landlockSandbox.js"; import { executeSandboxCommand } from "../sandbox/landlockSandbox.js";
import { setSandboxOwnership } from "../runtime/conversationWorkspace.js"; import { setSandboxOwnership } from "../runtime/conversationWorkspace.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { nativeToolOutputDirectory, stageSessionToolOutputs } from "../sandbox/tool-output.js";
export const sandboxInput = z.object({ export const sandboxInput = z.object({
session_id: z.string().min(1).max(128), session_id: z.string().min(1).max(128),
@@ -37,6 +39,7 @@ export function isShellCommandDenied(command: string): boolean {
export function registerSandboxToolBridge(app: Express, options: { export function registerSandboxToolBridge(app: Express, options: {
internalToken: string; internalToken: string;
runtime: { runtime: {
messages: OpencodeRuntimeAdapter["messages"];
ensureSessionWorkspace(sessionId: string): Promise<string>; ensureSessionWorkspace(sessionId: string): Promise<string>;
approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>; approveSandboxShell(input: { sessionId: string; command: string; description?: string; callId: string; messageId: string }, signal: AbortSignal): Promise<void>;
}; };
@@ -61,6 +64,15 @@ export function registerSandboxToolBridge(app: Express, options: {
sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description, sessionId: context.sessionId, command: parsed.data.command, description: parsed.data.description,
callId: parsed.data.call_id, messageId: parsed.data.message_id, callId: parsed.data.call_id, messageId: parsed.data.message_id,
}, controller.signal); }, controller.signal);
if (parsed.data.command.includes(`${nativeToolOutputDirectory()}/`)) {
const files = await stageSessionToolOutputs(parsed.data.command, workspace, context.sessionId,
await options.runtime.messages(context.sessionId, 100));
if (files.length) {
res.json({ outcome: "requires_session_path", files,
detail: "命令尚未执行。当前会话的完整工具结果已复制到会话目录。请用返回的 shell_path 重新调用 Shell;relative_path 相对于会话 workspace,shell_path 相对于 Shell 的 files/ 工作目录。" });
return;
}
}
const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal); const result = await executeSandboxCommand(workspace, parsed.data.command, parsed.data.timeout, controller.signal);
const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {}; const files: Partial<Record<"stdout_file" | "stderr_file", string>> = {};
for (const stream of ["stdout", "stderr"] as const) { for (const stream of ["stdout", "stderr"] as const) {
+58
View File
@@ -0,0 +1,58 @@
import { constants } from "node:fs";
import { open, realpath } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SANDBOX_GID, SANDBOX_UID } from "../runtime/conversationWorkspace.js";
import { openArtifactDirectory } from "../mcp/artifact-files.js";
export const nativeToolOutputDirectory = () => resolve(
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "tool-output",
);
// Only native truncation metadata from this session authorizes a source file.
// Text supplied by the model or a tool response is never an ownership proof.
export async function stageSessionToolOutputs(
command: string, workspace: string, sessionId: string,
messages: Awaited<ReturnType<OpencodeRuntimeAdapter["messages"]>>,
) {
const sources = new Set<string>();
const root = nativeToolOutputDirectory();
for (const message of messages) for (const part of message.parts) {
if (part.type !== "tool" || part.state.status !== "completed") continue;
const metadata = part.state.metadata;
const path = metadata?.outputPath;
if (metadata?.truncated === true && typeof path === "string" && command.includes(path)
&& dirname(path) === root && /^tool_[a-zA-Z0-9]+$/.test(basename(path))) sources.add(path);
}
if (!sources.size) return [];
const canonicalRoot = await realpath(root);
const directory = await openArtifactDirectory(workspace, sessionId);
const files: Array<{ source_path: string; relative_path: string; shell_path: string; size: number; sha256: string }> = [];
let remaining = 128 * 1024 * 1024;
try {
for (const path of sources) {
if (await realpath(path) !== join(canonicalRoot, basename(path))) throw new Error("Unsafe tool output path");
const source = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
let bytes: Buffer;
try {
const stat = await source.stat();
if (!stat.isFile() || stat.size > remaining) throw new Error("Tool output staging budget exceeded");
bytes = await source.readFile();
if (bytes.length > remaining) throw new Error("Tool output staging budget exceeded");
remaining -= bytes.length;
} finally { await source.close(); }
const filename = `tool-output-${randomUUID()}.txt`;
const destination = await open(`/proc/self/fd/${directory.fd}/${filename}`,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try {
await destination.writeFile(bytes);
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
} finally { await destination.close(); }
files.push({ source_path: path, relative_path: `files/${filename}`, shell_path: filename, size: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex") });
}
} finally { await directory.close(); }
return files;
}
+3 -2
View File
@@ -35,9 +35,10 @@ describe("unified view commands", () => {
}); });
it("passes aligned historical scatter points through native and publication contracts", () => { it("passes aligned historical scatter points through native and publication contracts", () => {
const command = { action: "open_chart", chart_id: "pressure-comparison", evidence_refs: ["E1"], chart: { const command = { action: "open_chart", chart_id: "pressure-comparison", evidence_refs: ["E1"], chart: {
title: "压力七天对比", chart_type: "line", y_axis_name: "压力(m)", title: "压力七天对比", chart_type: "time_comparison", y_axis_name: "压力(m)",
x_data: ["19:30", "19:45", "20:00"], series: [ x_data: ["19:30", "19:45", "20:00"], series: [
{ name: "当前", type: "line", data: [32, null, 31] }, { name: "当前", type: "line", data: [32, null, 31],
sample_times: ["2026-06-14T19:30:00+08:00", null, "2026-06-14T20:00:00+08:00"] },
{ name: "前一天", type: "scatter", data: [null, 32, 33], { name: "前一天", type: "scatter", data: [null, 32, 33],
sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] }, sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] },
], ],
@@ -3,11 +3,14 @@ import { afterEach, describe, expect, it } from "bun:test";
import { import {
bindOperationalTaskCredential, bindOperationalTaskCredential,
humanTaskEvidenceCommandSchema, humanTaskEvidenceCommandSchema,
hasOperationalTaskRun,
OperationalTaskClient, OperationalTaskClient,
proposedTaskActionSchema, proposedTaskActionSchema,
releaseOperationalTaskCredential, releaseOperationalTaskCredential,
} from "../../src/product/operational-task-client.js"; } from "../../src/product/operational-task-client.js";
import { runBackgroundTaskExecution } from "../../src/routes/chatSessionRoutes.js";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const taskId = "50000000-0000-4000-8000-000000000001"; const taskId = "50000000-0000-4000-8000-000000000001";
const runId = "55000000-0000-4000-8000-000000000001"; const runId = "55000000-0000-4000-8000-000000000001";
@@ -364,3 +367,28 @@ describe("OperationalTaskClient", () => {
expect(requestBodies[2]).toMatchObject({ expected_revision: 3 }); expect(requestBodies[2]).toMatchObject({ expected_revision: 3 });
}); });
}); });
it.each([true, false])("retains lease state on background stream loss only while running=%s", async (running) => {
const credential = { authorization: "Bearer fixture", projectId: "project-1" };
bindOperationalTaskCredential("runtime-1", credential);
globalThis.fetch = (async () => Response.json(run)) as unknown as typeof fetch;
const client = new OperationalTaskClient("http://server.test");
await client.claimTask("runtime-1", taskId, runId, {
requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1",
});
expect(hasOperationalTaskRun("runtime-1")).toBe(true);
globalThis.fetch = (async () => new Response(new ReadableStream({
start(controller) { controller.error(new Error("fixture stream disconnected")); },
}))) as unknown as typeof fetch;
await runBackgroundTaskExecution({ sessionId: "runtime-1", credential, model: "fixture/model",
isRunning: () => running });
expect(hasOperationalTaskRun("runtime-1")).toBe(running);
if (running) {
globalThis.fetch = (async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
expect(JSON.parse(String(init?.body)).expected_revision).toBe(2);
return Response.json({ ...run, revision: 3 });
}) as unknown as typeof fetch;
await client.heartbeatTask("runtime-1", taskId, runId, "session-1");
}
});
+37
View File
@@ -714,3 +714,40 @@ describe("streamPromptResponse", () => {
}); });
}); });
describe("background sandbox permissions", () => {
it.each([["permission.asked", true], ["permission.v2.asked", true],
["permission.asked", false], ["permission.v2.asked", false]] as const)("limits session Shell/edit approval for %s background=%s", async (type, background) => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () => createEventStream([
...["shell", "edit", "mcp_fixture_write"].map((action) => ({
type,
properties: {
id: action, sessionID: "runtime-session-1",
...(type === "permission.asked"
? { permission: action, patterns: ["fixture"], always: [] }
: { action, resources: ["fixture"], save: [] }),
},
})),
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (input: Record<string, unknown>) => { replies.push(input); },
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1",
message: "后台调查", approvalMode: "request", backgroundSandboxPermissions: background,
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual(background ? [
{ requestId: "shell", sessionId: "runtime-session-1", reply: "once" },
{ requestId: "edit", sessionId: "runtime-session-1", reply: "once" },
] : []);
expect(events.filter(({ event }) => event === "permission_request")
.map(({ data }) => data.permission)).toEqual(background ? ["mcp_fixture_write"] : ["shell", "edit", "mcp_fixture_write"]);
});
});
+52 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "bun:test"; import { afterEach, describe, expect, it } from "bun:test";
import express from "express"; import express from "express";
import { type Server } from "node:http"; import { type Server } from "node:http";
import { mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdtemp, mkdir, readFile, rm, writeFile, symlink } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { config } from "../../src/config.js"; import { config } from "../../src/config.js";
@@ -11,6 +11,7 @@ import { registerSandboxToolBridge } from "../../src/routes/sandboxToolBridge.js
let server: Server | undefined; let server: Server | undefined;
let root: string | undefined; let root: string | undefined;
const previousDataHome = process.env.XDG_DATA_HOME;
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
const session = "sandbox-bridge-test"; const session = "sandbox-bridge-test";
afterEach(async () => { afterEach(async () => {
@@ -18,6 +19,8 @@ afterEach(async () => {
if (root) await rm(root, { recursive: true, force: true }); if (root) await rm(root, { recursive: true, force: true });
config.CONVERSATION_WORKSPACE_ROOT = previousRoot; config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
removeRuntimeSessionContext(session); removeRuntimeSessionContext(session);
if (previousDataHome === undefined) delete process.env.XDG_DATA_HOME;
else process.env.XDG_DATA_HOME = previousDataHome;
}); });
describe("sandbox execution bridge", () => { describe("sandbox execution bridge", () => {
it("executes only after approval and cannot override the owning workspace", async () => { it("executes only after approval and cannot override the owning workspace", async () => {
@@ -31,6 +34,7 @@ describe("sandbox execution bridge", () => {
const requested = new Promise<void>((resolve) => { ready = resolve; }); const requested = new Promise<void>((resolve) => { ready = resolve; });
const app = express(); app.use(express.json()); const app = express(); app.use(express.json());
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: { registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
messages: async () => [],
ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; }, ensureSessionWorkspace: async (id) => { expect(id).toBe(session); return directory; },
approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; }, approveSandboxShell: async (input) => { if (approved) return; expect(input.command).toContain("approved.txt"); ready(); await new Promise<void>((resolve) => { approve = resolve; }); approved = true; },
} }); } });
@@ -55,3 +59,50 @@ describe("sandbox execution bridge", () => {
expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`); expect(await readFile(large.stdout_file, "utf8")).toBe(`${"x".repeat(10000)}\n`);
}); });
}); });
it("stages only this session's native output and keeps the global directory sandboxed", async () => {
root = await mkdtemp(join(tmpdir(), "sandbox-output-test-"));
config.CONVERSATION_WORKSPACE_ROOT = root;
process.env.XDG_DATA_HOME = join(root, "native");
const output = join(process.env.XDG_DATA_HOME, "opencode", "tool-output");
await mkdir(output, { recursive: true });
const own = join(output, "tool_own"), other = join(output, "tool_other");
const content = JSON.stringify({ evidence_id: "fixture", readings: "完整结果".repeat(1000) });
await writeFile(own, content); await writeFile(other, "other session");
const directory = await createConversationWorkspace(root);
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "owner", projectKey: "project", traceId: "test" });
const metadata = { truncated: true, outputPath: own };
const app = express(); app.use(express.json());
registerSandboxToolBridge(app, { internalToken: "TEST_ONLY", runtime: {
ensureSessionWorkspace: async () => directory,
approveSandboxShell: async () => {},
messages: async () => [{ info: { id: "native", role: "assistant" }, parts: [{
type: "tool", state: { status: "completed", metadata },
}] }] as never,
} });
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server!.once("listening", resolve));
const post = async (command: string) => {
const response = await fetch(`http://127.0.0.1:${(server!.address() as { port: number }).port}/internal/tools/sandbox-shell`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "TEST_ONLY" },
body: JSON.stringify({ session_id: session, call_id: "fixture", message_id: "fixture", command }),
});
return { status: response.status, data: await response.json() as Record<string, any> };
};
const staged = await post(`cat '${own}'`);
expect(staged.status).toBe(200);
expect(staged.data.outcome).toBe("requires_session_path");
const file = staged.data.files[0];
expect(await readFile(join(directory, file.relative_path), "utf8")).toBe(content);
const result = await post(`python3 -c 'import hashlib; print(hashlib.sha256(open("${file.shell_path}","rb").read()).hexdigest())'`);
expect(result.data.exitCode).toBe(0);
expect(result.data.stdout.trim()).toBe(file.sha256);
const forbidden = await post(`cat '${other}'`);
expect(forbidden.data.exitCode).not.toBe(0);
expect(forbidden.data.stderr).toContain("Permission denied");
const link = join(output, "tool_link");
await symlink(other, link);
metadata.outputPath = link;
expect((await post(`cat '${link}'`)).status).toBe(503);
});