@shanepadgett/tau-agent 0.5.1 → 0.7.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.
@@ -2,22 +2,21 @@ import { defineTool, withFileMutationQueue, type ExtensionAPI } from "@earendil-
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { link, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
6
  import { type Static, Type } from "typebox";
7
- import { editImage, generateImage, resolveCodexAuth, type EditImage } from "./client.ts";
7
+ import { XAI_IMAGE_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
8
+ import { detectImageMimeType, editImage, generateImage, type EditImage, type GeneratedImage } from "./client.ts";
8
9
 
9
- const MODEL = "gpt-image-2";
10
10
  const MAX_INPUT_BYTES = 50 * 1024 * 1024;
11
11
  const MAX_INLINE_BYTES = 12 * 1024 * 1024;
12
- const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13
12
 
14
13
  const imageGenSchema = Type.Object(
15
14
  {
16
15
  prompt: Type.String({ minLength: 1 }),
17
16
  path: Type.Optional(
18
- Type.String({ description: "Explicit PNG destination path; defaults to Tau's external image store" }),
17
+ Type.String({ description: "Explicit image destination path; defaults to Tau's external image store" }),
19
18
  ),
20
- referenced_image_paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 5 })),
19
+ referenced_image_paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 3 })),
21
20
  },
22
21
  { additionalProperties: false },
23
22
  );
@@ -26,23 +25,14 @@ type ImageGenParams = Static<typeof imageGenSchema>;
26
25
 
27
26
  interface ImageGenDetails {
28
27
  path: string;
29
- model: typeof MODEL;
28
+ model: typeof XAI_IMAGE_MODEL;
30
29
  operation: "generate" | "edit";
31
30
  }
32
31
 
33
- function detectImageMimeType(bytes: Buffer): EditImage["mimeType"] | undefined {
34
- if (bytes.length >= PNG_SIGNATURE.length && bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
35
- return "image/png";
36
- }
37
- if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
38
- if (
39
- bytes.length >= 12 &&
40
- bytes.subarray(0, 4).toString("ascii") === "RIFF" &&
41
- bytes.subarray(8, 12).toString("ascii") === "WEBP"
42
- ) {
43
- return "image/webp";
44
- }
45
- return undefined;
32
+ function outputExtension(image: GeneratedImage): string {
33
+ if (image.mimeType === "image/png") return ".png";
34
+ if (image.mimeType === "image/webp") return ".webp";
35
+ return ".jpg";
46
36
  }
47
37
 
48
38
  export default function imageGenExtension(pi: ExtensionAPI): void {
@@ -51,12 +41,12 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
51
41
  name: "image_gen",
52
42
  label: "Image Generation",
53
43
  description:
54
- "Generate a new raster image from a prompt, or edit one to five local raster images. Uses the existing OpenAI Codex OAuth login, saves a PNG under Tau's external image store unless an explicit path is provided, and returns the image for inspection.",
55
- promptSnippet: "Generate or edit raster images with OpenAI Codex",
44
+ "Generate a new raster image from a prompt, or edit one to three local raster images. Uses the xAI Grok subscription OAuth login, saves the result under Tau's external image store unless an explicit path is provided, and returns the image for inspection.",
45
+ promptSnippet: "Generate or edit raster images with Grok Imagine",
56
46
  promptGuidelines: [
57
47
  "Use image_gen when the user asks for a generated raster image or an AI edit of local raster images.",
58
48
  "Omit referenced_image_paths when image_gen should create a new image.",
59
- "Pass one to five local paths in referenced_image_paths when image_gen should edit or compose existing images.",
49
+ "Pass one to three local paths in referenced_image_paths when image_gen should edit or compose existing images.",
60
50
  "Omit path for temporary external storage. Pass path only when the user wants the generated image saved in their repository or another explicit location.",
61
51
  ],
62
52
  parameters: imageGenSchema,
@@ -66,18 +56,22 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
66
56
  if (!prompt) throw new Error("Image prompt cannot be empty");
67
57
  const requestedPath = params.path?.startsWith("@") ? params.path.slice(1) : params.path;
68
58
  if (requestedPath !== undefined && !requestedPath.trim()) throw new Error("Image path cannot be empty");
69
- const absolutePath = requestedPath
59
+ const requestedAbsolutePath = requestedPath
70
60
  ? isAbsolute(requestedPath)
71
61
  ? requestedPath
72
62
  : resolve(ctx.cwd, requestedPath)
73
- : join(homedir(), ".local", "share", "tau-agent", "images", `image-${randomUUID()}.png`);
74
- if (!absolutePath.toLowerCase().endsWith(".png")) throw new Error("Image path must end in .png");
63
+ : undefined;
64
+ if (
65
+ requestedAbsolutePath &&
66
+ ![".jpg", ".jpeg", ".png", ".webp"].includes(extname(requestedAbsolutePath).toLowerCase())
67
+ ) {
68
+ throw new Error("Image path must end in .jpg, .jpeg, .png, or .webp");
69
+ }
75
70
 
76
- const token = await ctx.modelRegistry.getApiKeyForProvider("openai-codex");
71
+ const token = await ctx.modelRegistry.getApiKeyForProvider(XAI_PROVIDER);
77
72
  if (!token) {
78
- throw new Error("OpenAI Codex OAuth is unavailable. Run /login and select OpenAI Codex.");
73
+ throw new Error("xAI OAuth is unavailable. Run /login and select xAI (Grok subscription OAuth).");
79
74
  }
80
- const auth = resolveCodexAuth(token);
81
75
  const images: EditImage[] = [];
82
76
  for (const path of params.referenced_image_paths ?? []) {
83
77
  const rawPath = path.startsWith("@") ? path.slice(1) : path;
@@ -101,22 +95,37 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
101
95
  type: "text",
102
96
  text:
103
97
  operation === "generate"
104
- ? `Generating image with ${MODEL}...`
105
- : `Editing image with ${MODEL}...`,
98
+ ? `Generating image with ${XAI_IMAGE_MODEL}...`
99
+ : `Editing image with ${XAI_IMAGE_MODEL}...`,
106
100
  },
107
101
  ],
108
102
  details: undefined,
109
103
  });
110
104
  const generated =
111
105
  operation === "generate"
112
- ? await generateImage(prompt, auth, signal)
113
- : await editImage(prompt, images, auth, signal);
106
+ ? await generateImage(prompt, token, signal)
107
+ : await editImage(prompt, images, token, signal);
114
108
  signal?.throwIfAborted();
109
+ const generatedExtension = outputExtension(generated);
110
+ if (requestedAbsolutePath) {
111
+ const requestedExtension = extname(requestedAbsolutePath).toLowerCase();
112
+ const matches =
113
+ requestedExtension === generatedExtension ||
114
+ (generatedExtension === ".jpg" && requestedExtension === ".jpeg");
115
+ if (!matches)
116
+ throw new Error(`xAI returned ${generated.mimeType}; destination must end in ${generatedExtension}`);
117
+ }
118
+ const absolutePath =
119
+ requestedAbsolutePath ??
120
+ join(homedir(), ".local", "share", "tau-agent", "images", `image-${randomUUID()}${generatedExtension}`);
115
121
 
116
122
  const outputDirectory = dirname(absolutePath);
117
123
  await withFileMutationQueue(absolutePath, async () => {
118
124
  await mkdir(outputDirectory, { recursive: true });
119
- const temporaryPath = join(outputDirectory, `.${basename(absolutePath)}.${randomUUID()}.tmp.png`);
125
+ const temporaryPath = join(
126
+ outputDirectory,
127
+ `.${basename(absolutePath)}.${randomUUID()}.tmp${generatedExtension}`,
128
+ );
120
129
  try {
121
130
  await writeFile(temporaryPath, generated.bytes, { flag: "wx" });
122
131
  signal?.throwIfAborted();
@@ -127,7 +136,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
127
136
  });
128
137
 
129
138
  const verb = operation === "generate" ? "Generated" : "Edited";
130
- const details: ImageGenDetails = { path: absolutePath, model: MODEL, operation };
139
+ const details: ImageGenDetails = { path: absolutePath, model: XAI_IMAGE_MODEL, operation };
131
140
  if (generated.bytes.length > MAX_INLINE_BYTES) {
132
141
  return {
133
142
  content: [
@@ -7,7 +7,6 @@ Agent definitions can override the parent model and thinking level. If an overri
7
7
  Tau includes three built-in agents:
8
8
 
9
9
  - `scout` explores local files and code with `read`, `grep`, `find`, and `ls`.
10
- - `context-maintenance` reconciles reusable repository context entries and requests approval before updates.
11
10
  - `web-research` researches web and code sources with `websearch`, `codesearch`, and `webfetch`.
12
11
 
13
12
  Ask Tau to delegate a task, or let it call `subagent` with an agent name and task. Children use the parent's current working directory and inherit its model and thinking level unless their definition overrides either value. They do not receive the parent conversation. Tau loads only the extensions that own a child's declared tools, so unrelated extension hooks do not run in child sessions. When a child must inspect another repository, put its exact absolute path in the delegated task.
@@ -126,10 +126,6 @@ async function loadScope(
126
126
  const reason = error instanceof Error ? error.message : "directory unavailable";
127
127
  return new Map([
128
128
  ["scout", [{ path: directory, name: "scout", reason: `packaged agents unavailable: ${reason}` }]],
129
- [
130
- "context-maintenance",
131
- [{ path: directory, name: "context-maintenance", reason: `packaged agents unavailable: ${reason}` }],
132
- ],
133
129
  [
134
130
  "web-research",
135
131
  [{ path: directory, name: "web-research", reason: `packaged agents unavailable: ${reason}` }],
@@ -28,11 +28,11 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
28
28
 
29
29
  ## context
30
30
 
31
- Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-manage <idea>` to run interactive, approval-based catalog maintenance. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
32
 
33
33
  ## explore
34
34
 
35
- Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. They produce smaller model payloads and readable tool rows. `read` includes focused line ranges; `grep`, `find`, and `ls` keep discovery output compact so the agent spends fewer tokens rereading directory and search results.
35
+ Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. They produce smaller model payloads and readable tool rows. Repeated `read` calls return unchanged markers or useful diffs when branch history proves the agent already saw the base content. A failed patch unlocks one normal reread of the affected path. `/read-stats` shows estimated token and cost savings for the current chat and whole session.
36
36
 
37
37
  ## footer
38
38
 
@@ -46,6 +46,10 @@ Adds `/ideas` to log rough ideas or open the ideas browser.
46
46
 
47
47
  Gives the agent an image-generation tool using the configured image service. Generated images are saved for inspection.
48
48
 
49
+ ## xai
50
+
51
+ Adds Grok 4.5 and Grok Imagine through an xAI Grok subscription OAuth login. Run `/login` and select xAI before use.
52
+
49
53
  ## manage-sessions
50
54
 
51
55
  Adds `/manage-sessions` to browse saved sessions and `/sweep` to archive or delete the current session after starting a new one.
@@ -84,7 +88,7 @@ Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed draf
84
88
 
85
89
  ## subagent
86
90
 
87
- Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in `scout`, `context-maintenance`, and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
91
+ Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in `scout` and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
88
92
 
89
93
  ## tau-help
90
94
 
@@ -0,0 +1,7 @@
1
+ # xAI OAuth
2
+
3
+ Adds Grok 4.5 using an xAI Grok subscription login. Run `/login`, select **xAI (Grok subscription OAuth)**, then authorize xAI in the browser. An existing official Grok CLI login can also be reused.
4
+
5
+ The same login powers Tau's `image_gen` tool through Grok Imagine. No xAI API key is used.
6
+
7
+ This integration uses xAI's undocumented subscription OAuth access. xAI may change its availability, entitlement rules, or protocol without notice.
@@ -0,0 +1,40 @@
1
+ import type { OAuthCredentials } from "@earendil-works/pi-ai";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { XAI_OAUTH_CLIENT_ID, XAI_OAUTH_ISSUER } from "./constants.ts";
6
+
7
+ function expiry(value: unknown): number | undefined {
8
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1_000_000_000_000) return value;
9
+ if (typeof value !== "string") return undefined;
10
+ const parsed = Date.parse(value);
11
+ return Number.isFinite(parsed) ? parsed : undefined;
12
+ }
13
+
14
+ export function parseGrokCredentials(value: unknown): OAuthCredentials | undefined {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
16
+ const entry = (value as Record<string, unknown>)[`${XAI_OAUTH_ISSUER}::${XAI_OAUTH_CLIENT_ID}`];
17
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined;
18
+ const record = entry as Record<string, unknown>;
19
+ const expires = expiry(record.expires_at);
20
+ if (
21
+ typeof record.key !== "string" ||
22
+ !record.key ||
23
+ typeof record.refresh_token !== "string" ||
24
+ !record.refresh_token ||
25
+ record.oidc_issuer !== XAI_OAUTH_ISSUER ||
26
+ record.oidc_client_id !== XAI_OAUTH_CLIENT_ID ||
27
+ expires === undefined
28
+ ) {
29
+ return undefined;
30
+ }
31
+ return { access: record.key, refresh: record.refresh_token, expires };
32
+ }
33
+
34
+ export async function readGrokCredentials(): Promise<OAuthCredentials | undefined> {
35
+ try {
36
+ return parseGrokCredentials(JSON.parse(await readFile(join(homedir(), ".grok", "auth.json"), "utf8")));
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
@@ -0,0 +1,11 @@
1
+ export const XAI_PROVIDER = "xai-oauth";
2
+ export const XAI_CHAT_MODEL = "grok-4.5";
3
+ export const XAI_IMAGE_MODEL = "grok-imagine-image-quality";
4
+ export const XAI_API_BASE_URL = "https://api.x.ai/v1";
5
+
6
+ export const XAI_OAUTH_ISSUER = "https://auth.x.ai";
7
+ export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
8
+ export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
9
+ export const XAI_OAUTH_CALLBACK_HOST = "127.0.0.1";
10
+ export const XAI_OAUTH_CALLBACK_PORT = 56121;
11
+ export const XAI_OAUTH_CALLBACK_PATH = "/callback";
@@ -0,0 +1,38 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { XAI_API_BASE_URL, XAI_CHAT_MODEL, XAI_PROVIDER } from "./constants.ts";
3
+ import { xaiOAuth } from "./oauth.ts";
4
+ import { rewriteXaiPayload } from "./payload.ts";
5
+
6
+ export default function xaiExtension(pi: ExtensionAPI): void {
7
+ pi.registerProvider(XAI_PROVIDER, {
8
+ name: "xAI (Grok subscription OAuth)",
9
+ baseUrl: XAI_API_BASE_URL,
10
+ api: "openai-responses",
11
+ authHeader: true,
12
+ oauth: xaiOAuth,
13
+ models: [
14
+ {
15
+ id: XAI_CHAT_MODEL,
16
+ name: "Grok 4.5",
17
+ reasoning: true,
18
+ input: ["text", "image"],
19
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
20
+ contextWindow: 500_000,
21
+ maxTokens: 131_072,
22
+ thinkingLevelMap: {
23
+ off: null,
24
+ minimal: "low",
25
+ low: "low",
26
+ medium: "medium",
27
+ high: "high",
28
+ xhigh: null,
29
+ max: null,
30
+ },
31
+ },
32
+ ],
33
+ });
34
+ pi.on("before_provider_request", (event, ctx) => {
35
+ if (ctx.model?.provider !== XAI_PROVIDER) return;
36
+ return rewriteXaiPayload(event.payload);
37
+ });
38
+ }
@@ -0,0 +1,342 @@
1
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { createServer, type Server } from "node:http";
4
+ import { readGrokCredentials } from "./auth.ts";
5
+ import {
6
+ XAI_OAUTH_CALLBACK_HOST,
7
+ XAI_OAUTH_CALLBACK_PATH,
8
+ XAI_OAUTH_CALLBACK_PORT,
9
+ XAI_OAUTH_CLIENT_ID,
10
+ XAI_OAUTH_ISSUER,
11
+ XAI_OAUTH_SCOPE,
12
+ } from "./constants.ts";
13
+
14
+ const DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
15
+ const REQUEST_TIMEOUT_MS = 30_000;
16
+ const LOGIN_TIMEOUT_MS = 180_000;
17
+ const REFRESH_SKEW_MS = 120_000;
18
+
19
+ interface Discovery {
20
+ authorization_endpoint: string;
21
+ token_endpoint: string;
22
+ }
23
+
24
+ interface TokenPayload {
25
+ access_token?: unknown;
26
+ refresh_token?: unknown;
27
+ id_token?: unknown;
28
+ expires_in?: unknown;
29
+ token_type?: unknown;
30
+ }
31
+
32
+ interface HttpResponse {
33
+ ok: boolean;
34
+ status: number;
35
+ body: { cancel(): Promise<void> } | null;
36
+ json(): Promise<unknown>;
37
+ }
38
+
39
+ interface CallbackResult {
40
+ code?: string;
41
+ error?: string;
42
+ errorDescription?: string;
43
+ }
44
+
45
+ function validatedEndpoint(value: unknown, field: string): string {
46
+ if (typeof value !== "string") throw new Error(`xAI OAuth discovery omitted ${field}`);
47
+ const url = new URL(value);
48
+ const host = url.hostname.toLowerCase();
49
+ if (url.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
50
+ throw new Error(`xAI OAuth discovery returned an unexpected ${field}`);
51
+ }
52
+ return url.toString();
53
+ }
54
+
55
+ function requestSignal(parent?: AbortSignal): AbortSignal {
56
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
57
+ return parent ? AbortSignal.any([parent, timeout]) : timeout;
58
+ }
59
+
60
+ async function discover(signal?: AbortSignal): Promise<Discovery> {
61
+ const response = (await fetch(DISCOVERY_URL, {
62
+ headers: { Accept: "application/json" },
63
+ signal: requestSignal(signal),
64
+ })) as HttpResponse;
65
+ if (!response.ok) throw new Error(`xAI OAuth discovery failed with status ${response.status}`);
66
+ const value: unknown = await response.json();
67
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
68
+ throw new Error("xAI OAuth discovery returned invalid JSON");
69
+ }
70
+ const record = value as Record<string, unknown>;
71
+ return {
72
+ authorization_endpoint: validatedEndpoint(record.authorization_endpoint, "authorization_endpoint"),
73
+ token_endpoint: validatedEndpoint(record.token_endpoint, "token_endpoint"),
74
+ };
75
+ }
76
+
77
+ async function tokenRequest(endpoint: string, body: URLSearchParams, signal?: AbortSignal): Promise<TokenPayload> {
78
+ const response = (await fetch(validatedEndpoint(endpoint, "token_endpoint"), {
79
+ method: "POST",
80
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
81
+ body,
82
+ signal: requestSignal(signal),
83
+ })) as HttpResponse;
84
+ if (!response.ok) {
85
+ await response.body?.cancel().catch(() => undefined);
86
+ throw new Error(`xAI OAuth token request failed with status ${response.status}`);
87
+ }
88
+ return (await response.json()) as TokenPayload;
89
+ }
90
+
91
+ function jwtClaims(token: string): Record<string, unknown> {
92
+ const segments = token.split(".");
93
+ if (segments.length !== 3 || !segments[1]) throw new Error("xAI OAuth returned an invalid ID token");
94
+ try {
95
+ const value: unknown = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8"));
96
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error();
97
+ return value as Record<string, unknown>;
98
+ } catch {
99
+ throw new Error("xAI OAuth returned an invalid ID token");
100
+ }
101
+ }
102
+
103
+ function credentials(payload: TokenPayload, endpoint: string, fallbackRefresh = "", nonce?: string): OAuthCredentials {
104
+ if (typeof payload.access_token !== "string" || !payload.access_token) {
105
+ throw new Error("xAI OAuth token response omitted the access token");
106
+ }
107
+ const refresh =
108
+ typeof payload.refresh_token === "string" && payload.refresh_token ? payload.refresh_token : fallbackRefresh;
109
+ if (!refresh) throw new Error("xAI OAuth token response omitted the refresh token");
110
+ if (nonce !== undefined) {
111
+ if (typeof payload.id_token !== "string" || !payload.id_token)
112
+ throw new Error("xAI OAuth token response omitted the ID token");
113
+ const claims = jwtClaims(payload.id_token);
114
+ const audience = claims.aud;
115
+ const validAudience =
116
+ audience === XAI_OAUTH_CLIENT_ID || (Array.isArray(audience) && audience.includes(XAI_OAUTH_CLIENT_ID));
117
+ if (claims.iss !== XAI_OAUTH_ISSUER || !validAudience || claims.nonce !== nonce) {
118
+ throw new Error("xAI OAuth ID token validation failed");
119
+ }
120
+ if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now()) {
121
+ throw new Error("xAI OAuth returned an expired ID token");
122
+ }
123
+ }
124
+ const expiresIn = typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : 3600;
125
+ return {
126
+ access: payload.access_token,
127
+ refresh,
128
+ expires: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS,
129
+ tokenEndpoint: endpoint,
130
+ };
131
+ }
132
+
133
+ async function closeServer(server: Server): Promise<void> {
134
+ if (!server.listening) return;
135
+ await new Promise<void>((resolve) => server.close(() => resolve()));
136
+ }
137
+
138
+ async function callbackServer(expectedState: string): Promise<{
139
+ redirectUri: string;
140
+ wait(signal?: AbortSignal): Promise<CallbackResult>;
141
+ acceptManual(input: string): string | undefined;
142
+ close(): Promise<void>;
143
+ }> {
144
+ let settle: ((result: CallbackResult) => void) | undefined;
145
+ let reject: ((error: Error) => void) | undefined;
146
+ let settled = false;
147
+ const result = new Promise<CallbackResult>((resolve, rejectResult) => {
148
+ settle = resolve;
149
+ reject = rejectResult;
150
+ });
151
+ const accept = (value: CallbackResult) => {
152
+ if (settled) return;
153
+ settled = true;
154
+ settle?.(value);
155
+ };
156
+ const parse = (params: URLSearchParams): CallbackResult | undefined => {
157
+ if (params.get("state") !== expectedState) return undefined;
158
+ const code = params.get("code") || undefined;
159
+ const error = params.get("error") || undefined;
160
+ if (!code && !error) return undefined;
161
+ return { code, error, errorDescription: params.get("error_description") || undefined };
162
+ };
163
+ const server = createServer((request, response) => {
164
+ const origin = request.headers.origin;
165
+ if (origin === "https://accounts.x.ai" || origin === "https://auth.x.ai") {
166
+ response.setHeader("Access-Control-Allow-Origin", origin);
167
+ response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
168
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type");
169
+ response.setHeader("Access-Control-Allow-Private-Network", "true");
170
+ response.setHeader("Vary", "Origin");
171
+ }
172
+ if (request.method === "OPTIONS") {
173
+ response.writeHead(204).end();
174
+ return;
175
+ }
176
+ const url = new URL(request.url ?? "/", `http://${XAI_OAUTH_CALLBACK_HOST}`);
177
+ if (request.method !== "GET" || url.pathname !== XAI_OAUTH_CALLBACK_PATH) {
178
+ response.writeHead(404).end("Not found");
179
+ return;
180
+ }
181
+ const parsed = parse(url.searchParams);
182
+ if (!parsed) {
183
+ response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Invalid OAuth callback");
184
+ return;
185
+ }
186
+ response
187
+ .writeHead(parsed.error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" })
188
+ .end("<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>", () =>
189
+ accept(parsed),
190
+ );
191
+ });
192
+ const listen = (port: number) =>
193
+ new Promise<number>((resolve, rejectListen) => {
194
+ server.once("error", rejectListen);
195
+ server.listen(port, XAI_OAUTH_CALLBACK_HOST, () => {
196
+ server.removeListener("error", rejectListen);
197
+ const address = server.address();
198
+ if (!address || typeof address === "string") rejectListen(new Error("Could not determine callback port"));
199
+ else resolve(address.port);
200
+ });
201
+ });
202
+ let port: number;
203
+ try {
204
+ port = await listen(XAI_OAUTH_CALLBACK_PORT);
205
+ } catch {
206
+ port = await listen(0);
207
+ }
208
+ return {
209
+ redirectUri: `http://${XAI_OAUTH_CALLBACK_HOST}:${port}${XAI_OAUTH_CALLBACK_PATH}`,
210
+ acceptManual(input) {
211
+ try {
212
+ const value = input.trim();
213
+ const url = value.startsWith("http")
214
+ ? new URL(value)
215
+ : new URL(`http://${XAI_OAUTH_CALLBACK_HOST}${XAI_OAUTH_CALLBACK_PATH}?${value.replace(/^\?/, "")}`);
216
+ if (url.pathname !== XAI_OAUTH_CALLBACK_PATH) return "Callback URL path was not recognized";
217
+ const parsed = parse(url.searchParams);
218
+ if (!parsed) return "Callback state did not match";
219
+ accept(parsed);
220
+ return undefined;
221
+ } catch {
222
+ return "Callback URL was invalid";
223
+ }
224
+ },
225
+ async wait(signal) {
226
+ const timeout = setTimeout(() => {
227
+ if (!settled) {
228
+ settled = true;
229
+ reject?.(new Error("Timed out waiting for xAI OAuth callback"));
230
+ }
231
+ }, LOGIN_TIMEOUT_MS);
232
+ const onAbort = () => {
233
+ if (!settled) {
234
+ settled = true;
235
+ reject?.(new Error("xAI OAuth login was cancelled"));
236
+ }
237
+ };
238
+ signal?.addEventListener("abort", onAbort, { once: true });
239
+ try {
240
+ return await result;
241
+ } finally {
242
+ clearTimeout(timeout);
243
+ signal?.removeEventListener("abort", onAbort);
244
+ await closeServer(server);
245
+ }
246
+ },
247
+ close: () => closeServer(server),
248
+ };
249
+ }
250
+
251
+ async function refreshXaiCredentials(value: OAuthCredentials): Promise<OAuthCredentials> {
252
+ if (!value.refresh) throw new Error("xAI OAuth credential cannot be refreshed; run /login again");
253
+ const endpoint =
254
+ typeof value.tokenEndpoint === "string" && value.tokenEndpoint
255
+ ? validatedEndpoint(value.tokenEndpoint, "token_endpoint")
256
+ : (await discover()).token_endpoint;
257
+ const payload = await tokenRequest(
258
+ endpoint,
259
+ new URLSearchParams({
260
+ grant_type: "refresh_token",
261
+ client_id: XAI_OAUTH_CLIENT_ID,
262
+ refresh_token: value.refresh,
263
+ }),
264
+ );
265
+ return credentials(payload, endpoint, value.refresh);
266
+ }
267
+
268
+ export const xaiOAuth = {
269
+ name: "xAI (Grok subscription)",
270
+ usesCallbackServer: true,
271
+ async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
272
+ const existing = await readGrokCredentials();
273
+ if (existing) {
274
+ const method = await callbacks.onSelect({
275
+ message: "Select xAI login method:",
276
+ options: [
277
+ { id: "browser", label: "Browser login" },
278
+ { id: "existing", label: "Use existing Grok CLI login" },
279
+ ],
280
+ });
281
+ if (!method) throw new Error("Login cancelled");
282
+ if (method === "existing") {
283
+ if (existing.expires > Date.now()) return existing;
284
+ try {
285
+ return await refreshXaiCredentials(existing);
286
+ } catch {
287
+ callbacks.onProgress?.("The existing Grok CLI login could not be refreshed. Starting browser login.");
288
+ }
289
+ }
290
+ }
291
+ const discovery = await discover(callbacks.signal);
292
+ const verifier = randomBytes(32).toString("base64url");
293
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
294
+ const state = randomBytes(24).toString("base64url");
295
+ const nonce = randomBytes(24).toString("base64url");
296
+ const callback = await callbackServer(state);
297
+ try {
298
+ const url = new URL(discovery.authorization_endpoint);
299
+ url.search = new URLSearchParams({
300
+ response_type: "code",
301
+ client_id: XAI_OAUTH_CLIENT_ID,
302
+ redirect_uri: callback.redirectUri,
303
+ scope: XAI_OAUTH_SCOPE,
304
+ code_challenge: challenge,
305
+ code_challenge_method: "S256",
306
+ state,
307
+ nonce,
308
+ }).toString();
309
+ callbacks.onAuth({ url: url.toString(), instructions: "Authorize xAI in your browser, then return to Tau." });
310
+ if (callbacks.onManualCodeInput) {
311
+ void callbacks
312
+ .onManualCodeInput()
313
+ .then((input) => {
314
+ const error = callback.acceptManual(input);
315
+ if (error) callbacks.onProgress?.(`Ignored pasted callback: ${error}`);
316
+ })
317
+ .catch(() => undefined);
318
+ }
319
+ const result = await callback.wait(callbacks.signal);
320
+ if (result.error) throw new Error(`xAI authorization failed: ${result.errorDescription ?? result.error}`);
321
+ if (!result.code) throw new Error("xAI authorization did not return a code");
322
+ const payload = await tokenRequest(
323
+ discovery.token_endpoint,
324
+ new URLSearchParams({
325
+ grant_type: "authorization_code",
326
+ client_id: XAI_OAUTH_CLIENT_ID,
327
+ code: result.code,
328
+ redirect_uri: callback.redirectUri,
329
+ code_verifier: verifier,
330
+ }),
331
+ callbacks.signal,
332
+ );
333
+ return credentials(payload, discovery.token_endpoint, "", nonce);
334
+ } finally {
335
+ await callback.close();
336
+ }
337
+ },
338
+ refreshToken: refreshXaiCredentials,
339
+ getApiKey(value: OAuthCredentials): string {
340
+ return value.access;
341
+ },
342
+ };