@amd-gaia/gaia 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SPEC.md CHANGED
@@ -190,6 +190,44 @@ supervises the sidecar and does its own SHA-256 check on the binary it finds
190
190
  there; installing an already-verified binary at that path turns the daemon's fetch
191
191
  into a cache hit instead of a second multi-hundred-megabyte download.
192
192
 
193
+ ### 4.1 The `.installed` sentinel
194
+
195
+ Installing the sidecar also writes `~/.gaia/agents/gaia/.installed`, the record
196
+ the daemon and the TUI both read to answer "is this agent installed?". It is
197
+ written on a **cache hit as well as a fresh download**, so an install staged by
198
+ an earlier release repairs itself on the next run, and only when the fetch
199
+ produced a runnable local install — that is, for the `sidecar` component (the
200
+ TUI is not a hub agent) **and** for this host's own platform. A `--platform`
201
+ fetch stages a binary for a different host; recording it would hand the daemon a
202
+ wrong-architecture executable that re-hashes correctly and then fails to exec.
203
+
204
+ ```json
205
+ {
206
+ "id": "gaia",
207
+ "version": "<sidecar componentVersion>",
208
+ "language": "python",
209
+ "installed_at": "<ISO-8601 UTC>",
210
+ "artifact_sha256": "<the verified SHA-256>",
211
+ "path": "<install directory>",
212
+ "artifact_kind": "binary",
213
+ "executable": "gaia-agent[.exe]"
214
+ }
215
+ ```
216
+
217
+ The shape is a cross-repo contract with `InstalledAgent.to_dict()` in
218
+ `gaia.hub.installer`. Three fields are load-bearing:
219
+ `gaia.daemon.sidecars.fetch._hub_installed_binary` ignores the install unless
220
+ `artifact_kind` is `binary` and `executable` and `artifact_sha256` are non-empty,
221
+ and it **re-hashes the binary and raises `IntegrityError` if the file no longer
222
+ matches `artifact_sha256`** — so the recorded hash is the one actually verified,
223
+ never the lock's nominal value. `executable` must be a bare filename;
224
+ `installer.read_sentinel` discards a sentinel whose executable contains a path
225
+ separator, which reads as "never installed".
226
+
227
+ Without the sentinel the daemon sees no install and the TUI falls back to running
228
+ `gaia-agent` as its own stdio child — spawning the REST sidecar over stdio and
229
+ filling the chat with uvicorn's startup log.
230
+
193
231
  The TUI directory is keyed by `agentVersion` — this package's version, not the
194
232
  component's — so a bump of either never reuses the previous release's executable.
195
233
 
@@ -207,6 +245,11 @@ Both defaults are overridable (`--sidecar-dir`, `--cache-dir`).
207
245
 
208
246
  Served by `gaia_agent.server`. Bound to `127.0.0.1` only.
209
247
 
248
+ Every request must carry a loopback `Host` header. A `Host` that is non-loopback,
249
+ absent or empty is refused with `400` before routing — that check is what defeats
250
+ DNS rebinding, so it fails closed rather than serving a request that simply omits
251
+ the header.
252
+
210
253
  | Property | Value |
211
254
  | ------------------ | --------------------------------------- |
212
255
  | Default port | `8141` (`DEFAULT_PORT` in `server.py`) |
@@ -246,6 +289,15 @@ collapses the rest to a one-line menu entry (re-activated by calling
246
289
  disables it for the session and every body renders. Omitting it is a valid, explicit one-shot: nothing
247
290
  persists past that single turn, and the agent is not told otherwise.
248
291
 
292
+ A retained session also carries a **project map** — up to 600 prompt tokens of
293
+ directory shape, entry points, installed commands and platform quirks, present
294
+ whenever the agent's working directory resolves to a code repository (a VCS
295
+ directory or a recognised manifest at its root). `GAIA_PROJECT_ROOT=<path>`
296
+ picks the project when the working directory is not it. If that repository has
297
+ no code index, the first turn starts one in a background thread;
298
+ `GAIA_PROJECT_MAP_AUTO_INDEX=0` disables that. Neither affects the wire
299
+ contract — they change what the agent knows and what the first turn costs.
300
+
249
301
  A second `/query` for a `session_id` that already has a turn in flight gets
250
302
  `409 Conflict` — cancel the running turn or wait for it, then retry. A
251
303
  `/query` that needs a **new** session while every retained slot is busy and
@@ -253,12 +305,23 @@ none is idle enough to evict gets `503` with the reason in `detail` — a
253
305
  temporary, retryable condition, not a bug: wait for a turn to finish (or
254
306
  close an idle session) and retry. A
255
307
  `session_id` can also be evicted from the retention table under an idle
256
- timeout or an LRU cap on concurrent sessions; a `/query` that lands on an
308
+ timeout (**4 hours** without a turn) or an LRU cap on concurrent sessions
309
+ (**100**) — the two knobs that decide when the `503` above can happen at all
310
+ (`session_registry.py`). Eviction is idle-only: a conversation still in use is
311
+ never timed out mid-flight. A `/query` that lands on an
257
312
  evicted id gets a fresh agent (the conversation is not blocked) but the
258
313
  response stream's first event is a `{"type":"status","status":"warning",...}`
259
314
  telling the caller that per-turn state — most visibly any loaded skill — did
260
315
  not survive and should be reloaded.
261
316
 
317
+ A second `/query` reusing a `run_id` that is still in flight gets `409` —
318
+ `run_id` is caller-minted, so mint a fresh UUID per request; reusing one would
319
+ leave the earlier run with no way to be cancelled. A `/query` supplying a `model`
320
+ that differs from the one its `session_id` was built with also gets `409`: only
321
+ agent construction reads a model, so the request cannot be honoured on the
322
+ retained agent. Omit `model` to continue on the session's current one, or start a
323
+ new `session_id` to switch.
324
+
262
325
  ### 5.3 Version gate
263
326
 
264
327
  `checkVersion()` reads `/version` and compares the **major** of `apiVersion`
@@ -266,13 +329,73 @@ against this package's `API_VERSION`. A differing major is a breaking contract
266
329
  change and raises `VersionMismatchError`. A higher minor with the same major is a
267
330
  backward-compatible addition and is accepted.
268
331
 
269
- ### 5.4 No caller-auth token
270
-
271
- The email sidecar authenticates callers with a per-session bearer minted into
272
- `GAIA_EMAIL_SIDECAR_TOKEN`. `gaia_agent` has **no equivalent** at 0.1.1, so
273
- this package mints and sends nothing. When the sidecar grows one, it lands here as
274
- a spawn-time env var and a request header — a change to this section, not a new
275
- subsystem.
332
+ ### 5.4 Caller authentication
333
+
334
+ Loopback binding is not access control: this sidecar exposes shell and file
335
+ tools, so an unauthenticated port lets any page the user visits drive it. Three
336
+ controls guard it, all in `gaia_agent.caller_auth`, which binds this sidecar's
337
+ env-var names and exempt paths onto the shared mechanism in
338
+ `gaia.sidecar.caller_auth`:
339
+
340
+ 1. **Per-session bearer token.** Every `/v1/gaia/*` request must carry
341
+ `Authorization: Bearer <token>`. The spawning parent supplies it one of two
342
+ ways: `GAIA_GAIA_SIDECAR_TOKEN_FILE` — the path to a `0600`, owner-only file
343
+ holding the token, which is the preferred channel because the secret never
344
+ sits in the environment — or `GAIA_GAIA_SIDECAR_TOKEN`, the token itself, the
345
+ legacy delivery. A request without it is **401**, with a `detail` naming both
346
+ env vars.
347
+ 2. **Host allowlist.** The `Host` header must be present *and* loopback
348
+ (`127.0.0.1`, `localhost`, `::1`); anything else — including an absent or
349
+ empty header — is **400**. This is what defeats DNS rebinding, where the
350
+ rebound request arrives with `Host: evil.com`, so it fails closed rather
351
+ than letting a caller opt out by omitting the header.
352
+ 3. **Origin rejection.** A request carrying a non-loopback browser `Origin` is
353
+ **403**. Non-browser clients send no `Origin` and are unaffected.
354
+
355
+ `/health`, `/version`, and `/v1/gaia/version` are **token-exempt** — they are
356
+ the probes a host polls during the attach handshake, before any token is in
357
+ play, and none of them exposes user data or accepts work. Host and Origin still
358
+ apply to them, so `waitForHealth` and `checkVersion` work against a
359
+ token-protected sidecar without knowing the token.
360
+
361
+ **Dev mode.** If neither env var is set the token check is skipped and the
362
+ sidecar logs a loud warning saying so; Host and Origin are still enforced. That
363
+ is the state a sidecar this package spawns comes up in, because `spawnSidecar`
364
+ mints nothing — pass your own token through `spawnSidecar`'s `env` option to
365
+ turn the check on. The shipped product does not rely on dev mode: the daemon
366
+ mints a per-session token and passes it as `GAIA_GAIA_SIDECAR_TOKEN_FILE`
367
+ (`gaia.daemon.sidecars.spec` mirrors both names as plain strings so core never
368
+ imports this wheel).
369
+
370
+ ### 5.5 Other transports
371
+
372
+ The HTTP server above is the surface this package drives, and everything in
373
+ this document describes it. It is not the agent's only transport:
374
+ `gaia_agent.stdio` runs the same agent over newline-delimited JSON on
375
+ stdin/stdout — no port, no token, no discovery file. The terminal UI reaches it
376
+ that way when it spawns the agent as a subprocess; an agent **installed** from
377
+ the hub, which is what this package delivers, is supervised by the daemon over
378
+ the HTTP surface above instead (§6.1).
379
+
380
+ It emits the identical canonical event vocabulary, but its input channel accepts
381
+ a JSON line carrying a `gaia_control` key, which gives it something HTTP does
382
+ not have: a back-channel that can answer a confirmation prompt *while* a turn is
383
+ in flight, and stop that turn (`cancel`) without ending the process — so loaded
384
+ skills, "always" grants, history and the bypass mode survive a cancel. It also
385
+ takes `--bypass-permissions` (start with gating off) and
386
+ `--use-claude` / `--claude-model` (route chat to the Anthropic API instead of
387
+ local Lemonade; embeddings stay on Lemonade either way). None of that is
388
+ reachable over `/v1/gaia/query`.
389
+
390
+ The stdio TUI also supports Local, Fireworks AI, and AMD LLM Gateway through
391
+ Lemonade. `/model` lists downloaded local and discovered cloud chat models;
392
+ `/model <id>` switches the live client while preserving conversation and skills.
393
+ Cloud IDs (`fireworks.*`, `amd.*`) route through Lemonade without local model
394
+ loading. Model-state status events identify the provider and remote inference.
395
+ The TUI's `/provider` panel configures credentials directly with local Lemonade;
396
+ keys never travel through stdio queries. These controls are not exposed over
397
+ `/v1/gaia/query`. Lemonade may independently be configured to route a model
398
+ remotely, so a local server URL alone does not establish local inference.
276
399
 
277
400
  ---
278
401
 
@@ -293,13 +416,23 @@ from `~/.gaia/agents/gaia/`. A sidecar spawned here would be a second process th
293
416
  TUI never talks to, competing for port `8141`.
294
417
 
295
418
  So `run`'s contribution to the sidecar is putting a **verified** binary where the
296
- daemon looks. Daemon and sidecar lifecycle belong to the daemon.
419
+ daemon looks, and recording the install in `.installed` (§4.1) so the daemon and
420
+ the TUI both see it. Daemon and sidecar lifecycle belong to the daemon.
297
421
 
298
422
  ### 6.2 `gaia serve` — the direct path
299
423
 
300
424
  `serve` fetches the sidecar and spawns it itself, for integrators who want the
301
- REST surface without a daemon or a UI. This path owns the process fully: spawn →
302
- `waitForHealth` → `checkVersion` → run until interrupted → tree-kill.
425
+ REST surface without a daemon or a UI. This path owns the process fully:
426
+ port pre-flight → spawn → `waitForHealth` → `checkVersion` → run until
427
+ interrupted → tree-kill.
428
+
429
+ The port is checked **before** the spawn, and a taken port raises
430
+ `PortInUseError` without starting anything. That ordering is the guarantee: the
431
+ frozen sidecar spends seconds unpacking before it attempts its bind, while an
432
+ incumbent answers `/health` in milliseconds — so a post-spawn check alone would
433
+ see a healthy port and a still-alive child, and hand back a handle for a server
434
+ it does not own. `assertOurs` and the re-probe after a failed health wait remain
435
+ as backstops for the narrower window where something binds after the pre-flight.
303
436
 
304
437
  ### 6.3 Tree-kill
305
438
 
@@ -325,8 +458,8 @@ failed start never leaks a process.
325
458
  | Code | Meaning |
326
459
  | ------- | ----------------------------------------------------------------------- |
327
460
  | `0` | Success |
328
- | `1` | A typed failure: `IntegrityError`, `PlatformError`, `HealthTimeoutError`, `VersionMismatchError`, `BinaryNotFoundError`, or an unexpected error |
329
- | `2` | Usage error: unknown command, unknown `--component`, invalid `--port` |
461
+ | `1` | A typed failure: `IntegrityError`, `PlatformError`, `HealthTimeoutError`, `VersionMismatchError`, `BinaryNotFoundError`, `PortInUseError`, `SidecarExitedError`, `MalformedResponseError`, or an unexpected error |
462
+ | `2` | Usage error: unknown command, invalid `--port`, or a flag the command does not read (`run --port`, `serve --component`, `serve --cache-dir`, `run`/`serve` `--platform`), an unknown `--component`, or a non-https `--base-url` without `--allow-insecure-base-url` |
330
463
  | *other* | From `run`: the TUI's own exit code, propagated verbatim |
331
464
 
332
465
  A TUI killed by a signal propagates as `128 + signum` (the shell convention), so a
@@ -340,12 +473,15 @@ All extend `GaiaError`, so `instanceof GaiaError` catches any of ours.
340
473
 
341
474
  | Class | Raised when |
342
475
  | ---------------------- | -------------------------------------------------------------- |
343
- | `IntegrityError` | A download's SHA-256 does not match the lock |
476
+ | `IntegrityError` | A download's SHA-256 does not match the lock, or a resolved binary's does not |
344
477
  | `PlatformError` | Unsupported platform, missing/incomplete entry, placeholder hash, malformed lock |
345
478
  | `HealthTimeoutError` | The sidecar did not report healthy within the timeout |
346
479
  | `VersionMismatchError` | `apiVersion` major differs from this package's |
347
480
  | `BinaryNotFoundError` | A binary is absent from disk when spawning |
481
+ | `PortInUseError` | The bind port was already taken; nothing was spawned |
482
+ | `SidecarExitedError` | The spawned sidecar died while the port answered — something else owns it |
348
483
  | `HttpError` | A non-2xx from a sidecar probe |
484
+ | `MalformedResponseError` | A 2xx from a sidecar probe whose body is not JSON |
349
485
 
350
486
  Per the repo's no-silent-fallbacks rule, every message names what failed, what to
351
487
  do, and where to look next.
@@ -370,7 +506,8 @@ do, and where to look next.
370
506
  Exported from the package root — see `src/index.ts`.
371
507
 
372
508
  **Fetch:** `fetchAll`, `fetchBinary`, `verifySha256`, `fileSha256`,
373
- `binaryExists`, `defaultCacheDir`, `daemonSidecarCacheDir`.
509
+ `binaryExists`, `defaultCacheDir`, `daemonSidecarCacheDir`,
510
+ `INSTALLED_SENTINEL_NAME`, `SIDECAR_AGENT_ID`.
374
511
 
375
512
  **Lifecycle:** `spawnSidecar`, `startSidecar`, `waitForHealth`, `checkVersion`,
376
513
  `health`, `version`, `shutdown`, `runTui`, `resolveSidecarPath`, `resolveTuiPath`,
@@ -384,6 +521,13 @@ Exported from the package root — see `src/index.ts`.
384
521
  **Constants:** `AGENT_ID`, `API_VERSION`, `DEFAULT_HOST`, `DEFAULT_PORT`,
385
522
  `RESERVED_PORT`.
386
523
 
524
+ `resolveSidecarPath` / `resolveTuiPath` re-verify the file's SHA-256 against
525
+ `binaries.lock.json` before returning a path that is about to be spawned — the
526
+ cache path is predictable, so anything able to write it would otherwise get code
527
+ run. That makes them **O(size of the binary)**, not a cheap path join: resolve
528
+ once at startup, not per request. Pass `{ verify: false }` for a binary you built
529
+ yourself, which no lock describes, and `{ lock }` to reuse an already-loaded lock.
530
+
387
531
  ---
388
532
 
389
533
  ## 11. Logging
@@ -1,76 +1,76 @@
1
1
  {
2
2
  "schemaVersion": "3.0",
3
- "agentVersion": "0.1.1",
3
+ "agentVersion": "0.2.0",
4
4
  "components": {
5
5
  "sidecar": {
6
- "componentVersion": "0.1.1",
7
- "baseUrl": "https://hub.amd-gaia.ai/agents/gaia/0.1.1",
6
+ "componentVersion": "0.2.0",
7
+ "baseUrl": "https://hub.amd-gaia.ai/agents/gaia/0.2.0",
8
8
  "platforms": {
9
9
  "win32-x64": {
10
10
  "filename": "gaia-agent-win32-x64.exe",
11
11
  "executable": "gaia-agent.exe",
12
- "sha256": "416ed0e24cd3457ae66acd1caaf8337d0761f093b86813d8640b7cad6236856a",
13
- "size": 90667208
12
+ "sha256": "5d2ea1a5fd40e2399ba2bf50aec80b44d89c7e7bfdbad846c050f683fc8bf842",
13
+ "size": 103531825
14
14
  },
15
15
  "darwin-arm64": {
16
16
  "filename": "gaia-agent-darwin-arm64",
17
17
  "executable": "gaia-agent",
18
- "sha256": "f962b8c7ce042938a940a01fcea5c94fc0ff33b4c05c02770804c89c31aaa1c2",
19
- "size": 74152544
18
+ "sha256": "fbe526e7eb044a2ce633fbd487a1ae75001683eff190d8359eabf9ccc7196069",
19
+ "size": 88311472
20
20
  },
21
21
  "darwin-x64": {
22
22
  "filename": "gaia-agent-darwin-x64",
23
23
  "executable": "gaia-agent",
24
- "sha256": "2f001c8c8e75b286c758269d0fc22fe57ef68ef5ee59476d960531e363b73c25",
25
- "size": 78642224
24
+ "sha256": "b02731c786d79d7f5e644662238a64d1761eb6a23d421821df46e6a8282b585d",
25
+ "size": 94294544
26
26
  },
27
27
  "linux-x64": {
28
28
  "filename": "gaia-agent-linux-x64",
29
29
  "executable": "gaia-agent",
30
- "sha256": "b704f83a4beaa4654a37cf8e2fcc7429cd6da91f7431c3381e7005370e760c7a",
31
- "size": 122029424
30
+ "sha256": "6c68a22c25954ea3c8d2a0a541bc04eba2401281d2a2b6448007983acd050a00",
31
+ "size": 140015368
32
32
  }
33
33
  }
34
34
  },
35
35
  "tui": {
36
- "componentVersion": "0.23.0",
37
- "baseUrl": "https://hub.amd-gaia.ai/agents/terminal-hub/0.23.0",
36
+ "componentVersion": "0.24.1",
37
+ "baseUrl": "https://hub.amd-gaia.ai/agents/terminal-hub/0.24.1",
38
38
  "platforms": {
39
39
  "win32-x64": {
40
40
  "filename": "gaia-win-x64.exe",
41
41
  "executable": "gaia-tui.exe",
42
- "sha256": "aad0c38c5c59e8107b608981f34608ee08feb7e5965de1cfcb929a49f21258ca",
43
- "size": 19433984
42
+ "sha256": "ce07269674de6773fd304d67eef3ff19cd578f447fc90a13f0309a61ae5ef504",
43
+ "size": 19213824
44
44
  },
45
45
  "win32-arm64": {
46
46
  "filename": "gaia-win-arm64.exe",
47
47
  "executable": "gaia-tui.exe",
48
- "sha256": "2f78c8128a8ad6d17d51938de12ed7aa29ceadb642af8d6e929920fe17e2463c",
49
- "size": 17975808
48
+ "sha256": "41f2a25b2bf15bea6f70626bb519496aeb822cc2629dd3529e325319b7abb3ca",
49
+ "size": 17744384
50
50
  },
51
51
  "darwin-arm64": {
52
52
  "filename": "gaia-darwin-arm64",
53
53
  "executable": "gaia-tui",
54
- "sha256": "766100459d22d9886a8d7839d5eddffb2a51134be76a57360b1584755be3cef1",
55
- "size": 17896354
54
+ "sha256": "dc334e89cebefaa2cc5117942ccbc4dc391034c3013ebf02d2bebf87f5e99a5a",
55
+ "size": 17499282
56
56
  },
57
57
  "darwin-x64": {
58
58
  "filename": "gaia-darwin-x64",
59
59
  "executable": "gaia-tui",
60
- "sha256": "494197bd2bf245df94de9361a274723536e73f275bbb92efc1c4db027cc08d9e",
61
- "size": 19003168
60
+ "sha256": "9af8c1fefeff4814bae6bbe9698ffaac3b59f248a422525e9f9b50812f5435da",
61
+ "size": 18629664
62
62
  },
63
63
  "linux-x64": {
64
64
  "filename": "gaia-linux-x64",
65
65
  "executable": "gaia-tui",
66
- "sha256": "d913c4cc4943f6a5a9be737cf397ad2a05e013e55a7880c27d4ddef973ee16f4",
67
- "size": 18661538
66
+ "sha256": "ef80198f3c4f6c2a4e92993fe19257c67cf009e29632398642549274ef8c2ffa",
67
+ "size": 18292898
68
68
  },
69
69
  "linux-arm64": {
70
70
  "filename": "gaia-linux-arm64",
71
71
  "executable": "gaia-tui",
72
- "sha256": "c8faee8cc7281819042d9e123d5a68b6ccfe4afa3d92d5fa3b2f42770df1d894",
73
- "size": 17432738
72
+ "sha256": "31d9e1fee15e4a21e677ca7abb707fcf08257d5e7f50f1baa71bc7159335c170",
73
+ "size": 17105058
74
74
  }
75
75
  }
76
76
  }
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAGA;;;;;;;;;;;GAWG;AA8BH,UAAU,UAAU;IAClB,CAAC,EAAE,MAAM,EAAE,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC;IACxC,mEAAmE;IACnE,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAaD,4EAA4E;AAC5E,qBAAa,UAAW,SAAQ,KAAK;CAAG;AAExC,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAuCpD;AA0ED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,GAAE,MAAM,GAAG,SAA+B,EACjD,KAAK,GAAE,MAAM,GAAG,SAA2B,GAC1C,MAAM,GAAG,SAAS,CAmBpB;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAQnG;AAqJD,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAiC1D"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAGA;;;;;;;;;;;GAWG;AA8BH,UAAU,UAAU;IAClB,CAAC,EAAE,MAAM,EAAE,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC;IACxC,mEAAmE;IACnE,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAaD,4EAA4E;AAC5E,qBAAa,UAAW,SAAQ,KAAK;CAAG;AAExC,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAuCpD;AAsID;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,GAAE,MAAM,GAAG,SAA+B,EACjD,KAAK,GAAE,MAAM,GAAG,SAA2B,GAC1C,MAAM,GAAG,SAAS,CAsBpB;AA+CD,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAUnG;AAuKD,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAkC1D"}
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * written, and a mismatch or a placeholder hash aborts the run. There is no
14
14
  * "continue anyway" path.
15
15
  */
16
- import { realpathSync } from "node:fs";
16
+ import { readFileSync, readdirSync, realpathSync } from "node:fs";
17
17
  import path from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { GaiaError } from "./errors.js";
@@ -88,7 +88,10 @@ Usage:
88
88
 
89
89
  run options:
90
90
  --base-url <url> Override the per-component download base URL from
91
- binaries.lock.json (applies to every component fetched)
91
+ binaries.lock.json (applies to every component fetched).
92
+ Must be https unless --allow-insecure-base-url is given.
93
+ --allow-insecure-base-url
94
+ Permit a non-https --base-url (a trusted local mirror)
92
95
  --cache-dir <dir> Where to cache the TUI binary
93
96
  --sidecar-dir <dir> Where to install the agent sidecar
94
97
  (default ~/.gaia/agents/gaia — the daemon's own cache)
@@ -102,6 +105,8 @@ fetch options:
102
105
 
103
106
  serve options:
104
107
  --port <n> Bind port (default ${DEFAULT_PORT}; ${RESERVED_PORT} is reserved and refused)
108
+ --base-url, --sidecar-dir, --force as above. \`serve\` never downloads the TUI,
109
+ so --cache-dir is refused rather than ignored.
105
110
 
106
111
  Notes:
107
112
  * No binaries are committed to the repo. Every download is SHA-256 verified
@@ -116,19 +121,69 @@ Notes:
116
121
  `;
117
122
  const flagStr = (args, k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
118
123
  /**
119
- * Shared download options. `--platform` is accepted only by `fetch`: `run` and
120
- * `serve` execute what they download, and honouring a foreign platform there
121
- * would write a wrong-architecture binary into the daemon's cache and then try
122
- * to spawn it.
124
+ * Which commands actually READ each value flag, and why the others refuse it.
125
+ * A flag a command ignores is worse than one it rejects: `run --port 9000`
126
+ * used to parse fine and silently come up on the default port.
123
127
  */
128
+ const VALUE_FLAG_SCOPE = {
129
+ "base-url": { commands: ["run", "fetch", "serve"], why: "it only affects downloads" },
130
+ "sidecar-dir": { commands: ["run", "fetch", "serve"], why: "it only affects downloads" },
131
+ platform: {
132
+ commands: ["fetch"],
133
+ why: "`run` and `serve` execute the binary, so it must be built for this host " +
134
+ `(${currentPlatformKey()})`,
135
+ },
136
+ port: {
137
+ commands: ["serve"],
138
+ why: "only `serve` binds a port; `run` reaches the agent through the daemon, which picks its own",
139
+ },
140
+ component: {
141
+ commands: ["fetch"],
142
+ why: "`run` needs both binaries and `serve` needs the sidecar, so neither can fetch a subset",
143
+ },
144
+ "cache-dir": {
145
+ commands: ["run", "fetch"],
146
+ why: "it caches the TUI, which `serve` never downloads (use --sidecar-dir)",
147
+ },
148
+ };
149
+ /** Refuse a value flag the chosen command would otherwise silently ignore. */
150
+ function assertFlagsInScope(args, cmd) {
151
+ for (const [flag, scope] of Object.entries(VALUE_FLAG_SCOPE)) {
152
+ if (args.flags[flag] === undefined || scope.commands.includes(cmd))
153
+ continue;
154
+ throw new UsageError(`--${flag} is not valid for \`gaia ${cmd}\` — ${scope.why}. ` +
155
+ `It is accepted by: ${scope.commands.map((c) => `\`gaia ${c}\``).join(", ")}.`);
156
+ }
157
+ }
158
+ /**
159
+ * Require https for a download source. Integrity still holds over plaintext
160
+ * (the SHA is pinned), but a MITM then surfaces as a baffling IntegrityError
161
+ * instead of the transport failure it is.
162
+ */
163
+ function checkedBaseUrl(args) {
164
+ const raw = flagStr(args, "base-url");
165
+ if (raw === undefined)
166
+ return undefined;
167
+ let parsed;
168
+ try {
169
+ parsed = new URL(raw);
170
+ }
171
+ catch {
172
+ throw new UsageError(`--base-url '${raw}' is not a valid absolute URL (expected e.g. https://host/path).`);
173
+ }
174
+ if (parsed.protocol !== "https:" && !args.flags["allow-insecure-base-url"]) {
175
+ throw new UsageError(`--base-url '${raw}' uses ${parsed.protocol}, not https. Downloads are ` +
176
+ "SHA-256 verified either way, but plaintext turns tampering into a " +
177
+ "confusing integrity failure instead of a transport error. Use an https " +
178
+ "URL, or pass --allow-insecure-base-url for a trusted local mirror.");
179
+ }
180
+ return raw;
181
+ }
182
+ /** Shared download options. Scope and scheme are validated before we get here. */
124
183
  function downloadOptions(args, allowPlatform = false) {
125
184
  const platformKey = flagStr(args, "platform");
126
- if (platformKey !== undefined && !allowPlatform) {
127
- throw new UsageError("--platform is only valid for `gaia fetch`. `run` and `serve` execute the " +
128
- `binary, so it must be built for this host (${currentPlatformKey()}).`);
129
- }
130
185
  return {
131
- baseUrl: flagStr(args, "base-url"),
186
+ baseUrl: checkedBaseUrl(args),
132
187
  ...(allowPlatform && platformKey !== undefined ? { platformKey } : {}),
133
188
  force: Boolean(args.flags["force"]),
134
189
  tuiDir: flagStr(args, "cache-dir"),
@@ -148,8 +203,10 @@ export function pathWithoutOwnShim(rawPath = process.env["PATH"], argv1 = proces
148
203
  return rawPath;
149
204
  const sep = process.platform === "win32" ? ";" : ":";
150
205
  let ownBinDir;
206
+ let ownName;
151
207
  try {
152
208
  ownBinDir = path.resolve(path.dirname(argv1));
209
+ ownName = path.basename(argv1);
153
210
  }
154
211
  catch {
155
212
  return rawPath;
@@ -160,14 +217,65 @@ export function pathWithoutOwnShim(rawPath = process.env["PATH"], argv1 = proces
160
217
  ? resolved.toLowerCase() === ownBinDir.toLowerCase()
161
218
  : resolved === ownBinDir;
162
219
  };
163
- return rawPath
164
- .split(sep)
165
- .filter((d) => d !== "" && !same(d))
166
- .join(sep);
220
+ const entries = rawPath.split(sep).filter((d) => d !== "");
221
+ const ours = entries.filter((d) => same(d) || shimTargetsScript(d, argv1));
222
+ const kept = entries.filter((d) => !ours.includes(d));
223
+ const shared = ours.filter((d) => !isExclusivelyOurs(d, same(d) ? ownName : "gaia"));
224
+ return [...kept, ...shared].join(sep);
225
+ }
226
+ /** Match npm's symlink or generated cmd/PowerShell wrapper to this script. */
227
+ function shimTargetsScript(dir, script) {
228
+ const canonical = (file) => {
229
+ try {
230
+ const resolved = realpathSync(file);
231
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
232
+ }
233
+ catch {
234
+ return undefined; // no resolvable executable at this candidate
235
+ }
236
+ };
237
+ const expected = canonical(script);
238
+ if (!expected)
239
+ return false;
240
+ for (const name of ["gaia", "gaia.cmd", "gaia.ps1"]) {
241
+ const shim = path.join(dir, name);
242
+ if (canonical(shim) === expected)
243
+ return true;
244
+ if (!name.includes("."))
245
+ continue;
246
+ let contents;
247
+ try {
248
+ contents = readFileSync(shim, "utf8");
249
+ }
250
+ catch {
251
+ continue; // this PATH directory does not contain a readable wrapper
252
+ }
253
+ for (const match of contents.matchAll(/["'](?:%dp0%|\$basedir)[\\/]([^"']+)["']/gi)) {
254
+ const target = path.resolve(dir, match[1].replace(/[\\/]/g, path.sep));
255
+ if (canonical(target) === expected)
256
+ return true;
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+ /** True when every file in `dir` is a shim for `name` (`gaia`, `gaia.cmd`, …). */
262
+ function isExclusivelyOurs(dir, name) {
263
+ const stem = name.replace(/\.(cmd|bat|ps1|exe)$/i, "");
264
+ let entries;
265
+ try {
266
+ entries = readdirSync(dir);
267
+ }
268
+ catch {
269
+ return false; // unreadable → treat as shared, the non-destructive branch
270
+ }
271
+ return (entries.length > 0 &&
272
+ entries.every((e) => e.replace(/\.(cmd|bat|ps1|exe)$/i, "") === stem));
167
273
  }
168
274
  /** Validate `--port`. Rejects out-of-range values and the reserved port. */
169
275
  export function resolvePort(raw) {
170
- const port = typeof raw === "string" ? Number(raw) : DEFAULT_PORT;
276
+ // Strict digits, not Number(): that accepts "0x2710", " 80 ", and "1e3",
277
+ // so a typo would quietly bind a port the user never named.
278
+ const port = typeof raw === "string" ? (/^\d+$/.test(raw) ? Number(raw) : NaN) : DEFAULT_PORT;
171
279
  if (!Number.isInteger(port) || port <= 0 || port > 65535 || port === RESERVED_PORT) {
172
280
  return {
173
281
  error: `--port must be a port in 1..65535 and not ${RESERVED_PORT} (got ${String(raw)})`,
@@ -231,6 +339,7 @@ async function cmdFetch(args) {
231
339
  }, null, 2) + "\n");
232
340
  return 0;
233
341
  }
342
+ const SERVE_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
234
343
  async function cmdServe(args) {
235
344
  const parsed = resolvePort(args.flags["port"]);
236
345
  if ("error" in parsed) {
@@ -257,11 +366,17 @@ async function cmdServe(args) {
257
366
  process.stdout.write(`\n ▸ GAIA agent: ${sidecar.baseUrl}/v1/gaia/query\n`);
258
367
  process.stdout.write(` Health: ${sidecar.baseUrl}/health\n`);
259
368
  process.stdout.write(" Lemonade must be running for live queries. Ctrl+C to stop.\n\n");
260
- await new Promise((resolve) => {
369
+ let stop;
370
+ const stopped = new Promise((resolve) => {
261
371
  let stopping = false;
262
- const stop = () => {
263
- if (stopping)
264
- return; // a second signal must not re-enter shutdown
372
+ stop = () => {
373
+ if (stopping) {
374
+ // Absorbed, so say what is happening — otherwise a repeat Ctrl+C
375
+ // during a slow teardown just looks like a frozen terminal.
376
+ process.stderr.write(`[gaia] already stopping the sidecar (pid ${String(sidecar.child.pid)}); ` +
377
+ "waiting for its process tree to exit ...\n");
378
+ return;
379
+ }
265
380
  stopping = true;
266
381
  process.stderr.write("\n[gaia] stopping the sidecar ...\n");
267
382
  void shutdown(sidecar).catch(() => undefined).finally(resolve);
@@ -270,9 +385,18 @@ async function cmdServe(args) {
270
385
  // otherwise hit Node's default disposition and kill us mid-teardown,
271
386
  // orphaning the detached sidecar tree on port 8141. The `stopping` guard
272
387
  // absorbs the repeats.
273
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
388
+ for (const sig of SERVE_SIGNALS)
274
389
  process.on(sig, stop);
275
390
  });
391
+ try {
392
+ await stopped;
393
+ }
394
+ finally {
395
+ // Left installed, they would swallow every later signal AND suppress
396
+ // Node's default disposition, so Ctrl+C would stop working entirely.
397
+ for (const sig of SERVE_SIGNALS)
398
+ process.removeListener(sig, stop);
399
+ }
276
400
  return 0;
277
401
  }
278
402
  catch (e) {
@@ -317,6 +441,7 @@ export async function main(argv) {
317
441
  throw new UsageError(`unexpected argument${extra.length > 1 ? "s" : ""} ${extra.map((a) => `'${a}'`).join(", ")}. ` +
318
442
  "Arguments for the terminal UI go after a bare `--`.");
319
443
  }
444
+ assertFlagsInScope(args, cmd);
320
445
  switch (cmd) {
321
446
  case "run":
322
447
  return cmdRun(args);