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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,43 @@ jobs:
- run: pnpm install --frozen-lockfile
# The pages are the repository's docs/ folder; a broken page fails here.
- run: pnpm build

push:
# Only what was asked for: see "on" above.
if: github.event_name != 'pull_request' || (github.event.action != 'labeled' || github.event.label.name == 'run-ci') && contains(github.event.pull_request.labels.*.name, 'run-ci')
runs-on: ${{ (vars.SELF_HOSTED == 'true' && github.event_name == 'push' && github.actor == github.repository_owner) && fromJSON('["self-hosted","omarchy"]') || 'ubuntu-latest' }}
defaults:
run:
working-directory: push
steps:
# omarchy runs only the owner's pushes; its job-started hook
# (ci/runner/job-started.sh) refuses anything else too.
- name: Only the owner's pushes run on a self-hosted runner
if: runner.environment == 'self-hosted'
working-directory: ${{ runner.temp }}
env:
ACTOR: ${{ github.actor }}
TRIGGERING_ACTOR: ${{ github.triggering_actor }}
OWNER: ${{ github.repository_owner }}
EVENT: ${{ github.event_name }}
run: |
if [ "$ACTOR" != "$OWNER" ] || [ "$TRIGGERING_ACTOR" != "$OWNER" ] || [ "$EVENT" != push ]; then
echo "::error::$EVENT by $ACTOR may not run on a self-hosted runner"
exit 1
fi
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This checkout persists GITHUB_TOKEN while the job executes pull-request-controlled scripts. Set persist-credentials: false so a fork cannot read and exfiltrate the token from the checkout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/ci.yml, line 318:

<comment>This checkout persists `GITHUB_TOKEN` while the job executes pull-request-controlled scripts. Set `persist-credentials: false` so a fork cannot read and exfiltrate the token from the checkout.</comment>

<file context>
@@ -291,3 +291,43 @@ jobs:
+            echo "::error::$EVENT by $ACTOR may not run on a self-hosted runner"
+            exit 1
+          fi
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+      - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
+        with:
</file context>
Suggested change
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
# A self-hosted runner keeps its pnpm store between jobs.
cache: ${{ runner.environment == 'github-hosted' && 'pnpm' || '' }}
cache-dependency-path: push/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
# The push relay (push/README.md): its checks, and the function Vercel
# deploys.
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Shipyard (formerly Berth) runs coding agents on your own machines. This repo has
- `cmd/berthd`, `internal/box`: the box daemon. It runs agents, terminals and services in its own tmux server.
- `cmd/berth`, `internal/agent`: the laptop CLI and agent (UI API on 1378, proxy on 1377, phone on 1379).
- `app/`: the Tauri 2 + React desktop app (coss-ui, 23 themes). `app/e2e` holds the Playwright suite on mock fixtures.
- `push/`: the push relay at `push.berthd.app` (Vercel), which sends the phone app the notifications its boxes ask for. `cd push && pnpm typecheck && pnpm test && pnpm build`.
- `docs/`: the docs as MDX, built by `docs-site/` (Fumadocs). `site/` is the landing page and `install.sh`.

**Names:** "Shipyard" is the brand in everything people read. `berth`, `berthd`, `~/.berth`, `BERTH_*`, `berth://`, `dev.berth.app`, `berth.prefs` and the `berth-*` skill ids are identifiers. Don't rename them; a later change will, with migrations.
Expand Down
2 changes: 2 additions & 0 deletions push/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.vercel/
node_modules/
58 changes: 58 additions & 0 deletions push/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Shipyard's push relay

Sends a Shipyard phone app the notifications its boxes ask for, at
`push.berthd.app`. Apple delivers a notification only from a server holding
the app's push key, which a box can't have, so boxes ask this relay. How it
fits together is in [the phone guide](../docs/guides/phone.mdx); the box's side is
`internal/box/phonev2.go`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This source reference is broken: internal/box/phonev2.go does not exist, so readers cannot reach the box-side implementation. Point it to internal/box/phone.go.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At push/README.md, line 7:

<comment>This source reference is broken: `internal/box/phonev2.go` does not exist, so readers cannot reach the box-side implementation. Point it to `internal/box/phone.go`.</comment>

<file context>
@@ -0,0 +1,58 @@
+`push.berthd.app`. Apple delivers a notification only from a server holding
+the app's push key, which a box can't have, so boxes ask this relay. How it
+fits together is in [the phone guide](../docs/guides/phone.mdx); the box's side is
+`internal/box/phonev2.go`.
+
+It keeps nothing. A phone app registers its device token and gets back a
</file context>
Suggested change
`internal/box/phonev2.go`.
`internal/box/phone.go`.


It keeps nothing. A phone app registers its device token and gets back a
**key**, the token sealed (AES-256-GCM) so only the relay can open it. The
app gives the key to each box it pairs with, and a box sends it with every
notice. A notice is a few short fields (`src/schema.ts`), and the relay makes
the notification from them itself, so a box, or anyone holding a phone's
key, can put nothing else in it.

| Route | |
| --- | --- |
| `POST /v1/register` | `{ platform: "ios", token, env: "production" \| "sandbox", bundle }` → `{ key }` |
| `POST /v1/send` | `Authorization: Bearer <key>`, a notice → `{ sent: true }`; 401 for a key that isn't this relay's, 410 when the phone no longer takes notifications (the box forgets it), 429, 502 for Apple's trouble |
| `GET /v1/health` | `{ ok: true }` |

Logs say what happened and to which key (the first characters of its hash),
never a body, a token or a key.

## Working on it

```sh
pnpm install --frozen-lockfile
pnpm typecheck && pnpm test && pnpm build
```

`pnpm build` bundles `src/node.ts` with esbuild into Vercel's Build Output
API layout (`.vercel/output`): one Node function that every route goes to.

## Deploying

A Vercel project with this folder as its root directory, and the domain
`push.berthd.app`. Its environment variables, all marked sensitive:

| Variable | |
| --- | --- |
| `APNS_KEY_P8` | The APNs auth key's PEM (`AuthKey_XXXXXXXXXX.p8`), from Apple Developer → Keys. `\n` in it is read as a newline |
| `APNS_KEY_ID` | That key's id |
| `APNS_TEAM_ID` | The Apple Developer team's id |
| `APNS_TOPIC` | The app's bundle id, `dev.berth.phone` |
| `SEAL_KEYS` | `{"<kid>": "<base64 of 32 random bytes>"}`: the keys phone keys are sealed with |
| `SEAL_KID` | Which of them seals new keys |

Make a sealing key with `openssl rand -base64 32`. To replace one, add the
new key to `SEAL_KEYS` beside the old, point `SEAL_KID` at it, and keep the
old one: phones' keys sealed with it still open. Remove it only once every
phone has registered again (the app does whenever its device token changes,
or a box answers 401).

The relay limits each key to 30 notices a minute and 300 a day, and each
address to 20 registrations an hour, in each instance's memory. For a limit
that holds across instances, add Vercel Firewall rate limits on the project:
`/v1/register` at 20 an hour per IP, and `/v1/send` at 120 a minute per IP.
46 changes: 46 additions & 0 deletions push/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Builds the relay as one Vercel function, in the Build Output API's layout
// (.vercel/output), so the deploy is exactly what this bundles: every route
// goes to it.
//
// node build.mjs
import { build } from "esbuild";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";

const out = join(import.meta.dirname, ".vercel", "output");
const fn = join(out, "functions", "relay.func");
await rm(out, { recursive: true, force: true });
await mkdir(fn, { recursive: true });

await build({
entryPoints: [join(import.meta.dirname, "src", "node.ts")],
outfile: join(fn, "index.mjs"),
bundle: true,
platform: "node",
target: "node22",
format: "esm",
minify: false,
sourcemap: false,
legalComments: "none",
});

await writeFile(join(fn, ".vc-config.json"), JSON.stringify({ runtime: "nodejs22.x", handler: "index.mjs", launcherType: "Nodejs", maxDuration: 30 }, null, 2));
await writeFile(
join(out, "config.json"),
JSON.stringify(
{
version: 3,
routes: [
{
src: "/(.*)",
headers: { "strict-transport-security": "max-age=63072000; includeSubDomains; preload", "x-robots-tag": "noindex" },
continue: true,
},
{ src: "/(.*)", dest: "/relay" },
],
},
null,
2,
),
);
console.log(`built ${fn}`);
19 changes: 19 additions & 0 deletions push/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "berth-push-relay",
"private": true,
"description": "Shipyard's push relay: sends a phone app the notifications its boxes ask for. Deployed to push.berthd.app.",
"type": "module",
"engines": {
"node": ">=22"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The declared Node range is broader than the test script supports. Raise the minimum to Node 22.6.0 or use a test runner that supports the full >=22 range.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At push/package.json, line 7:

<comment>The declared Node range is broader than the test script supports. Raise the minimum to Node 22.6.0 or use a test runner that supports the full `>=22` range.</comment>

<file context>
@@ -0,0 +1,19 @@
+  "description": "Shipyard's push relay: sends a phone app the notifications its boxes ask for. Deployed to push.berthd.app.",
+  "type": "module",
+  "engines": {
+    "node": ">=22"
+  },
+  "scripts": {
</file context>
Suggested change
"node": ">=22"
"node": ">=22.6.0"

},
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test 'test/**/*.test.ts'"
},
"devDependencies": {
"@types/node": "^26.6.6",
"esbuild": "^0.25.12",
"typescript": "~6.0.3"
}
}
Loading