tools: give coworkers the user's real toolchain, and stop silent skips

Sidecar inherits the login shell's env; toolchain resolves absolute paths with pinned
installs; request_tool replaces the 'tool missing -> STOP' instruction that hid a check.
This commit is contained in:
Rohit C Prasad
2026-08-14 15:28:57 -07:00
committed by Rohit P
parent 49c16af076
commit 62ad9dbdca
21 changed files with 1033 additions and 8 deletions
+24
View File
@@ -610,6 +610,17 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return; // suspended on the approval
}
// OPE-85: the agent hits a missing scanner and asks instead of skipping the check.
if (/scan for secrets/i.test(msg.text)) {
send("tool_requested", {
name: "gitleaks",
reason: "scan the git history for committed secrets",
installable: true,
version: "8.30.1",
summary: "scans git history and the working tree for committed secrets",
});
return; // suspended on the tool request
}
// §35 compact row: a routine workspace write (content rides in the args).
if (/write a file/i.test(msg.text)) {
pendingTool = "write_file";
@@ -767,6 +778,19 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
}
send("turn_done");
} else if (msg.type === "tool_response") {
// Either way the turn continues — the point of the contract is that declining
// degrades the report openly instead of dropping the check.
if (msg.approved) {
send("assistant_message", {
text: "Installed gitleaks 8.30.1 — scanned history, no secrets found.",
});
} else {
send("assistant_message", {
text: "Skipped gitleaks. Coverage: history secret sweep done by hand instead.",
});
}
send("turn_done");
} else if (msg.type === "interrupt") {
// Stop mid-stream: like the real engine, end the turn with `interrupted` and
// NO assistant_message — the client owns promoting the partial into the transcript.
+36
View File
@@ -0,0 +1,36 @@
// OPE-85: a missing CLI becomes a visible decision, never a silently dropped check.
// The bug this guards (owner-hit 2026-08-13): with gitleaks absent, a security review
// quietly omitted its git-history secret scan — "we couldn't look" rendered as "clean".
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function ask(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets");
await page.getByRole("button", { name: "Send" }).click();
}
test("request_tool surfaces a card naming the tool, the reason and the pinned version", async ({
page,
}) => {
await ask(page);
const card = page.locator(".dirreq-card");
await expect(card).toContainText("gitleaks");
await expect(card).toContainText("scan the git history for committed secrets");
await expect(card).toContainText("8.30.1");
await expect(card).toContainText(/checksum-verified/i);
// Declining must read as a normal choice, not a failure.
await expect(card.getByTestId("toolreq-skip")).toBeVisible();
});
test("installing runs the check; skipping still reports coverage", async ({ page }) => {
await ask(page);
await page.getByTestId("toolreq-install").click();
await expect(page.locator(".main-scroll")).toContainText("Installed gitleaks");
await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets");
await page.getByRole("button", { name: "Send" }).click();
await page.getByTestId("toolreq-skip").click();
// The whole point: the skipped check is disclosed, not invisible.
await expect(page.locator(".main-scroll")).toContainText(/Coverage:/);
});
+124
View File
@@ -47,6 +47,126 @@ fn launch_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
/// Directories where user-installed CLIs live but launchd's PATH never looks. Used to
/// repair PATH when the login-shell probe can't run (broken profile, exotic shell).
#[cfg(not(target_os = "windows"))]
const KNOWN_TOOL_DIRS: &[&str] = &[
"/opt/homebrew/bin", // Apple Silicon Homebrew
"/opt/homebrew/sbin",
"/usr/local/bin", // Intel Homebrew, most installers
"/usr/local/sbin",
"/opt/local/bin", // MacPorts
];
/// The environment the sidecar should run with (OPE-83).
///
/// A Finder/Dock-launched app inherits launchd's minimal PATH — `/usr/bin:/bin:/usr/sbin:/sbin`
/// — so every tool the user installed via Homebrew/nvm/pyenv/asdf is invisible to the agent:
/// semgrep, gitleaks, gh, node, aws, kubectl, terraform. That silently guts the security
/// coworkers (they drive those scanners) and every ops workflow. Fix, same as VS Code and
/// friends: ask the user's login shell for its environment once at spawn and merge it in, so
/// the coworker gets the user's REAL toolchain. Credentials follow for free — aws/kubectl read
/// ~/.aws and ~/.kube via HOME, which a Finder launch already has.
///
/// Guards: `-i` (not just `-l`) because brew/nvm/pyenv init usually lives in .zshrc; markers so
/// a chatty profile's own output can't be parsed as variables; a 5s timeout with the child
/// killed, so a hanging profile can never block app launch; and a well-known-dirs PATH repair as
/// the fallback. Skipped entirely when we were launched FROM a shell (SHLVL set) — we already
/// inherit the real thing, and `npm run tauri dev` should behave exactly as before.
#[cfg(not(target_os = "windows"))]
fn sidecar_env() -> std::collections::HashMap<String, String> {
use std::collections::HashMap;
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;
const START: &str = "__OCW_ENV_START__";
const END: &str = "__OCW_ENV_END__";
let mut out: HashMap<String, String> = HashMap::new();
// Launched from a shell (dev run, `open` from a terminal): the env is already real.
if std::env::var_os("SHLVL").is_some() {
return out;
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let script = format!("echo {START}; env; echo {END}");
let spawned = Command::new(&shell)
.args(["-ilc", &script])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = spawned {
if let Some(mut stdout) = child.stdout.take() {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = String::new();
let _ = stdout.read_to_string(&mut buf);
let _ = tx.send(buf);
});
match rx.recv_timeout(Duration::from_secs(5)) {
Ok(text) => {
let _ = child.wait();
let mut inside = false;
for line in text.lines() {
if line.trim_end() == START {
inside = true;
continue;
}
if line.trim_end() == END {
break;
}
if !inside {
continue;
}
// `env` prints KEY=value; continuation lines of a multi-line value
// have no '=' before whitespace and are skipped rather than guessed at.
if let Some((k, v)) = line.split_once('=') {
if !k.is_empty() && !k.contains(char::is_whitespace) {
out.insert(k.to_string(), v.to_string());
}
}
}
}
Err(_) => {
// Hung profile — never let it hold up launch.
let _ = child.kill();
let _ = child.wait();
}
}
}
}
// These describe the probe shell, not the user's environment.
for k in ["SHLVL", "PWD", "OLDPWD", "_"] {
out.remove(k);
}
// Whether the probe worked or not, make sure the usual install dirs are reachable.
let base = out
.get("PATH")
.cloned()
.or_else(|| std::env::var("PATH").ok())
.unwrap_or_default();
let mut parts: Vec<String> = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
for dir in KNOWN_TOOL_DIRS {
if !parts.iter().any(|p| p == dir) && std::path::Path::new(dir).is_dir() {
parts.push((*dir).to_string());
}
}
out.insert("PATH".to_string(), parts.join(":"));
out
}
/// Windows GUI apps inherit the user's full environment already.
#[cfg(target_os = "windows")]
fn sidecar_env() -> std::collections::HashMap<String, String> {
std::collections::HashMap::new()
}
/// Path to the server entrypoint. Resolution order:
/// 1. `COWORKER_SERVER_BIN` env override.
/// 2. The bundled onedir sidecar shipped via Tauri `resources` (production): the
@@ -629,6 +749,10 @@ pub fn run() {
let mut server_cmd = Command::new(server_bin());
server_cmd
.args(["--host", "127.0.0.1", "--port", &port.to_string()])
// The user's real shell environment (PATH to their tools, AWS_PROFILE,
// KUBECONFIG, …) — see sidecar_env(). Applied FIRST so the explicit COWORKER_*
// vars below always win over anything a profile happens to export.
.envs(sidecar_env())
// The sidecar self-exits if we die abruptly (dev-watcher restart, crash) —
// belt-and-suspenders alongside the RunEvent::ExitRequested kill below.
// The explicit PID matters: under PyInstaller onefile the python process is a
+35
View File
@@ -69,6 +69,7 @@ import { PersonaView } from "./components/PersonaView";
import { AuditView } from "./components/AuditView";
import { InboxView } from "./components/InboxView";
import { ApprovalCard } from "./components/ApprovalCard";
import { ToolRequestCard } from "./components/ToolRequestCard";
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
import { PlanCard } from "./components/PlanCard";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
@@ -728,6 +729,20 @@ export function App() {
{ kind: "dirreq", reason: d.reason || "", path: d.path || "", writable: !!d.writable },
]);
break;
case "tool_requested":
if (unattendedRef.current) break;
setItems((p) => [
...p,
{
kind: "toolreq",
tool: d.name || "",
reason: d.reason || "",
installable: d.installable !== false,
version: d.version || "",
summary: d.summary || "",
},
]);
break;
case "plan_proposed":
if (unattendedRef.current) break;
setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]);
@@ -980,6 +995,11 @@ export function App() {
dropSessionInbox("directory");
sessionRef.current?.respondDirectory(granted, path, writable);
};
const respondTool = (approved: boolean) => {
setItems((p) => resolveLastToolReq(p, approved ? "installed" : "skipped"));
dropSessionInbox("tool");
sessionRef.current?.respondTool(approved);
};
const answerQuestion = (answer: string) => {
setItems((p) => resolveLastQuestion(p, answer));
dropSessionInbox("question");
@@ -1341,6 +1361,7 @@ export function App() {
const idle = items.length === 0 && !streaming;
const pendingApproval = [...items].reverse().find((i) => i.kind === "approval" && !i.resolved);
const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved);
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
@@ -1857,6 +1878,8 @@ export function App() {
// parked in the Inbox and surfaced via the answer-in-context card below.
!unattended && pendingPlan?.kind === "planreq" ? (
<PlanCard item={pendingPlan} onRespond={respondPlan} />
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
<ToolRequestCard item={pendingToolReq} onRespond={respondTool} />
) : !unattended && pendingDirReq?.kind === "dirreq" ? (
<DirectoryRequestCard item={pendingDirReq} onRespond={respondDirectory} />
) : !unattended && pendingApproval?.kind === "approval" ? (
@@ -2029,6 +2052,18 @@ function resolveLastDirReq(items: Item[], resolved: "granted" | "denied"): Item[
return copy;
}
function resolveLastToolReq(items: Item[], resolved: "installed" | "skipped"): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
const it = copy[i];
if (it.kind === "toolreq" && !it.resolved) {
copy[i] = { ...it, resolved };
break;
}
}
return copy;
}
function resolveLastPlan(items: Item[], resolved: "approved" | "rejected"): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
+5
View File
@@ -2124,6 +2124,11 @@ export class Session {
this.send({ type: "directory_response", granted, ...(path ? { path } : {}), writable: !!writable });
}
// Reply to a `request_tool` prompt: install the pinned build, or skip the check.
respondTool(approved: boolean) {
this.send({ type: "tool_response", approved });
}
// Reply to a `propose_plan` prompt: approve (choosing the execution mode) or reject with feedback.
respondPlan(approved: boolean, mode?: string, feedback?: string) {
this.send({
@@ -0,0 +1,54 @@
import type { Item } from "../types";
import { Icon } from "./Icon";
type ToolReqItem = Extract<Item, { kind: "toolreq" }>;
// The agent asked (via request_tool) for a CLI it couldn't find — a scanner, usually.
// Declining is a normal outcome, not a failure: the agent falls back and says which checks
// were degraded, so the copy here shouldn't push the user toward Install.
export function ToolRequestCard({
item,
onRespond,
}: {
item: ToolReqItem;
onRespond: (approved: boolean) => void;
}) {
return (
<div className="dirreq-card">
<div className="dirreq-head">
<Icon name="wrench" size={16} className="ico" />
<span>
The coworker needs <code>{item.tool}</code>
</span>
</div>
{item.reason && <div className="dirreq-reason">{item.reason}</div>}
{item.installable ? (
<div className="dirreq-reason">
{item.summary ? `${item.summary}. ` : ""}
Installs {item.tool}
{item.version ? ` ${item.version}` : ""} a pinned build, checksum-verified before
it runs.
</div>
) : (
<div className="dirreq-reason">
No verified build is available for this machine install it yourself if you want
this check, or skip and the coworker will note the gap.
</div>
)}
<div className="dirreq-actions">
<span className="spacer" />
<button className="btn" data-testid="toolreq-skip" onClick={() => onRespond(false)}>
Skip this check
</button>
<button
className="btn primary"
data-testid="toolreq-install"
disabled={!item.installable}
onClick={() => onRespond(true)}
>
Install
</button>
</div>
</div>
);
}
+10
View File
@@ -8,6 +8,7 @@ export type EventType =
| "tool_proposed"
| "permission_required"
| "directory_requested"
| "tool_requested"
| "question_requested"
| "plan_proposed"
| "tool_started"
@@ -128,6 +129,15 @@ export type Item =
writable?: boolean;
resolved?: "granted" | "denied";
}
| {
kind: "toolreq";
tool: string;
reason: string;
installable?: boolean;
version?: string;
summary?: string;
resolved?: "installed" | "skipped";
}
| {
kind: "planreq";
plan: string;