From 22b3464822e64cd5351c2e3ca5084be1118a3ede Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:37:08 +0000 Subject: [PATCH] Read app-managed config for a directory from the state dir One shared daemon serves many apps, so an app can no longer hand "its" engine a private config through REDROB_CONFIG: env is per process and the process is shared. The engine now also reads /directories//redrob.json for every instance on that directory, merged after the global config and before the project's own files, exactly where REDROB_CONFIG merged. It lives outside the project tree so it never collides with the user's .redrob/redrob.json and never shows up in git. ConfigPaths.directoryKey/directoryConfigDir compute the path in the engine; directoryKey/directoryConfigDir in @redrob-labs/sdk/daemon compute it in an app, with a test pinning the two to the same answer. Anything a plugin used to read from the spawning app's env goes in the plugin's options in that file. --- docs/LOCAL-ENGINE-API.md | 50 ++++++++++++++-------- packages/redrob/src/config/config.ts | 9 ++++ packages/redrob/src/config/paths.ts | 24 +++++++++++ packages/redrob/test/config/config.test.ts | 38 ++++++++++++++++ packages/redrob/test/daemon.test.ts | 14 ++++++ packages/sdk/js/src/daemon.ts | 32 +++++++++++++- 6 files changed, 148 insertions(+), 19 deletions(-) diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index ea8b11d224..e685bd0104 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -54,16 +54,16 @@ provider the registry resolves, and callers see no difference. An OpenAI chat-completions subset: -| field | required | notes | -| --- | --- | --- | -| `model` | yes | `redrob/auto` routes through the Console; a local provider id targets a local model | -| `messages` | yes | `system` / `user` / `assistant` / `tool` roles | -| `tools` | no | **presence changes who executes them — see below** | -| `tool_choice` | no | `auto` \| `none` \| a named function | -| `stream` | no | default `false`; `true` returns SSE | -| `max_tokens` | no | | -| `temperature` | no | ignored for models that fix their own sampling | -| `reasoning_effort` | no | passed through where the provider accepts it | +| field | required | notes | +| ------------------ | -------- | ----------------------------------------------------------------------------------- | +| `model` | yes | `redrob/auto` routes through the Console; a local provider id targets a local model | +| `messages` | yes | `system` / `user` / `assistant` / `tool` roles | +| `tools` | no | **presence changes who executes them — see below** | +| `tool_choice` | no | `auto` \| `none` \| a named function | +| `stream` | no | default `false`; `true` returns SSE | +| `max_tokens` | no | | +| `temperature` | no | ignored for models that fix their own sampling | +| `reasoning_effort` | no | passed through where the provider accepts it | Unknown fields are ignored rather than rejected, so a newer client can talk to an older engine without a version check. @@ -180,6 +180,20 @@ engine of its own, so ACP hosts (Design, Cut, CAD, Browser) share the one proces Per-product processes were considered and rejected: with local inference, N processes means the same weights loaded N times, which defeats the reason for doing this. +One daemon also means an app can no longer hand "its" engine a private config through the +environment (`REDROB_CONFIG`, `REDROB_SERVER_URL`, …): env is per process, and the process is +shared. What an app needs to tell the engine about one directory goes in a file the engine reads +for that directory: `/directories//redrob.json` (`` is `$XDG_STATE_HOME/redrob`, +else `~/.local/state/redrob`; `` is the SHA-256 of the absolute, normalised directory path, +lower-cased on Windows). `directoryConfigDir(dir)` in `@redrob-labs/sdk/daemon` and +`ConfigPaths.directoryConfigDir` in the engine compute it. It merges after the global config and +before the project's own `redrob.json`, exactly where `REDROB_CONFIG` merged, so the user's files +still win; it lives outside the project tree so it never collides with them or shows up in git. +After writing it the app calls `POST /instance/dispose` for the directory and the next request +rebuilds the instance on the new file. Anything a plugin used to read from the spawning app's env +belongs in this file too (for example under a key the plugin owns), because the daemon's env is +nobody's. + That file is the auth boundary — it carries the Basic secret. It is per-user by location and must stay `0600`; a multi-user machine gets one daemon per user, never a shared one. @@ -211,14 +225,14 @@ run `redrob upgrade` instead of failing with a transport error. ## 4. Migration -| product | change | -| --- | --- | -| redrob-browser | point `sidecar_client.cc:592` at `/v1/chat/completions`; its host-owned tool loop already matches the contract | -| redrob-office | drop the bundled binary and the packaging guard; point the engine route at the local daemon; keep its editor-owned tool loop | -| redrob-cowork | drop the download-and-stage step and the duplicate alias copy; keep driving the session API for agent work | -| redrob-shape | either pass the local base URL and allow loopback HTTP in its sidecar, or delete the LOCAL transport that cannot work | -| redrob-canvas, redrob-query, redrob-recall | optional: switching from the Console to the local daemon buys them one shared login | -| redrob-extension, redrob-cad | unchanged; they want engine-owned tools and the session API already serves that | +| product | change | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| redrob-browser | point `sidecar_client.cc:592` at `/v1/chat/completions`; its host-owned tool loop already matches the contract | +| redrob-office | drop the bundled binary and the packaging guard; point the engine route at the local daemon; keep its editor-owned tool loop | +| redrob-cowork | drop the download-and-stage step and the duplicate alias copy; keep driving the session API for agent work | +| redrob-shape | either pass the local base URL and allow loopback HTTP in its sidecar, or delete the LOCAL transport that cannot work | +| redrob-canvas, redrob-query, redrob-recall | optional: switching from the Console to the local daemon buys them one shared login | +| redrob-extension, redrob-cad | unchanged; they want engine-owned tools and the session API already serves that | ## Open items diff --git a/packages/redrob/src/config/config.ts b/packages/redrob/src/config/config.ts index 674896fec0..dcdcc78edd 100644 --- a/packages/redrob/src/config/config.ts +++ b/packages/redrob/src/config/config.ts @@ -397,6 +397,15 @@ const layer = Layer.effect( yield* Effect.logDebug("loaded custom config", { path: Flag.REDROB_CONFIG }) } + // App-managed config for this directory (see ConfigPaths.directoryConfigDir). Same + // position as REDROB_CONFIG, so the project's own files below still win. + for (const file of ConfigPaths.fileInDirectory(ConfigPaths.directoryConfigDir(ctx.directory), "redrob")) { + const next = yield* loadFile(file, authEnv) + if (Object.keys(next).length === 0) continue + yield* merge(file, next, "local") + yield* Effect.logDebug("loaded directory config", { path: file }) + } + if (!Flag.REDROB_DISABLE_PROJECT_CONFIG) { for (const file of yield* ConfigPaths.files("redrob", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge(file, yield* loadFile(file, authEnv), "local") diff --git a/packages/redrob/src/config/paths.ts b/packages/redrob/src/config/paths.ts index b4055290fc..9d9bfa3a42 100644 --- a/packages/redrob/src/config/paths.ts +++ b/packages/redrob/src/config/paths.ts @@ -1,6 +1,7 @@ export * as ConfigPaths from "./paths" import path from "path" +import { createHash } from "crypto" import { Flag } from "@redrob-code/core/flag/flag" import { Global } from "@redrob-code/core/global" import { unique } from "remeda" @@ -53,3 +54,26 @@ export function isProjectDirectory(dir: string) { export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } + +/** + * Stable key for a project directory: the absolute, normalised path (lower-cased on Windows, + * where the filesystem is case-insensitive) hashed with SHA-256. Apps compute the same key to + * find `directoryConfigDir` without asking the engine. + */ +export function directoryKey(directory: string) { + let resolved = path.resolve(directory).replace(/[\\/]+$/, "") + if (process.platform === "win32") resolved = resolved.toLowerCase() + return createHash("sha256").update(resolved).digest("hex") +} + +/** + * Per-directory config written by an app on top of the engine (Redrob Cowork writes its + * workspace agents, plugins and MCP servers here), kept OUT of the project tree so it never + * collides with the user's own `.redrob/redrob.json` and never shows up in git. One shared + * daemon serves many apps and many directories, so this replaces the per-process + * REDROB_CONFIG an app used to pass to an engine it spawned itself. Loaded with the same + * precedence as REDROB_CONFIG: after the global config, before the project's own files. + */ +export function directoryConfigDir(directory: string) { + return path.join(Global.Path.state, "directories", directoryKey(directory)) +} diff --git a/packages/redrob/test/config/config.test.ts b/packages/redrob/test/config/config.test.ts index 3c3af2692f..3d51a50fed 100644 --- a/packages/redrob/test/config/config.test.ts +++ b/packages/redrob/test/config/config.test.ts @@ -36,6 +36,7 @@ import { Global } from "@redrob-code/core/global" import { ProjectV2 } from "@redrob-code/core/project" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" +import { ConfigPaths } from "@/config/paths" import { ConfigPluginV1 } from "@redrob-code/core/v1/config/plugin" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" @@ -1433,6 +1434,43 @@ it.instance("local .redrob config can override MCP from project config", () => }), ) +it.instance("loads app-managed directory config from the state dir, below the project's own files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const dir = ConfigPaths.directoryConfigDir(test.directory) + expect(dir.startsWith(path.join(Global.Path.state, "directories"))).toBe(true) + expect(ConfigPaths.directoryKey(test.directory)).toBe(ConfigPaths.directoryKey(test.directory + path.sep)) + expect(ConfigPaths.directoryKey(test.directory)).not.toBe(ConfigPaths.directoryKey(path.join(test.directory, "x"))) + + yield* FSUtil.use.ensureDir(dir) + yield* writeConfigEffect( + dir, + { + $schema: "https://code.redrob.ai/config.json", + snapshot: false, + mcp: { + docs: { type: "remote", url: "https://docs.example.com/mcp", enabled: false }, + app: { type: "remote", url: "https://app.example.com/mcp", enabled: true }, + }, + }, + "redrob.json", + ) + yield* writeConfigEffect(test.directory, { + $schema: "https://code.redrob.ai/config.json", + mcp: { docs: { type: "remote", url: "https://docs.example.com/mcp", enabled: true } }, + }) + + try { + const config = yield* Config.use.get() + expect(config.snapshot).toBe(false) + expect(config.mcp?.app?.enabled).toBe(true) + expect(config.mcp?.docs?.enabled).toBe(true) + } finally { + yield* Effect.promise(() => fs.rm(dir, { recursive: true, force: true })) + } + }), +) + const remoteProjectOverride = wellKnown({ config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, diff --git a/packages/redrob/test/daemon.test.ts b/packages/redrob/test/daemon.test.ts index f5d24083ae..782901e0a0 100644 --- a/packages/redrob/test/daemon.test.ts +++ b/packages/redrob/test/daemon.test.ts @@ -149,3 +149,17 @@ describe("attach", () => { ]) }, 10_000) }) + +describe("directory config", () => { + test("the SDK computes the same per-directory config dir as the engine", async () => { + const { ConfigPaths } = await import("@/config/paths") + const { Global } = await import("@redrob-code/core/global") + const sdk = await import("@redrob-labs/sdk/daemon") + const dir = path.join(os.tmpdir(), "redrob-dircfg", "Project") + expect(sdk.directoryKey(dir)).toBe(ConfigPaths.directoryKey(dir)) + expect(sdk.directoryKey(dir + path.sep)).toBe(sdk.directoryKey(dir)) + expect(sdk.directoryConfigDir(dir, { XDG_STATE_HOME: path.dirname(Global.Path.state) })).toBe( + ConfigPaths.directoryConfigDir(dir), + ) + }) +}) diff --git a/packages/sdk/js/src/daemon.ts b/packages/sdk/js/src/daemon.ts index e103161a46..eb93c46c59 100644 --- a/packages/sdk/js/src/daemon.ts +++ b/packages/sdk/js/src/daemon.ts @@ -10,6 +10,7 @@ * a Node sidecar that has nothing else from this SDK loaded. */ import { spawn } from "node:child_process" +import { createHash } from "node:crypto" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -45,6 +46,29 @@ export function daemonRecordPath(env: NodeJS.ProcessEnv = process.env): string { return path.join(env.REDROB_RUN_DIR?.trim() || path.join(os.homedir(), ".redrob", "run"), "server.json") } +/** + * Key the engine uses for a project directory: absolute, normalised, lower-cased on Windows, + * SHA-256 hex. Must match `ConfigPaths.directoryKey` in the engine. + */ +export function directoryKey(directory: string): string { + let resolved = path.resolve(directory).replace(/[\\/]+$/, "") + if (process.platform === "win32") resolved = resolved.toLowerCase() + return createHash("sha256").update(resolved).digest("hex") +} + +/** + * Where an app writes engine config for ONE directory it works in: `/directories//`, + * holding `redrob.json` (or `.jsonc`). The engine reads it for every instance on that directory, + * after the global config and before the project's own `redrob.json`, so the project still wins. + * Outside the project tree on purpose: it never touches the user's files and never shows in git. + * Write the file, then `POST /instance/dispose` for the directory so the running instance + * reloads. `` is `$XDG_STATE_HOME/redrob`, else `~/.local/state/redrob`. + */ +export function directoryConfigDir(directory: string, env: NodeJS.ProcessEnv = process.env): string { + const state = env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state") + return path.join(state, "redrob", "directories", directoryKey(directory)) +} + export function daemonHeaders(secret: string): { Authorization: string } { return { Authorization: `Basic ${Buffer.from(`${USERNAME}:${secret}`).toString("base64")}` } } @@ -54,7 +78,13 @@ export async function resolveRedrobBin(env: NodeJS.ProcessEnv = process.env): Pr const explicit = env.REDROB_CODE_BIN?.trim() if (explicit) return explicit const home = path.join(os.homedir(), ".redrob", "bin", process.platform === "win32" ? "redrob.exe" : "redrob") - if (await fs.stat(home).then(() => true, () => false)) return home + if ( + await fs.stat(home).then( + () => true, + () => false, + ) + ) + return home return "redrob" }