@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.
- package/docs/subagents.md +1 -2
- package/extensions/auto-name/index.ts +1 -1
- package/extensions/commit/commit-plan.ts +12 -5
- package/extensions/context/README.md +1 -1
- package/extensions/context/definitions.ts +2 -142
- package/extensions/context/index.ts +67 -387
- package/extensions/context/panel.ts +43 -108
- package/extensions/context/sync.ts +693 -0
- package/extensions/explore/README.md +2 -2
- package/extensions/explore/autoread.ts +20 -3
- package/extensions/explore/index.ts +23 -2
- package/extensions/explore/read-cache.ts +209 -0
- package/extensions/explore/read-snapshots.ts +44 -0
- package/extensions/explore/read-stats-panel.ts +154 -0
- package/extensions/explore/read-stats.ts +120 -0
- package/extensions/explore/read.ts +198 -42
- package/extensions/image-gen/README.md +4 -4
- package/extensions/image-gen/client.ts +126 -107
- package/extensions/image-gen/index.ts +44 -35
- package/extensions/subagent/README.md +0 -1
- package/extensions/subagent/agents.ts +0 -4
- package/extensions/tau-help/help.md +7 -3
- package/extensions/xai/README.md +7 -0
- package/extensions/xai/auth.ts +40 -0
- package/extensions/xai/constants.ts +11 -0
- package/extensions/xai/index.ts +38 -0
- package/extensions/xai/oauth.ts +342 -0
- package/extensions/xai/payload.ts +68 -0
- package/package.json +2 -2
- package/shared/git.ts +2 -2
- package/shared/model-fallback/index.ts +17 -12
- package/extensions/subagent/agents/context-maintenance.md +0 -23
|
@@ -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 {
|
|
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
|
|
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:
|
|
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
|
|
28
|
+
model: typeof XAI_IMAGE_MODEL;
|
|
30
29
|
operation: "generate" | "edit";
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
function
|
|
34
|
-
if (
|
|
35
|
-
|
|
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
|
|
55
|
-
promptSnippet: "Generate or edit raster images with
|
|
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
|
|
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
|
|
59
|
+
const requestedAbsolutePath = requestedPath
|
|
70
60
|
? isAbsolute(requestedPath)
|
|
71
61
|
? requestedPath
|
|
72
62
|
: resolve(ctx.cwd, requestedPath)
|
|
73
|
-
:
|
|
74
|
-
if (
|
|
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(
|
|
71
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider(XAI_PROVIDER);
|
|
77
72
|
if (!token) {
|
|
78
|
-
throw new Error("
|
|
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 ${
|
|
105
|
-
: `Editing image with ${
|
|
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,
|
|
113
|
-
: await editImage(prompt, images,
|
|
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(
|
|
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:
|
|
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-
|
|
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`
|
|
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
|
|
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
|
+
};
|