libfx 0.0.13 → 0.0.14

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/browser.js CHANGED
@@ -1,6 +1,11 @@
1
+ import { createDurableAgentFactory, memory } from "./durable.js";
2
+ import { fxHarness } from "./fx-harness.js";
1
3
  import {
2
- createFxAgent as createWasmAgent,
4
+ createFxEngine as createWasmAgent,
3
5
  createFxTerminal as createWasmTerminal,
6
+ createMemoryPersistence,
7
+ FxFencedError,
8
+ FxJournalVersionError,
4
9
  encodeXtermKeyEvent,
5
10
  fxSdkApiVersion,
6
11
  listModels,
@@ -8,16 +13,25 @@ import {
8
13
  xtermAdapter,
9
14
  } from "./fx-sdk.js";
10
15
 
11
- export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
12
- export const libfxApiVersion = 2;
16
+ export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, xtermAdapter };
17
+ export const libfxApiVersion = 3;
13
18
 
14
19
  const defaultCoreWasm = new URL("./fx-core.wasm", import.meta.url).href;
15
20
  const defaultTermWasm = new URL("./fx-term.wasm", import.meta.url).href;
16
21
 
17
- export function createFxAgent(options = {}) {
22
+ /** One fx session on WebAssembly, with no durability of its own. */
23
+ export function createFxEngine(options = {}) {
18
24
  return createWasmAgent({ ...options, wasm: options.wasm ?? defaultCoreWasm });
19
25
  }
20
26
 
27
+ /** An agent whose sessions live in this page unless `durability` says otherwise. */
28
+ export const createFxAgent = createDurableAgentFactory({
29
+ name: "createFxAgent",
30
+ label: "fx agent",
31
+ defaultDurability: async () => memory(),
32
+ harness: fxHarness({ createEngine: createFxEngine }),
33
+ });
34
+
21
35
  export function createFxTerminal(options = {}) {
22
36
  return createWasmTerminal({ ...options, wasm: options.wasm ?? defaultTermWasm });
23
37
  }
@@ -0,0 +1,141 @@
1
+ // `libfx/chatgpt`: sign in with ChatGPT for a Codex agent. Two flows, both
2
+ // plain data between steps so a website can start in one request and finish in
3
+ // another:
4
+ //
5
+ // - Paste the address: the user signs in on OpenAI, lands on a page at
6
+ // localhost:1455 that does not load, and pastes its address back.
7
+ // - Device code: the user enters a short code on OpenAI's page. ChatGPT
8
+ // workspaces and accounts must turn device code sign-in on first.
9
+ import { chatgptClientId, chatgptIssuer, normalizeChatgptCredentials, refreshChatgptCredentials } from "./chatgpt.js";
10
+ import { hostFetch, pkceChallenge, randomSecret, readSignInAddress, signInError, wait } from "./oauth.js";
11
+
12
+ const redirectUri = "http://localhost:1455/auth/callback";
13
+ const deviceRedirectUri = `${chatgptIssuer}/deviceauth/callback`;
14
+ const scope = "openid profile email offline_access api.connectors.read api.connectors.invoke";
15
+ const deviceCodeLifetimeMs = 15 * 60 * 1000;
16
+
17
+ /** Starts a sign-in. Keep the result on the server: `verifier` is secret. */
18
+ export async function startSignIn() {
19
+ const verifier = randomSecret();
20
+ const state = randomSecret();
21
+ const challenge = await pkceChallenge(verifier);
22
+ const url = new URL(`${chatgptIssuer}/oauth/authorize`);
23
+ for (const [key, value] of Object.entries({
24
+ response_type: "code",
25
+ client_id: chatgptClientId,
26
+ redirect_uri: redirectUri,
27
+ scope,
28
+ code_challenge: challenge,
29
+ code_challenge_method: "S256",
30
+ id_token_add_organizations: "true",
31
+ codex_cli_simplified_flow: "true",
32
+ state,
33
+ originator: "fx",
34
+ })) url.searchParams.set(key, value);
35
+ return { url: url.href, state, verifier };
36
+ }
37
+
38
+ async function exchangeCode({ code, verifier, redirect, fetch, signal }) {
39
+ const response = await hostFetch(fetch)(`${chatgptIssuer}/oauth/token`, {
40
+ method: "POST",
41
+ headers: { "content-type": "application/x-www-form-urlencoded" },
42
+ body: new URLSearchParams({ grant_type: "authorization_code", client_id: chatgptClientId, code, code_verifier: verifier, redirect_uri: redirect }).toString(),
43
+ signal,
44
+ });
45
+ if (response.status === 400 || response.status === 401) {
46
+ await response.body?.cancel().catch(() => {});
47
+ throw signInError("LIBFX_CHATGPT_SIGN_IN_EXPIRED", "This sign-in expired or was already used. Start again.", { status: response.status });
48
+ }
49
+ if (!response.ok) {
50
+ await response.body?.cancel().catch(() => {});
51
+ throw signInError("LIBFX_CHATGPT_SIGN_IN_FAILED", `OpenAI could not finish the sign-in (HTTP ${response.status}).`, { status: response.status });
52
+ }
53
+ const tokens = await response.json();
54
+ return normalizeChatgptCredentials({
55
+ accessToken: tokens.access_token,
56
+ refreshToken: tokens.refresh_token,
57
+ ...(Number.isFinite(tokens.expires_in) ? { expiresAt: Date.now() + tokens.expires_in * 1000 } : {}),
58
+ });
59
+ }
60
+
61
+ /** Finishes a sign-in from the address the user pasted. */
62
+ export async function finishSignIn(signIn, address, { fetch, signal } = {}) {
63
+ if (!signIn || typeof signIn.state !== "string" || typeof signIn.verifier !== "string") {
64
+ throw new TypeError("finishSignIn() needs the object startSignIn() returned");
65
+ }
66
+ const { code, state } = readSignInAddress(address, { prefix: "LIBFX_CHATGPT", issuer: "OpenAI", redirectUri });
67
+ if (state !== signIn.state) {
68
+ throw signInError("LIBFX_CHATGPT_SIGN_IN_STATE_MISMATCH", "That address is from a different sign-in. Start again and paste the address from this one.");
69
+ }
70
+ return exchangeCode({ code, verifier: signIn.verifier, redirect: redirectUri, fetch, signal });
71
+ }
72
+
73
+ /** Starts a device code sign-in. Show `userCode` and `verificationUrl`. */
74
+ export async function startDeviceSignIn({ fetch, signal } = {}) {
75
+ const response = await hostFetch(fetch)(`${chatgptIssuer}/api/accounts/deviceauth/usercode`, {
76
+ method: "POST",
77
+ headers: { "content-type": "application/json" },
78
+ body: JSON.stringify({ client_id: chatgptClientId }),
79
+ signal,
80
+ });
81
+ if (!response.ok) {
82
+ await response.body?.cancel().catch(() => {});
83
+ throw signInError("LIBFX_CHATGPT_DEVICE_CODE_UNAVAILABLE", `OpenAI did not issue a device code (HTTP ${response.status}).`, { status: response.status });
84
+ }
85
+ const body = await response.json();
86
+ const userCode = body.user_code ?? body.usercode;
87
+ if (typeof userCode !== "string" || typeof body.device_auth_id !== "string") {
88
+ throw signInError("LIBFX_CHATGPT_DEVICE_CODE_UNAVAILABLE", "OpenAI returned a device code response libfx does not understand.");
89
+ }
90
+ const interval = Number(body.interval);
91
+ return {
92
+ verificationUrl: `${chatgptIssuer}/codex/device`,
93
+ userCode,
94
+ deviceAuthId: body.device_auth_id,
95
+ intervalMs: Math.max(Number.isFinite(interval) ? interval : 5, 1) * 1000,
96
+ expiresAt: Date.now() + deviceCodeLifetimeMs,
97
+ };
98
+ }
99
+
100
+ /** Checks a device sign-in once: credentials when the user has entered the code, otherwise null. */
101
+ export async function pollDeviceSignIn(device, { fetch, signal } = {}) {
102
+ if (!device || typeof device.deviceAuthId !== "string" || typeof device.userCode !== "string") {
103
+ throw new TypeError("pollDeviceSignIn() needs the object startDeviceSignIn() returned");
104
+ }
105
+ if (Date.now() > device.expiresAt) {
106
+ throw signInError(
107
+ "LIBFX_CHATGPT_DEVICE_CODE_EXPIRED",
108
+ "The code expired before it was entered. If OpenAI's page said device code sign-in is off, turn it on in ChatGPT's security settings, or ask your workspace admin.",
109
+ );
110
+ }
111
+ const response = await hostFetch(fetch)(`${chatgptIssuer}/api/accounts/deviceauth/token`, {
112
+ method: "POST",
113
+ headers: { "content-type": "application/json" },
114
+ body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
115
+ signal,
116
+ });
117
+ if (response.status === 403 || response.status === 404) {
118
+ await response.body?.cancel().catch(() => {});
119
+ return null;
120
+ }
121
+ if (!response.ok) {
122
+ await response.body?.cancel().catch(() => {});
123
+ throw signInError("LIBFX_CHATGPT_SIGN_IN_FAILED", `OpenAI could not finish the sign-in (HTTP ${response.status}).`, { status: response.status });
124
+ }
125
+ const grant = await response.json();
126
+ return exchangeCode({ code: grant.authorization_code, verifier: grant.code_verifier, redirect: deviceRedirectUri, fetch, signal });
127
+ }
128
+
129
+ /** Waits until the user enters the code, polling at OpenAI's interval. */
130
+ export async function completeDeviceSignIn(device, { fetch, signal } = {}) {
131
+ for (;;) {
132
+ const credentials = await pollDeviceSignIn(device, { fetch, signal });
133
+ if (credentials) return credentials;
134
+ await wait(device.intervalMs, signal);
135
+ }
136
+ }
137
+
138
+ /** Refreshes credentials. Codex agents refresh on their own; this is for hosts. */
139
+ export function refreshCredentials(credentials, { fetch, signal } = {}) {
140
+ return refreshChatgptCredentials(normalizeChatgptCredentials(credentials), { fetch, signal });
141
+ }
package/chatgpt.js ADDED
@@ -0,0 +1,69 @@
1
+ // ChatGPT (Codex) credentials for libfx agents: the Codex endpoints, how a
2
+ // ChatGPT plan's credentials are checked and refreshed, and the headers that
3
+ // carry them. `subscription.js` adds them at the fetch edge. TLA+ model:
4
+ // `tests/tla/sdk/ProviderAuth.tla`.
5
+ import { normalizeCredentials } from "./subscription.js";
6
+
7
+ export const chatgptIssuer = "https://auth.openai.com";
8
+ export const chatgptClientId = "app_EMoamEEZ73f0CkXaXp7hrann";
9
+
10
+ const codexOrigin = "https://chatgpt.com";
11
+ const codexPaths = new Set(["/backend-api/codex/responses", "/backend-api/codex/models"]);
12
+ const authClaim = "https://api.openai.com/auth";
13
+
14
+ /** True only for the exact Codex endpoints the engine calls. */
15
+ export function isCodexRequest(url) {
16
+ let parsed;
17
+ try { parsed = new URL(String(url)); } catch { return false; }
18
+ return parsed.origin === codexOrigin && codexPaths.has(parsed.pathname);
19
+ }
20
+
21
+ /**
22
+ * Validates credentials from the host. The account id and expiry default to
23
+ * the access token's own claims. Returns a new object.
24
+ */
25
+ export function normalizeChatgptCredentials(value) {
26
+ return normalizeCredentials(value, { label: "ChatGPT", accountClaim: (claims) => claims?.[authClaim]?.chatgpt_account_id });
27
+ }
28
+
29
+ /**
30
+ * Exchanges a refresh token at OpenAI. Keeps the old refresh token when the
31
+ * response does not rotate it.
32
+ */
33
+ export async function refreshChatgptCredentials(credentials, { fetch, signal } = {}) {
34
+ if (credentials.refreshToken === undefined) throw new TypeError("ChatGPT credentials have no refreshToken");
35
+ const doFetch = fetch ?? globalThis.fetch?.bind(globalThis);
36
+ const response = await doFetch(`${chatgptIssuer}/oauth/token`, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify({ client_id: chatgptClientId, grant_type: "refresh_token", refresh_token: credentials.refreshToken }),
40
+ signal,
41
+ });
42
+ if (!response.ok) {
43
+ await response.body?.cancel().catch(() => {});
44
+ const error = new Error(`ChatGPT token refresh failed with HTTP ${response.status}`);
45
+ error.code = "LIBFX_CHATGPT_REFRESH_FAILED";
46
+ error.status = response.status;
47
+ throw error;
48
+ }
49
+ const tokens = await response.json();
50
+ const next = normalizeChatgptCredentials({
51
+ accessToken: tokens.access_token,
52
+ refreshToken: tokens.refresh_token ?? credentials.refreshToken,
53
+ ...(Number.isFinite(tokens.expires_in) ? { expiresAt: Date.now() + tokens.expires_in * 1000 } : {}),
54
+ });
55
+ return next.accountId === credentials.accountId ? next : { ...next, accountId: credentials.accountId };
56
+ }
57
+
58
+ /** Codex on a ChatGPT plan, for `createSubscriptionAuthorizer()`. */
59
+ export const codexSubscription = {
60
+ provider: "codex",
61
+ matches: isCodexRequest,
62
+ sessionId: (headers) => headers.get("session-id"),
63
+ normalize: normalizeChatgptCredentials,
64
+ refresh: refreshChatgptCredentials,
65
+ authorize(headers, _endpoint, credentials) {
66
+ headers.set("authorization", `Bearer ${credentials.accessToken}`);
67
+ headers.set("chatgpt-account-id", credentials.accountId);
68
+ },
69
+ };
@@ -0,0 +1,123 @@
1
+ // A custom provider for libfx agents: any server that speaks OpenAI Chat
2
+ // Completions, such as a local model server or a router. The engine sends its
3
+ // requests through the host's fetch without credentials; when the host has an
4
+ // API key, this module adds it at the fetch edge, to that provider's chat URL
5
+ // only. The key never enters the engine or a session's saved records.
6
+
7
+ import { tokenString } from "./subscription.js";
8
+
9
+ const encoder = new TextEncoder();
10
+ const reservedNames = new Set(["gateway", "codex", "grok"]);
11
+ const namePattern = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
12
+ const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
13
+ const providerFields = new Set(["name", "baseUrl", "models"]);
14
+ // libfx option names to the CLI's settings names, which the engine parses.
15
+ const modelFields = {
16
+ contextWindow: "context_window",
17
+ maxOutputTokens: "max_output_tokens",
18
+ supportsToolUse: "supports_tool_use",
19
+ supportsVision: "supports_vision",
20
+ };
21
+
22
+ function isPlainObject(value) {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value);
24
+ }
25
+
26
+ function chatUrlOf(baseUrl) {
27
+ if (typeof baseUrl !== "string") throw new TypeError("provider.baseUrl must be a string");
28
+ let url;
29
+ try {
30
+ url = new URL(`${baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl}/chat/completions`);
31
+ } catch {
32
+ throw new TypeError("provider.baseUrl must be an absolute URL");
33
+ }
34
+ if (url.username || url.password || url.search || url.hash) {
35
+ throw new TypeError("provider.baseUrl must not carry credentials, a query or a fragment");
36
+ }
37
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopbackHosts.has(url.hostname))) {
38
+ throw new TypeError("provider.baseUrl must use https, or http on localhost");
39
+ }
40
+ // The same form the fetch edge compares requests in.
41
+ return `${url.origin}${url.pathname}`;
42
+ }
43
+
44
+ // The engine's limits (configured_provider.zig), checked here so both engines
45
+ // report a mistake the same way.
46
+ const maxModels = 256;
47
+ const maxModelIdBytes = 1024;
48
+ const maxLimit = 2 ** 32 - 1;
49
+
50
+ function modelMetadata(models) {
51
+ if (!isPlainObject(models)) throw new TypeError("provider.models must be an object keyed by model id");
52
+ const entries = Object.entries(models);
53
+ if (entries.length > maxModels) throw new RangeError(`provider.models lists more than ${maxModels} models`);
54
+ const metadata = {};
55
+ for (const [id, model] of entries) {
56
+ if (id.length === 0 || id.trim() !== id || /[\u0000-\u001f\u007f]/.test(id) || encoder.encode(id).length > maxModelIdBytes) {
57
+ throw new TypeError(`provider.models has an invalid model id "${id}"`);
58
+ }
59
+ if (!isPlainObject(model)) throw new TypeError(`provider.models["${id}"] must be an object`);
60
+ const entry = {};
61
+ for (const [field, value] of Object.entries(model)) {
62
+ const name = modelFields[field];
63
+ if (name === undefined) throw new TypeError(`provider.models["${id}"] has unknown field ${field}`);
64
+ const limit = field === "contextWindow" || field === "maxOutputTokens";
65
+ if (limit ? !(Number.isInteger(value) && value > 0 && value <= maxLimit) : typeof value !== "boolean") {
66
+ throw new TypeError(`provider.models["${id}"].${field} must be ${limit ? "a positive integer" : "a boolean"}`);
67
+ }
68
+ entry[name] = value;
69
+ }
70
+ if (model.contextWindow !== undefined && model.maxOutputTokens !== undefined && model.maxOutputTokens >= model.contextWindow) {
71
+ throw new RangeError(`provider.models["${id}"].maxOutputTokens must be less than contextWindow`);
72
+ }
73
+ metadata[id] = entry;
74
+ }
75
+ return metadata;
76
+ }
77
+
78
+ /**
79
+ * Checks a custom provider `{ name, baseUrl, models? }` and returns its name,
80
+ * the chat URL its key may go to, and the settings-style JSON the engine
81
+ * parses. The engine applies the CLI's complete rules; these checks only catch
82
+ * mistakes early with a clearer message.
83
+ */
84
+ export function customProviderDefinition(provider) {
85
+ if (!isPlainObject(provider)) throw new TypeError("a custom provider must be an object { name, baseUrl, models? }");
86
+ for (const field of Object.keys(provider)) {
87
+ if (!providerFields.has(field)) throw new TypeError(`a custom provider has unknown field ${field}`);
88
+ }
89
+ const { name, baseUrl, models } = provider;
90
+ if (typeof name !== "string" || !namePattern.test(name) || reservedNames.has(name.toLowerCase())) {
91
+ throw new TypeError("provider.name must be a letter followed by letters, digits, _ or -, at most 64 characters, and not gateway, codex or grok");
92
+ }
93
+ const chatUrl = chatUrlOf(baseUrl);
94
+ const definition = {
95
+ protocol: "openai-chat-completions",
96
+ base_url: baseUrl,
97
+ // The host adds the key, so the engine's copy has none.
98
+ auth: { type: "none" },
99
+ ...(models === undefined ? {} : { model_metadata: modelMetadata(models) }),
100
+ };
101
+ return { name, chatUrl, json: JSON.stringify({ [name]: definition }) };
102
+ }
103
+
104
+ /**
105
+ * The custom provider's spec for `createSubscriptionAuthorizer()`: the host's
106
+ * API key as a bearer token, on the provider's chat URL only. `sessionId()`
107
+ * names the agent's session. A key has no refresh token, so it is never
108
+ * refreshed, and a rejected request returns as it is.
109
+ */
110
+ export function customProviderSpec({ name, chatUrl }, sessionId) {
111
+ return {
112
+ provider: name,
113
+ matches: (endpoint) => endpoint === chatUrl,
114
+ sessionId: () => sessionId(),
115
+ normalize(value) {
116
+ if (!isPlainObject(value)) throw new TypeError("credentials() must return { apiKey } for a custom provider");
117
+ return { accessToken: tokenString(value.apiKey, "apiKey", true, `provider "${name}"`) };
118
+ },
119
+ authorize(headers, _endpoint, credentials) {
120
+ headers.set("authorization", `Bearer ${credentials.accessToken}`);
121
+ },
122
+ };
123
+ }