fix(agent): harden permissions and startup readiness
This commit is contained in:
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user