Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .claude/skills/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Contributor agent skills

These optional workflows help coding agents work on Gravity. They are not
required to build or run the app. Start with `CONTRIBUTING.md` for dependencies
and verification; use whatever terminal, browser or native-window tools your
agent environment provides.

| Skill | Purpose | Requirements beyond the contributor setup |
| --- | --- | --- |
| `bus-live-test` | Exercise bot messaging and guardrails | Node 22+; real Claude sessions require consent and an authenticated CLI |
| `terminal-perf-test` | Exercise terminal replay and rendering | Node 22+, browser automation; synthetic runtime only |
| `marketing-screenshot` | Capture a staged native app window | macOS, Node 22+, window capture access; consent for billed sessions |
| `visual-regression` | Review and adopt CI screenshots | GitHub CLI access to the relevant workflow artifacts |
| `release` | Prepare and verify a release | Maintainer authorization and access to the intended repository's release configuration |

Local drivers require an explicit disposable daemon home, a matching published
port and a loopback endpoint. They reject the standard installed daemon home
and port. These checks are guardrails, not a sandbox: use only a daemon started
for the current test. Keep credentials, real transcripts and local agent
settings out of commits and shared tool output. Leave cleanup of existing user
data to the user unless explicitly authorized.

Shared driver checks live in `lib/` and run in CI and `pnpm run verify`. To run
just those checks:

```bash
node --test .claude/skills/lib/*.test.mjs
```
65 changes: 39 additions & 26 deletions .claude/skills/bus-live-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,46 +21,52 @@ end to end. Two modes, cheapest first:

```bash
cargo build -p gravityd
H=/tmp/gravityd-bustest # throwaway home; pick a fresh path per run
rm -rf $H && mkdir -p $H
cat > $H/gravityd.toml <<EOF
home = "$H"
port = 49666 # any free port
umask 077
BUS_HOME=$(mktemp -d "${TMPDIR:-/tmp}/gravity-bus.XXXXXX")
BUS_HOME=$(cd "$BUS_HOME" && pwd -P)
cat > "$BUS_HOME/gravityd.toml" <<EOF
home = "$BUS_HOME"
bind = ["127.0.0.1"]
port = 49666 # verify this test port is free first
negotiate_port = false
runtime = "double" # or "pty" for real mode
supervision_interval_ms = 1000

[scheduler]
tick_interval_ms = 2000 # fast expiry sweeps; default is 30s
EOF
./target/debug/gravityd --config $H/gravityd.toml > $H/gravityd.log 2>&1 &
sleep 2 && curl -s http://127.0.0.1:49666/health # expect status ok
./target/debug/gravityd --config "$BUS_HOME/gravityd.toml" > "$BUS_HOME/gravityd.log" 2>&1 &
BUS_DAEMON_PID=$!
```

For **real mode** add `claude_bin = "$(which claude)"` (absolute path — the
Wait for the published `$BUS_HOME/gravityd.port`, verify it is 49666, and
check `curl --fail http://127.0.0.1:49666/health`. Confirm the recorded PID
is still running; stop on startup failure. Never reuse the installed daemon
or stop another process to free this port.

For **real mode** add `claude_bin = "$(command -v claude)"` (absolute path — the
daemon's PATH may not include `~/.local/bin`) and set `runtime = "pty"`.

Create a project and bots with the bundled driver (Node 22+, no deps):

```bash
node .claude/skills/bus-live-test/scripts/bus.mjs setup $H 49666 \
node .claude/skills/bus-live-test/scripts/bus.mjs setup "$BUS_HOME" 49666 \
"lead:You coordinate work." "worker:You do small jobs."
# prints {"project":..., "bots":{"lead":"<id>","worker":"<id>"}}
```

Credentials on disk: client token at `$H/secrets/client.token`, per-bot MCP
tokens at `$H/secrets/bot-<id>.token`.
Credentials on disk: client token at `$BUS_HOME/secrets/client.token`, per-bot MCP
tokens at `$BUS_HOME/secrets/bot-<id>.token`.

## Synthetic mode: act as the bots

Call bus tools directly with a bot's bearer token:
Set `LEAD` and `WORKER` to the IDs printed by setup. Call bus tools with
tokens loaded from disk; do not paste token values into tool calls, terminal
arguments, logs, issues, or chat. Review responses before sharing them:

```bash
mcp() { # mcp <bot-id> <tool> <json-args>
curl -s http://127.0.0.1:49666/mcp \
-H "Authorization: Bearer $(cat $H/secrets/bot-$1.token)" \
-H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$2\",\"arguments\":$3}}" \
| python3 -c "import json,sys;r=json.load(sys.stdin)['result'];print(('REFUSED: ' if r.get('isError') else 'OK: ')+r['content'][0]['text'])"
mcp() { # mcp <bot-id> <tool> <json-args>; token is read only inside Node
node .claude/skills/bus-live-test/scripts/bus.mjs mcp "$BUS_HOME" 49666 "$@"
}
mcp $LEAD send_message '{"to":"worker","body":"do X","kind":"task"}'
mcp $WORKER complete_task '{"task_id":"...","result":"done","artifacts":["/path"]}'
Expand All @@ -81,7 +87,7 @@ Guardrail checklist — force each refusal and read its hint text:
5. **Expiry**: backdate a deadline and watch the sweep flip it and notify
both ends exactly once:
```bash
sqlite3 $H/bus.sqlite "UPDATE task SET deadline_at='2020-01-01T00:00:00Z' WHERE id='<id>';"
sqlite3 "$BUS_HOME/bus.sqlite" "UPDATE task SET deadline_at='2020-01-01T00:00:00Z' WHERE id='<id>';"
# within ~2 ticks: state='expired', WARN "task exceeded its deadline" in the log,
# one system note per live end (assignee: stop work; requester: re-delegate)
```
Expand All @@ -101,7 +107,7 @@ Wait for boot (`active_bots` in `/health`, `turn complete` per bot in the
log), then speak as the user:

```bash
node .claude/skills/bus-live-test/scripts/bus.mjs chat $H 49666 <lead-bot-id> \
node .claude/skills/bus-live-test/scripts/bus.mjs chat "$BUS_HOME" 49666 <lead-bot-id> \
"Delegate this to worker: ... When you get the result, read the artifact and tell me ..."
```

Expand All @@ -117,8 +123,8 @@ Good scenario shapes (each verified to exercise the guardrails):

- **Bus ledger** — the ground truth for chatter:
```bash
sqlite3 $H/bus.sqlite "SELECT num,sender_name,kind,substr(replace(body,char(10),' '),1,100) FROM message ORDER BY num;"
sqlite3 $H/bus.sqlite "SELECT substr(id,1,8),state,hop_count,reply_count,deadline_at IS NOT NULL FROM task;"
sqlite3 "$BUS_HOME/bus.sqlite" "SELECT num,sender_name,kind,substr(replace(body,char(10),' '),1,100) FROM message ORDER BY num;"
sqlite3 "$BUS_HOME/bus.sqlite" "SELECT substr(id,1,8),state,hop_count,reply_count,deadline_at IS NOT NULL FROM task;"
```
- **Daemon log** — bot state transitions, delivery, expiry warns.
- **Session transcripts** — tool-by-tool behavior including refusals the bot
Expand All @@ -145,12 +151,19 @@ Good scenario shapes (each verified to exercise the guardrails):
note to filter out.
- Real mode writes residue outside the throwaway home: transcript dirs in
`~/.claude/projects/` and trust entries in `~/.claude.json` for the
workspaces. Tell the user; offer to prune.
workspaces. Tell the user; do not remove it without approval.

## Cleanup

Stop only the daemon started in this live shell:

```bash
pkill -f "target/debug/gravityd --config $H"
pkill -f "claude.*$H" 2>/dev/null # real mode: orphaned sessions
rm -rf $H
kill "$BUS_DAEMON_PID"
wait "$BUS_DAEMON_PID"
```

If the shell/session was lost, verify process ownership again before signalling.
Never use broad process-name matching. Retain the run directory and logs for
review; ask before deleting its exact path. In real mode, identify any remaining
child sessions narrowly, and obtain approval before removing their exact
transcript directories or trust entries from the user's real home.
75 changes: 59 additions & 16 deletions .claude/skills/bus-live-test/scripts/bus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,57 @@
//
// node bus.mjs setup <home> <port> <name[:instructions]> ...
// node bus.mjs chat <home> <port> <bot-id> <body>
// node bus.mjs mcp <home> <port> <bot-id> <tool> <json-arguments>
//
// `setup` prints {project, bots: {name: id}} as JSON. Bot MCP tokens are on
// disk at <home>/secrets/bot-<id>.token.

import fs from 'fs';
import fs from "node:fs";
import { localDaemon } from "../../lib/local-daemon.mjs";

const [cmd, home, port, ...rest] = process.argv.slice(2);
if (!cmd || !home || !port) {
console.error('usage: bus.mjs setup|chat <home> <port> ...');
console.error("usage: bus.mjs setup|chat|mcp <home> <port> ...");
process.exit(2);
}

const token = fs.readFileSync(`${home}/secrets/client.token`, 'utf8').trim();
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const daemon = localDaemon(home, `ws://127.0.0.1:${port}/ws`);
if (cmd === "mcp") {
const [botId, tool, rawArguments] = rest;
if (
!botId ||
!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(botId) ||
!tool ||
!rawArguments
) {
throw new Error("mcp requires a bot UUID, tool name, and JSON arguments");
}
const args = JSON.parse(rawArguments);
const botToken = fs.readFileSync(`${daemon.home}/secrets/bot-${botId}.token`, "utf8").trim();
const response = await fetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(10_000),
headers: { Authorization: `Bearer ${botToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: tool, arguments: args },
}),
});
if (!response.ok) {
throw new Error(`MCP request failed (HTTP ${response.status})`);
}
console.log(JSON.stringify(await response.json()));
process.exit(0);
}
const token = fs.readFileSync(`${daemon.home}/secrets/client.token`, "utf8").trim();
const ws = new WebSocket(daemon.endpoint);
const watchdog = setTimeout(() => {
console.error("driver timed out");
process.exit(1);
}, 30_000);
let id = 0;
const pending = new Map();
const req = (msg) =>
Expand All @@ -34,38 +71,44 @@ ws.onmessage = (e) => {
pending.delete(v.req_id);
}
};
await new Promise((r) => (ws.onopen = r));
const hello = await req({ type: 'hello', protocol_version: 2, token, client: 'bus-live-test/0' });
if (hello.type !== 'hello_ok') {
console.error('handshake failed', JSON.stringify(hello));
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
const hello = await req({ type: "hello", protocol_version: 2, token, client: "bus-live-test/0" });
if (hello.type !== "hello_ok") {
console.error("handshake failed");
process.exit(1);
}

if (cmd === 'setup') {
const proj = await req({ type: 'create_project', name: 'bustest' });
if (cmd === "setup") {
const proj = await req({ type: "create_project", name: "bustest" });
const out = { project: proj.project.id, bots: {} };
for (const spec of rest) {
const [name, instructions = ''] = spec.split(':');
const separator = spec.indexOf(":");
const name = separator < 0 ? spec : spec.slice(0, separator);
const instructions = separator < 0 ? "" : spec.slice(separator + 1);
const b = await req({
type: 'create_bot',
type: "create_bot",
project_id: out.project,
name,
description: name,
instructions,
});
if (b.type !== 'bot') {
console.error('create_bot failed', JSON.stringify(b));
if (b.type !== "bot") {
console.error("create_bot failed", JSON.stringify(b));
process.exit(1);
}
out.bots[name] = b.bot.id;
}
console.log(JSON.stringify(out));
} else if (cmd === 'chat') {
} else if (cmd === "chat") {
const [botId, body] = rest;
const r = await req({ type: 'send_user_message', to_bot_id: botId, body });
const r = await req({ type: "send_user_message", to_bot_id: botId, body });
console.log(JSON.stringify(r.message ? { sent: r.message.id } : r));
} else {
console.error(`unknown command '${cmd}'`);
process.exit(2);
}
clearTimeout(watchdog);
ws.close();
47 changes: 47 additions & 0 deletions .claude/skills/lib/local-daemon.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";

/**
* Check a disposable daemon before reading credentials or opening a socket.
* @param {string | undefined} home
* @param {string} endpoint
* @returns {{ home: string, endpoint: URL }}
*/
export function localDaemon(home, endpoint) {
const url = new URL(endpoint);
if (
url.protocol !== "ws:" ||
url.hostname !== "127.0.0.1" ||
url.pathname !== "/ws" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!url.port ||
url.port === "49777"
) {
throw new Error("Use ws://127.0.0.1:<test-port>/ws; production port 49777 is forbidden");
}
if (!home) {
throw new Error("Set GRAVITY_HOME to the disposable daemon home created for this run");
}
const canonicalHome = realpathSync(home);
const installedHome = resolve(realpathSync(homedir()), ".gravity");
let canonicalInstalledHome = installedHome;
try {
canonicalInstalledHome = realpathSync(installedHome);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
if (canonicalHome === canonicalInstalledHome) {
throw new Error("The installed daemon home is forbidden; create a disposable home");
}
const port = readFileSync(join(canonicalHome, "gravityd.port"), "utf8").trim();
if (port !== url.port) {
throw new Error("Endpoint does not match the disposable daemon's published port");
}
return { home: canonicalHome, endpoint: url };
}
28 changes: 28 additions & 0 deletions .claude/skills/lib/local-daemon.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { localDaemon } from "./local-daemon.mjs";

test("reject unsafe endpoints before reading any home or credentials", () => {
for (const endpoint of [
"ws://example.org:49555/ws",
"wss://127.0.0.1:49555/ws",
"ws://127.0.0.1:49777/ws",
"ws://user:password@127.0.0.1:49555/ws",
"ws://127.0.0.1:49555/ws?token=example",
"ws://127.0.0.1:49555/other",
]) {
assert.throws(() => localDaemon(undefined, endpoint), /test-port/);
}
});

test("require an explicit home and match its published port", (context) => {
assert.throws(() => localDaemon(undefined, "ws://127.0.0.1:49555/ws"), /GRAVITY_HOME/);
const home = mkdtempSync(join(tmpdir(), "gravity-skill-test-"));
context.after(() => rmSync(home, { recursive: true }));
writeFileSync(join(home, "gravityd.port"), "49555\n");
assert.equal(localDaemon(home, "ws://127.0.0.1:49555/ws").endpoint.port, "49555");
assert.throws(() => localDaemon(home, "ws://127.0.0.1:49888/ws"), /published port/);
});
Loading
Loading