xmemcli 0.0.10__tar.gz → 0.0.12__tar.gz
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.
- {xmemcli-0.0.10 → xmemcli-0.0.12}/PKG-INFO +17 -10
- {xmemcli-0.0.10 → xmemcli-0.0.12}/README.md +16 -9
- {xmemcli-0.0.10 → xmemcli-0.0.12}/pyproject.toml +1 -1
- {xmemcli-0.0.10 → xmemcli-0.0.12}/uv.lock +1 -1
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/__init__.py +1 -1
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_login.py +236 -20
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_output.py +6 -3
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_pretty.py +1 -1
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_rc.py +19 -21
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_version.py +1 -1
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/cli.py +73 -4
- {xmemcli-0.0.10 → xmemcli-0.0.12}/.gitignore +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_binding.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_binding_cmd.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_context_cmd.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_email_login.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_envelope.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_help.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_instance_schema.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_mcp_bridge.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_text.py +0 -0
- {xmemcli-0.0.10 → xmemcli-0.0.12}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.12
|
|
4
4
|
Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
|
|
5
5
|
Keywords: api,cli,llm,memory,xmemory
|
|
6
6
|
Requires-Python: <4,>=3.12
|
|
@@ -15,7 +15,7 @@ Full command reference is included below.
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
|
|
18
|
+
xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
|
|
19
19
|
xmemcli auth logout
|
|
20
20
|
xmemcli auth status
|
|
21
21
|
xmemcli read <query>
|
|
@@ -97,7 +97,7 @@ xmemcli version
|
|
|
97
97
|
xmemcli auth login
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Version: **0.0.
|
|
100
|
+
Version: **0.0.12**.
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
---
|
|
@@ -108,7 +108,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
|
|
|
108
108
|
|
|
109
109
|
Two acquisition paths share `xmemcli auth login`:
|
|
110
110
|
|
|
111
|
-
- **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on **`127.0.0.1
|
|
111
|
+
- **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
|
|
112
112
|
- **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
|
|
113
113
|
|
|
114
114
|
> **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
|
|
@@ -129,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
129
129
|
|
|
130
130
|
1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
|
|
131
131
|
2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
|
|
132
|
-
3. Browser
|
|
132
|
+
3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
|
|
133
133
|
4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
|
|
134
134
|
|
|
135
135
|
### Sequence
|
|
@@ -137,7 +137,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
137
137
|
```
|
|
138
138
|
AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
|
|
139
139
|
Human ── opens Console URL ──► creates key → "Use this key in the CLI"
|
|
140
|
-
CLI ◄──
|
|
140
|
+
CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
|
|
141
141
|
AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
|
|
142
142
|
```
|
|
143
143
|
|
|
@@ -161,6 +161,8 @@ A credential that already resolves (via `/whoami`) to the requested address at t
|
|
|
161
161
|
|
|
162
162
|
Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
|
|
163
163
|
|
|
164
|
+
Those three keys are required and must be strings. Anything else in the file is **ignored, not rejected** (since 0.0.12): the rc is the only durable credential store, so a file this CLI cannot load is a CLI that cannot run any command at all — which would make adding a field to the file a breaking change far wider than the feature that added it.
|
|
165
|
+
|
|
164
166
|
Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin.
|
|
165
167
|
|
|
166
168
|
## Identity — `/whoami`
|
|
@@ -181,7 +183,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
|
|
|
181
183
|
| Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
|
|
182
184
|
| Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
|
|
183
185
|
|
|
184
|
-
|
|
186
|
+
Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
|
|
185
187
|
|
|
186
188
|
## Quota (observe vs escalate)
|
|
187
189
|
|
|
@@ -191,7 +193,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
|
|
|
191
193
|
|
|
192
194
|
# `xmemcli` — capabilities reference
|
|
193
195
|
|
|
194
|
-
**Version:** `0.0.
|
|
196
|
+
**Version:** `0.0.12` (see `xmemcli version`)
|
|
195
197
|
|
|
196
198
|
`xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
|
|
197
199
|
|
|
@@ -410,7 +412,7 @@ so a typo fails loudly rather than being ignored.
|
|
|
410
412
|
|
|
411
413
|
| Command | Description |
|
|
412
414
|
|---------|-------------|
|
|
413
|
-
| **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url
|
|
415
|
+
| **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
|
|
414
416
|
| **`auth logout`** | Remove active `.xmemrc.json` |
|
|
415
417
|
| **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
|
|
416
418
|
| **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
|
|
@@ -513,7 +515,9 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
|
|
|
513
515
|
|
|
514
516
|
**Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
|
|
515
517
|
|
|
516
|
-
|
|
518
|
+
**Compatibility change in 0.0.12:** an `.xmemrc.json` carrying a key this release does not recognise is now **accepted**, and the unknown key ignored; `email`, `api_url`, and `api_key` are still required and still have to be strings. Until 0.0.12 the key set was closed, which made adding a field to the file a breaking change for **every** command rather than for the one that would read it — a CLI that cannot load the rc cannot resolve a credential for anything. Nothing writes an extra key yet; this release exists so that a later one can.
|
|
519
|
+
|
|
520
|
+
See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
|
|
517
521
|
|
|
518
522
|
---
|
|
519
523
|
|
|
@@ -594,6 +598,7 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
594
598
|
| `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
|
|
595
599
|
| `--api-key` | `XMEM_API_KEY` | Bearer API key |
|
|
596
600
|
| `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
|
|
601
|
+
|
|
597
602
|
| `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
|
|
598
603
|
| `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
|
|
599
604
|
| `--json` | — | Force one JSON document on stdout |
|
|
@@ -601,6 +606,8 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
601
606
|
|
|
602
607
|
**Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
|
|
603
608
|
|
|
609
|
+
**`.xmemrc.json` shape:** `{email, api_url, api_key}`, all three required and all three strings. Keys beyond those are ignored rather than rejected (see the 0.0.12 note above), so a file written by a newer CLI still loads here.
|
|
610
|
+
|
|
604
611
|
**`context` is the one exception, and it is a security boundary.** Every other command walks up
|
|
605
612
|
from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
|
|
606
613
|
that directory and ran the command in it. `context` has neither — a session-start hook runs it
|
|
@@ -7,7 +7,7 @@ Full command reference is included below.
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--email ADDR [--org-name NAME]]
|
|
10
|
+
xmemcli auth login [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
|
|
11
11
|
xmemcli auth logout
|
|
12
12
|
xmemcli auth status
|
|
13
13
|
xmemcli read <query>
|
|
@@ -89,7 +89,7 @@ xmemcli version
|
|
|
89
89
|
xmemcli auth login
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Version: **0.0.
|
|
92
|
+
Version: **0.0.12**.
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
---
|
|
@@ -100,7 +100,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
|
|
|
100
100
|
|
|
101
101
|
Two acquisition paths share `xmemcli auth login`:
|
|
102
102
|
|
|
103
|
-
- **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on **`127.0.0.1
|
|
103
|
+
- **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
|
|
104
104
|
- **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
|
|
105
105
|
|
|
106
106
|
> **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
|
|
@@ -121,7 +121,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
121
121
|
|
|
122
122
|
1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
|
|
123
123
|
2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
|
|
124
|
-
3. Browser
|
|
124
|
+
3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
|
|
125
125
|
4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
|
|
126
126
|
|
|
127
127
|
### Sequence
|
|
@@ -129,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
129
129
|
```
|
|
130
130
|
AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
|
|
131
131
|
Human ── opens Console URL ──► creates key → "Use this key in the CLI"
|
|
132
|
-
CLI ◄──
|
|
132
|
+
CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
|
|
133
133
|
AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
|
|
134
134
|
```
|
|
135
135
|
|
|
@@ -153,6 +153,8 @@ A credential that already resolves (via `/whoami`) to the requested address at t
|
|
|
153
153
|
|
|
154
154
|
Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
|
|
155
155
|
|
|
156
|
+
Those three keys are required and must be strings. Anything else in the file is **ignored, not rejected** (since 0.0.12): the rc is the only durable credential store, so a file this CLI cannot load is a CLI that cannot run any command at all — which would make adding a field to the file a breaking change far wider than the feature that added it.
|
|
157
|
+
|
|
156
158
|
Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin.
|
|
157
159
|
|
|
158
160
|
## Identity — `/whoami`
|
|
@@ -173,7 +175,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
|
|
|
173
175
|
| Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
|
|
174
176
|
| Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
|
|
175
177
|
|
|
176
|
-
|
|
178
|
+
Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
|
|
177
179
|
|
|
178
180
|
## Quota (observe vs escalate)
|
|
179
181
|
|
|
@@ -183,7 +185,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
|
|
|
183
185
|
|
|
184
186
|
# `xmemcli` — capabilities reference
|
|
185
187
|
|
|
186
|
-
**Version:** `0.0.
|
|
188
|
+
**Version:** `0.0.12` (see `xmemcli version`)
|
|
187
189
|
|
|
188
190
|
`xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
|
|
189
191
|
|
|
@@ -402,7 +404,7 @@ so a typo fails loudly rather than being ignored.
|
|
|
402
404
|
|
|
403
405
|
| Command | Description |
|
|
404
406
|
|---------|-------------|
|
|
405
|
-
| **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url
|
|
407
|
+
| **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
|
|
406
408
|
| **`auth logout`** | Remove active `.xmemrc.json` |
|
|
407
409
|
| **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
|
|
408
410
|
| **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
|
|
@@ -505,7 +507,9 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, …)
|
|
|
505
507
|
|
|
506
508
|
**Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
|
|
507
509
|
|
|
508
|
-
|
|
510
|
+
**Compatibility change in 0.0.12:** an `.xmemrc.json` carrying a key this release does not recognise is now **accepted**, and the unknown key ignored; `email`, `api_url`, and `api_key` are still required and still have to be strings. Until 0.0.12 the key set was closed, which made adding a field to the file a breaking change for **every** command rather than for the one that would read it — a CLI that cannot load the rc cannot resolve a credential for anything. Nothing writes an extra key yet; this release exists so that a later one can.
|
|
511
|
+
|
|
512
|
+
See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
|
|
509
513
|
|
|
510
514
|
---
|
|
511
515
|
|
|
@@ -586,6 +590,7 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
586
590
|
| `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
|
|
587
591
|
| `--api-key` | `XMEM_API_KEY` | Bearer API key |
|
|
588
592
|
| `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
|
|
593
|
+
|
|
589
594
|
| `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
|
|
590
595
|
| `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
|
|
591
596
|
| `--json` | — | Force one JSON document on stdout |
|
|
@@ -593,6 +598,8 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
593
598
|
|
|
594
599
|
**Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
|
|
595
600
|
|
|
601
|
+
**`.xmemrc.json` shape:** `{email, api_url, api_key}`, all three required and all three strings. Keys beyond those are ignored rather than rejected (see the 0.0.12 note above), so a file written by a newer CLI still loads here.
|
|
602
|
+
|
|
596
603
|
**`context` is the one exception, and it is a security boundary.** Every other command walks up
|
|
597
604
|
from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
|
|
598
605
|
that directory and ran the command in it. `context` has neither — a session-start hook runs it
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"""The browser handoff behind ``xmem auth login`` (PKCE + loopback callback).
|
|
2
2
|
|
|
3
3
|
The CLI mints a PKCE ``verifier`` / ``challenge`` and opens ``/xmemcli/<challenge>``.
|
|
4
|
-
It listens on ``127.0.0.1
|
|
5
|
-
in the CLI**.
|
|
6
|
-
|
|
4
|
+
It listens on an ephemeral ``127.0.0.1`` port, named in the login URL, while the human
|
|
5
|
+
creates a key and clicks **Use this key in the CLI**. A port per run means two logins never
|
|
6
|
+
contend for one listener. The Console POSTs the key to ``/cli/handoff``, receives a signed
|
|
7
|
+
short-lived ``code``, then delivers it to that callback with ``code`` + ``state`` — in the
|
|
8
|
+
background where the browser allows that, otherwise through a link the human clicks.
|
|
7
9
|
The CLI redeems with ``verifier`` + ``code`` over TLS; only it holds the verifier.
|
|
8
10
|
|
|
11
|
+
Delivery reaching the CLI's own machine is what binds the key to the human who approved it:
|
|
12
|
+
the code is only ever handed to a listener on their loopback interface.
|
|
13
|
+
|
|
9
14
|
This loopback callback is a conscious multi-node design choice: the API remains stateless
|
|
10
15
|
for sign-in, because any node can mint/verify the signed code and no shared relay table,
|
|
11
16
|
cache, sticky session, or cross-node pub/sub is required.
|
|
@@ -13,6 +18,7 @@ Standard-library only, like the rest of the CLI.
|
|
|
13
18
|
"""
|
|
14
19
|
|
|
15
20
|
import base64
|
|
21
|
+
import errno
|
|
16
22
|
import hashlib
|
|
17
23
|
import json
|
|
18
24
|
import os
|
|
@@ -23,7 +29,7 @@ import threading
|
|
|
23
29
|
import time
|
|
24
30
|
import urllib.error
|
|
25
31
|
import urllib.request
|
|
26
|
-
from http.server import BaseHTTPRequestHandler,
|
|
32
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
27
33
|
from typing import IO, Any, Callable, NamedTuple, cast, override
|
|
28
34
|
from urllib.parse import parse_qs, quote, urlparse, urlsplit
|
|
29
35
|
|
|
@@ -46,8 +52,27 @@ _ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
|
|
|
46
52
|
_ENV_API_URL = "XMEM_API_URL"
|
|
47
53
|
DEFAULT_CONSOLE_URL = "https://console.xmemory.ai"
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
#: Every login binds its own ephemeral port and tells the Console which one in the login URL, so
|
|
56
|
+
#: two ``auth login`` runs never contend for one listener. This value is only the legacy default a
|
|
57
|
+
#: pre-ephemeral client used; it is what ``build_login_page_url`` compares against to decide the
|
|
58
|
+
#: URL still needs a ``callback_url``.
|
|
59
|
+
LEGACY_CALLBACK_PORT = 9636
|
|
60
|
+
#: Bind an ephemeral port: the OS picks a free one and the listener reports it back.
|
|
61
|
+
EPHEMERAL_CALLBACK_PORT = 0
|
|
50
62
|
CALLBACK_PATH = "/callback"
|
|
63
|
+
# Chrome restricts a public HTTPS page (the Console) reaching a loopback address behind a
|
|
64
|
+
# Local Network Access permission. Measured on Chrome 150: the same request issued straight
|
|
65
|
+
# off the human's click prompts for that permission, while one issued after an awaited POST
|
|
66
|
+
# is refused outright ("Permission was denied for this request to access the `loopback`
|
|
67
|
+
# address space") with no prompt at all. No Chrome/WICG/MDN document explains the difference
|
|
68
|
+
# — spending the click's transient user activation is the likeliest cause but is inference,
|
|
69
|
+
# not a documented contract. The Console therefore hits this path first, before it awaits
|
|
70
|
+
# anything else. Nothing is captured here, so a permission request is never mistaken for a
|
|
71
|
+
# callback.
|
|
72
|
+
PERMISSION_PROBE_PATH = "/permission-probe"
|
|
73
|
+
#: Answered on the probe so the Console can tell a real xmemcli listener from any other local
|
|
74
|
+
#: server on the port the login URL named. Must match cli_handoff.js XMEMCLI_MARKER_HEADER.
|
|
75
|
+
XMEMCLI_MARKER_HEADER = "X-Xmemcli"
|
|
51
76
|
CLI_LOGIN_PATH = "/xmemcli"
|
|
52
77
|
CLI_TOKEN_PROXY_PATH = "/api/cli/token"
|
|
53
78
|
CLI_WHOAMI_PROXY_PATH = "/api/cli/whoami"
|
|
@@ -58,10 +83,15 @@ API_KEY_PREFIX = "xmem_"
|
|
|
58
83
|
#: local-dev Console. One list, shared — three copies had already grown two different values.
|
|
59
84
|
LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
60
85
|
|
|
86
|
+
# Deliberately does NOT claim success: this page is written when the code arrives, before the
|
|
87
|
+
# CLI has redeemed it, and redemption can still fail (an expired code, most plausibly when a
|
|
88
|
+
# human clicks the fallback link minutes after it was minted). Reporting the outcome here would
|
|
89
|
+
# mean holding the response open across the redeem; pointing at the terminal, which does know,
|
|
90
|
+
# is honest and keeps the handler a single non-blocking write.
|
|
61
91
|
_DONE_PAGE = (
|
|
62
92
|
f"<!doctype html><html><head><meta charset='utf-8'><title>{CLI_NAME}</title></head>"
|
|
63
93
|
"<body style='font-family:system-ui;margin:3rem'>"
|
|
64
|
-
"<h2>
|
|
94
|
+
"<h2>Code received.</h2><p>Return to your terminal — it reports whether the login completed.</p>"
|
|
65
95
|
"</body></html>"
|
|
66
96
|
).encode("utf-8")
|
|
67
97
|
|
|
@@ -136,13 +166,14 @@ def build_login_page_url(console_url: str, *, challenge: str, callback_url: str
|
|
|
136
166
|
"""Console CLI login URL: ``/xmemcli/<challenge>`` (PKCE S256, also the callback state)."""
|
|
137
167
|
segment = quote(challenge, safe="-_")
|
|
138
168
|
url = f"{console_url.rstrip('/')}{CLI_LOGIN_PATH}/{segment}"
|
|
139
|
-
if callback_url and callback_url != redirect_uri_for_port(
|
|
169
|
+
if callback_url and callback_url != redirect_uri_for_port(LEGACY_CALLBACK_PORT):
|
|
140
170
|
url += "?callback_url=" + quote(callback_url, safe=":/?&=-_.")
|
|
141
171
|
return url
|
|
142
172
|
|
|
143
173
|
|
|
144
174
|
def _login_prompt_line(label: str, body: str) -> str:
|
|
145
|
-
|
|
175
|
+
# Width fits the longest label below; anything longer would run into its own body text.
|
|
176
|
+
return f" {label:<12}{body}"
|
|
146
177
|
|
|
147
178
|
|
|
148
179
|
def write_login_handoff_prompt(
|
|
@@ -153,7 +184,7 @@ def write_login_handoff_prompt(
|
|
|
153
184
|
) -> None:
|
|
154
185
|
"""Human-facing instructions while ``auth login`` waits for a credential."""
|
|
155
186
|
out = stream or sys.stderr
|
|
156
|
-
body_indent = "
|
|
187
|
+
body_indent = " " * 14 # 2-space margin + the 12-wide label column, so bodies line up
|
|
157
188
|
lines = [
|
|
158
189
|
"",
|
|
159
190
|
f"Authorize {CLI_NAME} on this machine.",
|
|
@@ -163,12 +194,16 @@ def write_login_handoff_prompt(
|
|
|
163
194
|
"",
|
|
164
195
|
_login_prompt_line(
|
|
165
196
|
"Onboarding",
|
|
166
|
-
"During first-instance setup the Console
|
|
197
|
+
"During first-instance setup the Console delivers the key for you where it can.",
|
|
167
198
|
),
|
|
168
199
|
_login_prompt_line(
|
|
169
|
-
"
|
|
200
|
+
"Otherwise",
|
|
170
201
|
'Create a key on API Keys and click "Use this key in the CLI".',
|
|
171
202
|
),
|
|
203
|
+
_login_prompt_line(
|
|
204
|
+
"New tab",
|
|
205
|
+
"Some browsers (Safari) finish the login in a new tab — follow the link if one appears.",
|
|
206
|
+
),
|
|
172
207
|
]
|
|
173
208
|
if offer_paste:
|
|
174
209
|
lines.extend(
|
|
@@ -287,21 +322,183 @@ def validate_api_key_shape(key: str) -> str | None:
|
|
|
287
322
|
return None
|
|
288
323
|
|
|
289
324
|
|
|
290
|
-
class _LoopbackServer(
|
|
325
|
+
class _LoopbackServer(ThreadingHTTPServer):
|
|
326
|
+
# Threading, not the plain server: browsers open speculative preconnects that send nothing,
|
|
327
|
+
# and a single-threaded server serves them one at a time — each costing the full per-request
|
|
328
|
+
# timeout before the real callback is even accepted. CPython documents ThreadingHTTPServer as
|
|
329
|
+
# existing for exactly this. Handlers run concurrently, so ``captured`` is written under a
|
|
330
|
+
# lock and only once: first delivery wins. Without that, two callbacks racing would both
|
|
331
|
+
# write and the later one — which may carry a stale or mismatched ``state`` — would replace
|
|
332
|
+
# the one already accepted.
|
|
333
|
+
daemon_threads = True
|
|
291
334
|
captured: dict[str, str] | None = None
|
|
335
|
+
# Serialized origin of the Console this login is talking to, per RFC 6454. Carried on the
|
|
336
|
+
# server so the handler compares against the origin actually in play — including one passed
|
|
337
|
+
# via ``--console-url`` — instead of re-deriving it from the environment.
|
|
338
|
+
console_origin: str = ""
|
|
339
|
+
|
|
340
|
+
@override
|
|
341
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
342
|
+
# Owned here rather than by the caller: every construction site needs it, including the
|
|
343
|
+
# tests that build a listener directly, and a missing lock fails as a dropped connection
|
|
344
|
+
# rather than anything that names the cause.
|
|
345
|
+
self.capture_lock = threading.Lock()
|
|
346
|
+
super().__init__(*args, **kwargs)
|
|
347
|
+
|
|
348
|
+
@override
|
|
349
|
+
def handle_error(self, request: Any, client_address: Any) -> None:
|
|
350
|
+
"""Swallow the disconnects this listener expects; let anything else surface.
|
|
351
|
+
|
|
352
|
+
Delivery is up to three connections plus browser preconnects, two of them fire-and-forget
|
|
353
|
+
``no-cors`` fetches whose sockets the browser may reset. The default handler would dump
|
|
354
|
+
those tracebacks onto stderr, interleaved with the login prompt.
|
|
355
|
+
|
|
356
|
+
Scoped deliberately: swallowing everything would hide a bug raised inside a handler, and
|
|
357
|
+
the only symptom would be a login that waits out its full timeout with nothing printed.
|
|
358
|
+
(Socket timeouts are quiet for a different reason — the ``log_message`` override.)
|
|
359
|
+
"""
|
|
360
|
+
exc = sys.exc_info()[1]
|
|
361
|
+
if isinstance(exc, (ConnectionResetError, BrokenPipeError, TimeoutError)):
|
|
362
|
+
return
|
|
363
|
+
super().handle_error(request, client_address)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _same_console_origin(actual: str, expected: str) -> bool:
|
|
367
|
+
"""Whether two serialized origins name the same Console.
|
|
368
|
+
|
|
369
|
+
Exact equality except among the loopback spellings, which are the same host reached by
|
|
370
|
+
different names — the equivalence ``LOOPBACK_HOSTS`` exists for. A developer whose CLI
|
|
371
|
+
derives ``http://localhost:8002`` but who opens ``http://127.0.0.1:8002`` is at their own
|
|
372
|
+
Console; refusing the marker there disables background delivery and blames a permission
|
|
373
|
+
the browser never asked about.
|
|
374
|
+
|
|
375
|
+
Deliberately not a general relaxation: scheme and port must still match exactly, so this
|
|
376
|
+
cannot bridge a loopback origin to a public one.
|
|
377
|
+
"""
|
|
378
|
+
if not actual or not expected:
|
|
379
|
+
return False
|
|
380
|
+
if actual == expected:
|
|
381
|
+
return True
|
|
382
|
+
parsed_actual, parsed_expected = urlsplit(actual), urlsplit(expected)
|
|
383
|
+
if parsed_actual.scheme != parsed_expected.scheme:
|
|
384
|
+
return False
|
|
385
|
+
if (parsed_actual.hostname or "") not in LOOPBACK_HOSTS:
|
|
386
|
+
return False
|
|
387
|
+
if (parsed_expected.hostname or "") not in LOOPBACK_HOSTS:
|
|
388
|
+
return False
|
|
389
|
+
try:
|
|
390
|
+
return parsed_actual.port == parsed_expected.port
|
|
391
|
+
except ValueError:
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def serialized_origin(url: str) -> str:
|
|
396
|
+
"""``scheme://host[:port]``, lowercased, default port elided — comparable to an ``Origin``."""
|
|
397
|
+
parsed = urlsplit(url.strip())
|
|
398
|
+
scheme = parsed.scheme.lower()
|
|
399
|
+
host = (parsed.hostname or "").lower()
|
|
400
|
+
if not scheme or not host:
|
|
401
|
+
return ""
|
|
402
|
+
try:
|
|
403
|
+
port = parsed.port
|
|
404
|
+
except ValueError:
|
|
405
|
+
# A non-numeric or out-of-range port is a malformed origin, and this value is only ever
|
|
406
|
+
# compared against a request's Origin header — an unparseable one matches nothing, which
|
|
407
|
+
# is the safe answer. Raising here would escape before the listener can be closed.
|
|
408
|
+
return ""
|
|
409
|
+
if port is None or (scheme, port) in (("http", 80), ("https", 443)):
|
|
410
|
+
return f"{scheme}://{host}"
|
|
411
|
+
return f"{scheme}://{host}:{port}"
|
|
292
412
|
|
|
293
413
|
|
|
294
414
|
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
415
|
+
# A connection that opens and then says nothing must not wedge the listener: HTTPServer is
|
|
416
|
+
# threaded, so a silent peer gets its own thread rather than starving the listener — but
|
|
417
|
+
# without this that thread would live for the whole login, so
|
|
418
|
+
# Threading keeps accept() moving, so a silent peer no longer delays the real callback. This
|
|
419
|
+
# timeout is what bounds how long that peer's worker thread sits in rfile.readline() — without
|
|
420
|
+
# it the thread would live for the whole login. Do not delete it as "redundant now we thread":
|
|
421
|
+
# the two solve different halves.
|
|
422
|
+
timeout = 5
|
|
423
|
+
|
|
424
|
+
def _console_origin_or_none(self) -> str | None:
|
|
425
|
+
"""The request's ``Origin`` when it is this login's Console, else ``None``.
|
|
426
|
+
|
|
427
|
+
One predicate for both the preflight and the probe. Written twice it drifted: the probe
|
|
428
|
+
branch echoed the raw header, so an obs-folded value carrying CR/LF round-tripped into
|
|
429
|
+
``Access-Control-Allow-Origin`` while the identical input on ``OPTIONS`` was refused.
|
|
430
|
+
"""
|
|
431
|
+
origin = self.headers.get("Origin")
|
|
432
|
+
if not origin or "\r" in origin or "\n" in origin:
|
|
433
|
+
return None
|
|
434
|
+
expected = cast(_LoopbackServer, self.server).console_origin
|
|
435
|
+
if not expected:
|
|
436
|
+
return None
|
|
437
|
+
return origin if _same_console_origin(serialized_origin(origin), expected) else None
|
|
438
|
+
|
|
439
|
+
def do_OPTIONS(self) -> None: # noqa: N802
|
|
440
|
+
"""Answer a CORS/Private-Network preflight, should one ever be sent.
|
|
441
|
+
|
|
442
|
+
Defensive rather than load-bearing: both Console requests are ``no-cors`` GETs, which are
|
|
443
|
+
never CORS-preflighted, and the only mechanism that would have preflighted them —
|
|
444
|
+
Private Network Access — is on hold, with its Local Network Access successor gating on a
|
|
445
|
+
permission instead. Left in place because ``BaseHTTPRequestHandler`` would otherwise
|
|
446
|
+
answer 501, which fails such a preflight and silently drops the callback.
|
|
447
|
+
"""
|
|
448
|
+
origin = self._console_origin_or_none()
|
|
449
|
+
self.send_response(204)
|
|
450
|
+
self.send_header("Vary", "Origin")
|
|
451
|
+
if origin:
|
|
452
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
453
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
454
|
+
# Private Network Access. Its Local Network Access successor dropped the header
|
|
455
|
+
# mechanism for a permission prompt, so there is no second header to send.
|
|
456
|
+
if self.headers.get("Access-Control-Request-Private-Network") == "true":
|
|
457
|
+
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
458
|
+
self.send_header("Access-Control-Max-Age", "60")
|
|
459
|
+
self.end_headers()
|
|
460
|
+
|
|
295
461
|
def do_GET(self) -> None: # noqa: N802
|
|
462
|
+
# The callback carries no CORS headers: the Console fetches it with ``mode: 'no-cors'``
|
|
463
|
+
# and never reads the response, so letting arbitrary pages read it buys nothing. The
|
|
464
|
+
# probe above is the deliberate exception — it is fetched with ``mode: 'cors'`` precisely
|
|
465
|
+
# so the marker is readable, which an opaque response would prevent.
|
|
296
466
|
parsed = urlparse(self.path)
|
|
467
|
+
if parsed.path == PERMISSION_PROBE_PATH:
|
|
468
|
+
# Captures nothing — its job is to let the Console tell this listener apart from an
|
|
469
|
+
# unrelated service that happens to hold the port named in the URL, before it mints a
|
|
470
|
+
# key toward it. The marker is a fixed public value, so it proves cooperation with
|
|
471
|
+
# this protocol, not identity: malicious local code can send the same headers, which
|
|
472
|
+
# is out of scope — a process on the user's loopback already implies code execution
|
|
473
|
+
# on the machine. What it does buy is that a benign service answering there gets no
|
|
474
|
+
# key. The CORS headers
|
|
475
|
+
# are what let the Console read the marker cross-origin; without a matching Origin we
|
|
476
|
+
# send neither, so nothing here leaks to an arbitrary page. Reaching it at all is also
|
|
477
|
+
# still what makes the browser prompt for local-network access.
|
|
478
|
+
origin = self._console_origin_or_none()
|
|
479
|
+
self.send_response(204)
|
|
480
|
+
self.send_header("Vary", "Origin")
|
|
481
|
+
if origin:
|
|
482
|
+
self.send_header("Access-Control-Allow-Origin", origin)
|
|
483
|
+
self.send_header("Access-Control-Expose-Headers", XMEMCLI_MARKER_HEADER)
|
|
484
|
+
self.send_header(XMEMCLI_MARKER_HEADER, "1")
|
|
485
|
+
self.end_headers()
|
|
486
|
+
return
|
|
297
487
|
if parsed.path != CALLBACK_PATH:
|
|
298
488
|
self.send_response(404)
|
|
489
|
+
self.send_header("Content-Length", "0")
|
|
299
490
|
self.end_headers()
|
|
300
491
|
return
|
|
301
492
|
params = {k: v[0] for k, v in parse_qs(parsed.query).items() if v}
|
|
302
|
-
cast(_LoopbackServer, self.server)
|
|
493
|
+
server = cast(_LoopbackServer, self.server)
|
|
494
|
+
# First delivery wins. Handlers are concurrent now, so an unconditional write would let a
|
|
495
|
+
# second callback replace one already accepted.
|
|
496
|
+
with server.capture_lock:
|
|
497
|
+
if server.captured is None:
|
|
498
|
+
server.captured = params
|
|
303
499
|
self.send_response(200)
|
|
304
500
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
501
|
+
self.send_header("Content-Length", str(len(_DONE_PAGE)))
|
|
305
502
|
self.end_headers()
|
|
306
503
|
self.wfile.write(_DONE_PAGE)
|
|
307
504
|
|
|
@@ -316,8 +513,9 @@ def wait_for_callback(
|
|
|
316
513
|
on_url: Callable[[str], None],
|
|
317
514
|
timeout_s: float,
|
|
318
515
|
*,
|
|
319
|
-
callback_port: int =
|
|
516
|
+
callback_port: int = EPHEMERAL_CALLBACK_PORT,
|
|
320
517
|
stop: threading.Event | None = None,
|
|
518
|
+
console_url: str | None = None,
|
|
321
519
|
) -> dict[str, str]:
|
|
322
520
|
"""Listen for the browser's local callback carrying the signed code.
|
|
323
521
|
|
|
@@ -327,13 +525,30 @@ def wait_for_callback(
|
|
|
327
525
|
try:
|
|
328
526
|
server = _LoopbackServer(("127.0.0.1", callback_port), _CallbackHandler)
|
|
329
527
|
except OSError as e:
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
528
|
+
# Say what actually happened. Naming a cause the errno does not support sends people
|
|
529
|
+
# hunting the wrong thing — a permission failure is not a port conflict — and the raw
|
|
530
|
+
# exception text ("[Errno 98] ...") is not something to put in front of a user.
|
|
531
|
+
if callback_port == EPHEMERAL_CALLBACK_PORT:
|
|
532
|
+
raise LoginError("could not bind a local port for the login callback") from None
|
|
533
|
+
if e.errno == errno.EACCES:
|
|
534
|
+
# Reachable through direct library use with a privileged port, and via the CLI only
|
|
535
|
+
# when something other than the port number denied the bind — `_callback_port` has
|
|
536
|
+
# already required >= 1024 there. So it names both causes rather than telling a CLI
|
|
537
|
+
# user to do what they just did.
|
|
538
|
+
raise LoginError(
|
|
539
|
+
f"not allowed to bind 127.0.0.1:{callback_port} — ports below 1024 need elevated "
|
|
540
|
+
f"privileges, and a host firewall or security product can deny a bind at any port."
|
|
541
|
+
) from None
|
|
542
|
+
if e.errno == errno.EADDRINUSE:
|
|
543
|
+
raise LoginError(
|
|
544
|
+
f"could not bind 127.0.0.1:{callback_port} — something is already using that port, "
|
|
545
|
+
f"possibly another `{CLI_NAME} auth login`."
|
|
546
|
+
) from None
|
|
547
|
+
raise LoginError(f"could not bind 127.0.0.1:{callback_port} for the login callback") from None
|
|
334
548
|
bound_port = server.server_address[1]
|
|
335
549
|
server.timeout = 0.5
|
|
336
550
|
try:
|
|
551
|
+
server.console_origin = serialized_origin(console_url_for_api(console_url=console_url))
|
|
337
552
|
on_url(build_url(redirect_uri_for_port(bound_port)))
|
|
338
553
|
deadline = time.monotonic() + timeout_s
|
|
339
554
|
while server.captured is None and not (stop and stop.is_set()):
|
|
@@ -548,7 +763,7 @@ def wait_for_handoff(
|
|
|
548
763
|
build_url: Callable[[str], str],
|
|
549
764
|
on_url: Callable[[str], None],
|
|
550
765
|
timeout_s: float,
|
|
551
|
-
callback_port: int =
|
|
766
|
+
callback_port: int = EPHEMERAL_CALLBACK_PORT,
|
|
552
767
|
interactive: bool = False,
|
|
553
768
|
notify: Callable[[str], None] | None = None,
|
|
554
769
|
) -> HandoffResult:
|
|
@@ -582,6 +797,7 @@ def wait_for_handoff(
|
|
|
582
797
|
timeout_s,
|
|
583
798
|
callback_port=callback_port,
|
|
584
799
|
stop=stop,
|
|
800
|
+
console_url=console_url,
|
|
585
801
|
)
|
|
586
802
|
code = params.get("code")
|
|
587
803
|
if not code:
|
|
@@ -674,7 +890,7 @@ def run_pkce_login(
|
|
|
674
890
|
verifier: str,
|
|
675
891
|
on_url: Callable[[str], None],
|
|
676
892
|
timeout_s: float,
|
|
677
|
-
callback_port: int =
|
|
893
|
+
callback_port: int = EPHEMERAL_CALLBACK_PORT,
|
|
678
894
|
interactive: bool = False,
|
|
679
895
|
notify: Callable[[str], None] | None = None,
|
|
680
896
|
) -> HandoffResult:
|
|
@@ -84,9 +84,12 @@ class OutputKind(StrEnum):
|
|
|
84
84
|
#: payload: ``write_id``; the caller must poll.
|
|
85
85
|
WRITE_ASYNC = "write_async"
|
|
86
86
|
|
|
87
|
-
#:
|
|
88
|
-
#:
|
|
89
|
-
#: ``
|
|
87
|
+
#: Result of an ``async_write_status`` poll. Curated payload: ``write_id`` + ``write_status``
|
|
88
|
+
#: — any ``WriteQueueStatus`` value, not only the terminal ones, plus the CLI's own
|
|
89
|
+
#: ``"error"`` / ``"unknown"`` fallbacks — whichever of ``submitted_at`` / ``started_at`` /
|
|
90
|
+
#: ``completed_at`` is set, ``restart_count`` once non-zero (``--verbose`` keeps a zero count and
|
|
91
|
+
#: drops only the null an unknown ``write_id`` reports), and ``error_detail`` / ``error``
|
|
92
|
+
#: / ``http_status`` / ``timed_out`` when applicable.
|
|
90
93
|
WRITE_STATUS = "write_status"
|
|
91
94
|
|
|
92
95
|
#: Read in ``mode=single-answer``. Curated payload: ``answer`` (falls
|
|
@@ -78,7 +78,7 @@ def _render_write_async(payload: Any, stream: IO[str]) -> None:
|
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
def _render_write_status(payload: Any, stream: IO[str]) -> None:
|
|
81
|
-
"""
|
|
81
|
+
"""Poll result — short status line, no body; the timing fields stay JSON-only."""
|
|
82
82
|
if not isinstance(payload, dict):
|
|
83
83
|
_default_handler(payload, stream)
|
|
84
84
|
return
|
|
@@ -5,9 +5,11 @@ The CLI resolves its API key and API origin from a **project-local**
|
|
|
5
5
|
(or the current directory). The search does **not** stop at the repository root
|
|
6
6
|
and is **ceilinged at ``$HOME``** — ``~/.xmemrc.json`` is the last fallback.
|
|
7
7
|
|
|
8
|
-
The file is stdlib JSON,
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
The file is stdlib JSON, one identity per file: ``{email, api_url, api_key}``.
|
|
9
|
+
Those three are required and must be strings; a key this release does not know is
|
|
10
|
+
ignored rather than rejected, so a later release can record something new in the
|
|
11
|
+
file without every older CLI refusing to run. The ``0600`` atomic writer is used
|
|
12
|
+
by ``xmem auth login``; see ``cli/AUTH-FLOW.md``.
|
|
11
13
|
|
|
12
14
|
Resolution precedence (applied by ``cli/xmem.py``): ``--api-key`` → ``$XMEM_API_KEY`` → ``.xmemrc.json``; and
|
|
13
15
|
``--api-url`` → ``$XMEM_API_URL`` → the rc's ``api_url`` → the built-in default — **with one escape hatch**: when the
|
|
@@ -26,9 +28,10 @@ from typing import NamedTuple
|
|
|
26
28
|
RC_FILENAME = ".xmemrc.json"
|
|
27
29
|
ENV_RC_DIR = "XMEM_RC_DIR"
|
|
28
30
|
|
|
29
|
-
# The
|
|
30
|
-
#
|
|
31
|
-
#
|
|
31
|
+
# The keys an `.xmemrc.json` must contain. Required, not exhaustive: anything else in the file is ignored.
|
|
32
|
+
# The set was closed until 0.0.12, on the grounds that a typo'd or extended file should fail loudly.
|
|
33
|
+
# It made adding a field a breaking change for every command rather than for the one that would read it,
|
|
34
|
+
# because a CLI that cannot load the rc cannot resolve a credential for anything.
|
|
32
35
|
_RC_KEYS = ("email", "api_url", "api_key")
|
|
33
36
|
|
|
34
37
|
|
|
@@ -90,27 +93,22 @@ def find_rc(start_dir: Path) -> Path | None:
|
|
|
90
93
|
|
|
91
94
|
|
|
92
95
|
def load_rc(path: Path) -> RcRecord:
|
|
93
|
-
"""Parse and validate an ``.xmemrc.json``; raise ``RcError`` if malformed.
|
|
96
|
+
"""Parse and validate an ``.xmemrc.json``; raise ``RcError`` if malformed.
|
|
97
|
+
|
|
98
|
+
"Required keys present", not "exactly these keys": an unrecognised key is ignored
|
|
99
|
+
whatever its type. Type-checking a field nothing here reads is how a later release's
|
|
100
|
+
richer value becomes this release's hard failure — having no opinion about it is the
|
|
101
|
+
whole point of ignoring it.
|
|
102
|
+
"""
|
|
94
103
|
try:
|
|
95
104
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
96
105
|
except (OSError, json.JSONDecodeError) as exc:
|
|
97
106
|
raise RcError(f"{path}: could not read JSON ({exc})") from exc
|
|
98
107
|
if not isinstance(raw, dict):
|
|
99
108
|
raise RcError(f"{path}: expected a JSON object, got {type(raw).__name__}")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
missing = sorted(expected - keys)
|
|
104
|
-
extra = sorted(keys - expected)
|
|
105
|
-
detail = ", ".join(
|
|
106
|
-
part
|
|
107
|
-
for part in (
|
|
108
|
-
f"missing {missing}" if missing else "",
|
|
109
|
-
f"unexpected {extra}" if extra else "",
|
|
110
|
-
)
|
|
111
|
-
if part
|
|
112
|
-
)
|
|
113
|
-
raise RcError(f"{path}: must contain exactly {list(_RC_KEYS)} ({detail})")
|
|
109
|
+
missing = sorted(set(_RC_KEYS) - set(raw.keys()))
|
|
110
|
+
if missing:
|
|
111
|
+
raise RcError(f"{path}: must contain {list(_RC_KEYS)} (missing {missing})")
|
|
114
112
|
for key in _RC_KEYS:
|
|
115
113
|
if not isinstance(raw[key], str):
|
|
116
114
|
raise RcError(f"{path}: '{key}' must be a string")
|
|
@@ -30,13 +30,16 @@ Curated output
|
|
|
30
30
|
The CLI does NOT echo the raw API payload. Every subcommand reduces the
|
|
31
31
|
server response to the fields the user actually asked for (see the
|
|
32
32
|
``_curate_*`` helpers): a read returns the answer / rows, a write returns
|
|
33
|
-
its ``write_id``, a status poll returns ``write_id`` + ``write_status
|
|
33
|
+
its ``write_id``, a status poll returns ``write_id`` + ``write_status``
|
|
34
|
+
plus whichever timing fields the server set (and the restart count once
|
|
35
|
+
it is non-zero).
|
|
34
36
|
Server internals — ``sql``, ``diff_plan``, the raw ``cleaned_objects``
|
|
35
37
|
tree — are never emitted, in either output mode. ``pending_suggestions``
|
|
36
38
|
(a count of schema-evolution proposals waiting for review) is kept on
|
|
37
39
|
read responses so agents know when to run ``schema suggestions review``.
|
|
38
40
|
``--verbose`` adds *extra* (still non-internal) context: ``trace_id`` and
|
|
39
|
-
``console_url``, when the server provided them
|
|
41
|
+
``console_url``, when the server provided them, and keeps a zero
|
|
42
|
+
``restart_count`` a status poll would otherwise curate away.
|
|
40
43
|
|
|
41
44
|
Sync vs async writes
|
|
42
45
|
--------------------
|
|
@@ -149,6 +152,12 @@ ENV_INSTANCE_ID = "XMEM_INSTANCE_ID"
|
|
|
149
152
|
ENV_API_URL = "XMEM_API_URL"
|
|
150
153
|
ENV_CLUSTER_ID = "XMEM_CLUSTER_ID"
|
|
151
154
|
ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
|
|
155
|
+
#: Pin the loopback callback port. Unset means any free port, which is what lets concurrent
|
|
156
|
+
#: logins coexist; pinning is for when the port has to be known before the login starts.
|
|
157
|
+
ENV_CALLBACK_PORT = "XMEM_CALLBACK_PORT"
|
|
158
|
+
#: Ports below this need privileges the CLI never takes, and the Console refuses them too.
|
|
159
|
+
#: Must match cli_handoff.js CLI_MIN_CALLBACK_PORT.
|
|
160
|
+
MIN_CALLBACK_PORT = 1024
|
|
152
161
|
DEFAULT_API_URL = "https://api.xmemory.ai"
|
|
153
162
|
|
|
154
163
|
#: Where MCP connections go. A separate origin from the API rather than a path on it,
|
|
@@ -743,10 +752,26 @@ def _curate_write_status(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
|
|
|
743
752
|
"write_status": item.get("write_status", "unknown"),
|
|
744
753
|
}
|
|
745
754
|
# Carry the failure / bookkeeping fields only when they say something.
|
|
746
|
-
for key in (
|
|
755
|
+
for key in (
|
|
756
|
+
"error_detail",
|
|
757
|
+
"submitted_at",
|
|
758
|
+
"started_at",
|
|
759
|
+
"completed_at",
|
|
760
|
+
"error",
|
|
761
|
+
"http_status",
|
|
762
|
+
"timed_out",
|
|
763
|
+
):
|
|
747
764
|
value = item.get(key)
|
|
748
765
|
if value is not None:
|
|
749
766
|
curated[key] = value
|
|
767
|
+
# Never-restarted is the norm, so a zero count is noise on the default output; show it there only when
|
|
768
|
+
# the write was restarted. Under --verbose the 0 is kept and only the null is dropped: the server sends
|
|
769
|
+
# null exactly for an unknown write_id, and printing that as 0 would let a caller aggregating polls count
|
|
770
|
+
# a row that does not exist as a write that ran clean.
|
|
771
|
+
restart_count = item.get("restart_count")
|
|
772
|
+
show_restart_count = restart_count is not None if verbose else bool(restart_count)
|
|
773
|
+
if show_restart_count:
|
|
774
|
+
curated["restart_count"] = restart_count
|
|
750
775
|
return _add_verbose_extras(curated, item, verbose)
|
|
751
776
|
|
|
752
777
|
|
|
@@ -1116,6 +1141,9 @@ def cmd_write_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1116
1141
|
}
|
|
1117
1142
|
last_status = item.get("write_status", "unknown")
|
|
1118
1143
|
last_body = item
|
|
1144
|
+
# Mirrors the terminal tail of WriteQueueStatus (completed, failed, not_found). Hand-written
|
|
1145
|
+
# rather than derived: this CLI is stdlib-only by design and imports nothing from the server
|
|
1146
|
+
# packages, so the enum is out of reach here.
|
|
1119
1147
|
if last_status in ("completed", "failed", "not_found"):
|
|
1120
1148
|
return item
|
|
1121
1149
|
if time.monotonic() >= deadline:
|
|
@@ -1217,6 +1245,35 @@ def cmd_auth_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1217
1245
|
)
|
|
1218
1246
|
|
|
1219
1247
|
|
|
1248
|
+
def _callback_port(args: argparse.Namespace) -> int:
|
|
1249
|
+
"""The port to bind the login callback to; ``0`` means "any free port".
|
|
1250
|
+
|
|
1251
|
+
Any free port is the default because it lets two logins run at once — one fixed port meant
|
|
1252
|
+
the second could not bind. Pinning exists for the case where the port must be known before
|
|
1253
|
+
the login starts, which an ``ssh -L`` tunnel to a remote dev box needs: the tunnel has to be
|
|
1254
|
+
up first, so the port cannot be chosen after.
|
|
1255
|
+
"""
|
|
1256
|
+
explicit = getattr(args, "callback_port", None)
|
|
1257
|
+
if explicit is None:
|
|
1258
|
+
from_env = os.environ.get(ENV_CALLBACK_PORT, "").strip()
|
|
1259
|
+
if not from_env:
|
|
1260
|
+
return _login.EPHEMERAL_CALLBACK_PORT
|
|
1261
|
+
try:
|
|
1262
|
+
explicit = int(from_env)
|
|
1263
|
+
except ValueError:
|
|
1264
|
+
# Bounded: this value is echoed back in a JSON error field and comes from the
|
|
1265
|
+
# environment, so it is not something to reproduce in full.
|
|
1266
|
+
shown = from_env if len(from_env) <= 32 else from_env[:32] + "…"
|
|
1267
|
+
raise UsageError(f"${ENV_CALLBACK_PORT} must be a port number, got {shown!r}") from None
|
|
1268
|
+
port = int(explicit)
|
|
1269
|
+
# Below 1024 is refused rather than merely unbindable: the Console rejects such a callback
|
|
1270
|
+
# too (WHATWG URL elides :80 entirely, so it never arrives as a port at all), and the login
|
|
1271
|
+
# would fail later with nothing pointing at the cause. 0 stays legal — it means ephemeral.
|
|
1272
|
+
if port != _login.EPHEMERAL_CALLBACK_PORT and not MIN_CALLBACK_PORT <= port <= 65535:
|
|
1273
|
+
raise UsageError(f"callback port must be 0 or between {MIN_CALLBACK_PORT} and 65535, got {port}")
|
|
1274
|
+
return port
|
|
1275
|
+
|
|
1276
|
+
|
|
1220
1277
|
def _login_timeout(args: argparse.Namespace, *, default: float) -> float:
|
|
1221
1278
|
"""The ``--timeout`` value, or the per-path default when the flag was not passed.
|
|
1222
1279
|
|
|
@@ -1409,6 +1466,7 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1409
1466
|
),
|
|
1410
1467
|
EXIT_HTTP_ERROR,
|
|
1411
1468
|
)
|
|
1469
|
+
|
|
1412
1470
|
explicit_api = _explicit_api_url(args)
|
|
1413
1471
|
rc_origin = rc.api_url.strip() if rc is not None and rc.api_url.strip() else None
|
|
1414
1472
|
api_origin = explicit_api or rc_origin or DEFAULT_API_URL
|
|
@@ -1606,7 +1664,7 @@ def _cmd_auth_login_email(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1606
1664
|
detail = getattr(exc, "strerror", None) or type(exc).__name__
|
|
1607
1665
|
return (
|
|
1608
1666
|
make_error(
|
|
1609
|
-
f
|
|
1667
|
+
f'could not write {RC_FILENAME} ({detail}). The minted key "{result.key_name}" '
|
|
1610
1668
|
f"(prefix {result.key_prefix}…) is active — revoke it in the Console if it should not stay.",
|
|
1611
1669
|
stage="login",
|
|
1612
1670
|
),
|
|
@@ -1668,6 +1726,7 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1668
1726
|
verifier=verifier,
|
|
1669
1727
|
on_url=on_url,
|
|
1670
1728
|
timeout_s=_login_timeout(args, default=300.0),
|
|
1729
|
+
callback_port=_callback_port(args),
|
|
1671
1730
|
interactive=interactive,
|
|
1672
1731
|
notify=_eprint,
|
|
1673
1732
|
)
|
|
@@ -2553,6 +2612,16 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2553
2612
|
add_help=False,
|
|
2554
2613
|
)
|
|
2555
2614
|
pal.add_argument("--no-browser", action="store_true", help="Print the URL only; do not open a browser")
|
|
2615
|
+
pal.add_argument(
|
|
2616
|
+
"--callback-port",
|
|
2617
|
+
type=int,
|
|
2618
|
+
default=None,
|
|
2619
|
+
help=(
|
|
2620
|
+
"Bind the login callback to this port instead of any free one "
|
|
2621
|
+
f"(or ${ENV_CALLBACK_PORT}). Use when the port must be known in advance, e.g. an "
|
|
2622
|
+
"ssh -L tunnel for remote development."
|
|
2623
|
+
),
|
|
2624
|
+
)
|
|
2556
2625
|
pal.add_argument(
|
|
2557
2626
|
"--timeout",
|
|
2558
2627
|
type=float,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|