Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ COLLIE_SUBMIT_KEYS=Enter
# Likewise your own preset chords: `keys.toml` next to this file, from `keys.toml.example`
# (README → Your own key presets).

# --- Launcher menu ---
# Your own dashboard launchers are NOT set here — they live in `launchers.toml` next to this file,
# from `launchers.toml.example` (README → Your own launchers).

# --- Pane history (the agent's own session log) ---
# On by default. This is the ONLY scrollback most agent panes can have: an agent TUI runs on the
# terminal's alternate screen, which keeps no scrollback ring, so history is read from the log the
Expand Down
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,9 @@ app. Closing this needs the server-side blocking-message capture described above
behind an mtime check (`bridge/operator-commands.ts`), so editing the file is live like a web
rebuild. On a pane they address they **replace** the shipped catalog rather than merging into it —
[ADR 0018](./.adr/0018-operator-command-rows-replace-the-catalog.md). Their **Keys-tray presets**
ride the same request on the same terms, from `keys.toml` (`bridge/operator-keys.ts`); the two
files share one reader (`bridge/operator-file.ts`) and one scope ladder
ride the same request on the same terms, from `keys.toml` (`bridge/operator-keys.ts`), and their
**launcher rows** do too, from `launchers.toml` (`bridge/operator-launchers.ts`); the three
files share one reader (`bridge/operator-file.ts`) and the first two share one scope ladder
(`web/src/lib/operator-scope.ts`).

## 6. Security model
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ the unit name; the Herdr action runs from anywhere.
Ctrl presets on the panes they address (ADR 0018 again), and only those presets: the tray's
keyboard is fixed. Both files share one reader (`bridge/operator-file.ts`) and one scope ladder
(`web/src/lib/operator-scope.ts`); teach both, never one.
- **`launchers.toml` is `commands.toml`'s sibling on the dashboard** — its rows are the allowlist
`POST /api/launch` matches exactly, so the client names a row and never supplies a command line;
the bridge re-reads the file behind an mtime check, so edits are live and need no restart. Do not
add a second allowlist or let the client supply a command line.
- **PWA** via `vite-plugin-pwa` (`web/vite.config.ts`): manifest + `sw.js`, registered manually
from `virtual:pwa-register` in `main.tsx` (bundled = CSP-safe). Install/SW need a **secure
context** — over plain HTTP they no-op silently (Chrome insecure-origin flag, or HTTPS, to test).
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,35 @@ is fixed and not configurable. Chords are herdr's spelling: `ctrl+c` (never `C-c
open a pane, tap **Keys → Presets**, your buttons are there. Rejected row?
`journalctl --user -u collie -n 20` names it and why.

### Your own launchers

One tap to open a new Space and run a command you declared, in `launchers.toml` next to `keys.toml`:

```bash
cp launchers.toml.example "$(herdr plugin config-dir herdr.collie)/launchers.toml"
```

```toml
[[launchers]]
command = "rumen-peek" # required; shell line typed verbatim into the new Space
label = "Runs & quota" # optional; defaults to the first word of command
# cwd = "~/dev/collie" # optional; defaults to your home dir, ~ expanded
```

Tapping a row creates a new Space labelled with its `label` in its `cwd`, types the `command`
verbatim into the fresh shell and sends Enter — the command owns its own lifetime, so a
self-closing peek takes the whole Space with it when you quit while `htop` sits there until you
close it. This file is the allowlist: `POST /api/launch` only accepts a `command` that matches a
row here exactly, so nothing absent from it can be launched from a phone. No restart — edits are
live, though an already-open tab reads the rows once per load.

Your rows appear twice: as a **Launch** section on the dashboard, between the agent list and
Spaces, which folds like Spaces and Recent; and behind the 🚀 in the Space and pane headers, which
opens them as a sheet showing each command under its label — that is the one you want when you are
reading an agent and do not want to go Home first. Declare none and neither appears. Verify: reload
the dashboard, your buttons are under the herd. Rejected row?
`journalctl --user -u collie -n 20` names it and why.

### Multi-session

`COLLIE_MULTI_SESSION=on` (the default) discovers and serves every named Herdr session under your
Expand Down
7 changes: 7 additions & 0 deletions bridge/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ export interface Config {
* in the same dir, read the same way (bridge/operator-keys.ts) and likewise never read here.
*/
keysFile: string;
/**
* Where the operator's launcher rows live — `launchers.toml`, the sibling of `commands.toml`
* and `keys.toml` in the same dir, read the same way (bridge/operator-launchers.ts) and
* likewise never read here.
*/
launchersFile: string;
/**
* Tailscale identity gate. If set, any request carrying a `Tailscale-User-Login` header
* (injected by `tailscale serve`) must match this login — a mismatching tailnet user is
Expand Down Expand Up @@ -278,6 +284,7 @@ export function loadConfig(): Config {
submitKeys: submitKeys.length ? submitKeys : ["Enter"],
commandsFile: join(configDir, "commands.toml"),
keysFile: join(configDir, "keys.toml"),
launchersFile: join(configDir, "launchers.toml"),
trustedUser: process.env.COLLIE_TRUSTED_USER ?? "",
auditContent: envEnum("COLLIE_AUDIT_CONTENT", ["preview", "none"] as const, "preview"),
deviceHeader: (process.env.COLLIE_DEVICE_HEADER ?? "").trim(),
Expand Down
251 changes: 251 additions & 0 deletions bridge/operator-launchers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { describe, expect, test } from "bun:test";
import { homedir } from "node:os";
import { join } from "node:path";

import { createOperatorLaunchers, validateOperatorLaunchers } from "./operator-launchers.ts";
import type { OperatorFileIo } from "./operator-file.ts";

// The dashboard's launch strip: the operator's own shell-line rows, typed verbatim into a new
// Space's shell. Driven exactly like the commands.toml and keys.toml suites — the validator with
// parsed TOML, the reader through a fake io.

const quiet = () => {};

/** Parse a TOML source the way the reader does, then validate it — the whole grammar in one call. */
function rows(toml: string) {
return validateOperatorLaunchers(Bun.TOML.parse(toml), quiet);
}

describe("validateOperatorLaunchers", () => {
test("nothing declared yields nothing", () => {
expect(rows("")).toEqual([]);
expect(rows("launchers = []")).toEqual([]);
expect(validateOperatorLaunchers(undefined, quiet)).toEqual([]);
expect(validateOperatorLaunchers(null, quiet)).toEqual([]);
});

test("a minimal row keeps the command and defaults the rest", () => {
const out = rows(`[[launchers]]
command = "rumen-peek"`);
expect(out).toEqual([{ command: "rumen-peek", label: "rumen-peek", cwd: homedir() }]);
});

test("label defaults to the command's first whitespace-separated token", () => {
expect(rows(`[[launchers]]
command = "make test"`)[0]!.label).toBe("make");
expect(rows(`[[launchers]]
command = " bun run foo "`)[0]!.label).toBe("bun");
// A single-token command labels itself.
expect(rows(`[[launchers]]
command = "htop"`)[0]!.label).toBe("htop");
});

test("an explicit label wins over the default", () => {
const out = rows(`[[launchers]]
command = "rumen-peek"
label = "Runs & quota"`);
expect(out[0]!.label).toBe("Runs & quota");
expect(out[0]!.label).not.toBe("rumen-peek");
});

test("cwd expansion of ~ and ~/sub, and the default", () => {
expect(rows(`[[launchers]]
command = "a"
cwd = "~"`)[0]!.cwd).toBe(homedir());
expect(rows(`[[launchers]]
command = "a"
cwd = "~/sub"`)[0]!.cwd).toBe(
join(homedir(), "sub"),
);
expect(rows(`[[launchers]]
command = "a"
cwd = "~/a/b"`)[0]!.cwd).toBe(
join(homedir(), "a/b"),
);
// A non-tilde cwd is passed through verbatim (after trim).
expect(rows(`[[launchers]]
command = "a"
cwd = "/tmp/foo"`)[0]!.cwd).toBe("/tmp/foo");
// No cwd defaults to the operator's home dir.
expect(rows(`[[launchers]]
command = "a"`)[0]!.cwd).toBe(homedir());
});

test("drops a row whose command is missing, empty, non-string, or control-character-bearing", () => {
// Missing/empty/non-string command — the allowlist key itself is absent.
expect(rows(`[[launchers]]
label = "A"`)).toEqual([]);
expect(rows(`[[launchers]]
command = ""`)).toEqual([]);
expect(rows(`[[launchers]]
command = " "`)).toEqual([]);
expect(rows(`[[launchers]]
command = 42`)).toEqual([]);
// A control character means the shell would see a second line nobody reviewed — the row is
// dropped, not sanitised. Use object-level validation because TOML cannot encode a raw newline
// inside a basic string without escaping.
expect(validateOperatorLaunchers({ launchers: [{ command: "a\nb" }] }, quiet)).toEqual([]);
expect(validateOperatorLaunchers({ launchers: [{ command: "a\tb" }] }, quiet)).toEqual([]);
expect(validateOperatorLaunchers({ launchers: [{ command: "a\x00b" }] }, quiet)).toEqual([]);
expect(validateOperatorLaunchers({ launchers: [{ command: "a\rb" }] }, quiet)).toEqual([]);
expect(validateOperatorLaunchers({ launchers: [{ command: "a\x7fb" }] }, quiet)).toEqual([]);
// Good siblings survive a bad row.
expect(
validateOperatorLaunchers({ launchers: [{ command: "a\nb" }, { command: "ok" }] }, quiet),
).toMatchObject([{ command: "ok" }]);
});

test("a non-string or empty label drops the row, not silently falls back", () => {
expect(rows(`[[launchers]]
command = "a"
label = 42`)).toEqual([]);
expect(rows(`[[launchers]]
command = "a"
label = ""`)).toEqual([]);
expect(rows(`[[launchers]]
command = "a"
label = " "`)).toEqual([]);
// Good siblings survive.
expect(
rows(`
[[launchers]]
command = "a"
label = ""

[[launchers]]
command = "ok"
`),
).toMatchObject([{ command: "ok" }]);
});

test("a non-string or empty cwd drops the row", () => {
expect(rows(`[[launchers]]
command = "a"
cwd = 42`)).toEqual([]);
expect(rows(`[[launchers]]
command = "a"
cwd = ""`)).toEqual([]);
expect(rows(`[[launchers]]
command = "a"
cwd = " "`)).toEqual([]);
expect(
rows(`
[[launchers]]
command = "a"
cwd = ""

[[launchers]]
command = "ok"
`),
).toMatchObject([{ command: "ok" }]);
});

test("a later row for the same command replaces the earlier one IN PLACE with a warning", () => {
const out = rows(`
[[launchers]]
command = "rumen-peek"
label = "A"

[[launchers]]
command = "other"
label = "B"

[[launchers]]
command = "rumen-peek"
label = "C"
`);
expect(out.map((r) => r.label)).toEqual(["C", "B"]);
expect(out.map((r) => r.command)).toEqual(["rumen-peek", "other"]);
expect(out).toHaveLength(2);
});

test("a `launchers` value that is not an array warns and yields []", () => {
expect(validateOperatorLaunchers({ launchers: "nope" }, quiet)).toEqual([]);
expect(validateOperatorLaunchers({ launchers: 42 }, quiet)).toEqual([]);
// An object where an array belongs costs the whole file, not just its own key.
expect(validateOperatorLaunchers({ launchers: { command: "a" } }, quiet)).toEqual([]);
// But a row that is not a table only costs itself — its siblings survive.
expect(
validateOperatorLaunchers({ launchers: ["not a table", { command: "ok" }] }, quiet),
).toMatchObject([{ command: "ok" }]);
});

test("a row that is not a table is dropped while its siblings survive", () => {
expect(
validateOperatorLaunchers(
{ launchers: [{ command: "ok" }, "bad", 42, null, { command: "ok2" }] },
quiet,
),
).toMatchObject([{ command: "ok" }, { command: "ok2" }]);
});
});

/** An io whose file contents and mtime are set by hand, counting every read it is asked for. */
function fakeIo(initial: { mtime: number | null; text: string }) {
const state = { ...initial, reads: 0 };
const io: OperatorFileIo = {
mtime: async () => state.mtime,
read: async () => {
state.reads += 1;
if (state.mtime === null) throw new Error("ENOENT");
return state.text;
},
};
return { io, state };
}

const ONE_ROW = `[[launchers]]
command = "a"`;
const TWO_ROWS = `${ONE_ROW}

[[launchers]]
command = "b"`;

describe("createOperatorLaunchers", () => {
test("parses once and serves the cache until the mtime moves", async () => {
const { io, state } = fakeIo({ mtime: 100, text: ONE_ROW });
const read = createOperatorLaunchers("/cfg/launchers.toml", io, quiet);
expect(await read()).toMatchObject([{ command: "a" }]);
expect(await read()).toMatchObject([{ command: "a" }]);
expect(state.reads).toBe(1);

state.text = TWO_ROWS;
state.mtime = 200;
expect(await read()).toMatchObject([{ command: "a" }, { command: "b" }]);
expect(state.reads).toBe(2);
});

test("a malformed rewrite keeps the last good rows, and does not re-read", async () => {
const { io, state } = fakeIo({ mtime: 100, text: ONE_ROW });
const read = createOperatorLaunchers("/cfg/launchers.toml", io, quiet);
expect(await read()).toMatchObject([{ command: "a" }]);

state.text = "[[launchers]\ncommand = ";
state.mtime = 200;
expect(await read()).toMatchObject([{ command: "a" }]);
// Warned once per change, not once per request: the failed mtime is remembered too.
expect(await read()).toMatchObject([{ command: "a" }]);
expect(state.reads).toBe(2);

state.text = TWO_ROWS;
state.mtime = 300;
expect(await read()).toMatchObject([{ command: "a" }, { command: "b" }]);
});

test("no file at all is not an error", async () => {
const { io, state } = fakeIo({ mtime: null, text: "" });
const read = createOperatorLaunchers("/cfg/launchers.toml", io, quiet);
expect(await read()).toEqual([]);
expect(state.reads).toBe(0);

state.text = ONE_ROW;
state.mtime = 100;
expect(await read()).toMatchObject([{ command: "a" }]);
});

test("a file that never parsed serves empty rather than failing", async () => {
const { io } = fakeIo({ mtime: 100, text: "nonsense = [" });
const read = createOperatorLaunchers("/cfg/launchers.toml", io, quiet);
expect(await read()).toEqual([]);
});
});
Loading
Loading