Repository navigation
Add the Edge CLI, on Node-safe extractions from the GUI - #6082
paullinator wants to merge 56 commits into
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
67406a5 to
35eeb43
Compare
a8819b9 to
60adf1e
Compare
7d3a2ba to
c0c5f13
Compare
0574066 to
4bb295e
Compare
4bb295e to
70e9045
Compare
Start the log's reopen retry window only when an open fails. The window spaces out retries after a failed reopen, so a persistent failure costs one openSync per REOPEN_RETRY_MS rather than one per line. A successful reopen started it too, and every roll reopens the log, so a roll within five seconds of the previous one found the window open: roll() had already released the stream, and every line was dropped until the window passed. At the 8 MB ceiling that takes more than about 1.6 MB/s of logging.
Account and session management, credentials, 2FA and vouchers, the data
store, keys and wallets, tokens, URIs, transactions and their export,
the staged spend path, swaps, exchange rates, and the `$internalStuff`
admin calls — the rest of the surface, each call its own `route({…})`
declaration carrying its own cleaners, error list and prose.
With the surface complete, the suites that drive all of it arrive too:
`testCliFake` and `testCliSubscribe` spawn a real engine daemon on a
Unix socket under a temporary data directory and walk every command
against the in-process fake world, needing no network and no API key —
and the fifth documentation gate, the one that asks whether a command's
handler is ever reached, counts them. `docs/api/README.md` is the
contributor's guide to the declaration format and to all five gates.
They hold across every route: the surface matches, each response field
carries prose, each call either matches its `edge-core-js` signature or
records why it differs, and every command either reaches its handler
offline or is listed with the suite that drives it instead. `npm run
docs:api:gates` reports the counts; they are not repeated here, because
a number in a commit message goes stale the first time a route is added.
Some handlers no offline suite can reach past a refusal: a spend needs
funds, a swap a live partner, a voucher a pending login. The coverage
gate lists each with what does reach it, either a handler suite such as
`spendHandlers.test.ts` or `ratesRoutes.test.ts`, which it checks
exists, or the reason nothing can. No command is excused as reachable
only over the network.
Where the API departs from `edge-core-js` it is recorded in `coreExtra`
with the reason — a wallet object that cannot cross HTTP as anything but
an id, the `to`/`amount` shorthand that expands into `spendTargets`,
engine-side paging and export on `get-transactions`. Anything not listed
there fails the build.
A caller's mistake is a refusal, not a success. Core's own surface is
tolerant in ways an API cannot be — `changeEnabledTokenIds` filters out
an id it does not know and answers 200, so a mistyped contract address
was indistinguishable from a no-op; a voucher id the login server has
never seen is forwarded and reported as approved, while the server
approves the real login on a timer; a `--token-id` the wallet tracks
nothing for answered an empty list and exit 0. Each of those is now a
`404` or a `400` naming the field, raised before anything is written,
and the declared error list says so. The same stance covers a pair of
fields that contradict each other: a `spendInfo` beside a `tokenId`, a
saved export preference with no format to save.
Where a value cannot be derived safely the call refuses rather than
guesses. `rates-usd-to-native` requires its `multiplier`, because this
route has no logged-in account to read a denomination from and an
assumed one returns a `nativeAmount` wrong by orders of magnitude.
`sign-bytes` rejects malformed base64 instead of signing whatever
decoded. Handles record the resolved `wallet.id`, so a wallet id and a
unique prefix of it name the same wallet on every step of a staged
spend.
Read the engine's run directory portably in the CLI suites.
The suites found session.json beside the engine's socket with
path.dirname. On Windows the socket is a named pipe that embeds the run
directory under \?\pipe\. Where the suites regexed it out of
engine-status, the capture was still JSON-escaped. They read and wrote
session.json in a directory that does not exist and opened the event
stream on a pipe that does not exist, which failed the one-shot suite and
three offline checks. cliHarness unescapes the capture and maps the pipe
back to its run directory. The open-subscription Ctrl-C check also accepts
the signalled exit on Windows, where kill('SIGINT') terminates without
running a handler.
Fixup-for: human
The CLI is built from this repository but is not this repository: rollup inlines every module it reaches under `src/`, so a published package is the two bundles, the CLI document as its README and `LICENSE`. Nothing about it needs the CLI to move to a workspace or a submodule, and the app's own `package.json` stays `private: true` — what is published is a separate manifest assembled in a temporary directory, so the app itself cannot reach npm by accident. `src/cli/npmMeta.ts` holds the decisions: the scoped name, the bin name, the licence — `BSD-3-Clause`, the CLI's own, not the app's `SEE LICENSE IN LICENSE`, which names a file a published package does not carry. There are no per-platform native packages. The version is not among them. The CLI ships in lockstep with the app, so the manifest takes it from the app's `package.json`: there is no second number to bump, and a published CLI says which app release it corresponds to. Lockstep costs one thing worth knowing before a release — npm will not replace an existing version, so a CLI-only fix goes out on the next app version bump rather than on its own. The dependency list is derived, not written down. `rollup.config.cli.mjs` externalises every key of the app's `dependencies` — its whole runtime set — so the bundles leave all of them as bare `require`s while needing fifteen, and anything the app does not declare is inlined instead. `buildCliManifest.ts` walks the module graph from both entry points, keeps the bare specifiers the app declares as dependencies, drops builtins and type-only imports, and treats the rest as bundled. Checked against the bundles' own `require` calls: fifteen declared and none missing, which is the direction that decides whether an install can run — three of the fifteen (`date-fns`, `edge-info-server`, `uuid`) rollup inlines rather than externalises, so the tarball declares a little more than it strictly needs. Hand-maintaining that list fails as an `npm install` that succeeds and a CLI that cannot resolve a module on first run, so `cli:manifest:check` gates it in `verify` and the pre-commit hook. `npm run prepare` regenerates it as it does the documentation artifacts, so in CI, as for those, `docs:api:committed` is what catches a stale one. `publishCli.ts` builds, stages and publishes, and by default the tarball carries no HMAC addon. The addon's shards rebuild the same `apiSecret` the mobile release builds sign with, from a pad that is a constant in this public repository, so shipping it would hand that secret to anyone with `npm pack` and `strings`. An unsigned CLI needs an `edgeApiKey` of its own, and the staged README says so. `--with-signer --signer-secret-is-cli-only` is the opt-in for a key pair issued to the CLI; `--with-signer` alone is refused. Each way a publish could go wrong is refused before it happens, because npm will not replace a version: - a working tree that is dirty, or that git cannot describe; - a HEAD that cannot be read, or that no long-lived branch (`develop`, `master`, `staging`) or tag contains after a fetch, since the README's links into this repository are pinned to it and a feature branch's next force-push would drop the commit; - a plugin family the bundled packages are known to break (`scripts/util/knownPluginBreakage.js`, which `cli:plugins:check` holds the installed packages to), unless `--allow-broken-plugins`; - a signer addon whose name is missing from the manifest's `files`, the allowlist npm packs from; - a flag whose value is missing or is itself a flag — `--out` with the path left off used to fall through to `npm publish --access public`. `--dry-run` packs without publishing and `--out` stages for inspection. CI moves from Node 18 to 22 for every build of this repository, because `cli:plugins:check` enters `.travis.yml` here and loads every plugin's tools: the Solana family reaches `rpc-websockets`, which `require`s the ESM-only `uuid@14`, and Node can `require()` ESM only from 20.19 and 22.12. The same floor is the package's `engines` — below it an install succeeds and Solana alone fails to load. Today the plugin refusal stops every publish: the EVM chains need an `edge-currency-accountbased` release that ships `lib/ethereum/abi/`. One thing the exercise surfaced, not fixed here: installing the package costs 2.3 GB across 69,756 files, of which about 1.3 GB is React Native mobile binaries — iOS simulator slices and Android libraries for the privacy coins — reached through `edge-currency-accountbased`, which also brings `react-native` itself. A Node CLI can load none of it.
Log a failed rates pass rather than showing it. A failed pass of the retried rates queue showed a warning drop-down, at most once every 30 seconds. A user cannot act on a background rates fetch, so the app leaves onPassError unset and the failure goes to reportWarning, which logs it, as on develop. The chain giving up still shows an error.
Show the error when the most-recent wallets order cannot be saved. Redux takes the new order at once, before the file is written, so the user sees it. A failed save went to reportWarning, which only logs, and the order then reverted at the next login with nothing said. It goes to showError now, as on develop.
Clear a handle's broadcast mark when its transaction is replaced. broadcast-tx marks a transaction handle sent, and save-tx records only a marked handle. accelerate --object-id and sign-tx replace the handle's transaction with one nobody has sent, and the mark stayed, so make-spend, sign-tx, broadcast-tx, accelerate --object-id, save-tx recorded the unsent replacement: a phantom pending send with its inputs marked spent until a resync. txHandleResponse now sets the mark from its caller, and only broadcast-tx sets it.
Make broadcast-tx safe to repeat. A second broadcast-tx on a handle already sent answers the first call's result and sends nothing. The likely repeat is a client that timed out or lost the socket after the engine sent, and sending again reached the plugin, which answers "already known" or "missing inputs": a failure report for a payment that went through. A broadcast that fails answers BROADCAST_FAILED with the txid and the same handle, as spend does, rather than the plain core error: it may have reached the network, and running broadcast-tx again re-sends the same bytes. BROADCAST_FAILED replaces NETWORK_ERROR in the route's errors, and the API reference is regenerated.
Refuse an amount that disagrees with the payment request's. An amount in the body won silently over the amount in a payment URI, so --to='bitcoin:<address>?amount=0.001' --amount=0.5, from a wrapper that always sets --amount, prepared a send 500 times the invoice. The two are refused when they differ, as useMax beside a payment request already is, and the nativeAmount doc says so.
Report a swap approve that may have sent as SWAP_OUTCOME_UNKNOWN. approve() signs, broadcasts and then does the plugin's bookkeeping. A failure reached the caller as the raw core error, usually NETWORK_ERROR with exit 6, which is the code a script treats as transient, and the route's note then named a fresh quote as the safe recovery. After a send that went through, that approves a second swap of the same funds. A failure core places before the send (insufficient funds, the swap limit, permission and currency errors) keeps its own code. Anything else answers SWAP_OUTCOME_UNKNOWN, 502, with the handle, the plugin and the source wallet in details, and exits 1 rather than 6. The note now says to check get-transactions on the source wallet before quoting again, and the code is in the published error table.
Ask for swap quotes with the app's options and gating. fetch-swap-quotes passed core only preferPluginId. The app also turns off the exchanges the info server's exchangeInfo disables, outright or for the request's assets, and the ones the account's referral or a promotion disables, prefers the account's exchange and exchange type, passes promo codes, and waits 10 s for slow exchanges and 60 s in all. Without the disables, the CLI could quote and execute through an exchange Edge had turned off for an outage. makeSwapRequestOptions builds those options for both, from the hook's own logic, and withAssetDisabledPlugins adds the per-asset disables that SwapProcessingScene added inline. The route reads the account's referral files through cleaners moved into accountReferralFiles.ts, its preferred exchange from Settings.json, and exchangeInfo from the public info rollup, fetched again once the last one read is five minutes old, as the app does. An asset Edge turned off for every exchange is refused with SWAP_CURRENCY and the swap scene's message. A preferPluginId in the request still wins, and its doc now says it prefers rather than restricts.
Enable the plugins the app enables, from the app's own table. The engine derived plugin enablement on its own. With no keys every currency plugin came on, liberlandtestnet, bitcoincashtestnet and bitcoingoldtestnet included, which the app turns off, and swap plugins followed config.json alone, so unizen and spookySwap could be on while transfer, cosmosibc, velodrome and fantomsonicupgrade, which the app always enables, were off. The app's table moves out of corePlugins.ts into corePluginsTable.ts, whose builders take the resolved plugin maps instead of reading the app's pluginMaps. corePlugins.ts passes pluginMaps, so the app is unchanged. The engine's buildPluginsInit feeds the same table the app's merge, config.json's corePlugins and swapPlugins over the keys through resolvePluginMaps, and still narrows each keys entry through initFromKeysEntry, which disables an entry it cannot read.
Let a CLI user start a 2FA reset. request-otp-reset takes the resetToken an OTP_REQUIRED error carries, and the CLI's printError replaced it with "read it from the REST body", so a CLI-only user could not start a reset. One who fetched it over REST had to pass it on argv, visible to ps, because --otp-reset-token had no variable. printError now writes the token to ~/.edge-cli/otp-reset-token, readable only by the user, and names the file where the token was. The flag reads EDGE_CLI_OTP_RESET_TOKEN when absent, like the other credentials, and the guide's table and the field's doc say so.
Declare OTP_REQUIRED and CHALLENGE_REQUIRED on the other login routes. login-with-pin, login-with-key and login-with-recovery take the same otp, otpKey and challengeId fields as login-with-password, and core can answer any of them with a 2FA or captcha challenge, but only login-with-password declared the two codes. The generated reference and help text now publish them for all four.
Exit 5, not 3, for a swap the exchange refused. SWAP_PERMISSION had no row of its own, so its 403 fell through to exit 3, AUTH. A geo restriction, missing verification or an asset that needs activating is not a credential problem, and a script that logs in again on exit 3 would loop on it. It maps to VALIDATION now, and the published exit-code table lists it there. QUOTE_EXPIRED keeps exit 4 through its 410, the same as OBJECT_EXPIRED: both release the handle and mean "start again", and the code tells them apart. The 410 row's comment says so, and a test pins the two together.
Export even when the preferences cannot be saved. With saveExportPrefs, an unreadable exportTxInfo.json made mergeExportTxInfo refuse the write, rightly, and the refusal failed the request after the whole fill, so the caller got no files. The export scene saves the preferences as a side errand and exports anyway. get-transactions now does too: it logs the failure and answers the files with exportPrefsError beside them, and the route's doc names the field.
Say in the CHANGELOG that an unpriced pair reads as zero for five minutes. A pair the rates server answers without a price is cached as unpriced for UNPRICED_TTL_MS and resolves 0 until then, where develop queued it again without end. The entry for the fix now says what the app shows meanwhile.
Add the app's settings changes to the CHANGELOG. The extraction changes two things the app does: a settings write refuses, with settings_not_saved_synced_unreadable or settings_not_saved_unreadable_file, while the file it would replace could not be read, and the most-recent wallets list is written from the file's own list rather than from Redux, which holds an empty list after a failed login read. Each gets an entry.
Add the app's export changes to the CHANGELOG. The shared export pipeline changes what the app's export scene writes: every fiat amount takes its transaction's sign, a transfer's fee row is priced, and the scene writes nothing when the settings could not be read at login and drops the Bitwave file without an account ID, saying so. Each gets an entry.
Drop "no behaviour change to the app" from the extraction's CHANGELOG entry. The extraction does change the app, in the ways its own entries now list, so the claim beside the move was not true.
Gate and smoke-load the modules fetch-swap-quotes now imports. The swap gating made the CLI import src/actions/ExchangeInfoActions.ts, src/types/TweakTypes.ts and src/types/AppleAdsAttributionTypes.ts, which no gate path watched, so a commit staging only one of them skipped the CLI gates, and src/util/swapRequestOptions.ts and src/util/accountReferralFiles.ts, which the Node-safety smoke test did not load. Both lists name them now.
Regenerate the CLI's package manifest for develop's versions. paul/mason's develop moves edge-core-js to 2.54.0, edge-currency-accountbased to 4.103.1 and edge-currency-plugins to 3.17.0, and the manifest pins the versions the lockfile resolves.
Regenerate src/cli/generated/npmPackage.json for develop's dependency versions: edge-core-js 2.55.0, edge-currency-accountbased 4.104.0, edge-exchange-plugins 2.60.1 and edge-info-server 3.16.1.
Leave the privacy coins off in the CLI. buildPluginsInit sets monero, piratechain, zano and zcash to false whatever the app's table, the keys or config.json say: their engines need the native libraries edge-currency-accountbased uses (react-native-monero, react-native-pirate-wallet, react-native-zano and zcash-native), which the CLI does not load. The app's table is unchanged. With the plugins off, core keeps wallets of those types out of activeWalletIds, so the CLI does not list them, and create-currency-wallet for one answers "Cannot find a currency plugin for wallet type wallet:<chain>" (checked on login-tester with a throwaway account). The Monero LWS key logging, which applied only while monero was enabled, goes. The unloadedWallets comments and tests, and the guide, describe the plugins the CLI does enable.
Name zcash-native in the guide's --omit=peer paragraph: edge-currency-accountbased 4.104.0 declares it as a peer in place of react-native-zcash.
| // `apiSecret` the iOS and Android release builds sign with and the runtime | ||
| // pad is a constant in this public repository: a public tarball would hand | ||
| // that secret to anyone with `npm pack` and `strings`. | ||
| if (flags.withSigner && !flags.signerSecretIsCliOnly) { |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: MEDIUM
Description: publish:cli will put the Node HMAC addon in the public @edgeapp/cli tarball when the operator passes --with-signer and --signer-secret-is-cli-only. That second flag is only a boolean. The build still embeds whatever apiSecret is in edgeKey.json, which on the app build is the same secret the iOS and Android release builds sign with, and the Node pad is the public constant app.edge.cli. Default publish (no flags) does not pack the addon.
Impact: The release job already has the mobile edgeKey.json from the Jenkins/Mason secret set. The documented signed-publish command is exactly those two flags, and nothing compares the embedded apiKey or apiSecret to a CLI-only key before npm publish --access public. A publish that skips the manual key swap leaves the mobile API HMAC secret in edge_api_signer.node. The addon reconstructs it as the XOR of the six shards with sha256("app.edge.cli"), which is also compiled in as EDGE_NODE_BUNDLE_ID. npm will not replace that version, so every consumer of the package gets the credential the mobile apps use for login-server and info-server HMAC.
Remediation: Do not treat --signer-secret-is-cli-only as proof. Give the published addon a separate input (for example edgeCliKey.json) so the mobile Jenkins edgeKey.json cannot feed it, and refuse the publish unless the embedded apiKey is on an allowlist of CLI keys or a hash of the embedded secret differs from a pinned fingerprint of the mobile release secret. Keep the default of omitting edge_api_signer.node.
Build the Node HMAC signer from edgeCliKey.json, a key pair issued for the CLI, and never from the app's edgeKey.json. makeApiSigner.ts writes the iOS and Android sources from edgeKey.json and, only with --cli, the Node addon's sources from edgeCliKey.json, with no stub and a stamp of its own. build:cli:native runs it with --cli and stops without the file. edgeCliKey.json and its stamp are gitignored, and EDGE_CLI.md and HMAC_SIGNING.md describe the split. The addon's shards reconstruct the secret it was built from and its runtime pad is a public constant, so building it from edgeKey.json put the mobile secret into any addon built on a machine that builds the app.
test:cli:node-hmac checks the addon against edgeCliKey.json, the key it is built from.
--with-signer needs edgeCliKey.json, and --signer-secret-is-cli-only is gone: the separate key file is what keeps the app's secret out of the addon. The publish refuses a CLI key that shares its apiKey or apiSecret with edgeKey.json, builds the addon from the CLI key, and before packing asks the staged addon to sign a probe, refusing unless the answer matches an HMAC of edgeCliKey.json's key, so --no-build cannot stage an addon built from another secret. Without edgeCliKey.json an unsigned publish needs --allow-unsigned, as it did without edgeKey.json.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 5 total unresolved issues (including 4 from previous reviews).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Copied app key can ship signed
- Restored --signer-secret-is-cli-only for --with-signer when edgeKey.json is absent so a copied app pair cannot ship on the documented CLI-key-only path.
Or push these changes by commenting:
@cursor push 2e455a6728
Preview (2e455a6728)
diff --git a/docs/EDGE_CLI.md b/docs/EDGE_CLI.md
--- a/docs/EDGE_CLI.md
+++ b/docs/EDGE_CLI.md
@@ -73,8 +73,10 @@
and never reads the app's `edgeKey.json`, which feeds the iOS and Android
signers only; without the file there is nothing to compile. For local testing
any pair the tester servers accept will do, including a copy of the app's
-development key: `publish:cli --with-signer` refuses a CLI key that shares its
-apiKey or apiSecret with `edgeKey.json`. It runs `build:cli:native` (node-gyp over
+development key. A signed publish refuses a CLI key that shares its apiKey or
+apiSecret with `edgeKey.json`, and without that file needs
+`--signer-secret-is-cli-only` to say the CLI key is a pair of its own.
+`build:cli:all` runs `build:cli:native` (node-gyp over
`native/edge-api-signer/node/`), then `build:cli`, then
`build:cli:copy-native`, which puts `edge_api_signer.node` beside the bundles
where `loadNodeApiSignerNative` looks for it. `npm run test:cli:node-hmac`
@@ -892,7 +894,9 @@
skips builtins and type-only imports, and treats the rest as bundled. The
result is checked against the built bundles' own `require` calls.
-A signed publish needs `edgeCliKey.json` and nothing else:
+A signed publish needs `edgeCliKey.json`. `--with-signer` still needs
+`edgeKey.json` to compare against, or `--signer-secret-is-cli-only` to say
+the CLI key is not a copy of the app pair:
```sh
npm run publish:cli -- --dry-run # build, stage, pack, publish nothing
@@ -938,10 +942,13 @@
stays the app's, and the addon never reads it.
2. `npm run build:cli:all`, which generates the shards, compiles the addon and
copies it beside the bundles.
-3. `npm run publish:cli -- --with-signer`.
+3. `npm run publish:cli -- --with-signer`. Without `edgeKey.json` beside
+ `edgeCliKey.json`, add `--signer-secret-is-cli-only`.
-`--with-signer` is refused without `edgeCliKey.json`, and refused when that
-file shares its apiKey or apiSecret with `edgeKey.json`. With it, the addon is
+`--with-signer` is refused without `edgeCliKey.json`, refused when that
+file shares its apiKey or apiSecret with `edgeKey.json`, and refused without
+`edgeKey.json` unless `--signer-secret-is-cli-only` says the CLI key is a
+pair of its own. With it, the addon is
added to the manifest's `files` — npm's `files` is an allowlist, so staging the
file is not enough — and the publish is refused if it did not make the list,
since a tarball that silently lacks the addon would be published as signed.
diff --git a/docs/HMAC_SIGNING.md b/docs/HMAC_SIGNING.md
--- a/docs/HMAC_SIGNING.md
+++ b/docs/HMAC_SIGNING.md
@@ -31,9 +31,11 @@
CLI's own `edgeCliKey.json` and never the app's `edgeKey.json`, why
`scripts/publishCli.ts` leaves it out of the published package by default,
and why `--with-signer` refuses without `edgeCliKey.json`, refuses a CLI key
- that shares its apiKey or apiSecret with `edgeKey.json`, and checks that
- the staged addon signs with the CLI key. Each run hashes its bundle id into
- the stamp that decides whether a regeneration is needed: `.edgeApiSigner.stamp`
+ that shares its apiKey or apiSecret with `edgeKey.json`, refuses without
+ `edgeKey.json` unless `--signer-secret-is-cli-only` says the CLI key is a
+ pair of its own, and checks that the staged addon signs with the CLI key.
+ Each run hashes its bundle id into the stamp that decides whether a
+ regeneration is needed: `.edgeApiSigner.stamp`
for the mobile pair, `.edgeCliApiSigner.stamp` for the Node one.
3. The C sources reconstruct `secret = s0 ⊕ … ⊕ s5 ⊕ runtimePad`.
diff --git a/scripts/publishCli.ts b/scripts/publishCli.ts
--- a/scripts/publishCli.ts
+++ b/scripts/publishCli.ts
@@ -16,12 +16,13 @@
* npm run publish:cli # publish
* npm run publish:cli -- --tag next # publish under a dist-tag
*
- * A signed build needs `edgeCliKey.json`, the CLI's own key pair, and nothing
- * else: with it, `build:cli:all` generates the XOR-split secret shards,
- * compiles the Node HMAC addon and rolls up both bundles. The app's
- * `edgeKey.json` is never read for the addon. Without the CLI key the addon
- * cannot be built at all, so publishing then needs `--allow-unsigned` said out
- * loud rather than quietly shipping a CLI that cannot sign.
+ * A signed build needs `edgeCliKey.json`, the CLI's own key pair: with it,
+ * `build:cli:all` generates the XOR-split secret shards, compiles the Node
+ * HMAC addon and rolls up both bundles. The app's `edgeKey.json` is never
+ * read for the addon. `--with-signer` still reads it to refuse a copy, and
+ * without it needs `--signer-secret-is-cli-only`. Without the CLI key the
+ * addon cannot be built at all, so publishing then needs `--allow-unsigned`
+ * said out loud rather than quietly shipping a CLI that cannot sign.
*/
import { spawnSync } from 'child_process'
import fs from 'fs'
@@ -72,7 +73,9 @@
* for a reformat that breaks the logic. `--with-signer` is off by default
* and needs `edgeCliKey.json`, the CLI's own key pair, because the addon's
* shards reconstruct the secret it carries and the runtime pad is a constant
- * in this public repository — see the note in `src/cli/npmMeta.ts`.
+ * in this public repository — see the note in `src/cli/npmMeta.ts`. A CLI
+ * key that is a copy of the app pair is refused when `edgeKey.json` is
+ * present, and attested with `--signer-secret-is-cli-only` when it is not.
*/
const flags = parsePublishFlags(argv)
// Only the ones this shell still reads for itself; the rest are the
@@ -149,7 +152,7 @@
const treeState = readTreeState()
// The addon is built from the CLI's own key pair. The app's key is read only
-// to refuse a CLI key that is a copy of it.
+// to refuse a CLI key that is a copy of it; without it, the flag attests.
const cliKey = readKeyPair('edgeCliKey.json')
const appKey = readKeyPair('edgeKey.json')
// Only what this phase actually judges: the tree, the key, and the flags.
@@ -160,6 +163,7 @@
treeState,
headState,
hasCliKey: cliKey != null,
+ hasAppKey: appKey != null,
cliKeyIsAppKey: cliKey != null && appKey != null && sharesKey(cliKey, appKey),
knownBrokenPlugins: [...KNOWN_MISSING, ...KNOWN_UNLOADABLE]
})
diff --git a/scripts/util/publishArgs.ts b/scripts/util/publishArgs.ts
--- a/scripts/util/publishArgs.ts
+++ b/scripts/util/publishArgs.ts
@@ -22,6 +22,7 @@
stageOnly: string | undefined
allowUnsigned: boolean
withSigner: boolean
+ signerSecretIsCliOnly: boolean
allowDirty: boolean
allowBrokenPlugins: boolean
allowUnpushed: boolean
@@ -60,6 +61,7 @@
stageOnly: flagValue(argv, '--out'),
allowUnsigned: has('--allow-unsigned'),
withSigner: has('--with-signer'),
+ signerSecretIsCliOnly: has('--signer-secret-is-cli-only'),
allowDirty: has('--allow-dirty'),
allowBrokenPlugins: has('--allow-broken-plugins'),
allowUnpushed: has('--allow-unpushed'),
@@ -291,6 +293,8 @@
headState: HeadState
/** `edgeCliKey.json`, the CLI's own key pair, is present. */
hasCliKey: boolean
+ /** The app's `edgeKey.json` is present, so a copy can be detected. */
+ hasAppKey: boolean
/** That pair shares its apiKey or apiSecret with the app's `edgeKey.json`. */
cliKeyIsAppKey: boolean
/** `knownPluginBreakage.js`'s two lists, joined. */
@@ -301,6 +305,7 @@
treeState,
headState,
hasCliKey,
+ hasAppKey,
cliKeyIsAppKey,
knownBrokenPlugins
} = opts
@@ -394,6 +399,23 @@
)
}
+ // A machine that holds only the CLI key cannot run the copy check, and
+ // that is the documented signed-publish path. A CLI key that is a copy of
+ // the app pair then passes every other gate and ships. The attestation
+ // is what `--signer-secret-is-cli-only` used to cover on every
+ // `--with-signer` run; with `edgeKey.json` present the comparison is
+ // stricter, so the flag is only required when the comparison cannot run.
+ if (flags.withSigner && !hasAppKey && !flags.signerSecretIsCliOnly) {
+ return (
+ 'Refusing to publish the HMAC addon: edgeKey.json is absent, so the ' +
+ 'CLI key cannot be checked for a copy of the app pair. A copied pair ' +
+ 'would pack the mobile apiSecret into a public tarball. Put ' +
+ 'edgeKey.json beside edgeCliKey.json so the copy can be detected, or ' +
+ 'pass --signer-secret-is-cli-only to say the CLI key is a pair of its ' +
+ 'own.'
+ )
+ }
+
if (!flags.skipBuild && !hasCliKey && !flags.allowUnsigned) {
return (
'edgeCliKey.json is absent, so the Node HMAC addon cannot be built and ' +
diff --git a/src/__tests__/cli/publishArgs.test.ts b/src/__tests__/cli/publishArgs.test.ts
--- a/src/__tests__/cli/publishArgs.test.ts
+++ b/src/__tests__/cli/publishArgs.test.ts
@@ -76,6 +76,7 @@
'/tmp/stage',
'--allow-unsigned',
'--with-signer',
+ '--signer-secret-is-cli-only',
'--allow-dirty',
'--allow-broken-plugins',
'--no-build',
@@ -87,6 +88,7 @@
stageOnly: '/tmp/stage',
allowUnsigned: true,
withSigner: true,
+ signerSecretIsCliOnly: true,
allowDirty: true,
allowBrokenPlugins: true,
allowUnpushed: false,
@@ -101,6 +103,7 @@
stageOnly: undefined,
allowUnsigned: false,
withSigner: false,
+ signerSecretIsCliOnly: false,
allowDirty: false,
allowBrokenPlugins: false,
allowUnpushed: false,
@@ -191,6 +194,7 @@
treeState: treeClean,
headState: pushed,
hasCliKey: true,
+ hasAppKey: true,
cliKeyIsAppKey: false,
knownBrokenPlugins: []
}
@@ -329,8 +333,41 @@
const refusal = preBuildRefusal({ ...base, flags, cliKeyIsAppKey: true })
expect(refusal).toContain('edgeKey.json')
expect(refusal).toContain('key pair of its own')
+ // The attestation is not an override: a detected copy still ships the
+ // app secret.
+ expect(
+ preBuildRefusal({
+ ...base,
+ flags: parsePublishFlags([
+ '--with-signer',
+ '--signer-secret-is-cli-only'
+ ]),
+ cliKeyIsAppKey: true
+ })
+ ).toContain('key pair of its own')
})
+ it('refuses --with-signer without edgeKey.json unless attested', () => {
+ // The copy check cannot run without the app pair, and a signed publish
+ // is documented to need only edgeCliKey.json. A CLI key that is a copy
+ // of the app pair then ships. The flag is the attestation that used to
+ // cover every --with-signer run.
+ const flags = parsePublishFlags(['--with-signer'])
+ const refusal = preBuildRefusal({ ...base, flags, hasAppKey: false })
+ expect(refusal).toContain('edgeKey.json is absent')
+ expect(refusal).toContain('--signer-secret-is-cli-only')
+ expect(
+ preBuildRefusal({
+ ...base,
+ flags: parsePublishFlags([
+ '--with-signer',
+ '--signer-secret-is-cli-only'
+ ]),
+ hasAppKey: false
+ })
+ ).toBeUndefined()
+ })
+
it('allows the addon built from edgeCliKey.json', () => {
const flags = parsePublishFlags(['--with-signer'])
expect(preBuildRefusal({ ...base, flags })).toBeUndefined()
diff --git a/src/cli/npmMeta.ts b/src/cli/npmMeta.ts
--- a/src/cli/npmMeta.ts
+++ b/src/cli/npmMeta.ts
@@ -59,8 +59,9 @@
// engine says exactly that when it has neither (`makeCoreContext.ts`), and
// `--fake` needs nothing at all. `publishCli.ts --with-signer` can still ship
// the addon, built from `edgeCliKey.json` — a key pair issued for the CLI —
-// and never from the app's `edgeKey.json`. It refuses without that file, or
-// when the file holds the app's key.
+// and never from the app's `edgeKey.json`. It refuses without that file, when
+// the file holds the app's key, or without `edgeKey.json` to compare against
+// unless `--signer-secret-is-cli-only` says the CLI key is a pair of its own.
export const CLI_PACKAGE_META: CliPackageMeta = {
name: '@edgeapp/cli',You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 5f12dcc. Configure here.
| treeState, | ||
| headState, | ||
| hasCliKey: cliKey != null, | ||
| cliKeyIsAppKey: cliKey != null && appKey != null && sharesKey(cliKey, appKey), |
There was a problem hiding this comment.
Copied app key can ship signed
High Severity
The --with-signer copy check only runs when edgeKey.json is on disk, but a signed publish is documented to need edgeCliKey.json and nothing else. A CLI key that is a copy of the app pair therefore passes preBuildRefusal, the staged HMAC probe (it matches edgeCliKey.json), and ships. That is the case --signer-secret-is-cli-only used to cover, and the local-testing note still allows copying the app development key into edgeCliKey.json.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 5f12dcc. Configure here.



Description
Adds the Edge CLI —
edge-cliand theedge-enginedaemon behind it — andthe work that had to come first: nine commits that move display metadata,
denominations, spam-threshold resolution, local and synced settings, the
transaction export pipeline, transaction tagging, locale selection, exchange
rates and the network helpers out of GUI-only modules so they load under
plain Node, with no behaviour change to the app.
The engine is a long-lived process owning an
EdgeContext, serving JSON overa Unix socket (with an optional token-authenticated loopback TCP listener).
The client is thin: it spawns the engine on demand, forwards one request and
prints the answer. 117 routes, each its own
route({…})declaration carryingits cleaners, its error list and its prose; the command table, the help text
and the OpenAPI reference are generated from those declarations and
committed.
Five documentation gates hold over the surface — the generated artifacts are
current, the surface matches, every response field carries prose, each call
matches its
edge-core-jssignature or records why it differs, and everycommand either reaches its handler offline against the fake world or is
listed with the suite that drives it.
npm run docs:api:gatesruns them;cli:manifest:checkanddocs:api:committedrun beside them.The twelve commits are meant to be read in order, and each one stands on its
own:
npm test,npm run docs:api:gates,npm run test:cli:node-safeandnpm run test:cli:offlinepass on every tree, with the npm scripts, theCHANGELOG entries and the CI steps landing in the commit that adds the file
each one names.
Not included, and waiting on a decision: the published package carries no
native HMAC signer. Its shards reconstruct the same
apiSecretthe iOS andAndroid release builds sign with, so shipping it in a public tarball would
hand that secret to
npm packplusstrings.publish:cli --with-signerexists and refuses unless
--signer-secret-is-cli-onlysays the secret isthe CLI's own — which needs an
apiKey/apiSecretpair issued for@edgeapp/cli. Until then a published CLI needs anedgeApiKeyof its ownin
keys.jsonor-k, and says so when it has neither.Documentation:
docs/EDGE_CLI.mdis theguide and the published README;
docs/api/README.mdis the declaration format.
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
none
Requirements
If you have made any visual changes to the GUI. Make sure you have:
Test plan
npm test— the jest suites, including 50-odd new ones undersrc/__tests__/cliandsrc/__tests__/utilfor the engine's handlers,the client, the rate queue, the locale boot and the export pipeline.
npm run docs:api:gates— the five documentation gates.npm run test:cli:node-safe— both CLI entry points loaded under plainNode with a
react-nativeimport poisoned, so a module-scope leak intothe shared code fails here.
npm run test:cli:offline—testCliFakeandtestCliSubscribedrive areal engine daemon on a Unix socket under a temporary data directory,
against the in-process fake world. No network, no API key.
npm run test:cli:offline:built— the same suites against the rollupbundle, which is what a published CLI runs.
npm run test:cli:network— the one-shot, CAPTCHA and edge-login suitesagainst the tester fleet. Needs the network and an Edge API key.
CLI session on the tester servers
Run on 2026-10-10 against
9beff6d7d:paul/cli(da1b8fe8, rebased on its master) packed in throughpack-dependencies, thennpm run build:cli:all.test:cli:networkagainst the tester fleet passed: one-shot 13/13, CAPTCHA, edge-login.test:cli:offlineagainst the built bundle passed: 242 fake-world checks plus the subscribe suite.Then the session below, run with the built CLI as
clitester, sending funds to and fromsharetestand swapping LTC to BTC. Every command is shown as typed, with its full output and exit code. Passwords went throughEDGE_CLI_PASSWORDand show as<redacted>; session ids are shortened. The full log, byte arrays included, is at https://balls.edge.app/pr-artifacts/edge-react-gui/cliTesterSession/202610101758-cli-session.log.Every command below was run with the built CLI (
lib/edgeCli.js, shown asedge-cli), from edge-react-guipaul/cliat5bda66390with edge-currency-accountbasedpaul/cliatda1b8fe8packed in (https://balls.edge.app/20261010174309-edge-currency-accountbased-4.102.0.tgz).-tpoints the client and engine at the tester servers: login-tester, info-tester, sync-tester.EDGE_CLI_PASSWORDand appear as<redacted>. PINs never appear, and live session ids are shortened tosess_….clitester(funded) andsharetest(the receiver), each with its own data directory.engine-configlogin-with-password(both accounts, password fromEDGE_CLI_PASSWORD)get-addressesltc1qmcs05z9stqwxlm48f3ja66qnu9w9edpvc6c456; clitester LTCltc1qfkvdvrcr8g82mr72q7u67p8yzpg7yl3n3nm9nw, BTCbc1q5kjamtm0424shsjp4dng3z85xxrqhgdzm7j7crencode-urilitecoin:ltc1qmcs…?amount=0.005&label=sharetestspend --dry-runspendclitester → sharetest, 0.005 LTC9ca11b2d32db160c39434b4b989ef737a08debc346411d792097ff5c21cd7c83; seen by sharetestspendsharetest → clitester, 0.002 LTCb9acbae3efc486c7e0bc423a64b0ca6131d7049b06ae4a64c2e09fd8a01a98f2fetch-swap-quotesandapprove-swap-quote, 0.05 LTC → BTC (THORChain)0f0a34872761fb57db762bb82660ea5142fc6fd894324ca1238a217f91236262; 2738 sats paid out, txid9ef7af518502d91bfd6513c624c08ca7ccf94122d3c6bd0d3e4a101c02d40d88approve-swap-quoteafter the exchange's 60 s410 QUOTE_EXPIRED, exit 4, nothing signedlogoutBalances: clitester LTC 2 went from 0.45255611 to 0.39912806, its BTC from 0.00109035 to 0.00111773, and sharetest LTC from 0 to 0.00264937.
The session: 31 commands and their output
2026-10-10 17:50:15 (clitester)
exit 0, 4.1s
2026-10-10 17:50:36 (clitester)
exit 0, 16.9s
2026-10-10 17:50:36 (clitester)
exit 0, 0.3s
2026-10-10 17:50:42 (clitester)
exit 0, 0.3s
2026-10-10 17:50:47 (clitester)
exit 0, 0.2s
2026-10-10 17:50:47 (clitester)
exit 0, 0.1s
2026-10-10 17:50:48 (clitester)
exit 0, 0.1s
2026-10-10 17:51:06 (sharetest)
exit 0, 7.9s
2026-10-10 17:51:06 (sharetest)
exit 0, 0.1s
2026-10-10 17:51:06 (sharetest)
exit 0, 0.1s
2026-10-10 17:51:06 (sharetest)
exit 0, 0.1s
2026-10-10 17:51:13 (clitester)
exit 0, 0.1s
2026-10-10 17:51:13 (clitester)
exit 0, 0.1s
2026-10-10 17:51:13 (clitester)
exit 0, 0.2s
2026-10-10 17:51:18 (clitester)
exit 0, 0.1s
2026-10-10 17:51:24 (clitester)
exit 0, 0.5s
2026-10-10 17:51:37 (clitester)
exit 0, 1.3s
2026-10-10 17:51:59 (sharetest)
exit 0, 1.6s
2026-10-10 17:51:59 (sharetest)
exit 0, 0.1s
2026-10-10 17:52:07 (sharetest)
exit 0, 0.2s
2026-10-10 17:52:19 (clitester)
exit 0, 5.3s
2026-10-10 17:52:28 (clitester)
exit 0, 0.2s
2026-10-10 17:52:40 (clitester)
exit 0, 3.1s
2026-10-10 17:53:43 (clitester)
exit 4, 0.1s
2026-10-10 17:53:50 (clitester)
exit 0, 0.2s
2026-10-10 17:57:25 (clitester)
exit 0, 1.5s
2026-10-10 17:57:25 (clitester)
exit 0, 0.1s
2026-10-10 17:57:25 (clitester)
exit 0, 0.1s
2026-10-10 17:57:25 (sharetest)
exit 0, 0.1s
2026-10-10 17:57:26 (sharetest)
exit 0, 0.1s
2026-10-10 17:57:26 (clitester)
exit 0, 0.1s