-
Notifications
You must be signed in to change notification settings - Fork 389
Let a bot have a working folder #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { homedir, tmpdir } from "node:os"; | ||
| import { join, resolve } from "node:path"; | ||
| import { afterAll, describe, expect, it } from "vitest"; | ||
|
|
||
| import { validateBotCwd } from "./bot-cwd.ts"; | ||
|
|
||
| const dir = mkdtempSync(join(tmpdir(), "omb-cwd-")); | ||
| afterAll(() => rmSync(dir, { recursive: true, force: true })); | ||
|
|
||
| describe("validateBotCwd", () => { | ||
| it("accepts an existing absolute directory", () => { | ||
| expect(validateBotCwd(dir)).toEqual({ ok: true, cwd: dir }); | ||
| }); | ||
|
|
||
| it("treats null and empty as clearing the folder", () => { | ||
| expect(validateBotCwd(null)).toEqual({ ok: true, cwd: null }); | ||
| expect(validateBotCwd("")).toEqual({ ok: true, cwd: null }); | ||
| expect(validateBotCwd(" ")).toEqual({ ok: true, cwd: null }); | ||
| }); | ||
|
|
||
| it("expands a leading ~ to the home folder", () => { | ||
| // compare against homedir() itself: a Windows home like C:\Users\RUNNER~1 | ||
| // legitimately contains "~", so "no ~ in the output" is not a valid check | ||
| expect(validateBotCwd("~")).toEqual({ ok: true, cwd: resolve(homedir()) }); | ||
| }); | ||
|
|
||
| it("rejects relative paths, files, and missing folders with a reason", () => { | ||
| expect(validateBotCwd("relative/path")).toEqual({ ok: false, error: expect.stringMatching(/absolute/) }); | ||
| const file = join(dir, "a-file.txt"); | ||
| writeFileSync(file, "x"); | ||
| expect(validateBotCwd(file)).toEqual({ ok: false, error: expect.stringMatching(/not a folder/) }); | ||
| expect(validateBotCwd(join(dir, "nope"))).toEqual({ ok: false, error: expect.stringMatching(/doesn't exist/) }); | ||
| expect(validateBotCwd(42)).toEqual({ ok: false, error: expect.stringMatching(/path/) }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // A bot's working folder — where its shell tools run. Validated here, once, | ||
| // so a bad path is refused at PATCH time with a reason the settings panel | ||
| // can show, rather than surfacing later as a driver spawn failure. | ||
| import { statSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { isAbsolute, resolve } from "node:path"; | ||
|
|
||
| export type CwdValidation = { ok: true; cwd: string | null } | { ok: false; error: string }; | ||
|
|
||
| export function validateBotCwd(input: unknown): CwdValidation { | ||
| if (input === null) return { ok: true, cwd: null }; | ||
| if (typeof input !== "string") return { ok: false, error: "working folder must be a path" }; | ||
| const trimmed = input.trim(); | ||
| if (!trimmed) return { ok: true, cwd: null }; | ||
| const expanded = trimmed === "~" || trimmed.startsWith("~/") ? homedir() + trimmed.slice(1) : trimmed; | ||
| if (!isAbsolute(expanded)) return { ok: false, error: "working folder must be an absolute path" }; | ||
| const cwd = resolve(expanded); | ||
| let stat; | ||
| try { | ||
| stat = statSync(cwd); | ||
| } catch { | ||
| return { ok: false, error: `that folder doesn't exist: ${cwd}` }; | ||
| } | ||
| if (!stat.isDirectory()) return { ok: false, error: `that path is not a folder: ${cwd}` }; | ||
| return { ok: true, cwd }; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ChevronLeft, Crown, X } from "lucide-react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ChevronLeft, Crown, FolderOpen, X } from "lucide-react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { api, useStore, type Bot } from "@/state/store"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { MausAvatar } from "./Avatar"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -9,8 +9,10 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| MAUS_COLOR_NAMES, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "@/lib/mascot"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ModelPicker } from "./ModelPicker"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useDesktopCapabilities } from "./DesktopCapabilities"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { cn } from "@/lib/cn"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { requestNotificationPermission } from "@/lib/notify"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { shortPath } from "@/lib/short-path"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function Field({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| label, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -30,6 +32,86 @@ function Field({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inputCls = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2.5 text-[15px] text-ink placeholder:text-ink-secondary focus:outline-none focus:border-hairline"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Where a bot's shell tools run. Set per bot; each task pins its own copy | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * on its first turn (the server does the pinning — Claude keeps sessions | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * per project folder, so a folder must not move under a live task). The | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * PATCH is made directly rather than through updateBot: the server | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * validates the path and a rejected folder must not stick in local state. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function WorkingFolder({ bot }: { bot: Bot }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { capabilities } = useDesktopCapabilities(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const home = capabilities.host.homeDir; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [draft, setDraft] = useState<string | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [error, setError] = useState<string | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [saving, setSaving] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const canPick = Boolean(window.ogb?.pickFolder); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const task = bot.tasks?.find((t) => t.threadId === bot.threadId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pinned = task?.cwd; // undefined = not yet, null = default, string = folder | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pinnedElsewhere = pinned !== undefined && (pinned ?? undefined) !== bot.cwd; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const save = async (cwd: string | null) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setSaving(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await api(`/api/bots/${bot.id}`, { method: "PATCH", body: JSON.stringify({ cwd }) }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setDraft(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setError(e instanceof Error ? e.message : String(e)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setSaving(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pick = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const chosen = await window.ogb?.pickFolder?.(bot.cwd); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (chosen) void save(chosen); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="rounded-xl bg-card p-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="text-[15px] font-medium text-ink">Working folder</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="mt-0.5 text-[13px] text-ink-secondary">Where this bot runs its shell and file tools.</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {canPick ? ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="mt-3 flex items-center gap-2"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="min-w-0 flex-1 truncate rounded-lg border border-hairline/40 bg-inset px-3 py-2 font-mono text-[12.5px] text-ink" title={bot.cwd}> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {bot.cwd ? shortPath(bot.cwd, home) : <span className="text-ink-secondary">Home folder</span>} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <button onClick={() => void pick()} disabled={saving} className="flex shrink-0 items-center gap-1.5 rounded-lg bg-raised px-3 py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <FolderOpen size={14} /> Choose… | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {bot.cwd && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <button onClick={() => void save(null)} disabled={saving} className="shrink-0 rounded-lg px-2 py-2 text-[13px] text-ink-secondary hover:text-ink disabled:opacity-50"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Clear | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <form | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className="mt-3 flex items-center gap-2" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onSubmit={(e) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| void save(draft ?? bot.cwd ?? ""); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className={cn(inputCls, "font-mono text-[12.5px]")} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| placeholder="Home folder — or an absolute path" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| value={draft ?? bot.cwd ?? ""} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onChange={(e) => setDraft(e.target.value)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <button type="submit" disabled={saving || draft === null} className="shrink-0 rounded-lg bg-raised px-3 py-2 text-[13px] text-ink hover:bg-raised-hover disabled:opacity-50"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Save | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+89
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Send When the user deletes an existing folder, Line 95 sends Proposed fix- void save(draft ?? bot.cwd ?? "");
+ void save(draft === "" ? null : (draft ?? bot.cwd ?? null));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </form> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {error && <div className="mt-2 text-[12px] text-danger">{error}</div>} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {pinnedElsewhere && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="mt-2 text-[12px] text-ink-secondary"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| New tasks start here. This task is pinned to {pinned ? <span className="font-mono">{shortPath(pinned, home)}</span> : "the home folder"} — start a new task to use the new folder. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function SettingsPanel({ bot }: { bot: Bot }) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { state, dispatch } = useStore(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [voices, setVoices] = useState<Array<{ id: string; label: string; description?: string }>>([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -333,6 +415,8 @@ export function SettingsPanel({ bot }: { bot: Bot }) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <WorkingFolder bot={bot} /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex items-center justify-between gap-4 rounded-xl bg-card p-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="text-[15px] font-medium text-ink">Auto mode</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50380
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50380
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50380
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50379
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 50379
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 333
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 249
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 286
Suppress the working-folder fallback for cloud tasks.
Cloud dispatch leaves
Task.cwdunset. Whenbot.cwdis configured,WorkingFolderChipdisplays the host folder for an active cloud task. Preserve the dispatch target on the task, or skip thebot.cwdfallback for cloud tasks.🤖 Prompt for AI Agents