3 Commits
Author SHA1 Message Date
jiang 88b41ba04c refactor(scada): use canonical device identifiers
Generic Container CI/CD / test-build-publish (push) Successful in 32s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 32s
2026-09-08 18:18:51 +08:00
jiang b33d950d2b fix: avoid embedded opencode port conflicts 2026-08-19 16:56:25 +08:00
jiang f0bf667d02 fix(agent): harden permissions and startup readiness 2026-08-18 17:10:48 +08:00
14 changed files with 309 additions and 49 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ LOG_LEVEL=info
OPENCODE_MODE=embedded OPENCODE_MODE=embedded
OPENCODE_MODEL=deepseek/deepseek-v4-flash OPENCODE_MODEL=deepseek/deepseek-v4-flash
OPENCODE_HOSTNAME=127.0.0.1 OPENCODE_HOSTNAME=127.0.0.1
OPENCODE_PORT=4096 OPENCODE_PORT=0
AGENT_LOCAL_USER_ID=local-user AGENT_LOCAL_USER_ID=local-user
# 必须填写 Server /api/v1/projects 返回的项目 UUID。 # 必须填写 Server /api/v1/projects 返回的项目 UUID。
+2 -2
View File
@@ -32,8 +32,8 @@ Agent 负责:
1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。 1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。
2. 每次普通工具调用必须填写具体 `reason` 2. 每次普通工具调用必须填写具体 `reason`
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。 3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
4. SCADA 地图结果只允许使用可信 `sensor_id``high | medium | low | unrated` 等级,不得传入自定义标签、颜色或 HTML。 4. SCADA 地图结果只允许使用后端 API 返回的可信 `device_id``high | medium | low | unrated` 等级,不得传入自定义标签、颜色或 HTML。
5. 同一批结果不得包含重复 `sensor_id`,每次最多 100 个点位。 5. 同一批结果不得包含重复 `device_id`,每次最多 100 个点位。
6. 只有 SCADA 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。 6. 只有 SCADA 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。
## 执行约束 ## 执行约束
+4 -4
View File
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js"; import { executeFrontendAction } from "./frontend_action.js";
const sensorId = tool.schema.string().trim().min(1).max(128); const deviceId = tool.schema.string().trim().min(1).max(128);
export default tool({ export default tool({
description: description:
@@ -16,7 +16,7 @@ export default tool({
items: tool.schema items: tool.schema
.array( .array(
tool.schema.object({ tool.schema.object({
sensor_id: sensorId.describe("Trusted SCADA sensor identifier."), device_id: deviceId.describe("Trusted SCADA device identifier from the backend API."),
level: tool.schema level: tool.schema
.enum(["high", "medium", "low", "unrated"]) .enum(["high", "medium", "low", "unrated"])
.describe("Completed analysis level; use unrated only when no level was concluded."), .describe("Completed analysis level; use unrated only when no level was concluded."),
@@ -25,8 +25,8 @@ export default tool({
.min(1) .min(1)
.max(100) .max(100)
.refine( .refine(
(items) => new Set(items.map((item) => item.sensor_id)).size === items.length, (items) => new Set(items.map((item) => item.device_id)).size === items.length,
"sensor_id values must be unique", "device_id values must be unique",
) )
.describe("One to one hundred unique SCADA analysis results."), .describe("One to one hundred unique SCADA analysis results."),
}, },
+10 -1
View File
@@ -45,4 +45,13 @@ and UIEnvelope mapping. Tests must not depend on mutable files under `data/`.
This experimental service has no user authentication. Do not add secrets, This experimental service has no user authentication. Do not add secrets,
tokens, passwords, API keys, session dumps, or generated result references to tokens, passwords, API keys, session dumps, or generated result references to
the repository. the repository. Keep `.env`, `data/`, and `logs/` statically denied in
`opencode.json`; do not replace those denials with prompt-only guidance. Keep
arbitrary Bash and edit operations approval-gated and subagents disabled.
## Runtime Readiness
Complete the OpenCode health check, warmup session, and model tool discovery
before the HTTP server starts listening. A warmup failure must fail startup.
Once listening, `/health` must expose the `ready` and `warmed_up` readiness
fields used by deployment checks.
+41 -5
View File
@@ -16,10 +16,36 @@
"port": 4096 "port": 4096
}, },
"permission": { "permission": {
"*": "ask",
"external_directory": "deny",
"read": {
"*": "allow", "*": "allow",
"external_directory": "ask", ".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"edit": {
"*": "ask",
".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"bash": { "bash": {
"*": "allow", "*": "ask",
"rm *": "ask", "rm *": "ask",
"rmdir *": "ask", "rmdir *": "ask",
"mv *": "ask", "mv *": "ask",
@@ -27,10 +53,20 @@
"chown *": "ask", "chown *": "ask",
"sudo *": "ask", "sudo *": "ask",
"curl *": "ask", "curl *": "ask",
"wget *": "ask" "wget *": "ask",
"*.env*": "deny",
"*data/*": "deny",
"* data": "deny",
"*/data": "deny",
"*logs/*": "deny",
"* logs": "deny",
"*/logs": "deny"
}, },
"edit": "ask", "question": "allow",
"task": "deny" "task": "deny",
"todo": "allow",
"todoread": "allow",
"todowrite": "allow"
}, },
"default_agent": "instruction" "default_agent": "instruction"
} }
+2 -2
View File
@@ -58,8 +58,8 @@ const envSchema = z
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"),
// embedded opencode server 的监听地址。 // embedded opencode server 的监听地址。
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
// embedded opencode server 的监听端口。 // embedded opencode server 的监听端口0 表示由系统动态分配
OPENCODE_PORT: z.coerce.number().int().positive().default(4096), OPENCODE_PORT: z.coerce.number().int().nonnegative().default(0),
// opencode SDK 启动或连接运行时时的超时时间(毫秒)。 // opencode SDK 启动或连接运行时时的超时时间(毫秒)。
OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// 默认使用的 opencode 模型标识。 // 默认使用的 opencode 模型标识。
+8 -8
View File
@@ -2,7 +2,7 @@ import { z } from "zod";
import type { FrontendActionManifest } from "./types.js"; import type { FrontendActionManifest } from "./types.js";
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny }; type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
const sensorId = z.string().trim().min(1).max(128); const deviceId = z.string().trim().min(1).max(128);
const scadaLevel = z.enum(["high", "medium", "low", "unrated"]); const scadaLevel = z.enum(["high", "medium", "low", "unrated"]);
const levelCounts = z.object({ const levelCounts = z.object({
high: z.number().int().nonnegative(), high: z.number().int().nonnegative(),
@@ -11,18 +11,18 @@ const levelCounts = z.object({
unrated: z.number().int().nonnegative(), unrated: z.number().int().nonnegative(),
}).strict(); }).strict();
const renderScadaAnalysisInput = z.object({ const renderScadaAnalysisInput = z.object({
items: z.array(z.object({ sensor_id: sensorId, level: scadaLevel }).strict()).min(1).max(100), items: z.array(z.object({ device_id: deviceId, level: scadaLevel }).strict()).min(1).max(100),
}).strict().superRefine((value, context) => { }).strict().superRefine((value, context) => {
const seen = new Set<string>(); const seen = new Set<string>();
value.items.forEach((item, index) => { value.items.forEach((item, index) => {
if (seen.has(item.sensor_id)) { if (seen.has(item.device_id)) {
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "sensor_id values must be unique", message: "device_id values must be unique",
path: ["items", index, "sensor_id"], path: ["items", index, "device_id"],
}); });
} }
seen.add(item.sensor_id); seen.add(item.device_id);
}); });
}); });
const definitions: Definition[] = [ const definitions: Definition[] = [
@@ -36,8 +36,8 @@ const definitions: Definition[] = [
}, },
inputSchema: renderScadaAnalysisInput, inputSchema: renderScadaAnalysisInput,
outputSchema: z.object({ outputSchema: z.object({
rendered_ids: z.array(sensorId), rendered_ids: z.array(deviceId),
missing_ids: z.array(sensorId), missing_ids: z.array(deviceId),
level_counts: levelCounts, level_counts: levelCounts,
fitted: z.literal(true), fitted: z.literal(true),
}).strict(), }).strict(),
+104 -3
View File
@@ -4,6 +4,7 @@ import {
type OpencodeClient, type OpencodeClient,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
@@ -44,6 +45,34 @@ type RuntimeMessage = {
const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role; const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;
const isTcpPortAvailable = (hostname: string, port: number) =>
new Promise<boolean>((resolve, reject) => {
const server = createServer();
server.unref();
server.once("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") {
resolve(false);
return;
}
reject(error);
});
server.listen({ host: hostname, port, exclusive: true }, () => {
server.close((error) => (error ? reject(error) : resolve(true)));
});
});
export const resolveEmbeddedOpencodePort = async (
hostname: string,
configuredPort: number,
) => {
if (configuredPort === 0) {
return 0;
}
return (await isTcpPortAvailable(hostname, configuredPort))
? configuredPort
: 0;
};
export class OpencodeRuntimeAdapter { export class OpencodeRuntimeAdapter {
private clientPromise: Promise<OpencodeClient> | null = null; private clientPromise: Promise<OpencodeClient> | null = null;
private closeServer: (() => void) | null = null; private closeServer: (() => void) | null = null;
@@ -64,6 +93,64 @@ export class OpencodeRuntimeAdapter {
return requireData(response.data, "global.health"); return requireData(response.data, "global.health");
} }
async warmup(): Promise<void> {
const client = await this.ensureClient();
const healthStartedAt = Date.now();
const healthResponse = await client.global.health();
const health = requireData(healthResponse.data, "global.health");
logDevelopmentDebug("opencode warmup health check completed", {
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
healthy: health.healthy,
version: health.version,
});
const sessionStartedAt = Date.now();
const sessionResponse = await client.session.create({
title: "tjwater-agent-warmup",
});
const session = requireData(sessionResponse.data, "session.create");
logDevelopmentDebug("opencode warmup session created", {
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
sessionId: session.id,
});
try {
const [provider, model] = config.OPENCODE_MODEL.split("/");
if (!provider || !model) {
throw new Error(
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
);
}
const toolsStartedAt = Date.now();
const toolsResponse = await client.tool.list({ provider, model });
const tools = requireData(toolsResponse.data, "tool.list");
logDevelopmentDebug("opencode warmup tools loaded", {
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
model: config.OPENCODE_MODEL,
sessionId: session.id,
toolCount: tools.length,
});
} finally {
const cleanupStartedAt = Date.now();
let cleanupSucceeded = true;
await client.session.delete(
{ sessionID: session.id },
{ throwOnError: true },
).catch((error) => {
cleanupSucceeded = false;
logger.warn(
{ err: error, sessionId: session.id },
"failed to remove opencode warmup session",
);
});
logDevelopmentDebug("opencode warmup session cleanup completed", {
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
sessionId: session.id,
succeeded: cleanupSucceeded,
});
}
}
async createSession(title?: string) { async createSession(title?: string) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.session.create({ const response = await client.session.create({
@@ -350,10 +437,24 @@ export class OpencodeRuntimeAdapter {
process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
""; "";
const port = await resolveEmbeddedOpencodePort(
config.OPENCODE_HOSTNAME,
config.OPENCODE_PORT,
);
if (port !== config.OPENCODE_PORT) {
logger.warn(
{
hostname: config.OPENCODE_HOSTNAME,
configuredPort: config.OPENCODE_PORT,
},
"configured opencode port is occupied; falling back to a dynamic port",
);
}
logger.info( logger.info(
{ {
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port,
model: config.OPENCODE_MODEL, model: config.OPENCODE_MODEL,
mode: config.OPENCODE_MODE, mode: config.OPENCODE_MODE,
}, },
@@ -365,7 +466,7 @@ export class OpencodeRuntimeAdapter {
try { try {
runtime = await createOpencode({ runtime = await createOpencode({
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, port,
timeout: config.OPENCODE_TIMEOUT_MS, timeout: config.OPENCODE_TIMEOUT_MS,
config: buildOpencodeConfig(), config: buildOpencodeConfig(),
}); });
@@ -382,7 +483,7 @@ export class OpencodeRuntimeAdapter {
{ {
elapsedMs: Math.max(0, Date.now() - startedAt), elapsedMs: Math.max(0, Date.now() - startedAt),
hostname: config.OPENCODE_HOSTNAME, hostname: config.OPENCODE_HOSTNAME,
port: config.OPENCODE_PORT, baseUrl: runtime.server.url,
mode: config.OPENCODE_MODE, mode: config.OPENCODE_MODE,
}, },
"opencode server started in embedded mode", "opencode server started in embedded mode",
+22 -12
View File
@@ -75,6 +75,8 @@ app.get("/health", async (_req, res) => {
const runtime = await opencodeRuntime.health(); const runtime = await opencodeRuntime.health();
res.json({ res.json({
ok: true, ok: true,
ready: true,
warmed_up: true,
runtime, runtime,
sessions: sessionBridge.count(), sessions: sessionBridge.count(),
}); });
@@ -82,6 +84,8 @@ app.get("/health", async (_req, res) => {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({ res.status(503).json({
ok: false, ok: false,
ready: false,
warmed_up: true,
message: "opencode runtime unavailable", message: "opencode runtime unavailable",
detail, detail,
sessions: sessionBridge.count(), sessions: sessionBridge.count(),
@@ -148,23 +152,12 @@ const bootstrap = async () => {
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
sessionTranscriptStore.initialize(), sessionTranscriptStore.initialize(),
]); ]);
resultReferenceStore.startCleanupLoop();
}; };
await bootstrap();
const server = app.listen(config.PORT, config.HOST, () => {
logger.info(
{ host: config.HOST, port: config.PORT },
"TJWaterAgent listening",
);
void warmupOpencodeRuntime();
});
const warmupOpencodeRuntime = async () => { const warmupOpencodeRuntime = async () => {
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
await opencodeRuntime.ensureClient(); await opencodeRuntime.warmup();
logger.info( logger.info(
{ {
elapsedMs: Math.max(0, Date.now() - startedAt), elapsedMs: Math.max(0, Date.now() - startedAt),
@@ -181,9 +174,26 @@ const warmupOpencodeRuntime = async () => {
}, },
"failed to warm up opencode runtime", "failed to warm up opencode runtime",
); );
throw error;
} }
}; };
await bootstrap();
await warmupOpencodeRuntime();
resultReferenceStore.startCleanupLoop();
const server = app.listen(config.PORT, config.HOST, () => {
logger.info(
{
host: config.HOST,
port: config.PORT,
ready: true,
warmedUp: true,
},
"TJWaterAgent listening",
);
});
const shutdown = async () => { const shutdown = async () => {
logger.info("shutting down TJWaterAgent"); logger.info("shutting down TJWaterAgent");
server.close(); server.close();
+6 -6
View File
@@ -8,8 +8,8 @@ import { frontendActionRegistry } from "../../src/frontendAction/registry.js";
import type { FrontendActionRequest } from "../../src/frontendAction/types.js"; import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
const items = [ const items = [
{ sensor_id: "MP01", level: "high" }, { device_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" }, { device_id: "MP02", level: "medium" },
] as const; ] as const;
const start = ( const start = (
@@ -119,10 +119,10 @@ describe("FrontendActionCoordinator", () => {
params: value, params: value,
}); });
expect(() => request({ items: [] })).toThrow(FrontendActionError); expect(() => request({ items: [] })).toThrow(FrontendActionError);
expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ sensor_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params"); expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ device_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params"); expect(() => request({ items: [{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ sensor_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params"); expect(() => request({ items: [{ device_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params");
expect(() => request({ items: [{ sensor_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params"); expect(() => request({ items: [{ device_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params");
}); });
it("keeps terminal results idempotent and rejects conflicts", async () => { it("keeps terminal results idempotent and rejects conflicts", async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ describe("appendBackendToolArtifact", () => {
const artifacts = appendBackendToolArtifact([], { const artifacts = appendBackendToolArtifact([], {
tool: "render_scada_analysis", tool: "render_scada_analysis",
reason: "显示已完成的分级结论", reason: "显示已完成的分级结论",
params: { items: [{ sensor_id: "MP01", level: "high" }] }, params: { items: [{ device_id: "MP01", level: "high" }] },
}) as Array<Record<string, unknown>>; }) as Array<Record<string, unknown>>;
expect(artifacts[0]).toMatchObject({ expect(artifacts[0]).toMatchObject({
+76 -1
View File
@@ -1,7 +1,12 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2"; import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { createServer } from "node:net";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { config } from "../../src/config.js";
import {
OpencodeRuntimeAdapter,
resolveEmbeddedOpencodePort,
} from "../../src/runtime/opencode.js";
const createRuntimeAdapter = ( const createRuntimeAdapter = (
messages: unknown[], messages: unknown[],
@@ -85,3 +90,73 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
expect(attempts).toBe(2); expect(attempts).toBe(2);
}); });
}); });
describe("resolveEmbeddedOpencodePort", () => {
it("falls back to a dynamic port when the configured port is occupied", async () => {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("expected a TCP server address");
}
try {
await expect(
resolveEmbeddedOpencodePort("127.0.0.1", address.port),
).resolves.toBe(0);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
});
describe("OpencodeRuntimeAdapter.warmup", () => {
it("initializes the project session and model tools before reporting ready", async () => {
const calls: string[] = [];
const client = {
global: {
health: async () => {
calls.push("health");
return { data: { healthy: true, version: "test" } };
},
},
session: {
create: async () => {
calls.push("session.create");
return { data: { id: "warmup-session" } };
},
delete: async ({ sessionID }: { sessionID: string }) => {
calls.push(`session.delete:${sessionID}`);
return { data: true };
},
},
tool: {
list: async (model: { provider: string; model: string }) => {
calls.push(`tool.list:${model.provider}/${model.model}`);
return { data: [] };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.warmup();
expect(calls).toEqual([
"health",
"session.create",
`tool.list:${config.OPENCODE_MODEL}`,
"session.delete:warmup-session",
]);
});
});
+30 -1
View File
@@ -1,4 +1,33 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, it, test } from "bun:test";
import { readFile } from "node:fs/promises";
describe("internal OpenCode permissions", () => {
it("keeps protected paths denied in every approval mode", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
permission?: Record<string, string | Record<string, string>>;
};
const permission = config.permission ?? {};
const bash = permission.bash as Record<string, string> | undefined;
const edit = permission.edit as Record<string, string> | undefined;
const read = permission.read as Record<string, string> | undefined;
expect(permission["*"]).toBe("ask");
expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow");
expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny");
expect(read?.["**/logs/**"]).toBe("deny");
expect(edit?.["*"]).toBe("ask");
expect(edit?.["data/**"]).toBe("deny");
expect(edit?.["**/logs/**"]).toBe("deny");
expect(bash?.["*"]).toBe("ask");
expect(bash?.["*.env*"]).toBe("deny");
expect(bash?.["*data/*"]).toBe("deny");
expect(bash?.["*logs/*"]).toBe("deny");
});
});
describe("OpenCode runtime config", () => { describe("OpenCode runtime config", () => {
test("continues the agent loop after a question or permission is denied", async () => { test("continues the agent loop after a question or permission is denied", async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ describe("toUiEnvelopeFromToolCall", () => {
it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => { it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => {
expect(toUiEnvelopeFromToolCall({ expect(toUiEnvelopeFromToolCall({
tool: "render_scada_analysis", tool: "render_scada_analysis",
params: { items: [{ sensor_id: "MP01", level: "high" }] }, params: { items: [{ device_id: "MP01", level: "high" }] },
})).toBeNull(); })).toBeNull();
expect(toUiEnvelopeFromToolCall({ expect(toUiEnvelopeFromToolCall({
tool: "clear_scada_analysis", tool: "clear_scada_analysis",