fix(agent): harden permissions and startup readiness

This commit is contained in:
2026-08-18 17:10:48 +08:00
parent e8a335d5ba
commit f0bf667d02
6 changed files with 207 additions and 20 deletions
+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.
+42 -6
View File
@@ -16,10 +16,36 @@
"port": 4096 "port": 4096
}, },
"permission": { "permission": {
"*": "allow", "*": "ask",
"external_directory": "ask", "external_directory": "deny",
"bash": { "read": {
"*": "allow", "*": "allow",
".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": {
"*": "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"
} }
+58
View File
@@ -64,6 +64,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({
+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();
+45
View File
@@ -1,6 +1,7 @@
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 { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
const createRuntimeAdapter = ( const createRuntimeAdapter = (
@@ -85,3 +86,47 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
expect(attempts).toBe(2); expect(attempts).toBe(2);
}); });
}); });
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 () => {