e2e 0.21.1 → 0.21.2

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 (133) hide show
  1. package/README.md +2 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act-validation.js +1 -0
  4. package/dist/agent/act.js +7 -3
  5. package/dist/agent/action-dispatcher.js +30 -8
  6. package/dist/agent/actions.d.ts +2 -0
  7. package/dist/agent/actions.js +5 -2
  8. package/dist/agent/error.js +1 -0
  9. package/dist/agent/executor.d.ts +14 -6
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/invocation.d.ts +3 -0
  12. package/dist/agent/model/sdk.d.ts +5 -0
  13. package/dist/agent/model/sdk.js +18 -1
  14. package/dist/agent/observation.js +1 -0
  15. package/dist/agent/primitives.js +14 -3
  16. package/dist/agent/replay.js +9 -3
  17. package/dist/agent/step-cache.d.ts +3 -2
  18. package/dist/agent/step-cache.js +7 -4
  19. package/dist/agent/tool-loop.js +4 -1
  20. package/dist/cache/context.js +1 -1
  21. package/dist/cache/recorder.d.ts +2 -1
  22. package/dist/cache/recorder.js +11 -2
  23. package/dist/cache/trace-fields.d.ts +10 -0
  24. package/dist/cache/trace-fields.js +17 -0
  25. package/dist/cache/trace.d.ts +9 -1
  26. package/dist/cache/trace.js +26 -7
  27. package/dist/cli/feedback.js +2 -1
  28. package/dist/cli/init/sibling-versions.json +1 -1
  29. package/dist/collect/select.js +12 -2
  30. package/dist/config/agent.d.ts +1 -1
  31. package/dist/config/diagnose.js +9 -3
  32. package/dist/config/esm-hooks.js +3 -2
  33. package/dist/config/resolve.js +3 -2
  34. package/dist/config/typescript.js +52 -2
  35. package/dist/engine/contract.d.ts +3 -2
  36. package/dist/engine/manifest.js +2 -2
  37. package/dist/internal/errors.js +5 -2
  38. package/dist/internal/source.js +2 -0
  39. package/dist/internal/unserved-model.d.ts +5 -0
  40. package/dist/internal/unserved-model.js +22 -0
  41. package/dist/locator/screen.js +1 -2
  42. package/dist/oauth/copilot.js +23 -5
  43. package/dist/oauth/device-code.js +35 -19
  44. package/dist/oauth/errors.d.ts +3 -0
  45. package/dist/oauth/errors.js +26 -11
  46. package/dist/oauth/providers/github-copilot.d.ts +4 -0
  47. package/dist/oauth/providers/github-copilot.js +64 -16
  48. package/dist/oauth/providers/openai.d.ts +2 -2
  49. package/dist/oauth/providers/openai.js +47 -19
  50. package/dist/oauth/providers/opencode-console.js +36 -15
  51. package/dist/oauth/providers/xai.js +18 -9
  52. package/dist/oauth/token-endpoint.d.ts +15 -6
  53. package/dist/oauth/token-endpoint.js +18 -6
  54. package/dist/report/code-frame.js +9 -3
  55. package/dist/report/failure-text.d.ts +10 -2
  56. package/dist/report/failure-text.js +24 -8
  57. package/dist/report/list-model.d.ts +13 -0
  58. package/dist/report/list-steps.d.ts +6 -1
  59. package/dist/report/list-steps.js +29 -0
  60. package/dist/report/list.d.ts +1 -0
  61. package/dist/report/list.js +16 -7
  62. package/dist/report/live-window.js +2 -1
  63. package/dist/report/markdown.js +1 -1
  64. package/dist/run/fixtures.js +2 -0
  65. package/dist/run/host-trace.d.ts +3 -2
  66. package/dist/run/host-trace.js +2 -1
  67. package/dist/run/last-run.d.ts +9 -0
  68. package/dist/run/last-run.js +28 -0
  69. package/dist/run/open-session.d.ts +3 -1
  70. package/dist/run/open-session.js +1 -1
  71. package/dist/run/runner.js +6 -2
  72. package/dist/run/sessions.js +3 -3
  73. package/dist/run/standalone-attempt.d.ts +2 -1
  74. package/dist/run/standalone-attempt.js +3 -1
  75. package/dist/run/steps.d.ts +3 -1
  76. package/dist/run/units.d.ts +8 -0
  77. package/dist/run/units.js +14 -0
  78. package/dist/run/worker-models.d.ts +2 -0
  79. package/dist/run/worker-models.js +7 -0
  80. package/dist/types.d.ts +2 -2
  81. package/docs/authentication.mdx +26 -23
  82. package/docs/browser.mdx +7 -4
  83. package/docs/bug-bash.mdx +2 -2
  84. package/docs/cache.mdx +6 -1
  85. package/docs/ci/bitrise.mdx +1 -1
  86. package/docs/ci/codemagic.mdx +2 -2
  87. package/docs/ci/eas-workflows.mdx +1 -1
  88. package/docs/coding-agents.mdx +7 -35
  89. package/docs/debugging.mdx +24 -36
  90. package/docs/executors.mdx +1 -1
  91. package/docs/github.mdx +2 -1
  92. package/docs/integrations/eas.mdx +1 -1
  93. package/docs/integrations/index.mdx +6 -0
  94. package/docs/integrations/lightpanda.mdx +192 -0
  95. package/docs/integrations/testmu.mdx +152 -0
  96. package/docs/mcp.mdx +123 -0
  97. package/docs/migrate/detox.mdx +1 -1
  98. package/docs/migrate/maestro.mdx +101 -105
  99. package/docs/migrate/playwright.mdx +65 -70
  100. package/docs/mobile.mdx +18 -3
  101. package/docs/models.mdx +4 -2
  102. package/docs/quickstart.mdx +7 -0
  103. package/docs/reference/agent.mdx +369 -277
  104. package/docs/reference/app.mdx +141 -32
  105. package/docs/reference/cli.mdx +674 -558
  106. package/docs/reference/config.mdx +1406 -752
  107. package/docs/reference/engine.mdx +1062 -256
  108. package/docs/reference/environment.mdx +156 -84
  109. package/docs/reference/errors.mdx +106 -72
  110. package/docs/reference/expect.mdx +752 -372
  111. package/docs/reference/mcp.mdx +366 -141
  112. package/docs/reference/mobile.mdx +844 -403
  113. package/docs/reference/reporters.mdx +312 -106
  114. package/docs/reference/runner.mdx +373 -174
  115. package/docs/reference/screen.mdx +931 -398
  116. package/docs/reference/security.mdx +106 -40
  117. package/docs/reference/test.mdx +626 -273
  118. package/docs/reference/web.mdx +956 -533
  119. package/docs/security.mdx +6 -5
  120. package/docs/snippets/model-provider.jsx +763 -0
  121. package/docs/snippets/setup-prompt-card.jsx +22 -0
  122. package/docs/snippets/show-more.jsx +22 -0
  123. package/docs/snippets/video-card.jsx +34 -0
  124. package/docs/snippets/video.jsx +25 -0
  125. package/docs/subscriptions.mdx +10 -3
  126. package/docs/telemetry.mdx +39 -39
  127. package/package.json +3 -2
  128. package/schema/report-v1.schema.json +6 -0
  129. package/skills/e2e/references/agent.md +8 -3
  130. package/skills/e2e/references/debugging.md +1 -1
  131. package/skills/e2e/references/mcp.md +1 -1
  132. package/skills/e2e/references/setup.md +12 -3
  133. package/skills/e2e/references/writing-tests.md +1 -1
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  export class OAuthError extends Error {
2
3
  code;
3
4
  name = 'OAuthError';
@@ -6,26 +7,40 @@ export class OAuthError extends Error {
6
7
  this.code = code;
7
8
  }
8
9
  }
10
+ /** The fields an error body names its failure with; a field of another type, or a body that is not an object, names nothing. */
11
+ const ErrorBodySchema = z
12
+ .object({
13
+ error: z.union([z.string(), z.object({ message: z.string() })]).optional().catch(undefined),
14
+ error_description: z.string().optional().catch(undefined),
15
+ message: z.string().optional().catch(undefined),
16
+ })
17
+ .catch({});
9
18
  /** Reads an error body for a message without assuming JSON. */
10
19
  export async function describeResponse(response) {
11
20
  const text = await response.text().catch(() => '');
12
21
  if (text === '')
13
22
  return `${response.status} ${response.statusText}`.trim();
14
23
  try {
15
- const json = JSON.parse(text);
16
- const error = json.error;
17
- const detail = typeof json.error_description === 'string'
18
- ? json.error_description
19
- : typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string'
20
- ? error.message
21
- : typeof json.message === 'string'
22
- ? json.message
23
- : typeof error === 'string'
24
- ? error
25
- : undefined;
24
+ const { error, error_description, message } = ErrorBodySchema.parse(JSON.parse(text));
25
+ const detail = error_description ?? (typeof error === 'object' ? error.message : undefined) ?? message ?? (typeof error === 'string' ? error : undefined);
26
26
  return detail === undefined ? `${response.status}: ${text.slice(0, 200)}` : `${response.status}: ${detail}`;
27
27
  }
28
28
  catch {
29
29
  return `${response.status}: ${text.slice(0, 200)}`;
30
30
  }
31
31
  }
32
+ /** A response's or request's JSON body checked against `schema`; FLOW_FAILED as `<subject> is not JSON` or `<subject> <invalid>` otherwise. */
33
+ export async function readJsonBody(source, schema, subject, invalid = 'is malformed') {
34
+ const text = await source.text();
35
+ let value;
36
+ try {
37
+ value = JSON.parse(text);
38
+ }
39
+ catch (cause) {
40
+ throw new OAuthError('FLOW_FAILED', `${subject} is not JSON`, { cause });
41
+ }
42
+ const result = schema.safeParse(value);
43
+ if (!result.success)
44
+ throw new OAuthError('FLOW_FAILED', `${subject} ${invalid}`, { cause: result.error });
45
+ return result.data;
46
+ }
@@ -48,3 +48,7 @@ export type CopilotProtocol = 'chat' | 'responses';
48
48
  * missing listing and rejects.
49
49
  */
50
50
  export declare function copilotProtocolFor(modelId: string, fetch: FetchFunction, signal: AbortSignal): Promise<CopilotProtocol | undefined>;
51
+ /** Why `modelId` cannot be used, as the run error reads it, once Copilot answered `model_not_supported`. */
52
+ export declare function copilotRefusal(modelId: string): string;
53
+ /** True for the AI SDK's error over a 4xx whose body code is `model_not_supported`; a wrong endpoint (`unsupported_api_for_model`) is not this. */
54
+ export declare function isCopilotRefusal(cause: unknown): cause is Error;
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
+ import { z } from 'zod';
9
10
  import { rfc8628Flow } from '../device-code.js';
10
- import { OAuthError, describeResponse } from '../errors.js';
11
+ import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
11
12
  const execFileAsync = promisify(execFile);
12
13
  export const COPILOT_API_URL = 'https://api.githubcopilot.com';
13
14
  /**
@@ -99,6 +100,13 @@ export async function sendCopilotRequest(request, credentials, upstream) {
99
100
  headers.set('copilot-vision-request', 'true');
100
101
  return upstream(new Request(url, { method: request.method, headers, signal: request.signal, ...(text === undefined ? {} : { body: text }) }));
101
102
  }
103
+ /** One turn of either protocol, a chat `messages[]` entry or a Responses `input[]` item; one that is not an object reads as empty. */
104
+ const CopilotTurnSchema = z.object({ role: z.unknown().optional(), content: z.unknown().optional() }).catch({});
105
+ /** The turns of a chat or Responses body; a list of another type reads as absent. */
106
+ const CopilotBodySchema = z.object({
107
+ messages: z.array(CopilotTurnSchema).optional().catch(undefined),
108
+ input: z.array(CopilotTurnSchema).optional().catch(undefined),
109
+ });
102
110
  /**
103
111
  * Reads who initiated the turn and whether images are present, whichever
104
112
  * protocol carried the body. Chat completions put turns in `messages[]` and
@@ -109,17 +117,16 @@ function copilotTurn(text) {
109
117
  if (text === undefined)
110
118
  return { initiator: 'user', vision: false };
111
119
  try {
112
- const body = JSON.parse(text);
120
+ const { messages, input } = CopilotBodySchema.parse(JSON.parse(text));
113
121
  // A Responses body has `input` and no `messages`; anything else stays on the chat path.
114
- if (!Array.isArray(body.messages) && Array.isArray(body.input)) {
115
- const input = body.input;
122
+ if (messages === undefined && input !== undefined) {
116
123
  return { initiator: input.at(-1)?.role === 'user' ? 'user' : 'agent', vision: input.some((item) => hasPart(item.content, 'input_image')) };
117
124
  }
118
- const messages = Array.isArray(body.messages) ? body.messages : [];
119
- return { initiator: messages.at(-1)?.role === 'user' ? 'user' : 'agent', vision: messages.some((message) => hasPart(message.content, 'image_url')) };
125
+ const turns = messages ?? [];
126
+ return { initiator: turns.at(-1)?.role === 'user' ? 'user' : 'agent', vision: turns.some((message) => hasPart(message.content, 'image_url')) };
120
127
  }
121
128
  catch {
122
- // Not JSON; the headers stay at their defaults.
129
+ // Not a JSON object; the headers stay at their defaults.
123
130
  return { initiator: 'user', vision: false };
124
131
  }
125
132
  }
@@ -127,6 +134,26 @@ function copilotTurn(text) {
127
134
  function hasPart(content, type) {
128
135
  return Array.isArray(content) && content.some((part) => part?.type === type);
129
136
  }
137
+ /** One entry of the Copilot API's `/models`, as far as the listing reads it; a field of another type reads as absent. */
138
+ const CopilotModelSchema = z.object({
139
+ id: z.string().min(1),
140
+ name: z.string().optional().catch(undefined),
141
+ vendor: z.string().optional().catch(undefined),
142
+ preview: z.unknown().optional(),
143
+ policy: z.object({ state: z.string().optional().catch(undefined) }).optional().catch(undefined),
144
+ supported_endpoints: z.array(z.unknown()).optional().catch(undefined),
145
+ capabilities: z
146
+ .object({
147
+ type: z.unknown().optional(),
148
+ supports: z.object({ tool_calls: z.unknown().optional(), vision: z.unknown().optional() }).optional().catch(undefined),
149
+ })
150
+ .optional()
151
+ .catch(undefined),
152
+ });
153
+ /** The models in a Copilot API `/models` body, `undefined` when it names none; an entry without an id is left out. */
154
+ const CopilotModelListSchema = z
155
+ .object({ data: z.array(CopilotModelSchema.optional().catch(undefined)).nullish() })
156
+ .transform(({ data }) => data?.filter((model) => model !== undefined));
130
157
  /**
131
158
  * The chat models the Copilot plan serves; embeddings and other kinds are
132
159
  * left out because only chat models fit `copilot()`. A model it cannot call
@@ -138,23 +165,22 @@ async function listCopilotModels(fetch) {
138
165
  const response = await fetch(`${COPILOT_API_URL}/models`);
139
166
  if (!response.ok)
140
167
  throw new OAuthError('FLOW_FAILED', `GitHub Copilot did not list its models: ${await describeResponse(response)}`);
141
- const payload = (await response.json());
142
- const models = Array.isArray(payload.data) ? payload.data : [];
168
+ const models = (await readJsonBody(response, CopilotModelListSchema, 'the GitHub Copilot model list')) ?? [];
143
169
  return models
144
- .filter((model) => typeof model.id === 'string' && model.id !== '' && (model.capabilities?.type === undefined || model.capabilities.type === 'chat'))
170
+ .filter((model) => model.capabilities?.type === undefined || model.capabilities.type === 'chat')
145
171
  .map((model) => {
146
172
  const supports = model.capabilities?.supports;
147
173
  const detail = [
148
- typeof model.vendor === 'string' ? model.vendor : undefined,
174
+ model.vendor,
149
175
  supports?.tool_calls === true ? 'tools' : undefined,
150
176
  supports?.vision === true ? 'vision' : undefined,
151
177
  model.preview === true ? 'preview' : undefined,
152
- typeof model.policy?.state === 'string' && model.policy.state !== 'enabled' ? 'not enabled' : undefined,
178
+ model.policy?.state !== undefined && model.policy.state !== 'enabled' ? 'not enabled' : undefined,
153
179
  copilotProtocol(model) === undefined ? 'no chat or responses' : undefined,
154
180
  ].filter((part) => part !== undefined);
155
181
  return {
156
182
  id: model.id,
157
- ...(typeof model.name === 'string' && model.name !== '' ? { name: model.name } : {}),
183
+ ...(model.name === undefined || model.name === '' ? {} : { name: model.name }),
158
184
  ...(detail.length === 0 ? {} : { detail: detail.join(', ') }),
159
185
  };
160
186
  });
@@ -173,10 +199,10 @@ export async function copilotProtocolFor(modelId, fetch, signal) {
173
199
  const response = await fetch(new Request(`${COPILOT_API_URL}/models`, { signal }));
174
200
  if (!response.ok)
175
201
  return undefined;
176
- const payload = (await response.json());
177
- if (!Array.isArray(payload.data))
202
+ const listing = CopilotModelListSchema.safeParse(await response.json());
203
+ if (!listing.success || listing.data === undefined)
178
204
  return undefined;
179
- return copilotProtocol(payload.data.find((model) => model.id === modelId)) ?? 'chat';
205
+ return copilotProtocol(listing.data.find((model) => model.id === modelId)) ?? 'chat';
180
206
  }
181
207
  catch (error) {
182
208
  if (error instanceof OAuthError)
@@ -202,3 +228,25 @@ function copilotProtocol(model) {
202
228
  return 'responses';
203
229
  return undefined;
204
230
  }
231
+ /** Why `modelId` cannot be used, as the run error reads it, once Copilot answered `model_not_supported`. */
232
+ export function copilotRefusal(modelId) {
233
+ return `GitHub Copilot does not serve ${modelId} to this account: the plan does not include it, it is turned off in Copilot settings on GitHub, or Copilot lists no such id; npx e2e models github-copilot lists the ids, and the listing cannot see plan limits`;
234
+ }
235
+ /** True for the AI SDK's error over a 4xx whose body code is `model_not_supported`; a wrong endpoint (`unsupported_api_for_model`) is not this. */
236
+ export function isCopilotRefusal(cause) {
237
+ if (!(cause instanceof Error) || cause.name !== 'AI_APICallError')
238
+ return false;
239
+ const { statusCode, data, responseBody } = cause;
240
+ if (typeof statusCode !== 'number' || statusCode < 400 || statusCode >= 500)
241
+ return false;
242
+ const parsed = data ?? (typeof responseBody === 'string' ? parseJson(responseBody) : undefined);
243
+ return parsed?.error?.code === 'model_not_supported';
244
+ }
245
+ function parseJson(text) {
246
+ try {
247
+ return JSON.parse(text);
248
+ }
249
+ catch {
250
+ return undefined;
251
+ }
252
+ }
@@ -28,8 +28,8 @@ export interface CodexProviderOptions {
28
28
  }
29
29
  export declare function createCodexProvider(options?: CodexProviderOptions): OAuthProvider<CodexCredentials, CodexLoginOptions>;
30
30
  export declare function extractAccountId(tokens: {
31
- id_token?: string;
32
- access_token?: string;
31
+ id_token?: string | undefined;
32
+ access_token?: string | undefined;
33
33
  }): string | undefined;
34
34
  /** The code from what the user pasted: a full redirect URL, `code#state`, a query string, or the bare code. */
35
35
  export declare function parseAuthorizationInput(input: string): {
@@ -6,9 +6,10 @@
6
6
  * Responses API, requires `store: false`, and answers only as a stream; the
7
7
  * stream is folded back into JSON when the SDK asked for a single response.
8
8
  */
9
+ import { z } from 'zod';
9
10
  import { startCallbackServer } from '../callback-server.js';
10
11
  import { runDeviceFlow } from '../device-code.js';
11
- import { OAuthError, describeResponse } from '../errors.js';
12
+ import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
12
13
  import { decodeJwtPayload } from '../jwt.js';
13
14
  import { generatePkce, randomState } from '../pkce.js';
14
15
  import { foldResponsesStream } from '../sse.js';
@@ -192,6 +193,17 @@ async function pasteCode(callbacks, state) {
192
193
  }
193
194
  return pasted.code;
194
195
  }
196
+ /** OpenAI's user-code answer: the code to show and the id the poll names it by. */
197
+ const UserCodeSchema = z.object({
198
+ device_auth_id: z.string(),
199
+ user_code: z.string(),
200
+ interval: z.unknown().optional(),
201
+ });
202
+ /** OpenAI's answer once the user entered the code: an authorization code and the PKCE verifier to exchange it with. */
203
+ const DeviceGrantSchema = z.object({
204
+ authorization_code: z.string(),
205
+ code_verifier: z.string(),
206
+ });
195
207
  /** The Codex CLI's device login: OpenAI's own user-code endpoints, pending until the user enters the code. */
196
208
  function deviceLogin(callbacks, issuer, timeoutMs) {
197
209
  const json = (url, body, signal) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...(signal === undefined ? {} : { signal }) });
@@ -201,7 +213,7 @@ function deviceLogin(callbacks, issuer, timeoutMs) {
201
213
  const response = await json(`${issuer}/api/accounts/deviceauth/usercode`, { client_id: CLIENT_ID }, signal);
202
214
  if (!response.ok)
203
215
  throw new OAuthError('FLOW_FAILED', `ChatGPT device login could not start: ${await describeResponse(response)}`);
204
- const device = (await response.json());
216
+ const device = await readJsonBody(response, UserCodeSchema, 'the ChatGPT device code response', 'is missing fields');
205
217
  return {
206
218
  deviceCode: device.device_auth_id,
207
219
  userCode: device.user_code,
@@ -213,7 +225,7 @@ function deviceLogin(callbacks, issuer, timeoutMs) {
213
225
  async poll(authorization, signal) {
214
226
  const response = await json(`${issuer}/api/accounts/deviceauth/token`, { device_auth_id: authorization.deviceCode, user_code: authorization.userCode }, signal);
215
227
  if (response.ok) {
216
- const grant = (await response.json());
228
+ const grant = await readJsonBody(response, DeviceGrantSchema, 'the ChatGPT device grant', 'is missing fields');
217
229
  return { status: 'granted', value: await exchangeCode(issuer, grant.authorization_code, `${issuer}/deviceauth/callback`, grant.code_verifier, signal) };
218
230
  }
219
231
  // Pending shows as 403 or 404 until the user enters the code.
@@ -225,6 +237,14 @@ function deviceLogin(callbacks, issuer, timeoutMs) {
225
237
  },
226
238
  });
227
239
  }
240
+ /** A Responses API body, as far as the Codex backend needs it changed; every other field passes through. */
241
+ const CodexBodySchema = z.looseObject({
242
+ stream: z.unknown().optional(),
243
+ store: z.unknown().optional(),
244
+ instructions: z.unknown().optional(),
245
+ include: z.array(z.unknown()).nullish(),
246
+ max_output_tokens: z.unknown().optional(),
247
+ });
228
248
  /**
229
249
  * Routes a Responses API request at the Codex backend with the body the
230
250
  * Codex CLI sends: no server-side storage, encrypted reasoning carried
@@ -240,13 +260,7 @@ export async function sendCodexRequest(request, credentials, upstream, options)
240
260
  return upstream(new Request(request, { headers }));
241
261
  if (credentials.residency !== undefined)
242
262
  headers.set('x-openai-internal-codex-residency', credentials.residency);
243
- let body;
244
- try {
245
- body = (await request.json());
246
- }
247
- catch (cause) {
248
- throw new OAuthError('FLOW_FAILED', 'the Responses request body is not JSON', { cause });
249
- }
263
+ const body = await readJsonBody(request, CodexBodySchema, 'the Responses request body');
250
264
  const wantedStream = body.stream === true;
251
265
  body.stream = true;
252
266
  body.store = false;
@@ -260,6 +274,24 @@ export async function sendCodexRequest(request, credentials, upstream, options)
260
274
  const response = await upstream(new Request(options.apiUrl, { method: request.method, headers, body: JSON.stringify(body), signal: request.signal }));
261
275
  return wantedStream ? response : foldResponsesStream(response);
262
276
  }
277
+ /** A reasoning level the Codex backend names bare or as `{ effort }`; one of another shape reads as absent. */
278
+ const ReasoningLevelSchema = z
279
+ .union([z.string(), z.object({ effort: z.string() }).transform(({ effort }) => effort)])
280
+ .optional()
281
+ .catch(undefined);
282
+ /** One entry of the Codex backend's model list, as far as the listing reads it; a field of another type reads as absent. */
283
+ const CodexModelSchema = z.object({
284
+ slug: z.string().min(1),
285
+ display_name: z.string().optional().catch(undefined),
286
+ visibility: z.unknown().optional(),
287
+ default_reasoning_level: z.string().optional().catch(undefined),
288
+ supported_reasoning_levels: z.array(ReasoningLevelSchema).optional().catch(undefined),
289
+ priority: z.number().optional().catch(undefined),
290
+ });
291
+ /** The models in the Codex backend's `/models` body; a body that names none has none, and an entry without a slug is left out. */
292
+ const CodexModelListSchema = z
293
+ .object({ models: z.array(CodexModelSchema.optional().catch(undefined)).nullish() })
294
+ .transform(({ models }) => (models ?? []).filter((model) => model !== undefined));
263
295
  /**
264
296
  * The models the Codex backend serves this subscription, `/models` next to
265
297
  * `/responses`. Hidden ones are listed too, marked, because the backend
@@ -273,22 +305,18 @@ async function listCodexModels(fetch, apiUrl) {
273
305
  const response = await fetch(url);
274
306
  if (!response.ok)
275
307
  throw new OAuthError('FLOW_FAILED', `ChatGPT did not list its models: ${await describeResponse(response)}`);
276
- const payload = (await response.json());
277
- const models = Array.isArray(payload.models) ? payload.models : [];
308
+ const models = await readJsonBody(response, CodexModelListSchema, 'the ChatGPT model list');
278
309
  return models
279
- .filter((model) => typeof model.slug === 'string' && model.slug !== '')
280
- .toSorted((a, b) => (typeof a.priority === 'number' ? a.priority : 0) - (typeof b.priority === 'number' ? b.priority : 0))
310
+ .toSorted((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
281
311
  .map((model) => {
282
- const levels = Array.isArray(model.supported_reasoning_levels)
283
- ? model.supported_reasoning_levels.map((level) => (typeof level === 'string' ? level : level?.effort)).filter((level) => typeof level === 'string')
284
- : [];
312
+ const levels = (model.supported_reasoning_levels ?? []).filter((level) => level !== undefined);
285
313
  const detail = [
286
- levels.length === 0 ? undefined : `reasoning ${levels.join('/')}${typeof model.default_reasoning_level === 'string' ? ` (default ${model.default_reasoning_level})` : ''}`,
314
+ levels.length === 0 ? undefined : `reasoning ${levels.join('/')}${model.default_reasoning_level === undefined ? '' : ` (default ${model.default_reasoning_level})`}`,
287
315
  model.visibility === 'hide' ? 'hidden in Codex' : undefined,
288
316
  ].filter((part) => part !== undefined);
289
317
  return {
290
318
  id: model.slug,
291
- ...(typeof model.display_name === 'string' && model.display_name !== '' ? { name: model.display_name } : {}),
319
+ ...(model.display_name === undefined || model.display_name === '' ? {} : { name: model.display_name }),
292
320
  ...(detail.length === 0 ? {} : { detail: detail.join('; ') }),
293
321
  };
294
322
  });
@@ -6,8 +6,9 @@
6
6
  * over which protocol: Zen is its `opencode` provider, Go `opencode-go`, present
7
7
  * only for the Go subscriber. Go ids take a `go/` prefix here.
8
8
  */
9
+ import { z } from 'zod';
9
10
  import { rfc8628Flow } from '../device-code.js';
10
- import { OAuthError, describeResponse } from '../errors.js';
11
+ import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
11
12
  import { expiryFrom, requestTokens } from '../token-endpoint.js';
12
13
  /** Console accepts any client id. */
13
14
  const CLIENT_ID = 'e2e';
@@ -69,26 +70,46 @@ async function sendOpencodeConsoleRequest(request, credentials, upstream, consol
69
70
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
70
71
  return upstream(new Request(request.url, { method: request.method, headers, signal: request.signal, ...(body === undefined ? {} : { body }) }));
71
72
  }
73
+ /** The npm package and base URL a provider or one of its models is served through; a field of another type reads as absent. */
74
+ const ConfigRouteSchema = z.object({
75
+ npm: z.string().optional().catch(undefined),
76
+ api: z.string().optional().catch(undefined),
77
+ });
78
+ /** One model of a workspace provider, as far as routing and the listing read it; a field of another type reads as absent. */
79
+ const ConfigModelSchema = z.object({
80
+ id: z.string().optional().catch(undefined),
81
+ name: z.string().optional().catch(undefined),
82
+ disabled: z.unknown().optional(),
83
+ cost: z.object({ input: z.number().optional().catch(undefined), output: z.number().optional().catch(undefined) }).optional().catch(undefined),
84
+ modalities: z.object({ input: z.array(z.unknown()).optional().catch(undefined) }).optional().catch(undefined),
85
+ provider: ConfigRouteSchema.optional().catch(undefined),
86
+ });
87
+ /** One provider of the workspace config; a model that is not an object is left out. */
88
+ const ConfigProviderSchema = ConfigRouteSchema.extend({
89
+ models: z.record(z.string(), ConfigModelSchema.optional().catch(undefined)).optional().catch(undefined),
90
+ });
91
+ /** The providers of the Console's `/api/config` body by id; a body that names none has none, and a provider that is not an object is left out. */
92
+ const ConfigSchema = z
93
+ .object({ config: z.object({ provider: z.record(z.string(), ConfigProviderSchema.optional().catch(undefined)).nullish() }).nullish() })
94
+ .transform(({ config }) => config?.provider ?? {});
72
95
  async function readConfig(fetch, consoleUrl, signal) {
73
96
  const response = await fetch(new Request(`${consoleUrl}/api/config`, signal === undefined ? {} : { signal }));
74
97
  if (!response.ok)
75
98
  throw new OAuthError('FLOW_FAILED', `OpenCode Console did not return the workspace config: ${await describeResponse(response)}`);
76
- const payload = (await response.json());
77
- const providers = payload.config?.provider;
78
- return typeof providers === 'object' && providers !== null ? providers : {};
99
+ return readJsonBody(response, ConfigSchema, 'the OpenCode Console workspace config');
79
100
  }
80
101
  /** Leaves out disabled models and Zen's free ones, which only OpenCode's own clients may use. */
81
102
  async function listOpencodeConsoleModels(fetch, consoleUrl) {
82
103
  const providers = await readConfig(fetch, consoleUrl);
83
- const listed = (key, prefix, plan) => Object.entries(providers[key]?.models ?? {})
84
- .filter(([, model]) => model.disabled !== true && (key === GO_PROVIDER || !isFree(model)))
85
- .map(([id, model]) => {
104
+ const listed = (key, prefix, plan) => Object.entries(providers[key]?.models ?? {}).flatMap(([id, model]) => {
105
+ if (model === undefined || model.disabled === true || (key !== GO_PROVIDER && isFree(model)))
106
+ return [];
86
107
  const detail = [
87
108
  plan,
88
- typeof model.cost?.input === 'number' && typeof model.cost.output === 'number' && key === ZEN_PROVIDER ? `$${model.cost.input} in, $${model.cost.output} out per 1M` : undefined,
89
- Array.isArray(model.modalities?.input) && model.modalities.input.includes('image') ? 'vision' : undefined,
109
+ model.cost?.input !== undefined && model.cost.output !== undefined && key === ZEN_PROVIDER ? `$${model.cost.input} in, $${model.cost.output} out per 1M` : undefined,
110
+ model.modalities?.input?.includes('image') === true ? 'vision' : undefined,
90
111
  ].filter((part) => part !== undefined);
91
- return { id: `${prefix}${id}`, ...(typeof model.name === 'string' && model.name !== '' ? { name: model.name } : {}), detail: detail.join(', ') };
112
+ return [{ id: `${prefix}${id}`, ...(model.name === undefined || model.name === '' ? {} : { name: model.name }), detail: detail.join(', ') }];
92
113
  });
93
114
  return [...listed(ZEN_PROVIDER, '', 'Zen'), ...listed(GO_PROVIDER, GO_PREFIX, 'Go')];
94
115
  }
@@ -130,15 +151,15 @@ export async function opencodeConsoleRouteFor(modelId, fetch, signal) {
130
151
  if (model === undefined || model.disabled === true) {
131
152
  throw new OAuthError('MISCONFIGURED', `the OpenCode Console workspace does not serve ${modelId}; \`npx e2e models opencode-console\` lists the ids`);
132
153
  }
133
- const npm = typeof model.provider?.npm === 'string' ? model.provider.npm : provider.npm;
134
- const baseURL = typeof model.provider?.api === 'string' ? model.provider.api : provider.api;
135
- if (typeof npm !== 'string' || typeof baseURL !== 'string')
154
+ const npm = model.provider?.npm ?? provider.npm;
155
+ const baseURL = model.provider?.api ?? provider.api;
156
+ if (npm === undefined || baseURL === undefined)
136
157
  return undefined;
137
- return { go, npm, baseURL, modelId: typeof model.id === 'string' && model.id !== '' ? model.id : id };
158
+ return { go, npm, baseURL, modelId: model.id === undefined || model.id === '' ? id : model.id };
138
159
  }
139
160
  /** A refresh keeps the workspace and the refresh token when the grant omits them. */
140
161
  function toCredentials(tokens, previous) {
141
- const orgId = tokens.org_id;
162
+ const orgId = tokens['org_id'];
142
163
  const workspace = typeof orgId === 'string' && orgId !== '' ? orgId : previous?.orgId;
143
164
  return {
144
165
  access: tokens.access_token,
@@ -4,8 +4,9 @@
4
4
  * bearer against the ordinary SpaceXAI API. Refresh tokens rotate, so a renewed
5
5
  * refresh token replaces the stored one at once.
6
6
  */
7
+ import { z } from 'zod';
7
8
  import { rfc8628Flow } from '../device-code.js';
8
- import { OAuthError, describeResponse } from '../errors.js';
9
+ import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
9
10
  import { decodeJwtPayload } from '../jwt.js';
10
11
  import { expiryFrom, requestTokens } from '../token-endpoint.js';
11
12
  /** The public OAuth client of the Grok CLI, which third-party harnesses sign in through. */
@@ -40,20 +41,28 @@ export function createXaiProvider(options = {}) {
40
41
  },
41
42
  };
42
43
  }
44
+ /** One entry of SpaceXAI's language-model list, as far as the listing reads it; a field of another type reads as absent. */
45
+ const XaiModelSchema = z.object({
46
+ id: z.string().min(1),
47
+ aliases: z.array(z.unknown()).optional().catch(undefined),
48
+ input_modalities: z.array(z.unknown()).optional().catch(undefined),
49
+ });
50
+ /** One of the two keys SpaceXAI lists models under; an entry that does not fit reads as `undefined`. */
51
+ const XaiModelsSchema = z.array(XaiModelSchema.optional().catch(undefined)).nullish();
52
+ /** The models in SpaceXAI's list body, under `models` or `data`; a body that names none has none, and an entry without an id is left out. */
53
+ const XaiModelListSchema = z
54
+ .object({ models: XaiModelsSchema, data: XaiModelsSchema })
55
+ .transform(({ models, data }) => (models ?? data ?? []).filter((model) => model !== undefined));
43
56
  /** The language models the subscription's token reaches; aliases are listed with their target. */
44
57
  async function listXaiModels(fetch, url) {
45
58
  const response = await fetch(url);
46
59
  if (!response.ok)
47
60
  throw new OAuthError('FLOW_FAILED', `SpaceXAI did not list its models: ${await describeResponse(response)}`);
48
- const payload = (await response.json());
49
- const models = (Array.isArray(payload.models) ? payload.models : Array.isArray(payload.data) ? payload.data : []);
50
- return models
51
- .filter((model) => typeof model.id === 'string' && model.id !== '')
52
- .map((model) => {
53
- const aliases = Array.isArray(model.aliases) ? model.aliases.filter((alias) => typeof alias === 'string') : [];
54
- const modalities = Array.isArray(model.input_modalities) ? model.input_modalities.filter((kind) => typeof kind === 'string') : [];
61
+ const models = await readJsonBody(response, XaiModelListSchema, 'the SpaceXAI model list');
62
+ return models.map((model) => {
63
+ const aliases = (model.aliases ?? []).filter((alias) => typeof alias === 'string');
55
64
  const detail = [
56
- modalities.includes('image') ? 'vision' : undefined,
65
+ model.input_modalities?.includes('image') === true ? 'vision' : undefined,
57
66
  aliases.length === 0 ? undefined : `also ${aliases.join(', ')}`,
58
67
  ].filter((part) => part !== undefined);
59
68
  return { id: model.id, ...(detail.length === 0 ? {} : { detail: detail.join('; ') }) };
@@ -4,13 +4,19 @@
4
4
  * wire are coerced defensively: a `NaN` expiry written to disk would poison
5
5
  * the credentials file.
6
6
  */
7
+ import { z } from 'zod';
7
8
  import { type OAuthErrorCode } from './errors.ts';
8
- export interface TokenResponse {
9
- readonly access_token: string;
10
- readonly refresh_token?: string;
11
- readonly id_token?: string;
12
- readonly expires_in?: number;
13
- }
9
+ /**
10
+ * A token response. Vendor-specific fields ride along; an optional token of the
11
+ * wrong type is dropped, and `expires_in` stays raw for `expiryFrom`.
12
+ */
13
+ declare const TokenResponseSchema: z.ZodObject<{
14
+ access_token: z.ZodString;
15
+ refresh_token: z.ZodCatch<z.ZodOptional<z.ZodString>>;
16
+ id_token: z.ZodCatch<z.ZodOptional<z.ZodString>>;
17
+ expires_in: z.ZodOptional<z.ZodUnknown>;
18
+ }, z.core.$loose>;
19
+ export type TokenResponse = z.infer<typeof TokenResponseSchema>;
14
20
  export declare function postForm(url: string, params: Record<string, string>, signal?: AbortSignal): Promise<Response>;
15
21
  /**
16
22
  * Posts a grant and returns the tokens. A 400 or 401 means the grant itself
@@ -19,7 +25,10 @@ export declare function postForm(url: string, params: Record<string, string>, si
19
25
  * the failure only; the fetch that called for the refresh appends the remedy.
20
26
  */
21
27
  export declare function requestTokens(vendor: string, url: string, params: Record<string, string>, rejected?: OAuthErrorCode, signal?: AbortSignal): Promise<TokenResponse>;
28
+ /** The tokens in a token endpoint's JSON body, or undefined when it carries no access token. */
29
+ export declare function parseTokens(body: unknown): TokenResponse | undefined;
22
30
  /** A seconds value from the server, or the default when it is missing or not a positive finite number. */
23
31
  export declare function positiveSeconds(value: unknown, fallback: number): number;
24
32
  /** When a token expires, from `expires_in`; one hour when the server names nothing usable. */
25
33
  export declare function expiryFrom(expiresIn: unknown, now?: number): number;
34
+ export {};
@@ -4,7 +4,18 @@
4
4
  * wire are coerced defensively: a `NaN` expiry written to disk would poison
5
5
  * the credentials file.
6
6
  */
7
- import { OAuthError, describeResponse } from './errors.js';
7
+ import { z } from 'zod';
8
+ import { OAuthError, describeResponse, readJsonBody } from './errors.js';
9
+ /**
10
+ * A token response. Vendor-specific fields ride along; an optional token of the
11
+ * wrong type is dropped, and `expires_in` stays raw for `expiryFrom`.
12
+ */
13
+ const TokenResponseSchema = z.looseObject({
14
+ access_token: z.string().min(1),
15
+ refresh_token: z.string().optional().catch(undefined),
16
+ id_token: z.string().optional().catch(undefined),
17
+ expires_in: z.unknown().optional(),
18
+ });
8
19
  export async function postForm(url, params, signal) {
9
20
  return fetch(url, {
10
21
  method: 'POST',
@@ -25,11 +36,12 @@ export async function requestTokens(vendor, url, params, rejected = 'FLOW_FAILED
25
36
  const code = response.status === 400 || response.status === 401 ? rejected : 'FLOW_FAILED';
26
37
  throw new OAuthError(code, `${vendor} token request failed (${await describeResponse(response)})`);
27
38
  }
28
- const tokens = (await response.json());
29
- if (typeof tokens.access_token !== 'string' || tokens.access_token === '') {
30
- throw new OAuthError('FLOW_FAILED', `the ${vendor} token response is missing access_token`);
31
- }
32
- return tokens;
39
+ return readJsonBody(response, TokenResponseSchema, `the ${vendor} token response`, 'is missing access_token');
40
+ }
41
+ /** The tokens in a token endpoint's JSON body, or undefined when it carries no access token. */
42
+ export function parseTokens(body) {
43
+ const result = TokenResponseSchema.safeParse(body);
44
+ return result.success ? result.data : undefined;
33
45
  }
34
46
  /** A seconds value from the server, or the default when it is missing or not a positive finite number. */
35
47
  export function positiveSeconds(value, fallback) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { userFrame } from '../internal/source.js';
8
- import { bounded, terminalColumns } from './format.js';
8
+ import { bounded, fitColumns, terminalColumns, visibleWidth } from './format.js';
9
9
  export { userFrame };
10
10
  /** Lines of context on each side of the failing line. */
11
11
  const FRAME_RANGE = 2;
@@ -36,10 +36,16 @@ export function codeFrame(frame, pc) {
36
36
  const raw = bounded(lines[i] ?? '').replaceAll('\t', ' ');
37
37
  if (raw.length > MAX_FRAME_LINE_CHARS)
38
38
  return [];
39
- const text = raw.length > maxChars ? `${raw.slice(0, maxChars - 1)}…` : raw.trimEnd();
39
+ const text = fitColumns(raw.trimEnd(), maxChars);
40
40
  rows.push(`${gutter(i + 1)}${text === '' ? '' : ` ${text}`}`);
41
41
  if (i === index) {
42
- rows.push(`${gutter()}${' '.repeat(Math.max(1, frame.column))}${pc.red('^')}`);
42
+ const prefix = bounded((lines[i] ?? '').slice(0, Math.max(0, frame.column - 1)));
43
+ const column = visibleWidth(prefix);
44
+ // A clipped-away location has no visible token to annotate.
45
+ const limit = text === raw.trimEnd() ? maxChars : visibleWidth(text) - 1;
46
+ if (column < limit) {
47
+ rows.push(`${gutter()}${' '.repeat(column + 1)}${pc.red('^')}`);
48
+ }
43
49
  }
44
50
  }
45
51
  return rows;
@@ -55,8 +55,16 @@ export declare function toldAttempt(result: ReportResult, final: Outcome): Attem
55
55
  export type FailedStep = ReportStep & {
56
56
  readonly status: Exclude<ReportStep['status'], 'passed'>;
57
57
  };
58
- /** The step a failure happened at: the first that did not pass, with its position. */
59
- export declare function failedStepOf(steps: readonly ReportStep[]): {
58
+ /**
59
+ * The step whose error ended the attempt, with its position: the step in the
60
+ * error's phase whose own error carries its code and line, else the last with
61
+ * its code, since a caught failure comes earlier, else the step a timeout or
62
+ * interrupt cut.
63
+ */
64
+ export declare function failedStepOf(attempt: {
65
+ readonly steps: readonly ReportStep[];
66
+ readonly error?: ReportError | undefined;
67
+ }): {
60
68
  index: number;
61
69
  step: FailedStep;
62
70
  } | undefined;