From 610667c954810d835f501769c2e808aa38db4a8a Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 22 Sep 2026 14:02:18 +0530 Subject: [PATCH 1/3] Drop conflicts_with so the Homebrew install line works on Homebrew 6+ Homebrew 6 refuses to load a formula from a tap the user has not trusted, and `brew install hsnice16/tap/tula` trusts only tula. The formula named tula-latest as a conflict, so Homebrew loaded it to check, refused, and the install line failed before downloading anything. Installing both channels still fails, at the link step, which names `brew unlink`. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 11 ++++++----- CHANGELOG.md | 4 ++++ scripts/homebrew-formula.sh | 15 ++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e1c6bc..13e568a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -999,11 +999,12 @@ before pasting keys tied to their net worth. writes `Formula/tula@.rb` beside the two channel formulae and the tap accumulates them. Going back is the direction that matters when a build is showing somebody a wrong number, and it is not worth having on two channels - out of three. The pinned formulae are `keg_only`: they cannot name each other - in a `conflicts_with` because none of the later files exist when one is - rendered, and an old build belongs on PATH only when somebody links it on - purpose. `homebrew-formula.sh` mirrors Homebrew's own `Formulary.class_s` - rather than the two names we happen to ship — `tula@0.1.0` must declare + out of three. The pinned formulae are `keg_only`: an old build belongs on PATH + only when somebody links it on purpose. No formula declares `conflicts_with`: + Homebrew 6+ refuses to load a named formula from a tap the user has not + trusted, and the install line trusts only the formula it names. + `homebrew-formula.sh` mirrors Homebrew's own `Formulary.class_s` rather than + the two names we happen to ship — `tula@0.1.0` must declare `TulaAT010`, and a class name that disagrees with its file name fails the whole tap for every user at once. - **Artifact names are a contract** between `release-build.sh`, the formula, the diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e0dfe..52f28fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ## [Unreleased] +### Fixed + +- **`brew install hsnice16/tap/tula` works on Homebrew 6 and later.** The formula named `tula-latest` as a conflict, and Homebrew refuses to load a formula from a tap you have not trusted, so the install line failed before downloading anything. + ## [0.3.2] - 2026-09-22 ### Fixed diff --git a/scripts/homebrew-formula.sh b/scripts/homebrew-formula.sh index 051232f..3250e8a 100755 --- a/scripts/homebrew-formula.sh +++ b/scripts/homebrew-formula.sh @@ -65,17 +65,14 @@ for t in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do [ -n "${!var}" ] || die "no checksum for tula-v$VERSION-$t.tar.gz in $RELEASE/checksums.txt" done -# Two tula binaries on one PATH is a coin toss about which liquidation number you -# are reading, and the two channels and the pinned versions refuse that in the -# two ways Homebrew offers. The channels conflict, because they are alternatives -# and picking one is the point. A pinned version is keg_only instead: it cannot -# name every other pinned version, those files do not exist yet when this one is -# rendered — and keg_only is the stronger answer anyway, since an old build ends -# up on PATH only when somebody links it on purpose. +# A pinned version is keg_only, so an old build reaches PATH only when somebody +# links it on purpose. The two channels declare no conflicts_with: Homebrew 6+ +# loads the named formula to check it, and `brew install hsnice16/tap/tula` +# trusts only tula — so the install line refused on an untrusted tula-latest. +# Installing both still fails, at the link step, which names `brew unlink`. case "$NAME" in *@*) PATH_RULE=" keg_only :versioned_formula" ;; - *) OTHER=$([ "$NAME" = tula ] && echo tula-latest || echo tula) - PATH_RULE=" conflicts_with \"$OTHER\", because: \"both install a tula binary\"" ;; + *) PATH_RULE="" ;; esac cat < Date: Tue, 22 Sep 2026 14:09:22 +0530 Subject: [PATCH 2/3] Stop telling Homebrew users to tap and install by the short name On Homebrew 6+, tapping trusts nothing, so `brew install tula` from an untrusted tap is refused; only the full-name line trusts the formula. An install made before Homebrew 6 has no trust entry, and `brew upgrade tula` refuses it until `brew trust --formula hsnice16/tap/tula` runs once, so the update step says so. Co-Authored-By: Claude Opus 5 (1M context) --- site/app/install/page.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx index 8dc93f0..1d50f2b 100644 --- a/site/app/install/page.tsx +++ b/site/app/install/page.tsx @@ -180,9 +180,6 @@ const CHANNELS: Channel[] = [ <> {'brew install hsnice16/tap/tula'}

- Or run brew tap hsnice16/tap once, then brew install tula. -

-

Homebrew checks the download against the checksum in the formula.

@@ -206,6 +203,10 @@ const CHANNELS: Channel[] = [ +

+ If Homebrew refuses an untrusted tap, run{' '} + brew trust --formula hsnice16/tap/tula once. +

{'brew upgrade tula'} From 38e59a7a4f2f941e68c7fa816edea6737c35dbe8 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 22 Sep 2026 15:10:18 +0530 Subject: [PATCH 3/3] Fix a false private-key promise, history opt-out and asset matching `tula connect` said it never asks for a private key before asking Kraken and Coinbase users for theirs; it now splits the way the in-app screen does. TULA_NO_HISTORY=true went on recording, since only `1` turned it off. The assistant's position filter and scenario compared a venue's raw spelling with upper-cased input, so `purr` was missed; its scenario tool also repriced shocks past -100% that /shock refuses. A forced reinstall of a build that does not start now leaves the working one in place: install.sh unpacks beside the version directory, checks the build there, and only then moves it in. Price-source, Stripe, redirect and update failures name a next step, spelled for the surface they print on. Homebrew upgrades use the full formula name, which Homebrew 6+ trusts. Stale doc claims in tasks/ and AGENTS.md are corrected. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 10 +++--- AGENTS.md | 45 ++++++------------------ CHANGELOG.md | 8 ++++- CONTRIBUTING.md | 4 +-- README.md | 8 ++--- install.sh | 36 ++++++++++++------- scripts/homebrew-formula.sh | 5 ++- scripts/install-test.sh | 21 +++++++++-- scripts/npm-pack.sh | 2 +- scripts/release-build.sh | 3 +- site/app/install/page.tsx | 13 +++---- site/app/layout.tsx | 1 - src/agent/agent.ts | 12 +++---- src/agent/tools.test.ts | 15 +++++++- src/agent/tools.ts | 18 +++++++--- src/cli/commands.ts | 4 +-- src/cli/prompt.ts | 1 + src/connectors/coinbase.ts | 6 ++-- src/connectors/hyperliquid.ts | 6 ++-- src/connectors/kraken.ts | 2 +- src/connectors/registry.test.ts | 12 +++---- src/connectors/stripe.ts | 11 +++--- src/connectors/types.ts | 2 +- src/connectors/wallet.ts | 4 +-- src/core/coverage.test.ts | 2 +- src/core/http.ts | 3 +- src/history/history.test.ts | 6 ++++ src/history/history.ts | 4 +-- src/index.ts | 23 ++++++------ src/prices/coingecko.ts | 13 ++++--- src/prices/coinmarketcap.ts | 4 +-- src/prices/coinpaprika.ts | 8 +++-- src/prices/cryptocompare.ts | 6 ++-- src/site-claims.test.ts | 2 +- src/ui/app.tsx | 10 +++--- src/update/apply.test.ts | 12 +++---- src/update/apply.ts | 21 ++++++----- src/update/channel.ts | 2 +- src/update/command.test.ts | 2 +- src/update/command.ts | 10 ++++-- tasks/README.md | 5 ++- tasks/breadth/02-binance-connector.md | 4 +-- tasks/breadth/03-chain-coverage.md | 9 +++-- tasks/breadth/08-aave-v4.md | 2 +- tasks/breadth/09-aave-depth.md | 4 +-- tasks/breadth/13-binance-depth.md | 2 +- tasks/breadth/16-stripe-depth.md | 5 +-- tasks/distribution/03-homebrew.md | 2 +- tasks/foundations/03-kraken-connector.md | 9 ++--- tasks/the-shell/09-injection-defense.md | 2 +- 50 files changed, 231 insertions(+), 190 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdae819..f06f427 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -119,7 +119,7 @@ jobs: # ------------------------------------------------------------- signing -- # # Gated on the certificate being configured rather than assumed: an - # unsigned build still installs correctly through curl and Homebrew, + # ad-hoc-signed build still installs through curl and Homebrew, # neither of which quarantines what it downloads. Once the secrets exist # this stops being optional — there is no continue-on-error below, so a # signing failure fails the release. @@ -245,7 +245,7 @@ jobs: # workflow but not the ref it ran from — so a dry run would mint proof # that a build from an arbitrary branch came from this workflow, which is # indistinguishable from a released one at the only place anybody checks. - # The cost is that a dry run no longer exercises this step; the first real + # The cost is that a dry run does not exercise this step; the first real # tag does, before any channel points at it. - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 if: steps.version.outputs.publish == 'true' @@ -421,9 +421,9 @@ jobs: bash scripts/homebrew-formula.sh dist/release "tula@$VERSION" \ >"tap/Formula/tula@$VERSION.rb" - # Stable lags deliberately: a plain tag promotes, a pre-release never - # does, and a build discovered to be wrong is skipped by promoting the - # next one instead of this one. + # Stable lags deliberately: a plain tag promotes, a pre-release only + # when promote_stable says so, and a build discovered to be wrong is + # skipped by promoting the next one instead of this one. if [ "$PRERELEASE" = false ] || [ "$PROMOTE" = true ]; then bash scripts/homebrew-formula.sh dist/release tula >tap/Formula/tula.rb fi diff --git a/AGENTS.md b/AGENTS.md index 13e568a..d98b8b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,7 +210,7 @@ src/ types.ts # Connector, KeyScope (tri-state), isOverScoped, unverified, retired venues, # storedVenues — the one split every count of venues reads off — and Coverage symbols.ts # one canonical spelling per asset, so one holding is one row - kraken.ts # HMAC over the payload digest; scope partly unprovable + kraken.ts # HMAC over the payload digest; scope from GetApiKeyInfo binance.ts # HMAC over the query string; scope fully provable coinbase.ts # CDP keys over JWT (ES256 / EdDSA); scope fully provable hyperliquid.ts # public address — every account mode, every dex, borrowing, holds, staking, vaults, sub-accounts @@ -560,7 +560,6 @@ Two rules, and they are the reason the architecture exists: modal ones included. Ink's own `kittyKeyboard` detection is not used: under Bun it handed one reply to the input over and over, and a late one was typed into the line. -- **Comments say why.** A comment that restates the code is a second copy that drifts. - **The model's failures are ours to translate.** `explain()` in `src/agent/agent.ts` turns an API error into a sentence with a next step. A raw `overloaded_error` envelope printed at somebody asking about their money is not an answer. @@ -677,17 +676,9 @@ The agent reads that task for goal and acceptance criteria, the milestone's published example; if it drifts, every private call fails as `EAPI:Invalid signature`, which reads as a bad key. - The caps in `decodeString` (`src/connectors/evm.ts`), `symbol()` - (`src/cli/session.ts`) and `remote()` (`src/core/errors.ts`), and the one - filter every one of them shares, - `visible()` in `src/core/untrusted.ts`. A decoded symbol, a venue's error text - and the model provider's are the strings somebody else writes that are - rendered *and* sent to the model; each is capped and flattened to one line so - none can pose as an instruction. A Hyperliquid builder dex name is the fourth, - held to `DEX_NAME` because a venue label cannot be cut. A fifth has to reach `SECURITY.md` and the - `SOURCES` list in `src/site-claims.test.ts` in the same commit — that list is - what fails the build when a surface names fewer sources than the build has, - and the third got in without it, so two published surfaces disagreed about - how many there were. The filter is one function because it was three + (`src/cli/session.ts`) and `remote()` (`src/core/errors.ts`), `DEX_NAME`, and + the one filter they share, `visible()` in `src/core/untrusted.ts` — rule 4 + under Security says why. The filter is one function because it was three copies that had to agree. - The tri-state `KeyScope`. Collapsing it to booleans reintroduces the lie. It is also per-power on purpose: when trading ships, `isOverScoped` drops its @@ -957,24 +948,10 @@ bun run build # -> site/out, static The install path is part of the security product: someone runs it immediately before pasting keys tied to their net worth. -- **A manual run is a dry run.** `workflow_dispatch` defaults `publish` to - false, because `GITHUB_REF_TYPE` is `branch` there and 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. A pre-release tag (`v0.4.0-rc.1`) - exercises the real channels without touching the stable ones. - - **Attestation is gated with the publish steps, not run beside them.** It had - been unconditional, on the reasoning that a dry run should exercise every - step. But an attestation is a public transparency-log entry, and `install.sh` - pins the signing workflow but not the ref it ran from — so a dry run from any - branch minted proof that a build off that branch came from this workflow, which - is indistinguishable from a release at the only place anybody checks. That is - why a dry run publishes nothing at all, and why the input says so. -- **One tag produces every artifact.** `.github/workflows/release.yml` checks the - tag against `src/version.ts`, runs `bun run check`, cross-compiles - darwin/linux × arm64/x64 with Bun, signs the macOS binaries when Apple - credentials are configured, attests every archive, then publishes to GitHub - Releases, npm and the Homebrew tap. Any failing step fails the release. +- **One tag produces every artifact, and a manual run is a dry run that attests + nothing.** `install.sh` pins the signing workflow but not the ref it ran from, + so an attestation minted on a dry run would vouch for any branch. + CONTRIBUTING.md's Releasing section says what `release.yml` checks. - **Attestation, not a signing key.** GitHub artifact attestations are sigstore-backed and keyless, so this project has no key to generate, publish, rotate or lose. `install.sh` verifies one and **refuses** on failure; without @@ -989,9 +966,9 @@ before pasting keys tied to their net worth. rather than reaching the download as a version number and failing as "No build of latest for " — a working release reading as a broken one. It resolves what GitHub's own `/releases/latest` and npm's `latest` tag both mean: - the newest release that is not a pre-release. Homebrew spells it `tula` and - `tula-latest`, because `@` there means a pinned version and `tula@latest` would - be a contradiction that installed `keg_only` and reached nobody's PATH. + the newest release that is not a pre-release. Homebrew spells it `tula`, and + the rolling channel `tula-latest`: `@` there means a pinned version, so + `tula@latest` would install `keg_only` and reach nobody's PATH. - **A version is reachable after the channels move past it, on every channel.** The installer takes `TULA_VERSION` and keeps each build under `~/.tula/versions`; npm keeps every version it has published. Homebrew keeps diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f28fd..fd6ca45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,13 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ### Fixed -- **`brew install hsnice16/tap/tula` works on Homebrew 6 and later.** The formula named `tula-latest` as a conflict, and Homebrew refuses to load a formula from a tap you have not trusted, so the install line failed before downloading anything. +- **`brew install hsnice16/tap/tula` works on Homebrew 6 and later.** The formula named `tula-latest` as a conflict, and Homebrew refuses to load a formula from a tap you have not trusted, so the install line failed before downloading anything. To update, name the formula in full — `brew upgrade hsnice16/tap/tula` — since Homebrew refuses the short name for an install it holds no trust for. +- **`TULA_NO_HISTORY` keeps nothing whatever it is set to.** Only `1` turned it off, so `TULA_NO_HISTORY=true` went on saving every line typed. +- **Asking the assistant about an asset matches however the venue spells it.** A position Hyperliquid names `purr` was missed by a question about `PURR`, and a shock the assistant was asked for below -100% now gets the same refusal `/shock` gives. +- **`tula connect` no longer says it never asks for a private key** before asking Kraken and Coinbase users for theirs. Address venues are told an address is all tula needs; keyed venues get the read-only key advice the in-app screen gives. +- **A forced reinstall of a build that does not start leaves the working one in place.** `TULA_FORCE=1` unpacked over the version in use before checking it. +- **More failures say what to do next:** a price source that is rate-limited or down, a Stripe key of the wrong kind, a redirected request, and every `update` hint, which now spells the command for where it is printed. +- **Backspace in an empty field at `tula connect`** no longer erases the prompt. ## [0.3.2] - 2026-09-22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f99703..6af3ab3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,8 +75,8 @@ These are not style preferences. 1. **A code path that can place an order or move funds.** Including "validate only" order endpoints. The absence is the product. -2. **A prompt for a seed phrase or private key.** On-chain reads take a public - address. +2. **A prompt for a seed phrase or a wallet's private key.** On-chain reads take + a public address. 3. **Anything that widens access to `src/secrets/store.ts`.** The command layer and connectors read it; the agent layer never may. 4. **Collapsing an unknown into a default.** `KeyScope.canTrade` is `'unknown'` diff --git a/README.md b/README.md index 7e3a3fb..390d1a4 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ proves that check still catches one. wording on a connect screen makes it safe to store. Circle Mint was dropped for exactly this — a Mint key can create payouts and transfers, and Circle publishes no way to make one that cannot. -- **Credentials stay on your machine**, at `~/.config/tula/credentials.json`, - mode 600 enforced on every read, and are sent only to the venue they belong to. +- **Credentials stay on your machine**, and are sent only to the venue they + belong to. - **Credentials never enter model context.** The agent layer sees one interface — the risk engine — and cannot import a connector or the secret store. That is enforced by `scripts/guard.sh` in CI, not by convention. @@ -212,10 +212,6 @@ useful thing you can send. | Aave V4 | planned — v4 is Hubs and Spokes rather than Pools, so no call the connector makes reaches it ([`breadth/08`](./tasks/breadth/08-aave-v4.md)) | | Execution | later — see [ROADMAP.md](./ROADMAP.md) | -On a Kraken margin account the positions are read, but the margin level Kraken -would actually liquidate on is not, so those rows rank `unknown` rather than -carrying a distance. - ## Keys diff --git a/install.sh b/install.sh index ce75bbe..e80c7d3 100755 --- a/install.sh +++ b/install.sh @@ -85,6 +85,7 @@ need mktemp need chmod need ln need rm +need mv need cat need ls need id @@ -295,10 +296,10 @@ check_dir "$BIN_DIR" check_dir "$VERSION_DIR" # Every version stays on disk under its own number, so a run asking for one that -# is already there has nothing to fetch. It downloaded and re-verified the whole -# archive regardless — minutes of it, on a link where that is minutes — to arrive -# at the file it already had. The launcher and the PATH line are still put right -# below, because repairing those is the other reason to run this twice. +# is already there has nothing to fetch — re-verifying the archive costs minutes +# on a slow link to arrive at the file it already has. The launcher and the PATH +# line are still put right below, because repairing those is the other reason to +# run this twice. # # What makes the short cut safe is that every part of it has to be this script's # own work: the launcher is a symlink, it points at *this* version directory, @@ -340,25 +341,34 @@ if [ -z "$ALREADY" ]; then note "checking it was built by $REPO" verify_attestation "$TMP/$ARCHIVE" - mkdir -p "$VERSION_DIR" "$BIN_DIR" - chmod go-w "$INSTALL_DIR" "$VERSION_DIR" "$BIN_DIR" 2>/dev/null || true - tar -xzf "$TMP/$ARCHIVE" -C "$VERSION_DIR" || + mkdir -p "$INSTALL_DIR/versions" "$BIN_DIR" + chmod go-w "$INSTALL_DIR" "$INSTALL_DIR/versions" "$BIN_DIR" 2>/dev/null || true + # Unpacked beside the version directory, not into it: TULA_FORCE reinstalls + # the version the launcher already runs, and a dead build unpacked over it + # would be running before the check below could refuse it. Same filesystem, + # so the move after the check is a rename; not $TMP, which may be noexec. + STAGE=$(mktemp -d "$INSTALL_DIR/versions/.tula.XXXXXX") || + die "Could not write to $(tilde "$INSTALL_DIR/versions")." "Check its permissions, then try again." + trap 'rm -rf "$TMP" "$STAGE"' EXIT INT TERM + tar -xzf "$TMP/$ARCHIVE" -C "$STAGE" || die "Could not unpack $ARCHIVE." "The download may be truncated; try again." - [ -f "$VERSION_DIR/tula" ] || die "$ARCHIVE did not contain a tula binary." \ + [ -f "$STAGE/tula" ] || die "$ARCHIVE did not contain a tula binary." \ "Report it: https://github.com/$REPO/issues" - chmod 755 "$VERSION_DIR/tula" + chmod 755 "$STAGE/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" + if ! (TULA_NO_UPDATE_CHECK=1 "$STAGE/tula" --version; exit $?) >/dev/null 2>&1; then 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:" \ + "Nothing was changed. Report it with your OS version:" \ "https://github.com/$REPO/issues — or pin an earlier release with TULA_VERSION." fi + mkdir -p "$VERSION_DIR" + chmod go-w "$VERSION_DIR" 2>/dev/null || true + mv -f "$STAGE/tula" "$VERSION_DIR/tula" + [ ! -f "$STAGE/LICENSE" ] || mv -f "$STAGE/LICENSE" "$VERSION_DIR/LICENSE" # 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/scripts/homebrew-formula.sh b/scripts/homebrew-formula.sh index 3250e8a..7e5dde8 100755 --- a/scripts/homebrew-formula.sh +++ b/scripts/homebrew-formula.sh @@ -74,6 +74,7 @@ case "$NAME" in *@*) PATH_RULE=" keg_only :versioned_formula" ;; *) PATH_RULE="" ;; esac +PATH_BLOCK=${PATH_RULE:+$PATH_RULE$'\n\n'} cat </dev/null) && ln -sf "$path" "$BIN/$tool" done @@ -487,7 +487,6 @@ 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" @@ -495,6 +494,22 @@ else bad "refuses a verified binary that does not start, and leaves the launcher alone" "$out" fi +# TULA_FORCE over the version the launcher runs: the dead build must never +# replace the working one, even for the length of the check. +H="$WORK/h-dead-force" +mkdir -p "$H" +mv "$RELEASE" "$WORK/release.dead" && mv "$WORK/release.bak" "$RELEASE" +run "$H" >/dev/null +mv "$RELEASE" "$WORK/release.bak" && mv "$WORK/release.dead" "$RELEASE" +out=$(run "$H" env TULA_FORCE=1) +rm -rf "$RELEASE" && mv "$WORK/release.bak" "$RELEASE" +if [ "$("$H/.tula/bin/tula" --version 2>/dev/null)" = "tula 9.9.9" ] && + case "$out" in *"does not start on this machine"*) true ;; *) false ;; esac; then + ok "a forced reinstall of a dead build leaves the working one in place" +else + bad "a forced reinstall of a dead build leaves the working one in place" "$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 20287e3..a631ec3 100755 --- a/scripts/npm-pack.sh +++ b/scripts/npm-pack.sh @@ -7,7 +7,7 @@ # Staged rather than published from the repository root, because the wrapper # needs optionalDependencies on packages that do not exist until this release — # putting them in the root manifest would break `bun install` for every -# contributor between now and the first publish. +# contributor until that release is published. set -euo pipefail RELEASE=${1:?usage: npm-pack.sh [staging-dir]} diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 5c7441b..20621cd 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -49,8 +49,9 @@ for entry in "${TARGETS[@]}"; do # 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 + command -v codesign >/dev/null || + { echo "release-build: $name must be re-signed with codesign; run this on macOS" >&2; exit 1; } codesign --force -s - "$stage/tula" codesign --verify --strict "$stage/tula" fi diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx index 1d50f2b..6574c24 100644 --- a/site/app/install/page.tsx +++ b/site/app/install/page.tsx @@ -183,12 +183,13 @@ const CHANNELS: Channel[] = [ Homebrew checks the download against the checksum in the formula.

- tula gets stable releases; tula-latest gets every release. + tula gets stable releases; hsnice16/tap/tula-latest gets every + release.

@@ -203,11 +204,7 @@ const CHANNELS: Channel[] = [ -

- If Homebrew refuses an untrusted tap, run{' '} - brew trust --formula hsnice16/tap/tula once. -

- {'brew upgrade tula'} + {'brew upgrade hsnice16/tap/tula'}
diff --git a/site/app/layout.tsx b/site/app/layout.tsx index b28113b..b40ce9b 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -100,7 +100,6 @@ const SCHEMA = { offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, softwareVersion: VERSION, license: `${REPO}/blob/main/LICENSE`, - codeRepository: REPO, // The other places this same software is published, so a search engine // can tell the repository, the package and the tap are one thing. sameAs: [ diff --git a/src/agent/agent.ts b/src/agent/agent.ts index c60d535..9fbe030 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -22,7 +22,7 @@ const API_BASE_URL = 'https://api.anthropic.com' /** * The SDK's default is 2. A terminal question is cheap to retry and expensive * to lose: the user typed it, the spinner is already on screen, and an - * `overloaded_error` means "later", not "no". Five attempts is the difference + * `overloaded_error` means "later", not "no". Five retries is the difference * between a transient blip and a raw API envelope printed at somebody who * asked what their ETH exposure was. */ @@ -236,7 +236,7 @@ export class Agent { this.history.length = before throw new TulaError( `Gave up after ${MAX_TURNS} tool rounds without an answer.\n` + - 'Ask for one thing at a time, or use a command — type / for the list.', + ' Ask for one thing at a time, or use a command — type / for the list.', ) } @@ -269,10 +269,8 @@ export class Agent { */ export function explain(err: unknown): string { // Every one of these carries text tula did not write, so every one goes - // through `remote()` — the same bound each venue's error takes. The argument - // there is about what an escape sequence in outside text can repaint, and it - // does not stop at the venue boundary: this is the third source of it on - // screen, and it was the one left unbounded. + // through `remote()`: an escape sequence repaints the screen whichever side + // of the venue boundary it came from. const fallback = 'Type / for the commands — they answer without the model.' @@ -292,7 +290,7 @@ export function explain(err: unknown): string { if (status === 429) { return `Over your Anthropic rate limit. Wait a minute and ask again.\n ${fallback}` } - if (status === 529 || status >= 500) { + if (status >= 500) { return ( `Anthropic is overloaded. tula retried ${MAX_RETRIES} times and kept getting the same answer.\n` + ` This is their side, not yours — ask again in a moment.\n ${fallback}` diff --git a/src/agent/tools.test.ts b/src/agent/tools.test.ts index eb898f9..8975a5e 100644 --- a/src/agent/tools.test.ts +++ b/src/agent/tools.test.ts @@ -84,6 +84,13 @@ describe('get_positions', () => { expect(rows.map((r: { sub_account?: string }) => r.sub_account)).toContain('cex-margin') }) + test('an asset filter finds a symbol the venue spelled in lower case', () => { + // `get_net_exposure` buckets by the canonical spelling, so the two tools + // disagreed about whether the book held it. + const rows = call('get_positions', { asset: 'purr' }, engineOver([{ ...FIXTURE_POSITIONS[0]!, asset: 'purr' }])).positions + expect(rows).toHaveLength(1) + }) + test('names a venue the user connected, never a sub-account label they never chose', () => { const engine = engineOver([{ ...FIXTURE_POSITIONS[2]!, id: 'sub', venue: 'lend-prime' }]) // `get_venue_status` folds `lend-prime` under `lend`, because that is the @@ -149,6 +156,12 @@ describe('run_scenario', () => { expect(call('run_scenario', { shocks: [{ asset: 'ETH' }] }).error).toContain('signed percent') }) + test('refuses a move the command line refuses, instead of repricing past zero', () => { + const error = call('run_scenario', { shocks: [{ asset: 'ETH', percent: -150 }] }).error + expect(error).toContain('is not a scenario') + expect(error).toContain('-100%') + }) + test('the health factor the tool advertises is in the payload, not left to the model', () => { const result = call('run_scenario', { shocks: [{ asset: 'ETH', percent: -20 }] }) // One row for the market, not one per collateral leg: the factor is the @@ -575,7 +588,7 @@ describe('what the venues were never asked for', () => { }) test('the tool that carries it says when to call it, so the model can still be right', () => { - // Nothing else prompts the model now, so the description is the whole of + // Nothing else prompts the model, so the description is the whole of // what makes a completeness question reach the list. const status = TOOLS.find((t) => t.name === 'get_venue_status') expect(status?.description).toContain('never_asked_for') diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 59cf617..0e782ef 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -2,8 +2,9 @@ import Decimal from 'decimal.js' import { claimed, RELEASES } from '../core/availability.js' import { freshness, healthFactor, marginRatio, pct, price, quantity, ratioFloor, ratioValue, usd } from '../core/format.js' import { unrankedVenues } from '../core/coverage.js' +import { canonicalAsset } from '../core/exposure.js' import { belongsToVenue, type Position } from '../core/position.js' -import type { Shock } from '../core/risk.js' +import { SHOCK_CEILING, SHOCK_FLOOR, usableShock, type Shock } from '../core/risk.js' import { seal, untrusted, type Untrusted } from '../core/untrusted.js' import type { RiskEngine } from './engine.js' @@ -102,7 +103,7 @@ const marked = (text: string | null): Untrusted | null => (text === null ? null export function executeTool(engine: RiskEngine, name: string, input: unknown): unknown { const args = (input ?? {}) as Record - const asset = typeof args['asset'] === 'string' ? args['asset'].toUpperCase() : undefined + const asset = typeof args['asset'] === 'string' ? canonicalAsset(args['asset']) : undefined const venue = typeof args['venue'] === 'string' ? args['venue'] : undefined const now = new Date() const at = (d: Date): string => freshness(d, now) @@ -229,7 +230,7 @@ export function executeTool(engine: RiskEngine, name: string, input: unknown): u .positions() .filter( (p) => - (asset === undefined || p.asset === asset) && + (asset === undefined || canonicalAsset(p.asset) === asset) && (venue === undefined || belongsToVenue(p.venue, venue)), ) .map((p) => { @@ -333,14 +334,21 @@ export function executeTool(engine: RiskEngine, name: string, input: unknown): u for (const entry of raw) { const e = entry as Record if (typeof e['asset'] !== 'string' || typeof e['percent'] !== 'number') continue - shocks.push({ asset: e['asset'].toUpperCase(), pct: new Decimal(e['percent']).div(100) }) + shocks.push({ asset: canonicalAsset(e['asset']), pct: new Decimal(e['percent']).div(100) }) } if (shocks.length === 0) { return seal({ error: 'No valid shocks. Each needs an asset and a signed percent.' }) } + // The command line refuses these too; repriced, -150% reads as an unpriced asset. + const outOfRange = shocks.find((s) => !usableShock(s.pct)) + if (outOfRange) { + return seal({ + error: `${pct(outOfRange.pct, 0)} is not a scenario: a move runs from ${pct(SHOCK_FLOOR, 0)}, where the asset is worth nothing, up to ${pct(SHOCK_CEILING, 0)}.`, + }) + } const result = engine.scenario(shocks) - const moveOn = (a: string): Decimal | undefined => shocks.find((s) => s.asset === a)?.pct + const moveOn = (a: string): Decimal | undefined => shocks.find((s) => s.asset === canonicalAsset(a))?.pct const row = (p: Position) => ({ ...named(p.venue), ...from(p), diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 462ad4a..d02c645 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -139,7 +139,7 @@ export function isIncomplete(session: Session): boolean { * * A venue is out of it only where nothing at all came back from it: a venue * that answered in part is exactly what this line is about, and a venue - * watching several addresses now fails one of them at a time — dropped on the + * watching several addresses fails one of them at a time — dropped on the * first failure, the wallet that did answer would go undisclosed with it. */ function uncovered(session: Session): Disclosure { @@ -839,7 +839,7 @@ export async function shock(session: Session, args: string[]): Promise { process.exit(130) } if (ch === DEL || ch === BACKSPACE) { + if (buf === '') continue buf = [...buf].slice(0, -1).join('') if (!hidden) stdout.write('\b \b') continue diff --git a/src/connectors/coinbase.ts b/src/connectors/coinbase.ts index 2ce8bc4..1f0431b 100644 --- a/src/connectors/coinbase.ts +++ b/src/connectors/coinbase.ts @@ -62,15 +62,15 @@ export function loadKey(raw: string): KeyObject { } } +/** ES256 signs over P-256, so r and s are 32 bytes each. */ +const P256_INT = 32 + /** * ECDSA signatures come back DER-wrapped; JOSE wants a fixed 64-byte r||s. * Node can do this with `dsaEncoding: 'ieee-p1363'`, but bun's crypto throws on * that option, so the conversion is done here — a silently wrong signature would * present as "Coinbase rejected your key". */ -/** ES256 signs over P-256, so r and s are 32 bytes each. */ -const P256_INT = 32 - export function derToJose(der: Buffer): Buffer { // Not a TulaError: this is tula's own signing code disagreeing with itself, // which the user can do nothing about and which should keep its stack. diff --git a/src/connectors/hyperliquid.ts b/src/connectors/hyperliquid.ts index 7418969..2470be8 100644 --- a/src/connectors/hyperliquid.ts +++ b/src/connectors/hyperliquid.ts @@ -608,9 +608,9 @@ export const hyperliquidConnector: Connector = { ], doesNotRead: [ // Declared here as well as in `wallet.ts`, which names it as an unread - // chain. The `NOT READ` line is built from connected venues, so somebody - // who connected Hyperliquid and no address would be told nothing at all - // — and the spot balances above are exactly the half of a HyperEVM + // chain: coverage is listed per connected venue, so somebody who + // connected Hyperliquid and no address would be told nothing at all — + // and the spot balances above are exactly the half of a HyperEVM // holding that reads as the whole of it. { what: 'balances on HyperEVM, Hyperliquid’s own EVM chain', diff --git a/src/connectors/kraken.ts b/src/connectors/kraken.ts index 144cb04..89d3927 100644 --- a/src/connectors/kraken.ts +++ b/src/connectors/kraken.ts @@ -60,7 +60,7 @@ function decodeSecret(secret: string): Buffer { const buf = Buffer.from(secret, 'base64') // Buffer.from is lenient: bad base64 yields a short buffer and a signature // that fails as EAPI:Invalid signature, which reads as the wrong problem. - if (buf.length < 32) throw new KrakenAuthError('Kraken API secret is not valid base64.') + if (buf.length < 32) throw new KrakenAuthError(`Kraken API secret is not valid base64.\n Copy the Private Key from Kraken again and reconnect with ${connectCommand(KRAKEN.id)}.`) return buf } diff --git a/src/connectors/registry.test.ts b/src/connectors/registry.test.ts index 766b129..b92979c 100644 --- a/src/connectors/registry.test.ts +++ b/src/connectors/registry.test.ts @@ -1,12 +1,6 @@ import { describe, expect, test } from 'bun:test' import { CONNECTORS } from './registry.js' -/** - * `coverage` is optional on `Connector` for the test doubles alone. A shipped - * venue that leaves it out contributes no areas to `disclosure()`, so it never - * reaches the `NOT READ` line and reads as a venue nothing was missed on — - * the same silence as an undeclared gap, arriving as an omission instead. - */ /** Without it the refusal falls back to a remedy that names no box on the venue's key page. */ describe('a venue that takes a key says how to make a read-only one', () => { for (const [id, connector] of CONNECTORS) { @@ -17,6 +11,12 @@ describe('a venue that takes a key says how to make a read-only one', () => { } }) +/** + * `coverage` is optional on `Connector` for the test doubles alone. A shipped + * venue that leaves it out contributes no areas to `disclosure()`, so it reads + * as a venue nothing was missed on — the same silence as an undeclared gap, + * arriving as an omission instead. + */ describe('a venue added to the build without saying what it cannot see', () => { for (const [id, connector] of CONNECTORS) { test(`${id} would otherwise read as answered in full`, () => { diff --git a/src/connectors/stripe.ts b/src/connectors/stripe.ts index 4a1a9a9..48d61a8 100644 --- a/src/connectors/stripe.ts +++ b/src/connectors/stripe.ts @@ -6,6 +6,9 @@ import { json, request } from '../core/http.js' import { connectCommand } from '../core/surface.js' const API = 'https://api.stripe.com/v1' +const MAKE_ONE = + '\n' + + ' Make a restricted key with read access: https://dashboard.stripe.com/apikeys' export const STRIPE: Venue = { id: 'stripe', kind: 'payments', name: 'Stripe' } @@ -111,20 +114,20 @@ export const stripeConnector: Connector = { */ async verifyScope(creds: ConnectorCredentials): Promise { const apiKey = creds['apiKey']?.trim() - if (!apiKey) throw new TulaError('Stripe needs a restricted API key.') + if (!apiKey) throw new TulaError(`Stripe needs a restricted API key.${MAKE_ONE}`) if (apiKey.startsWith('pk_')) { throw new TulaError( - 'That is a publishable key. It cannot read your balance. You need a restricted key (rk_).', + `That is a publishable key. It cannot read your balance. You need a restricted key (rk_).${MAKE_ONE}`, ) } if (apiKey.startsWith('sk_')) { throw new TulaError( - 'Refused: a secret key (sk_) can move money. tula only holds a restricted key (rk_) with read access.', + `Refused: a secret key (sk_) can move money. tula only holds a restricted key (rk_) with read access.${MAKE_ONE}`, ) } if (!apiKey.startsWith('rk_')) { - throw new TulaError('That does not look like a Stripe key. Restricted keys start with rk_.') + throw new TulaError(`That does not look like a Stripe key. Restricted keys start with rk_.${MAKE_ONE}`) } await get('/balance', apiKey) diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 0c9536e..0e11b64 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -12,7 +12,7 @@ import type { Position, Venue } from '../core/position.js' * still degrades loudly — it is a `TulaError`, so the failure text is what * reaches the screen, and the rows are lost rather than passed off as complete. * - * `failures` are already sentences: each one names the chain it happened on, + * `failures` are already sentences: each one names the part that did not load, * which is the whole point of separating them. */ export class PartialRead extends TulaError { diff --git a/src/connectors/wallet.ts b/src/connectors/wallet.ts index df9fae1..cdb4bc7 100644 --- a/src/connectors/wallet.ts +++ b/src/connectors/wallet.ts @@ -126,9 +126,9 @@ export function chainTokens(entries: readonly unknown[], chain: Chain): TokenEnt export interface Holding { symbol: string amount: Decimal - /** Absent for native ETH, which is the one holding with no contract behind it. */ + /** Absent for the chain's gas token, the one holding with no contract behind it. */ address?: string - /** True when another token on the list answers to the same symbol. */ + /** True when another holding on the chain would be listed under the same name. */ contested?: boolean } diff --git a/src/core/coverage.test.ts b/src/core/coverage.test.ts index c527cd5..26ac497 100644 --- a/src/core/coverage.test.ts +++ b/src/core/coverage.test.ts @@ -26,7 +26,7 @@ const made = new Map([ ]) /** - * `/venues` is where the whole of this is read: it is no longer printed beside + * `/venues` is where the whole of this is read: it is not printed beside * every figure, because coverage does not resolve the way a failure does and a * count that never reaches zero teaches the reader to skip the block. The * properties below are the same ones, checked on the surface that kept them. diff --git a/src/core/http.ts b/src/core/http.ts index 90e1d61..4604cc7 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -81,7 +81,8 @@ const redirected = (url: string): Intercepted => new Intercepted( `${host(url)} redirected the request, and tula does not follow redirects.\n` + ' Nothing was sent on. This is normal for a captive portal or a proxy\n' + - ' that intercepts TLS; on a plain network it is worth treating as suspect.', + ' that intercepts TLS; on a plain network it is worth treating as suspect.\n' + + ' Sign in to the portal or change networks, then try again.', ) /** diff --git a/src/history/history.test.ts b/src/history/history.test.ts index d5b7259..190e0b8 100644 --- a/src/history/history.test.ts +++ b/src/history/history.test.ts @@ -207,6 +207,12 @@ describe('the file', () => { expect(await readHistory()).toEqual([]) }) + test('any value of TULA_NO_HISTORY keeps nothing, not only 1', async () => { + process.env['TULA_NO_HISTORY'] = 'true' + await recordHistory('/exposure') + expect(await readHistory()).toEqual([]) + }) + // `/history` says ↑ and ctrl+r reach only this session's lines while it is set. test('TULA_NO_HISTORY=1 hands back nothing a file saved earlier holds', async () => { await recordHistory('/exposure') diff --git a/src/history/history.ts b/src/history/history.ts index 3d766cc..899ad8e 100644 --- a/src/history/history.ts +++ b/src/history/history.ts @@ -39,8 +39,8 @@ const REQUIRED_MODE = 0o600 export const historyPath = (): string => join(configDir(), 'history.jsonl') -/** The documented way to keep nothing, in the spelling `TULA_NO_UPDATE_CHECK` set. */ -export const historyOff = (): boolean => process.env['TULA_NO_HISTORY'] === '1' +/** Any value, as `TULA_NO_UPDATE_CHECK` reads it: `=true` recording anyway would be a privacy trap. */ +export const historyOff = (): boolean => Boolean(process.env['TULA_NO_HISTORY']) /** * `.githooks/scan-staged`'s credential patterns, as that script spells them, diff --git a/src/index.ts b/src/index.ts index 6ef3c25..adf5948 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,19 +117,19 @@ async function connect(venueId: string | undefined): Promise { if (!connector) fail(retired(venueId, forgetCommand(venueId)) ?? `Unknown venue "${venueId}". Available: ${known}`) console.log(`Connecting ${connector.venue.name}.`) - // Wallet, Hyperliquid and Aave read a public address and hold no credential - // at all, so this advice does not merely not apply there — it describes a key - // they will never be asked for. - if (connector.fields.some((f) => f.secret)) { - console.log(connector.readOnlyKey ?? 'Use a key that can only read.') - } - console.log('tula never asks for a seed phrase or private key.\n') + // Split as the connect screen splits it: Coinbase's signing key is a private + // key and Kraken calls its secret one, so the promise holds for addresses only. + const addressOnly = connector.fields.every((f) => !f.secret) + console.log( + addressOnly + ? 'A public address only. tula never asks for a seed phrase or private key.\n' + : `${connector.readOnlyKey ?? 'Use a key that can only read.'}\n`, + ) for (const link of connector.help) console.log(` ${link.label} ${link.url}`) if (connector.help.length > 0) console.log() const command = `tula connect ${venueId}` - const addressOnly = connector.fields.every((f) => !f.secret) const held = await secrets.listCredentials(venueId) const replacing = held.length > 0 @@ -294,11 +294,8 @@ async function main(): Promise { console.log(usage()) return } - // No load here. The shell reads the book as it opens, behind a busy row that - // names the venue being read. Awaited here instead, a venue slow to answer - // left the terminal blank and still in cooked mode for as long as it took: - // nothing said what it was waiting on, the tty echoed what was typed, and - // the keys reached the shell as one run of text rather than commands. + // No load here: the shell reads the book behind its busy row. AGENTS.md's + // Conventions has why, and `src/cli/oneshot.test.ts` fails on a load here. // The environment wins over the stored key, so a shell export can override // what is on disk without editing the file. const apiKey = envApiKey() ?? (await secrets.getProviderKey()) diff --git a/src/prices/coingecko.ts b/src/prices/coingecko.ts index 05db2c6..a66b47a 100644 --- a/src/prices/coingecko.ts +++ b/src/prices/coingecko.ts @@ -1,5 +1,6 @@ import Decimal from 'decimal.js' import { TulaError } from '../core/errors.js' +import { typed } from '../core/surface.js' import type { AssetId } from '../core/position.js' import { UNITY, usablePrice, type PriceOracle, type Quote } from '../core/prices.js' import { request } from '../core/http.js' @@ -116,7 +117,7 @@ export const PINNED: Readonly> = { interface MarketRow { id: string - symbol: string + symbol?: string current_price: number | null } @@ -157,19 +158,21 @@ export class CoinGeckoOracle implements PriceOracle { const res = await this.fetcher(url) if (!res.ok) { throw new TulaError( - res.status === 429 + (res.status === 429 ? 'CoinGecko rate limit reached. Prices are unavailable; quantities are still correct.' - : `CoinGecko returned HTTP ${res.status}. Prices are unavailable; quantities are still correct.`, + : `CoinGecko returned HTTP ${res.status}. Prices are unavailable; quantities are still correct.`) + + `\n Try ${typed('refresh')} in a moment.`, ) } const rows = (await res.json()) as MarketRow[] - if (!Array.isArray(rows)) throw new TulaError('CoinGecko returned an unexpected response.') + if (!Array.isArray(rows)) throw new TulaError(`CoinGecko returned an unexpected response.\n Try ${typed('refresh')} in a moment.`) for (const row of rows) { const price = usablePrice(row.current_price) if (!price) continue byId.set(row.id, price) - const symbol = row.symbol.toUpperCase() + const symbol = row.symbol?.toUpperCase() + if (!symbol) continue // Market-cap order means the first symbol seen is the largest holder of it. if (!prices.has(symbol)) prices.set(symbol, price) } diff --git a/src/prices/coinmarketcap.ts b/src/prices/coinmarketcap.ts index ec1acea..7a5fbcb 100644 --- a/src/prices/coinmarketcap.ts +++ b/src/prices/coinmarketcap.ts @@ -3,7 +3,7 @@ import { TulaError } from '../core/errors.js' import type { AssetId } from '../core/position.js' import { byTicker, UNITY, usablePrice, type PriceOracle, type Quote } from '../core/prices.js' import { request } from '../core/http.js' -import { inShell } from '../core/surface.js' +import { inShell, typed } from '../core/surface.js' const QUOTES = 'https://pro-api.coinmarketcap.com/v2/cryptocurrency/quotes/latest' @@ -93,5 +93,5 @@ function failure(status: number): TulaError { ' Every plan caps calls per month; the free one caps them soonest.', ) } - return new TulaError(`CoinMarketCap returned HTTP ${status}. Prices are unavailable; quantities are still correct.`) + return new TulaError(`CoinMarketCap returned HTTP ${status}. Prices are unavailable; quantities are still correct.\n Try ${typed('refresh')} in a moment.`) } diff --git a/src/prices/coinpaprika.ts b/src/prices/coinpaprika.ts index 5cae85c..f15e9c4 100644 --- a/src/prices/coinpaprika.ts +++ b/src/prices/coinpaprika.ts @@ -1,5 +1,6 @@ import Decimal from 'decimal.js' import { TulaError } from '../core/errors.js' +import { typed } from '../core/surface.js' import type { AssetId } from '../core/position.js' import { UNITY, usablePrice, type PriceOracle, type Quote } from '../core/prices.js' import { request } from '../core/http.js' @@ -50,13 +51,14 @@ export class CoinPaprikaOracle implements PriceOracle { const res = await this.fetcher(TICKERS) if (!res.ok) { throw new TulaError( - res.status === 429 + (res.status === 429 ? 'CoinPaprika rate limit reached. Prices are unavailable; quantities are still correct.' - : `CoinPaprika returned HTTP ${res.status}. Prices are unavailable; quantities are still correct.`, + : `CoinPaprika returned HTTP ${res.status}. Prices are unavailable; quantities are still correct.`) + + `\n Try ${typed('refresh')} in a moment.`, ) } const rows = (await res.json()) as Ticker[] - if (!Array.isArray(rows)) throw new TulaError('CoinPaprika returned an unexpected response.') + if (!Array.isArray(rows)) throw new TulaError(`CoinPaprika returned an unexpected response.\n Try ${typed('refresh')} in a moment.`) this.cache = { at: Date.now(), rows: bestBySymbol(rows) } return this.cache diff --git a/src/prices/cryptocompare.ts b/src/prices/cryptocompare.ts index 18a6c92..8bc022b 100644 --- a/src/prices/cryptocompare.ts +++ b/src/prices/cryptocompare.ts @@ -3,7 +3,7 @@ import { remote, TulaError } from '../core/errors.js' import type { AssetId } from '../core/position.js' import { byTicker, UNITY, usablePrice, type PriceOracle, type Quote } from '../core/prices.js' import { request } from '../core/http.js' -import { inShell } from '../core/surface.js' +import { inShell, typed } from '../core/surface.js' const PRICEMULTI = 'https://min-api.cryptocompare.com/data/pricemulti' @@ -81,7 +81,7 @@ function failure(status: number): TulaError { ) } if (status === 429) { - return new TulaError('CryptoCompare rate limit reached. Prices are unavailable; quantities are still correct.') + return new TulaError(`CryptoCompare rate limit reached. Prices are unavailable; quantities are still correct.\n Try ${typed('refresh')} in a moment.`) } - return new TulaError(`CryptoCompare returned HTTP ${status}. Prices are unavailable; quantities are still correct.`) + return new TulaError(`CryptoCompare returned HTTP ${status}. Prices are unavailable; quantities are still correct.\n Try ${typed('refresh')} in a moment.`) } diff --git a/src/site-claims.test.ts b/src/site-claims.test.ts index 160fd37..1fa6ca1 100644 --- a/src/site-claims.test.ts +++ b/src/site-claims.test.ts @@ -628,7 +628,7 @@ describe('the security page names enforcement that exists', () => { const history = read('src/history/history.ts') expect(history).toContain('REQUIRED_MODE = 0o600') expect(history).toContain('O_NOFOLLOW') - expect(history).toContain("process.env['TULA_NO_HISTORY'] === '1'") + expect(history).toContain("Boolean(process.env['TULA_NO_HISTORY'])") expect(history).not.toMatch(/from '\.\.\/secrets\//) expect(guard).toContain('reaches the history write, which only src/ui/app.tsx may call') diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 7b04ef7..348e1ce 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -295,10 +295,10 @@ interface Forget { function loadLabel(step: LoadStep): string { if (step.kind !== 'venue') return `pricing ${step.assets} asset${step.assets === 1 ? '' : 's'}` const where = `reading ${step.venue}${step.account ? ` (${step.account})` : ''}` - // One unchanging label over a nine-chain read is what a hang looks like. - // The count only ever goes up, - // and it is a count rather than the name of whichever chain is outstanding: - // a label that churns through nine names is the motion AGENTS.md rules out. + // One unchanging label over a nine-chain read is what a hang looks like. The + // count only ever goes up, and it is a count rather than the name of whichever + // chain is outstanding: a label that churns through nine names is the motion + // AGENTS.md rules out. return step.total !== undefined && step.total > 1 && step.done !== undefined ? `${where} · ${step.done} of ${step.total} chains` : where @@ -1263,7 +1263,7 @@ export function App({ if (historyOff()) { return { output: - 'Nothing is saved: TULA_NO_HISTORY=1 is set.\n' + + 'Nothing is saved: TULA_NO_HISTORY is set.\n' + " ↑ and ctrl+r reach this session's lines, and they go when tula closes.", } } diff --git a/src/update/apply.test.ts b/src/update/apply.test.ts index f58c4da..e591f76 100644 --- a/src/update/apply.test.ts +++ b/src/update/apply.test.ts @@ -247,12 +247,6 @@ describe('the tree an update will and will not go into', () => { expect((await stat(join(into.versions, NEW, 'tula'))).mode & 0o777).toBe(0o755) }) - /** - * install.sh reads a matching `.tula-sha256` as *this script downloaded, - * verified and unpacked that binary* and answers "already installed". Nothing - * here checks provenance, so a receipt written here would have the repair run - * — the thing somebody does to a tree they suspect — skip the attestation. - */ test('refuses a verified build that does not start, and leaves the launcher alone', async () => { const build = join(root, 'dead') await mkdir(build, { recursive: true }) @@ -265,6 +259,12 @@ describe('the tree an update will and will not go into', () => { expect(await stillOnOld()).toBe(true) }) + /** + * install.sh reads a matching `.tula-sha256` as *this script downloaded, + * verified and unpacked that binary* and answers "already installed". Nothing + * here checks provenance, so a receipt written here would have the repair run + * — the thing somebody does to a tree they suspect — skip the attestation. + */ test('writes no install receipt, so the installer still re-verifies this tree', async () => { ok() await applyUpdate(NEW, into) diff --git a/src/update/apply.ts b/src/update/apply.ts index 401fc3b..9bd07df 100644 --- a/src/update/apply.ts +++ b/src/update/apply.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { TulaError } from '../core/errors.js' +import { typed } from '../core/surface.js' import { DOWNLOAD_TIMEOUT_MS, host, request } from '../core/http.js' import { APP_VERSION, REPO_URL, SITE_URL } from '../version.js' import type { NativeInstall } from './channel.js' @@ -24,6 +25,9 @@ const run = promisify(execFile) */ const UNPACK_TIMEOUT_MS = 120_000 +/** `--version` answers at once; a build that has not by now is not going to. */ +const LAUNCH_TIMEOUT_MS = 30_000 + /** * What this machine's build is called in a release. Mirrors `detect_target` in * `install.sh`, and is exported for the tests: they have to name the archive @@ -34,7 +38,11 @@ export function target(): string { const os = process.platform === 'darwin' ? 'darwin' : process.platform === 'linux' ? 'linux' : '' const native = process.arch === 'arm64' || (os === 'darwin' && appleSilicon()) const arch = native ? 'arm64' : process.arch === 'x64' ? 'x64' : '' - if (!os || !arch) throw new TulaError(`tula has no build for ${process.platform}-${process.arch}.`) + if (!os || !arch) { + throw new TulaError( + `tula has no build for ${process.platform}-${process.arch}.\n Build from source instead: ${REPO_URL}`, + ) + } return `${os}-${arch}` } @@ -68,7 +76,7 @@ async function fetchBytes(url: string, onProgress?: DownloadProgress): Promise { * 0.3.x install on 0.3.x for every release that matters. */ export const OTHER_CHANNELS = - 'Installed with Homebrew or npm: brew upgrade tula, or npm install -g @hsnice16/tula' + 'Installed with Homebrew or npm: brew upgrade hsnice16/tap/tula, or npm install -g @hsnice16/tula' diff --git a/src/update/command.test.ts b/src/update/command.test.ts index 326a7d1..fa0ed13 100644 --- a/src/update/command.test.ts +++ b/src/update/command.test.ts @@ -181,7 +181,7 @@ describe('/update', () => { const before = await linkedVersion() const { output } = await update(['install']) - expect(output).toContain('brew upgrade tula') + expect(output).toContain('brew upgrade hsnice16/tap/tula') expect(await linkedVersion()).toBe(before) }) }) diff --git a/src/update/command.ts b/src/update/command.ts index d90df3e..cc517fd 100644 --- a/src/update/command.ts +++ b/src/update/command.ts @@ -1,4 +1,5 @@ import { TulaError } from '../core/errors.js' +import { typed } from '../core/surface.js' import { APP_VERSION, REPO_URL, SITE_URL } from '../version.js' import { applyUpdate, type DownloadProgress } from './apply.js' import { availableNow } from './check.js' @@ -28,7 +29,10 @@ export async function update( ): Promise { const sub = args[0]?.toLowerCase() if (sub && sub !== 'install') { - return { output: `/update has no "${sub}". Run /update, or /update install.`, failed: true } + return { + output: `${typed('update')} has no "${sub}". Run ${typed('update')}, or ${typed('update install')}.`, + failed: true, + } } const native = await nativeInstall() @@ -45,7 +49,7 @@ export async function update( 'current is unknown. Nothing was downloaded and nothing changed.', '', ` Check the releases yourself: ${REPO_URL}/releases/latest`, - ' Then run /update again.', + ` Then run ${typed('update')} again.`, ].join('\n'), failed: true, } @@ -80,7 +84,7 @@ export async function update( 'The download is checked against its published checksum. Who built it', 'is not checked: that needs the GitHub CLI, signed in.', '', - ' /update install download it and switch to it', + ` ${typed('update install')} download it and switch to it`, ].join('\n'), } } diff --git a/tasks/README.md b/tasks/README.md index ecb3977..d80b371 100644 --- a/tasks/README.md +++ b/tasks/README.md @@ -96,6 +96,5 @@ task lands, update its status line, add the `**Covered by**` line, and add a ## Versioning -`0.x` while the read-only risk view is finding its shape. `1.0` when it is complete -and trustworthy without an agent. Milestones: `ROADMAP.md`. Shipped work: -`CHANGELOG.md`. Current version: `src/version.ts`. +The rules: `ROADMAP.md`'s Versions section. Shipped work: `CHANGELOG.md`. +Current version: `src/version.ts`. diff --git a/tasks/breadth/02-binance-connector.md b/tasks/breadth/02-binance-connector.md index 7710786..1d8d110 100644 --- a/tasks/breadth/02-binance-connector.md +++ b/tasks/breadth/02-binance-connector.md @@ -22,14 +22,14 @@ and belongs in tier 1. - Futures positions carry a liquidation price. **Written and unreachable**: `enableFutures` sets `canTrade`, so connect refuses every key that could read them. Kept because the refusal is the thing that could change. -- `verifyScope` uses `apiRestrictions`, which unlike Kraken does report permissions. +- `verifyScope` uses `apiRestrictions`. - A key with trade or withdraw permission is refused, and so is one that can take a margin loan or move funds between wallets — `enableMargin`, `enableInternalTransfer` and `permitsUniversalTransfer` are none of them a trade or a withdrawal, and all three passed the gate until `KeyScope` gained the axis they sit on. - What is left unread is declared in the connector's own `coverage`, so it - reaches the `NOT READ` line: the margin level a cross account is liquidated at, + reaches `/venues`: the margin level a cross account is liquidated at, COIN-M and Portfolio Margin, the funding wallet and the earn products, balances frozen or withdrawing, and sub-accounts. diff --git a/tasks/breadth/03-chain-coverage.md b/tasks/breadth/03-chain-coverage.md index ff154a4..bad5df0 100644 --- a/tasks/breadth/03-chain-coverage.md +++ b/tasks/breadth/03-chain-coverage.md @@ -28,11 +28,10 @@ credential; HyperEVM and Solana are declared unread rather than built. "the Ethereum node", which on a multi-chain book sends the reader to fix the wrong endpoint. Each message is built from `chain.name` and names that chain's own variable, and no two chains offer the same one. -- **A chain nothing is configured for is uncovered, not failed**, and it says so - on the view rather than being absent from it. `UNCOVERED_CHAINS` and each - chain-reading connector's `Coverage` block are what - [`open-pieces/01`](../open-pieces/01-scope-disclosure.md) assembles the - `NOT READ` line from. Nothing here can reach the failure state instead: every +- **A chain nothing is configured for is uncovered, not failed**, and `/venues` + says so. `UNCOVERED_CHAINS` and each chain-reading connector's `Coverage` + block are what [`open-pieces/01`](../open-pieces/01-scope-disclosure.md) + assembles that list from. Nothing here can reach the failure state instead: every chain in `CHAINS` ships a public default RPC. - Every chain failing is `INCOMPLETE` and a non-zero exit, not an empty book. Every chain failing raises a plain `TulaError` rather than a `PartialRead`, diff --git a/tasks/breadth/08-aave-v4.md b/tasks/breadth/08-aave-v4.md index a7a1174..893cbf4 100644 --- a/tasks/breadth/08-aave-v4.md +++ b/tasks/breadth/08-aave-v4.md @@ -62,5 +62,5 @@ V4's base64 `chain::address::id` tuples, which is the one criterion there that needs a venue tula is already reading — so that bullet lands here, and the field itself does not wait for this task. -Do not touch the `NOT READ` declaration ahead of the connector. The gap is real +Do not touch the `doesNotRead` declaration ahead of the connector. The gap is real until V4 is read, and the declaration is what tells the user so. diff --git a/tasks/breadth/09-aave-depth.md b/tasks/breadth/09-aave-depth.md index 0c6af3b..d1917e5 100644 --- a/tasks/breadth/09-aave-depth.md +++ b/tasks/breadth/09-aave-depth.md @@ -4,8 +4,8 @@ ## Goal -Read the rest of what an Aave account holds, so `NOT READ` on this venue is a -list that shortens rather than a standing shape. +Read the rest of what an Aave account holds, so this venue's list in `/venues` +shortens rather than standing still. `src/connectors/aave.ts` declares five gaps. [`08`](./08-aave-v4.md) takes the largest; this takes the other four, three of which hide a liquidation and are diff --git a/tasks/breadth/13-binance-depth.md b/tasks/breadth/13-binance-depth.md index 5ef7ece..9ddacfb 100644 --- a/tasks/breadth/13-binance-depth.md +++ b/tasks/breadth/13-binance-depth.md @@ -4,7 +4,7 @@ ## Goal -Read the rest of what a Binance account holds. Binance's `NOT READ` line is the +Read the rest of what a Binance account holds. Binance's list in `/venues` is the longest tula prints, because Binance is the venue that keeps money in the most places at once — spot, margin, a funding wallet, the earn products, and any number of sub-accounts under one login. diff --git a/tasks/breadth/16-stripe-depth.md b/tasks/breadth/16-stripe-depth.md index 3064039..6c071da 100644 --- a/tasks/breadth/16-stripe-depth.md +++ b/tasks/breadth/16-stripe-depth.md @@ -44,8 +44,9 @@ this file is mostly for. were drawn by, and `available` keeps meaning available *in Stripe's sense* — payable on the payout schedule, not now. Do not reach for a `hides: 'availability'` declaration to cover a bucket whose free figure is unclear: - `venueFacts` in `src/core/coverage.ts` reads it venue-wide, so one such entry - blanks the `FREE` column for every Stripe row, including the ones proven today. + `availabilityFacts` in `src/core/coverage.ts` reads it venue-wide, so one such + entry blanks the `FREE` column for every Stripe row, including the ones proven + today. - Each gap closed removes its `doesNotRead` entry and the test in `stripe.test.ts` holding it open, in the same change. The connected-account test asserts on request headers rather than URLs, because a `Stripe-Account` diff --git a/tasks/distribution/03-homebrew.md b/tasks/distribution/03-homebrew.md index 9a0ad21..833e262 100644 --- a/tasks/distribution/03-homebrew.md +++ b/tasks/distribution/03-homebrew.md @@ -12,7 +12,7 @@ Second channel, and the one most users will actually use. - `brew install hsnice16/tap/tula`. The bare `brew install tula` needs homebrew-core, which wants a public release with real usage behind it, so it is a 1.0 follow-up rather than something to claim now. -- Two channels: a stable one that deliberately lags and skips known-bad builds, and `@latest`. +- Two channels: a stable one that deliberately lags and skips known-bad builds, and `tula-latest`. - Channel selected by formula name rather than configuration. ## Notes diff --git a/tasks/foundations/03-kraken-connector.md b/tasks/foundations/03-kraken-connector.md index e97bd5e..19cb0e4 100644 --- a/tasks/foundations/03-kraken-connector.md +++ b/tasks/foundations/03-kraken-connector.md @@ -20,11 +20,12 @@ key cannot withdraw. `XTZ` intact. - `.S`/`.M`/`.B` yield suffixes map to `staked` and `.HOLD` to `pending`; duplicates merge into one exposure. -- `verifyScope` proves withdraw scope and reports trade scope as `unknown`. +- `verifyScope` proves both powers from `GetApiKeyInfo`, which is gated on none; + where it does not answer, trade is `unknown`. - No order endpoint is called, including validate-only variants. ## Notes -Kraken exposes no endpoint that reports a key's permissions, and every endpoint -gated on trade permission mutates an order. `WithdrawMethods` is gated on -"Withdraw Funds" but only lists methods, so a success there is proof. +Every endpoint gated on trade permission mutates an order, so the fallback never +probes trade. `WithdrawMethods` is gated on "Withdraw Funds" but only lists +methods, so a success there is proof. diff --git a/tasks/the-shell/09-injection-defense.md b/tasks/the-shell/09-injection-defense.md index 56016e9..8c719d3 100644 --- a/tasks/the-shell/09-injection-defense.md +++ b/tasks/the-shell/09-injection-defense.md @@ -58,7 +58,7 @@ Labelling, which is the half this task was open for: reason as those two: one signal could not tell two states apart. It renders as `ALTERED` on `/positions`, `/exposure`, `/breaks`, `/shock`, `/refresh`, `/venues` and `/ status`, saying in its first line that nothing is - missing — it is not `INCOMPLETE`, `REMOVED`, `NOT READ` or a venue holding + missing — it is not `INCOMPLETE`, `REMOVED`, an area never read or a venue holding nothing, and it raises no exit code. Rule 7: the venue is named on every line, and where the row came off a chain so is the `TULA__RPC` that chooses the node that sent it.