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
38 changes: 32 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ that is the difference from `caffeinate -d`.
docs/QUICKSTART.md. Before adding a second status claim anywhere, change this
one instead.
-->
**Status: v0.1.1 is released as a signed, notarised DMG. Homebrew still
builds from source and installs 0.1.0. The two tags carry the same code, so
v0.1.1 is that source, newly signed.**
**Status: v0.3.0 is released as a signed, notarised DMG, and the Homebrew tap
now builds v0.3.0 from source. That source build is ad-hoc signed, so the app
cannot register the privileged helper: lid-closed mode is armed by the command
route below instead.**
See [Building](docs/BUILDING.md).

## Quick start
Expand All @@ -48,18 +49,37 @@ exist; it reads session state from agent hooks and from nothing else.
machine awake forever.
- A Serving switch with Off, Auto and On. **Off is an absolute veto.**
- A battery floor: at or below 15% on battery, it does not hold.
- Asks what it needs to know the first time it runs: whether to hold the
display, where to put the battery floor, and which agent tools to listen for.
- Opens at login once you ask it to, and installs nothing until you do.
- Says when a newer version is published. It downloads no update and never
replaces itself.
- Answers `/status` on the same unix socket the hooks post to, so an agent can
read what coffee-bar is doing: the switch position, whether a hold is live,
and how many sessions are working or waiting on you. Read-only, and it
carries no session identity, no working directory and no message text.

It has no Dock icon and opens no window. Look for the cup at the right end of
the menu bar.

coffee-bar makes exactly one outbound request. At most once a day when it
starts, and whenever you press Check now, it asks this project's site which
release is current; it carries no identifier and no query string, and there is
no setting that turns it off. Everything else stays on the machine.
[Security](SECURITY.md) prints that request in full.

## Lid-closed mode

Everything above holds the Mac awake with the **lid open**. Closing the lid still
sleeps it, because a power assertion does not survive the lid — overriding it
means changing a system setting, and that needs root.

coffee-bar's everyday work needs no root and no password, and the app never
elevates its own privilege. Lid-closed mode is an opt-in extra, and the only part of the product that involves root at all.
elevates its own privilege. Lid-closed mode is an opt-in extra, and the only
part of the product that involves root at all. There are two routes into it: the
**Arm lid-closed mode** button on a signed build, and `sudo coffee-bar-probe arm`
everywhere else. A Homebrew install has only the second, and that one asks for
your password because `sudo` does.

- **One button on a signed build.** The notarised DMG carries an
**Arm lid-closed mode** button in the Preferences window, under Power. coffee-bar
Expand All @@ -68,11 +88,17 @@ elevates its own privilege. Lid-closed mode is an opt-in extra, and the only par
runs no interpreter as root.
- **Approval is yours, and nothing prompts you for it.** macOS files the helper
away switched off, and you turn it on under System Settings → General → Login
Items & Extensions. There is no dialog to accept and **no password, at any
point.** If you are waiting for something to pop up, nothing will.
Items & Extensions. On this route there is no dialog to accept and **no
password, at any point.** If you are waiting for something to pop up, nothing
will: the first click reports that the helper is waiting on you, and you press
the button again once you have approved it.
- **It is removable from the same window**, and removal ends the hold before it
unregisters, so the sleep setting is never left changed with nothing to put it
back.
- **The hold has a length, and it is yours to set.** You choose it in
Preferences, the `sudo` command the window prints carries the same number, and
24 hours is the ceiling either way. A root process still holding the setting
after whatever armed it has gone is the failure that ceiling exists to bound.
- **On an unsigned build the button is disabled.** A Homebrew install is
unsigned by design: the formula compiles the source on your machine, so the
bundle carries no team identifier and macOS has nothing to pin a helper to.
Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ If the flaw is already exploited in the wild, say so — that shortens the clock
| Version | Supported |
|---|---|
| `main` | Yes |
| v0.1.x | Yes |
| v0.3.x | Yes |

v0.1.0 is tagged, and the Homebrew tap installs from that tag. `main` and the
v0.1.x line both get fixes.
v0.3.0 is tagged, and the Homebrew tap installs from that tag. `main` and the
v0.3.x line both get fixes. Earlier lines do not.

`scripts/build-app.sh` derives the version from `git describe --tags`. A build
from a tagged tree reports the tag plus the commits since it, such as
Expand Down
98 changes: 95 additions & 3 deletions Tests/CoffeeBarCoreTests/DocsClaims_test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -606,9 +606,22 @@ func theProseHookCountMatchesTheRequiredEventCount(_ name: String) throws {

// MARK: - Claim 2: numbers must be real product constants

/// The constants a document may state a duration for, keyed by the spelling the
/// prose uses in backticks.
///
/// `JournalRecord.maxTTLSeconds` joined them on 2026-08-19, when README.md began
/// stating the lid-closed ceiling. It is keyed by its QUALIFIED name because
/// that is how `SECURITY.md` writes it, and the key is what
/// `everyNamedConstantMatchesTheNumberBesideIt` looks for between backticks: a
/// bare `maxTTLSeconds` key would match nothing on any surface and would be
/// coverage that only looks like coverage. Keyed as it is, it now reads the two
/// live sentences that pair the symbol with a number, SECURITY.md "caps it at 24
/// hours however much you ask for" and SECURITY.md "which is 24 hours", as well
/// as admitting 86400 to the sweep below.
private let productConstants: [String: Double] = [
"workingTimeout": StalePolicy.standard.workingTimeout,
"blockedTimeout": StalePolicy.standard.blockedTimeout,
"JournalRecord.maxTTLSeconds": Double(JournalRecord.maxTTLSeconds),
]

private let secondsPerUnit: [String: Double] = ["second": 1, "minute": 60, "hour": 3600]
Expand Down Expand Up @@ -1748,7 +1761,86 @@ private let absoluteEgressClaimPatterns: [String] = [
"\\bnothing\\s+(?:ever\\s+)?leaves\\s+(?:your|the|this)\\s+(?:mac|machine|computer)\\b",
]

/// No page under `site/` claims, about the app as it ships, that it makes no
/// Documented surfaces the egress ban does NOT sweep, and why each is out.
///
/// **PR #141 shipped this ban over `site/` alone and its own report flagged the
/// Markdown as unswept.** The repository's own front page was therefore free to
/// claim what the site was forbidden to, which is the wrong way round: a
/// stranger arriving from a link reads README.md first.
///
/// The sweep below is now every documented surface MINUS this map, so a new
/// page or document is swept by default and an exclusion has to be typed here
/// on purpose. Each entry costs a sentence saying what the ban gives up.
///
/// **Neither exclusion is a weakness in the patterns, and neither may be
/// "fixed" by narrowing one.** Both files carry a sentence that a pattern reads
/// correctly and that is TRUE, because the ban matches phrasing and cannot see
/// the subject a sentence is about. Narrowing `posts nothing` until
/// `docs/QUICKSTART.md` passes would blind the ban to "It posts nothing
/// anywhere.", a sentence in the banned list of
/// `theEgressBanFiresOnAbsoluteClaimsAndSparesTheTrueOnes` and one of the
/// phrasings the false site copy actually used. A guard loosened until it
/// passes is worth less than one with a stated gap.
private let egressSweepExclusions: [String: String] = [
"SECURITY.md":
"""
The policy QUOTES both retired false claims, in the paragraph beginning \
"This section previously read", while recording that each became false \
and when. That paragraph is the policy keeping its promise to say so the \
day an outbound request existed, so the ban would fire on the very prose \
that documents the ban's subject. Three patterns match there today.
""",
"docs/QUICKSTART.md":
"""
The quick start says an unrecognised --tool name "posts nothing rather \
than guessing", and it is true of the shim: CoffeeBarShim/main.swift \
refuses an unknown name with "Nothing was posted." The subject is a \
local unix-socket post, not the app's egress, and the ban cannot tell \
those apart by phrasing alone.
""",
]

/// Every documented surface the egress ban sweeps.
///
/// Subtractive rather than a list of names, and that is the whole design: an
/// enumerated list fails OPEN the moment somebody adds a page and forgets a
/// line, which is the hole `documentedSurfaces` was already rewritten to close.
private let egressSweptSurfaces =
documentedSurfaces.filter { egressSweepExclusions[$0] == nil }

/// The sweep reaches the front page, and the exclusions name real documents.
///
/// **Named bug this catches.** Every `@Test(arguments:)` below passes trivially
/// on an empty argument list, so a mis-resolved root or a filter that excluded
/// everything would switch the ban off and report success: the false-absence
/// trap this file throws from `matches` to avoid. The literals are written out
/// rather than derived from the filter, because comparing the filter against
/// itself would hold whether it worked or not.
///
/// The second half is the discriminating one: an exclusion naming a document
/// that does not exist excludes nothing, reads like a deliberate gap, and rots
/// silently. `theDurationSweepExcludesOnlySurfacesAnotherGuardCovers` is the
/// precedent.
@Test func theEgressSweepReachesTheReadmeAndExcludesOnlyRealDocuments() {
#expect(egressSweptSurfaces.contains("README.md"), """
README.md is not swept by the egress ban. It is the document a stranger \
following a link reads first, and PR #141 left it unswept while site/ \
was guarded. Swept: \(egressSweptSurfaces)
""")
#expect(egressSweptSurfaces.contains("site/index.html"),
"the site pages fell out of the egress sweep: \(egressSweptSurfaces)")
#expect(egressSweptSurfaces.count >= 5,
"the egress ban sweeps only \(egressSweptSurfaces.count) surfaces; it is reading almost nothing")

for name in egressSweepExclusions.keys {
#expect(documentedSurfaces.contains(name), """
\(name) is excluded from the egress sweep but is not a documented \
surface, so the exclusion covers nothing and the name has rotted
""")
}
}

/// No documented surface claims, about the app as it ships, that it makes no
/// network request.
///
/// Scoped to CURRENT claims, for the reason `currentClaimProse` documents: a
Expand All @@ -1757,8 +1849,8 @@ private let absoluteEgressClaimPatterns: [String] = [
/// this app's promise to make no network request" was true of 0.2.2 when it was
/// written. Reading history as a live claim would leave only one way to green,
/// which is to falsify the changelog.
@Test(arguments: discoveredSitePages())
func noSitePageClaimsTheAppMakesNoNetworkRequest(_ page: String) throws {
@Test(arguments: egressSweptSurfaces)
func noDocumentedSurfaceClaimsTheAppMakesNoNetworkRequest(_ page: String) throws {
let prose = try currentClaimProse(page)

for pattern in absoluteEgressClaimPatterns {
Expand Down
9 changes: 5 additions & 4 deletions docs/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ nothing else — it never polls, and it never inspects a running process. It nev
writes your settings file for you. Until you add them, the app runs but no
session event reaches it.

This page wires Claude Code, which is the agent v0.1 supports. Codex and Cursor
This page wires Claude Code, which is the agent it documents. Codex and Cursor
have adapters in the code and no documented wiring yet. The `coffeebar-hook`
shim below posts to their endpoints, but their own configuration files are a
different shape from Claude Code's and this page documents neither.
Expand All @@ -19,9 +19,10 @@ different shape from Claude Code's and this page documents neither.
places that assert whether a release exists. Keep it that way: the tag flips
these two and nothing else.
-->
**v0.1.1 is released** as a signed, notarised DMG. Homebrew still builds from
source and installs 0.1.0. The two tags carry the same code, so v0.1.1 is that
source, newly signed. To build that source yourself, see
**v0.3.0 is released** as a signed, notarised DMG, and Homebrew builds v0.3.0
from source. That source build is ad-hoc signed, so the app cannot register the
privileged helper: lid-closed mode there is armed from the command line rather
than from the button. To build that source yourself, see
[Building](BUILDING.md). It takes about a minute.

The install through Homebrew is:
Expand Down