mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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--) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user