ai-fly 0.4.0-alpha.9 → 0.5.0-alpha.1

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.
Files changed (59) hide show
  1. package/README.md +161 -2
  2. package/dist/ai-fly.js +9 -6
  3. package/dist/app/main.js +4168 -3166
  4. package/dist/{common-CwlJNk3L.mjs → common-9KRvrs5f.mjs} +1 -1
  5. package/dist/{common-B6U1vLFw.mjs → common-DqSyhR6F.mjs} +25 -23
  6. package/dist/{daemon-iqO29D7K.mjs → daemon-Oh-SQUeo.mjs} +4 -4
  7. package/dist/{forget-BIorc8Rx.mjs → forget-DGJwF3Gy.mjs} +2 -2
  8. package/dist/{group-C13I2bCR.mjs → group-Dh64zhKn.mjs} +6 -6
  9. package/dist/hooks-BztmB61b.mjs +243 -0
  10. package/dist/http-errors-BA_8giMK.mjs +175 -0
  11. package/dist/{import-BhasJTGS.mjs → import-DpW4ignw.mjs} +5 -5
  12. package/dist/{join-BHhCLh9B.mjs → join-CLD4fdCf.mjs} +3 -3
  13. package/dist/{join-DiVTFi59.mjs → join-D5QUz8mh.mjs} +14 -10
  14. package/dist/{key-DYeTZ7MI.mjs → key-Cpw-HzT4.mjs} +26 -10
  15. package/dist/{key-DdZsNmo_.mjs → key-JLJewbw9.mjs} +3 -3
  16. package/dist/{local-test-B1ys1pNo.mjs → local-test-BGgTbZC7.mjs} +1 -1
  17. package/dist/{models-dev-DPT_BK_V.mjs → models-dev-BNxZl4yw.mjs} +170 -8
  18. package/dist/{ports-DDM5bbJg.mjs → ports-DmBKgLSi.mjs} +2 -2
  19. package/dist/{presets-Do5DWXfQ.mjs → presets-BwkVV7E2.mjs} +2 -2
  20. package/dist/{revoke-6lrZkjN4.mjs → revoke-CQdhajpu.mjs} +2 -2
  21. package/dist/{rewrite-vGrkDZx8.mjs → rewrite-BMXvJYE1.mjs} +120 -134
  22. package/dist/{route-test-DfGzFjKw.mjs → route-test-D7kj9-Xf.mjs} +4 -4
  23. package/dist/rpc-contract-BAz-C8fx.mjs +1171 -0
  24. package/dist/{run-BLxeLngt.mjs → run-nJ_AEsMr.mjs} +68 -5
  25. package/dist/{runtime-D8P29hIl.mjs → runtime-D9rSByOO.mjs} +401 -409
  26. package/dist/{secret-Ds6pIoLP.mjs → secret-BrfkuRkP.mjs} +9 -9
  27. package/dist/{secrets-bHJBznMX.mjs → secrets-AGbk6IRp.mjs} +17 -21
  28. package/dist/secrets-CmkG0pM4.mjs +2 -0
  29. package/dist/{serve-B4f79Eqq.mjs → serve-B7CRdGoy.mjs} +3 -2
  30. package/dist/serve-BlWkrYrl.mjs +1704 -0
  31. package/dist/{service-CYkpuJuc.mjs → service-P-vz61tx.mjs} +138 -41
  32. package/dist/services-Deh7Gxwa.mjs +100 -0
  33. package/dist/{settings-CSran7QD.mjs → settings-15j9BB6j.mjs} +1 -1
  34. package/dist/{settings-CY-bC0MJ.mjs → settings-DtjTKZfl.mjs} +2 -2
  35. package/dist/{share-B94HhX_b.mjs → share-BogH4NQJ.mjs} +32 -14
  36. package/dist/{status-C_EI8-b9.mjs → status-COzqwi-v.mjs} +14 -6
  37. package/dist/{status-BFpr-LHB.mjs → status-VCGexKPW.mjs} +8 -4
  38. package/dist/{store-DCLn0Df-.mjs → store-Bu4I1rNj.mjs} +103 -10
  39. package/dist/store-CmHtxOCd.mjs +1353 -0
  40. package/dist/{test-BERWIlFb.mjs → test-ChqaPwHA.mjs} +2 -2
  41. package/hooks/codex.cjs +58 -9
  42. package/hooks/env.cjs +4 -3
  43. package/hooks/file.cjs +7 -7
  44. package/hooks/rust-fetch.cjs +179 -0
  45. package/hooks/secret.cjs +4 -4
  46. package/package.json +4 -3
  47. package/webui/dist/assets/index-DQr5cBwc.js +67 -0
  48. package/webui/dist/assets/index-Dne1d-yC.css +2 -0
  49. package/webui/dist/index.html +2 -2
  50. package/dist/frames-BDz8buix.mjs +0 -386
  51. package/dist/hook-CvAMGts4.mjs +0 -228
  52. package/dist/hooks-BeBzkBL-.mjs +0 -142
  53. package/dist/mux-DlwEK4Tt.mjs +0 -630
  54. package/dist/rpc-contract-BXviZUtF.mjs +0 -683
  55. package/dist/secrets-G2HTT66T.mjs +0 -2
  56. package/dist/serve-C8VRRJrt.mjs +0 -1254
  57. package/dist/store-DxU0bd_u.mjs +0 -636
  58. package/webui/dist/assets/index-BziQsPBL.js +0 -77
  59. package/webui/dist/assets/index-CufH1z8j.css +0 -2
package/README.md CHANGED
@@ -8,7 +8,7 @@ Share any HTTP/WebSocket upstream (ollama / vllm / LM Studio, an internal gatewa
8
8
  Consumer machine Provider machine
9
9
  Agent ──► http://127.0.0.1:11434/… ollama :11434 (or any HTTP/WS upstream)
10
10
  ──► ws://127.0.0.1:11434/… ▲ provider engine
11
- consumer engine │ match set + rewrite ($env keys)
11
+ consumer engine │ match set + lifecycle hooks (auth/headers)
12
12
  │ AUTH keyring / catalog │ quotas (per key)
13
13
  └────── OpenDWeb fabric ──────┘ (identity / invites / QUIC + relay)
14
14
  ```
@@ -38,15 +38,122 @@ credential injection impossible without MITM).
38
38
  ```bash
39
39
  # Provider
40
40
  ai-fly serve --upstream http://127.0.0.1:11434 # run + manage services/groups
41
+ ai-fly service stop ollama # pause exposure (catalog drops it, config kept)
42
+ ai-fly service start ollama # resume; consumers see it again
41
43
  ai-fly key issue --group friends # sk-aifly-… keys
42
44
  ai-fly share --group friends --ttl 30m # aifly1.… link (token + key)
43
45
 
44
46
  # Consumer
45
47
  ai-fly import <link> --run # join + keyring + start gateway
46
48
  ai-fly key add <sk-aifly-…> --provider <id> # bare key into an existing ring
49
+ ai-fly services # list services across groups (state + ports)
50
+ ai-fly services stop <provider> # stop ALL of a provider's services (revivable)
51
+ ai-fly services stop <provider> <service> # stop one service's local listener (live)
52
+ ai-fly services rm <provider> # remove the whole provider (= forget: keyring + fabric identity)
53
+ ai-fly services start <provider> [<service>] # re-enable (per-service stops persist across ring stops)
47
54
  ai-fly status --verbose # ports, providers, service details
48
55
  ```
49
56
 
57
+ Service lifecycle: a stopped provider service disappears from the consumer catalog
58
+ (local listener closes, in-flight requests abort, requests get 404); a stopped
59
+ consumer service is "removed but revivable" — the provider's catalog sync still
60
+ updates the entry, it just never re-materializes a local port until you start it
61
+ back. The running gateway daemon picks these changes up live (keyring watch); the
62
+ dashboard's port table exposes the same start/stop/remove actions.
63
+
64
+ ## Request lifecycle (hooks v2)
65
+
66
+ Every service runs a four-stage pipeline around the upstream call:
67
+
68
+ `onRequestBearerAuthentication (1) -> onRequestHeaders (2) -> onRequest (3) -> onResponse (4)`
69
+
70
+ - `service.auth` (stage 1) — Authorization header source: `{secret: <name>}`,
71
+ `{script: <name>, args?}` or `{literal: <value>}` (literals accept `$env:<VAR>` /
72
+ `$secret:<name>` indirection, resolved per request), plus `bearer: false` to drop
73
+ the default `Bearer ` prefix.
74
+ - `service.headers` (stage 2) — `remove: [...]`, `set: {name: literal}` and an
75
+ optional whole-stage `script: {name, args?}` returning `{set?, remove?}` (script
76
+ output wins over the declared set).
77
+ - `service.request` (stage 3) — takes over the outbound call entirely; ctx is
78
+ `{url, method, headers, body, signal}`, returns `{status, headers, body?}`.
79
+ - `service.response` (stage 4) — post-processes the response; ctx is
80
+ `{status, headers, body, signal}`, returns `{status?, headers?, body?}`.
81
+
82
+ Hook scripts export one function per stage they implement — the export name is the
83
+ stage name. Builtin library: `codex` (reads `$CODEX_HOME/auth.json`, falling back to
84
+ `~/.codex/auth.json` — read-only, ai-fly never writes credential files; set
85
+ `CODEX_HOME` when your `~/.codex` holds a different Codex CLI config), plus `env` /
86
+ `file` / `secret` bridges for stage 1.
87
+ All stage fns receive `{homedir, args, secrets, env}`; stages 1-2 additionally get
88
+ the request-level `{method, path, headers}`. Returned streams (3/4 bodies) are
89
+ cancelled when the engine aborts the request.
90
+
91
+ ```bash
92
+ # --secret -> auth = {secret: mykey}; --headers-script -> stage-2 whole-stage
93
+ # script; --request-script/--response-script -> stage 3/4 bindings
94
+ ai-fly service add myapi --upstream https://api.example.com --match suffix:api.example.com \
95
+ --secret mykey \
96
+ --header-set x-org=acme --header-remove x-internal \
97
+ --headers-script hdrfix \
98
+ --request-script mock-up --response-script log
99
+ ai-fly hooks list # stage matrix per script
100
+ ai-fly hooks run codex --stage onRequestBearerAuthentication
101
+ ```
102
+
103
+ **Breaking changes (hooks-lifecycle v2)**
104
+
105
+ - Old hook scripts must be rewritten, not just renamed: export names changed
106
+ (`authHeader` -> `onRequestBearerAuthentication`, etc.) and the runtime contract
107
+ is new — request-scoped ctx, 3/4 object return shapes, stream cancellation.
108
+ - v1 `services.json` (pre-`version: 2`) is invalid: the store enters a legacy
109
+ read-only state (`ai-fly service list` and `status --verbose` show the stale
110
+ names); remove them with `ai-fly service remove <name>`, then re-add services
111
+ to rebuild a clean v2 store (groups and share keys are not carried over).
112
+ - The v1 string form `--hooks "<script>"` became an object slot in preset mode:
113
+ `--hooks <name>` binds one whole-script lifecycle (see below), mutually
114
+ exclusive with per-stage flags; `--secret` fills `auth.secret`; `--header-set`
115
+ values accept literals and `$env:`/`$secret:` references only (per-header hook
116
+ objects are gone — bind a stage script with `--headers-script` instead).
117
+
118
+ ## Lifecycle binding modes & rust-fetch
119
+
120
+ Lifecycle config has two **mutually exclusive** modes (Owner ruling 2026-09-15):
121
+
122
+ - **custom** — pick per-stage scripts or inline config (auth secret/literal,
123
+ header set/remove, …). The default.
124
+ - **preset** — bind one hook-js with `hooks: {script}` (CLI `--hooks <name>`);
125
+ the script's stage exports form the whole lifecycle. Stages without an export
126
+ fall back to defaults (stage 3 unbound = js-backend-fetch — the current JS
127
+ runtime backend's fetch, Node/Deno/Bun alike).
128
+
129
+ The `codex` preset runs in preset mode: the built-in `codex` script provides the
130
+ complete lifecycle — ① auth token from `$CODEX_HOME/auth.json` (falling back to
131
+ `~/.codex/auth.json`, read-only), ② the codex CLI header set (`chatgpt-account-id`,
132
+ `originator`, `openai-beta`, user-agent), ③ outbound via the **rust-fetch** sidecar.
133
+ Launch e.g. `CODEX_HOME=~/.ai-fly/codex-home pnpm app:dev` to keep that credential
134
+ copy fully isolated from your own `~/.codex`.
135
+
136
+ `rust-fetch` (rustls TLS + HTTP/2, a client stack distinct from
137
+ js-backend-fetch) replaces the outbound HTTPS call — empirical A/B against
138
+ chatgpt.com: GET /rate_limits via js-backend-fetch → 403 (Cloudflare, 0/13
139
+ historically) vs rust-fetch → 404 (passes CF, reaches the backend); POST
140
+ /responses with a real subscription → 200 + SSE through the full pipeline.
141
+
142
+ Build & install the sidecar (not shipped in the repo) — **required before the
143
+ codex preset can serve**; without it every request fails with a masked
144
+ `hook_failed` (the daemon logs a one-line install hint to its stderr):
145
+
146
+ ```bash
147
+ pnpm sidecar:install # cargo build --release + copy to ~/.aifly/sidecars/rust-fetch/
148
+ # or point AIFLY_RUST_FETCH_BIN at the binary when starting the daemon
149
+ ```
150
+
151
+ Bind it per service (custom mode, stage 3 only) with `--request-script
152
+ rust-fetch` (or the stage-3 selector in the WebUI); the codex preset carries it
153
+ automatically. Per-request process spawn (no pooling yet); env proxies
154
+ (HTTPS_PROXY/…) follow the Rust stack's defaults. stdio protocol is frozen in
155
+ `openspec/changes/rust-fetch-sidecar`.
156
+
50
157
  ## Development
51
158
 
52
159
  ```bash
@@ -61,6 +168,35 @@ Node >= 20. License: MIT OR Apache-2.0. The fabric dependency
61
168
  `@jixo/opendweb-client-sdk` ships native binaries for darwin-arm64 and win32-x64
62
169
  (no Linux yet).
63
170
 
171
+ ### Local link development against the opendweb kernel
172
+
173
+ The consumer/provider data plane rides the opendweb session-continuity kernel
174
+ (`Fabric.openSession` / `fetchHttp` / `serveHttp`; the aifly envelope wire
175
+ protocol is retired). While iterating against the opendweb workspace, link the
176
+ SDK instead of installing from the registry:
177
+
178
+ ```bash
179
+ # one-time, inside the opendweb workspace
180
+ cd /path/to/opendweb/packages/client-sdk && npm link
181
+
182
+ # then in this repo (replaces the registry install with a symlink)
183
+ npm link @jixo/opendweb-client-sdk
184
+ ```
185
+
186
+ Notes:
187
+
188
+ - Do **not** run `pnpm install` in this repo while the link is in place — it
189
+ resolves the symlink back to the registry version. Add new dependencies in a
190
+ separate step and re-link afterwards.
191
+ - `test/e2e/kernel-migration.test.mjs` is the real-kernel acceptance suite
192
+ (dual in-process fabrics + serveHttp/fetchHttp; SSE mid-stream resume, dead
193
+ semantics, provider restart, WS tunnel). It runs via
194
+ `node --import tsx --test --test-force-exit ...` (the native runtime keeps
195
+ the event loop alive; force-exit is required, mirroring the SDK's own test
196
+ script).
197
+ - The `package.json` version stays on the published semver (`^0.5.0` line once
198
+ the dual-release milestone lands); the link is a dev-time override only.
199
+
64
200
  ## Manual regression checklist
65
201
 
66
202
  Desktop-shell behaviors that automated suites do not cover. Run before any
@@ -95,7 +231,7 @@ strings below are exactly what the UI shows.
95
231
  plain browser tab the layout must look identical (zero inset fallback).
96
232
  9. **Secrets panel** — Advanced > secrets: add a key-value pair, pick it in
97
233
  the share wizard's "api key" selector, generate a service; the saved
98
- rewrite shows `authorization: $secret:<name>`; values never re-display
234
+ service shows the auth slot `secret <name>`; values never re-display
99
235
  after saving; removing a secret referenced by a service is allowed but the
100
236
  service then fails with `secret_missing` until re-added.
101
237
  10. **Connectivity test** — in the share wizard (with a secret picked) or a
@@ -103,3 +239,26 @@ strings below are exactly what the UI shows.
103
239
  picked by default, the dropdown lists models sorted by price, and the
104
240
  result line shows `ok · <ms> · <model>` or the upstream error (an
105
241
  upstream 401 with an invalid key is a PASS — the network is proven).
242
+ 11. **Service lifecycle** — with a running gateway, stop a service from the
243
+ dashboard's port table (or `ai-fly services stop`): its local port
244
+ refuses connections within ~a second and the row flips to disabled;
245
+ start restores the same port. Stop it on the *provider* (`ai-fly service
246
+ stop <name>`): the consumer's row disappears (catalog sync); start brings
247
+ it back. Remove on the consumer keeps the entry hidden-but-revivable —
248
+ `ai-fly services` still lists it as `disabled`, and starting it again
249
+ works without re-importing.
250
+ 12. **Codex e2e through the gateway** — with a provider exposing a codex
251
+ subscription service and a consumer gateway running, point Codex CLI at
252
+ the mapped port (`OPENAI_BASE_URL=http://127.0.0.1:<port>/v1` with the
253
+ group key): a chat completion streams through, tool calls round-trip, and
254
+ `ai-fly status --verbose` shows the served count climbing.
255
+ 13. **SSE mid-stream connection swap (kernel continuity)** — start a long
256
+ streaming request through the gateway (e.g. a slow/long codex response),
257
+ then kill the provider's network path mid-stream (`ai-fly` fabric reset
258
+ injection or simply restart the provider process within the recovery
259
+ window): the stream continues in order with **no duplicated tokens**, the
260
+ upstream request is NOT re-executed (provider logs show one execution),
261
+ and `ai-fly status` shows the provider flipping to offline then back —
262
+ the client never sees an error. Kill the provider for longer than the
263
+ recovery window: in-flight requests fail with a network error and only
264
+ *new* requests get 503 `provider_offline`.
package/dist/ai-fly.js CHANGED
@@ -37,6 +37,9 @@ Consumer:
37
37
  ai-fly test [--data <dir>] [--service <name>] [--form …] [--content <text>]
38
38
  single AI request through the local gateway
39
39
  ai-fly key add <sk-aifly-key> --provider <id> bare key into an existing ring
40
+ ai-fly services [list] services across groups (state + ports)
41
+ ai-fly services stop|start <provider> [<service>] provider- or service-level lifecycle (live)
42
+ ai-fly services rm <provider> [<service>] rm provider (= forget) or one service
40
43
  ai-fly forget <endpointId|8-char-prefix> drop an imported provider
41
44
 
42
45
  Config:
@@ -63,7 +66,7 @@ async function dispatch(command, rest) {
63
66
  const lazy = (load) => {
64
67
  return (argv, ctx) => load().then((m) => m.run([...argv], ctx));
65
68
  };
66
- return match(command).with("serve", () => lazy(() => import("./serve-B4f79Eqq.mjs"))(rest)).with("app", async () => {
69
+ return match(command).with("serve", () => lazy(() => import("./serve-B7CRdGoy.mjs"))(rest)).with("app", async () => {
67
70
  const { join, dirname } = await import("node:path");
68
71
  const { fileURLToPath } = await import("node:url");
69
72
  const { spawn } = await import("node:child_process");
@@ -78,17 +81,17 @@ async function dispatch(command, rest) {
78
81
  return await new Promise((resolve) => {
79
82
  child.once("exit", (c) => resolve(c ?? 0));
80
83
  });
81
- }).with("daemon", () => lazy(() => import("./daemon-iqO29D7K.mjs"))(rest)).with("presets", () => lazy(() => import("./presets-Do5DWXfQ.mjs"))(rest)).with("hooks", () => lazy(() => import("./hooks-BeBzkBL-.mjs"))(rest)).with("secret", () => lazy(() => import("./secret-Ds6pIoLP.mjs"))(rest)).with("settings", async () => (await import("./settings-CSran7QD.mjs")).runAsSettings(rest, {})).with("relay", async () => (await import("./settings-CSran7QD.mjs")).runAsRelay(rest, {})).with("test", () => lazy(() => import("./test-BERWIlFb.mjs"))(rest)).with("service", () => lazy(() => import("./service-CYkpuJuc.mjs"))(rest)).with("group", () => lazy(() => import("./group-C13I2bCR.mjs"))(rest)).with("share", () => lazy(() => import("./share-B94HhX_b.mjs"))(rest)).with("revoke", () => lazy(() => import("./revoke-6lrZkjN4.mjs"))(rest)).with("join", () => lazy(() => import("./join-BHhCLh9B.mjs"))(rest)).with("import", () => lazy(() => import("./import-BhasJTGS.mjs"))(rest)).with("run", () => lazy(() => import("./run-BLxeLngt.mjs"))(rest)).with("ports", () => lazy(() => import("./ports-DDM5bbJg.mjs"))(rest)).with("forget", () => lazy(() => import("./forget-BIorc8Rx.mjs"))(rest)).with("key", () => {
82
- if (rest[0] === "add") return lazy(() => import("./key-DdZsNmo_.mjs"))(rest);
83
- return lazy(() => import("./key-DYeTZ7MI.mjs"))(rest);
84
+ }).with("daemon", () => lazy(() => import("./daemon-Oh-SQUeo.mjs"))(rest)).with("presets", () => lazy(() => import("./presets-BwkVV7E2.mjs"))(rest)).with("hooks", () => lazy(() => import("./hooks-BztmB61b.mjs"))(rest)).with("secret", () => lazy(() => import("./secret-BrfkuRkP.mjs"))(rest)).with("settings", async () => (await import("./settings-15j9BB6j.mjs")).runAsSettings(rest, {})).with("relay", async () => (await import("./settings-15j9BB6j.mjs")).runAsRelay(rest, {})).with("test", () => lazy(() => import("./test-ChqaPwHA.mjs"))(rest)).with("service", () => lazy(() => import("./service-P-vz61tx.mjs"))(rest)).with("group", () => lazy(() => import("./group-Dh64zhKn.mjs"))(rest)).with("share", () => lazy(() => import("./share-BogH4NQJ.mjs"))(rest)).with("revoke", () => lazy(() => import("./revoke-CQdhajpu.mjs"))(rest)).with("join", () => lazy(() => import("./join-CLD4fdCf.mjs"))(rest)).with("import", () => lazy(() => import("./import-DpW4ignw.mjs"))(rest)).with("run", () => lazy(() => import("./run-nJ_AEsMr.mjs"))(rest)).with("ports", () => lazy(() => import("./ports-DmBKgLSi.mjs"))(rest)).with("forget", () => lazy(() => import("./forget-DGJwF3Gy.mjs"))(rest)).with("services", () => lazy(() => import("./services-Deh7Gxwa.mjs"))(rest)).with("key", () => {
85
+ if (rest[0] === "add") return lazy(() => import("./key-JLJewbw9.mjs"))(rest);
86
+ return lazy(() => import("./key-Cpw-HzT4.mjs"))(rest);
84
87
  }).with("status", async () => {
85
88
  const [scope, ...tail] = rest;
86
89
  const wantProvider = scope === "provider" || scope !== "consumer" && providerDirExists();
87
90
  const wantConsumer = scope === "consumer" || scope !== "provider" && consumersRootExists();
88
91
  const argv = scope === "provider" || scope === "consumer" ? tail : rest;
89
92
  let code = 0;
90
- if (wantProvider) code = Math.max(code, await lazy(() => import("./status-BFpr-LHB.mjs"))(argv));
91
- if (wantConsumer) code = Math.max(code, await lazy(() => import("./status-C_EI8-b9.mjs"))(argv));
93
+ if (wantProvider) code = Math.max(code, await lazy(() => import("./status-VCGexKPW.mjs"))(argv));
94
+ if (wantConsumer) code = Math.max(code, await lazy(() => import("./status-COzqwi-v.mjs"))(argv));
92
95
  if (!wantProvider && !wantConsumer) process.stdout.write("nothing to report: no provider storage and no imported consumers\n");
93
96
  return code;
94
97
  }).otherwise(() => {