diff --git a/AGENTS.md b/AGENTS.md index 65d42e6..4a0143f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, 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. diff --git a/opencode.json b/opencode.json index 7f024a5..c041b6a 100644 --- a/opencode.json +++ b/opencode.json @@ -16,10 +16,36 @@ "port": 4096 }, "permission": { - "*": "allow", - "external_directory": "ask", - "bash": { + "*": "ask", + "external_directory": "deny", + "read": { "*": "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", "rmdir *": "ask", "mv *": "ask", @@ -27,10 +53,20 @@ "chown *": "ask", "sudo *": "ask", "curl *": "ask", - "wget *": "ask" + "wget *": "ask", + "*.env*": "deny", + "*data/*": "deny", + "* data": "deny", + "*/data": "deny", + "*logs/*": "deny", + "* logs": "deny", + "*/logs": "deny" }, - "edit": "ask", - "task": "deny" + "question": "allow", + "task": "deny", + "todo": "allow", + "todoread": "allow", + "todowrite": "allow" }, "default_agent": "instruction" } diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index e8f7dc1..c2db38e 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -64,6 +64,64 @@ export class OpencodeRuntimeAdapter { return requireData(response.data, "global.health"); } + async warmup(): Promise { + 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) { const client = await this.ensureClient(); const response = await client.session.create({ diff --git a/src/server.ts b/src/server.ts index a5c5305..bf406ec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -75,6 +75,8 @@ app.get("/health", async (_req, res) => { const runtime = await opencodeRuntime.health(); res.json({ ok: true, + ready: true, + warmed_up: true, runtime, sessions: sessionBridge.count(), }); @@ -82,6 +84,8 @@ app.get("/health", async (_req, res) => { const detail = error instanceof Error ? error.message : String(error); res.status(503).json({ ok: false, + ready: false, + warmed_up: true, message: "opencode runtime unavailable", detail, sessions: sessionBridge.count(), @@ -148,23 +152,12 @@ const bootstrap = async () => { resultReferenceStore.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 startedAt = Date.now(); try { - await opencodeRuntime.ensureClient(); + await opencodeRuntime.warmup(); logger.info( { elapsedMs: Math.max(0, Date.now() - startedAt), @@ -181,9 +174,26 @@ const warmupOpencodeRuntime = async () => { }, "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 () => { logger.info("shutting down TJWaterAgent"); server.close(); diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index 60e75a3..7fb4548 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { config } from "../../src/config.js"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; const createRuntimeAdapter = ( @@ -85,3 +86,47 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { 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", + ]); + }); +}); diff --git a/tests/runtime/opencodeConfig.test.ts b/tests/runtime/opencodeConfig.test.ts index 7debf74..c063ec4 100644 --- a/tests/runtime/opencodeConfig.test.ts +++ b/tests/runtime/opencodeConfig.test.ts @@ -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>; + }; + const permission = config.permission ?? {}; + const bash = permission.bash as Record | undefined; + const edit = permission.edit as Record | undefined; + const read = permission.read as Record | 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", () => { test("continues the agent loop after a question or permission is denied", async () => {