import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { bindScopedConversationWorkspace, createConversationWorkspace } from "../../src/runtime/conversationWorkspace.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js"; import { startOpencode2 } from "../../src/runtime/opencode2-server.js"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { config } from "../../src/config.js"; import { isSessionNotFoundError } from "@opencode/client"; // Native permissions only; uses temporary data and no model inference. const root = await mkdtemp(join(tmpdir(), "tjwater-workspace-smoke-")); const workspaceRoot = join(root, "data", "session-workspaces"); const previousWorkspaceRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = workspaceRoot; let directory = await createConversationWorkspace(workspaceRoot); let other = await createConversationWorkspace(workspaceRoot); await mkdir(join(root, "data", "session-metadata")); let native: Awaited> | undefined; let sessionID: string | undefined; let otherSessionID: string | undefined; try { native = await startOpencode2({ command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000, config: serviceOpencodeConfig(JSON.parse(await readFile("opencode.json", "utf8")), undefined, workspaceRoot), env: { AGENT_INTERNAL_TOKEN: randomBytes(32).toString("hex"), TJWATER_AGENT_WORKSPACE_ROOT: workspaceRoot, WATERNETWORK_API_KEY: "WORKSPACE_TEST_ONLY", XDG_DATA_HOME: join(root, "native-data"), OPENCODE_DB: join(root, "native-data", "opencode", "opencode.db"), XDG_CONFIG_HOME: join(root, "native-config"), XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") }, }); const session = await native.client.session.create({ agent: "instruction", location: { directory } }); sessionID = session.id; const owner = { actorKey: "actor-smoke", projectKey: "project-smoke", sessionId: session.id }; directory = await bindScopedConversationWorkspace(directory, workspaceRoot, owner); await new SessionWorkspaceStore(workspaceRoot).ensure(owner); await native.client.session.move({ sessionID, directory }); const otherSession = await native.client.session.create({ agent: "instruction", location: { directory: other } }); otherSessionID = otherSession.id; other = await bindScopedConversationWorkspace(other, workspaceRoot, { actorKey: "actor-other", projectKey: "personal-actor-other", sessionId: otherSession.id }); await native.client.session.move({ sessionID: otherSessionID, directory: other }); await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } }); const decision = async (action: string, resource: string) => (await native!.client.permission.create({ sessionID: session.id, action, resources: [resource], save: [] })).effect; const file = join(directory, "files", "result.json"); await writeFile(file, '{"value":7}'); assert.equal(await decision("read", file), "allow", "own workspace results must be readable under data/"); assert.equal(await decision("edit", file), "ask", "own workspace writes must require approval"); assert.equal(await decision("read", join(root, "data", "session-metadata", "private.json")), "deny"); assert.equal(await decision("read", join(other, "files", "result.json")), "deny"); assert.equal(await decision("read", join(directory, "files", ".local.env")), "deny"); assert.equal(await decision("read", join(directory, "..", "chat", "owner.json")), "deny", "chat records must stay outside the model workspace"); assert.equal(await decision("edit", join(directory, "..", "chat", "messages.json")), "deny"); await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory: other } }); await native.client.session.move({ sessionID, directory: other }); assert.equal(await decision("read", join(other, "files", "result.json")), "deny", "a moved session cannot impersonate the owner of another workspace"); await native.client.session.move({ sessionID, directory }); const runtime = Object.assign(new OpencodeRuntimeAdapter(), { ensureClient: async () => native!.client }); await runtime.removeSession(sessionID, owner); await assert.rejects(native.client.session.get({ sessionID }), isSessionNotFoundError); await assert.rejects(lstat(directory), { code: "ENOENT" }); assert.equal((await native.client.session.get({ sessionID: otherSessionID })).id, otherSessionID); assert.equal((await lstat(other)).isDirectory(), true); assert.equal(JSON.parse(await readFile(join(directory, "..", "chat", "owner.json"), "utf8")).sessionId, sessionID); await runtime.removeSession(sessionID, owner); console.log("Native workspace permissions and session cleanup passed; other sessions and host chat records preserved. No inference requests."); } finally { if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {}); if (native && otherSessionID) await native.client.session.remove({ sessionID: otherSessionID }).catch(() => {}); native?.close(); if (native) await native.exited; config.CONVERSATION_WORKSPACE_ROOT = previousWorkspaceRoot; await rm(root, { recursive: true, force: true }); }