mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(daemon): let git over SSH use the daemon's SSH agent in managed seats (#2222)
Git over SSH inside a managed seat can now reach the daemon's ssh-agent for a passphrase-protected key: the daemon forwards its own SSH_AUTH_SOCK through the shared launch environment to every managed launch, and Pi and OMP pass it to the agent process. An empty daemon value counts as unset, so a socket only the seat's terminal has stays out. rig-spec.md says the socket is forwarded and how to check it with ssh-add -l. Finishes #1162 by @akushonkamen, for #1159. 5 files: source +7 -1, tests +18 -10, docs +3 -3. Co-authored-by: akushonkamen <[email protected]>
This commit is contained in:
1 parent
0224e7710d
commit
76c0170ec8
5 files changed
+28
-14
No files matched your search
@@ -511,9 +511,9 @@ pods:
|
||||
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
|
||||
|
||||
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE` and `XDG_CURRENT_DESKTOP` are not forwarded. The SSH agent socket (`SSH_AUTH_SOCK`) is forwarded when OpenRig itself was started with one, so git over SSH works in the seat; a socket only the seat's terminal has stays out. To check, run `ssh-add -l` inside the seat. If it can't reach an SSH agent, start the OpenRig daemon from a terminal where `ssh-add -l` works. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
|
||||
**A seat's own account.** Claude Code keeps its login in its config dir and Codex in its home, so a seat whose `env:`
|
||||
declares an absolute `CLAUDE_CONFIG_DIR` (Claude Code) or `CODEX_HOME` (Codex) runs on whatever login that directory
|
||||
@@ -653,7 +653,7 @@ own defaults/settings apply. Other runtimes ignore this field.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
|
||||
@@ -174,7 +174,9 @@ export const OMP_PROVIDER_EXTRA_ENV_VARS: Record<string, readonly string[]> = {
|
||||
};
|
||||
|
||||
// Baseline process needs. No host credential families or shell customization.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
// SSH_AUTH_SOCK lets Pi's shell tools run git over SSH (#1159): the path isn't key material, but the socket lets the
|
||||
// agent use the keys loaded in ssh-agent.
|
||||
export const PI_ENV_BASELINE_VARS = ["PATH", "HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TERM", "LANG", "LC_ALL", "SHELL", "TMPDIR"] as const;
|
||||
|
||||
// NodeLauncher supplies identity and instance routing on both fresh and resumed
|
||||
// seats. Pi's shell tools inherit this child env: dropping these values makes
|
||||
|
||||
@@ -655,6 +655,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
HOME: daemonHome,
|
||||
USER: process.env.USER,
|
||||
LOGNAME: process.env.LOGNAME,
|
||||
// The SSH agent socket, so git over SSH works in a seat (#1159). The path isn't key material, but the socket lets
|
||||
// the seat's coding agent use the keys loaded in ssh-agent. An empty value is left out like an unset one, so a
|
||||
// socket only the seat's terminal has never takes its place.
|
||||
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK || undefined,
|
||||
CODEX_HOME: codexHome,
|
||||
};
|
||||
// OPR.0.4.6.02 S1 — ONE shared tmux option-defaults applier, injected into
|
||||
|
||||
@@ -28,15 +28,16 @@ function runner() {
|
||||
}
|
||||
|
||||
describe("OMP runner command and child isolation", () => {
|
||||
it.each(["pi", "omp"] as const)("retains only supplied user names in the %s child baseline", runtime => {
|
||||
it.each(["pi", "omp"] as const)("retains only supplied user names and agent socket in the %s child baseline", runtime => {
|
||||
const opts = { runtime, agentDir: "/fixture/agent", sessionsDir: "/fixture/sessions" };
|
||||
const source = { HOME: "/fixture/home", PATH: "/fixture/bin", UNRELATED_VALUE: "not-forwarded" };
|
||||
const absent = buildPiChildEnv(source, opts);
|
||||
expect(absent).not.toHaveProperty("USER");
|
||||
expect(absent).not.toHaveProperty("LOGNAME");
|
||||
expect(absent).not.toHaveProperty("SSH_AUTH_SOCK");
|
||||
expect(absent).not.toHaveProperty("UNRELATED_VALUE");
|
||||
expect(buildPiChildEnv({ ...source, USER: "fixture-user", LOGNAME: "fixture-login" }, opts))
|
||||
.toEqual({ ...absent, USER: "fixture-user", LOGNAME: "fixture-login" });
|
||||
expect(buildPiChildEnv({ ...source, USER: "fixture-user", LOGNAME: "fixture-login", SSH_AUTH_SOCK: "/fixture/agent.sock" }, opts))
|
||||
.toEqual({ ...absent, USER: "fixture-user", LOGNAME: "fixture-login", SSH_AUTH_SOCK: "/fixture/agent.sock" });
|
||||
});
|
||||
it("launches exact session/fork with OMP approval flags, never Pi-only flags", () => {
|
||||
const common = { runnerEntryPath: "/daemon/pi-runner.js", sessionName, stateRoot: "/openrig/state/omp", cwd: "/work", model: "anthropic/claude-sonnet", runtime: "omp" as const, launchId: "attempt-1" };
|
||||
|
||||
@@ -95,9 +95,14 @@ const input = { seatRef: "owner@rig", mode: "auto", actor: "operator", reason: "
|
||||
|
||||
describe("S03 production managed capability selection", () => {
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
{ USER: "fixture-user", LOGNAME: "fixture-login" },
|
||||
{ USER: "fixture-user" }, { LOGNAME: "fixture-login" }, {},
|
||||
])("forwards only supplied daemon user names through the actual startup channel: %j", async names => {
|
||||
[{ USER: "fixture-user", LOGNAME: "fixture-login" }, {}],
|
||||
[{ USER: "fixture-user" }, {}], [{ LOGNAME: "fixture-login" }, {}], [{}, {}],
|
||||
// #1159: the daemon's own agent socket reaches the seat. A socket only the pane has stays out, also when the
|
||||
// daemon's value is empty.
|
||||
[{ SSH_AUTH_SOCK: "/fixture/agent.sock" }, {}],
|
||||
[{}, { SSH_AUTH_SOCK: "/pane-only/agent.sock" }],
|
||||
[{ SSH_AUTH_SOCK: "" }, { SSH_AUTH_SOCK: "/pane-only/agent.sock" }],
|
||||
] as [Record<string, string>, Record<string, string>][])("forwards only supplied daemon user names and agent socket through the actual startup channel: daemon %j, pane %j", async (names, pane) => {
|
||||
const f = fixture();
|
||||
// Execute the actual initializer, without starting a daemon or reading its env.
|
||||
const source = readFileSync(new URL("../src/startup.ts", import.meta.url), "utf8");
|
||||
@@ -112,15 +117,16 @@ describe("S03 production managed capability selection", () => {
|
||||
});
|
||||
for (const key of Object.keys(f.env)) delete f.env[key];
|
||||
Object.assign(f.env, sessionEnv);
|
||||
writeFileSync(f.executable, `#!${process.execPath}\nif (process.argv.includes('--help')) console.log(${JSON.stringify(help)}); else console.log(JSON.stringify(Object.fromEntries(['USER','LOGNAME','UNRELATED_VALUE'].filter(k => process.env[k] !== undefined).map(k => [k,process.env[k]]))));\n`);
|
||||
writeFileSync(f.executable, `#!${process.execPath}\nif (process.argv.includes('--help')) console.log(${JSON.stringify(help)}); else console.log(JSON.stringify(Object.fromEntries(['USER','LOGNAME','SSH_AUTH_SOCK','UNRELATED_VALUE'].filter(k => process.env[k] !== undefined).map(k => [k,process.env[k]]))));\n`);
|
||||
const native = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
vi.mocked(execFile).mockImplementation(native.execFile);
|
||||
const prepared = await f.managed.prepare({ nodeId: "node", session: "seat", pane: "%1" }, "auto");
|
||||
const child = native.execFileSync("/bin/sh", ["-c", prepared.command(["--permission-mode", "auto"])], {
|
||||
encoding: "utf8", timeout: 3000,
|
||||
env: { ...sessionEnv, UNRELATED_VALUE: "not-forwarded" },
|
||||
env: { ...sessionEnv, UNRELATED_VALUE: "not-forwarded", ...pane },
|
||||
});
|
||||
expect(JSON.parse(child)).toEqual(names);
|
||||
// An empty daemon value counts as unset.
|
||||
expect(JSON.parse(child)).toEqual(Object.fromEntries(Object.entries(names).filter(([, value]) => value !== "")));
|
||||
});
|
||||
it.each([
|
||||
["unset", undefined, "present"], ["relative", "./config", "present"],
|
||||
@@ -231,7 +237,8 @@ else console.log(JSON.stringify(Object.fromEntries(['TMUX', 'TERM_PROGRAM', 'TER
|
||||
// Present in the pane on purpose: they must stay out. XDG_RUNTIME_DIR is where sd-bus, GDBus and libdbus look for the
|
||||
// session bus when DBUS_SESSION_BUS_ADDRESS is unset, so the pane's own pair stays out; only OpenRig's pair, with the
|
||||
// bus address that can't connect, is forwarded (#2030, below). The desktop names reach services (notifications,
|
||||
// xdg-open) that nothing in the editor or paste path uses; SSH_AUTH_SOCK is a credential channel.
|
||||
// xdg-open) that nothing in the editor or paste path uses. A socket only the pane has stays out; the daemon's own
|
||||
// socket is forwarded through the session environment (#1159).
|
||||
const withheldNames = ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "SSH_AUTH_SOCK", "TMUX", "TMUX_PANE"];
|
||||
const paneNames = [...sessionNames, "WAYLAND_DISPLAY", ...withheldNames];
|
||||
// A managed launch runs as a staged script under /bin/sh, which is dash on Debian and Ubuntu. Run the cases under dash too
|
||||
|
||||
Reference in new issue
Block a user