From 829eb4921917ca5c42d0d318da6c6df9f6839c59 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Tue, 3 Nov 2026 11:29:09 +0800 Subject: [PATCH 1/3] fix: record devin sessions in the user's session database --- docs/devin-contract.md | 4 +- plugins/devin/scripts/lib/devin-home.mjs | 91 ++++++++++++- .../devin/skills/devin-cli-runtime/SKILL.md | 2 +- tests/devin-home.test.mjs | 125 ++++++++++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/docs/devin-contract.md b/docs/devin-contract.md index bc93c94..90c4b37 100644 --- a/docs/devin-contract.md +++ b/docs/devin-contract.md @@ -15,11 +15,11 @@ The companion resolves the CLI as `devin` on `PATH`; `DEVIN_BIN` overrides the p The Devin CLI reads configuration, rules, skills, hooks, MCP servers, and plugins from the home directory and imports them from other tools: `~/.config/devin`, `~/.config/cognition/skills`, `~/.agents/skills`, `~/.claude`, `~/.claude.json`, `~/.codeium`, `~/.copilot`, `~/.config/opencode`, `~/.config/zed`, and personal plugins and MCP servers from the Devin Cloud account. The companion therefore runs every job with `HOME` and the XDG base directories pointing at directories it owns. - `HOME` is `/home`, shared by every job and otherwise empty, so no home scoped source exists. -- `XDG_DATA_HOME` is `/home/.local/share`, shared by every job. It holds the CLI's session database (`devin/cli/sessions.db`), logs, and session locks, which is what lets `session/load` resume a session in a later job. `devin/credentials.toml` inside it is a symbolic link to the real user's credentials file (`$XDG_DATA_HOME/devin/credentials.toml`, else `~/.local/share/devin/credentials.toml`). The companion never reads or copies the credential. +- `XDG_DATA_HOME` is `/home/.local/share`, shared by every job. `devin/cli/sessions.db` is a symbolic link to the real user's session database (`$XDG_DATA_HOME/devin/cli/sessions.db` when `XDG_DATA_HOME` is absolute, else `~/.local/share/devin/cli/sessions.db`); logs and session locks stay private. `devin/credentials.toml` is a symbolic link to the real user's credentials file (`$XDG_DATA_HOME/devin/credentials.toml` when `XDG_DATA_HOME` is absolute, else `~/.local/share/devin/credentials.toml`). The companion never reads or copies the credential. - `XDG_STATE_HOME` and `XDG_CACHE_HOME` are `/home/.local/state` and `/home/.cache`. - `XDG_CONFIG_HOME` is `/runs//config`, private to the job. It contains `devin/config.json`, generated per mode, and `devin/mcp_config.json` with an empty `mcpServers` object. -The generated `config.json` sets `auto_update` to `false`, `attribution` to `false`, `subagents_enabled` to `false`, `notify` to `"never"`, and `read_config_from` with `agents_standard` `true` and `cursor`, `windsurf`, `claude`, `copilot`, `opencode`, `zed`, and `cloud` all `false`. `permissions.ask` is empty. `permissions.deny` holds `Fetch(*)` and a `Read(/**)` rule for each sensitive directory of the real user's home: `.ssh`, `.gnupg`, `.aws`, `.azure`, `.kube`, `.docker`, `.config`, `.local/share`, `.claude`, `.codex`, `.grok`, `.cursor`, `.devin`, `Library/Keychains`, and `Library/Application Support`, plus `Read(/.netrc)`, `Read(/.npmrc)`, and `Read(/.git-credentials)`, the directory that holds the real credentials file (which differs when `XDG_DATA_HOME` is set), and the credentials link inside the private home. Consult and review add `edit` and `exec` to `permissions.deny`. Write mode sets `permissions.allow` to `["Write(/**)"]`, and for a linked worktree also the worktree's own Git directory; the repository's Git common directory stays read only. +The generated `config.json` sets `auto_update` to `false`, `attribution` to `false`, `subagents_enabled` to `false`, `notify` to `"never"`, and `read_config_from` with `agents_standard` `true` and `cursor`, `windsurf`, `claude`, `copilot`, `opencode`, `zed`, and `cloud` all `false`. `permissions.ask` is empty. `permissions.deny` holds `Fetch(*)` and a `Read(/**)` rule for each sensitive directory of the real user's home: `.ssh`, `.gnupg`, `.aws`, `.azure`, `.kube`, `.docker`, `.config`, `.local/share`, `.claude`, `.codex`, `.grok`, `.cursor`, `.devin`, `Library/Keychains`, and `Library/Application Support`, plus `Read(/.netrc)`, `Read(/.npmrc)`, and `Read(/.git-credentials)`, the directory that holds the real credentials file (which differs when `XDG_DATA_HOME` is set), and the credentials and sessions database links inside the private home. Consult and review add `edit` and `exec` to `permissions.deny`. Write mode sets `permissions.allow` to `["Write(/**)"]`, and for a linked worktree also the worktree's own Git directory; the repository's Git common directory stays read only. The child environment inherits only `PATH`, `LANG`, `LANGUAGE`, `TERM`, `COLORTERM`, `TMPDIR`, `TZ`, `USER`, `LOGNAME`, `SHELL`, every `LC_*` variable, `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` (and their lowercase forms), and the toolchain roots listed in the Cursor contract. `DEVIN_MODEL`, `DEVIN_REFUSAL_FALLBACK`, `DEVIN_PERMISSION_MODE`, and every other `DEVIN_*` variable are never inherited, because each would change the model or the permission posture outside the companion's control. The companion sets `RUST_LOG=error` and, in write mode, `DEVIN_SANDBOX=true`. diff --git a/plugins/devin/scripts/lib/devin-home.mjs b/plugins/devin/scripts/lib/devin-home.mjs index 970585f..5749e48 100644 --- a/plugins/devin/scripts/lib/devin-home.mjs +++ b/plugins/devin/scripts/lib/devin-home.mjs @@ -2,6 +2,71 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { withRecordLock } from "./record-lock.mjs"; +import { listAllJobRecords } from "./state.mjs"; +import { processGroupAlive, processIdentityMatches, processIsDirectlyAlive, validProcessIdentity } from "./process-identity.mjs"; + +const SESSION_TABLES = ["sessions", "message_nodes", "tool_call_state", "live_row_times", "subagent_heads", "prompt_history", "rendered_commits"]; +const ORDER_KEYS = { message_nodes: "row_id", prompt_history: "id", rendered_commits: "id" }; + +function jobMayBeLive(record) { + if (record.status !== "running") return false; + const processes = [[record.pid, record.pidIdentity], [record.devinPid, record.devinPidIdentity]] + .filter(([pid]) => Number.isInteger(pid) && pid > 1); + if (processes.some(([pid, identity]) => validProcessIdentity(identity) && processIdentityMatches(pid, identity) + || processIsDirectlyAlive(pid) || processGroupAlive(pid))) return true; + if (processes.length) return false; + const createdAt = Date.parse(record.createdAt); + return Number.isFinite(createdAt) && Date.now() - createdAt < 15000; +} + +function tableColumns(db, schema, table) { + const present = db.prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`).get(table); + return present ? db.prepare(`PRAGMA ${schema}.table_info("${table}")`).all().map((column) => column.name) : []; +} + +function mergeSessions(privateFile, realFile) { + const source = new DatabaseSync(privateFile, { timeout: 5000 }); + try { + const checkpoint = source.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get(); + if (checkpoint.busy) throw new Error("Private session database checkpoint is busy"); + if (!fs.existsSync(realFile)) { + source.prepare("VACUUM INTO ?").run(realFile); + return; + } + } finally { + source.close(); + } + const real = new DatabaseSync(realFile, { timeout: 5000 }); + try { + real.prepare("ATTACH DATABASE ? AS private_store").run(privateFile); + real.exec("BEGIN IMMEDIATE"); + try { + if (!tableColumns(real, "main", "sessions").includes("id") || !tableColumns(real, "private_store", "sessions").includes("id")) throw new Error("Missing sessions table"); + real.exec("CREATE TEMP TABLE missing_sessions AS SELECT p.id FROM private_store.sessions AS p WHERE NOT EXISTS (SELECT 1 FROM main.sessions AS r WHERE r.id = p.id)"); + for (const table of SESSION_TABLES) { + const sourceColumns = tableColumns(real, "private_store", table); + const targetColumns = tableColumns(real, "main", table); + if (!sourceColumns.length || !targetColumns.length) continue; + if (table !== "sessions" && (!sourceColumns.includes("session_id") || !targetColumns.includes("session_id"))) continue; + const key = ORDER_KEYS[table]; + const columns = sourceColumns.filter((column) => targetColumns.includes(column) && column !== key); + if (!columns.length) continue; + const quoted = columns.map((column) => `"${column.replaceAll('"', '""')}"`); + const joinColumn = table === "sessions" ? "id" : "session_id"; + const order = key && sourceColumns.includes(key) ? ` ORDER BY p."${key}"` : ""; + real.exec(`INSERT INTO main."${table}" (${quoted.join(", ")}) SELECT ${quoted.map((column) => `p.${column}`).join(", ")} FROM private_store."${table}" AS p JOIN missing_sessions AS m ON m.id = p."${joinColumn}"${order}`); + } + real.exec("COMMIT"); + } catch (error) { + real.exec("ROLLBACK"); + throw error; + } + } finally { + real.close(); + } +} export function realCredentialsPath({ env = process.env, realHome = os.homedir() }) { const dataHome = path.isAbsolute(env.XDG_DATA_HOME || "") ? env.XDG_DATA_HOME : path.join(realHome, ".local", "share"); @@ -26,6 +91,30 @@ export function ensurePrivateHome({ dataDir, env = process.env, realHome = os.ho const stats = fs.lstatSync(link, { throwIfNoEntry: false }); if (stats && (!stats.isSymbolicLink() || fs.readlinkSync(link) !== target)) fs.rmSync(link, { recursive: true, force: true }); if (!fs.lstatSync(link, { throwIfNoEntry: false })) fs.symlinkSync(target, link); + const privateCli = path.join(homeDir, ".local", "share", "devin", "cli"); + const privateSessions = path.join(privateCli, "sessions.db"); + const realCli = path.join(path.dirname(target), "cli"); + const realSessions = path.join(realCli, "sessions.db"); + fs.mkdirSync(privateCli, { recursive: true, mode: 0o700 }); + fs.chmodSync(privateCli, 0o700); + fs.mkdirSync(realCli, { recursive: true }); + try { + withRecordLock(path.join(dataDir, ".devin-session-migration.json"), () => { + const existing = fs.lstatSync(privateSessions, { throwIfNoEntry: false }); + if (existing?.isFile()) { + if (listAllJobRecords(dataDir).some(({ record }) => jobMayBeLive(record))) return; + try { + mergeSessions(privateSessions, realSessions); + } catch { + return; + } + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(`${privateSessions}${suffix}`, { force: true }); + } else if (existing && (!existing.isSymbolicLink() || fs.readlinkSync(privateSessions) !== realSessions)) { + fs.rmSync(privateSessions, { recursive: true, force: true }); + } + if (!fs.lstatSync(privateSessions, { throwIfNoEntry: false })) fs.symlinkSync(realSessions, privateSessions); + }); + } catch {} return { homeDir }; } @@ -45,7 +134,7 @@ export function buildJobConfig({ configDir, mode, cwd, realHome = os.homedir(), ...sensitiveDirs.map((directory) => `Read(${path.join(realHome, directory)}/**)`), ...[".netrc", ".npmrc", ".git-credentials"].map((file) => `Read(${path.join(realHome, file)})`), `Read(${path.dirname(realCredentialsPath({ env, realHome }))}/**)`, - ...(homeDir ? [`Read(${path.join(homeDir, ".local", "share", "devin", "credentials.toml")})`] : []) + ...(homeDir ? ["credentials.toml", "cli/sessions.db"].map((file) => `Read(${path.join(homeDir, ".local", "share", "devin", file)})`) : []) ]; if (mode !== "write") deny.push("edit", "exec"); const permissions = { ask: [], deny }; diff --git a/plugins/devin/skills/devin-cli-runtime/SKILL.md b/plugins/devin/skills/devin-cli-runtime/SKILL.md index d3fc779..2f9c4dc 100644 --- a/plugins/devin/skills/devin-cli-runtime/SKILL.md +++ b/plugins/devin/skills/devin-cli-runtime/SKILL.md @@ -33,7 +33,7 @@ Execution rules: - Every run executes `devin acp` with the workspace as its working directory. The companion sends JSON-RPC `initialize`, `session/new` or `session/load`, the supported `session/set_config_option` requests, then `session/prompt`. The brief appears only in that prompt message. The companion never passes `--cloud`, `--agent-type`, `--model`, or `--refusal-fallback`. Permission requests receive `cancelled`. - Consult is the default mode, `--write` selects write mode, and `review` is always read only. Consult and review set ACP mode `ask` and allow `read`, `grep`, `glob`, `find_file_by_name`, `notebook_read`, `todo_write`, and `web_search`, which runs on Devin's backend. Write sets `autonomous` and additionally allows `edit`, `write`, `notebook_edit`, `exec`, `get_output`, `kill_shell`, and `write_to_process`. A `tool_call` outside the mode list terminates the process group and fails as `policy`. - Consult and review deny edit and exec in the generated configuration. Write sets `DEVIN_SANDBOX=true` and allows `Write(/**)` plus the linked worktree Git directory. The OS sandbox confines shell writes to these scopes and fails closed when it cannot start. Sensitive real home paths and `Fetch(*)` are denied in every mode; other home reads may remain possible. -- Every job uses shared private `HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` under `/home`, and private `XDG_CONFIG_HOME` at `/runs//config`. The shared data directory holds the CLI session database and a symbolic link to the real user credentials file; the companion never reads or copies the credential. Generated `devin/config.json` disables auto update, attribution, subagents, notifications, and personal imports, and `devin/mcp_config.json` has empty `mcpServers`. The child environment never inherits `DEVIN_*` variables. +- Every job uses shared private `HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` under `/home`, and private `XDG_CONFIG_HOME` at `/runs//config`. The shared data directory links `devin/cli/sessions.db` to the real user's session database and `devin/credentials.toml` to the real user's credentials file; logs and session locks stay private. The companion never reads or copies the credential. Generated `devin/config.json` disables auto update, attribution, subagents, notifications, and personal imports, and `devin/mcp_config.json` has empty `mcpServers`. The child environment never inherits `DEVIN_*` variables. - Project scoped `AGENTS.md`, `AGENTS.local.md`, `AGENT.md`, `.windsurfrules`, and project skills under `.devin/skills`, `.cognition/skills`, and `.agents/skills` still load. Project `.devin/config.json`, `.devin/config.local.json`, `.devin/mcp_config.json`, and `.devin/mcp_config.local.json` may add permissions, hooks, MCP servers, or imports; the companion warns when they exist. Organization settings still apply. Prompts, workspace content, and results pass through Devin's backend to the serving model vendor, so never delegate secrets or credentials to this lane. - Fusion routed dispatches always carry `--model` and `--effort` from the routing table. A direct user call without `--model` uses the account default. `models` reads `devin models list --format json`, caches the family catalog for one hour, and `--refresh` reads it again. - Models are family slugs such as `kimi-k3`, `glm-5.2`, `muse-wonder`, and `swe-2`, their aliases, or variant uids. The companion matches the requested family to a usable session model option and records that option as `resolvedModel`; the option keeps the family's base variant uid whatever effort applies. A missing catalog family fails before job creation as `input`; a family unavailable to the account fails as `setup`. Effort is the selected model's `thought_level`: `default` leaves its default, a supported level is set explicitly, and an unsupported level fails as `input`. The level in effect when the prompt is sent is recorded as `resolvedEffort`. `speed` is set to `standard` when offered. `adaptive` and `fusion-*` pairings are refused as `input`. diff --git a/tests/devin-home.test.mjs b/tests/devin-home.test.mjs index cf49f8a..1622b04 100644 --- a/tests/devin-home.test.mjs +++ b/tests/devin-home.test.mjs @@ -3,9 +3,11 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import test from "node:test"; import { buildJobConfig, ensurePrivateHome, projectConfigFiles, realCredentialsPath, removeJobConfig, worktreeGitDirs } from "../plugins/devin/scripts/lib/devin-home.mjs"; +import { createJobRecord, jobFilePath } from "../plugins/devin/scripts/lib/state.mjs"; function fixture(t) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "devin-home-test-")); @@ -29,10 +31,14 @@ test("private home creates only its XDG directories and repairs credentials link const target = path.join(root, "external", "devin", "credentials.toml"); const homeDir = path.join(dataDir, "home"); const link = path.join(homeDir, ".local", "share", "devin", "credentials.toml"); + const sessionLink = path.join(homeDir, ".local", "share", "devin", "cli", "sessions.db"); + const sessionTarget = path.join(root, "external", "devin", "cli", "sessions.db"); const args = { dataDir, env: { XDG_DATA_HOME: path.join(root, "external") }, realHome }; assert.deepEqual(ensurePrivateHome(args), { homeDir }); assert.equal(fs.lstatSync(link).isSymbolicLink(), true); assert.equal(fs.readlinkSync(link), target); + assert.equal(fs.readlinkSync(sessionLink), sessionTarget); + assert.equal(fs.statSync(path.dirname(sessionTarget)).isDirectory(), true); for (const directory of [homeDir, path.join(homeDir, ".local"), path.join(homeDir, ".local", "share"), path.join(homeDir, ".local", "share", "devin"), path.join(homeDir, ".local", "state"), path.join(homeDir, ".cache")]) { assert.equal(fs.statSync(directory).mode & 0o777, 0o700); } @@ -45,6 +51,124 @@ test("private home creates only its XDG directories and repairs credentials link fs.writeFileSync(link, "wrong credential"); ensurePrivateHome(args); assert.equal(fs.readlinkSync(link), target); + fs.rmSync(sessionLink); + fs.symlinkSync(path.join(root, "wrong-session"), sessionLink); + ensurePrivateHome(args); + assert.equal(fs.readlinkSync(sessionLink), sessionTarget); + fs.rmSync(sessionLink); + fs.mkdirSync(sessionLink); + ensurePrivateHome(args); + assert.equal(fs.readlinkSync(sessionLink), sessionTarget); +}); + +function sessionFixture(t) { + const root = fixture(t); + const dataDir = path.join(root, "data"); + const realHome = path.join(root, "real"); + const privateFile = path.join(dataDir, "home", ".local", "share", "devin", "cli", "sessions.db"); + const realFile = path.join(realHome, ".local", "share", "devin", "cli", "sessions.db"); + const args = { dataDir, realHome, env: {} }; + fs.mkdirSync(path.dirname(privateFile), { recursive: true }); + return { dataDir, privateFile, realFile, args }; +} + +function createSessionDb(file, { older = false } = {}) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec(` + CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT${older ? "" : ", newer TEXT"}); + CREATE TABLE message_nodes (row_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, node_id TEXT, body TEXT${older ? "" : ", newer TEXT"}, UNIQUE(session_id, node_id)); + CREATE TABLE prompt_history (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, prompt TEXT); + CREATE TABLE rendered_commits (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, body TEXT); + ${older ? "" : "CREATE TABLE live_row_times (session_id TEXT, value TEXT);"} + `); + return db; +} + +function rows(db, sql) { + return db.prepare(sql).all().map((row) => ({ ...row })); +} + +test("private session database moves into a missing real database", (t) => { + const { privateFile, realFile, args } = sessionFixture(t); + const db = createSessionDb(privateFile); + db.exec("INSERT INTO sessions VALUES ('first', 'title', 'new'); INSERT INTO message_nodes (session_id, node_id, body, newer) VALUES ('first', 'node', 'body', 'new')"); + db.close(); + ensurePrivateHome(args); + assert.equal(fs.lstatSync(privateFile).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(privateFile), realFile); + const real = new DatabaseSync(realFile); + assert.deepEqual(rows(real, "SELECT id, title, newer FROM sessions"), [{ id: "first", title: "title", newer: "new" }]); + assert.deepEqual(rows(real, "SELECT session_id, node_id, body FROM message_nodes"), [{ session_id: "first", node_id: "node", body: "body" }]); + real.close(); + for (const suffix of ["-wal", "-shm"]) assert.equal(fs.existsSync(`${privateFile}${suffix}`), false); +}); + +test("private sessions merge into older real schema by new session and common columns", (t) => { + const { privateFile, realFile, args } = sessionFixture(t); + const real = createSessionDb(realFile, { older: true }); + real.exec("INSERT INTO sessions VALUES ('existing', 'real'); INSERT INTO message_nodes (session_id, node_id, body) VALUES ('existing', 'same', 'real body')"); + real.close(); + const privateDb = createSessionDb(privateFile); + privateDb.exec(` + INSERT INTO sessions VALUES ('existing', 'private', 'new'); + INSERT INTO sessions VALUES ('fresh', 'private fresh', 'new'); + INSERT INTO message_nodes (session_id, node_id, body, newer) VALUES ('existing', 'same', 'private body', 'new'); + INSERT INTO message_nodes (session_id, node_id, body, newer) VALUES ('fresh', 'second', 'two', 'new'); + INSERT INTO message_nodes (session_id, node_id, body, newer) VALUES ('fresh', 'first', 'one', 'new'); + INSERT INTO prompt_history (session_id, prompt) VALUES ('fresh', 'prompt two'), ('fresh', 'prompt one'); + INSERT INTO rendered_commits (session_id, body) VALUES ('fresh', 'commit'); + INSERT INTO live_row_times VALUES ('fresh', 'time'); + `); + privateDb.close(); + ensurePrivateHome(args); + assert.equal(fs.readlinkSync(privateFile), realFile); + const merged = new DatabaseSync(realFile); + assert.deepEqual(rows(merged, "SELECT id, title FROM sessions ORDER BY id"), [{ id: "existing", title: "real" }, { id: "fresh", title: "private fresh" }]); + assert.deepEqual(rows(merged, "SELECT session_id, node_id, body FROM message_nodes ORDER BY row_id"), [ + { session_id: "existing", node_id: "same", body: "real body" }, + { session_id: "fresh", node_id: "second", body: "two" }, + { session_id: "fresh", node_id: "first", body: "one" } + ]); + assert.deepEqual(rows(merged, "SELECT prompt FROM prompt_history ORDER BY id"), [{ prompt: "prompt two" }, { prompt: "prompt one" }]); + assert.deepEqual(rows(merged, "SELECT body FROM rendered_commits"), [{ body: "commit" }]); + assert.equal(merged.prepare("SELECT 1 FROM sqlite_master WHERE name = 'live_row_times'").get(), undefined); + merged.close(); + for (const suffix of ["-wal", "-shm"]) assert.equal(fs.existsSync(`${privateFile}${suffix}`), false); +}); + +test("failed session merge keeps the private store for retry", (t) => { + const { privateFile, realFile, args } = sessionFixture(t); + const privateDb = createSessionDb(privateFile); + privateDb.exec("INSERT INTO sessions VALUES ('fresh', 'private', 'new')"); + privateDb.close(); + fs.mkdirSync(path.dirname(realFile), { recursive: true }); + const real = new DatabaseSync(realFile); + real.exec("CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, required TEXT NOT NULL)"); + real.close(); + ensurePrivateHome(args); + assert.equal(fs.lstatSync(privateFile).isFile(), true); + const retry = new DatabaseSync(privateFile); + assert.deepEqual(rows(retry, "SELECT id FROM sessions"), [{ id: "fresh" }]); + retry.close(); + const unchanged = new DatabaseSync(realFile); + assert.equal(unchanged.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, 0); + unchanged.close(); +}); + +test("live companion job defers private session migration", (t) => { + const { dataDir, privateFile, realFile, args } = sessionFixture(t); + const db = createSessionDb(privateFile); + db.exec("INSERT INTO sessions VALUES ('fresh', 'private', 'new')"); + db.close(); + const cwd = path.join(path.dirname(dataDir), "workspace"); + const id = "other-job"; + const file = jobFilePath(dataDir, cwd, id); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(createJobRecord({ id, cwd, mode: "consult", pid: process.pid }))); + ensurePrivateHome(args); + assert.equal(fs.lstatSync(privateFile).isFile(), true); + assert.equal(fs.existsSync(realFile), false); }); test("job config contains the exact base policy and consult or review tool denies", (t) => { @@ -132,4 +256,5 @@ test("job config denies the resolved credential directory and its private home l const { deny } = JSON.parse(fs.readFileSync(path.join(configDir, "devin", "config.json"), "utf8")).permissions; assert.ok(deny.includes(`Read(${path.join(xdg, "devin")}/**)`)); assert.ok(deny.includes(`Read(${path.join(homeDir, ".local", "share", "devin", "credentials.toml")})`)); + assert.ok(deny.includes(`Read(${path.join(homeDir, ".local", "share", "devin", "cli", "sessions.db")})`)); }); From eb0b9d040c9a6c10f8d0c892199c24d7cf712e7f Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Tue, 3 Nov 2026 11:29:09 +0800 Subject: [PATCH 2/3] chore: release 0.0.70 --- .claude-plugin/marketplace.json | 12 ++++++------ CHANGELOG.md | 6 ++++++ plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/cursor/.claude-plugin/plugin.json | 2 +- plugins/devin/.claude-plugin/plugin.json | 2 +- plugins/fusion/.claude-plugin/plugin.json | 2 +- plugins/grok/.claude-plugin/plugin.json | 2 +- 7 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6b3b7de..62060ca 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ "email": "hi@okis.dev" }, "description": "Multi-model orchestration marketplace for Claude Code.", - "version": "0.0.69", + "version": "0.0.70", "plugins": [ { "name": "grok", "source": "./plugins/grok", "displayName": "Grok Companion", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", - "version": "0.0.69", + "version": "0.0.70", "author": { "name": "Harry Yep" }, @@ -34,7 +34,7 @@ "source": "./plugins/codex", "displayName": "Codex Companion", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", - "version": "0.0.69", + "version": "0.0.70", "author": { "name": "Harry Yep" }, @@ -55,7 +55,7 @@ "source": "./plugins/cursor", "displayName": "Cursor Companion", "description": "Local Cursor Agent CLI delegation: multi family consults, Composer implementation in owned worktrees, review, resumable history, background jobs, and setup health checks.", - "version": "0.0.69", + "version": "0.0.70", "author": { "name": "Harry Yep" }, @@ -76,7 +76,7 @@ "source": "./plugins/devin", "displayName": "Devin Companion", "description": "Local Devin CLI delegation: multi family consults, SWE implementation in owned worktrees, review, resumable history, background jobs, and setup health checks.", - "version": "0.0.69", + "version": "0.0.70", "author": { "name": "Harry Yep" }, @@ -97,7 +97,7 @@ "source": "./plugins/fusion", "displayName": "Fusion Orchestrator", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", - "version": "0.0.69", + "version": "0.0.70", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 476895d..0b15634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # changelog +## 0.0.70 + +- devin jobs record their sessions in the user's own devin session database. the private home held its own `sessions.db`, so every routed devin job was invisible to usage readers and to `devin list`; on two hosts the real database had no session since the engine joined while the private one held 14 and 2. the private home now links `devin/cli/sessions.db` to the real one, as it links the credentials file, and keeps logs and session locks private. +- the first companion call after the upgrade merges the private session database into the real one under a lock, copying only sessions the real database lacks and only the tables and columns both share, then replaces it with the link. a merge that fails or a devin job still running leaves the private store in place and retries on a later call. +- the job configuration also denies agent reads of the session database link. + ## 0.0.69 - this release acts on the first day of devin call records on one host: 2 routed jobs, one accepted consult and one panel track lost after 6 minutes of work. diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index c7a9bdd..77fadd7 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "codex", "displayName": "Codex Companion", - "version": "0.0.69", + "version": "0.0.70", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/cursor/.claude-plugin/plugin.json b/plugins/cursor/.claude-plugin/plugin.json index 076ae5b..345f28d 100644 --- a/plugins/cursor/.claude-plugin/plugin.json +++ b/plugins/cursor/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "cursor", "displayName": "Cursor Companion", - "version": "0.0.69", + "version": "0.0.70", "description": "Local Cursor Agent CLI delegation: multi family consults, Composer implementation in owned worktrees, review, resumable history, background jobs, and setup health checks.", "author": { "name": "Harry Yep" diff --git a/plugins/devin/.claude-plugin/plugin.json b/plugins/devin/.claude-plugin/plugin.json index 8c30de0..0dc6f60 100644 --- a/plugins/devin/.claude-plugin/plugin.json +++ b/plugins/devin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "devin", "displayName": "Devin Companion", - "version": "0.0.69", + "version": "0.0.70", "description": "Local Devin CLI delegation: multi family consults, SWE implementation in owned worktrees, review, resumable history, background jobs, and setup health checks.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index 3d36e0d..52c3b01 100644 --- a/plugins/fusion/.claude-plugin/plugin.json +++ b/plugins/fusion/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "fusion", "displayName": "Fusion Orchestrator", - "version": "0.0.69", + "version": "0.0.70", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", "author": { "name": "Harry Yep" diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index d1c4282..a7659a2 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "grok", "displayName": "Grok Companion", - "version": "0.0.69", + "version": "0.0.70", "description": "Local Grok CLI delegation: task, review, resumable history, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep" From d9f78158783885e9e18c51a2e4a9b6742a9ec86d Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Tue, 3 Nov 2026 11:31:28 +0800 Subject: [PATCH 3/3] fix: load node:sqlite only when merging devin sessions --- plugins/devin/scripts/lib/devin-home.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/devin/scripts/lib/devin-home.mjs b/plugins/devin/scripts/lib/devin-home.mjs index 5749e48..36cbdd0 100644 --- a/plugins/devin/scripts/lib/devin-home.mjs +++ b/plugins/devin/scripts/lib/devin-home.mjs @@ -1,8 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { withRecordLock } from "./record-lock.mjs"; import { listAllJobRecords } from "./state.mjs"; import { processGroupAlive, processIdentityMatches, processIsDirectlyAlive, validProcessIdentity } from "./process-identity.mjs"; @@ -27,6 +27,7 @@ function tableColumns(db, schema, table) { } function mergeSessions(privateFile, realFile) { + const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite"); const source = new DatabaseSync(privateFile, { timeout: 5000 }); try { const checkpoint = source.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();