From 2a488d9f1929aa6159766494a2ea42fec6d72278 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 22 Sep 2026 11:11:41 +0530 Subject: [PATCH 1/2] Make tula start on every Mac from macOS 13, and tell rows apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.3.1's macOS binaries carry an ad-hoc signature that does not verify, and macOS 27 kills them on launch. Every darwin build is now re-signed and verified, locally and in the release, and the release launches both Mac builds — Intel under Rosetta — before publishing. install.sh and /update install run a new build once before pointing the launcher at it. The Intel build targets Bun's baseline, since Rosetta before macOS 15 has no AVX2. The installer fetches the native build on Apple silicon from a Rosetta shell, and every channel names macOS 13 as the floor on an older Mac. Also: rows that differ only by a number now say why (heldAs, PRODUCT, VENUE); a Kraken wallet that was not read is reported; a Hyperliquid token named WETH no longer takes ether's price; an arrow key typed into a secret at `tula connect` is not stored in it; errors give commands that work on the surface they are printed to; comments and docs trimmed to what the code cannot say. Co-Authored-By: Claude Opus 5 (1M context) --- .githooks/allowed-secrets | 4 + .github/workflows/ci.yml | 16 ++- .github/workflows/injection-eval.yml | 7 +- .github/workflows/release.yml | 19 ++- .gitignore | 6 + AGENTS.md | 53 ++++---- CHANGELOG.md | 17 +++ CONTRIBUTING.md | 4 +- README.md | 2 +- ROADMAP.md | 121 ++++++------------- SECURITY.md | 5 +- install.sh | 27 +++++ package.json | 2 +- scripts/homebrew-formula.sh | 1 + scripts/install-test.sh | 70 ++++++++++- scripts/npm-pack.sh | 18 ++- scripts/release-build.sh | 12 +- site/app/globals.css | 11 +- site/app/install/page.tsx | 6 +- site/app/llms.txt/route.ts | 2 +- site/components/Footer.tsx | 15 ++- src/agent/fixture.ts | 2 +- src/agent/tools.test.ts | 12 ++ src/agent/tools.ts | 20 ++- src/cli/commands.test.ts | 73 +++++++++++ src/cli/commands.ts | 141 ++++++++++++++-------- src/cli/oneshot.test.ts | 10 +- src/cli/prompt.ts | 36 +++--- src/cli/registry.ts | 12 +- src/cli/session.test.ts | 28 +++++ src/cli/session.ts | 64 +++++----- src/cli/shell.test.ts | 5 +- src/cli/shell.ts | 25 ++-- src/connectors/aave.test.ts | 22 +++- src/connectors/aave.ts | 25 +++- src/connectors/binance.test.ts | 29 +++++ src/connectors/binance.ts | 22 ++-- src/connectors/chains.ts | 5 +- src/connectors/coinbase.test.ts | 17 +++ src/connectors/coinbase.ts | 13 +- src/connectors/evm.ts | 5 +- src/connectors/hyperliquid.test.ts | 56 ++++++++- src/connectors/hyperliquid.ts | 43 ++++--- src/connectors/kraken.test.ts | 66 +++++++++- src/connectors/kraken.ts | 88 ++++++++++---- src/connectors/stripe.test.ts | 8 +- src/connectors/stripe.ts | 3 +- src/connectors/symbols.ts | 13 ++ src/connectors/wallet.test.ts | 61 ++++++++++ src/connectors/wallet.ts | 49 +++++--- src/core/availability.ts | 2 +- src/core/http.ts | 7 +- src/core/position.ts | 23 ++++ src/core/risk.test.ts | 17 +++ src/core/risk.ts | 42 ++++--- src/core/untrusted.ts | 2 +- src/index.ts | 32 +++-- src/secrets/store.ts | 13 +- src/site-claims.test.ts | 2 + src/ui/ConnectFlow.tsx | 12 +- src/ui/Credentials.tsx | 10 +- src/ui/app.tsx | 119 +++++++++--------- src/ui/keys.ts | 6 +- src/ui/mouse.ts | 4 +- src/ui/screen.test.ts | 2 +- src/ui/terminal.ts | 7 +- src/ui/vim.ts | 3 +- src/update/apply.test.ts | 12 ++ src/update/apply.ts | 29 ++++- tasks/distribution/05-release-workflow.md | 2 +- 70 files changed, 1231 insertions(+), 486 deletions(-) diff --git a/.githooks/allowed-secrets b/.githooks/allowed-secrets index a6d0de8..bff9301 100644 --- a/.githooks/allowed-secrets +++ b/.githooks/allowed-secrets @@ -27,6 +27,10 @@ # prove the venue listing is asked for once 0x0000000000000000000000000000000000000123 0x0000000000000000000000000000000000000def +# not addresses; two same-symbol tokens told apart by address, and a second vault +0x1111111100000000000000000000000000000001 +0x2222222200000000000000000000000000000002 +0x00000000000000000000000000000000000000ff # Binance's own published HMAC-SHA256 worked example, used to prove the signing # code matches the vendor's documented output. Public in their API docs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c4a54..023c0f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,6 @@ jobs: # The install path ships to users the same way the binary does, and it is # the one piece nothing else exercises until a tag is pushed. - run: bash scripts/install-test.sh - - name: every release target still cross-compiles - run: bash scripts/release-build.sh dist/release # The release job runs `bun run check` on macOS, where the binary is signed, so # the same command runs here: a failure only macOS shows has to fail the pull @@ -54,6 +52,20 @@ jobs: node-version: 22 - run: bun install --frozen-lockfile - run: bun run check + # Here, not on Linux: the darwin targets are re-signed with codesign, and + # release-build.sh refuses without it. + - name: every release target still cross-compiles + run: bash scripts/release-build.sh dist/release + # Launched, not only signature-checked; the Intel build under Rosetta. + - name: both macOS builds start + run: | + set -euo pipefail + /usr/bin/pgrep -q oahd || sudo softwareupdate --install-rosetta --agree-to-license + for a in arm64:arm64 x64:x86_64; do + work=$(mktemp -d) + tar -xzf dist/release/tula-v*-darwin-"${a%%:*}".tar.gz -C "$work" + arch "-${a##*:}" "$work/tula" --version + done # The site is the published origin of install.sh and of every claim a reader # sees before installing anything. Vercel builds it too, but a preview that diff --git a/.github/workflows/injection-eval.yml b/.github/workflows/injection-eval.yml index 7fe4511..7143fda 100644 --- a/.github/workflows/injection-eval.yml +++ b/.github/workflows/injection-eval.yml @@ -20,10 +20,10 @@ permissions: jobs: eval: runs-on: ubuntu-latest - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.2.16 @@ -31,7 +31,10 @@ jobs: # A fork has no secret and never will. Saying so is the point: a run that # failed here would read as a model that followed the payload. + # On this step alone, so `bun install`'s lifecycle scripts never see it. - name: run the eval, or say why it did not + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | set -euo pipefail if [ -z "${ANTHROPIC_API_KEY:-}" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d931046..fdae819 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: echo "configured=true" >>"$GITHUB_OUTPUT" else echo "configured=false" >>"$GITHUB_OUTPUT" - echo "::warning::Apple signing is not configured; macOS binaries ship unsigned." + echo "::warning::Apple signing is not configured; macOS binaries ship ad-hoc signed." fi - name: sign and notarize macOS binaries @@ -218,14 +218,23 @@ jobs: # verifies, and a binary that starts. A signed Mach-O that Gatekeeper or a # bad re-tar has broken passes every check above and fails on first run. - name: the shipped archives verify and run + env: + VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail cd dist/release shasum -a 256 -c checksums.txt - work=$(mktemp -d) - tar -xzf "tula-v${{ steps.version.outputs.version }}-darwin-arm64.tar.gz" -C "$work" - "$work/tula" --version - rm -rf "$work" + # The runner's macOS may still run a binary with a broken signature + # that a newer one kills, so launching it alone proves nothing. The + # Intel build runs under Rosetta, the path it takes on Apple silicon. + /usr/bin/pgrep -q oahd || sudo softwareupdate --install-rosetta --agree-to-license + for a in arm64:arm64 x64:x86_64; do + work=$(mktemp -d) + tar -xzf "tula-v$VERSION-darwin-${a%%:*}.tar.gz" -C "$work" + codesign --verify --strict --verbose=2 "$work/tula" + arch "-${a##*:}" "$work/tula" --version + rm -rf "$work" + done # Signed by GitHub's workflow identity through sigstore: keyless, so there # is no signing key for this project to generate, publish, rotate or lose. diff --git a/.gitignore b/.gitignore index e5135e8..f90d105 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,15 @@ dist/ # Belt and braces: credentials live in ~/.config/tula, never the repo. # This catches a stray copy before it reaches a commit. *.key +*.pem +.npmrc credentials.json .env .env.* +# What TULA_CONFIG_DIR holds, should a scratch run point it inside the tree. +history.jsonl +state.json +preferences.json # The site is a separate package: its dependencies never enter the binary. site/node_modules diff --git a/AGENTS.md b/AGENTS.md index f3ca1b2..5e1c6bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,18 +34,12 @@ into a directory anyone can replace it in. None of those failed a type check. ## Versioning -[SemVer](https://semver.org), and the version describes a **release**, never a -plan — the milestones live in `ROADMAP.md` precisely so a reordered plan cannot -make a published number wrong. Pre-1.0: **patch** for fixes, security hardening -and doc or site corrections; **minor** for a new venue, command or capability, -and for anything breaking; **major** is reserved for `1.0.0` and `2.0.0`. - -A hyphen means pre-release. It is the only signal — `src/version.ts` derives -`IS_PRE_RELEASE` from `APP_VERSION`, and `release.yml` reads the same hyphen to -choose `--prerelease` over `--latest` and the npm dist-tag. They were once a -hand-set boolean apart and had already drifted into a stable release whose -binary called itself a pre-release; `guard.sh` now fails if the derivation is -replaced by a literal. +The version describes a **release**, never a plan; the bump rules are in +`ROADMAP.md`'s Versions section. A hyphen means pre-release and is the only +signal: `src/version.ts` derives `IS_PRE_RELEASE` from `APP_VERSION`, and +`release.yml` reads the same hyphen for `--prerelease` and the npm dist-tag. +`guard.sh` fails if the derivation is replaced by a literal — a hand-set boolean +once shipped a stable release whose binary called itself a pre-release. ## Stack @@ -198,7 +192,7 @@ src/ version.ts # APP_NAME, APP_VERSION, IS_PRE_RELEASE, REPO_URL, SITE_URL, # APP_DESCRIPTION — single source; guard.sh reads four of them core/ - position.ts # canonical schema: Position, NetExposure, LiquidationParams + position.ts # canonical schema: Position, NetExposure, LiquidationParams; rowIdentity untrusted.ts # visible() — the one filter over text somebody else wrote exposure.ts # netExposure, portfolioValue — equity, never a perp's notional; oldest risk.ts # liquidation distance, scenario shocks, what breaks first @@ -379,9 +373,9 @@ Two rules, and they are the reason the architecture exists: called in *in order*, so a venue in the book with an unread area that hides a liquidation makes the order wrong rather than short, and they say so. `ALTERED`, the fifth thing a view says about itself, is the only one about - text rather than holdings: a venue spelled an asset in characters this build - could not print, so the block names the venue, what was - done and the *bounded* name — never what the venue sent, which is the string + text rather than holdings: a venue spelled an asset, product or held-as name + in characters this build could not print, so the block names the venue, what + was done and the *bounded* name — never what the venue sent, which is the string the bound exists to keep off a terminal. `LoadResult.altered` carries it, and the first line says nothing is missing, or a reader has five states to tell apart and four of them mean go and fetch something. @@ -390,9 +384,8 @@ Two rules, and they are the reason the architecture exists: goes — and `src/coverage-plan.test.ts` fails the build on a path that is not a real task, on one already `done`, on a liquidation-hiding gap filed under the aggregator, and on any plan `ROADMAP.md`'s table does not name. Declaring a - gap costs one object and used to create no obligation at all, which is how - thirty accumulated with thirteen in no plan and two named in no file in the - repository. The declaration and the plan are one edit now. + gap costs one object, so without this gaps accumulate with no plan behind + them. The declaration and the plan are one edit. - **`src/index.ts` reaches the terminal UI only through a dynamic import.** A one-shot command draws its tables through `src/ui/table.ts` and never needs a reconciler; loading Ink and React for one cost 82ms against 56ms on @@ -618,7 +611,8 @@ venue in it. the error text of a venue or a price source, the error text of the model provider, and a Hyperliquid builder dex name, which is a venue label and so is refused unless it matches `DEX_NAME` rather than capped. The symbol is capped in - `src/cli/session.ts`, where every connector arrives; the error text is capped + `src/cli/session.ts`, where every connector arrives, and so are the venue's + `heldAs` spelling and `product` name beside it; the error text is capped by `remote()` in `src/core/errors.ts` where it enters, at the connector or the price source that received it, which is the only place that can tell tula's own words from somebody else's — and by `explain()` in @@ -657,6 +651,11 @@ endpoint — including "validate only" variants. The absence is the product. 7. Give it a colour in `src/ui/brand.ts`. A venue without one renders a hole beside the rest, and `src/ui/brand.test.ts` fails on it. 8. Do not sort — the command layer does that. +9. No two rows may read alike but for their figures. Where one label holds an + asset twice, say what the venue calls the difference: `product` for the + contract, margin book, vault or staking state, `heldAs` for the venue's own + spelling of an asset counted as another. Its test asserts `rowIdentity` is + unique over what it returns, as `kraken.test.ts` does. ## Working from tasks/ @@ -1014,10 +1013,9 @@ before pasting keys tied to their net worth. - **Never document an install path that does not work yet.** A published command that fetches nothing is an impersonation surface, not a convenience. -### Before the first release +### What a release needs outside the repository -None of this lives in the repository, and each missing piece fails a different -channel at a different moment. The tap and the npm scope fail *after* the GitHub +Each missing piece fails a different channel at a different moment. The tap and the npm scope fail *after* the GitHub release is already public, while the site is telling people to use them. | What | Why it blocks | Check | @@ -1029,16 +1027,15 @@ release is already public, while the site is telling people to use them. | `usetu.la` resolves, HTTPS enforced | the install command is the domain | `curl -sI https://usetu.la/install.sh` | | The Vercel root directory is `site` | `vercel.json`'s headers are read from there and nowhere else | `curl -sI https://usetu.la/` | | Vercel includes files outside that root | the build script copies `install.sh` in from there; without it the build fails | `curl -sI https://usetu.la/install.sh` | -| `APPLE_*` secrets | optional; without them macOS ships unsigned | `gh secret list` | +| `APPLE_*` secrets | optional; without them macOS ships ad-hoc signed | `gh secret list` | | A `release` environment with a required reviewer | `release.yml` names it on all three jobs, and naming it does nothing until it exists — GitHub silently creates an unprotected one on first use, and the run publishes unreviewed | `curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/hsnice16/tula/environments/release` | Set each variable last, after its token exists: `true` without the token turns a skipped job into a failed one, and it fails after the GitHub release is public. -The site is already deployed and already names all three channels, so the table -above is not a checklist for later — every row that is not true when the tag is -pushed is a published command that fetches nothing for as long as it takes to -notice. Each can be checked without `gh` and without being signed in, which is +The site names all three channels, so every row above that is not true when a +tag is pushed is a published command that fetches nothing for as long as it +takes to notice. Each can be checked without `gh` and without being signed in, which is also how a reader would find out before you do: ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 744acf8..43abfbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ## [Unreleased] +### Fixed + +- **tula starts on macOS 27.** The macOS binaries shipped with a code signature that did not verify, which earlier releases of macOS let run and macOS 27 kills on launch — `zsh: killed tula`. They are signed on the build machine now, and a release whose binaries do not verify is not published. A tula that is killed cannot update itself: re-run the install line, `brew upgrade`, or `npm install -g @hsnice16/tula`. +- **No two rows read alike.** Wherever tula lists holdings — the tables, the lines under `/shock` and `/breaks`, a venue's `status`, and what the model is handed — two rows now differ in something other than a number. + - An asset counted as another says what the venue calls it: `ETH (as WETH)`, `USDT0 (as USDT)`, `PEPE (as kPEPE)`. + - A `PRODUCT` column names the contract, margin book, vault or staking state a row is held in: Binance's cross and isolated margin; each Coinbase perp; Kraken's margin pairs; Hyperliquid's vaults, and staked HYPE under the names its staking panel gives it, Total Staked and Available to Stake. + - A venue's own views gain a `VENUE` column wherever its rows come from sub-accounts, builder dexes, markets or chains: `/ positions`, `/ breaks`, and the borrowing table in `/ status`. + - Two Kraken wallets of one type are numbered, `kraken-spot-1` and `kraken-spot-2`. Several margin positions on one pair and side are one row, summed as Kraken's own `consolidation=market` sums them. + - A token calling itself the chain's gas token, and two Aave reserves one market would list under one name, carry their contract. +- **Two addresses on one Aave market are two health factors under `/shock`.** They were pooled into one market: the second address's factor was never printed, and the one shown moved on collateral from both. +- **An update that does not start is never installed.** `install.sh` and `/update install` run the new build once before pointing `tula` at it; one that fails leaves you on the version you had. +- **tula runs on every Mac from macOS 13.** The Intel build no longer needs AVX2, so it also runs under Rosetta on macOS 13 and 14. The installer fetches the native build on Apple silicon even from a shell running under Rosetta. The installer, Homebrew and npm say so on an older macOS. +- **An arrow key pressed while typing a key at `tula connect` is not saved into it.** Its escape sequence was stored as part of the secret, and the venue then refused a key that looked right. +- **A Kraken wallet tula did not read is named.** When Kraken's wallet list fails to load, only the default wallet is read, and the book now says so instead of reading as whole. +- **A Hyperliquid spot token called `WETH` is not ether.** It netted with ETH and took ether's price; any deployer can list a token under that name. +- **`tula connect --help` prints connect's usage.** It answered `Unknown venue "--help"`. + ## [0.3.1] - 2026-09-16 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e130552..2f99703 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ goes on telling people to use that channel, so the run prints a warning naming each channel that is off. Read it before you announce anything. ```bash -bash scripts/release-build.sh dist/release # the same artifacts, locally +bash scripts/release-build.sh dist/release # the same artifacts; macOS only, it needs codesign bash scripts/install-test.sh # runs install.sh against a fake release bash scripts/npm-pack.sh dist/release # the npm tree that would be published bash scripts/homebrew-formula.sh dist/release tula # the formula, real checksums @@ -134,7 +134,7 @@ Two ways, neither of which publishes anything by accident. `publish` off. It builds all four targets, verifies them and runs the installer against them — then stops, and leaves the artifacts and `checksums.txt` on the run to inspect. It signs only where `secrets.APPLE_CERT_P12` is set; without it -the macOS binaries are unsigned and the run says so. Publishing is off by default +the macOS binaries ship ad-hoc signed and the run says so. Publishing is off by default because `GITHUB_REF_TYPE` is `branch` on a manual run, so the tag-matches-version check cannot protect it; without the gate, a manual run would cut a real release from whatever was on the branch. **It does not attest.** That step is gated with diff --git a/README.md b/README.md index 99ef69a..7d31ab6 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ curl --proto '=https' --tlsv1.2 -LsSf https://usetu.la/install.sh | sh brew install hsnice16/tap/tula # or: npm install -g @hsnice16/tula ``` -macOS and Linux, on 64-bit Intel and ARM. Alpine and other musl systems are not +macOS 13 or later and Linux, on 64-bit Intel and ARM. Alpine and other musl systems are not supported, and there is no native Windows build — install inside WSL. The installer always checks the download against its published checksum, and checks the sigstore-backed attestation proving this repository's release workflow built diff --git a/ROADMAP.md b/ROADMAP.md index da10198..f0235ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,50 +33,21 @@ is only worth the check behind it, so a task marked `done` names the tests covering its acceptance and `src/tasks.test.ts` fails on one that does not — [`tasks/README.md`](./tasks/README.md) has the convention. -Three milestones are ◐ — **4**, **8** and **13** — and every open piece is named -here rather than left to the reader. A bullet that did not land is marked -`**Not met.**`, that spelling and no other, so `grep -rn 'Not met' tasks/*/` +The table names no versions: 6 landed alongside 4 and 5 rather than after them, +and a plan that moves makes a published number wrong. + +Three milestones are ◐ — **4**, **8** and **13**. A bullet that did not land is +marked `**Not met.**`, that spelling and no other, so `grep -rn 'Not met' tasks/*/` lists every one — the directories, not `tasks/README.md`, which documents the -marker and so contains it — and this paragraph can be checked rather than -believed. 8's open pieces are bullets that missed, each saying why in its own +marker. 8's open pieces are bullets that missed, each saying why in its own task; so is the one in 4's [`breadth/10`](./tasks/breadth/10-hyperliquid-depth.md), -which the venue's own documentation argues against doing. The rest of 4 and 13 -is open work rather than work that missed. 4's is the aggregator, which waits -for 11; Aave V4, which does not; [`breadth/12`](./tasks/breadth/12-chain-reach.md), -part-shipped; and the per-venue depth tasks 09, 11 and 13–16. 13 is ◐ the -other way round from every other row: the docs site shipped, and of the -hardening pass two bullets already hold on their own — no panic path leaves the -terminal in raw mode, on both the shell and the one-shot prompt, and every -venue-supplied string is treated as data on every path that renders it — while -the rest of that milestone has not started. - -**1**, **2** and **6** closed. 2's last bullet was the reader-facing half of the -injection work: venue text was labelled for the model — every tool result -carries a sidecar naming the paths its outside text sits at — and said nothing -to the reader, who is on the surface that works with no model at all. A name a -venue spelled in text tula could not print is `ALTERED` on every view now, -naming the venue, the bounded name and the `TULA__RPC` that chooses the -node that sent it; what the venue actually sent is not shown, because that -string is the one that repaints a line. - -1's last bullet accepted a `redact()` helper for the log and error paths; what -shipped instead is the property it would only have approximated — no module -holding a credential may hand one to a log, an error, a file or another process, -and `src/secrets/` has no way out of the process at all, both failing the build -in `scripts/guard.sh`. 6's was a shocked health factor computed as though the -debt stood still, which is a stablecoin borrow and not a same-asset one: the -assumption is checked against the book now and stated to the reader where it -breaks, rather than sitting in a comment on `healthFactorUnder`. - -**7** shipped. It closed 3's last gap — the credential store holds an ordered -set, every entry is read, and each row carries the account it came from — and it -added the line that says what tula never asked for: a chain never queried is not -a venue that *failed*, so `INCOMPLETE` stayed quiet and the total was short with -nothing saying so. Availability landed beside it, under 6, which is why 6 -closed on one unstated assumption rather than on four connectors summing a free -balance away. Labelling venue text *to the model* was 7's item; saying -so on screen was 2's, and both now hold — `ALTERED` is the fifth thing a view -says about itself. +which the venue's own documentation argues against doing. The rest of 4 is open +work: the aggregator, which waits for 11; Aave V4, which does not; +[`breadth/12`](./tasks/breadth/12-chain-reach.md), part-shipped; and the depth +tasks 09, 11 and 13–16. 13 is the other way round: the docs site shipped, and two +hardening bullets already hold — no panic path leaves the terminal in raw mode, +and every venue-supplied string is treated as data on every path that renders +it — while the rest has not started. The aggregator and Aave V4 are scheduled in different places, and one rule puts each where it is: everything uncovered that *can* be liquidated is hand-built, @@ -87,31 +58,19 @@ add is exposure nobody can be liquidated on, and it could not supply a health factor for it anyway. It completes a total rather than repairing one, which is the line 7 was drawn on. -**Aave V4 stays in 4**, and is the one piece of it scheduled here. It is live on -Ethereum, it holds real deposits, and it hides a *liquidation* — the connector -declares it unread and `scripts/conformance.live.ts` re-checks against the live -address book that the gap is still real. It was filed as deliberately deferred -on the grounds that V4 is Hubs and Spokes rather than Pools, which is a statement -about effort and not a decision: it is the venue tula already claims to read, one -major version on, and as v3 drains into v4 an Aave position tula cannot see is a -liquidation tula cannot rank. [`breadth/08`](./tasks/breadth/08-aave-v4.md) is -the task, and it is honest about what is unknown — the account model, whether a -health factor is per Hub, whether `getUserAccountData` has an equivalent. The -declaration stands until the connector reads V4, and `/venues` is where the -reader meets it — that, or the sentence under an Aave book that came back -empty, which is where somebody on V4 actually lands. - -The risk engine (6) landed alongside breadth and distribution rather than after -them, and that reordering is why this table stopped naming versions: it used to, -and a plan that moves makes a published number wrong. +**Aave V4 stays in 4.** It is live on Ethereum, holds real deposits, and hides a +*liquidation*; the connector declares it unread and `scripts/conformance.live.ts` +re-checks that the gap is still real. Hubs and Spokes rather than Pools is a +statement about effort, not a decision: as v3 drains into v4, an Aave position +tula cannot see is a liquidation tula cannot rank. +[`breadth/08`](./tasks/breadth/08-aave-v4.md) is the task, and names what is +unknown. 8, 9 and 10 come before any of 11 through 17, as 7 did. A gap inside something shipped outranks new scope — a wrong number and a rule only a good model keeps are both shipped, in a product that already stores exchange keys. 8 led -because it was the wrong number: 0.2.0 overstated the USDC row of a Hyperliquid -unified or portfolio-margin account by the account's whole value, after a fix -that did not remove it. Its input-line half -is not a gap in a figure, and [`field-report`](./tasks/field-report) orders it +because it was a wrong number in a shipped view. Its input-line half is not a +gap in a figure, and [`field-report`](./tasks/field-report) orders it behind every task that changes one. 10 is there rather than later because a risk tool you have to remember to open is a tool you forget. @@ -144,9 +103,8 @@ model. Nothing left in 8–12 changes that: a corrected figure, a second model provider, a venue read over MCP, and a user-added venue are all still reads. **2.0 is 15**, because that is where signing authority enters the product and -the read-only promise is retracted by plan. Retracting it is one commit across -the nine surfaces `src/site-claims.test.ts` pins the caveat to, which is what -makes it one commit rather than nine. +the read-only promise is retracted by plan. Retracting it is one commit, because +`src/site-claims.test.ts` pins the caveat to every surface that carries it. **14 sits between them on purpose.** A diff that states a proposed change in exposure terms — fees, slippage, the resulting move in liquidation distance, @@ -164,14 +122,11 @@ the list. That declaration exists so a half-read account is never served as a whole one — it was never meant to be a standing statement about the product, and a count of it beside every figure was one, so the count came off the view. -The obligation it creates is answered here instead. Twenty-four areas are -declared across seven venues, and each one names the task that would close it in the -manifest itself: `src/coverage-plan.test.ts` fails the build on a gap whose -plan is not a real task, on one filed under a task already finished, on one -hiding a liquidation filed under the aggregator, and on any plan this table -does not name. Declared-and-unfiled is what that test exists to make -impossible — thirteen of the thirty were in that state when it was written, two -of them mentioned in no file at all. +The obligation it creates is answered here instead. Each declared area names +the task that would close it in the manifest itself: `src/coverage-plan.test.ts` +fails the build on a gap whose plan is not a real task, on one filed under a task +already finished, on one hiding a liquidation filed under the aggregator, and on +any plan this table does not name. | What | Where | |---|---| @@ -185,18 +140,12 @@ of them mentioned in no file at all. | Stripe Treasury and connected accounts under a platform | [`breadth/16`](./tasks/breadth/16-stripe-depth.md) | | What an LP or vault receipt token is a claim on | [`breadth/01`](./tasks/breadth/01-aggregator-api.md) | -Ordered by venue rather than by size. An earlier draft of this paragraph called -three of them "a decode of bytes already fetched", and reading the code rather -than the declarations showed that claim was worth what such claims usually are. -The bytes are in hand; decoding them changes no figure on any screen, and -retiring a declaration for that would shorten the list without the reader seeing -anything new. What the exercise turned up instead was a gap nobody had declared -— the eMode category Aave liquidates an account against — and it was a wrong -number rather than a missing one, sitting under a health factor Aave did state, -which is why nothing about it looked wrong. It is fixed rather than listed -above; [`breadth/09`](./tasks/breadth/09-aave-depth.md) records what reading it -against the chain turned out to require, including the field that looks like -the answer and is not. +Ordered by venue rather than by size. Decoding bytes already fetched retires no +area here unless it changes a figure the reader sees: otherwise it shortens the +list and shows nobody anything new. Reading the code rather than the +declarations is what found the one gap nobody had declared — the eMode category +Aave liquidates against, a wrong number under a health factor Aave did state — +and [`breadth/09`](./tasks/breadth/09-aave-depth.md) records what fixing it took. Nothing here is a promise about a date. What it is is the difference between a gap somebody chose and a gap nobody has looked at, and the two sections at the diff --git a/SECURITY.md b/SECURITY.md index 1ffe072..806ef65 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,7 +75,10 @@ Highest severity first: a column is drawn in, whichever comes first, so a cut never lands inside a code point and a wide script cannot push the columns beside it off the row. `src/connectors/evm.ts` caps its own decode as well, so a lying ABI length - prefix is never allocated. + prefix is never allocated. The venue's text beside a symbol — its own + spelling where the symbol nets as another (`WETH` beside `ETH`), and the + contract or margin book a row is held in (`BTCUSDT isolated`) — is the same + listing and takes the same cap in the same place. What is stripped is every codepoint that is invisible or moves what is drawn — `visible()` in `src/core/untrusted.ts`, the one filter all three diff --git a/install.sh b/install.sh index 9875d8a..ce75bbe 100755 --- a/install.sh +++ b/install.sh @@ -109,6 +109,21 @@ detect_target() { *) die "tula has no build for $arch." \ "Build from source instead: https://github.com/$REPO" ;; esac + if [ "$os" = darwin ]; then + # Bun, which tula is compiled with, supports macOS 13 and later; on an older + # one nothing about the binary is tested, so it is refused here by name. + if command -v sw_vers >/dev/null 2>&1; then + macos=$(sw_vers -productVersion) + [ "${macos%%.*}" -ge 13 ] 2>/dev/null || + die "tula needs macOS 13 (Ventura) or later; this Mac runs $macos." \ + "Update it in System Settings > General > Software Update." + fi + # A shell under Rosetta reports x86_64 on Apple silicon. The native build is + # the one to install there; the chip is asked rather than the shell. + if [ "$arch" = x64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then + arch=arm64 + fi + fi # Builds link against glibc. musl silently fails at exec time with a message # about a missing loader, which reads as a corrupt download rather than an # unsupported libc — so it is caught here instead. @@ -332,6 +347,18 @@ if [ -z "$ALREADY" ]; then [ -f "$VERSION_DIR/tula" ] || die "$ARCHIVE did not contain a tula binary." \ "Report it: https://github.com/$REPO/issues" chmod 755 "$VERSION_DIR/tula" + # A checksum and an attestation prove what was built, not that this machine + # will run it — a macOS newer than the build kills a binary that passed both. + # So it runs once before the receipt or the launcher can name it. + note "checking it starts" + # In a subshell that cannot exec it in place, so the shell's own "Killed: 9" + # report lands in the redirect rather than above the message below. + if ! (TULA_NO_UPDATE_CHECK=1 "$VERSION_DIR/tula" --version; exit $?) >/dev/null 2>&1; then + rm -f "$RECEIPT" + die "tula $VERSION was downloaded and verified, but does not start on this machine." \ + "Your launcher was left as it was. Report it with your OS version:" \ + "https://github.com/$REPO/issues — or pin an earlier release with TULA_VERSION." + fi # What the fast path above compares against on the next run. Written after the # archive passed its checksum and its attestation, so it records a binary this # script verified rather than one it merely found. diff --git a/package.json b/package.json index c558eb0..445c797 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "scripts": { "dev": "bun run src/index.ts", - "build": "bun build src/index.ts --compile --outfile dist/tula", + "build": "bun build src/index.ts --compile --outfile dist/tula && if [ \"$(uname)\" = Darwin ]; then codesign --force -s - dist/tula && codesign --verify --strict dist/tula; fi", "typecheck": "tsc --noEmit", "test": "bun test", "check": "tsc --noEmit && bun test && bash scripts/install-test.sh && bash scripts/guard.sh && bash scripts/guard-test.sh && bash scripts/scan-test.sh", diff --git a/scripts/homebrew-formula.sh b/scripts/homebrew-formula.sh index ae02905..051232f 100755 --- a/scripts/homebrew-formula.sh +++ b/scripts/homebrew-formula.sh @@ -87,6 +87,7 @@ class $CLASS < Formula license "MIT" on_macos do + depends_on macos: :ventura on_arm do url "$BASE/tula-v$VERSION-darwin-arm64.tar.gz" sha256 "$SUM_DARWIN_ARM64" diff --git a/scripts/install-test.sh b/scripts/install-test.sh index 724c59b..a04c48e 100755 --- a/scripts/install-test.sh +++ b/scripts/install-test.sh @@ -83,7 +83,7 @@ mkdir -p "$BIN" # decompresses in-process — so a list built by reading install.sh on a laptop # misses it, and every extraction fails on Linux with "Cannot exec". for tool in sh env cp tar gzip gunzip uname mkdir grep cut sed basename dirname \ - mktemp chmod ln rm cat ls id readlink sha256sum shasum openssl; do + mktemp chmod ln rm cat ls id readlink sha256sum shasum openssl sw_vers sysctl; do path=$(command -v "$tool" 2>/dev/null) && ln -sf "$path" "$BIN/$tool" done @@ -427,6 +427,74 @@ else fi +# A Mac, whatever this suite runs on: `uname`, `sw_vers` and `sysctl` answer as +# one would, and each case restores the real tools after itself. +as_mac() { + mv "$BIN/uname" "$BIN/uname.real" + # Unlinked first: writing through the symlink would target the real tool. + rm -f "$BIN/sw_vers" "$BIN/sysctl" + printf '#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo %s ;; esac\n' "$1" >"$BIN/uname" + printf '#!/bin/sh\necho %s\n' "$2" >"$BIN/sw_vers" + printf '#!/bin/sh\necho %s\n' "$3" >"$BIN/sysctl" + chmod 755 "$BIN/uname" "$BIN/sw_vers" "$BIN/sysctl" +} +real_platform() { + rm -f "$BIN/uname" "$BIN/sw_vers" "$BIN/sysctl" + mv "$BIN/uname.real" "$BIN/uname" + for tool in sw_vers sysctl; do + path=$(command -v "$tool" 2>/dev/null) && ln -sf "$path" "$BIN/$tool" + done +} + +H="$WORK/h-old-mac" +mkdir -p "$H" +as_mac arm64 12.7.4 1 +out=$(run "$H") +real_platform +if [ ! -e "$H/.tula/bin/tula" ] && case "$out" in *"needs macOS 13"*"12.7.4"*) true ;; *) false ;; esac; then + ok "refuses a macOS older than the runtime supports, and installs nothing" +else + bad "refuses a macOS older than the runtime supports, and installs nothing" "$out" +fi + +H="$WORK/h-rosetta" +mkdir -p "$H" +as_mac x86_64 15.1 1 +out=$(run "$H") +real_platform +case "$out" in *"darwin-arm64"*) ok "installs the native build from a shell running under Rosetta" ;; + *) bad "installs the native build from a shell running under Rosetta" "$out" ;; esac + +H="$WORK/h-intel" +mkdir -p "$H" +as_mac x86_64 13.0 0 +out=$(run "$H") +real_platform +case "$out" in *"darwin-x64"*) ok "installs the Intel build on an Intel Mac" ;; + *) bad "installs the Intel build on an Intel Mac" "$out" ;; esac + +# Verified and still dead on arrival: the launcher must not be pointed at it. +H="$WORK/h-dead" +mkdir -p "$H" +cp -R "$RELEASE" "$WORK/release.bak" +printf '#!/bin/sh\nexit 137\n' >"$RELEASE/stage/tula" +for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + tar -czf "$RELEASE/tula-v9.9.9-$t.tar.gz" -C "$RELEASE/stage" tula LICENSE +done +if command -v sha256sum >/dev/null 2>&1; then + (cd "$RELEASE" && sha256sum ./*.tar.gz | sed 's| \./| |' >checksums.txt) +else + (cd "$RELEASE" && shasum -a 256 ./*.tar.gz | sed 's| \./| |' >checksums.txt) +fi +out=$(run "$H") +rm -rf "$RELEASE" && mv "$WORK/release.bak" "$RELEASE" +if [ ! -e "$H/.tula/bin/tula" ] && [ ! -e "$H/.tula/versions/9.9.9/.tula-sha256" ] && + case "$out" in *"does not start on this machine"*) true ;; *) false ;; esac; then + ok "refuses a verified binary that does not start, and leaves the launcher alone" +else + bad "refuses a verified binary that does not start, and leaves the launcher alone" "$out" +fi + # Nothing above may have touched a profile outside the sandbox. This test edits # shell config, so a leak is silent, permanent and in someone's real home. leaked=0 diff --git a/scripts/npm-pack.sh b/scripts/npm-pack.sh index 9bbb94d..20287e3 100755 --- a/scripts/npm-pack.sh +++ b/scripts/npm-pack.sh @@ -116,8 +116,9 @@ cat >"$WRAPPER/scripts/postinstall.mjs" <<'POSTINSTALL' * link. The installed binary is the compiled executable, so running tula never * starts Node — Node is needed to install it, not to run it. */ -import { chmodSync, copyFileSync } from 'node:fs' +import { chmodSync, copyFileSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { release } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -125,10 +126,19 @@ const require = createRequire(import.meta.url) const here = dirname(fileURLToPath(import.meta.url)) const target = `${process.platform}-${process.arch}` +const launcher = join(here, '..', 'bin', 'tula') +// Darwin 22 is macOS 13, the oldest Bun supports. Below it the binary is +// untested, so the launcher says why instead of running it. +const tooOld = process.platform === 'darwin' && Number(release().split('.')[0]) < 22 + try { - const source = require.resolve(`@hsnice16/tula-${target}/bin/tula`) - const launcher = join(here, '..', 'bin', 'tula') - copyFileSync(source, launcher) + if (tooOld) { + const why = ['tula needs macOS 13 (Ventura) or later.', ' Update it in System Settings > General > Software Update.'] + writeFileSync(launcher, `#!/bin/sh\n${why.map((line) => `echo '${line}' >&2`).join('\n')}\nexit 1\n`) + console.error(why.join('\n')) + } else { + copyFileSync(require.resolve(`@hsnice16/tula-${target}/bin/tula`), launcher) + } chmodSync(launcher, 0o755) } catch { // Left as the placeholder, which says the same thing when run. Exiting diff --git a/scripts/release-build.sh b/scripts/release-build.sh index e86d33d..5c7441b 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -11,9 +11,11 @@ VERSION=$(grep -m1 'APP_VERSION' src/version.ts | sed "s/.*'\([^']*\)'.*/\1/") # The names the installer builds its URLs from. Changing one here without # changing install.sh produces a release nobody can install. +# Baseline on Intel macOS: Bun 1.2's default x64 build needs AVX2, which Rosetta +# before macOS 15 does not emulate. TARGETS=( "darwin-arm64:bun-darwin-arm64" - "darwin-x64:bun-darwin-x64" + "darwin-x64:bun-darwin-x64-baseline" "linux-x64:bun-linux-x64" "linux-arm64:bun-linux-arm64" ) @@ -44,6 +46,14 @@ for entry in "${TARGETS[@]}"; do echo "building $name" bun build src/index.ts --compile --target="$target" --outfile "$stage/tula" chmod 755 "$stage/tula" + # Bun 1.2.16 emits darwin binaries whose ad-hoc signature does not verify, and + # macOS 27 kills them on launch. The workflow's Developer ID step re-signs over + # this when its secrets exist; without them, this is the signature that ships. + # No codesign means no fix, so refuse rather than build a binary that dies. + if [[ $name == darwin-* ]]; then + codesign --force -s - "$stage/tula" + codesign --verify --strict "$stage/tula" + fi # Before the archive exists, so a binary carrying a debug listener is never a # file anybody can pick up. Every target, because what gets bundled is decided # by what resolves at build time and that is the same for all four. diff --git a/site/app/globals.css b/site/app/globals.css index 86a7117..22d8ad6 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -21,12 +21,11 @@ --color-ink: #eceae5; --color-dim: #8d877e; - /* 3.05:1 on the page ground, below the 4.5 AA asks of text this size, so it - is kept for one thing: the transcript inside a terminal frame, which is a - drawing of dim terminal output and reads as wrong at any lighter value. - Everything a reader needs the words of — section labels, a frame's title - bar, prose — takes --color-dim, which passes at 5.26. A third step light - enough to pass would sit on top of dim and stop being a third step. */ + /* 3.05:1 on the page ground, below the 4.5 AA asks of text, so no words take + it: only the Aside's info icon, which the dim text beside it restates. + Everything a reader needs the words of takes --color-dim, which passes at + 5.26. A third step light enough to pass would sit on top of dim and stop + being a third step. */ --color-faint: #666159; --animate-pop: pop 360ms cubic-bezier(0.2, 0.9, 0.3, 1.25); diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx index b980a11..997feba 100644 --- a/site/app/install/page.tsx +++ b/site/app/install/page.tsx @@ -80,7 +80,7 @@ const FLAGS = [ * the rows most worth printing, and a target list cannot carry them. */ const SYSTEMS = [ - ['macOS', 'Yes', 'Intel and ARM, 64-bit.'], + ['macOS 13 or later', 'Yes', 'Intel and ARM, 64-bit.'], ['Linux', 'Yes', 'Intel and ARM, 64-bit. Needs glibc.'], ['Alpine, or any musl Linux', 'No', 'The installer says so and stops.'], ['Windows', 'Through WSL', 'Install inside WSL. There is no native build.'], @@ -242,12 +242,12 @@ const CHANNELS: Channel[] = [

Node is needed to install it, not to run it.

- Name the version. npm update moves you back to the newest. + Name the version. Installing without one moves you back to the newest.

{'npm install -g @hsnice16/tula@'}
diff --git a/site/app/llms.txt/route.ts b/site/app/llms.txt/route.ts index 6efd4c6..89762b8 100644 --- a/site/app/llms.txt/route.ts +++ b/site/app/llms.txt/route.ts @@ -70,7 +70,7 @@ ${INSTALL_COMMAND} \`\`\` Also \`brew install hsnice16/tap/tula\` and \`npm install -g @hsnice16/tula\` — the same -binary. macOS and Linux, 64-bit Intel and ARM; Windows through WSL. Every release +binary. macOS 13 or later and Linux, 64-bit Intel and ARM; Windows through WSL. Every release carries a published checksum, which the installer always checks and refuses on, and a sigstore-backed build attestation, which it checks wherever the GitHub CLI is installed and signed in to read one. diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index 3c42a6d..4c754e3 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -42,7 +42,9 @@ export function Footer() { return (