botanary-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -0
- package/dist/bin/botanary-mcp.js +21 -0
- package/dist/bin/botanary-mcp.js.map +1 -0
- package/dist/src/api-client.js +42 -0
- package/dist/src/api-client.js.map +1 -0
- package/dist/src/cli.js +79 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/identity/backends/exec.js +34 -0
- package/dist/src/identity/backends/exec.js.map +1 -0
- package/dist/src/identity/backends/file-fallback.js +58 -0
- package/dist/src/identity/backends/file-fallback.js.map +1 -0
- package/dist/src/identity/backends/libsecret.js +57 -0
- package/dist/src/identity/backends/libsecret.js.map +1 -0
- package/dist/src/identity/backends/macos-keychain.js +63 -0
- package/dist/src/identity/backends/macos-keychain.js.map +1 -0
- package/dist/src/identity/backends/select.js +34 -0
- package/dist/src/identity/backends/select.js.map +1 -0
- package/dist/src/identity/backends/types.js +16 -0
- package/dist/src/identity/backends/types.js.map +1 -0
- package/dist/src/identity/backends/windows-dpapi.js +89 -0
- package/dist/src/identity/backends/windows-dpapi.js.map +1 -0
- package/dist/src/identity/fingerprint.js +47 -0
- package/dist/src/identity/fingerprint.js.map +1 -0
- package/dist/src/identity/keypair.js +40 -0
- package/dist/src/identity/keypair.js.map +1 -0
- package/dist/src/identity/pairing-code.js +60 -0
- package/dist/src/identity/pairing-code.js.map +1 -0
- package/dist/src/identity/redact.js +21 -0
- package/dist/src/identity/redact.js.map +1 -0
- package/dist/src/identity/store.js +249 -0
- package/dist/src/identity/store.js.map +1 -0
- package/dist/src/identity/types.js +2 -0
- package/dist/src/identity/types.js.map +1 -0
- package/dist/src/paths.js +22 -0
- package/dist/src/paths.js.map +1 -0
- package/dist/src/runtime.js +195 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/server.js +36 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/tools.js +320 -0
- package/dist/src/tools.js.map +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# botanary-mcp
|
|
2
|
+
|
|
3
|
+
Botanary's **agent connector**: a local MCP server that lets an outside coding agent - Claude Code,
|
|
4
|
+
Codex, Cursor, or something you wrote yourself - hold its own signing key, prove who it is, pair with a
|
|
5
|
+
Botanary account, read its balance, and spend under whatever grant its owner gave it - all without
|
|
6
|
+
Botanary ever holding, seeing, or backing up that key.
|
|
7
|
+
|
|
8
|
+
The tools this server exposes:
|
|
9
|
+
|
|
10
|
+
| Tool | What it does |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `get_identity` | Reports this agent's address/public key/fingerprint. Generates the key on first call. No network call. |
|
|
13
|
+
| `get_pairing_code` / `regenerate_pairing_code` | Shows (or rotates) the short code the owner types into "Connected agents". No network call. |
|
|
14
|
+
| `pair` | Signs the pairing code and submits the proof to `POST /agents/pair`. |
|
|
15
|
+
| `whoami` | Calls `GET /agents/me` - this agent's own identity, account, and live grant (or an honest `grant: null`). |
|
|
16
|
+
| `get_balance` | Calls `GET /balance` under this agent's session. |
|
|
17
|
+
| `propose_payment` | Reads the grant's bounds first; refuses honestly (citing the backend's own numbers) when there is none or the amount is out of bounds; otherwise builds against THIS agent's own grant id (`POST /delegations/{delegationId}/actions` - the session-key-validatable lane, never the owner-lane `/money/send/build`) and relays (`POST /userops`) signed with the grant's own session key. |
|
|
18
|
+
| `request_approval` | Raises a request for an action outside the grant (`POST /agents/requests`) - the "ask" half of Flow 7d. |
|
|
19
|
+
| `what_may_i_do` | Renders the grant in plain terms, or an honest "nothing yet" - a convenience read, never the check. |
|
|
20
|
+
|
|
21
|
+
`forget` (delete the key from this machine) is a **CLI command only**, not a tool - see "Why `forget` has
|
|
22
|
+
no MCP tool" below.
|
|
23
|
+
|
|
24
|
+
## Add it to your agent
|
|
25
|
+
|
|
26
|
+
One-time setup (from this directory):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm install # also builds dist/ automatically (see "prepare" in package.json)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then, the one command per client:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Claude Code
|
|
36
|
+
claude mcp add --transport stdio botanary -- node "$(pwd)/dist/bin/botanary-mcp.js"
|
|
37
|
+
|
|
38
|
+
# Codex
|
|
39
|
+
codex mcp add botanary -- node "$(pwd)/dist/bin/botanary-mcp.js"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The server talks to `https://api.app.botanary.xyz` by default. Point it somewhere else with
|
|
43
|
+
`BOTANARY_API_URL`:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
claude mcp add --transport stdio botanary \
|
|
47
|
+
-e BOTANARY_API_URL=http://localhost:3000 \
|
|
48
|
+
-- node "$(pwd)/dist/bin/botanary-mcp.js"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Cursor has no CLI equivalent - add this block to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json`
|
|
52
|
+
(global) instead:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"botanary": {
|
|
58
|
+
"command": "node",
|
|
59
|
+
"args": ["/absolute/path/to/tools/botanary-mcp/dist/bin/botanary-mcp.js"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Restart the client so it picks up the new server. Once connected, ask the agent for its Botanary
|
|
66
|
+
identity or pairing code - it will call `get_identity` / `get_pairing_code` and read the key
|
|
67
|
+
straight out of wherever this machine's OS keeps secrets. The first call generates the key; nothing
|
|
68
|
+
before that point touches disk or asks any question.
|
|
69
|
+
|
|
70
|
+
## What a freshly-connected agent can and cannot do
|
|
71
|
+
|
|
72
|
+
This is the product's own promise (Flow 7c/7d), and every tool in this package exists to make it true:
|
|
73
|
+
|
|
74
|
+
- **Connecting is harmless by itself.** A newly connected, ungranted agent can read balances
|
|
75
|
+
(`get_balance`) and read its own bounds (`whoami`/`what_may_i_do`) - `propose_payment` refuses
|
|
76
|
+
honestly, citing the backend's own "you have no grant" answer, rather than pretending. It cannot move
|
|
77
|
+
a cent.
|
|
78
|
+
- **What it's granted, it can spend - unattended, signed with its own key.** `propose_payment` builds
|
|
79
|
+
and relays a payment under the grant when the amount is within its bounds. Nothing outside that grant
|
|
80
|
+
works; `request_approval` turns it into a request the owner approves as themselves, or it doesn't
|
|
81
|
+
happen.
|
|
82
|
+
- **What it can never do, no matter what it was granted:** sign in as the owner, add or remove a
|
|
83
|
+
signer, write or change a policy, grant access to another agent, or unfreeze the account. A grant is
|
|
84
|
+
permission to spend inside a fence, never permission to move the fence. None of these tools exist in
|
|
85
|
+
this package - there is nothing here that could call them even if a prompt tried to talk it into it.
|
|
86
|
+
- **The bound it reads is a convenience, not the check.** `whoami`/`what_may_i_do`/`propose_payment`'s
|
|
87
|
+
own pre-check all read the grant's bounds from the backend, but the actual enforcement happens
|
|
88
|
+
on-chain. A bug or a lie in what any of them reports changes nothing about what this agent can
|
|
89
|
+
actually get away with.
|
|
90
|
+
|
|
91
|
+
## Why intercepting the pairing code alone grants nothing
|
|
92
|
+
|
|
93
|
+
The short code this server shows (`get_pairing_code`) is built from two parts, and **neither one is a
|
|
94
|
+
secret**:
|
|
95
|
+
|
|
96
|
+
1. A **fingerprint** - a few characters derived from the agent's *public* key. It exists so a human can
|
|
97
|
+
visually confirm "yes, this is the code my agent just showed me," the same role an SSH host-key
|
|
98
|
+
fingerprint plays. It is only ever a function of information that's already public (the address
|
|
99
|
+
itself is shared openly by `get_identity`), so there is nothing about it worth hiding to begin with.
|
|
100
|
+
2. A **nonce** - fresh randomness, generated new each time a pairing attempt starts, with a short
|
|
101
|
+
(10-minute default) expiry. This is what makes the code single-use and short-lived: once it expires,
|
|
102
|
+
or once a fresh one is requested, the old code stops being the one anything checks against.
|
|
103
|
+
|
|
104
|
+
Neither piece is derived *from* the private key, and there's no computation that gets you from
|
|
105
|
+
"fingerprint + nonce" back to the 32 bytes that would let something sign as this agent. The code isn't
|
|
106
|
+
an obfuscated secret - it was never built out of one.
|
|
107
|
+
|
|
108
|
+
That's what makes the rest of the claim hold: **the code identifies, the signature authenticates.**
|
|
109
|
+
Completing a connection is designed to require the agent to sign a server-issued challenge with the key
|
|
110
|
+
that never left this machine - proof of possession, not proof of having seen a string. Someone who only
|
|
111
|
+
intercepted the code cannot produce that signature, because they don't have the key. The worst they can
|
|
112
|
+
do is type the code into their *own* Botanary account's "Connected agents" box - which, per the product
|
|
113
|
+
rule above, gets *them* nothing: a connection with no grant can do nothing, and it takes nothing away
|
|
114
|
+
from the real owner, whose agent still holds the only key that can ever complete a real pairing for that
|
|
115
|
+
identity. There is no password, token, or secret anywhere in this exchange for anyone to steal.
|
|
116
|
+
|
|
117
|
+
(The `pair` tool is what actually submits the signed challenge - `POST /agents/pair` - to complete this
|
|
118
|
+
half of a pairing. The cryptographic property above does not depend on that call existing: the code is
|
|
119
|
+
derived only from public information, so intercepting it is intercepting nothing, whether or not
|
|
120
|
+
anything has submitted a proof yet.)
|
|
121
|
+
|
|
122
|
+
## The key: generated here, stored here, never sent anywhere
|
|
123
|
+
|
|
124
|
+
**This is a property to preserve, not an implementation detail.** There is no code path in this package
|
|
125
|
+
that sends the private key anywhere - no telemetry, no crash reporting, no "just in case" backup, no
|
|
126
|
+
cloud sync. `test/no-leak.spec.ts` exists specifically to keep that true: it drives every tool, the real
|
|
127
|
+
MCP protocol, the CLI, and a deliberately misbehaving backend that tries to leak the key through an
|
|
128
|
+
error message, then greps everything that came out for it. If you're extending this package, that test
|
|
129
|
+
is the one to think hardest about before touching.
|
|
130
|
+
|
|
131
|
+
The key is generated locally on first use (secp256k1, the same curve/derivation as every other signer in
|
|
132
|
+
this system - see `src/identity/keypair.ts`) and handed to the best available OS secret store:
|
|
133
|
+
|
|
134
|
+
| Platform | Backend | Mechanism |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| macOS | Keychain | the `security` CLI (`add-generic-password` / `find-generic-password` / `delete-generic-password`) |
|
|
137
|
+
| Linux | Secret Service (GNOME Keyring, KWallet's libsecret shim, ...) | the `secret-tool` CLI, secret piped over **stdin** |
|
|
138
|
+
| Windows | DPAPI, current-user scope | a short PowerShell call to `ProtectedData.Protect`/`Unprotect`, secret piped over **stdin**; only the ciphertext touches disk |
|
|
139
|
+
| any platform, if none of the above is available | a local file, mode `0600`, parent dir `0700` | plain file, last resort only |
|
|
140
|
+
|
|
141
|
+
No native addon is linked for any of this - every backend shells out to a platform CLI that's already
|
|
142
|
+
there, which is also why `npm install` never needs a compiler. The trade-off is documented rather than
|
|
143
|
+
hidden: macOS's `security` CLI has no way to pass the password except as an argument, so it's briefly
|
|
144
|
+
visible in this process's own argv (to anything else on the same machine that can list processes) for
|
|
145
|
+
the moment that one command runs - `secret-tool` and the Windows path both avoid this by using stdin.
|
|
146
|
+
See the comments in `src/identity/backends/*.ts` for the full reasoning per backend.
|
|
147
|
+
|
|
148
|
+
Two more things worth knowing:
|
|
149
|
+
|
|
150
|
+
- **Exactly one copy, always.** A new key is written to the *first* available backend and stops there -
|
|
151
|
+
never a redundant "backup" copy in a second place. `src/identity/store.ts` and its tests
|
|
152
|
+
(`test/identity/store.spec.ts`) pin this down explicitly.
|
|
153
|
+
- **The key is read fresh for every signing operation and never cached in memory.** Only `store.sign()`
|
|
154
|
+
(EIP-191 personal-message signing) and `store.signHash()` (raw ECDSA over an exact 32-byte hash - what
|
|
155
|
+
`pair`/session-minting/`propose_payment`'s spend all actually use, since the backend recovers over the
|
|
156
|
+
raw hash with no prefix) ever touch it, and only for the duration of that one call. Identity lookups
|
|
157
|
+
(`get_identity`, `get_pairing_code`) never touch the keychain at all - they read a small, non-secret
|
|
158
|
+
metadata file (address / public key / fingerprint / when it was created / which backend holds the key)
|
|
159
|
+
that's written once alongside the real key and never contains it.
|
|
160
|
+
|
|
161
|
+
### Deleting the key on its own machine
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
node dist/bin/botanary-mcp.js forget
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
This deletes the private key from wherever it's stored (every backend candidate is swept, not just the
|
|
168
|
+
one currently in use, so this also cleans up after a platform change or a partial past failure) and
|
|
169
|
+
clears the local identity. **It does not touch any grant a Botanary account still has on file for that
|
|
170
|
+
address** - this machine never had the authority to revoke that, only the key. That's the whole point:
|
|
171
|
+
after this command, nothing on this machine can produce a signature for the old address, which is the
|
|
172
|
+
part anyone can verify for themselves without trusting Botanary's word for it. `test/identity/store.spec.ts`
|
|
173
|
+
and `test/no-leak.spec.ts` both exercise this directly: after `forget()`, signing fails, and the *next*
|
|
174
|
+
identity created is a different address - proof the old key is really gone, not just hidden.
|
|
175
|
+
|
|
176
|
+
#### Why `forget` has no MCP tool
|
|
177
|
+
|
|
178
|
+
Every other capability in this package is an MCP tool because an agent legitimately needs to invoke it
|
|
179
|
+
itself. Deleting its own key is different: an agent that can erase its own identity ON REQUEST is one an
|
|
180
|
+
injected instruction (a poisoned webpage, a malicious dependency's README, anything the agent reads as
|
|
181
|
+
part of its normal work) can silence - "forget your Botanary identity" is exactly the kind of one-line
|
|
182
|
+
instruction a prompt injection would try, and a tool that honored it would hand an attacker a free,
|
|
183
|
+
untraceable way to sever a working connection. There is no legitimate reason an AGENT (as opposed to the
|
|
184
|
+
human running it) would ever need to delete its own key mid-session, so `forget` stays a command the
|
|
185
|
+
human runs directly (`node dist/bin/botanary-mcp.js forget`), never something `tools.ts` exposes -
|
|
186
|
+
`test/tools.spec.ts` and `test/server.spec.ts` both assert no tool named `forget` exists.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
pnpm install # installs deps and builds dist/ (the "prepare" script)
|
|
192
|
+
pnpm build # rebuild dist/ by hand
|
|
193
|
+
pnpm dev # run bin/botanary-mcp.ts directly, watching for changes (no MCP client attached)
|
|
194
|
+
pnpm typecheck # tsc --noEmit, strict (matches the backend repo's compiler settings)
|
|
195
|
+
pnpm test # vitest - no network, no real keychain (every OS call is mocked; see
|
|
196
|
+
# test/fixtures/fake-exec.ts and test/fixtures/fake-backend.ts)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`BOTANARY_MCP_HOME` overrides where the non-secret metadata file (and the file-fallback secret, if it's
|
|
200
|
+
ever in use) live - defaults to `~/.botanary-mcp`. It exists mainly for tests and for running this
|
|
201
|
+
server inside a container with an isolated home; a normal install never needs to set it.
|
|
202
|
+
|
|
203
|
+
## What's deliberately not here
|
|
204
|
+
|
|
205
|
+
Everything the original design called out as "later work" is now built: the pairing HTTP exchange
|
|
206
|
+
(`pair`), reading balances (`get_balance`), building and relaying under a grant (`propose_payment`), and
|
|
207
|
+
the out-of-grant request lane (`request_approval`, Flow 7d step 5 - "ask, when it needs more").
|
|
208
|
+
|
|
209
|
+
What stays out on purpose:
|
|
210
|
+
|
|
211
|
+
- **`forget` as a tool** - see "Why `forget` has no MCP tool" above. It remains a human-run CLI command.
|
|
212
|
+
- **Owner-lane authority.** Nothing in this package can add or remove a signer, write or change a
|
|
213
|
+
policy, grant another agent access, or unfreeze the account - not because those endpoints are hard to
|
|
214
|
+
call, but because an agent's grant is authority to spend inside a fence, never authority to move the
|
|
215
|
+
fence. There is no tool here that could be talked into trying.
|
|
216
|
+
- **A cached or session-spanning bounds check as the real gate.** `propose_payment`'s pre-check (and
|
|
217
|
+
`what_may_i_do`'s rendering) reads `GET /agents/me` fresh via `AgentRuntime.me()` and refuses using
|
|
218
|
+
whatever the backend reports at that moment - it never assumes a bound it read earlier still holds.
|
|
219
|
+
The chain is still the only real enforcement either way (see "The bound it reads is a convenience, not
|
|
220
|
+
the check" above).
|
|
221
|
+
|
|
222
|
+
`src/runtime.ts`'s `AgentRuntime` is the seam every tool in `src/tools.ts` is built on: it owns identity,
|
|
223
|
+
signing (`store.sign()`/`store.signHash()`), a cached-with-retry agent session, and one method per
|
|
224
|
+
backend call a tool needs (`me`, `getBalance`, `buildDelegatedAction`, `raiseRequest`, `spendUnderGrant`) -
|
|
225
|
+
a future tool extends this class rather than reaching for `fetch` directly.
|
|
226
|
+
|
|
227
|
+
`buildDelegatedAction` (`POST /delegations/{delegationId}/actions`), not `/money/send/build`, is the
|
|
228
|
+
build call `propose_payment` uses, and deliberately so: `/money/send/build` builds an owner-lane op in
|
|
229
|
+
the Kernel account's ROOT nonce lane, which a session-key signature can never validate (it routes
|
|
230
|
+
validation to the root ECDSA validator, which cannot parse the USE-mode `(bytes1, bytes32, bytes)`
|
|
231
|
+
envelope `spendUnderGrant` wraps every signature in as anything resembling a valid 65-byte ECDSA
|
|
232
|
+
signature). `/delegations/{delegationId}/actions` builds in the delegation's own Smart Sessions nonce
|
|
233
|
+
lane with fixed native gas instead - the one lane this package's session key can actually sign for. The
|
|
234
|
+
backend additionally scopes this route to the caller's OWN grant: an agent session naming a *different*
|
|
235
|
+
agent's delegation id is refused (403, named reason) before any build happens at all.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { runCli } from '../src/cli.js';
|
|
4
|
+
import { createDefaultRuntime } from '../src/runtime.js';
|
|
5
|
+
import { createServer } from '../src/server.js';
|
|
6
|
+
// Deliberately the only file in this package that is not unit tested directly - it does nothing but
|
|
7
|
+
// wire the real runtime/server/transport into runCli (src/cli.ts), which is where all the actual
|
|
8
|
+
// logic and tests live. Trust this file by reading it, the same way tools/agent-harness/mcp/server.mjs
|
|
9
|
+
// keeps its own main() to a couple of lines around attachShutdown.
|
|
10
|
+
const exitCode = await runCli(process.argv.slice(2), {
|
|
11
|
+
runtime: createDefaultRuntime(),
|
|
12
|
+
createServer,
|
|
13
|
+
createTransport: () => new StdioServerTransport(),
|
|
14
|
+
});
|
|
15
|
+
// The `serve` (no-args) path resolves once the stdio transport is connected, then the process stays
|
|
16
|
+
// alive on its own (stdin is being read) until the client closes the pipe or the process is signaled -
|
|
17
|
+
// there is nothing to await further and nothing here should force an exit while a session is live.
|
|
18
|
+
if (process.argv.slice(2).length > 0) {
|
|
19
|
+
process.exit(exitCode);
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=botanary-mcp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"botanary-mcp.js","sourceRoot":"","sources":["../../bin/botanary-mcp.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD,oGAAoG;AACpG,iGAAiG;AACjG,uGAAuG;AACvG,mEAAmE;AACnE,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;IACnD,OAAO,EAAE,oBAAoB,EAAE;IAC/B,YAAY;IACZ,eAAe,EAAE,GAAG,EAAE,CAAC,IAAI,oBAAoB,EAAE;CAClD,CAAC,CAAC;AAEH,oGAAoG;AACpG,uGAAuG;AACvG,mGAAmG;AACnG,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;IACrC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;AACzB,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Thrown for any non-2xx response, carrying the HTTP status alongside the SERVER's own message (never a
|
|
2
|
+
* generic one - whoever built this agent needs the reason, §6-40 one layer up from the chain). The
|
|
3
|
+
* status is what lets `AgentRuntime` tell "this session token is stale, re-mint and retry" (401) apart
|
|
4
|
+
* from every other failure, which it should surface as-is. */
|
|
5
|
+
export class BotanaryApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(message, status) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.name = 'BotanaryApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** JSON in and out, throwing the SERVER's own message rather than a generic one - whoever built this
|
|
14
|
+
* agent needs the reason, not "request failed" (§6-40, one layer up from the chain). */
|
|
15
|
+
export class BotanaryApiClient {
|
|
16
|
+
baseUrl;
|
|
17
|
+
fetchImpl;
|
|
18
|
+
constructor(baseUrl, fetchImpl = fetch) {
|
|
19
|
+
this.baseUrl = baseUrl;
|
|
20
|
+
this.fetchImpl = fetchImpl;
|
|
21
|
+
}
|
|
22
|
+
async post(path, body, token) {
|
|
23
|
+
return this.#request('POST', path, body, token);
|
|
24
|
+
}
|
|
25
|
+
/** GET has no body - a query string, when one is needed, belongs in `path` itself (callers below never
|
|
26
|
+
* put a token or anything secret there; see test/no-leak.spec.ts). */
|
|
27
|
+
async get(path, token) {
|
|
28
|
+
return this.#request('GET', path, undefined, token);
|
|
29
|
+
}
|
|
30
|
+
async #request(method, path, body, token) {
|
|
31
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
34
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
35
|
+
});
|
|
36
|
+
const json = (await res.json());
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new BotanaryApiError(json?.error?.message ?? `${path} failed with ${res.status}`, res.status);
|
|
39
|
+
return json;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=api-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../../src/api-client.ts"],"names":[],"mappings":"AAAA;;;+DAG+D;AAC/D,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAG9B;IAFX,YACE,OAAe,EACN,MAAc;QAEvB,KAAK,CAAC,OAAO,CAAC,CAAC;QAFN,WAAM,GAAN,MAAM,CAAQ;QAGvB,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;IACjC,CAAC;CACF;AAED;yFACyF;AACzF,MAAM,OAAO,iBAAiB;IAET;IACA;IAFnB,YACmB,OAAe,EACf,YAA0B,KAAK;QAD/B,YAAO,GAAP,OAAO,CAAQ;QACf,cAAS,GAAT,SAAS,CAAsB;IAC/C,CAAC;IAEJ,KAAK,CAAC,IAAI,CAAI,IAAY,EAAE,IAAa,EAAE,KAAc;QACvD,OAAO,IAAI,CAAC,QAAQ,CAAI,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;IACrD,CAAC;IAED;2EACuE;IACvE,KAAK,CAAC,GAAG,CAAI,IAAY,EAAE,KAAc;QACvC,OAAO,IAAI,CAAC,QAAQ,CAAI,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IACzD,CAAC;IAED,KAAK,CAAC,QAAQ,CAAI,MAAsB,EAAE,IAAY,EAAE,IAAa,EAAE,KAAc;QACnF,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,EAAE;YACzD,MAAM;YACN,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE;YACvG,GAAG,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC9D,CAAC,CAAC;QACH,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAqC,CAAC;QACpE,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,MAAM,IAAI,gBAAgB,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,IAAI,GAAG,IAAI,gBAAgB,GAAG,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;QACjH,OAAO,IAAS,CAAC;IACnB,CAAC;CACF"}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const HELP_TEXT = `botanary-mcp - Botanary's agent connector (local MCP server)
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
botanary-mcp Start the MCP server (stdio transport). This is what an MCP client
|
|
5
|
+
(Claude Code, Codex, Cursor, ...) runs - see the README for the exact
|
|
6
|
+
one-line command per client.
|
|
7
|
+
botanary-mcp whoami Print this agent's identity (address, public key, fingerprint) without
|
|
8
|
+
starting a server. Never prints the private key.
|
|
9
|
+
botanary-mcp forget Delete the agent's private key from this machine (OS keychain or file
|
|
10
|
+
fallback). Any grant a Botanary account still has on file for this
|
|
11
|
+
agent's address is UNCHANGED - revoke that separately, in Botanary. See
|
|
12
|
+
the README ("what deleting the key does and doesn't do").
|
|
13
|
+
botanary-mcp --help Show this message.
|
|
14
|
+
`;
|
|
15
|
+
/** Runs one CLI invocation and returns the process exit code. Kept free of `process.exit`/signal
|
|
16
|
+
* handling so it's directly testable with fake deps - see test/cli.spec.ts. bin/botanary-mcp.ts is
|
|
17
|
+
* the (deliberately tiny, untested-by-inspection) shim that wires real deps and process lifecycle. */
|
|
18
|
+
export async function runCli(argv, deps) {
|
|
19
|
+
const stdout = deps.stdout ?? ((line) => process.stdout.write(`${line}\n`));
|
|
20
|
+
const stderr = deps.stderr ?? ((line) => process.stderr.write(`${line}\n`));
|
|
21
|
+
const [command] = argv;
|
|
22
|
+
try {
|
|
23
|
+
switch (command) {
|
|
24
|
+
case undefined:
|
|
25
|
+
return await serve(deps, stderr);
|
|
26
|
+
case 'whoami':
|
|
27
|
+
return await whoami(deps, stdout);
|
|
28
|
+
case 'forget':
|
|
29
|
+
return await forget(deps, stdout);
|
|
30
|
+
case '--help':
|
|
31
|
+
case '-h':
|
|
32
|
+
case 'help':
|
|
33
|
+
stdout(HELP_TEXT);
|
|
34
|
+
return 0;
|
|
35
|
+
default:
|
|
36
|
+
stderr(`Unknown command "${command}". Run "botanary-mcp --help" for usage.`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
stderr(e instanceof Error ? e.message : String(e));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function serve(deps, stderr) {
|
|
46
|
+
// The server must not die mid-session over a stray async error - mirrors
|
|
47
|
+
// tools/agent-harness/mcp/server.mjs's own top-level handlers, for the same reason: the failing
|
|
48
|
+
// call already gets an error result through the normal tool-call try/catch (see server.ts); this is
|
|
49
|
+
// only a last-resort net so an unrelated stray rejection can't take the whole stdio session down.
|
|
50
|
+
process.on('unhandledRejection', (e) => stderr(`unhandledRejection: ${e instanceof Error ? e.message : String(e)}`));
|
|
51
|
+
process.on('uncaughtException', (e) => stderr(`uncaughtException: ${e.message}`));
|
|
52
|
+
const server = deps.createServer(deps.runtime);
|
|
53
|
+
await server.connect(deps.createTransport());
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
async function whoami(deps, stdout) {
|
|
57
|
+
const identity = await deps.runtime.identity();
|
|
58
|
+
stdout(JSON.stringify({
|
|
59
|
+
address: identity.address,
|
|
60
|
+
publicKey: identity.publicKey,
|
|
61
|
+
fingerprint: identity.fingerprint,
|
|
62
|
+
createdAt: identity.createdAt,
|
|
63
|
+
keyStorage: identity.backend,
|
|
64
|
+
}, null, 2));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
async function forget(deps, stdout) {
|
|
68
|
+
const hadIdentity = await deps.runtime.store.hasIdentity();
|
|
69
|
+
await deps.runtime.store.forget();
|
|
70
|
+
deps.runtime.reset();
|
|
71
|
+
stdout(hadIdentity
|
|
72
|
+
? 'Deleted the agent key from this machine. It can no longer sign anything - including for any ' +
|
|
73
|
+
'grant a Botanary account still has on file for its old address. That grant itself still ' +
|
|
74
|
+
'exists until its owner revokes it in Botanary; this command only removes the key, which is ' +
|
|
75
|
+
'the only thing this machine ever held.'
|
|
76
|
+
: 'No agent identity was stored on this machine - nothing to delete.');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/cli.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A tiny, injectable process-runner. Every real key-storage backend (macOS Keychain, libsecret,
|
|
2
|
+
// Windows DPAPI) shells out to a platform CLI rather than linking a native addon - see the package
|
|
3
|
+
// README for why. Routing every backend through this one seam is what makes the backend tests real
|
|
4
|
+
// unit tests: they inject a fake `Exec` and never touch an actual keychain, `security`, `secret-tool`,
|
|
5
|
+
// or `powershell` process (the harness this package's tests must run without, per the task brief).
|
|
6
|
+
/** The real implementation: spawn a child process (no shell - argv is passed directly, so there is no
|
|
7
|
+
* shell-quoting/injection surface), capture stdout/stderr, resolve with the exit code. `spawn` (not
|
|
8
|
+
* `execFile`) because we need to write to stdin ourselves and close it explicitly. */
|
|
9
|
+
export const nodeExec = async (command, args, options) => {
|
|
10
|
+
const { spawn } = await import('node:child_process');
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
child.stdout.on('data', (chunk) => {
|
|
16
|
+
stdout += chunk.toString('utf8');
|
|
17
|
+
});
|
|
18
|
+
child.stderr.on('data', (chunk) => {
|
|
19
|
+
stderr += chunk.toString('utf8');
|
|
20
|
+
});
|
|
21
|
+
child.on('error', (err) => {
|
|
22
|
+
// ENOENT (binary not installed) and similar - the caller treats this as "backend unavailable".
|
|
23
|
+
reject(err);
|
|
24
|
+
});
|
|
25
|
+
child.on('close', (code) => {
|
|
26
|
+
resolve({ stdout, stderr, code: code ?? -1 });
|
|
27
|
+
});
|
|
28
|
+
if (options?.input !== undefined) {
|
|
29
|
+
child.stdin.write(options.input, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
child.stdin.end();
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=exec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exec.js","sourceRoot":"","sources":["../../../../src/identity/backends/exec.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,mGAAmG;AACnG,mGAAmG;AACnG,uGAAuG;AACvG,mGAAmG;AAkBnG;;uFAEuF;AACvF,MAAM,CAAC,MAAM,QAAQ,GAAS,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;IAC7D,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,oBAAoB,CAAC,CAAC;IACrD,OAAO,IAAI,OAAO,CAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACjD,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QACxE,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACxC,MAAM,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACxC,MAAM,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YACxB,+FAA+F;YAC/F,MAAM,CAAC,GAAG,CAAC,CAAC;QACd,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACzB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;QAChD,CAAC,CAAC,CAAC;QACH,IAAI,OAAO,EAAE,KAAK,KAAK,SAAS,EAAE,CAAC;YACjC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAC3C,CAAC;QACD,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;IACpB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { KeyBackendError } from './types.js';
|
|
4
|
+
import { fileFallbackSecretPath } from '../../paths.js';
|
|
5
|
+
// The last-resort backend, used only when no platform keychain is available (Linux with no Secret
|
|
6
|
+
// Service running, a locked-down/headless box, or an unsupported platform). A plain file, permissions
|
|
7
|
+
// locked down as tightly as this process can manage:
|
|
8
|
+
// - parent dir created 0700 (owner rwx only) and the mode reasserted after mkdir in case an umask
|
|
9
|
+
// loosened it, or the directory already existed with wider permissions from a previous run;
|
|
10
|
+
// - the file itself written 0600 (owner rw only) and reasserted after write for the same reason -
|
|
11
|
+
// `writeFile`'s `mode` option is only honored when the file doesn't already exist yet, so an
|
|
12
|
+
// overwrite of an existing file (key rotation) would silently keep whatever mode it already had
|
|
13
|
+
// without the explicit chmod below.
|
|
14
|
+
// This is real protection against another local user reading the file, but it is not what "the key
|
|
15
|
+
// never leaves the machine" is about - a keychain backend is preferred on every platform this package
|
|
16
|
+
// knows how to detect one on. See README for when this path is actually taken.
|
|
17
|
+
export function createFileFallbackBackend(filePath = fileFallbackSecretPath()) {
|
|
18
|
+
return {
|
|
19
|
+
id: 'file-fallback',
|
|
20
|
+
label: `Local file (${filePath}, mode 0600)`,
|
|
21
|
+
async isAvailable() {
|
|
22
|
+
// Always available by design - it's the backend nothing else falls back to.
|
|
23
|
+
return true;
|
|
24
|
+
},
|
|
25
|
+
async save(secret) {
|
|
26
|
+
const dir = dirname(filePath);
|
|
27
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
await chmod(dir, 0o700).catch(() => {
|
|
29
|
+
// Best-effort: some platforms (notably Windows) don't honor POSIX mode bits via chmod at all.
|
|
30
|
+
// DPAPI, not file permissions, is the real protection there (windows-dpapi.ts) - this fallback
|
|
31
|
+
// is a last resort on Windows too, but only reached if even PowerShell is unavailable.
|
|
32
|
+
});
|
|
33
|
+
await writeFile(filePath, secret, { encoding: 'utf8', mode: 0o600 });
|
|
34
|
+
await chmod(filePath, 0o600).catch(() => { });
|
|
35
|
+
},
|
|
36
|
+
async load() {
|
|
37
|
+
try {
|
|
38
|
+
const content = await readFile(filePath, 'utf8');
|
|
39
|
+
return content.length > 0 ? content : null;
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (e.code === 'ENOENT')
|
|
43
|
+
return null;
|
|
44
|
+
throw new KeyBackendError('file-fallback', 'load', e.message);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
async delete() {
|
|
48
|
+
await rm(filePath, { force: true });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Exposed for tests: true only if the file exists and (on POSIX) is exactly owner-rw. Windows mode
|
|
53
|
+
* bits are not meaningful via fs.stat, so callers should skip the assertion there. */
|
|
54
|
+
export async function isPrivateFileMode(filePath) {
|
|
55
|
+
const s = await stat(filePath);
|
|
56
|
+
return (s.mode & 0o777) === 0o600;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=file-fallback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-fallback.js","sourceRoot":"","sources":["../../../../src/identity/backends/file-fallback.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC/E,OAAO,EAAmB,eAAe,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAExD,kGAAkG;AAClG,sGAAsG;AACtG,qDAAqD;AACrD,oGAAoG;AACpG,gGAAgG;AAChG,oGAAoG;AACpG,iGAAiG;AACjG,oGAAoG;AACpG,wCAAwC;AACxC,mGAAmG;AACnG,sGAAsG;AACtG,+EAA+E;AAC/E,MAAM,UAAU,yBAAyB,CAAC,WAAmB,sBAAsB,EAAE;IACnF,OAAO;QACL,EAAE,EAAE,eAAe;QACnB,KAAK,EAAE,eAAe,QAAQ,cAAc;QAE5C,KAAK,CAAC,WAAW;YACf,4EAA4E;YAC5E,OAAO,IAAI,CAAC;QACd,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,MAAc;YACvB,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;YAC9B,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACnD,MAAM,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE;gBACjC,8FAA8F;gBAC9F,+FAA+F;gBAC/F,uFAAuF;YACzF,CAAC,CAAC,CAAC;YACH,MAAM,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACrE,MAAM,KAAK,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;QAC/C,CAAC;QAED,KAAK,CAAC,IAAI;YACR,IAAI,CAAC;gBACH,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;gBACjD,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;YAC7C,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,IAAK,CAA2B,CAAC,IAAI,KAAK,QAAQ;oBAAE,OAAO,IAAI,CAAC;gBAChE,MAAM,IAAI,eAAe,CAAC,eAAe,EAAE,MAAM,EAAG,CAAW,CAAC,OAAO,CAAC,CAAC;YAC3E,CAAC;QACH,CAAC;QAED,KAAK,CAAC,MAAM;YACV,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACtC,CAAC;KACF,CAAC;AACJ,CAAC;AAED;uFACuF;AACvF,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,QAAgB;IACtD,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC/B,OAAO,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,KAAK,KAAK,CAAC;AACpC,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { nodeExec } from './exec.js';
|
|
2
|
+
import { ACCOUNT_NAME, KeyBackendError, SERVICE_NAME } from './types.js';
|
|
3
|
+
import { redactSecret } from '../redact.js';
|
|
4
|
+
// Linux Secret Service (GNOME Keyring, KWallet's libsecret shim, ...) via the `secret-tool` CLI
|
|
5
|
+
// (package `libsecret-tools` / `libsecret` depending on distro). Unlike macOS's `security`,
|
|
6
|
+
// `secret-tool store` reads the secret from STDIN, so it never touches argv or `ps` output.
|
|
7
|
+
//
|
|
8
|
+
// `secret-tool` needs a running Secret Service (a D-Bus session + an unlocked keyring). Headless boxes,
|
|
9
|
+
// bare containers and plenty of real dev machines don't have one - isAvailable() below treats that as
|
|
10
|
+
// "unavailable" so store.ts falls through to the file fallback, rather than failing outright.
|
|
11
|
+
const NOT_FOUND_CODES = new Set([1]); // secret-tool lookup exits 1 (empty stdout) when there's no match.
|
|
12
|
+
export function createLibsecretBackend(exec = nodeExec) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'libsecret',
|
|
15
|
+
label: 'Linux Secret Service (libsecret)',
|
|
16
|
+
async isAvailable() {
|
|
17
|
+
if (process.platform !== 'linux')
|
|
18
|
+
return false;
|
|
19
|
+
try {
|
|
20
|
+
// A lookup for a name that (almost certainly) doesn't exist. We only care that the process
|
|
21
|
+
// spawned and a Secret Service answered - "no such item" (exit 1, empty stdout) still proves
|
|
22
|
+
// the backend is usable; a spawn failure (ENOENT: no secret-tool installed) throws instead.
|
|
23
|
+
const r = await exec('secret-tool', ['lookup', 'service', '__botanary-mcp-availability-probe__']);
|
|
24
|
+
return r.code === 0 || NOT_FOUND_CODES.has(r.code);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async save(secret) {
|
|
31
|
+
const r = await exec('secret-tool', ['store', '--label=Botanary agent identity', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME], { input: secret });
|
|
32
|
+
if (r.code !== 0) {
|
|
33
|
+
// secret-tool doesn't echo stdin back on error, but redact defensively anyway - this backend
|
|
34
|
+
// should never be the one place that skips it.
|
|
35
|
+
throw new KeyBackendError('libsecret', 'save', redactSecret(r.stderr || `exit ${r.code}`, secret));
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
async load() {
|
|
39
|
+
const r = await exec('secret-tool', ['lookup', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME]);
|
|
40
|
+
if (r.code !== 0) {
|
|
41
|
+
if (NOT_FOUND_CODES.has(r.code) && !r.stderr.trim())
|
|
42
|
+
return null;
|
|
43
|
+
throw new KeyBackendError('libsecret', 'load', r.stderr || `exit ${r.code}`);
|
|
44
|
+
}
|
|
45
|
+
const value = r.stdout.replace(/\n$/, '');
|
|
46
|
+
return value.length > 0 ? value : null;
|
|
47
|
+
},
|
|
48
|
+
async delete() {
|
|
49
|
+
const r = await exec('secret-tool', ['clear', 'service', SERVICE_NAME, 'account', ACCOUNT_NAME]);
|
|
50
|
+
// secret-tool clear exits 0 whether or not anything was there - nothing to tolerate specially.
|
|
51
|
+
if (r.code !== 0) {
|
|
52
|
+
throw new KeyBackendError('libsecret', 'delete', r.stderr || `exit ${r.code}`);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=libsecret.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"libsecret.js","sourceRoot":"","sources":["../../../../src/identity/backends/libsecret.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { nodeExec } from './exec.js';
|
|
2
|
+
import { ACCOUNT_NAME, KeyBackendError, SERVICE_NAME } from './types.js';
|
|
3
|
+
import { redactSecret } from '../redact.js';
|
|
4
|
+
// macOS Keychain via the `security` CLI - no native addon (see README: this package is a plain `npm
|
|
5
|
+
// install`, and a native keychain binding would need a compiled addon per Node ABI/arch, which is
|
|
6
|
+
// exactly the kind of install friction that keeps an agent from getting connected).
|
|
7
|
+
//
|
|
8
|
+
// KNOWN LIMITATION, stated plainly rather than glossed over: `security add-generic-password` has no
|
|
9
|
+
// way to read the password from stdin - it only accepts it via the `-w` argument. That means the
|
|
10
|
+
// secret is briefly present in this process's argv, visible to anything else on the same machine that
|
|
11
|
+
// can list processes (e.g. another local user running `ps`) for the moment the command runs. This is
|
|
12
|
+
// a limitation of the macOS CLI, not of this package; the only way around it is a native Keychain
|
|
13
|
+
// binding, which we deliberately don't ship. `find-generic-password`/`delete-generic-password` take no
|
|
14
|
+
// secret argument, so they don't have this issue.
|
|
15
|
+
const NOT_FOUND_RE = /could not be found|item.*could not be found|specified item could not be found/i;
|
|
16
|
+
export function createMacosKeychainBackend(exec = nodeExec) {
|
|
17
|
+
return {
|
|
18
|
+
id: 'macos-keychain',
|
|
19
|
+
label: 'macOS Keychain',
|
|
20
|
+
async isAvailable() {
|
|
21
|
+
if (process.platform !== 'darwin')
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
// `security help` always exits non-zero (it's not a real subcommand) - a successful SPAWN
|
|
25
|
+
// (no ENOENT) is all we're checking: does this binary exist and run at all.
|
|
26
|
+
await exec('security', ['help']);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
async save(secret) {
|
|
34
|
+
// -U: update in place if an entry already exists, instead of erroring "already exists".
|
|
35
|
+
const r = await exec('security', [
|
|
36
|
+
'add-generic-password',
|
|
37
|
+
'-a', ACCOUNT_NAME,
|
|
38
|
+
'-s', SERVICE_NAME,
|
|
39
|
+
'-w', secret,
|
|
40
|
+
'-U',
|
|
41
|
+
]);
|
|
42
|
+
if (r.code !== 0) {
|
|
43
|
+
throw new KeyBackendError('macos-keychain', 'save', redactSecret(r.stderr || `exit ${r.code}`, secret));
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
async load() {
|
|
47
|
+
const r = await exec('security', ['find-generic-password', '-a', ACCOUNT_NAME, '-s', SERVICE_NAME, '-w']);
|
|
48
|
+
if (r.code !== 0) {
|
|
49
|
+
if (NOT_FOUND_RE.test(r.stderr))
|
|
50
|
+
return null;
|
|
51
|
+
throw new KeyBackendError('macos-keychain', 'load', r.stderr || `exit ${r.code}`);
|
|
52
|
+
}
|
|
53
|
+
return r.stdout.trim();
|
|
54
|
+
},
|
|
55
|
+
async delete() {
|
|
56
|
+
const r = await exec('security', ['delete-generic-password', '-a', ACCOUNT_NAME, '-s', SERVICE_NAME]);
|
|
57
|
+
if (r.code !== 0 && !NOT_FOUND_RE.test(r.stderr)) {
|
|
58
|
+
throw new KeyBackendError('macos-keychain', 'delete', r.stderr || `exit ${r.code}`);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=macos-keychain.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"macos-keychain.js","sourceRoot":"","sources":["../../../../src/identity/backends/macos-keychain.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,QAAQ,EAAE,MAAM,WAAW,CAAC;AAChD,OAAO,EAAE,YAAY,EAAmB,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1F,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,oGAAoG;AACpG,kGAAkG;AAClG,oFAAoF;AACpF,EAAE;AACF,oGAAoG;AACpG,iGAAiG;AACjG,sGAAsG;AACtG,qGAAqG;AACrG,kGAAkG;AAClG,uGAAuG;AACvG,kDAAkD;AAClD,MAAM,YAAY,GAAG,gFAAgF,CAAC;AAEtG,MAAM,UAAU,0BAA0B,CAAC,OAAa,QAAQ;IAC9D,OAAO;QACL,EAAE,EAAE,gBAAgB;QACpB,KAAK,EAAE,gBAAgB;QAEvB,KAAK,CAAC,WAAW;YACf,IAAI,OAAO,CAAC,QAAQ,KAAK,QAAQ;gBAAE,OAAO,KAAK,CAAC;YAChD,IAAI,CAAC;gBACH,0FAA0F;gBAC1F,4EAA4E;gBAC5E,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;gBACjC,OAAO,IAAI,CAAC;YACd,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,KAAK,CAAC;YACf,CAAC;QACH,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,MAAc;YACvB,wFAAwF;YACxF,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE;gBAC/B,sBAAsB;gBACtB,IAAI,EAAE,YAAY;gBAClB,IAAI,EAAE,YAAY;gBAClB,IAAI,EAAE,MAAM;gBACZ,IAAI;aACL,CAAC,CAAC;YACH,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;gBACjB,MAAM,IAAI,eAAe,CAAC,gBAAgB,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC,MAAM,IAAI,QAAQ,CAAC,CAAC,IAAI,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;YAC1G,CAAC;QACH,CAAC;QAED,KAAK,CAAC,IAAI;YACR,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC,uBAAuB,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,CAAC,CAAC;YAC1G,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;gBACjB,IAAI,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC;oBAAE,OAAO,IAAI,CAAC;gBAC7C,MAAM,IAAI,eAAe,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YACpF,CAAC;YACD,OAAO,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QACzB,CAAC;QAED,KAAK,CAAC,MAAM;YACV,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC,yBAAyB,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,CAAC;YACtG,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;gBACjD,MAAM,IAAI,eAAe,CAAC,gBAAgB,EAAE,QAAQ,EAAE,CAAC,CAAC,MAAM,IAAI,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;YACtF,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
|