1 Commits

Author SHA1 Message Date
2fd2a4a71a security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)
getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for
hop and read x-real-ip unconditionally. In our topology — client → Cloudflare
→ same-host origin proxy → app — both proxies APPEND, so the leftmost hop is
whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit
bucket per request, and unlimited bug-report quota via the same pattern in
routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in
the chain overwrites it. It is no longer read at all.

New trust order (src/server/trusted-client-ip.ts, resolveTrustedClientIp):

  1. a non-loopback socket peer IS the client — its headers are ignored;
  2. cf-connecting-ip (Cloudflare overwrites it at the edge; parsed as an
     address or rejected);
  3. x-forwarded-for counted from the RIGHT, discounting one hop for the
     Cloudflare edge address the origin proxy appended (the topology has
     exactly one trusted proxy below Cloudflare);
  4. "unknown" — callers key stricter shared limits on it, so resolution
     fails closed toward a tighter bucket, never a looser one.

Addresses are never truncated: a /24-truncated key would pool attackers with
the innocents behind them.

Share-vs-copy, stated deliberately: this is a port of the parsing primitives
 FRE-366 reviewed in request-client.ts, NOT an import. resolveClientIp carries
a standing rule (1d9287a) that it never feeds a security decision; lifting
that rule for these callers would couple an access-control change to an
analytics branch under review, and the analytics helper only exposes
truncated prefixes while rate limiting needs the full address. If the two
drift, trusted-client-ip.ts is the one security paths follow. The deploy-side
precondition (origin reachable only via Cloudflare + the same-host proxy) is
documented in the file header; if the app is ever directly internet-reachable
no in-app read can recover the true client.

Tests updated deliberately, not to go green: rate-limit.test.ts now pins the
rightmost-minus-one order, cf-connecting-ip precedence, peer-over-headers,
x-real-ip rejection, and the spoof case (a rotating leftmost hop does not
change the resolved client); report.test.ts pins the quota key stability
under a rotating header. New trusted-client-ip.test.ts covers the helper's
own edges: APIEvent wrapper, loopback forms, unparseable hops failing closed.
2026-10-30 05:06:54 +00:00
9 changed files with 232 additions and 386 deletions

View File

@@ -1,79 +0,0 @@
# Remote SSH copy — HELD, do not publish
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
correction, and **not yet off the live site** — the deploy is blocked on a write
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
serves every sentence below until that push or a Vercel deploy lands.
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
verifies that flow, and a recording shows a remote agent joining the island.
Until then this file is the only home for these sentences.
## What was removed
### 1. Landing collage tile
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Agents on other machines
body: Run agents on Linux boxes and SSH servers — one click installs the
nook-hook, and their sessions join the same island.
```
The mock attached to it showed two session rows whose agent chips named
`build-box` and `gpu-rig`.
### 2. Landing feature breakdown section
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Any SSH box, one click away
body: Pick a host from your ssh config or your tailnet and click once — the
nook-hook installs itself and its agents appear beside your local ones,
indistinguishable.
bullets:
- One click: connect, push the hook, configure agents, check it can reach this Mac
- Hosts mirror this Mac's installed integrations automatically
- Remote sessions are ordinary rows; the agent chip names the machine
```
The section's demo animated a `gpu-rig` host moving from "Not set up" through
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
host can reach this Mac` → `Live`, then showed a remote session row.
### 3. Approvals bullet
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
- Works across every hooked agent, local or remote
```
### 4. Feature chip
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
```
One-click SSH setup
```
## What replaced it
Nothing. The Remote tile and section were removed rather than replaced; the
existing Approvals cell already carries the verified local claim:
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
## Restore checklist
1. FRE-320/FRE-205 have shipped the one-click install and removed the
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
3. A recording exists showing a remote agent joining the island.
4. CMO restores the copy above, then owns the launch plan for it — with an
owner and date per item — before anything goes back on the site.

View File

@@ -1,30 +0,0 @@
# Why the remote claim is parked
The site was selling a setup path a buyer does not get.
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
with `BatchMode=yes`, and has no manual fallback for minting the per-host
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
unverified and demo-gated. The CTO named it the largest launch risk because it
was already in front of buyers.
So the `freno-dev` source now says only what the product can back today: local
agents, the card, and the notch. That correction is committed locally and is
**not deployed** — the live page still carries the remote sentences until the
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
install ships and is recorded.
## What has to be true before it returns
| Requirement | Owner | Date |
|---|---|---|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
Nothing in this folder is publishable. `marketing/approved/` remains the only
place a human publishes from.

View File

@@ -4,37 +4,13 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
artifact invents its own framing, fix the artifact or fix this file — never both
silently diverging.
**Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322],
[FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
no longer exists; the verdicts below are the source of truth it was pointing at.
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
## The sentence
There is no single approved public sentence yet, and saying otherwise is the one
mistake this file exists to prevent. What exists is a **launch claim set** — two
sentences, both from one 15-second take, recorded as the chosen route by the Chief
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
| Slot | Exact sentence | Status today |
|---|---|---|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
Working line for internal writing, not for publication:
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
> see what is running, you unblock what is waiting, and the machine stays cool
> while it works.
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
> fleet in the notch — thirteen agents, local or over SSH — so you see what is running,
> unblock it in one tap, and keep the machine from overheating while it works.
## The wedge
@@ -46,18 +22,15 @@ The thing to say over and over, in every channel, in slightly different words:
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
Allow, work resumes. Ten seconds, no explanation needed.
## The consolidation reframe
## The bundling reframe
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
app reads like a grab bag. Reframe as consolidation, which is the honest story:
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
> strip, and a notification hack. One purchase, no subscription.
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
> stats strip, and a notification hack. Same one-time purchase, no subscription.
**No price, no discount and no future price in copy.** The price is $10, it stays
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
price change is a Chief of Staff + sponsor call; it is never a marketing decision
and never a launch asset.
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
## Hard rules
@@ -75,49 +48,24 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
community replies written by a human, where quoting what a user said about another
tool is normal conversation.
### 2. A claim needs a verdict, not just a test
### 2. A claim requires a test to exist
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
tables below, it does not go in copy.
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
If a claim cannot be pointed at code or a test, it does not go in copy.
### 3. Honest privacy scoping
Telemetry and the licence check **do** leave the device: Sentry runs on **every
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
Telemetry and the trial email **do** leave the device. The defensible claim is:
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
- "nothing leaves your Mac"
> **No prompt or code content leaves the device.**
The one favourable engine sentence — about the **hook and bridge only**, never
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
service**. Do not write "talk to nothing but your Mac" (too absolute: the
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
it to "over SSH" (a tailnet peer dials directly).
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
**held** until the disclosures land: Sentry gated behind a preference or removed,
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
false ones is worse than no sentence.
Never "no data leaves the device".
### 4. Sell what works today
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way.
Remote SSH is **held**, not approved: the one-click install has not
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
recording shows a remote agent joining the notch.
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
parentheticals. The launch story is remote SSH: agents running on other machines,
visible and unblockable from the Mac's notch.
### 5. Humans hold every account
@@ -125,29 +73,23 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
without disclosure. `approved/` **is the only thing a human ever publishes from** —
nothing posts itself.
## Claims: status at 2026-10-29
## Approved claims (each backed by code)
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
| Claim | Status | Evidence / next step |
|---|---|---|
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
| Agent coverage numbers ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. |
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
| Claim | Backing |
|---|---|
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
| Remote agents over SSH | `hook/setup.go` remote install path; `NOOK_HOST` TCP transport |
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
| macOS 14+ | 27 source files use the `@Observable` framework |
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
## Voice
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage
counts (see the excluded row above).
- The notch is the product's home; agents are the product's point.
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
"one tap", "$10 once").
- The notch/island is the product's home; agents are the product's point.
- Never apologize for being a utility. The utility is the point.
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
"game-changer", "seamlessly", any unverifiable superlative — and the specific
banned sentences in rule 3 and the table above.
"game-changer", "seamlessly". Unverifiable superlatives generally.

View File

@@ -1,5 +1,6 @@
import {
For,
Show,
createEffect,
createSignal,
onCleanup,
@@ -20,6 +21,7 @@ import { Campfire, type CampfireState } from "./Campfire";
import {
ApprovalCardMock,
SessionRowMock,
Glyph,
CLAUDE,
CODEX
} from "./ModuleMocks";
@@ -35,7 +37,7 @@ import {
* Detailed feature breakdowns: alternating copy + live-demo sections.
* Each demo stages the real interaction loop: attention flares, approval
* resolves, fans throttle, the charge ceiling holds, meetings surface,
* the camera wakes. Newer demos live in
* the camera wakes, a remote host goes live. Newer demos live in
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
*/
@@ -805,6 +807,112 @@ function CameraDemo() {
);
}
/* ── Demo 7: Settings ▸ Remote hosts — one click to Live ──────────── */
/** RemoteMirror's progress steps, in order. */
const SETUP_STEPS = [
"connecting",
"pushing the hook binary",
"configuring agents",
"checking the host can reach this Mac"
];
function RemoteHostDemo() {
/* -1 not set up · 0…3 setting up · 4 live */
const [step, setStep] = createSignal(-1);
const [pressed, setPressed] = createSignal(false);
useDemoLoop(11000, [
[0, () => (setStep(-1), setPressed(false))],
[1400, () => setPressed(true)],
[1700, () => (setPressed(false), setStep(0))],
[2700, () => setStep(1)],
[3700, () => setStep(2)],
[4700, () => setStep(3)],
[5800, () => setStep(4)]
]);
const live = () => step() === 4;
const working = () => step() >= 0 && step() < 4;
const chip = () =>
live()
? { color: "rgb(48,209,88)", label: "Live" }
: working()
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
return (
<div class="relative mx-auto w-full max-w-[360px] space-y-3">
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
<ModuleCard>
<div class="space-y-2 text-left">
<div class="flex items-center gap-2.5">
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
</div>
<Show when={working()}>
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
</Show>
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
<span
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
style={{ background: chip().color }}
/>
{chip().label}
</span>
<Show
when={live()}
fallback={
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
style={{
background: ACCENT,
opacity: working() ? 0 : 1,
transform: pressed() ? "scale(0.92)" : "scale(1)"
}}
>
Set up
</span>
}
>
<Glyph name="refresh" size={11} class="text-white/50" />
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
Remove
</span>
</Show>
</div>
<p class="h-4 text-[11px] text-white/50">
{working() ? SETUP_STEPS[step()] : " "}
</p>
</div>
</ModuleCard>
{/* its agents arrive as ordinary rows, the machine named in the chip */}
<div
class="transition-all duration-500"
style={{
opacity: live() ? 1 : 0,
transform: live() ? "translateY(0)" : "translateY(6px)"
}}
>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Running python bench.py…"
status="running"
agent={CODEX}
host="gpu-rig"
origin="iTerm2"
age="now"
/>
</div>
<FakeCursor
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
at={{ x: 88, y: 16 }}
/>
</div>
);
}
/* ── Demo 8: campfire states ───────────────────────────────────────── */
function CampfireDemo() {
@@ -851,7 +959,7 @@ export default function FeatureBreakdowns() {
title="Your whole fleet, in one pixel strip"
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
bullets={[
"Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor and more",
"13 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
"Custom agents: your own coding agent can wire itself in from the published contract",
"Slot rankings — you choose which data earns the notch",
"You don't need a notch — external displays get the same island, floating top-center"
@@ -868,6 +976,7 @@ export default function FeatureBreakdowns() {
"Approval cards pin above the session list; questions answer inside their row",
"Allow for session waves through the rest of that session's requests",
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
"Works across every hooked agent, local or remote"
]}
reversed
>
@@ -887,6 +996,20 @@ export default function FeatureBreakdowns() {
<TerminalReplyDemo />
</BreakdownSection>
<BreakdownSection
kicker="Remote"
title="Any SSH box, one click away"
body="Pick a host from your ssh config or your tailnet and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
bullets={[
"One click: connect, push the hook, configure agents, check it can reach this Mac",
"Hosts mirror this Mac's installed integrations automatically",
"Remote sessions are ordinary rows; the agent chip names the machine"
]}
reversed
>
<RemoteHostDemo />
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="A hearth that tells the truth"

View File

@@ -1,6 +1,12 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
import {
ApprovalCardMock,
SessionRowMock,
DieLine,
CLAUDE,
CODEX
} from "./ModuleMocks";
/**
* Feature collage: five cells sold as the app's actual UI. The hero cell
@@ -99,6 +105,35 @@ function FansPanelMock() {
);
}
/**
* Remote agents as the panel draws them: ordinary session rows whose agent
* chip names the machine ("Claude Code · build-box").
*/
function RemoteMock() {
return (
<div class="space-y-1.5">
<SessionRowMock
title="api · main"
prompt="fix the flaky test"
state="Running pytest -x…"
status="running"
agent={CLAUDE}
host="build-box"
age="4m"
/>
<SessionRowMock
title="trainer · fp16"
prompt="benchmark fp16"
state="Done · replied 1m ago"
status="ready"
agent={CODEX}
host="gpu-rig"
age="12m"
/>
</div>
);
}
/* Deterministic usage wander for the traces. */
const wander = (n: number, k: number) =>
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
@@ -221,6 +256,19 @@ export default function FeatureCollage() {
</InkStage>
</CollageCell>
{/* Remote agents */}
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
>
<InkStage class="w-full">
<div class="w-full px-3 text-left">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
{/* Gauges */}
<CollageCell
kicker="Your Mac"

View File

@@ -722,7 +722,7 @@ function LayoutMock() {
/* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [
"Agents wired in",
"13 agents",
"Custom agents",
"Six chiptunes",
"Focus-aware slots",
@@ -740,6 +740,7 @@ const CHIPS_B = [
"tmux · iTerm2 · kitty",
"Markdown replies",
"Tailscale hosts",
"One-click SSH setup",
"iPhone widgets",
"Watch-face widgets",
"Screenshots to clipboard",

View File

@@ -1,6 +1,5 @@
/**
* Regression test for the /agents roster (NOOK-336) and its coverage wording
* (FRE-359).
* Regression test for the /agents roster (NOOK-336).
*
* The page mirrors the app's registry, `AgentSpec.all` in
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
@@ -9,12 +8,6 @@
* them. The registry lives in another repo, so the expected roster is
* restated here; when an agent is added there, add it here and on the page.
*
* FRE-359 verdict B: the page no longer states a count or claims coverage
* ("all thirteen", "every coding agent The Nook supports"). The page still
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its
* regeneration is its own ticket — so the roster check here is a subset
* check in registry order, not an equality check.
*
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
*/
import { describe, it, expect } from "bun:test";
@@ -23,10 +16,7 @@ import { join } from "node:path";
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
/** User-facing copy only: comments are not copy and may name the old wording. */
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
/** AgentSpec.all display names, in registry order. */
const REGISTRY = [
"Claude Code",
"Codex",
@@ -40,59 +30,25 @@ const REGISTRY = [
"Gemini CLI",
"Cline",
"Qwen Code",
"DeepSeek",
"Kimi",
"GitHub Copilot CLI",
"Crush",
"Cursor",
"Amp"
"DeepSeek"
];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen"];
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
/** The page's rows are a subset of the registry, in registry order. */
function subsetInOrder(page: string[], registry: string[]): boolean {
let cursor = 0;
for (const name of page) {
const at = registry.indexOf(name, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
describe("Nook /agents roster (NOOK-336)", () => {
it("lists only registry agents, in registry order", () => {
expect(entries.length).toBeGreaterThan(0);
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
it("lists every registry agent, in registry order", () => {
expect(entries).toEqual(REGISTRY);
});
it("lists each agent at most once", () => {
expect(new Set(entries).size).toBe(entries.length);
});
});
describe("Nook /agents coverage wording (FRE-359)", () => {
it("states no agent count anywhere on the page", () => {
for (const word of NUMBER_WORDS) {
expect(COPY.toLowerCase()).not.toContain(word);
}
// A bare numeral count ("13 agents", "The 13") is just as precise and just
// as unverified; the page must not state one in any form.
expect(COPY).not.toMatch(/\b\d{1,2} agents\b/);
expect(COPY).not.toMatch(/\ball \d{1,2}\b/);
});
it("makes no completeness claim over the agent set", () => {
for (const banned of [
"every coding agent",
"the agents the nook supports",
"agents the nook supports",
"supported agents"
]) {
expect(COPY.toLowerCase()).not.toContain(banned);
}
it("states the same count everywhere it names one", () => {
const word = NUMBER_WORDS[entries.length - 12];
for (const other of NUMBER_WORDS.filter((w) => w !== word)) {
expect(SOURCE.toLowerCase()).not.toContain(other);
}
expect(SOURCE).toContain(`all ${word}`);
expect(SOURCE).toContain(`The ${entries.length} agents`);
expect(SOURCE).toContain(`watches ${word} coding agents`);
});
});

View File

@@ -1,7 +1,5 @@
/**
* `/agents` on the Nook subdomain — the agent roster and each row's event
* coverage. It states no agent count and claims no completeness over the
* registry (FRE-359 verdict B).
* `/agents` on the Nook subdomain — supported agents and their event coverage.
*
* The registry below mirrors `AgentSpec.all` in the app
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
@@ -284,25 +282,26 @@ export default function NookAgentsPage() {
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Agent coverage"
description="The coding agents The Nook connects to, with the events each hook reports and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The coding agents The Nook connects to, and what each hook sees"
title="Supported agents"
description="Every coding agent The Nook supports — all thirteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The 13 agents The Nook supports, and what each hook sees"
/>
<SubdomainHeader />
<main class="min-h-screen px-[8vw] py-[8vh]">
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
Agent coverage
Supported agents
</h1>
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
The Nook watches coding agents in one place — your menu bar and notch
show sessions that are running, thinking, or blocked, and a card can
answer a permission prompt without leaving your keyboard.
The Nook watches thirteen coding agents in one place — your menu bar and
notch show every session that is running, thinking, or blocked, and a
card answers permission prompts without leaving your keyboard.
</p>
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
Each agent reports its own set of events. "Allow / Deny from a card"
means the agent hands the decision to The Nook while it waits;
"Observe" means every event still appears live, but the agent keeps
its own prompting.
its own prompting. Agents can run on this Mac or on remote machines
over SSH — remote sessions report into the same grid.
</p>
<div class="grid gap-4 lg:grid-cols-2">

View File

@@ -1,114 +0,0 @@
/**
* Regression test for the removed remote / SSH claims and the removed agent
* count (FRE-323, FRE-359 verdict B).
*
* FRE-323 took the remote story off the site because it was unverified and
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
* installs the nook-hook" flow is app-UI-only, needs a hand-edited
* `~/.ssh/config` for any host not already in ssh config or on the tailnet,
* needs a one-time `ssh-copy-id` because the app runs ssh with
* `BatchMode=yes`, and has no manual fallback for minting the per-host
* credential. FRE-359 verdict B removed the coverage count for the same
* reason: the /agents table lists 13 of the registry's 18 rows.
*
* The copy was not thrown away — it is parked in
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
* it is still there so nobody deletes the parked story as stale. It goes back
* on the site only when the one-click install ships and is recorded.
*
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
*
* Deliberate exceptions, not audited here — both were weighed with the Chief
* of Staff at the go/no-go and left in place on purpose:
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
* it" is a limitation disclosure, not a capability claim.
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
* the removed remote story, but tailnet host lookup does exist. Raised on
* FRE-323 rather than widened into this correction.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "../../..");
/** Shipped page and component source. Tests and drafts may quote the old copy. */
const AUDITED = [
"src/routes/nook/index.tsx",
"src/routes/nook/agents.tsx",
"src/routes/nook/faq.tsx",
"src/components/nook/FeatureCollage.tsx",
"src/components/nook/FeatureBreakdowns.tsx",
"src/components/nook/IslandExtras.tsx"
];
/** Every sentence FRE-323 ordered off the site. */
const BANNED_REMOTE_CLAIMS = [
"Agents on other machines",
"one click installs the nook-hook",
"Any SSH box",
"Works across every hooked agent, local or remote",
"Remote sessions are ordinary rows",
"One-click SSH setup",
"remote machines over SSH",
"this Mac or remote machines"
];
/** FRE-359 verdict B: no count, no coverage claim, in any form. */
const BANNED_COVERAGE = [
"all thirteen",
"thirteen coding agents",
"13 agents",
"every coding agent",
"Supported agents"
];
/** User-facing copy only: comments are not copy and may name the old wording. */
function copyOf(file: string): string {
return readFileSync(join(ROOT, file), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
}
describe("remote claims stay off the site (FRE-323)", () => {
for (const file of AUDITED) {
it(`${file} does not restore a removed remote sentence`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_REMOTE_CLAIMS) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents no longer offers remote sessions", () => {
const copy = copyOf("src/routes/nook/agents.tsx");
expect(copy).not.toContain("remote sessions report into the same grid");
expect(copy).not.toContain("on this Mac or on remote");
});
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
expect(parked).toContain(phrase);
}
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
"FRE-320"
);
});
});
describe("agent coverage count stays off the page (FRE-359 verdict B)", () => {
for (const file of AUDITED) {
it(`${file} states no coverage count`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_COVERAGE) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
});
}
it("/agents titles the table Agent coverage", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage");
});
});