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
2 changes: 2 additions & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The maintained list of what a person can do in the app, and how to prove each on
| Chat's foot and reply box: a live turn's spinner, timer, Stop and Queue; a turn that ended while its work runs on shows an hourglass and Send | [chat-turn-state.md](chat-turn-state.md) | `chat-turn-state`; `chat-turn-state-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` asserts it |
| Chat and board: no question card or answers but the options the agent's screen shows; the mode menu while turn check waits; a structured agent's form while its helpers report back, and a reply box that answers a card with nothing to pick | [chat-ask-card.md](chat-ask-card.md) | `chat-ask-card`, `chat-structured-ask-live` (a real box, `--agent`); `app/e2e/ask-card.spec.ts` on a stand-in box |
| The board's cards for agents in chat: what they ask with its answers, their reply's last lines, the prompt they started with; never the host's frames or "stdio" | [chat-board-cards.md](chat-board-cards.md) | `chat-board-cards`; `app/e2e/dashboard-chat-cards.spec.ts` asserts it |
| An agent asks for a secret: the card in its chat and on the board (masked field, reveal, expiry, production's warnings and no expiry), Save securely in one POST and then the value nowhere, a refused save keeping it in the field only, Decline, the notification | [secret-request.md](secret-request.md) | `secret-request`; `app/src/lib/secret-request.test.ts` |
| Settings › Secrets: each box's saved secrets by name and facts, never a value; a production delete needs the name typed; where a box and a project save them | [settings-secrets.md](settings-secrets.md) | `settings-secrets` |
| Worktrees changed with git outside Shipyard: added, committed in, switched, locked, removed; a new remote; a quiet box runs no git for them | [worktree-git.md](worktree-git.md) | `worktree-git-live` (a real box, `--agent`); `TestListingLocationsAgainRunsNoGitUntilSomethingChanges` |
| Turn check's "working": an hourglass with why in the sidebar, the tab and the rail's card, not a live turn's spinner; a handoff reads as done | [turn-check-indicator.md](turn-check-indicator.md) | `turn-check-indicator`; `app/e2e/sidebar-turn-check.spec.ts` asserts it, `TestTurnCheckLive` asks the real provider |
| Upgrading a box: sessions keep running, and nothing the old build started stays a zombie under berthd | [box-upgrade.md](box-upgrade.md) | `../scripts/upgrade-zombies.sh FROM TO` (a real box, Linux); `go test ./internal/childreap` |
Expand Down
42 changes: 42 additions & 0 deletions .claude/skills/verify-berthd/features/secret-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# An agent asks for a secret

An agent that needs an API key or a token asks the person for it with `berthd secret request NAME --reason "…"`. The box holds the request on the agent's session (`Session.secret_request`, with `agent_state: "waiting"`, `soft_wait: true` and `ask.tool: "SecretRequest"`) until it is saved, declined or an hour passes. The app draws a **secret card** wherever the agent's question would show (`components/conversation/secret-request-card.tsx`, its state in `lib/secret-request.ts`):

- **In its chat**, at the foot of the conversation (`conversation-pane.tsx`, the chat's tail), never as Allow / Deny. The reply box says "Give NAME in the card above, never here".
- **On the Agent Dashboard**, in its card in place of its last lines (`views/dashboard/agent-card.tsx`, `compact`).
- **As a notification**: `secret.requested` routes "AGENT asks for a secret", "NAME — reason", in An agent needs you (`lib/events.ts`); `secret.request.ended` settles it (`lib/notifications.ts`). The agent's own `agent.waiting` for it is not a second one. Home's Needs you row and the rail's card say "Asks for the secret NAME".

The card shows the name (Replace NAME, with when the old one was saved, when `replace`), the reason verbatim, who asks, where it goes (`store_label · where`), the environment, any grant ("Grants … read access to this one secret"), a masked field with a reveal toggle (Show the value / Hide the value), an expiry picker (No expiry, 1 hour, 24 hours, 7 days; the agent's `ttl` preselected), and "The agent can use this in commands. A command that prints it puts it in the chat." **Production** shows the environment, project and grant in the warning colour (`text-warning-foreground`) and has no expiry picker: it never expires.

**Where it goes.** When the store is GCP and the box names more than one project, the card has a **Project** select with the request's own first and the box's private project marked "(private)" (`where_options`, `private_where`). What the person picks goes with the save as `where`; the agent can't choose it. When a name template makes the secret's name in the store differ from the name the agent uses, **Saved as** shows it (`secret_id`).

**Save securely** reads the field once and sends it in one request, `POST secrets/requests/{id}/save` with `{ value, expires, where? }`; the box answers with a reference (`box://NAME`, `gcp://projects/P/secrets/NAME`). The field is uncontrolled, so the value is in no React state, prop or attribute while typed, and after the save it is cleared and the card goes. A save the box refuses (or a box away) shows its words on the card, and the value stays in the field only: nothing queues or retries it. **Decline** posts `secrets/requests/{id}/decline`. Both buttons are disabled while either is in flight.

## Sub-features

- `secret-card-chat`: the card in the chat, with every field above, and no Allow / Deny.
- `secret-card-board`: the card on the Agent Dashboard's card, shorter (Never / 1h / 24h / 7d).
- `secret-reveal`: the toggle flips the field between `password` and `text`.
- `secret-save`: one POST, then the value is in no DOM node, field, transcript item or storage, and the card goes.
- `secret-production`: warning colour on environment, project and grant; no expiry picker; Replace with when.
- `secret-project-picker`: the select offers the request's project and the private one; the save goes to the one picked, with the template's name in the reference.
- `secret-refused`: the box's words on the card ("Couldn't save it: … The value is still in the field, and nowhere else."), the value in the field, no second POST.
- `secret-decline`: one POST to decline, the card goes, the field with it.
- `secret-notification`: the needs-you notification, settled when the request ends.

## How to get to it (user POV)

- An agent runs `berthd secret request ACME_TOKEN --reason "…"`. Its chat, its card on ⌘J's board and a notification show the request.

## Driving it with drive.mjs

- On the mock: `scenarios/secret-request.mjs` opens `?mock=1&secrets=1&sidebar=list&view=conversation`. With `?secrets` (`lib/mock-secrets.ts`), devl's search-perf Claude ("Speed up product search") asks for `ACME_TOKEN` (staging, Encrypted file, `ttl: "24h"`, a two-line reason), and gpu's ci-flake Claude ("Move acme billing to the new ledger") asks to replace `ACME_LEDGER_PROD` (production, GCP Secret Manager · acme-prod, a grant, saved 9 days ago), and gpu's judge-v2 Claude asks for `ACME_FRONT_KEY` (staging, saved as `ACME_FRONT_KEY_EVALS_STAGING`, acme-staging or acme-private). `window.__secrets.announce()` plays their `secret.requested`.
- Second views: `window.__berthCalls` (every box call the app made, by method and path, as the mock counts them) for the one POST, and `window.__secretSaves` (box, request, expiry; never the value) for what reached the box. A value with `denied` in it is refused as GCP refuses a missing permission.
- It asserts the board's two cards, the chat card's every field, that the typed value is in no attribute, the reveal toggle, the notification, the save's one POST with `7d`, that afterwards the value is in no `outerHTML`, field value, chat item or storage, the production card's warning classes and no picker, the refusal keeping the value in the field with no second POST, and Decline. Screenshots: `1-board-cards`, `2-chat-card`, `3-revealed`, `3b-notification`, `4-saved`, `5-production-card`, `6-save-refused`, `7-declined`.
- `app/e2e/a11y-scenes.ts` has `chat-secret-request` (axe over the production card); `app/src/lib/secret-request.test.ts` covers the card's state, the expiry rules and production's.

## Gotchas

- `document.documentElement.outerHTML` doesn't hold a field's live value; the scenario reads every `input` and `textarea`'s `.value` too.
- The mock answers a save after 300 ms and ends the request just before, so the card can go before Save's own answer lands.
- A real box is the only proof that the laptop agent and the box keep no copy of the value (no log, no event); the mock can't show it.
29 changes: 29 additions & 0 deletions .claude/skills/verify-berthd/features/settings-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Settings › Secrets

The secrets agents asked for and the person saved, per online box, and where each box saves them (`views/settings/secrets-section.tsx`, `GET secrets`, `POST secrets/delete`, `PUT secrets/settings`). Never a value: the box never sends one back, and the page has no field or Show for one.

- **The list**: each secret's name, its environment (Production in the warning colour), where it is (`store_label · where`), when it expires ("never" for production), when it was saved, and who asked (the session's work, as the box still has it) and why. A store the box couldn't list (`errors`, a GCP project it has no access to) says so in red.
- **Delete**: asks first. A production secret wants its name typed exactly before Delete enables, and sends it as `confirm`; a staging one needs only the confirm. The list reads again on `secret.saved` and `secret.deleted`, from anywhere.
- **Where it saves them**: the box's default store (from `stores`) and, for GCP, its project; and per project (each of the box's locations) its own store and project, or "Box default". Where GCP is on offer: the box's private project (the card also offers it), its name template (`{NAME}_{SERVICE}_{ENV}`), and each project's own template and service name while that project saves to GCP. One Save per box sends `PUT secrets/settings`. A row saved under a template says "Saved as ID".
- Find a setting answers to "api token", "keys", "credentials", "secret manager"; ⌘K has it too.

## Sub-features

- `secrets-list`: names and facts only, per box, with the box's listing errors.
- `secrets-delete-production`: Delete disabled until the name is typed exactly; then one `POST secrets/delete`.
- `secrets-delete-staging`: Delete after a confirm.
- `secrets-where`: the default store and a project's own, saved.

## How to get to it (user POV)

- Settings (the gear at the sidebar's foot) › Agents › Secrets, or ⌘K "Secrets".

## Driving it with drive.mjs

- On the mock: `scenarios/settings-secrets.mjs` opens `?mock=1` (no `?secrets` needed). devl keeps `ACME_STRIPE_TEST` (staging, Encrypted file, expires in 4 days); gpu keeps `ACME_PROD_DB` (production, acme-prod) and `ACME_LEDGER_KEY` (staging, acme-staging), saves to GCP · acme-staging by default and evals to its encrypted file, and can't list acme-legacy (`lib/mock-secrets.ts`).
- It asserts each row's facts and who asked, the listing error, no password field or Show, gpu's store choices, shop given GCP · acme-shop and saved (`PUT gpu/secrets/settings` once, in `window.__berthCalls`), the production delete locked on `ACME_PROD` and `acme_prod_db` and open on `ACME_PROD_DB` (one `POST gpu/secrets/delete`), the staging delete, and Find a setting. Screenshots: `1-secrets-list`, `2-where-saved`, `3-production-delete-locked`, `4-production-delete-typed`, `5-after-deletes`.
- `app/e2e/a11y-scenes.ts` has `settings-secrets`; `app/e2e/look.spec.ts`'s "every Settings section opens" opens it.

## Gotchas

- A box without the secrets API answers `GET secrets` with an error; the box's group shows it with `BoxError` (an older berthd gets Update).
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// secret-request-live: the save step of ../secret-request-live.sh, in the
// app against a dev box (VERIFY_DRIVER=claude ../dev-box.sh up). The real
// Claude in session "ask" has asked for ACME_TOKEN; this opens its chat,
// finds the card, types the run's sentinel (VERIFY_SECRET_SENTINEL), and
// presses Save securely, as a person would. The card goes, the request is
// saved on the box, and the value is in no DOM node afterwards. See
// ../../features/secret-request.md.
export const live = true;
export const params = { view: "conversation", sidebar: "list" };

export async function run({ page, shot, step, expect, call }) {
const sentinel = process.env.VERIFY_SECRET_SENTINEL;
const worktree = process.env.VERIFY_WORKTREE;
if (!sentinel || !worktree) throw new Error("run by ../secret-request-live.sh: VERIFY_SECRET_SENTINEL and VERIFY_WORKTREE");

step("open the agent's chat");
await page.locator(`[data-testid=worktree-row][data-worktree="${worktree}"]`).click({ position: { x: 32, y: 12 } });
const pane = page.locator("[data-testid=pane]:visible").first();
await expect(pane.getByTestId("chat")).toBeVisible({ timeout: 30_000 });

step("the agent's request shows as a masked card");
const card = pane.getByTestId("chat").getByTestId("secret-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card.getByTestId("secret-name")).toHaveText("ACME_TOKEN");
await expect(card.getByTestId("secret-reason")).toHaveText("verify: deploy the acme app to staging");
await expect(card.getByTestId("secret-store")).toHaveText(/Encrypted file|Keychain|Secret Service/);
const input = card.getByTestId("secret-input");
await expect(input).toHaveAttribute("type", "password");
await shot("1-live-card");

step("type the value and save it securely");
await input.fill(sentinel);
await card.getByTestId("secret-save").click();
await expect(pane.getByTestId("chat").getByTestId("secret-card")).toHaveCount(0, { timeout: 30_000 });
await shot("2-live-saved");

step("the box has it saved, and the page holds the value nowhere");
const pending = await call("/v1/boxes/devq/api/secrets/requests");
if (pending.some((r) => r.session === "ask")) throw new Error(`still pending: ${JSON.stringify(pending)}`);
const inPage = await page.evaluate((s) => document.documentElement.outerHTML.includes(s) || [...document.querySelectorAll("input,textarea")].some((el) => el.value.includes(s)) || JSON.stringify({ ...localStorage }).includes(s) || JSON.stringify({ ...sessionStorage }).includes(s), sentinel);
if (inPage) throw new Error("the value is still in the page");
}
Loading