Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 32 additions & 18 deletions docs/LOCAL-ENGINE-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: `<state>/directories/<key>/redrob.json` (`<state>` is `$XDG_STATE_HOME/redrob`,
else `~/.local/state/redrob`; `<key>` 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.
Expand Down Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions packages/redrob/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions packages/redrob/src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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))
}
38 changes: 38 additions & 0 deletions packages/redrob/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 } },
Expand Down
14 changes: 14 additions & 0 deletions packages/redrob/test/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
})
})
32 changes: 31 additions & 1 deletion packages/sdk/js/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: `<state>/directories/<key>/`,
* 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. `<state>` 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")}` }
}
Expand All @@ -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"
}

Expand Down
Loading