Compare commits

..
Author SHA1 Message Date
dev60-review 88a210b195 fix: withhold an unsafe receiving-agent description; say where unrouted Slack goes with no receiver; qualify the default person
The settings view's default-receiver row withholds its description when agents.operator_session or the resolved session holds unsafe text, since the description names the session and can quote the setting. With no receiving agent, Slack's unchosen-destination line and the docs say unrouted messages go to operator-agent@kernel for compatibility. The rigs skill says requests and asks that name nobody go to the default person.
2026-11-11 02:29:44 +00:00
10 changed files with 60 additions and 68 deletions

No files matched your search

+10 -12
View File
@@ -428,24 +428,22 @@ running seat only through the action in the last column.
### New guidance for a running seat
There are three ways for a seat to pick up new guidance:
These reach a running seat without a restart, in the order they reach the agent:
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. The seat's
interruption settings can hold a message; `rig send` may report `retained, not delivered`.
1. **Tell it.** `rig send <seat> "Read <file>: <why>"`, or `rig walk <seat> --through <files>` for several. It's in the
conversation at the seat's next turn. The seat's reply is the only evidence that it read the file.
2. **The refocus hook**, where the seat runs OpenRig's `openrig-core` hooks (**Refocus** in `rig seat status` shows whether
the hook has recorded it). The hook's content comes from `OPENRIG_REFOCUS_CONTENT_REF` (a `rig context get` address),
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. After
compaction the hook waits for an eligible prompt. For managed Claude compaction it skips the acknowledgement boundary,
and other prompts can arrive first and be skipped while restore is pending. Claude can also refocus after the
conversation grows past the hook's size threshold.
else `OPENRIG_REFOCUS_CONTENT_FILE`, else `refocus/REFOCUS.md` under `OPENRIG_HOME`, else the shipped default. It
reaches the agent at the next prompt after a compaction, and for Claude also once the conversation has grown past the
hook's size threshold.
3. **Its guidance file, loaded again:** for Claude at its next compaction, `/clear` or start, for Codex at its next start.
**Guidance** in `rig seat status` shows which of OpenRig's blocks were written after the session's recorded startup finish.
A file pointer leaves the read to the seat, under its own permission settings. `rig walk` reads the named files in the
calling process and sends their contents. A seat whose harness asks before reading a file outside its project (its own
`LEARNED.md` under the topology root, a plugin's skill) waits at that prompt until someone answers it, even though a hook
could deliver an excerpt of the same file. Finding a file, a hook picking it up, and the agent reading and using it are
separate facts.
A pointer or a message names a file; the seat still reads it itself, under its own permission settings. A seat whose
harness asks before reading a file outside its project (its own `LEARNED.md` under the topology root, a plugin's skill)
waits at that prompt until someone answers it, even though a hook could deliver an excerpt of the same file. Finding a
file, a hook picking up, and the agent reading and using it are separate facts.
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
+2 -1
View File
@@ -91,7 +91,8 @@ never picks whichever seat happens to be running. The TUI shows the effective ag
under Settings > Agents & runtime ("Default receiving agent"). It never replaces the default person.
For Slack, `rig slack setup --inbound-destination default-receiver` (the default for a new setup) sends unrouted
messages to the default receiving agent, read when the connector starts. A destination already saved in the connector
messages to the default receiving agent, read when the connector starts. With no receiving agent on the host, they go to
`operator-agent@kernel` for compatibility, as before. A destination already saved in the connector
config, `operator-agent@kernel` included, is kept as chosen; pass `default-receiver` to switch it.
## In the TUI: whose queue, and who acts
+2 -1
View File
@@ -146,7 +146,8 @@ person needs in their project or home folder and name that path.
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks go to them, and don't offer again.
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
name someone else), and don't offer again.
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
or leave it. Never pick one yourself.
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,
@@ -449,18 +449,14 @@ function closesFramedPromptAbove(lines: string[], open: number, indent: number,
const codexFooter = (line: string): boolean => isComposerFooter(line)
|| /(?:\b\d+% context left\b|\bContext \[)/.test(line);
/** Preserve the styling used for autocomplete ghosts and the drawn cursor.
* Color channel values must never be interpreted as style codes. */
/** Preserve faint styling so autocomplete ghosts can be separated from authored
* text. Color channel values must never be interpreted as style codes. */
function styledTextLines(screen: string) {
let faint = false, inverse = false;
let faint = false;
return screen.replace(/\r\n/g, "\n").split("\n").map(raw => {
let text = "", offset = 0;
const faintCells: boolean[] = [], inverseCells: boolean[] = [];
const append = (value: string) => {
text += value;
faintCells.push(...Array<boolean>(value.length).fill(faint));
inverseCells.push(...Array<boolean>(value.length).fill(inverse));
};
const faintCells: boolean[] = [];
const append = (value: string) => { text += value; faintCells.push(...Array<boolean>(value.length).fill(faint)); };
for (const match of raw.matchAll(/\x1b\[([0-9;:]*)m/g)) {
append(raw.slice(offset, match.index));
const codes = match[1]!.split(";");
@@ -471,18 +467,15 @@ function styledTextLines(screen: string) {
const skip = codes[i + 1] === "5" ? 2 : codes[i + 1] === "2" ? (codes[i + 2] === "" ? 5 : 4) : 0;
// Color channels are not style codes. Ambiguous compound sequences
// provide no faint-style proof, rather than interpreting an RGB 2 as dim.
if (!skip || i + skip !== codes.length - 1) { faint = inverse = false; break; }
if (!skip || i + skip !== codes.length - 1) { faint = false; break; }
i += skip;
} else if (code === "" || code === "0") faint = inverse = false;
else if (code === "22") faint = false;
} else if (code === "" || code === "0" || code === "22") faint = false;
else if (code === "2") faint = true;
else if (code === "7") inverse = true;
else if (code === "27") inverse = false;
}
offset = match.index! + match[0].length;
}
append(raw.slice(offset));
return { text, faintCells, inverseCells };
return { text, faintCells };
});
}
@@ -577,11 +570,7 @@ export function inspectComposerInput(snapshot: ComposerSnapshot | null, runtime:
else if (cursorOffset >= 0 && !rows[cursorRow]!.slice(cursorOffset).trim()) visible[cursorRow] = rows[cursorRow]!.slice(0, cursorOffset);
}
const atStart = y === start && x === prefix;
// Claude can draw an inverse-video blank in the cursor cell (#2925). Only
// that single cell at the start is padding; ordinary/preceding spaces stay input.
const inverseCells = styled[start]!.inverseCells;
const cursorBlank = prompt.glyph === "❯" && rows[0] === " " && inverseCells[prefix] && !inverseCells[prefix - 1];
const empty = atStart && rows.length === 1 && (rows[0] === "" || cursorBlank);
const empty = atStart && rows.length === 1 && rows[0] === "";
const last = visible.length - 1;
const cursorAtEnd = y === start + last && x === prefix + stringWidth(visible[last] ?? "");
const label = visible.length === 1 && COMPOSER_COLLAPSED_PASTE_PATTERN.test(visible[0]!) ? visible[0]! : null;
@@ -4,7 +4,7 @@ import { join, isAbsolute } from "node:path";
import { getOpenRigHome } from "../../openrig-compat.js";
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { projectionPath } from "../gateway/human-registry.js";
import { loadHostRegistry } from "../hosts/hosts-registry-reader.js";
import { DEFAULT_HEALTH_POLICY, validateHealthPolicy } from "../health-policy.js";
@@ -144,9 +144,14 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
// Derived, not a setting: the agent that receives answers nothing else reads, from agents.operator_session or the
// kernel operator. Shown with where it came from and its state; changed with rig config set agents.operator_session.
if (receiver) {
// The description names the session and can quote the setting: when either is withheld, so is the description.
const unsafe = [receiver.session, resolved?.["agents.operator_session"]?.value]
.some((v) => typeof v === "string" && v !== "" && safeValue(v, "identity", redact).value === null);
add("agents.default_receiver", "general", receiver.session ?? "", undefined,
receiver.source === "setting" ? resolved?.["agents.operator_session"]?.source ?? "unreported" : "default", "identity",
"Displayed daemon instance", describeDefaultReceiver(receiver));
"Displayed daemon instance", unsafe
? "Withheld: agents.operator_session holds credential-bearing or unsafe text, so the receiving agent isn't described here. Inspect it at its source."
: describeDefaultReceiver(receiver));
}
if (resolved) {
for (const host of store.listFeedHostSubscriptions()) {
@@ -171,7 +176,10 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
const unchosen = key === "inboundDestination" && (!cfg || cfg[key] === INHERIT_INBOUND_DESTINATION);
add("slack." + key, "slack", cfg ? flatten(cfg[key]) : null, flatten(defs[key]),
cfg ? read.fields.includes(key) ? "file" : "default" : "unavailable", kind, undefined,
unchosen ? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)." : undefined);
unchosen ? receiver?.session
? "Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime)."
: `Not chosen: this host has no receiving agent (Agents & runtime), so when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`
: undefined);
}
// #192: the channel map as one read-only line, "match: channel" per entry.
add("slack.channelMap", "slack", read.cfg ? c.configuration!.channelMap.map((e) => `${e.match ?? "withheld"}: ${e.channel ?? "withheld"}`).join(", ") : null, "",
@@ -162,15 +162,6 @@ describe("cursor-bound composer input", () => {
expect(inspectComposerInput(claude("\x1b[2;38;5;245mAsk Codex to do anything\x1b[0m", { x: 2, y: 2 })).state).toBe("empty");
});
it("recognizes only a single inverse-video Claude cursor blank at the prompt start", () => {
const cursor = { x: 2, y: 2 };
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", cursor))).toMatchObject({ state: "empty", rows: [""], cursorAtEnd: true });
for (const body of [" ", "\x1b[38;5;7m \x1b[0m", "\x1b[7m\x1b[27m ", "\x1b[7m \x1b[0mdraft"]) {
expect(inspectComposerInput(claude(body, cursor)).state).toBe("text");
}
expect(inspectComposerInput(claude("\x1b[7m \x1b[0m", { x: 3, y: 2 }))).toMatchObject({ state: "text", rows: [" "] });
});
it("requires the complete owned text and preserves significant spaces", () => {
const input = inspectComposerInput(claude("Run two commands\nthen report."));
expect(composerContainsOwnedText(input, "Run two commands\nthen report.")).toBe(true);
@@ -145,4 +145,19 @@ describe("the settings view", () => {
.toBe("Not chosen: when the connector starts, unrouted messages go to the default receiving agent (Agents & runtime).");
expect(settingsBrowser(store, null, home).entries.some((entry) => entry.key === "agents.default_receiver")).toBe(false);
});
it("withholds the receiving agent's description when the setting holds unsafe text; with no receiver, Slack's line says where unrouted messages go", () => {
const home = mkdtempSync(join(tmpdir(), "default-receiver-browser-"));
closes.push(() => rmSync(home, { recursive: true, force: true }));
const store = new SettingsStore(join(home, "config.json"));
const unsafe = "token=synthetic-example-value";
store.set("agents.operator_session", unsafe);
const view = settingsBrowser(store, null, home, { session: null, source: "none", state: "missing",
note: `agents.operator_session is "${unsafe}", which isn't an agent's session, so it's ignored` });
const row = view.entries.find((entry) => entry.key === "agents.default_receiver")!;
expect(row.application).toBe("Withheld: agents.operator_session holds credential-bearing or unsafe text, so the receiving agent isn't described here. Inspect it at its source.");
expect(JSON.stringify(view)).not.toContain("synthetic-example-value");
expect(view.entries.find((entry) => entry.key === "slack.inboundDestination")?.application)
.toBe(`Not chosen: this host has no receiving agent (Agents & runtime), so when the connector starts, unrouted messages go to ${LEGACY_INBOUND_DESTINATION} for compatibility, as before.`);
});
});
@@ -10,7 +10,7 @@ import { TmuxAdapter } from "../src/adapters/tmux.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { annotateInputBox, inspectComposerInput } from "../src/domain/session-transport.js";
import { inspectComposerInput } from "../src/domain/session-transport.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -133,7 +133,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
}
}, 30_000);
it.each(["update-banner", "cursor-block"])("submits a multiline paste with %s and keeps a real draft through tmux", async variant => {
it("submits a multiline paste under a combined update footer through real tmux", async () => {
const scratch = mkdtempSync(join(tmpdir(), "draft-banner-"));
const socket = join(scratch, "s");
const env = { ...process.env }; delete env.TMUX; delete env.TMUX_TMPDIR;
@@ -143,9 +143,8 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const db = new Database(":memory:");
let transport: SessionTransport | undefined;
try {
const command = [process.execPath, composer, "claude", stateFile, variant].map(shellQuote).join(" ");
const [width, height] = variant === "cursor-block" ? ["68", "31"] : ["120", "40"];
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", width, "-y", height, "-s", "worker@test", command])).trim();
const command = [process.execPath, composer, "claude", stateFile, "update-banner"].map(shellQuote).join(" ");
const pane = (await tmux(["-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-x", "120", "-y", "40", "-s", "worker@test", command])).trim();
await vi.waitFor(() => expect(state().input).toBe(""), { timeout: 5000 });
migrate(db, ALL_MIGRATIONS);
db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test');
@@ -158,14 +157,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); adapter.deliveryGuard = guard;
transport = new SessionTransport({ db, tmuxAdapter: adapter, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db) });
await guard.set("a", { mode: "draft-aware" }, "human@test", "protect fixture input");
if (variant === "cursor-block") {
await vi.waitFor(async () => {
const snapshot = await adapter.captureComposerSnapshot(pane);
expect(snapshot?.cursor).toEqual({ x: 2, y: 26, width: 68, height: 31 });
expect(snapshot!.screen.split("\n")[26]).toContain("❯\u00a0\x1b[7m ");
expect(annotateInputBox(snapshot!.screen, snapshot!.cursor, "claude-code")).toMatchObject({ status: "empty", confidence: "high" });
});
} else await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
await vi.waitFor(async () => expect(inspectComposerInput(await adapter.captureComposerSnapshot(pane), "claude-code").state).toBe("empty"));
const text = "first line\nsecond line\nlast line";
const result = await transport.send("worker@test", text, { deliveryId: "banner", actorSession: "sender@test", verify: false });
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, sent: true });
@@ -173,7 +165,7 @@ describe.skipIf(process.platform === "win32")("native draft-aware terminal deliv
await tmux(["send-keys", "-t", pane, "-l", "person's draft"]);
await vi.waitFor(() => expect(state().input).toBe("person's draft"));
expect(await transport.send("worker@test", "another message", { deliveryId: "draft", verify: false }))
.toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
.toMatchObject({ outcome: "retained", sent: false });
expect(state()).toEqual({ input: "person's draft", submissions: [text] });
} finally {
await transport?.guardedDelivery?.stop();
+3 -7
View File
@@ -5,23 +5,19 @@ import { StringDecoder } from "node:string_decoder";
import stringWidth from "string-width";
const [runtime, stateFile, footerVariant] = process.argv.slice(2);
const cursorBlock = footerVariant === "cursor-block";
const rule = cursorBlock ? `\x1b[38;5;244m${"─".repeat(68)}\x1b[39m` : "────────────────────────────────────────";
let input = "", pending = "", pasting = false, cursor = 0, ghost = false;
const submissions = [];
const decoder = new StringDecoder("utf8");
function render() {
const rows = ["Editable delivery fixture", ...submissions.slice(-3).flatMap(text => text.split("\n"))];
if (cursorBlock) while (rows.length < 25) rows.push("");
if (runtime === "claude") rows.push(rule);
if (runtime === "claude") rows.push("────────────────────────────────────────");
const start = rows.length;
const displayed = ghost && cursor === input.length ? `${input}\x1b[2m suggested follow-up\x1b[22m`
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : cursorBlock ? "\x1b[7m \x1b[0m" : "");
: input || (runtime === "codex" ? "\x1b[2mAsk Codex to do anything\x1b[22m" : "");
const body = displayed.split("\n");
rows.push(`${runtime === "claude" ? "❯\u00a0" : "› "}${body[0]}`, ...body.slice(1).map(line => ` ${line}`));
rows.push(...(runtime === "claude" ? [rule, cursorBlock
? " \x1b[38;5;211m⏵⏵ bypass permissions on\x1b[38;5;246m (shift+tab to cycle)\x1b[0m" : "? for shortcuts"] : ["", "fixture · 90% context left"]));
rows.push(...(runtime === "claude" ? ["────────────────────────────────────────", "? for shortcuts"] : ["", "fixture · 90% context left"]));
if (runtime === "claude" && ghost) {
rows[rows.length - 1] = "⏵⏵ accept edits on (shift+tab to cycle) · ← for agents";
rows.push("✘ Auto-update failed: no write permission to npm prefix · Run claude doctor");
+2 -1
View File
@@ -146,7 +146,8 @@ person needs in their project or home folder and name that path.
A declared name is a claim, not a seat's own identity: for a team that acts only on seat identity, the attached
shell above is the route.
- **Who answers OpenRig's questions:** read `rig gateway participants` (its `default:` line) before the handoff.
- A default person is there: say requests and asks go to them, and don't offer again.
- A default person is there: say requests and asks that name nobody go to them (a pairing request or an ask can
name someone else), and don't offer again.
- Several people with no default: ask which should get requests nobody is named for (`rig gateway default <id>`),
or leave it. Never pick one yourself.
- Nobody is registered: offer it in a sentence or two. OpenRig's questions, such as another machine asking to pair,