xmemcli 0.0.9__tar.gz → 0.0.11__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.9/README.md → xmemcli-0.0.11/PKG-INFO +17 -8
- xmemcli-0.0.9/PKG-INFO → xmemcli-0.0.11/README.md +9 -16
- {xmemcli-0.0.9 → xmemcli-0.0.11}/pyproject.toml +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.11}/uv.lock +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/__init__.py +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_context_cmd.py +150 -5
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_login.py +236 -20
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_output.py +12 -1
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_pretty.py +12 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_version.py +1 -1
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/cli.py +52 -1
- {xmemcli-0.0.9 → xmemcli-0.0.11}/.gitignore +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_binding.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_binding_cmd.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_email_login.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_envelope.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_help.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_instance_schema.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_mcp_bridge.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_rc.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_text.py +0 -0
- {xmemcli-0.0.9 → xmemcli-0.0.11}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: xmemcli
|
|
3
|
+
Version: 0.0.11
|
|
4
|
+
Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
|
|
5
|
+
Keywords: api,cli,llm,memory,xmemory
|
|
6
|
+
Requires-Python: <4,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
1
9
|
# `xmemcli` — xmemory command-line client
|
|
2
10
|
|
|
3
11
|
Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
|
|
@@ -7,7 +15,7 @@ Full command reference is included below.
|
|
|
7
15
|
## Usage
|
|
8
16
|
|
|
9
17
|
```text
|
|
10
|
-
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]]
|
|
11
19
|
xmemcli auth logout
|
|
12
20
|
xmemcli auth status
|
|
13
21
|
xmemcli read <query>
|
|
@@ -89,7 +97,7 @@ xmemcli version
|
|
|
89
97
|
xmemcli auth login
|
|
90
98
|
```
|
|
91
99
|
|
|
92
|
-
Version: **0.0.
|
|
100
|
+
Version: **0.0.11**.
|
|
93
101
|
|
|
94
102
|
|
|
95
103
|
---
|
|
@@ -100,7 +108,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
|
|
|
100
108
|
|
|
101
109
|
Two acquisition paths share `xmemcli auth login`:
|
|
102
110
|
|
|
103
|
-
- **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.
|
|
104
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.
|
|
105
113
|
|
|
106
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).
|
|
@@ -121,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
121
129
|
|
|
122
130
|
1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
|
|
123
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`**.
|
|
124
|
-
3. Browser
|
|
132
|
+
3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
|
|
125
133
|
4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
|
|
126
134
|
|
|
127
135
|
### Sequence
|
|
@@ -129,7 +137,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
129
137
|
```
|
|
130
138
|
AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
|
|
131
139
|
Human ── opens Console URL ──► creates key → "Use this key in the CLI"
|
|
132
|
-
CLI ◄──
|
|
140
|
+
CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
|
|
133
141
|
AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
|
|
134
142
|
```
|
|
135
143
|
|
|
@@ -173,7 +181,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
|
|
|
173
181
|
| 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
182
|
| 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
183
|
|
|
176
|
-
|
|
184
|
+
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
185
|
|
|
178
186
|
## Quota (observe vs escalate)
|
|
179
187
|
|
|
@@ -183,7 +191,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
|
|
|
183
191
|
|
|
184
192
|
# `xmemcli` — capabilities reference
|
|
185
193
|
|
|
186
|
-
**Version:** `0.0.
|
|
194
|
+
**Version:** `0.0.11` (see `xmemcli version`)
|
|
187
195
|
|
|
188
196
|
`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
197
|
|
|
@@ -402,7 +410,7 @@ so a typo fails loudly rather than being ignored.
|
|
|
402
410
|
|
|
403
411
|
| Command | Description |
|
|
404
412
|
|---------|-------------|
|
|
405
|
-
| **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url
|
|
413
|
+
| **`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
414
|
| **`auth logout`** | Remove active `.xmemrc.json` |
|
|
407
415
|
| **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
|
|
408
416
|
| **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
|
|
@@ -586,6 +594,7 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
586
594
|
| `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
|
|
587
595
|
| `--api-key` | `XMEM_API_KEY` | Bearer API key |
|
|
588
596
|
| `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
|
|
597
|
+
|
|
589
598
|
| `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
|
|
590
599
|
| `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
|
|
591
600
|
| `--json` | — | Force one JSON document on stdout |
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: xmemcli
|
|
3
|
-
Version: 0.0.9
|
|
4
|
-
Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
|
|
5
|
-
Keywords: api,cli,llm,memory,xmemory
|
|
6
|
-
Requires-Python: <4,>=3.12
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
|
|
9
1
|
# `xmemcli` — xmemory command-line client
|
|
10
2
|
|
|
11
3
|
Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
|
|
@@ -15,7 +7,7 @@ Full command reference is included below.
|
|
|
15
7
|
## Usage
|
|
16
8
|
|
|
17
9
|
```text
|
|
18
|
-
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]]
|
|
19
11
|
xmemcli auth logout
|
|
20
12
|
xmemcli auth status
|
|
21
13
|
xmemcli read <query>
|
|
@@ -97,7 +89,7 @@ xmemcli version
|
|
|
97
89
|
xmemcli auth login
|
|
98
90
|
```
|
|
99
91
|
|
|
100
|
-
Version: **0.0.
|
|
92
|
+
Version: **0.0.11**.
|
|
101
93
|
|
|
102
94
|
|
|
103
95
|
---
|
|
@@ -108,7 +100,7 @@ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run re
|
|
|
108
100
|
|
|
109
101
|
Two acquisition paths share `xmemcli auth login`:
|
|
110
102
|
|
|
111
|
-
- **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.
|
|
112
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.
|
|
113
105
|
|
|
114
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).
|
|
@@ -129,7 +121,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
129
121
|
|
|
130
122
|
1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
|
|
131
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`**.
|
|
132
|
-
3. Browser
|
|
124
|
+
3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
|
|
133
125
|
4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
|
|
134
126
|
|
|
135
127
|
### Sequence
|
|
@@ -137,7 +129,7 @@ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed
|
|
|
137
129
|
```
|
|
138
130
|
AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
|
|
139
131
|
Human ── opens Console URL ──► creates key → "Use this key in the CLI"
|
|
140
|
-
CLI ◄──
|
|
132
|
+
CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
|
|
141
133
|
AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
|
|
142
134
|
```
|
|
143
135
|
|
|
@@ -181,7 +173,7 @@ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `-
|
|
|
181
173
|
| 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
174
|
| 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
175
|
|
|
184
|
-
|
|
176
|
+
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
177
|
|
|
186
178
|
## Quota (observe vs escalate)
|
|
187
179
|
|
|
@@ -191,7 +183,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
|
|
|
191
183
|
|
|
192
184
|
# `xmemcli` — capabilities reference
|
|
193
185
|
|
|
194
|
-
**Version:** `0.0.
|
|
186
|
+
**Version:** `0.0.11` (see `xmemcli version`)
|
|
195
187
|
|
|
196
188
|
`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
189
|
|
|
@@ -410,7 +402,7 @@ so a typo fails loudly rather than being ignored.
|
|
|
410
402
|
|
|
411
403
|
| Command | Description |
|
|
412
404
|
|---------|-------------|
|
|
413
|
-
| **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--no-browser`, `--timeout`, `--console-url
|
|
405
|
+
| **`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
406
|
| **`auth logout`** | Remove active `.xmemrc.json` |
|
|
415
407
|
| **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix. |
|
|
416
408
|
| **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
|
|
@@ -594,6 +586,7 @@ These apply **before** any subcommand (`xmemcli help options`).
|
|
|
594
586
|
| `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
|
|
595
587
|
| `--api-key` | `XMEM_API_KEY` | Bearer API key |
|
|
596
588
|
| `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
|
|
589
|
+
|
|
597
590
|
| `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
|
|
598
591
|
| `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
|
|
599
592
|
| `--json` | — | Force one JSON document on stdout |
|
|
@@ -94,6 +94,12 @@ _CONSUMER_HOOK_OUTPUT_LIMIT = 10_000
|
|
|
94
94
|
#: just fits over the edge.
|
|
95
95
|
MAX_PACK_CHARS = _CONSUMER_HOOK_OUTPUT_LIMIT - 500
|
|
96
96
|
|
|
97
|
+
#: Sections the server renders per pack (identity, owner, rules, schema, live state).
|
|
98
|
+
#: The footprint is a diagnostic, so a server sending more is not describing a pack this
|
|
99
|
+
#: client received; publishing them unbounded would let a small response build a large
|
|
100
|
+
#: document. Mirrors the renderer's section list rather than any transport limit.
|
|
101
|
+
_MAX_SECTIONS_PER_PACK = 5
|
|
102
|
+
|
|
97
103
|
|
|
98
104
|
def _requestable_ids(autoload: tuple[Any, ...], listed: AbstractSet[str]) -> tuple[list[str], list[str]]:
|
|
99
105
|
"""Ids the server will accept, and the ones dropped with a reason.
|
|
@@ -456,9 +462,94 @@ def _curated_unavailable(entry: Any) -> dict[str, Any]:
|
|
|
456
462
|
return curated
|
|
457
463
|
|
|
458
464
|
|
|
465
|
+
def _curated_pack(entry: Any) -> dict[str, Any]:
|
|
466
|
+
"""One pack's footprint: what it cost, whether it was cut, and where the cost sits.
|
|
467
|
+
|
|
468
|
+
Sizes and identity only. The pack's own text is already in ``text``, and republishing
|
|
469
|
+
section bodies here would hand a caller a second copy of content that the clamp and
|
|
470
|
+
scrub below bound exactly once.
|
|
471
|
+
|
|
472
|
+
Section titles are the server's own headings rather than anything an owner wrote, but
|
|
473
|
+
they arrive over the wire like everything else here, so they are scrubbed and length-
|
|
474
|
+
bounded. Not the UTF-16 clamp the pack text also gets — a title is capped at 200 code
|
|
475
|
+
points, so it cannot reach the limit that clamp exists for. A count that is not a
|
|
476
|
+
number is published as ``None``: a caller doing arithmetic on it should see a gap, not
|
|
477
|
+
a zero it cannot distinguish from a section that genuinely cost nothing.
|
|
478
|
+
"""
|
|
479
|
+
curated: dict[str, Any] = {
|
|
480
|
+
"instance_id": scrub(entry["instance_id"], limit=200) if isinstance(entry.get("instance_id"), str) else None,
|
|
481
|
+
"estimated_tokens": _count(entry.get("estimated_tokens")),
|
|
482
|
+
"truncated": entry["truncated"] if isinstance(entry.get("truncated"), bool) else None,
|
|
483
|
+
}
|
|
484
|
+
raw_sections = entry.get("sections")
|
|
485
|
+
# Filtered before the cap, not after: slicing first let junk consume the slots, so a
|
|
486
|
+
# response whose first five entries were nulls published none of the real ones.
|
|
487
|
+
sections = [s for s in raw_sections if isinstance(s, dict)] if isinstance(raw_sections, list) else []
|
|
488
|
+
# Bounded as well as filtered. The server renders at most five sections per pack, so
|
|
489
|
+
# anything beyond that is not a breakdown of anything, and an unbounded array on a
|
|
490
|
+
# payload whose text is hard-capped is a way for a small response to build a large
|
|
491
|
+
# document. Not the only one — ``unavailable`` above is still uncapped — but the cap
|
|
492
|
+
# is cheap where the shape is known.
|
|
493
|
+
curated["sections"] = [
|
|
494
|
+
{
|
|
495
|
+
"title": scrub(section["title"], limit=200) if isinstance(section.get("title"), str) else None,
|
|
496
|
+
"estimated_tokens": _count(section.get("estimated_tokens")),
|
|
497
|
+
}
|
|
498
|
+
for section in sections[:_MAX_SECTIONS_PER_PACK]
|
|
499
|
+
]
|
|
500
|
+
return curated
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _curated_universal_rules(entry: Any) -> dict[str, Any] | None:
|
|
504
|
+
"""The response-level rules block's footprint, or ``None`` when the server sent none.
|
|
505
|
+
|
|
506
|
+
Published for the same reason the per-pack sections are: this is the one part of the response
|
|
507
|
+
that belongs to no pack, so a caller summing ``packs`` to account for ``estimated_tokens``
|
|
508
|
+
comes up short by exactly it. It used to be inside every pack and is now charged once, which
|
|
509
|
+
is a change someone reading the breakdown to explain a budget needs to see.
|
|
510
|
+
|
|
511
|
+
Title and size only, like ``_curated_pack`` — the body is already in ``text``.
|
|
512
|
+
"""
|
|
513
|
+
if not isinstance(entry, dict):
|
|
514
|
+
return None
|
|
515
|
+
return {
|
|
516
|
+
"title": scrub(entry["title"], limit=200) if isinstance(entry.get("title"), str) else None,
|
|
517
|
+
"estimated_tokens": _count(entry.get("estimated_tokens")),
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _count(value: Any) -> int | None:
|
|
522
|
+
"""A published token count, or ``None`` when the server sent something that is not one.
|
|
523
|
+
|
|
524
|
+
``bool`` is excluded explicitly because it is a subclass of ``int``: without that,
|
|
525
|
+
``"estimated_tokens": true`` published ``true`` and a caller summing the sections
|
|
526
|
+
added one, which is the arithmetic the ``None`` exists to keep honest. Spelled the
|
|
527
|
+
same way in ``_pretty.py`` and ``xmem.py``; ``_binding.py`` reaches the same result
|
|
528
|
+
through the negated form. The idiom appears at several sites in ``cli/`` and each
|
|
529
|
+
checks something different afterwards (``> 0``, ``>= 0``, ``< 1``), which is why this
|
|
530
|
+
stays local rather than becoming a shared helper.
|
|
531
|
+
"""
|
|
532
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
533
|
+
|
|
534
|
+
|
|
459
535
|
def _nothing_to_inject() -> dict[str, Any]:
|
|
460
|
-
"""The payload for "there is no context to add", used by every such path.
|
|
461
|
-
|
|
536
|
+
"""The payload for "there is no context to add", used by every such path.
|
|
537
|
+
|
|
538
|
+
``max_tokens`` is ``None`` rather than absent: the success path always carries it,
|
|
539
|
+
and a caller told the shape is stable should not have to test for the key before
|
|
540
|
+
reading it on the one branch that omitted it.
|
|
541
|
+
"""
|
|
542
|
+
return {
|
|
543
|
+
"text": "",
|
|
544
|
+
"instance_ids": [],
|
|
545
|
+
"unavailable": [],
|
|
546
|
+
"estimated_tokens": 0,
|
|
547
|
+
"max_tokens": None,
|
|
548
|
+
"packs": [],
|
|
549
|
+
# Present and null for the same reason ``max_tokens`` is: the success path always carries
|
|
550
|
+
# the key, and a caller told the shape is stable should not have to test for it.
|
|
551
|
+
"universal_rules": None,
|
|
552
|
+
}
|
|
462
553
|
|
|
463
554
|
|
|
464
555
|
def _command_budget(args: argparse.Namespace) -> float:
|
|
@@ -743,6 +834,23 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
|
|
|
743
834
|
# `diff_plan`, anything an internal renderer attaches) cannot arrive here and be
|
|
744
835
|
# published; it is dropped until someone decides it belongs.
|
|
745
836
|
unavailable = [_curated_unavailable(entry) for entry in unavailable]
|
|
837
|
+
# Shape-checked before iteration for the same reason `items` and `unavailable` above
|
|
838
|
+
# are: `result.get("packs") or []` keeps whatever truthy value arrived, so
|
|
839
|
+
# `{"packs": 7}` raised `TypeError` out of the comprehension and the blanket guard
|
|
840
|
+
# turned a perfectly good, already-clamped pack into an empty payload — the whole
|
|
841
|
+
# session's context discarded because an optional diagnostic field had the wrong
|
|
842
|
+
# type. A dict or a string would not even raise: they iterate, and publish phantom
|
|
843
|
+
# entries. The footprint is a diagnostic, so a bad one costs itself and nothing else.
|
|
844
|
+
raw_packs = result.get("packs")
|
|
845
|
+
if raw_packs is not None and not isinstance(raw_packs, list):
|
|
846
|
+
m._note_cli_warning(args, "xmemory returned an unreadable context footprint; sizes omitted")
|
|
847
|
+
raw_packs = []
|
|
848
|
+
# Non-dict elements are skipped rather than curated into all-null entries: a
|
|
849
|
+
# phantom pack is the outcome the guard above warns about, one level down, and a
|
|
850
|
+
# caller cannot tell it from an instance that genuinely reported nothing. Capped at
|
|
851
|
+
# the number of instances the server will accept in one request, for the same reason
|
|
852
|
+
# the sections are capped.
|
|
853
|
+
packs = [_curated_pack(p) for p in [p for p in (raw_packs or []) if isinstance(p, dict)][:MAX_REQUESTED_INSTANCES]]
|
|
746
854
|
delivered = [i for i in requested if i not in missing]
|
|
747
855
|
if unavailable:
|
|
748
856
|
m._note_cli_warning(
|
|
@@ -769,7 +877,30 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
|
|
|
769
877
|
# passes the first at 9,500 and measures 19,000 to the second, and Claude Code
|
|
770
878
|
# writes it to a file and injects a preview instead of the context. Bounding the
|
|
771
879
|
# unit the consumer actually counts is what the cap was for.
|
|
772
|
-
|
|
880
|
+
# Sanitised first, with a limit that cannot bite, so the two jobs `scrub` performs are
|
|
881
|
+
# separated: it strips Cc/Cf/Cs characters *and* clamps. Measuring the final text
|
|
882
|
+
# against the raw server text conflated them — a fully delivered pack carrying one
|
|
883
|
+
# zero-width joiner, soft hyphen, BOM or bidi mark came out shorter and was reported as
|
|
884
|
+
# cut, which is a false alarm aimed at exactly the reader who asked why their context
|
|
885
|
+
# shrank. Only a length cut is a length cut.
|
|
886
|
+
sanitised = scrub(text, limit=len(text))
|
|
887
|
+
injected = clamp_utf16(scrub(sanitised, limit=MAX_PACK_CHARS), limit=MAX_PACK_CHARS)
|
|
888
|
+
# The footprint below describes what the *server* rendered. When this clamp bites, part
|
|
889
|
+
# of that never reaches the model, and a breakdown still reporting the full sizes with
|
|
890
|
+
# `truncated: false` misleads precisely the reader it exists for — someone asking why
|
|
891
|
+
# their live state disappeared, being shown a section that advertises 4,900 tokens and
|
|
892
|
+
# arrived as nothing. So a client-side cut marks every pack too. The flag means "what
|
|
893
|
+
# you are reading is not the whole of it", whichever side did the cutting; the warning
|
|
894
|
+
# names which side, because the fixes are different — retier a binding, or lower
|
|
895
|
+
# `pack_max_tokens`.
|
|
896
|
+
if len(injected) < len(sanitised):
|
|
897
|
+
m._note_cli_warning(
|
|
898
|
+
args,
|
|
899
|
+
"the rendered context was longer than this client can inject and was cut; "
|
|
900
|
+
"the sizes reported describe what xmemory rendered, not what was injected",
|
|
901
|
+
)
|
|
902
|
+
packs = [{**pack, "truncated": True} for pack in packs]
|
|
903
|
+
text = injected
|
|
773
904
|
return (
|
|
774
905
|
CliOutput(
|
|
775
906
|
OutputKind.AGENT_CONTEXT,
|
|
@@ -777,8 +908,22 @@ def _context(args: argparse.Namespace, deadline: float) -> tuple[CliOutput, int]
|
|
|
777
908
|
"text": text,
|
|
778
909
|
"instance_ids": delivered,
|
|
779
910
|
"unavailable": unavailable,
|
|
780
|
-
|
|
781
|
-
|
|
911
|
+
# Through the same guard as the parts: publishing `true` as a total while
|
|
912
|
+
# the sections it explains correctly read null is the inconsistency the
|
|
913
|
+
# breakdown exists to avoid.
|
|
914
|
+
"estimated_tokens": _count(result.get("estimated_tokens")) or 0,
|
|
915
|
+
"max_tokens": _count(result.get("max_tokens")),
|
|
916
|
+
# Where the total sits, per instance and per section. The total alone says a
|
|
917
|
+
# pack was expensive or was cut; only the breakdown says which instance spent
|
|
918
|
+
# the budget and what a shrink took, which is what someone asking "why is my
|
|
919
|
+
# context so big" or "why did my live state disappear" is actually asking.
|
|
920
|
+
# Curated the way `unavailable` is, so a section body cannot arrive by this
|
|
921
|
+
# route and bypass the clamp the pack text goes through.
|
|
922
|
+
"packs": packs,
|
|
923
|
+
# The part of the total that belongs to no pack: the operating rules, carried once
|
|
924
|
+
# for the response rather than repeated in each pack. Without it the breakdown
|
|
925
|
+
# cannot account for its own total.
|
|
926
|
+
"universal_rules": _curated_universal_rules(result.get("universal_rules")),
|
|
782
927
|
},
|
|
783
928
|
),
|
|
784
929
|
m.EXIT_OK,
|
|
@@ -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:
|
|
@@ -139,7 +139,18 @@ class OutputKind(StrEnum):
|
|
|
139
139
|
|
|
140
140
|
#: Output of ``xmemcli context`` — the session-start pack for the autoload
|
|
141
141
|
#: bindings in this directory. Curated payload: ``{text, instance_ids,
|
|
142
|
-
#: unavailable, estimated_tokens, max_tokens}``. ``text`` is injected
|
|
142
|
+
#: unavailable, estimated_tokens, max_tokens, packs, universal_rules}``. ``text`` is injected
|
|
143
|
+
#: verbatim; ``packs`` is the footprint breakdown — per instance, and per
|
|
144
|
+
#: section within it — carrying sizes and titles but no section bodies.
|
|
145
|
+
#: The breakdown accounts for slightly less than ``estimated_tokens``, in both
|
|
146
|
+
#: directions: each pack's heading, the blank lines between packs and the note
|
|
147
|
+
#: prepended when a project binding introduced instances are injected text
|
|
148
|
+
#: charged to no section, and a pack too long for this client is cut after the
|
|
149
|
+
#: sizes are read — every ``truncated`` is then ``true`` and a warning says so.
|
|
150
|
+
#: Present the parts as where the budget went, not as an exact decomposition.
|
|
151
|
+
#: ``max_tokens`` is ``null`` whenever no pack was delivered — which includes
|
|
152
|
+
#: every failure path, so read ``warnings`` for why rather than inferring an
|
|
153
|
+
#: empty binding.
|
|
143
154
|
AGENT_CONTEXT = "agent_context"
|
|
144
155
|
|
|
145
156
|
#: Output of ``xmemcli org list instances`` (and ``instance list`` alias).
|
|
@@ -114,6 +114,18 @@ def _render_read_structured(payload: Any, stream: IO[str]) -> None:
|
|
|
114
114
|
The curated payload IS the rows / xresponse the operator explicitly
|
|
115
115
|
asked for; dump it as JSON.
|
|
116
116
|
"""
|
|
117
|
+
if isinstance(payload, dict) and payload.get("reader_result", ...) is None:
|
|
118
|
+
stream.write("No rows.\n")
|
|
119
|
+
pending = payload.get("pending_suggestions")
|
|
120
|
+
if isinstance(pending, int) and not isinstance(pending, bool) and pending > 0:
|
|
121
|
+
stream.write(f"({pending} pending schema suggestion(s) — {CLI_NAME} schema suggestions review)\n")
|
|
122
|
+
trace_id = payload.get("trace_id")
|
|
123
|
+
if isinstance(trace_id, str) and trace_id.strip():
|
|
124
|
+
stream.write(f" trace: {_terminal_safe(trace_id.strip())}\n")
|
|
125
|
+
console_url = payload.get("console_url")
|
|
126
|
+
if isinstance(console_url, str) and console_url.strip():
|
|
127
|
+
stream.write(f" console: {_terminal_safe(console_url.strip())}\n")
|
|
128
|
+
return
|
|
117
129
|
json.dump(payload, stream, indent=2, default=str)
|
|
118
130
|
stream.write("\n")
|
|
119
131
|
|
|
@@ -149,6 +149,12 @@ ENV_INSTANCE_ID = "XMEM_INSTANCE_ID"
|
|
|
149
149
|
ENV_API_URL = "XMEM_API_URL"
|
|
150
150
|
ENV_CLUSTER_ID = "XMEM_CLUSTER_ID"
|
|
151
151
|
ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
|
|
152
|
+
#: Pin the loopback callback port. Unset means any free port, which is what lets concurrent
|
|
153
|
+
#: logins coexist; pinning is for when the port has to be known before the login starts.
|
|
154
|
+
ENV_CALLBACK_PORT = "XMEM_CALLBACK_PORT"
|
|
155
|
+
#: Ports below this need privileges the CLI never takes, and the Console refuses them too.
|
|
156
|
+
#: Must match cli_handoff.js CLI_MIN_CALLBACK_PORT.
|
|
157
|
+
MIN_CALLBACK_PORT = 1024
|
|
152
158
|
DEFAULT_API_URL = "https://api.xmemory.ai"
|
|
153
159
|
|
|
154
160
|
#: Where MCP connections go. A separate origin from the API rather than a path on it,
|
|
@@ -776,7 +782,12 @@ def _curate_read(item: dict[str, Any], *, read_mode: str, verbose: bool) -> dict
|
|
|
776
782
|
rr = item.get("reader_result")
|
|
777
783
|
rr_dict = rr if isinstance(rr, dict) else {}
|
|
778
784
|
curated: dict[str, Any]
|
|
779
|
-
if
|
|
785
|
+
if "reader_result" in item and rr is None:
|
|
786
|
+
# Preserve the server's explicit no-result signal. Converting None to
|
|
787
|
+
# an empty dict makes a zero-row read indistinguishable from an unknown
|
|
788
|
+
# or malformed ReaderResult shape.
|
|
789
|
+
curated = {"reader_result": None}
|
|
790
|
+
elif read_mode == "single":
|
|
780
791
|
answer = _extract_single_answer(rr_dict)
|
|
781
792
|
# No extractable answer string → emit the reader_result subtree so
|
|
782
793
|
# the user still sees something truthful rather than `answer: null`.
|
|
@@ -1212,6 +1223,35 @@ def cmd_auth_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1212
1223
|
)
|
|
1213
1224
|
|
|
1214
1225
|
|
|
1226
|
+
def _callback_port(args: argparse.Namespace) -> int:
|
|
1227
|
+
"""The port to bind the login callback to; ``0`` means "any free port".
|
|
1228
|
+
|
|
1229
|
+
Any free port is the default because it lets two logins run at once — one fixed port meant
|
|
1230
|
+
the second could not bind. Pinning exists for the case where the port must be known before
|
|
1231
|
+
the login starts, which an ``ssh -L`` tunnel to a remote dev box needs: the tunnel has to be
|
|
1232
|
+
up first, so the port cannot be chosen after.
|
|
1233
|
+
"""
|
|
1234
|
+
explicit = getattr(args, "callback_port", None)
|
|
1235
|
+
if explicit is None:
|
|
1236
|
+
from_env = os.environ.get(ENV_CALLBACK_PORT, "").strip()
|
|
1237
|
+
if not from_env:
|
|
1238
|
+
return _login.EPHEMERAL_CALLBACK_PORT
|
|
1239
|
+
try:
|
|
1240
|
+
explicit = int(from_env)
|
|
1241
|
+
except ValueError:
|
|
1242
|
+
# Bounded: this value is echoed back in a JSON error field and comes from the
|
|
1243
|
+
# environment, so it is not something to reproduce in full.
|
|
1244
|
+
shown = from_env if len(from_env) <= 32 else from_env[:32] + "…"
|
|
1245
|
+
raise UsageError(f"${ENV_CALLBACK_PORT} must be a port number, got {shown!r}") from None
|
|
1246
|
+
port = int(explicit)
|
|
1247
|
+
# Below 1024 is refused rather than merely unbindable: the Console rejects such a callback
|
|
1248
|
+
# too (WHATWG URL elides :80 entirely, so it never arrives as a port at all), and the login
|
|
1249
|
+
# would fail later with nothing pointing at the cause. 0 stays legal — it means ephemeral.
|
|
1250
|
+
if port != _login.EPHEMERAL_CALLBACK_PORT and not MIN_CALLBACK_PORT <= port <= 65535:
|
|
1251
|
+
raise UsageError(f"callback port must be 0 or between {MIN_CALLBACK_PORT} and 65535, got {port}")
|
|
1252
|
+
return port
|
|
1253
|
+
|
|
1254
|
+
|
|
1215
1255
|
def _login_timeout(args: argparse.Namespace, *, default: float) -> float:
|
|
1216
1256
|
"""The ``--timeout`` value, or the per-path default when the flag was not passed.
|
|
1217
1257
|
|
|
@@ -1663,6 +1703,7 @@ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
|
1663
1703
|
verifier=verifier,
|
|
1664
1704
|
on_url=on_url,
|
|
1665
1705
|
timeout_s=_login_timeout(args, default=300.0),
|
|
1706
|
+
callback_port=_callback_port(args),
|
|
1666
1707
|
interactive=interactive,
|
|
1667
1708
|
notify=_eprint,
|
|
1668
1709
|
)
|
|
@@ -2548,6 +2589,16 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
2548
2589
|
add_help=False,
|
|
2549
2590
|
)
|
|
2550
2591
|
pal.add_argument("--no-browser", action="store_true", help="Print the URL only; do not open a browser")
|
|
2592
|
+
pal.add_argument(
|
|
2593
|
+
"--callback-port",
|
|
2594
|
+
type=int,
|
|
2595
|
+
default=None,
|
|
2596
|
+
help=(
|
|
2597
|
+
"Bind the login callback to this port instead of any free one "
|
|
2598
|
+
f"(or ${ENV_CALLBACK_PORT}). Use when the port must be known in advance, e.g. an "
|
|
2599
|
+
"ssh -L tunnel for remote development."
|
|
2600
|
+
),
|
|
2601
|
+
)
|
|
2551
2602
|
pal.add_argument(
|
|
2552
2603
|
"--timeout",
|
|
2553
2604
|
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
|