@volter/twin-xai 0.1.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/LICENSE +202 -0
- package/README.md +246 -0
- package/client/xai-device-auth.css +246 -0
- package/client/xai-device-auth.tsx +138 -0
- package/dist/client/xai-device-auth.bundle.js +18 -0
- package/dist/client/xai-device-auth.css +246 -0
- package/dist/client/xai-device-auth.d.ts +19 -0
- package/dist/client/xai-device-auth.js +50 -0
- package/dist/client/xai-device-auth.tsx +138 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +70 -0
- package/dist/src/xai-budget.d.ts +60 -0
- package/dist/src/xai-budget.js +139 -0
- package/dist/src/xai-capabilities.d.ts +4 -0
- package/dist/src/xai-capabilities.js +1072 -0
- package/dist/src/xai-conformance.d.ts +13 -0
- package/dist/src/xai-conformance.js +148 -0
- package/dist/src/xai-connector.d.ts +82 -0
- package/dist/src/xai-connector.js +174 -0
- package/dist/src/xai-device-auth-css.gen.d.ts +1 -0
- package/dist/src/xai-device-auth-css.gen.js +6 -0
- package/dist/src/xai-device-auth-ui.d.ts +13 -0
- package/dist/src/xai-device-auth-ui.js +72 -0
- package/dist/src/xai-models.d.ts +57 -0
- package/dist/src/xai-models.js +102 -0
- package/dist/src/xai-oauth.d.ts +30 -0
- package/dist/src/xai-oauth.js +279 -0
- package/dist/src/xai-scenario.d.ts +33 -0
- package/dist/src/xai-scenario.js +139 -0
- package/dist/src/xai-server.d.ts +36 -0
- package/dist/src/xai-server.js +232 -0
- package/dist/src/xai-stub.d.ts +69 -0
- package/dist/src/xai-stub.js +210 -0
- package/dist/src/xai-twin.d.ts +89 -0
- package/dist/src/xai-twin.js +883 -0
- package/dist/src/xai-types.d.ts +118 -0
- package/dist/src/xai-types.js +6 -0
- package/package.json +76 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +120 -0
- package/src/xai-budget.ts +165 -0
- package/src/xai-capabilities.ts +1046 -0
- package/src/xai-conformance.ts +136 -0
- package/src/xai-connector.ts +212 -0
- package/src/xai-device-auth-css.gen.ts +6 -0
- package/src/xai-device-auth-ui.ts +90 -0
- package/src/xai-journey.uitest.ts +155 -0
- package/src/xai-models.ts +154 -0
- package/src/xai-oauth.ts +301 -0
- package/src/xai-scenario.ts +148 -0
- package/src/xai-server.ts +258 -0
- package/src/xai-stub.ts +213 -0
- package/src/xai-twin.ts +960 -0
- package/src/xai-types.ts +111 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// xAI twin conformance — a lightweight, offline structural check that the twin's served
|
|
2
|
+
// response ENVELOPES match the documented xAI shapes. xAI publishes no first-party
|
|
3
|
+
// machine-readable spec (spec-sources.json records kind:'none'), so this harness validates
|
|
4
|
+
// the load-bearing envelope SHAPES directly: chat.completion (+ the streaming chunk
|
|
5
|
+
// sequence + tool_calls), the flat {code, error} error envelope, the three model catalogs,
|
|
6
|
+
// deferred-completion lifecycle, tokenize-text, image generations, and the Anthropic-
|
|
7
|
+
// compatible /v1/messages envelope. Fully offline + deterministic (drives the local handler
|
|
8
|
+
// against a temp root) so it runs in CI without an API key. Honest scope: it checks the
|
|
9
|
+
// protocol envelope, NOT model output (a deterministic labeled stub by design).
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { handleXaiTwinRequest } from './xai-twin.ts';
|
|
14
|
+
import type { SseEvent } from './xai-types.ts';
|
|
15
|
+
|
|
16
|
+
export type ConformanceViolation = { check: string; detail: string };
|
|
17
|
+
export type XaiConformanceReport = {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
checksRun: number;
|
|
20
|
+
violations: ConformanceViolation[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Body = Record<string, unknown>;
|
|
24
|
+
const isObj = (v: unknown): v is Body => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
25
|
+
|
|
26
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
27
|
+
export async function checkXaiConformance(opts: { root?: string } = {}): Promise<XaiConformanceReport> {
|
|
28
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'xai-conf-'));
|
|
29
|
+
const owned = opts.root === undefined;
|
|
30
|
+
const violations: ConformanceViolation[] = [];
|
|
31
|
+
let checksRun = 0;
|
|
32
|
+
const fail = (check: string, detail: string) => violations.push({ check, detail });
|
|
33
|
+
const H = (method: string, path: string, body?: string, sseSink?: (e: SseEvent) => void) =>
|
|
34
|
+
handleXaiTwinRequest({ method, path, body, root, occurredAt: '2026-01-01T00:00:00Z', ...(sseSink ? { sseSink } : {}) });
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// 1. chat.completion response envelope (+ xAI usage detail objects).
|
|
38
|
+
checksRun++;
|
|
39
|
+
const chat = await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'grok-4-0709', messages: [{ role: 'user', content: 'hi' }] }));
|
|
40
|
+
const cb = chat.body as Body;
|
|
41
|
+
if (chat.status !== 200) fail('chat.envelope', `status ${chat.status}`);
|
|
42
|
+
else {
|
|
43
|
+
for (const k of ['id', 'object', 'created', 'model', 'choices', 'usage', 'system_fingerprint']) if (!(k in cb)) fail('chat.envelope', `missing field "${k}"`);
|
|
44
|
+
if (cb.object !== 'chat.completion') fail('chat.envelope', `object is ${String(cb.object)}`);
|
|
45
|
+
const choices = cb.choices as Body[];
|
|
46
|
+
if (!Array.isArray(choices) || !isObj(choices[0]) || (choices[0]!.message as Body)?.role !== 'assistant') fail('chat.envelope', 'choices[0].message not assistant');
|
|
47
|
+
if ((choices[0]!.finish_reason) !== 'stop') fail('chat.envelope', `finish_reason is ${String(choices[0]!.finish_reason)}`);
|
|
48
|
+
const usage = cb.usage as Body;
|
|
49
|
+
if (!isObj(usage) || typeof usage.prompt_tokens !== 'number' || typeof usage.total_tokens !== 'number') fail('chat.usage', 'usage missing prompt/total tokens');
|
|
50
|
+
if (!isObj(usage?.prompt_tokens_details) || !isObj(usage?.completion_tokens_details)) fail('chat.usage', 'usage missing xAI token detail objects');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. chat streaming chunk sequence (ends with done).
|
|
54
|
+
checksRun++;
|
|
55
|
+
const events: SseEvent[] = [];
|
|
56
|
+
await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'grok-4-0709', stream: true, messages: [{ role: 'user', content: 'stream' }] }), (e) => events.push(e));
|
|
57
|
+
const hasRole = events.some((e) => !e.done && ((e.data!.choices as Body[])?.[0]?.delta as Body)?.role === 'assistant');
|
|
58
|
+
const hasDone = events.length > 0 && events[events.length - 1]!.done === true;
|
|
59
|
+
const firstChunkObj = events[0]?.data?.object;
|
|
60
|
+
if (!hasRole) fail('chat.stream', 'no role delta chunk');
|
|
61
|
+
if (!hasDone) fail('chat.stream', 'stream did not end with [DONE]');
|
|
62
|
+
if (firstChunkObj !== 'chat.completion.chunk') fail('chat.stream', `first chunk object is ${String(firstChunkObj)}`);
|
|
63
|
+
|
|
64
|
+
// 3. tool_calls envelope when tools provided.
|
|
65
|
+
checksRun++;
|
|
66
|
+
const tool = await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'grok-4-0709', tools: [{ type: 'function', function: { name: 'get_weather', parameters: { type: 'object' } } }], messages: [{ role: 'user', content: 'weather?' }] }));
|
|
67
|
+
const tc = (tool.body as Body).choices as Body[];
|
|
68
|
+
if ((tc?.[0]?.finish_reason) !== 'tool_calls') fail('chat.tool_calls', 'finish_reason not tool_calls');
|
|
69
|
+
const calls = (tc?.[0]?.message as Body)?.tool_calls as Body[];
|
|
70
|
+
if (!Array.isArray(calls) || calls[0]?.type !== 'function') fail('chat.tool_calls', 'no function tool_call');
|
|
71
|
+
|
|
72
|
+
// 4. the flat {code, error} error envelope (xAI's gRPC-transcoded shape) + unknown model 404.
|
|
73
|
+
checksRun++;
|
|
74
|
+
const bad = await H('POST', '/v1/chat/completions', JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }));
|
|
75
|
+
const eb = bad.body as Body;
|
|
76
|
+
if (bad.status !== 400 || typeof eb.code !== 'string' || typeof eb.error !== 'string') fail('error.envelope', 'missing model did not yield the flat {code, error} 400 envelope');
|
|
77
|
+
if (isObj(eb.error)) fail('error.envelope', 'error must be a FLAT string, not a nested object (that is OpenAI\'s shape, not xAI\'s)');
|
|
78
|
+
const noModel = await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'grok-nope', messages: [{ role: 'user', content: 'hi' }] }));
|
|
79
|
+
if (noModel.status !== 404 || typeof (noModel.body as Body).code !== 'string') fail('error.model_not_found', 'unknown model did not 404 with the {code, error} envelope');
|
|
80
|
+
const nf = await H('GET', '/v1/nonexistent');
|
|
81
|
+
if (nf.status !== 404 || typeof (nf.body as Body).code !== 'string') fail('error.not_found', 'unknown route did not 404');
|
|
82
|
+
|
|
83
|
+
// 5. the three model catalogs (flat + rich + image).
|
|
84
|
+
checksRun++;
|
|
85
|
+
const models = await H('GET', '/v1/models');
|
|
86
|
+
if (!Array.isArray((models.body as Body).data)) fail('models.list', 'no data array');
|
|
87
|
+
const one = await H('GET', '/v1/models/grok-4-0709');
|
|
88
|
+
if ((one.body as Body).object !== 'model') fail('models.retrieve', 'object is not a model');
|
|
89
|
+
const rich = await H('GET', '/v1/language-models');
|
|
90
|
+
const richList = (rich.body as Body).models as Body[];
|
|
91
|
+
if (!Array.isArray(richList) || !richList.some((m) => Array.isArray(m.aliases))) fail('models.language', 'no rich models array with aliases');
|
|
92
|
+
const img = await H('GET', '/v1/image-generation-models');
|
|
93
|
+
if (!Array.isArray((img.body as Body).models)) fail('models.image', 'no image-generation models array');
|
|
94
|
+
|
|
95
|
+
// 6. deferred completion lifecycle: request_id → 202 pending → 200 result → 404 after.
|
|
96
|
+
checksRun++;
|
|
97
|
+
const def = await H('POST', '/v1/chat/completions', JSON.stringify({ model: 'grok-4-0709', deferred: true, messages: [{ role: 'user', content: 'later' }] }));
|
|
98
|
+
const reqId = (def.body as Body).request_id;
|
|
99
|
+
if (def.status !== 200 || typeof reqId !== 'string') fail('deferred.create', 'no request_id');
|
|
100
|
+
else {
|
|
101
|
+
const p1 = await H('GET', `/v1/chat/deferred-completion/${reqId}`);
|
|
102
|
+
if (p1.status !== 202) fail('deferred.pending', `first poll status ${p1.status}`);
|
|
103
|
+
const p2 = await H('GET', `/v1/chat/deferred-completion/${reqId}`);
|
|
104
|
+
if (p2.status !== 200 || (p2.body as Body).object !== 'chat.completion') fail('deferred.result', 'second poll did not serve the completion');
|
|
105
|
+
const p3 = await H('GET', `/v1/chat/deferred-completion/${reqId}`);
|
|
106
|
+
if (p3.status !== 404) fail('deferred.consumed_once', 'result served more than once');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 7. tokenize-text + api-key introspection.
|
|
110
|
+
checksRun++;
|
|
111
|
+
const tok = await H('POST', '/v1/tokenize-text', JSON.stringify({ text: 'hello twin', model: 'grok-4-0709' }));
|
|
112
|
+
const ids = (tok.body as Body).token_ids as Body[];
|
|
113
|
+
if (!Array.isArray(ids) || ids.length === 0 || typeof ids[0]?.token_id !== 'number') fail('tokenize.shape', 'no token_ids array');
|
|
114
|
+
const key = await H('GET', '/v1/api-key');
|
|
115
|
+
if (typeof (key.body as Body).redacted_api_key !== 'string' || !Array.isArray((key.body as Body).acls)) fail('api_key.shape', 'no redacted_api_key/acls');
|
|
116
|
+
|
|
117
|
+
// 8. image generations (shape + the size-rejected compatibility delta).
|
|
118
|
+
checksRun++;
|
|
119
|
+
const image = await H('POST', '/v1/images/generations', JSON.stringify({ model: 'grok-2-image', prompt: 'a cat' }));
|
|
120
|
+
const data = (image.body as Body).data as Body[];
|
|
121
|
+
if (!Array.isArray(data) || typeof data[0]?.url !== 'string') fail('images.shape', 'no data[].url');
|
|
122
|
+
const size = await H('POST', '/v1/images/generations', JSON.stringify({ model: 'grok-2-image', prompt: 'a cat', size: '1024x1024' }));
|
|
123
|
+
if (size.status !== 400) fail('images.size_rejected', 'size param was not rejected (xAI rejects OpenAI\'s size)');
|
|
124
|
+
|
|
125
|
+
// 9. Anthropic-compatible /v1/messages envelope.
|
|
126
|
+
checksRun++;
|
|
127
|
+
const msg = await H('POST', '/v1/messages', JSON.stringify({ model: 'grok-4-0709', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }] }));
|
|
128
|
+
const mb = msg.body as Body;
|
|
129
|
+
if (mb.type !== 'message' || !Array.isArray(mb.content) || (mb.content as Body[])[0]?.type !== 'text') fail('messages.envelope', 'bad messages envelope');
|
|
130
|
+
const noMax = await H('POST', '/v1/messages', JSON.stringify({ model: 'grok-4-0709', messages: [{ role: 'user', content: 'hi' }] }));
|
|
131
|
+
if (noMax.status !== 400) fail('messages.max_tokens_required', 'missing max_tokens did not 400');
|
|
132
|
+
} finally {
|
|
133
|
+
if (owned) rmSync(root, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
return { ok: violations.length === 0, checksRun, violations };
|
|
136
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// xAI CONNECTOR — the live-vendor pull path that lets the xAI twin mirror real account state.
|
|
2
|
+
//
|
|
3
|
+
// PULL (real → twin): fetch the real model catalogs (GET /v1/models + /v1/language-models),
|
|
4
|
+
// map them → SyncResource[], fold into the event log via syncPull
|
|
5
|
+
// (shadow-diff dedup, so re-pulling identical state appends nothing).
|
|
6
|
+
// The twin's served /v1/models and /v1/language-models MERGE these
|
|
7
|
+
// observed rows over the static catalog.
|
|
8
|
+
// PUSH (twin → real): THE xAI API HAS NO CLIENT-WRITABLE RESOURCES — every endpoint is
|
|
9
|
+
// inference or read-only catalog/introspection. The twin's only local
|
|
10
|
+
// writes are its own deferred-completion lifecycle rows, which are twin
|
|
11
|
+
// inference state, not vendor resources. So push REFUSES LOUDLY for
|
|
12
|
+
// every operation rather than silently dropping (or fabricating) a
|
|
13
|
+
// write — the same fail-loud rule the other connectors apply to
|
|
14
|
+
// unmodeled op types, applied to the whole (empty) push surface.
|
|
15
|
+
//
|
|
16
|
+
// The vendor I/O is an INJECTED executor (B3 auth boundary): the kernel + this pack hold NO
|
|
17
|
+
// xAI key and import NO SDK at runtime. Tests pass a fake executor; live runs pass
|
|
18
|
+
// `liveXaiExecute(apiKey)`. Same code path either way — fully exercisable offline.
|
|
19
|
+
import { assertBudgetGuardIntact, observeResources } from '@volter/world-core';
|
|
20
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
21
|
+
import { XaiBudget, XaiBudgetError, xaiCallWeight, type XaiBudgetOptions } from './xai-budget.ts';
|
|
22
|
+
|
|
23
|
+
const SERVICE = 'xai';
|
|
24
|
+
|
|
25
|
+
/** The real xAI API host. The ONLY place this pack names it as a target. */
|
|
26
|
+
export const XAI_API_BASE = 'https://api.x.ai';
|
|
27
|
+
|
|
28
|
+
/** Construction options for the live executor. There is deliberately no way to disable the guard. */
|
|
29
|
+
export type LiveXaiOptions = {
|
|
30
|
+
/** Injected transport. Tests pass a counting fake; production passes nothing. */
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
/** An existing budget to share across executes. Omit and one is constructed. Cannot be null. */
|
|
33
|
+
budget?: XaiBudget;
|
|
34
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
35
|
+
budgetOptions?: XaiBudgetOptions;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The injected real-xAI boundary. `request` issues ONE xAI REST call:
|
|
40
|
+
* method — 'GET' (the pull surface is read-only)
|
|
41
|
+
* path — e.g. '/v1/models' or '/v1/language-models'
|
|
42
|
+
* Returns the parsed JSON (a `{ data }` / `{ models }` list, or a `{ code, error }` envelope).
|
|
43
|
+
*/
|
|
44
|
+
export type XaiExecute = (
|
|
45
|
+
method: 'GET',
|
|
46
|
+
path: string,
|
|
47
|
+
) => Promise<{ data?: any; models?: any; code?: string; error?: string; [k: string]: unknown }>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* THE CHOKE POINT — the one place this pack builds an `XaiExecute` that really reaches api.x.ai, and
|
|
51
|
+
* therefore the one place the rate budget has to be enforced. Sends the required
|
|
52
|
+
* `Authorization: Bearer` header. Never imported by the pack's own code path — only constructed by a
|
|
53
|
+
* caller that opts into real I/O.
|
|
54
|
+
*
|
|
55
|
+
* EVERY request is guarded: the budget is charged BEFORE it goes out (`checkBudget`, which THROWS
|
|
56
|
+
* instead of returning once the ceiling or a persisted cooldown says stop) and the response is fed
|
|
57
|
+
* back (`recordCall`) so a `Retry-After` / 429 / rate-limit-exhaustion signal becomes a persisted
|
|
58
|
+
* cooldown that makes every later call fail fast WITHOUT touching xAI. There is deliberately NO
|
|
59
|
+
* option to disable the guard. See `xai-budget.ts` for the numbers — and for why they are the
|
|
60
|
+
* kernel's austere fallback rather than a model of a published limit (xAI publishes none at all).
|
|
61
|
+
*
|
|
62
|
+
* No vendor SDK is imported: real-transport `fetch` keeps the SDK a dev-only dependency, which the
|
|
63
|
+
* architecture guardrail requires.
|
|
64
|
+
*/
|
|
65
|
+
export function liveXaiExecute(
|
|
66
|
+
apiKey: string,
|
|
67
|
+
base = XAI_API_BASE,
|
|
68
|
+
opts: LiveXaiOptions = {},
|
|
69
|
+
): XaiExecute {
|
|
70
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
71
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
72
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
73
|
+
// must be an UNMODIFIED `XaiBudget` — a duck-typed stand-in, a SUBCLASS overriding `checkBudget`,
|
|
74
|
+
// and a Proxy trapping it are ALL refused, because each is a one-liner that would otherwise hand
|
|
75
|
+
// back a client with no ceiling. What this cannot stop is deliberate sabotage from inside the
|
|
76
|
+
// process (an injected clock, a throwaway ledger path); the kernel's header states that limit
|
|
77
|
+
// rather than pretending otherwise.
|
|
78
|
+
//
|
|
79
|
+
// The default ledger is keyed by a hash of THIS key — the limit is per key, so a cwd-scoped ledger
|
|
80
|
+
// would hand the same key a fresh allowance per checkout/worktree/CI leg.
|
|
81
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
82
|
+
? assertBudgetGuardIntact(opts.budget, XaiBudget, 'liveXaiExecute')
|
|
83
|
+
: new XaiBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
|
|
84
|
+
return async (method, path) => {
|
|
85
|
+
const weight = xaiCallWeight(method, path);
|
|
86
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
87
|
+
const reservation = budget.checkBudget(weight);
|
|
88
|
+
const res = await doFetch(`${base}${path}`, { method, headers: { authorization: `Bearer ${apiKey}` } });
|
|
89
|
+
const resHeaders: Record<string, string> = {};
|
|
90
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
91
|
+
const parsed = (await res.json()) as { data?: any; models?: any; code?: string; error?: string };
|
|
92
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
93
|
+
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted first
|
|
94
|
+
// either way, so the refusal survives the throw.
|
|
95
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
96
|
+
// call that louder refusal wins; an answer xAI ACCEPTED is kept, so a write that landed is
|
|
97
|
+
// never recorded as failed and performed again on retry.
|
|
98
|
+
try {
|
|
99
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!(error instanceof XaiBudgetError) || !res.ok) throw error;
|
|
102
|
+
}
|
|
103
|
+
return parsed;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function throwIfError(res: { code?: string; error?: string }, ctx: string): void {
|
|
108
|
+
// xAI's error envelope is FLAT: { code, error } strings (no nested error object).
|
|
109
|
+
if (typeof res.error === 'string' && res.error) throw new Error(`xai ${ctx} failed: ${res.error}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── PULL ────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** Map a real-xAI flat model object (GET /v1/models) → a twin sync resource. */
|
|
115
|
+
export function mapModel(m: Record<string, unknown>): SyncResource {
|
|
116
|
+
return {
|
|
117
|
+
type: 'model',
|
|
118
|
+
id: String(m.id),
|
|
119
|
+
fields: {
|
|
120
|
+
object: 'model',
|
|
121
|
+
created: (m.created as number) ?? 0,
|
|
122
|
+
owned_by: (m.owned_by as string) ?? 'xai',
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Map a real-xAI rich language-model object (GET /v1/language-models) → a twin sync resource. */
|
|
128
|
+
export function mapLanguageModel(m: Record<string, unknown>): SyncResource {
|
|
129
|
+
return {
|
|
130
|
+
type: 'language_model',
|
|
131
|
+
id: String(m.id),
|
|
132
|
+
fields: {
|
|
133
|
+
object: 'model',
|
|
134
|
+
created: (m.created as number) ?? 0,
|
|
135
|
+
owned_by: (m.owned_by as string) ?? 'xai',
|
|
136
|
+
version: (m.version as string) ?? null,
|
|
137
|
+
fingerprint: (m.fingerprint as string) ?? null,
|
|
138
|
+
input_modalities: Array.isArray(m.input_modalities) ? m.input_modalities : [],
|
|
139
|
+
output_modalities: Array.isArray(m.output_modalities) ? m.output_modalities : [],
|
|
140
|
+
aliases: Array.isArray(m.aliases) ? m.aliases : [],
|
|
141
|
+
prompt_text_token_price: (m.prompt_text_token_price as number) ?? null,
|
|
142
|
+
completion_text_token_price: (m.completion_text_token_price as number) ?? null,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Pull the real model catalogs via the executor and map them to twin sync resources. */
|
|
148
|
+
export async function pullXaiModels(execute: XaiExecute): Promise<SyncResource[]> {
|
|
149
|
+
const out: SyncResource[] = [];
|
|
150
|
+
const flat = await execute('GET', '/v1/models');
|
|
151
|
+
throwIfError(flat, 'pull /v1/models');
|
|
152
|
+
for (const item of Array.isArray(flat.data) ? flat.data : []) out.push(mapModel(item));
|
|
153
|
+
const rich = await execute('GET', '/v1/language-models');
|
|
154
|
+
throwIfError(rich, 'pull /v1/language-models');
|
|
155
|
+
for (const item of Array.isArray(rich.models) ? rich.models : []) out.push(mapLanguageModel(item));
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Pull from real xAI and fold into the twin (mirror seeding). syncPull's shadow-diff makes a
|
|
161
|
+
* re-pull of identical state a no-op.
|
|
162
|
+
*/
|
|
163
|
+
export async function syncXaiFromReal(
|
|
164
|
+
execute: XaiExecute,
|
|
165
|
+
opts: { root?: string; occurredAt: string },
|
|
166
|
+
): Promise<{ observed: number; deltasAppended: number }> {
|
|
167
|
+
const resources = await pullXaiModels(execute);
|
|
168
|
+
// protocol 2: the observation lands on the head through the kernel's fold — one batch, one instant
|
|
169
|
+
const result = observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), { ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}` });
|
|
170
|
+
return { observed: result.observed, deltasAppended: result.appended };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── PUSH (refuses loudly — the vendor has no writable surface) ──────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Push ONE local action to real xAI. The xAI API exposes NO client-writable resources, so
|
|
177
|
+
* EVERY operation refuses loudly BEFORE any client dispatch — pushing a twin-local row (e.g.
|
|
178
|
+
* a deferred-completion lifecycle write) to the vendor would fabricate a write that cannot
|
|
179
|
+
* exist. Silent dropping is exactly the failure mode this refusal prevents.
|
|
180
|
+
*/
|
|
181
|
+
export async function pushXaiAction(
|
|
182
|
+
_execute: XaiExecute,
|
|
183
|
+
action: Pick<TwinAction, 'operation' | 'subject'>,
|
|
184
|
+
): Promise<never> {
|
|
185
|
+
const op = action.operation ?? `${action.subject.type}.update`;
|
|
186
|
+
throw new Error(`xai push: unsupported operation '${op}' — the xAI API has no client-writable resources; refusing to silently drop a local write`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
190
|
+
/** The pack's executor over the kernel's. */
|
|
191
|
+
export function xaiExecuteOver(execute: RemoteExecute): XaiExecute {
|
|
192
|
+
// the pack's executor is READ-ONLY by type (GET), which is the same fact `perform` states: xAI has no
|
|
193
|
+
// client-writable resource, so there is nothing for this boundary to send
|
|
194
|
+
return async (method: 'GET', path: string) => {
|
|
195
|
+
const res = await execute({ method, path, headers: { accept: 'application/json', authorization: 'Bearer twin' } });
|
|
196
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`xai GET ${path} refused: HTTP ${res.status} ${res.body.slice(0, 200)}`);
|
|
197
|
+
return JSON.parse(res.body || '{}') as Awaited<ReturnType<XaiExecute>>;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** The refresh adapter: pull the account's model catalog and keys through the executor. */
|
|
201
|
+
export async function syncXaiFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<{ observed: number; deltasAppended: number }> {
|
|
202
|
+
return syncXaiFromReal(xaiExecuteOver(execute), {
|
|
203
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
204
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** The perform adapter — and the honest answer is that nothing can cross: the xAI API has NO
|
|
208
|
+
* client-writable resource. A completion is a call already answered, not a row the vendor keeps for you.
|
|
209
|
+
* Each entry settles with that reason rather than being marked across or silently dropped. */
|
|
210
|
+
export async function performXaiAction(_execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
211
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type}: the xAI API has no client-writable resources — nothing to perform` } };
|
|
212
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// GENERATED by scripts/text-modules.ts — do not hand-edit; text-modules.test.ts fails on drift.
|
|
2
|
+
// A VERBATIM copy of packages/twin/xai/client/xai-device-auth.css as committed text, so the serve path imports it instead of
|
|
3
|
+
// reading it off disk (runtime contract R12b workerd-clean: no node:fs, no import.meta.dir on a
|
|
4
|
+
// fetch closure). The .css file remains the source of truth — edit THAT, then run
|
|
5
|
+
// `bun scripts/text-modules.ts` and commit this module alongside it.
|
|
6
|
+
export const XAI_DEVICE_AUTH_CSS = ":root {\n color-scheme: light;\n --xai-bg: #ffffff;\n --xai-text: #0a0a0a;\n --xai-muted: #777777;\n --xai-faint: #fafafa;\n --xai-border: #dedede;\n --xai-error: #b42318;\n}\n\n* { box-sizing: border-box; }\n\nhtml, body, #root { min-height: 100%; }\n\nbody {\n margin: 0;\n background: var(--xai-bg);\n color: var(--xai-text);\n font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n -webkit-font-smoothing: antialiased;\n}\n\nbutton, input { font: inherit; }\n\n.account-page { min-height: 100vh; }\n\n.account-header {\n position: fixed;\n inset: 0 0 auto;\n z-index: 2;\n display: flex;\n align-items: center;\n justify-content: space-between;\n height: 88px;\n padding: 20px 24px;\n pointer-events: none;\n}\n\n.account-header > * { pointer-events: auto; }\n\n.account-home {\n display: inline-flex;\n color: var(--xai-text);\n text-decoration: none;\n padding: 8px 0;\n}\n\n.xai-mark {\n display: block;\n width: 52px;\n height: auto;\n}\n\n.sign-out {\n display: inline-flex;\n align-items: center;\n text-decoration: none;\n min-height: 40px;\n padding: 0 18px;\n border: 1px solid var(--xai-border);\n border-radius: 999px;\n background: var(--xai-bg);\n color: var(--xai-text);\n cursor: pointer;\n font-size: 14px;\n transition: background .15s ease, border-color .15s ease;\n}\n\n.sign-out:hover { background: #f6f6f6; border-color: #c8c8c8; }\n\n.auth-shell {\n width: min(512px, calc(100% - 40px));\n margin: 0 auto;\n padding: clamp(106px, 11vh, 112px) 0 72px;\n}\n\n.auth-shell-compact {\n width: min(448px, calc(100% - 40px));\n padding-top: clamp(240px, 35.7vh, 351px);\n}\n\nh1 {\n margin: 0 0 32px;\n font-size: 16px;\n line-height: 1.3;\n font-weight: 650;\n letter-spacing: -0.015em;\n}\n\n.auth-shell-compact h1 {\n margin-bottom: 16px;\n font-size: 26px;\n font-weight: 450;\n letter-spacing: -0.035em;\n}\n\n.lead {\n margin: 0 0 28px;\n color: var(--xai-muted);\n font-size: 17px;\n line-height: 1.5;\n}\n\n.entry-form input {\n width: 100%;\n height: 42px;\n border: 1px solid #252525;\n border-radius: 0;\n background: var(--xai-bg);\n color: var(--xai-text);\n padding: 0 16px;\n text-align: center;\n text-transform: uppercase;\n letter-spacing: .08em;\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n font-size: 18px;\n outline: none;\n}\n\n.entry-form input:focus { box-shadow: 0 0 0 2px #bbbbbb; }\n.device-help, .form-error {\n margin: 14px 0 20px;\n color: var(--xai-muted);\n font-size: 14px;\n line-height: 1.45;\n}\n\n.form-error { margin-bottom: -7px; color: var(--xai-error); }\n\n.primary-action, .secondary-action {\n min-height: 40px;\n border-radius: 999px;\n padding: 0 24px;\n cursor: pointer;\n font-size: 16px;\n transition: transform .12s ease, background .15s ease;\n}\n\n.primary-action {\n border: 1px solid #050505;\n background: #050505;\n color: #ffffff;\n}\n\n.primary-action:hover { background: #252525; }\n.primary-action:disabled,\n.primary-action:disabled:hover {\n background: #4b4b4b;\n border-color: #4b4b4b;\n cursor: default;\n}\n.primary-action:active, .secondary-action:active { transform: scale(.99); }\n.entry-form .primary-action { width: 100%; min-height: 50px; }\n\n.permission-list {\n display: grid;\n gap: 4px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.permission-list li {\n display: flex;\n align-items: center;\n min-height: 44px;\n padding: 0 16px;\n border-radius: 13px;\n background: var(--xai-faint);\n font-size: 15px;\n font-weight: 500;\n}\n\n.permission-icon {\n flex: 0 0 auto;\n width: 17px;\n height: 17px;\n margin-right: 16px;\n color: #888888;\n}\n\n.decision-form {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 12px;\n margin-top: 32px;\n}\n\n.secondary-action {\n border: 1px solid var(--xai-border);\n background: var(--xai-bg);\n color: var(--xai-text);\n}\n\n.secondary-action:hover { background: #f7f7f7; }\n\n.result-icon {\n display: grid;\n place-items: center;\n width: 48px;\n height: 48px;\n margin-bottom: 24px;\n border-radius: 50%;\n background: #f1f1f1;\n font-size: 28px;\n font-weight: 350;\n}\n\n.result-icon-approved { background: #050505; color: #ffffff; }\n.result-copy { margin-bottom: 6px; }\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\nbutton:focus-visible, a:focus-visible, input:focus-visible {\n outline: 2px solid #666666;\n outline-offset: 3px;\n}\n\n@media (max-width: 640px) {\n .account-header { height: 76px; padding: 16px 20px; }\n .sign-out { min-height: 42px; padding: 0 17px; font-size: 15px; }\n .auth-shell, .auth-shell-compact {\n width: min(100% - 32px, 532px);\n padding-top: 126px;\n }\n .auth-shell h1, .auth-shell-compact h1 { font-size: 27px; margin-bottom: 18px; }\n .lead { font-size: 16px; }\n .permission-list li { min-height: 54px; padding: 0 14px; font-size: 15px; }\n .permission-icon { margin-right: 14px; }\n .decision-form { grid-template-columns: 1fr; margin-top: 28px; }\n .decision-form .primary-action { grid-row: 1; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n * { transition: none !important; }\n}\n";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Server half of xAI's Grok Build device-authorization UI. This is protocol UI, not an
|
|
2
|
+
// invented dashboard: it is served on the OAuth paths and coupled directly to the pending
|
|
3
|
+
// device authorization projected from the Twin kernel.
|
|
4
|
+
import { createElement } from 'react';
|
|
5
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
6
|
+
import {
|
|
7
|
+
DeviceConsentPage,
|
|
8
|
+
DeviceDonePage,
|
|
9
|
+
DeviceEntryPage,
|
|
10
|
+
type XaiDeviceAuthView,
|
|
11
|
+
XAI_DEVICE_PERMISSIONS,
|
|
12
|
+
} from '../client/xai-device-auth.tsx';
|
|
13
|
+
import { inspectXaiTwinDeviceAuthorization } from './xai-oauth.ts';
|
|
14
|
+
import { XAI_DEVICE_AUTH_CSS } from './xai-device-auth-css.gen.ts';
|
|
15
|
+
|
|
16
|
+
export type { XaiDeviceAuthView } from '../client/xai-device-auth.tsx';
|
|
17
|
+
export { XAI_DEVICE_PERMISSIONS } from '../client/xai-device-auth.tsx';
|
|
18
|
+
|
|
19
|
+
export const XAI_DEVICE_STYLE_PATH = '/_twin/assets/xai-device-auth.css';
|
|
20
|
+
export const XAI_DEVICE_SCRIPT_PATH = '/_twin/assets/xai-device-auth.js';
|
|
21
|
+
export const XAI_DEVICE_FAVICON_PATH = '/_twin/assets/xai-favicon.svg';
|
|
22
|
+
// The stylesheet is COMMITTED TEXT (scripts/text-modules.ts, the `xai` entry), beside the
|
|
23
|
+
// favicon and script constants below: the serve path answers this route from a constant and
|
|
24
|
+
// never opens a file (R12b workerd-clean). `client/xai-device-auth.css` stays the source of
|
|
25
|
+
// truth — edit THAT, run `bun scripts/text-modules.ts`, and text-modules.test.ts fails on drift.
|
|
26
|
+
|
|
27
|
+
const SYMBOL_PATHS = `<path d="M832.565 0.590983C736.633 8.41256 359.563 55.1598 98.1057 317.999H1.08203L11.9228 307.195C66.6371 254.336 308.741 30.3127 832.565 0.299805V0.590983Z"/><path d="M504.192 317.999H428.146L278.315 208.933C292.122 200.25 306.018 191.98 319.957 184.104L504.192 317.999Z"/><path d="M382.659 318H306.648L283.589 301.228H154.852C163.915 293.18 173.091 285.368 182.362 277.784H251.322L215.636 251.826C228.392 242.335 241.288 233.244 254.287 224.535L382.659 318Z"/><path d="M105.997 116.89L164.612 159.488C150.024 168.078 136.37 176.58 123.626 184.897L30.0522 116.854L105.997 116.89Z"/>`;
|
|
28
|
+
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 834 318" fill="#000">${SYMBOL_PATHS}</svg>`;
|
|
29
|
+
const DEVICE_SCRIPT = `(()=>{const i=document.getElementById('device-code');const b=document.querySelector('.entry-form button[type="submit"]');if(!(i instanceof HTMLInputElement)||!(b instanceof HTMLButtonElement))return;const sync=()=>{i.value=i.value.toUpperCase();b.disabled=!/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(i.value.trim())};i.addEventListener('input',sync);sync()})();`;
|
|
30
|
+
|
|
31
|
+
function escapeHtml(value: string): string {
|
|
32
|
+
return value.replace(/&/gu, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `base` is where the twin is reached (`twinPublicBase`: origin plus any served-World mount
|
|
36
|
+
* path), so the page's assets resolve inside the World's mount; empty for an in-process render. */
|
|
37
|
+
function page(title: string, markup: string, base: string): string {
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
40
|
+
<meta name="color-scheme" content="light"><title>${escapeHtml(title)} | SpaceXAI Accounts</title>
|
|
41
|
+
<link rel="icon" href="${base}${XAI_DEVICE_FAVICON_PATH}" type="image/svg+xml">
|
|
42
|
+
<link rel="stylesheet" href="${base}${XAI_DEVICE_STYLE_PATH}"><script src="${base}${XAI_DEVICE_SCRIPT_PATH}" defer></script></head><body><div id="root">${markup}</div></body></html>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function xaiDeviceAuthView(userCode: string, root?: string, error?: string, nowAt?: string): XaiDeviceAuthView {
|
|
46
|
+
const normalized = userCode.trim().toUpperCase().replace(/[^A-Z0-9-]/gu, '');
|
|
47
|
+
const state = inspectXaiTwinDeviceAuthorization(normalized, root);
|
|
48
|
+
let status = typeof state?.status === 'string' ? state.status : 'unknown';
|
|
49
|
+
if (state?.expiresAt && Date.parse(String(state.expiresAt)) <= Date.parse(nowAt ?? new Date().toISOString()) && status === 'pending') status = 'expired';
|
|
50
|
+
const supported = new Set<XaiDeviceAuthView['status']>(['pending', 'approved', 'denied', 'connected', 'expired', 'unknown']);
|
|
51
|
+
const scopes = new Set(String(state?.scope ?? '').split(/\s+/u).filter(Boolean));
|
|
52
|
+
const permissions = XAI_DEVICE_PERMISSIONS.filter((_, index) => [
|
|
53
|
+
scopes.has('openid'),
|
|
54
|
+
scopes.has('profile'),
|
|
55
|
+
scopes.has('email'),
|
|
56
|
+
scopes.has('offline_access'),
|
|
57
|
+
scopes.has('api:access') || scopes.has('grok-cli:access'),
|
|
58
|
+
scopes.has('conversations:read') || scopes.has('conversations:write') || scopes.has('workspaces:read') || scopes.has('workspaces:write'),
|
|
59
|
+
][index]);
|
|
60
|
+
return {
|
|
61
|
+
userCode: normalized,
|
|
62
|
+
status: supported.has(status as XaiDeviceAuthView['status']) ? status as XaiDeviceAuthView['status'] : 'unknown',
|
|
63
|
+
permissions,
|
|
64
|
+
...(error ? { error } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function deviceEntryPageHtml(view: XaiDeviceAuthView, base = ''): string {
|
|
69
|
+
return page('Device Sign-in', renderToStaticMarkup(createElement(DeviceEntryPage, { view, base })), base);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function deviceConsentPageHtml(view: XaiDeviceAuthView, base = ''): string {
|
|
73
|
+
return page('Authorize Grok Build', renderToStaticMarkup(createElement(DeviceConsentPage, { view, base })), base);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function deviceDonePageHtml(approved: boolean, base = ''): string {
|
|
77
|
+
return page(approved ? 'Device Authorized' : 'Authorization Denied', renderToStaticMarkup(createElement(DeviceDonePage, { approved, base })), base);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function xaiDeviceStylesheet(): string {
|
|
81
|
+
return XAI_DEVICE_AUTH_CSS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function xaiDeviceFavicon(): string {
|
|
85
|
+
return FAVICON_SVG;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function xaiDeviceScript(): string {
|
|
89
|
+
return DEVICE_SCRIPT;
|
|
90
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Grok Build device-auth UI journey. The browser is the protocol participant: it enters the
|
|
2
|
+
// issued code, reviews the projected permission surface, and approves or denies. The approved
|
|
3
|
+
// journey then redeems and USES the bearer, closing the same virtual-account loop the CLI uses.
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import { atPath, requireBrowser, runUiJourney, visible, JOURNEY_TIMEOUT_MS } from '@volter/world-tooling';
|
|
6
|
+
import { createXaiTwinServer } from './xai-server.ts';
|
|
7
|
+
|
|
8
|
+
const JOURNEY_LABEL = 'xAI Grok Build device authorization UI journey';
|
|
9
|
+
type DeviceReply = { device_code: string; user_code: string; verification_uri_complete: string };
|
|
10
|
+
|
|
11
|
+
async function startDevice(origin: string): Promise<DeviceReply> {
|
|
12
|
+
const response = await fetch(`${origin}/oauth2/device/code`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
15
|
+
body: new URLSearchParams({
|
|
16
|
+
client_id: 'grok-build',
|
|
17
|
+
scope: 'openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write',
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
expect(response.status).toBe(200);
|
|
21
|
+
return response.json() as Promise<DeviceReply>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('xAI Grok Build device authorization UI journey', () => {
|
|
25
|
+
test('code entry → consent → Allow → token exchange → Grok CLI request', async () => {
|
|
26
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
27
|
+
let origin = '';
|
|
28
|
+
let device: DeviceReply | null = null;
|
|
29
|
+
|
|
30
|
+
await runUiJourney({
|
|
31
|
+
label: 'xai device authorization approved round trip',
|
|
32
|
+
seed: async () => {},
|
|
33
|
+
serve: async (root) => {
|
|
34
|
+
const server = await createXaiTwinServer({ root, port: 0 });
|
|
35
|
+
origin = `http://127.0.0.1:${server.port}`;
|
|
36
|
+
device = await startDevice(origin);
|
|
37
|
+
return { url: device.verification_uri_complete, stop: () => server.stop() };
|
|
38
|
+
},
|
|
39
|
+
journey: async (page) => {
|
|
40
|
+
const issued = device!;
|
|
41
|
+
await visible(page, 'Sign in to Grok Build');
|
|
42
|
+
expect(await page.getByRole('textbox', { name: 'Enter device code' }).inputValue()).toBe(issued.user_code);
|
|
43
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
44
|
+
await atPath(page, '/oauth2/device/consent');
|
|
45
|
+
await visible(page, 'Authorize Grok Build');
|
|
46
|
+
for (const permission of [
|
|
47
|
+
'Verify your identity',
|
|
48
|
+
'Read your profile (name, avatar)',
|
|
49
|
+
'Read your email address',
|
|
50
|
+
"Maintain access when you're not present",
|
|
51
|
+
'Use the xAI API',
|
|
52
|
+
'Read and write access Grok.com',
|
|
53
|
+
]) await visible(page, permission);
|
|
54
|
+
await page.getByRole('button', { name: 'Allow' }).click();
|
|
55
|
+
await atPath(page, '/oauth2/device/done');
|
|
56
|
+
await visible(page, 'Device Authorized');
|
|
57
|
+
|
|
58
|
+
const exchange = await fetch(`${origin}/oauth2/token`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
61
|
+
body: new URLSearchParams({ client_id: 'grok-build', device_code: issued.device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
|
|
62
|
+
});
|
|
63
|
+
expect(exchange.status).toBe(200);
|
|
64
|
+
const credential = await exchange.json() as { access_token: string };
|
|
65
|
+
const use = await fetch(`${origin}/v1/chat/completions`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
authorization: `Bearer ${credential.access_token}`,
|
|
69
|
+
'content-type': 'application/json',
|
|
70
|
+
'x-grok-model-override': 'grok-build',
|
|
71
|
+
'x-xai-token-auth': 'xai-grok-cli',
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ model: 'grok-build', messages: [{ role: 'user', content: 'journey ping' }] }),
|
|
74
|
+
});
|
|
75
|
+
expect(use.status).toBe(200);
|
|
76
|
+
expect((await use.json() as { model: string }).model).toBe('grok-code-fast-1');
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}, JOURNEY_TIMEOUT_MS);
|
|
80
|
+
|
|
81
|
+
test('Deny leaves token exchange at access_denied', async () => {
|
|
82
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
83
|
+
let origin = '';
|
|
84
|
+
let device: DeviceReply | null = null;
|
|
85
|
+
|
|
86
|
+
await runUiJourney({
|
|
87
|
+
label: 'xai device authorization denied round trip',
|
|
88
|
+
seed: async () => {},
|
|
89
|
+
serve: async (root) => {
|
|
90
|
+
const server = await createXaiTwinServer({ root, port: 0 });
|
|
91
|
+
origin = `http://127.0.0.1:${server.port}`;
|
|
92
|
+
device = await startDevice(origin);
|
|
93
|
+
return { url: device.verification_uri_complete, stop: () => server.stop() };
|
|
94
|
+
},
|
|
95
|
+
journey: async (page) => {
|
|
96
|
+
const issued = device!;
|
|
97
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
98
|
+
await atPath(page, '/oauth2/device/consent');
|
|
99
|
+
await page.getByRole('button', { name: 'Deny' }).click();
|
|
100
|
+
await atPath(page, '/oauth2/device/done');
|
|
101
|
+
await visible(page, 'Authorization Denied');
|
|
102
|
+
const exchange = await fetch(`${origin}/oauth2/token`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
105
|
+
body: new URLSearchParams({ client_id: 'grok-build', device_code: issued.device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
|
|
106
|
+
});
|
|
107
|
+
expect(exchange.status).toBe(400);
|
|
108
|
+
expect((await exchange.json() as { error: string }).error).toBe('access_denied');
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}, JOURNEY_TIMEOUT_MS);
|
|
112
|
+
|
|
113
|
+
test('blank entry → invalid PRG → consent sign-out preserves the pending grant', async () => {
|
|
114
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
115
|
+
let origin = '';
|
|
116
|
+
let device: DeviceReply | null = null;
|
|
117
|
+
|
|
118
|
+
await runUiJourney({
|
|
119
|
+
label: 'xai device entry error and account sign out',
|
|
120
|
+
seed: async () => {},
|
|
121
|
+
serve: async (root) => {
|
|
122
|
+
const server = await createXaiTwinServer({ root, port: 0 });
|
|
123
|
+
origin = `http://127.0.0.1:${server.port}`;
|
|
124
|
+
device = await startDevice(origin);
|
|
125
|
+
return { url: `${origin}/oauth2/device`, stop: () => server.stop() };
|
|
126
|
+
},
|
|
127
|
+
journey: async (page) => {
|
|
128
|
+
const issued = device!;
|
|
129
|
+
await visible(page, 'Sign in to Grok Build');
|
|
130
|
+
const entry = page.getByRole('textbox', { name: 'Enter device code' });
|
|
131
|
+
const proceed = page.getByRole('button', { name: 'Continue' });
|
|
132
|
+
expect(await proceed.isDisabled()).toBe(true);
|
|
133
|
+
await entry.fill('NOPE-0000');
|
|
134
|
+
expect(await proceed.isEnabled()).toBe(true);
|
|
135
|
+
await proceed.click();
|
|
136
|
+
await atPath(page, '/oauth2/device');
|
|
137
|
+
await visible(page, 'Invalid or expired code. Please try again.');
|
|
138
|
+
expect(await entry.inputValue()).toBe('');
|
|
139
|
+
expect(await proceed.isDisabled()).toBe(true);
|
|
140
|
+
|
|
141
|
+
await page.goto(issued.verification_uri_complete);
|
|
142
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
143
|
+
await atPath(page, '/oauth2/device/consent');
|
|
144
|
+
await page.getByRole('link', { name: 'Sign out' }).click();
|
|
145
|
+
await atPath(page, '/oauth2/device');
|
|
146
|
+
const pending = await fetch(`${origin}/oauth2/token`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
149
|
+
body: new URLSearchParams({ client_id: 'grok-build', device_code: issued.device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
|
|
150
|
+
});
|
|
151
|
+
expect((await pending.json() as { error: string }).error).toBe('authorization_pending');
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}, JOURNEY_TIMEOUT_MS);
|
|
155
|
+
});
|