@sandgarden/miranda 0.1.90 → 0.1.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/collector/stub.sh
CHANGED
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
# now downloads the same release from "sandgardenhq/plugins" with the
|
|
45
45
|
# "miranda-collector" prefix (#813). They stay placeholders rather than
|
|
46
46
|
# literals so the release host can move without editing this file.
|
|
47
|
-
BUILD_VERSION="
|
|
48
|
-
RELEASE_TAG="collector-
|
|
47
|
+
BUILD_VERSION="4d72cbf60ffe"
|
|
48
|
+
RELEASE_TAG="collector-4d72cbf60ffe"
|
|
49
49
|
RELEASE_REPO="sandgardenhq/plugins"
|
|
50
50
|
ASSET_PREFIX="miranda-collector"
|
|
51
|
-
CHECKSUM_DARWIN_ARM64="
|
|
52
|
-
CHECKSUM_DARWIN_X64="
|
|
53
|
-
CHECKSUM_LINUX_X64="
|
|
54
|
-
CHECKSUM_LINUX_ARM64="
|
|
55
|
-
CHECKSUM_WINDOWS_X64="
|
|
51
|
+
CHECKSUM_DARWIN_ARM64="cebbd6ce3940e7ce8cb82bb4feb32973b26b6640025c047d31310f4b8f25f824"
|
|
52
|
+
CHECKSUM_DARWIN_X64="8c29c85669b9f54ba61b48bc018d997389775bf96aba32506da4751289de44bb"
|
|
53
|
+
CHECKSUM_LINUX_X64="55a9b493a25dc2a18db4a2d7eef1665f9a1fdf97bf27f7c81618a5f1c8a345a1"
|
|
54
|
+
CHECKSUM_LINUX_ARM64="5b5a0a5dfcb184b87a956d57652a551b9b0a2c3632c7b288e9ca090398b59eb5"
|
|
55
|
+
CHECKSUM_WINDOWS_X64="29a72add21729a071faf51b56db60863221dd49a8141fbd95c95366708951811"
|
|
56
56
|
|
|
57
57
|
# A download lock older than this is a downloader that died mid-run: take it
|
|
58
58
|
# over (mirrors the collector's sweep-lock staleness cutoff).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setting-up-usage-tracking
|
|
3
|
-
description: Use when installing the Miranda plugin into a repo ("set up token tracking", "enable usage tracking", "install miranda"), when a repo is missing .miranda/USING-MIRANDA.md or the miranda section in CLAUDE.md/AGENTS.md, or after a Miranda plugin update to refresh a stale doc — copies the canonical USING-MIRANDA.md agent doc into the repo, wires an evergreen Miranda section into the agent instruction files (with the user's permission), enrols this machine for usage collection, and registers the current repository as a Miranda project if it isn't one. Idempotent; safe to re-run.
|
|
3
|
+
description: Use when installing the Miranda plugin into a repo ("set up token tracking", "enable usage tracking", "install miranda"), when a repo is missing .miranda/USING-MIRANDA.md or the miranda section in CLAUDE.md/AGENTS.md, or after a Miranda plugin update to refresh a stale doc — copies the canonical USING-MIRANDA.md agent doc into the repo, wires an evergreen Miranda section into the agent instruction files (with the user's permission), enrols this machine for usage collection, writes Miranda's identifying headers into the config of any coding agent routed through an AI gateway, and registers the current repository as a Miranda project if it isn't one. Idempotent; safe to re-run.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Setting Up Usage Tracking
|
|
@@ -25,9 +25,15 @@ repository is a Miranda project — the collector attributes a session to a
|
|
|
25
25
|
project only when the session's `origin` remote matches one of a project's repositories, so an
|
|
26
26
|
unregistered repository's spend all lands in **Unassigned**.
|
|
27
27
|
|
|
28
|
+
If a coding agent here sends its requests through an AI gateway (LiteLLM,
|
|
29
|
+
Cloudflare AI Gateway, Bifrost, or an OTLP exporter), Miranda can also import
|
|
30
|
+
its usage from the gateway. For that, each request has to say which machine
|
|
31
|
+
and project it came from, so the skill writes those two values (the
|
|
32
|
+
**carrier**) into that agent's own config (step 6).
|
|
33
|
+
|
|
28
34
|
Never modify the user's files without showing them exactly what will change
|
|
29
35
|
and getting a yes first. Registering the repository writes no file, and is
|
|
30
|
-
done without asking (step
|
|
36
|
+
done without asking (step 7).
|
|
31
37
|
|
|
32
38
|
## What the collector reports
|
|
33
39
|
|
|
@@ -126,9 +132,32 @@ commitment the team has made, not a suggestion.
|
|
|
126
132
|
→ the deprecated `GLORIA_HOME` → `$XDG_CONFIG_HOME/sandgarden` →
|
|
127
133
|
`~/.config/sandgarden`). `daemon status` always exits 0, so "not
|
|
128
134
|
enrolled" is not an error. Treat this as extra detail for the report in
|
|
129
|
-
step
|
|
135
|
+
step 8, not as the enrolment test — the `config.json` check above is the
|
|
130
136
|
one that has to work on every machine.
|
|
131
137
|
|
|
138
|
+
- **Is any agent routed through an AI gateway?** Read only; change
|
|
139
|
+
nothing. Look for a base URL each agent is pointed at:
|
|
140
|
+
- Claude Code: `ANTHROPIC_BASE_URL` in the shell environment, or in the
|
|
141
|
+
`env` block of `~/.claude/settings.json`, `.claude/settings.json` or
|
|
142
|
+
`.claude/settings.local.json`.
|
|
143
|
+
- Codex: the top-level `model_provider` in `$CODEX_HOME/config.toml`
|
|
144
|
+
(default `~/.codex/config.toml`), naming a custom
|
|
145
|
+
`[model_providers.<id>]` block with a `base_url`.
|
|
146
|
+
- OpenCode: a provider with `options.baseURL` in the repo's
|
|
147
|
+
`opencode.json` or in `~/.config/opencode/opencode.json`.
|
|
148
|
+
|
|
149
|
+
For each base URL found, ask the script which gateway it is:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
node <skill-dir>/scripts/write-gateway-carriers.mjs gateway-for <base-url>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`<skill-dir>` is this skill's base directory. It prints
|
|
156
|
+
`{ "gateway": … }`: `cloudflare-ai-gateway` or `openrouter` when the host
|
|
157
|
+
says so, otherwise `null`, and then you ask (step 2). An agent with no
|
|
158
|
+
base URL talks to its provider directly and needs nothing here. Never
|
|
159
|
+
print an API key or token you see while reading these files.
|
|
160
|
+
|
|
132
161
|
### 2. Propose and ask permission — once
|
|
133
162
|
|
|
134
163
|
Tell the user precisely what will happen, in one message, and ask a single
|
|
@@ -140,10 +169,17 @@ yes/no. Cover only the actions actually needed, e.g.:
|
|
|
140
169
|
- create `AGENTS.md` containing the section, when neither instruction file
|
|
141
170
|
exists;
|
|
142
171
|
- enrol this machine for usage tracking, if it is not already, saying what
|
|
143
|
-
the collector reports (see "What the collector reports")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
172
|
+
the collector reports (see "What the collector reports");
|
|
173
|
+
- for each gateway-routed agent from step 1, write Miranda's carrier into
|
|
174
|
+
that agent's config file (step 6 names the file per agent). When the
|
|
175
|
+
gateway wasn't inferred, ask which one it is in the same message:
|
|
176
|
+
LiteLLM (`litellm`), Cloudflare AI Gateway (`cloudflare-ai-gateway`),
|
|
177
|
+
Bifrost (`bifrost`), an OTLP exporter (`otlp`), or OpenRouter
|
|
178
|
+
(`openrouter`). Miranda imports from no other gateway yet, so for any other
|
|
179
|
+
answer say so and leave that agent alone.
|
|
180
|
+
|
|
181
|
+
If everything is already current, say so and skip to step 7.
|
|
182
|
+
Registering the repository is not part of this yes/no — step 7 does it
|
|
147
183
|
without asking. If the user
|
|
148
184
|
declines the file edits, stop — do not partially apply.
|
|
149
185
|
|
|
@@ -199,8 +235,22 @@ the same `config.json` — never do two of them.
|
|
|
199
235
|
|
|
200
236
|
#### Fallback: minting the credential through MCP
|
|
201
237
|
|
|
202
|
-
1.
|
|
203
|
-
|
|
238
|
+
1. Read this machine's id first. It is the collector's own enrolment id, and
|
|
239
|
+
the command creates it, in the collector's format, if the machine has
|
|
240
|
+
none yet:
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
node <skill-dir>/scripts/write-gateway-carriers.mjs machine-id
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
It prints `{ "machineId": …, "source": …, "file": … }`. The id is not a
|
|
247
|
+
secret.
|
|
248
|
+
|
|
249
|
+
Call the `miranda` MCP tool **`enable_usage_tracking`**, passing that
|
|
250
|
+
`machineId` and always passing a `machineLabel` — never omit either.
|
|
251
|
+
`machineId` links this machine to you on the server, which is what lets
|
|
252
|
+
a gateway request carrying it count as yours even before the collector
|
|
253
|
+
reports anything. Clerk key names must be unique per org,
|
|
204
254
|
and omitting `machineLabel` falls back to a non-unique default name, so a
|
|
205
255
|
second machine (or a re-run after a partial failure) collides with an
|
|
206
256
|
already-minted key and the call fails with a Clerk `409 token_creation_conflict`.
|
|
@@ -210,6 +260,7 @@ the same `config.json` — never do two of them.
|
|
|
210
260
|
one, keep it unique the same way (append a short suffix) rather than
|
|
211
261
|
passing their label bare. It mints a write-only, org-scoped Clerk API key
|
|
212
262
|
and returns `{ apiBaseUrl, ingestToken }` plus write instructions.
|
|
263
|
+
|
|
213
264
|
2. Write `$XDG_CONFIG_HOME/sandgarden/config.json` — `~/.config/sandgarden/config.json`
|
|
214
265
|
when `XDG_CONFIG_HOME` is unset — directly from the tool result, merging
|
|
215
266
|
with any existing keys in the file. The directory is per-machine, not
|
|
@@ -279,9 +330,69 @@ this machine's local session stores whether or not any agent plugin fires, so
|
|
|
279
330
|
no further wiring is needed. See `USING-MIRANDA.md`'s "Codex, OpenCode, and
|
|
280
331
|
Cursor" section for per-agent notes.
|
|
281
332
|
|
|
282
|
-
### 6.
|
|
333
|
+
### 6. Write the gateway carriers
|
|
334
|
+
|
|
335
|
+
Only for the agents step 1 found routed through a gateway, and only after
|
|
336
|
+
the user said yes in step 2. For each one, run:
|
|
337
|
+
|
|
338
|
+
```sh
|
|
339
|
+
node <skill-dir>/scripts/write-gateway-carriers.mjs write --agent <claude-code|codex|opencode> --gateway <gateway> --repo <repo-root>
|
|
340
|
+
```
|
|
283
341
|
|
|
284
|
-
|
|
342
|
+
Add `--provider <id>` for Codex or OpenCode when step 1 found the provider
|
|
343
|
+
id and the script would otherwise have to guess.
|
|
344
|
+
|
|
345
|
+
The script uses this machine's id (creating it as the `machine-id` command
|
|
346
|
+
does) and the repository's `origin` URL, normalized, and writes the carrier
|
|
347
|
+
the gateway records:
|
|
348
|
+
|
|
349
|
+
| Gateway | What the agent will send |
|
|
350
|
+
| ----------------------- | ------------------------------------------------------------------------------------ |
|
|
351
|
+
| `litellm` | Headers `Miranda-Machine` and `Miranda-Project` |
|
|
352
|
+
| `cloudflare-ai-gateway` | One `cf-aig-metadata` header: `miranda_machine`, `miranda_project`, `miranda_agent` |
|
|
353
|
+
| `bifrost` | Headers `x-bf-lh-miranda-machine` and `x-bf-lh-miranda-project` |
|
|
354
|
+
| `otlp` | `OTEL_RESOURCE_ATTRIBUTES`: `miranda.machine.id`, `miranda.project`, `miranda.agent` |
|
|
355
|
+
| `openrouter` | Nothing: OpenRouter has no carrier |
|
|
356
|
+
|
|
357
|
+
into one place per agent, merged with any headers the agent already sends:
|
|
358
|
+
|
|
359
|
+
- **Claude Code:** `env.ANTHROPIC_CUSTOM_HEADERS` in the repo's
|
|
360
|
+
`.claude/settings.local.json` (per user, per project, not committed).
|
|
361
|
+
Never the committed `.claude/settings.json`. Tell the user it needs Claude
|
|
362
|
+
Code v2.1.227 or later (`claude --version`), and takes effect once they
|
|
363
|
+
trust the folder. For `otlp` the script writes `OTEL_RESOURCE_ATTRIBUTES`
|
|
364
|
+
in the same `env` block.
|
|
365
|
+
- **Codex:** `http_headers` on the active custom `[model_providers.<id>]`
|
|
366
|
+
block in `$CODEX_HOME/config.toml`. Codex reads providers only from that
|
|
367
|
+
user-level file, so tell the user the project value now applies to every
|
|
368
|
+
repository they run Codex in, until the skill is re-run in another one.
|
|
369
|
+
- **OpenCode:** `options.headers` on the gateway provider in the repo's
|
|
370
|
+
`opencode.json`. OpenCode merges it with the provider defined globally.
|
|
371
|
+
If `git ls-files --error-unmatch opencode.json` succeeds the file is
|
|
372
|
+
committed: tell the user the change holds their personal machine id and
|
|
373
|
+
should stay out of the commit.
|
|
374
|
+
|
|
375
|
+
On success it prints `{ "status": "written" | "unchanged", "file": … }`.
|
|
376
|
+
A re-run with the same values is `unchanged`. A run naming a different
|
|
377
|
+
gateway replaces the old carrier rather than adding a second one.
|
|
378
|
+
|
|
379
|
+
Exit code 2 is a refusal, and nothing was written. Relay its stderr to the
|
|
380
|
+
user as it is: it says what to fix. The refusals are OpenRouter (it has no
|
|
381
|
+
carrier, so recommend running the collector for that agent), a Codex
|
|
382
|
+
built-in provider (`openai`, `ollama`, `lmstudio` can't carry headers), a
|
|
383
|
+
`cf-aig-metadata` that would pass Cloudflare's five-entry limit, `otlp` for
|
|
384
|
+
Codex or OpenCode (their config has no place for it, so the user sets
|
|
385
|
+
`OTEL_RESOURCE_ATTRIBUTES` in the exporter's environment), and a config file
|
|
386
|
+
the script can't safely rewrite. Exit code 1 means you passed bad arguments.
|
|
387
|
+
|
|
388
|
+
The script only adds Miranda's values. It never sets `ANTHROPIC_BASE_URL`,
|
|
389
|
+
a `base_url` or `baseURL`, or any credential: pointing the agent at the
|
|
390
|
+
gateway is the user's job. The work item still comes from
|
|
391
|
+
`tag_session_work_item`, as `USING-MIRANDA.md` says.
|
|
392
|
+
|
|
393
|
+
### 7. Register this repository as a project
|
|
394
|
+
|
|
395
|
+
Run this after step 6, whichever enrolment path ran — and also when steps 2–6
|
|
285
396
|
had nothing to do. Do it **without asking**: the developer is setting up
|
|
286
397
|
tracking for exactly this repository, and registering it is the only way its
|
|
287
398
|
spend stops landing in Unassigned. Use the `miranda` MCP tools only.
|
|
@@ -298,7 +409,7 @@ spend stops landing in Unassigned. Use the `miranda` MCP tools only.
|
|
|
298
409
|
transcript, a tool argument or the report. Use `origin` and nothing else —
|
|
299
410
|
it is the only remote the collector reads. If this isn't a git repository,
|
|
300
411
|
or the command prints nothing on stdout (no `origin` remote — git's
|
|
301
|
-
`error: No such remote` goes to stderr), skip the rest of step
|
|
412
|
+
`error: No such remote` goes to stderr), skip the rest of step 7 and say
|
|
302
413
|
why in the report (e.g. "this repository has no `origin` remote, so there
|
|
303
414
|
is no URL a project could match").
|
|
304
415
|
|
|
@@ -364,7 +475,7 @@ spend stops landing in Unassigned. Use the `miranda` MCP tools only.
|
|
|
364
475
|
repositories are one product, and attaching moves spend between projects.
|
|
365
476
|
Never call `attach_project_repository` yourself.
|
|
366
477
|
|
|
367
|
-
5. **Report what happened** in step
|
|
478
|
+
5. **Report what happened** in step 8, from the result's `project` and
|
|
368
479
|
`backfill`:
|
|
369
480
|
- the `slug` it registered under (and the name, when it isn't the same as
|
|
370
481
|
the slug);
|
|
@@ -400,24 +511,29 @@ dropped connection, a timeout), retry it once. If it still fails, or the
|
|
|
400
511
|
`miranda` MCP server isn't connected, or the call returns an error — say so,
|
|
401
512
|
with the error, and carry on to the report. It doesn't undo the file edits or the enrolment.
|
|
402
513
|
|
|
403
|
-
###
|
|
514
|
+
### 8. Report
|
|
404
515
|
|
|
405
516
|
Summarize what changed (files created/updated, section inserted where,
|
|
406
|
-
usage tracking configured or already present,
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
files`.
|
|
517
|
+
usage tracking configured or already present, each gateway carrier written,
|
|
518
|
+
unchanged or refused and why, the project registered or matched — or why
|
|
519
|
+
the step was skipped — with what step 7 moved) and suggest committing the
|
|
520
|
+
changes, e.g. `chore: wire miranda agent doc into instruction files`. Leave
|
|
521
|
+
`.claude/settings.local.json`, the Codex config and any `opencode.json`
|
|
522
|
+
carrier change out of that commit: they hold this machine's id.
|
|
410
523
|
|
|
411
524
|
## Idempotency rules
|
|
412
525
|
|
|
413
526
|
- Re-running with identical content is a no-op; say "already current".
|
|
414
527
|
- Re-running in a repository that is already a project registers nothing:
|
|
415
|
-
step
|
|
528
|
+
step 7 finds the match and names it.
|
|
529
|
+
- Re-running the carrier write with the same gateway is a no-op
|
|
530
|
+
(`unchanged`); with a different gateway it replaces the carrier.
|
|
416
531
|
- Marker blocks are always replaced in place, never duplicated. If a file
|
|
417
532
|
somehow contains multiple marker blocks, replace the first and remove the
|
|
418
533
|
rest, and mention it.
|
|
419
534
|
- Never touch anything outside the marker block in an instruction file, and
|
|
420
|
-
never edit any other file
|
|
535
|
+
never edit any other file — except the agent config files step 6 writes,
|
|
536
|
+
and only through the script.
|
|
421
537
|
- Never re-mint a usage-tracking credential when step 1 found a `config.json`
|
|
422
538
|
in either location (including the pre-rename `~/.gloria/config.json`). Offer
|
|
423
539
|
to add `machineLabel` or rotate only if the user asks.
|
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Writes Miranda's gateway carriers into a coding agent's own config (#1889;
|
|
3
|
+
// design §5 of docs/plans/2026-10-27-ai-gateway-import-design.md). Run by the
|
|
4
|
+
// setting-up-usage-tracking skill. Dependency-free: it ships inside the
|
|
5
|
+
// published plugin and runs on the developer's machine with plain `node`.
|
|
6
|
+
//
|
|
7
|
+
// It only ever adds Miranda's identifying values. It never writes a base URL
|
|
8
|
+
// or a credential: pointing the agent at the gateway is the customer's job.
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process"
|
|
11
|
+
import { randomUUID } from "node:crypto"
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { homedir as osHomedir } from "node:os"
|
|
14
|
+
import { dirname, join } from "node:path"
|
|
15
|
+
import { fileURLToPath } from "node:url"
|
|
16
|
+
import { parseArgs } from "node:util"
|
|
17
|
+
|
|
18
|
+
/** The gateways a carrier can be written for: core's `GatewayType` ids. */
|
|
19
|
+
export const GATEWAYS = ["litellm", "cloudflare-ai-gateway", "bifrost", "otlp", "openrouter"]
|
|
20
|
+
|
|
21
|
+
/** Header names per header-logging gateway: [machine, project]. */
|
|
22
|
+
const HEADER_CARRIERS = {
|
|
23
|
+
litellm: ["Miranda-Machine", "Miranda-Project"],
|
|
24
|
+
bifrost: ["x-bf-lh-miranda-machine", "x-bf-lh-miranda-project"],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Cloudflare logs no request headers, only `cf-aig-metadata`'s JSON. */
|
|
28
|
+
const CF_METADATA_HEADER = "cf-aig-metadata"
|
|
29
|
+
const CF_MACHINE_KEY = "miranda_machine"
|
|
30
|
+
const CF_PROJECT_KEY = "miranda_project"
|
|
31
|
+
const CF_AGENT_KEY = "miranda_agent"
|
|
32
|
+
const CF_MAX_ENTRIES = 5
|
|
33
|
+
|
|
34
|
+
/** OTLP resource attributes, set through `OTEL_RESOURCE_ATTRIBUTES`. */
|
|
35
|
+
const OTEL_MACHINE_KEY = "miranda.machine.id"
|
|
36
|
+
const OTEL_PROJECT_KEY = "miranda.project"
|
|
37
|
+
const OTEL_AGENT_KEY = "miranda.agent"
|
|
38
|
+
|
|
39
|
+
/** Every header name a gateway carrier owns, lowercased. Removed before a
|
|
40
|
+
* write, so a re-run, a changed value, or a switch to another gateway never
|
|
41
|
+
* leaves two carriers behind. */
|
|
42
|
+
const MIRANDA_HEADER_NAMES = new Set(
|
|
43
|
+
Object.values(HEADER_CARRIERS)
|
|
44
|
+
.flat()
|
|
45
|
+
.map((name) => name.toLowerCase()),
|
|
46
|
+
)
|
|
47
|
+
const MIRANDA_CF_KEYS = new Set([CF_MACHINE_KEY, CF_PROJECT_KEY, CF_AGENT_KEY])
|
|
48
|
+
const MIRANDA_OTEL_KEYS = new Set([OTEL_MACHINE_KEY, OTEL_PROJECT_KEY, OTEL_AGENT_KEY])
|
|
49
|
+
|
|
50
|
+
/** A refusal the skill relays to the user verbatim. Nothing was written. */
|
|
51
|
+
function refuse(message) {
|
|
52
|
+
const error = new Error(message)
|
|
53
|
+
error.name = "CarrierRefusal"
|
|
54
|
+
return error
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How `gateway` carries the Miranda values (design §5). */
|
|
58
|
+
function carrierKind(gateway) {
|
|
59
|
+
if (gateway === "openrouter") {
|
|
60
|
+
throw refuse(
|
|
61
|
+
"OpenRouter has no Miranda carrier: it doesn't pass custom request headers through to " +
|
|
62
|
+
"the records Miranda imports, so nothing written here would reach Miranda. Run the " +
|
|
63
|
+
"Miranda collector on this machine instead; its copy of the session is the one counted.",
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
if (gateway in HEADER_CARRIERS) return "headers"
|
|
67
|
+
if (gateway === "cloudflare-ai-gateway") return "cf-aig-metadata"
|
|
68
|
+
if (gateway === "otlp") return "otel"
|
|
69
|
+
throw refuse(`Unknown gateway "${gateway}". Use one of: ${GATEWAYS.join(", ")}.`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- values -----------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/** The machine id's only valid form: the collector's lowercase UUID. The
|
|
75
|
+
* server ignores anything else as no signal (design §5). */
|
|
76
|
+
const MACHINE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
77
|
+
|
|
78
|
+
/** The server's cap on a project carrier value (design §5). */
|
|
79
|
+
const MAX_PROJECT_LENGTH = 512
|
|
80
|
+
|
|
81
|
+
/** Refuse values the server would discard, before anything is written. */
|
|
82
|
+
export function validateCarrierValues({ machineId, project }) {
|
|
83
|
+
if (typeof machineId !== "string" || !MACHINE_ID_PATTERN.test(machineId)) {
|
|
84
|
+
throw refuse(
|
|
85
|
+
`"${machineId}" isn't a valid machine id: Miranda only accepts the collector's lowercase UUID.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
if (typeof project !== "string" || project.trim() === "") {
|
|
89
|
+
throw refuse("The project is empty: run this inside a repository with an origin remote.")
|
|
90
|
+
}
|
|
91
|
+
if (project.length > MAX_PROJECT_LENGTH) {
|
|
92
|
+
throw refuse(
|
|
93
|
+
`The project URL is longer than ${MAX_PROJECT_LENGTH} characters, which Miranda ignores.`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** GitHub `owner/repo` shorthand. */
|
|
99
|
+
const GITHUB_SHORTHAND = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
|
|
100
|
+
/** scp-like SSH remotes, `git@github.com:owner/repo.git`. */
|
|
101
|
+
const SCP_LIKE_SSH = /^[\w.-]+@([\w.-]+):(.+)$/
|
|
102
|
+
|
|
103
|
+
function stripGitSuffixAndSlash(path) {
|
|
104
|
+
return path.replace(/\.git\/*$/i, "").replace(/\/+$/, "")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A port of core's `normalizeRepoUrl` (packages/core/src/inventory-schema.ts),
|
|
109
|
+
* which the server resolves a project with; a parity test pins the two
|
|
110
|
+
* together. `URL.host` drops any userinfo, so a credential in the remote
|
|
111
|
+
* never reaches a config file.
|
|
112
|
+
*/
|
|
113
|
+
export function normalizeRepoUrl(input) {
|
|
114
|
+
const trimmed = input.trim()
|
|
115
|
+
if (GITHUB_SHORTHAND.test(trimmed)) return `https://github.com/${trimmed}`
|
|
116
|
+
const scp = SCP_LIKE_SSH.exec(trimmed)
|
|
117
|
+
if (scp !== null) return `https://${scp[1]}/${stripGitSuffixAndSlash(scp[2])}`
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(trimmed)
|
|
120
|
+
if (url.protocol === "ssh:" || url.protocol === "git:" || url.protocol === "git+ssh:") {
|
|
121
|
+
return `https://${url.host}${stripGitSuffixAndSlash(url.pathname)}`
|
|
122
|
+
}
|
|
123
|
+
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
124
|
+
return `${url.protocol}//${url.host}${stripGitSuffixAndSlash(url.pathname)}`
|
|
125
|
+
}
|
|
126
|
+
return trimmed
|
|
127
|
+
} catch {
|
|
128
|
+
return trimmed
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The gateway a base URL points at, when its host says so; else null and
|
|
133
|
+
* the skill asks. Self-hosted gateways (LiteLLM, Bifrost) have no fixed
|
|
134
|
+
* host, so only the hosted ones are inferred. */
|
|
135
|
+
export function inferGateway(baseUrl) {
|
|
136
|
+
let host
|
|
137
|
+
try {
|
|
138
|
+
host = new URL(baseUrl).hostname.toLowerCase()
|
|
139
|
+
} catch {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
if (host === "gateway.ai.cloudflare.com") return "cloudflare-ai-gateway"
|
|
143
|
+
if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) return "openrouter"
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- machine id -------------------------------------------------------------
|
|
148
|
+
//
|
|
149
|
+
// The machine value is the collector's own enrolment id, so the server
|
|
150
|
+
// resolves a gateway request's user through the same `machine_owner` row the
|
|
151
|
+
// collector's ingest uses. This mirrors packages/collector/src/state.ts; keep
|
|
152
|
+
// the two in step.
|
|
153
|
+
|
|
154
|
+
/** Full-path overrides of the collector's home, highest precedence first. */
|
|
155
|
+
const HOME_OVERRIDE_VARS = ["SANDGARDEN_HOME", "GLORIA_HOME"]
|
|
156
|
+
|
|
157
|
+
/** The entries whose presence tells the collector its one-time `~/.gloria`
|
|
158
|
+
* migration is done (MIGRATED_STATE_ENTRIES in state.ts). */
|
|
159
|
+
const MIGRATION_GATE_ENTRIES = [
|
|
160
|
+
"config.json",
|
|
161
|
+
"machine.json",
|
|
162
|
+
"usage-cursors.json",
|
|
163
|
+
"usage-queue",
|
|
164
|
+
"work-item-throttle.json",
|
|
165
|
+
"sync-debounce.json",
|
|
166
|
+
"heartbeat.json",
|
|
167
|
+
"declared-work-item.json",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
function envPath(env, homedir, name) {
|
|
171
|
+
const raw = env[name]
|
|
172
|
+
if (raw === undefined || raw === "") return null
|
|
173
|
+
return raw.startsWith("~/") ? join(homedir, raw.slice(2)) : raw
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function homeOverride(env, homedir) {
|
|
177
|
+
for (const name of HOME_OVERRIDE_VARS) {
|
|
178
|
+
const value = envPath(env, homedir, name)
|
|
179
|
+
if (value !== null) return value
|
|
180
|
+
}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The collector's state directory: `SANDGARDEN_HOME` → `GLORIA_HOME` →
|
|
185
|
+
* `$XDG_CONFIG_HOME/sandgarden` → `~/.config/sandgarden`. */
|
|
186
|
+
export function collectorHome(env, homedir) {
|
|
187
|
+
return (
|
|
188
|
+
homeOverride(env, homedir) ??
|
|
189
|
+
join(envPath(env, homedir, "XDG_CONFIG_HOME") ?? join(homedir, ".config"), "sandgarden")
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The id in a machine.json, or null when there is no file. Refuses a file
|
|
194
|
+
* it can't use: replacing it would give the machine a second identity. */
|
|
195
|
+
function readMachineFile(file) {
|
|
196
|
+
if (!existsSync(file)) return null
|
|
197
|
+
let machineId
|
|
198
|
+
try {
|
|
199
|
+
machineId = JSON.parse(readFileSync(file, "utf8"))?.machineId
|
|
200
|
+
} catch {
|
|
201
|
+
machineId = undefined
|
|
202
|
+
}
|
|
203
|
+
if (typeof machineId !== "string" || !MACHINE_ID_PATTERN.test(machineId)) {
|
|
204
|
+
throw refuse(
|
|
205
|
+
`${file} doesn't hold a lowercase UUID machineId, so it was left alone. Replacing it ` +
|
|
206
|
+
"would give this machine a second identity. Fix or remove it, then re-run.",
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
return machineId
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* This machine's id: the collector's `machine.json`; else, before the
|
|
214
|
+
* collector's one-time migration has run, the pre-rename `~/.gloria` id (read
|
|
215
|
+
* only: writing machine.json would close the migration gate and strand the
|
|
216
|
+
* legacy config.json); else a fresh UUID v4 written in the collector's
|
|
217
|
+
* format, so a collector installed later reuses it.
|
|
218
|
+
*/
|
|
219
|
+
export function resolveMachineId({ env, homedir }) {
|
|
220
|
+
const home = collectorHome(env, homedir)
|
|
221
|
+
const file = join(home, "machine.json")
|
|
222
|
+
const existing = readMachineFile(file)
|
|
223
|
+
if (existing !== null) return { machineId: existing, source: "existing", file }
|
|
224
|
+
|
|
225
|
+
const legacyFile = join(homedir, ".gloria", "machine.json")
|
|
226
|
+
const migrationPending =
|
|
227
|
+
homeOverride(env, homedir) === null &&
|
|
228
|
+
!MIGRATION_GATE_ENTRIES.some((name) => existsSync(join(home, name)))
|
|
229
|
+
if (migrationPending) {
|
|
230
|
+
const legacy = readMachineFile(legacyFile)
|
|
231
|
+
if (legacy !== null) return { machineId: legacy, source: "legacy", file: legacyFile }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const machineId = randomUUID()
|
|
235
|
+
mkdirSync(home, { recursive: true })
|
|
236
|
+
const tmp = `${file}.${randomUUID()}.tmp`
|
|
237
|
+
try {
|
|
238
|
+
writeFileSync(tmp, JSON.stringify({ machineId }, null, 2))
|
|
239
|
+
renameSync(tmp, file)
|
|
240
|
+
} finally {
|
|
241
|
+
rmSync(tmp, { force: true })
|
|
242
|
+
}
|
|
243
|
+
return { machineId, source: "minted", file }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- header lists -----------------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// Each agent keeps its headers in a different format, but all of them are an
|
|
249
|
+
// ordered list of { name, value } entries. Entries the merge doesn't touch are
|
|
250
|
+
// returned as the same objects, so a format can keep their original text.
|
|
251
|
+
|
|
252
|
+
function isNamed(entry, name) {
|
|
253
|
+
return entry.name.toLowerCase() === name
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Parse a `cf-aig-metadata` value as a JSON object, or null. */
|
|
257
|
+
function parseMetadata(value) {
|
|
258
|
+
try {
|
|
259
|
+
const parsed = JSON.parse(value)
|
|
260
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
|
|
261
|
+
} catch {
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Miranda's `cf-aig-metadata` entries merged into the customer's, Miranda's
|
|
267
|
+
* first: behind Cloudflare Access, `cf.user_id` evicts the LAST entry. */
|
|
268
|
+
function mergedMetadata(existing, { machineId, project, agent }) {
|
|
269
|
+
let customer = {}
|
|
270
|
+
if (existing !== undefined) {
|
|
271
|
+
const parsed = parseMetadata(existing.value)
|
|
272
|
+
if (parsed === null) {
|
|
273
|
+
throw refuse(
|
|
274
|
+
`The existing ${CF_METADATA_HEADER} header isn't a JSON object, so it was left alone. ` +
|
|
275
|
+
"Fix it, then re-run.",
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
customer = Object.fromEntries(
|
|
279
|
+
Object.entries(parsed).filter(([key]) => !MIRANDA_CF_KEYS.has(key)),
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
const merged = {
|
|
283
|
+
[CF_MACHINE_KEY]: machineId,
|
|
284
|
+
[CF_PROJECT_KEY]: project,
|
|
285
|
+
[CF_AGENT_KEY]: agent,
|
|
286
|
+
...customer,
|
|
287
|
+
}
|
|
288
|
+
if (Object.keys(merged).length > CF_MAX_ENTRIES) {
|
|
289
|
+
throw refuse(
|
|
290
|
+
`${CF_METADATA_HEADER} holds at most five entries. Miranda needs three, and this agent ` +
|
|
291
|
+
`already sends ${Object.keys(customer).length} of its own (${Object.keys(customer).join(", ")}). ` +
|
|
292
|
+
"Remove enough of them to leave room, then re-run. Nothing was written.",
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
return JSON.stringify(merged)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The customer's `cf-aig-metadata` with any Miranda entries removed: the
|
|
299
|
+
* entry unchanged when it has none, null when nothing of theirs is left. */
|
|
300
|
+
function withoutMirandaMetadata(entry) {
|
|
301
|
+
const parsed = parseMetadata(entry.value)
|
|
302
|
+
if (parsed === null || !Object.keys(parsed).some((key) => MIRANDA_CF_KEYS.has(key))) return entry
|
|
303
|
+
const customer = Object.entries(parsed).filter(([key]) => !MIRANDA_CF_KEYS.has(key))
|
|
304
|
+
return customer.length === 0
|
|
305
|
+
? null
|
|
306
|
+
: { name: entry.name, value: JSON.stringify(Object.fromEntries(customer)) }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Merge the gateway's carrier into an agent's header list. Every Miranda
|
|
311
|
+
* carrier already there is removed first; the customer's own headers are
|
|
312
|
+
* kept, in order.
|
|
313
|
+
*/
|
|
314
|
+
export function mergeCarrierHeaders(entries, { gateway, machineId, project, agent }) {
|
|
315
|
+
const kind = carrierKind(gateway)
|
|
316
|
+
if (kind === "otel") throw refuse("OTLP is carried by resource attributes, not headers.")
|
|
317
|
+
const kept = entries.filter((entry) => !MIRANDA_HEADER_NAMES.has(entry.name.toLowerCase()))
|
|
318
|
+
if (kind === "cf-aig-metadata") {
|
|
319
|
+
const existing = kept.find((entry) => isNamed(entry, CF_METADATA_HEADER))
|
|
320
|
+
const value = mergedMetadata(existing, { machineId, project, agent })
|
|
321
|
+
return [
|
|
322
|
+
...kept.filter((entry) => !isNamed(entry, CF_METADATA_HEADER)),
|
|
323
|
+
{ name: CF_METADATA_HEADER, value },
|
|
324
|
+
]
|
|
325
|
+
}
|
|
326
|
+
const [machineHeader, projectHeader] = HEADER_CARRIERS[gateway]
|
|
327
|
+
return [
|
|
328
|
+
...kept
|
|
329
|
+
.map((entry) => (isNamed(entry, CF_METADATA_HEADER) ? withoutMirandaMetadata(entry) : entry))
|
|
330
|
+
.filter((entry) => entry !== null),
|
|
331
|
+
{ name: machineHeader, value: machineId },
|
|
332
|
+
{ name: projectHeader, value: project },
|
|
333
|
+
]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** `Name: Value` lines, as Claude Code's `ANTHROPIC_CUSTOM_HEADERS` holds
|
|
337
|
+
* them. Each entry keeps its original line. */
|
|
338
|
+
function parseHeaderLines(text) {
|
|
339
|
+
return text
|
|
340
|
+
.split("\n")
|
|
341
|
+
.filter((line) => line.trim() !== "")
|
|
342
|
+
.map((line) => {
|
|
343
|
+
const colon = line.indexOf(":")
|
|
344
|
+
return colon === -1
|
|
345
|
+
? { name: line.trim(), value: "", line }
|
|
346
|
+
: { name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim(), line }
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function formatHeaderLines(entries) {
|
|
351
|
+
return entries.map((entry) => entry.line ?? `${entry.name}: ${entry.value}`).join("\n")
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- OTEL resource attributes -----------------------------------------------
|
|
355
|
+
|
|
356
|
+
/** OpenTelemetry's baggage-octet set, minus `%` (the escape itself). Anything
|
|
357
|
+
* else in a value is percent-encoded. */
|
|
358
|
+
function isBaggageOctet(char) {
|
|
359
|
+
const code = char.charCodeAt(0)
|
|
360
|
+
return (
|
|
361
|
+
code === 0x21 ||
|
|
362
|
+
(code >= 0x23 && code <= 0x2b && code !== 0x25) ||
|
|
363
|
+
(code >= 0x2d && code <= 0x3a) ||
|
|
364
|
+
(code >= 0x3c && code <= 0x5b) ||
|
|
365
|
+
(code >= 0x5d && code <= 0x7e)
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function encodeAttributeValue(value) {
|
|
370
|
+
return [...value].map((char) => (isBaggageOctet(char) ? char : encodeURIComponent(char))).join("")
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function attributeKey(entry) {
|
|
374
|
+
const separator = entry.indexOf("=")
|
|
375
|
+
const raw = (separator === -1 ? entry : entry.slice(0, separator)).trim()
|
|
376
|
+
try {
|
|
377
|
+
return decodeURIComponent(raw)
|
|
378
|
+
} catch {
|
|
379
|
+
return raw
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Merge Miranda's three attributes into an `OTEL_RESOURCE_ATTRIBUTES`
|
|
384
|
+
* list, keeping the customer's entries verbatim. */
|
|
385
|
+
export function mergeResourceAttributes(existing, { machineId, project, agent }) {
|
|
386
|
+
const kept = existing
|
|
387
|
+
.split(",")
|
|
388
|
+
.filter((entry) => entry.trim() !== "" && !MIRANDA_OTEL_KEYS.has(attributeKey(entry)))
|
|
389
|
+
const added = [
|
|
390
|
+
[OTEL_MACHINE_KEY, machineId],
|
|
391
|
+
[OTEL_PROJECT_KEY, project],
|
|
392
|
+
[OTEL_AGENT_KEY, agent],
|
|
393
|
+
].map(([key, value]) => `${key}=${encodeAttributeValue(value)}`)
|
|
394
|
+
return [...kept, ...added].join(",")
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- JSON config files ------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
function readJsonObject(file) {
|
|
400
|
+
if (!existsSync(file)) return {}
|
|
401
|
+
const text = readFileSync(file, "utf8")
|
|
402
|
+
let parsed
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(text)
|
|
405
|
+
} catch {
|
|
406
|
+
throw refuse(`${file} isn't valid JSON, so it was left alone. Fix or remove it, then re-run.`)
|
|
407
|
+
}
|
|
408
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
409
|
+
throw refuse(`${file} isn't a JSON object, so it was left alone.`)
|
|
410
|
+
}
|
|
411
|
+
return parsed
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Write `next` only if it differs from what is on disk. */
|
|
415
|
+
function writeJsonIfChanged(file, before, next) {
|
|
416
|
+
if (existsSync(file) && JSON.stringify(before) === JSON.stringify(next)) {
|
|
417
|
+
return { status: "unchanged", file }
|
|
418
|
+
}
|
|
419
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
420
|
+
writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
|
|
421
|
+
return { status: "written", file }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- Claude Code ------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Claude Code: `env.ANTHROPIC_CUSTOM_HEADERS` in the repository's
|
|
428
|
+
* `.claude/settings.local.json` (per user, per project, kept out of git).
|
|
429
|
+
* Claude Code resolves `env` by settings precedence with no merge, so both
|
|
430
|
+
* values go here together, merged with whatever headers the file already
|
|
431
|
+
* sends. The committed `.claude/settings.json` is never touched. OTLP's
|
|
432
|
+
* carrier is `OTEL_RESOURCE_ATTRIBUTES` in the same `env` block.
|
|
433
|
+
*/
|
|
434
|
+
export function writeClaudeCodeCarrier({ repoRoot, gateway, machineId, project }) {
|
|
435
|
+
const values = { gateway, machineId, project, agent: "claude-code" }
|
|
436
|
+
const kind = carrierKind(gateway)
|
|
437
|
+
validateCarrierValues(values)
|
|
438
|
+
const file = join(repoRoot, ".claude", "settings.local.json")
|
|
439
|
+
const before = readJsonObject(file)
|
|
440
|
+
const env = { ...(before.env ?? {}) }
|
|
441
|
+
if (kind === "otel") {
|
|
442
|
+
env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(
|
|
443
|
+
env.OTEL_RESOURCE_ATTRIBUTES ?? "",
|
|
444
|
+
values,
|
|
445
|
+
)
|
|
446
|
+
} else {
|
|
447
|
+
const headers = parseHeaderLines(env.ANTHROPIC_CUSTOM_HEADERS ?? "")
|
|
448
|
+
env.ANTHROPIC_CUSTOM_HEADERS = formatHeaderLines(mergeCarrierHeaders(headers, values))
|
|
449
|
+
}
|
|
450
|
+
return writeJsonIfChanged(file, before, { ...before, env })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** OTLP's carrier is an environment variable of the exporter; only Claude
|
|
454
|
+
* Code's settings file has an `env` block to hold it. */
|
|
455
|
+
function otlpNotInConfig(where) {
|
|
456
|
+
return refuse(
|
|
457
|
+
`${where} has no resource-attribute setting. Set OTEL_RESOURCE_ATTRIBUTES in the ` +
|
|
458
|
+
"environment of whatever exports your OTLP traces instead.",
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- Codex ------------------------------------------------------------------
|
|
463
|
+
//
|
|
464
|
+
// Codex reads `model_providers` only from the user config ($CODEX_HOME,
|
|
465
|
+
// default ~/.codex): project `.codex/config.toml` ignores it. So the carrier
|
|
466
|
+
// goes on the customer's own custom provider block there. This is a narrow
|
|
467
|
+
// line editor, not a TOML parser: it rewrites one `http_headers = { … }`
|
|
468
|
+
// line, or inserts one, and refuses any shape it can't rewrite safely.
|
|
469
|
+
|
|
470
|
+
/** Provider ids Codex reserves; a custom block can't reuse them, so they
|
|
471
|
+
* can't carry headers. */
|
|
472
|
+
const CODEX_BUILTIN_PROVIDERS = new Set(["openai", "ollama", "lmstudio"])
|
|
473
|
+
|
|
474
|
+
const CODEX_CUSTOM_PROVIDER_GUIDANCE =
|
|
475
|
+
"Codex can only send extra headers from a custom provider: a [model_providers.<id>] " +
|
|
476
|
+
"block in config.toml whose base_url points at your gateway, selected with " +
|
|
477
|
+
'model_provider = "<id>". Create that block (pointing Codex at the gateway is yours to ' +
|
|
478
|
+
"do), then re-run with its id."
|
|
479
|
+
|
|
480
|
+
/** A TOML basic string (double-quoted), escaped. */
|
|
481
|
+
function tomlString(value) {
|
|
482
|
+
return JSON.stringify(value)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A table header line's dotted key, as plain segments, or null. */
|
|
486
|
+
function tableHeaderKey(line) {
|
|
487
|
+
const match = /^\s*\[\s*([^[\]]+?)\s*\]\s*(#.*)?$/.exec(line)
|
|
488
|
+
if (match === null) return null
|
|
489
|
+
return [...match[1].matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/g)].map((part) =>
|
|
490
|
+
part[1] !== undefined ? JSON.parse(`"${part[1]}"`) : (part[2] ?? part[3]),
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function sameKey(a, b) {
|
|
495
|
+
return a !== null && a.length === b.length && a.every((segment, i) => segment === b[i])
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The top-level `model_provider`, read from before the first table. */
|
|
499
|
+
function activeCodexProvider(lines) {
|
|
500
|
+
for (const line of lines) {
|
|
501
|
+
if (/^\s*\[/.test(line)) return null
|
|
502
|
+
const match = /^\s*model_provider\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/.exec(line)
|
|
503
|
+
if (match !== null) return match[1] !== undefined ? JSON.parse(`"${match[1]}"`) : match[2]
|
|
504
|
+
}
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Parse the inside of a one-line inline table of string values into header
|
|
509
|
+
* entries, or null if it isn't that shape. */
|
|
510
|
+
function parseInlineHeaders(body) {
|
|
511
|
+
const entries = []
|
|
512
|
+
const pair =
|
|
513
|
+
/\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(,|$)/y
|
|
514
|
+
let index = 0
|
|
515
|
+
while (index < body.length && body.slice(index).trim() !== "") {
|
|
516
|
+
pair.lastIndex = index
|
|
517
|
+
const match = pair.exec(body)
|
|
518
|
+
if (match === null) return null
|
|
519
|
+
const name = match[1] !== undefined ? JSON.parse(`"${match[1]}"`) : (match[2] ?? match[3])
|
|
520
|
+
const value = match[4] !== undefined ? JSON.parse(`"${match[4]}"`) : match[5]
|
|
521
|
+
entries.push({ name, value })
|
|
522
|
+
index = pair.lastIndex
|
|
523
|
+
if (match[6] === "") break
|
|
524
|
+
}
|
|
525
|
+
return entries
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function formatInlineHeaders(entries) {
|
|
529
|
+
const pairs = entries.map((entry) => `${tomlString(entry.name)} = ${tomlString(entry.value)}`)
|
|
530
|
+
return `{ ${pairs.join(", ")} }`
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Codex: `http_headers` on the active custom `[model_providers.<id>]` block
|
|
535
|
+
* of `$CODEX_HOME/config.toml`, merged with the headers it already sends.
|
|
536
|
+
* `provider` overrides the top-level `model_provider`.
|
|
537
|
+
*/
|
|
538
|
+
export function writeCodexCarrier({ codexHome, gateway, machineId, project, provider }) {
|
|
539
|
+
const values = { gateway, machineId, project, agent: "codex" }
|
|
540
|
+
if (carrierKind(gateway) === "otel") throw otlpNotInConfig("Codex's config.toml")
|
|
541
|
+
validateCarrierValues(values)
|
|
542
|
+
const file = join(codexHome, "config.toml")
|
|
543
|
+
if (!existsSync(file)) {
|
|
544
|
+
throw refuse(
|
|
545
|
+
`${file} doesn't exist, so Codex is on its built-in provider. ${CODEX_CUSTOM_PROVIDER_GUIDANCE}`,
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
const text = readFileSync(file, "utf8")
|
|
549
|
+
const lines = text.split("\n")
|
|
550
|
+
const id = provider ?? activeCodexProvider(lines)
|
|
551
|
+
if (id === null || CODEX_BUILTIN_PROVIDERS.has(id)) {
|
|
552
|
+
throw refuse(
|
|
553
|
+
`Codex is using the built-in provider "${id ?? "openai"}", which can't carry headers. ` +
|
|
554
|
+
CODEX_CUSTOM_PROVIDER_GUIDANCE,
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const blockKey = ["model_providers", id]
|
|
559
|
+
const start = lines.findIndex((line) => sameKey(tableHeaderKey(line), blockKey))
|
|
560
|
+
if (start === -1) {
|
|
561
|
+
throw refuse(`${file} has no [model_providers.${id}] block. ${CODEX_CUSTOM_PROVIDER_GUIDANCE}`)
|
|
562
|
+
}
|
|
563
|
+
if (lines.some((line) => sameKey(tableHeaderKey(line), [...blockKey, "http_headers"]))) {
|
|
564
|
+
throw refuse(
|
|
565
|
+
`[model_providers.${id}.http_headers] is a separate table, which this script doesn't ` +
|
|
566
|
+
"rewrite. Add the headers to it by hand (see the Codex page of Miranda's gateway docs).",
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
let end = lines.findIndex((line, i) => i > start && /^\s*\[/.test(line))
|
|
570
|
+
if (end === -1) end = lines.length
|
|
571
|
+
|
|
572
|
+
const headerLine = lines.findIndex(
|
|
573
|
+
(line, i) => i > start && i < end && /^\s*http_headers\s*=/.test(line),
|
|
574
|
+
)
|
|
575
|
+
let next
|
|
576
|
+
if (headerLine === -1) {
|
|
577
|
+
let last = end - 1
|
|
578
|
+
while (last > start && lines[last].trim() === "") last -= 1
|
|
579
|
+
const merged = mergeCarrierHeaders([], values)
|
|
580
|
+
next = [
|
|
581
|
+
...lines.slice(0, last + 1),
|
|
582
|
+
`http_headers = ${formatInlineHeaders(merged)}`,
|
|
583
|
+
...lines.slice(last + 1),
|
|
584
|
+
]
|
|
585
|
+
} else {
|
|
586
|
+
const match = /^(\s*http_headers\s*=\s*)\{(.*)\}(\s*(#.*)?)$/.exec(lines[headerLine])
|
|
587
|
+
const existing = match === null ? null : parseInlineHeaders(match[2])
|
|
588
|
+
if (existing === null) {
|
|
589
|
+
throw refuse(
|
|
590
|
+
`The http_headers in [model_providers.${id}] isn't a one-line table of strings, which is ` +
|
|
591
|
+
"the only shape this script rewrites. Add the headers by hand (see the Codex page of " +
|
|
592
|
+
"Miranda's gateway docs).",
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
const merged = mergeCarrierHeaders(existing, values)
|
|
596
|
+
next = [...lines]
|
|
597
|
+
next[headerLine] = `${match[1]}${formatInlineHeaders(merged)}${match[3]}`
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const nextText = next.join("\n")
|
|
601
|
+
if (nextText === text) return { status: "unchanged", file, provider: id }
|
|
602
|
+
writeFileSync(file, nextText)
|
|
603
|
+
return { status: "written", file, provider: id }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// --- OpenCode ---------------------------------------------------------------
|
|
607
|
+
//
|
|
608
|
+
// OpenCode merges its global and project configs, so headers on the
|
|
609
|
+
// repository's `opencode.json` provider entry merge with the provider the
|
|
610
|
+
// customer defined globally, and the project value stays per repository.
|
|
611
|
+
|
|
612
|
+
function providerIdsWithBaseUrl(config) {
|
|
613
|
+
return Object.entries(config.provider ?? {})
|
|
614
|
+
.filter(([, entry]) => typeof entry?.options?.baseURL === "string")
|
|
615
|
+
.map(([id]) => id)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* OpenCode: `provider.<id>.options.headers` in the repository's
|
|
620
|
+
* `opencode.json`, merged with the headers it already sends. Without
|
|
621
|
+
* `provider`, the gateway provider is the one provider whose `baseURL` the
|
|
622
|
+
* project or global config sets.
|
|
623
|
+
*/
|
|
624
|
+
export function writeOpenCodeCarrier({
|
|
625
|
+
repoRoot,
|
|
626
|
+
globalConfigFile,
|
|
627
|
+
gateway,
|
|
628
|
+
machineId,
|
|
629
|
+
project,
|
|
630
|
+
provider,
|
|
631
|
+
}) {
|
|
632
|
+
const values = { gateway, machineId, project, agent: "opencode" }
|
|
633
|
+
if (carrierKind(gateway) === "otel") throw otlpNotInConfig("OpenCode's opencode.json")
|
|
634
|
+
validateCarrierValues(values)
|
|
635
|
+
const file = join(repoRoot, "opencode.json")
|
|
636
|
+
const before = readJsonObject(file)
|
|
637
|
+
|
|
638
|
+
let id = provider
|
|
639
|
+
if (id === undefined) {
|
|
640
|
+
const candidates = [
|
|
641
|
+
...new Set([
|
|
642
|
+
...providerIdsWithBaseUrl(before),
|
|
643
|
+
...providerIdsWithBaseUrl(readJsonObject(globalConfigFile)),
|
|
644
|
+
]),
|
|
645
|
+
]
|
|
646
|
+
if (candidates.length !== 1) {
|
|
647
|
+
throw refuse(
|
|
648
|
+
candidates.length === 0
|
|
649
|
+
? "No provider in opencode.json sets options.baseURL, so there is no gateway provider to " +
|
|
650
|
+
"add headers to. Point a provider at your gateway first, then re-run."
|
|
651
|
+
: `More than one provider sets options.baseURL (${candidates.join(", ")}). Re-run naming ` +
|
|
652
|
+
"the one routed through your gateway.",
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
id = candidates[0]
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const entry = before.provider?.[id] ?? {}
|
|
659
|
+
const options = entry.options ?? {}
|
|
660
|
+
const existing = Object.entries(options.headers ?? {}).map(([name, value]) => ({ name, value }))
|
|
661
|
+
const headers = Object.fromEntries(
|
|
662
|
+
mergeCarrierHeaders(existing, values).map((header) => [header.name, header.value]),
|
|
663
|
+
)
|
|
664
|
+
const next = {
|
|
665
|
+
...before,
|
|
666
|
+
provider: { ...before.provider, [id]: { ...entry, options: { ...options, headers } } },
|
|
667
|
+
}
|
|
668
|
+
return { ...writeJsonIfChanged(file, before, next), provider: id }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// --- CLI --------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
const USAGE = `Usage:
|
|
674
|
+
write-gateway-carriers.mjs machine-id
|
|
675
|
+
write-gateway-carriers.mjs write --agent <claude-code|codex|opencode> --gateway <${GATEWAYS.join("|")}>
|
|
676
|
+
[--repo <dir>] [--provider <id>] [--project <url>]
|
|
677
|
+
write-gateway-carriers.mjs gateway-for <base-url>
|
|
678
|
+
|
|
679
|
+
Prints one JSON object on success (exit 0). A refusal prints its reason on
|
|
680
|
+
stderr and exits 2, having written nothing. Bad arguments exit 1.`
|
|
681
|
+
|
|
682
|
+
const WRITE_OPTIONS = {
|
|
683
|
+
agent: { type: "string" },
|
|
684
|
+
gateway: { type: "string" },
|
|
685
|
+
repo: { type: "string" },
|
|
686
|
+
provider: { type: "string" },
|
|
687
|
+
project: { type: "string" },
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The repository's `origin` URL, or null when it has none. */
|
|
691
|
+
function originUrl(repoRoot) {
|
|
692
|
+
try {
|
|
693
|
+
return execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
|
|
694
|
+
encoding: "utf8",
|
|
695
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
696
|
+
}).trim()
|
|
697
|
+
} catch {
|
|
698
|
+
return null
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function writeFor(agent, { env, homedir, repoRoot, ...values }) {
|
|
703
|
+
if (agent === "claude-code") return writeClaudeCodeCarrier({ repoRoot, ...values })
|
|
704
|
+
if (agent === "codex") {
|
|
705
|
+
const codexHome = envPath(env, homedir, "CODEX_HOME") ?? join(homedir, ".codex")
|
|
706
|
+
return writeCodexCarrier({ codexHome, ...values })
|
|
707
|
+
}
|
|
708
|
+
if (agent === "opencode") {
|
|
709
|
+
const configHome = envPath(env, homedir, "XDG_CONFIG_HOME") ?? join(homedir, ".config")
|
|
710
|
+
const globalConfigFile = join(configHome, "opencode", "opencode.json")
|
|
711
|
+
return writeOpenCodeCarrier({ repoRoot, globalConfigFile, ...values })
|
|
712
|
+
}
|
|
713
|
+
throw refuse(`No config writer for agent "${agent}". Use one of: claude-code, codex, opencode.`)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function runWrite(args, { env, homedir }) {
|
|
717
|
+
const { values: flags } = parseArgs({ args, options: WRITE_OPTIONS, strict: true })
|
|
718
|
+
if (flags.agent === undefined || flags.gateway === undefined) return null
|
|
719
|
+
const repoRoot = flags.repo ?? process.cwd()
|
|
720
|
+
const remote = flags.project ?? originUrl(repoRoot)
|
|
721
|
+
if (remote === null || remote === "") {
|
|
722
|
+
throw refuse(`${repoRoot} has no origin remote, so there is no project URL to send.`)
|
|
723
|
+
}
|
|
724
|
+
const project = normalizeRepoUrl(remote)
|
|
725
|
+
// Validate before minting, so a refused run leaves no machine.json behind.
|
|
726
|
+
carrierKind(flags.gateway)
|
|
727
|
+
const { machineId } = resolveMachineId({ env, homedir })
|
|
728
|
+
const result = writeFor(flags.agent, {
|
|
729
|
+
env,
|
|
730
|
+
homedir,
|
|
731
|
+
repoRoot,
|
|
732
|
+
gateway: flags.gateway,
|
|
733
|
+
provider: flags.provider,
|
|
734
|
+
machineId,
|
|
735
|
+
project,
|
|
736
|
+
})
|
|
737
|
+
return { ...result, agent: flags.agent, gateway: flags.gateway, machineId, project }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function runCommand(args, deps) {
|
|
741
|
+
const [command, ...rest] = args
|
|
742
|
+
if (command === "machine-id" && rest.length === 0) return resolveMachineId(deps)
|
|
743
|
+
if (command === "gateway-for" && rest.length === 1) return { gateway: inferGateway(rest[0]) }
|
|
744
|
+
if (command === "write") return runWrite(rest, deps)
|
|
745
|
+
return null
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Run the CLI. Returns the exit code. */
|
|
749
|
+
export function main(args, { env, homedir, stdout, stderr }) {
|
|
750
|
+
let result
|
|
751
|
+
try {
|
|
752
|
+
result = runCommand(args, { env, homedir })
|
|
753
|
+
} catch (error) {
|
|
754
|
+
if (error.name === "CarrierRefusal") {
|
|
755
|
+
stderr(error.message)
|
|
756
|
+
return 2
|
|
757
|
+
}
|
|
758
|
+
if (error.code?.startsWith("ERR_PARSE_ARGS")) result = null
|
|
759
|
+
else throw error
|
|
760
|
+
}
|
|
761
|
+
if (result === null) {
|
|
762
|
+
stderr(USAGE)
|
|
763
|
+
return 1
|
|
764
|
+
}
|
|
765
|
+
stdout(JSON.stringify(result, null, 2))
|
|
766
|
+
return 0
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
770
|
+
process.exitCode = main(process.argv.slice(2), {
|
|
771
|
+
env: process.env,
|
|
772
|
+
homedir: osHomedir(),
|
|
773
|
+
stdout: (text) => process.stdout.write(text + "\n"),
|
|
774
|
+
stderr: (text) => process.stderr.write(text + "\n"),
|
|
775
|
+
})
|
|
776
|
+
}
|