@benjamolina/pi-antigravity-guard 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Pi Antigravity Guard
2
+
3
+ `@benjamolina/pi-antigravity-guard` is a Pi extension that registers the text-only `antigravity-guard` provider. It exposes exactly one selectable model, `antigravity-gemini-3.8-flash`, which is sent to Antigravity as `gemini-3.8-flash`.
4
+
5
+ ## Use
6
+
7
+ Install the package in Pi, then run:
8
+
9
+ ```text
10
+ /login antigravity-guard
11
+ ```
12
+
13
+ Choose browser login, or choose manual login and paste the complete callback URL. The loopback callback uses fixed port `51121`; browser callback failures, remote shells, and port conflicts can use the manual callback-URL flow.
14
+
15
+ Start a fresh text-only session with `--no-builtin-tools` and disable any active extension-provided tools. That flag does not disable extension tools by itself. Tools, tool history, images, and thinking content are unsupported and rejected rather than silently changed.
16
+
17
+ ## Limits and safety
18
+
19
+ - This provider stores credentials through Pi's OAuth lifecycle and never reads or changes OpenCode account files; each is independent.
20
+ - Costs are reported as zero because subscription usage is unpriced, not because access is free.
21
+ - The documented public-to-wire model mapping is not proof that the model is live, available, or entitled. No authorized live OAuth or generation check has been run.
22
+ - It has no account rotation, quota fallback, model substitution, or tool support.
23
+
24
+ To remove it, disable or uninstall `@benjamolina/pi-antigravity-guard` and reload Pi. Removal does not delete Pi-managed credentials or revoke tokens.
@@ -0,0 +1,17 @@
1
+ import type { AuthHttpDependencies, PiCredentials } from "./types.ts";
2
+ type AuthErrorKind = "aborted" | "authentication" | "response" | "transport";
3
+ export declare class AuthHttpError extends Error {
4
+ readonly kind: AuthErrorKind;
5
+ readonly status?: number;
6
+ constructor(kind: AuthErrorKind, message: string, status?: number);
7
+ }
8
+ export interface ExchangeAuthorizationCodeOptions extends AuthHttpDependencies {
9
+ code: string;
10
+ verifier: string;
11
+ }
12
+ export interface RefreshCredentialsOptions extends AuthHttpDependencies {
13
+ credentials: PiCredentials;
14
+ }
15
+ export declare function exchangeAuthorizationCode(options: ExchangeAuthorizationCodeOptions): Promise<PiCredentials>;
16
+ export declare function refreshCredentials(options: RefreshCredentialsOptions): Promise<PiCredentials>;
17
+ export {};
@@ -0,0 +1,143 @@
1
+ import { ANTIGRAVITY_OAUTH_CLIENT, buildCodeExchangeForm, buildRefreshForm, calculateTokenExpiry, } from "@benjamolina/antigravity-guard-core";
2
+ const MAX_BODY_BYTES = 64 * 1024;
3
+ const REQUEST_TIMEOUT_MS = 10_000;
4
+ export class AuthHttpError extends Error {
5
+ kind;
6
+ status;
7
+ constructor(kind, message, status) {
8
+ super(message);
9
+ this.kind = kind;
10
+ this.status = status;
11
+ }
12
+ }
13
+ export async function exchangeAuthorizationCode(options) {
14
+ return requestTokens(buildCodeExchangeForm(ANTIGRAVITY_OAUTH_CLIENT, options), options, undefined);
15
+ }
16
+ export async function refreshCredentials(options) {
17
+ return requestTokens(buildRefreshForm(ANTIGRAVITY_OAUTH_CLIENT, options.credentials.refresh), options, options.credentials.refresh);
18
+ }
19
+ async function requestTokens(form, options, priorRefresh) {
20
+ const signal = requestSignal(options);
21
+ const requestTime = options.now();
22
+ let response;
23
+ try {
24
+ response = await abortable(options.fetch(ANTIGRAVITY_OAUTH_CLIENT.tokenEndpoint, {
25
+ method: "POST",
26
+ redirect: "error",
27
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
28
+ body: form.toString(),
29
+ signal,
30
+ }), signal);
31
+ }
32
+ catch {
33
+ if (signal.aborted)
34
+ throw safeAbortError(signal);
35
+ throw new AuthHttpError("transport", "Authentication request failed.");
36
+ }
37
+ const body = await readBoundedBody(response, signal);
38
+ if (!response.ok)
39
+ throw responseError(response.status, tryParseJson(body));
40
+ return credentialsFrom(parseJson(body), requestTime, priorRefresh);
41
+ }
42
+ function requestSignal(options) {
43
+ const remaining = options.deadlineMs === undefined
44
+ ? REQUEST_TIMEOUT_MS
45
+ : Math.min(REQUEST_TIMEOUT_MS, options.deadlineMs - options.now());
46
+ if (remaining <= 0 || options.signal?.aborted)
47
+ throw safeAbortError(options.signal);
48
+ return options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(remaining)]) : AbortSignal.timeout(remaining);
49
+ }
50
+ async function readBoundedBody(response, signal) {
51
+ if (!response.body)
52
+ return "";
53
+ const reader = response.body.getReader();
54
+ const chunks = [];
55
+ let size = 0;
56
+ try {
57
+ while (true) {
58
+ const next = await abortable(reader.read(), signal);
59
+ if (next.done)
60
+ break;
61
+ size += next.value.byteLength;
62
+ if (size > MAX_BODY_BYTES)
63
+ throw new AuthHttpError("response", "Authentication response was too large.");
64
+ chunks.push(next.value);
65
+ }
66
+ }
67
+ catch (error) {
68
+ void reader.cancel().catch(() => undefined);
69
+ if (error instanceof AuthHttpError)
70
+ throw error;
71
+ if (signal.aborted)
72
+ throw safeAbortError(signal);
73
+ throw new AuthHttpError("transport", "Authentication request failed.");
74
+ }
75
+ finally {
76
+ reader.releaseLock();
77
+ }
78
+ return new TextDecoder().decode(concat(chunks, size));
79
+ }
80
+ function abortable(promise, signal) {
81
+ if (signal.aborted)
82
+ return Promise.reject(safeAbortError(signal));
83
+ return new Promise((resolve, reject) => {
84
+ const abort = () => reject(safeAbortError(signal));
85
+ signal.addEventListener("abort", abort, { once: true });
86
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
87
+ });
88
+ }
89
+ function concat(chunks, size) {
90
+ const output = new Uint8Array(size);
91
+ let offset = 0;
92
+ for (const chunk of chunks) {
93
+ output.set(chunk, offset);
94
+ offset += chunk.byteLength;
95
+ }
96
+ return output;
97
+ }
98
+ function parseJson(body) {
99
+ const value = tryParseJson(body);
100
+ if (value === undefined)
101
+ throw new AuthHttpError("response", "Authentication service returned an invalid response.");
102
+ return value;
103
+ }
104
+ function tryParseJson(body) {
105
+ try {
106
+ return JSON.parse(body);
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ }
112
+ function credentialsFrom(value, requestTime, priorRefresh) {
113
+ if (!isRecord(value) || !nonempty(value.access_token) || !finitePositive(value.expires_in)) {
114
+ throw new AuthHttpError("response", "Authentication service returned invalid credentials.");
115
+ }
116
+ if (Object.hasOwn(value, "refresh_token") && !nonempty(value.refresh_token)) {
117
+ throw new AuthHttpError("response", "Authentication service returned invalid credentials.");
118
+ }
119
+ const refresh = nonempty(value.refresh_token) ? value.refresh_token : priorRefresh;
120
+ const expires = calculateTokenExpiry(requestTime, value.expires_in);
121
+ if (!refresh || !Number.isFinite(expires)) {
122
+ throw new AuthHttpError("response", "Authentication service returned invalid credentials.");
123
+ }
124
+ return { access: value.access_token, refresh, expires };
125
+ }
126
+ function responseError(status, value) {
127
+ if (isRecord(value) && (value.error === "invalid_grant" || (isRecord(value.error) && value.error.error === "invalid_grant"))) {
128
+ return new AuthHttpError("authentication", "Authentication expired. Run /login antigravity-guard.", status);
129
+ }
130
+ return new AuthHttpError("authentication", status >= 500 ? "Authentication service is unavailable." : "Authentication request was rejected.", status);
131
+ }
132
+ function safeAbortError(signal) {
133
+ return new AuthHttpError("aborted", signal?.aborted ? "Authentication request was cancelled." : "Authentication request failed.");
134
+ }
135
+ function isRecord(value) {
136
+ return typeof value === "object" && value !== null;
137
+ }
138
+ function nonempty(value) {
139
+ return typeof value === "string" && value.trim().length > 0;
140
+ }
141
+ function finitePositive(value) {
142
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
143
+ }
@@ -0,0 +1,41 @@
1
+ import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+ declare const WIRE_MODEL = "gemini-3.8-flash-tiered";
3
+ interface Part {
4
+ text: string;
5
+ }
6
+ interface Content {
7
+ role: "user" | "model";
8
+ parts: Part[];
9
+ }
10
+ export interface GenerationRequest {
11
+ project: string;
12
+ model: typeof WIRE_MODEL;
13
+ request: {
14
+ contents: Content[];
15
+ systemInstruction?: {
16
+ parts: Part[];
17
+ };
18
+ generationConfig: {
19
+ temperature: number;
20
+ maxOutputTokens: number;
21
+ thinkingConfig: {
22
+ thinkingLevel: "low";
23
+ includeThoughts: false;
24
+ };
25
+ };
26
+ };
27
+ requestType: "agent";
28
+ userAgent: "antigravity";
29
+ requestId: string;
30
+ }
31
+ export interface SerializeTextContextInput {
32
+ context: Context;
33
+ model: Model<string>;
34
+ options?: SimpleStreamOptions;
35
+ project: string;
36
+ requestId: string;
37
+ }
38
+ export declare class ContextSerializationError extends Error {
39
+ }
40
+ export declare function serializeTextContext(input: SerializeTextContextInput): GenerationRequest;
41
+ export {};
@@ -0,0 +1,128 @@
1
+ const MAX_TEXT_BYTES = 8 * 1024 * 1024;
2
+ const MAX_OUTPUT_TOKENS = 65_536;
3
+ const PUBLIC_MODEL = "antigravity-gemini-3.8-flash";
4
+ const WIRE_MODEL = "gemini-3.8-flash-tiered";
5
+ const RESERVED_HEADERS = new Set(["authorization", "host", "content-type", "content-length"]);
6
+ export class ContextSerializationError extends Error {
7
+ }
8
+ export function serializeTextContext(input) {
9
+ try {
10
+ if (!isRecord(input))
11
+ fail("Invalid text context.");
12
+ const context = field(input, "context", true);
13
+ const model = field(input, "model", true);
14
+ const options = field(input, "options");
15
+ const project = field(input, "project", true);
16
+ const requestId = field(input, "requestId", true);
17
+ if (!isRecord(context) || !isRecord(model) || field(model, "id", true) !== PUBLIC_MODEL || typeof project !== "string" || typeof requestId !== "string")
18
+ fail("Invalid text context.");
19
+ const systemPrompt = field(context, "systemPrompt");
20
+ const tools = field(context, "tools");
21
+ if (systemPrompt !== undefined && typeof systemPrompt !== "string")
22
+ fail("Text-only context is required.");
23
+ if (tools !== undefined) {
24
+ if (isDenseArray(tools).length)
25
+ fail("Tools are not supported by this text-only provider.");
26
+ }
27
+ validateOptions(options);
28
+ let textBytes = byteLength(systemPrompt ?? "");
29
+ const contents = [];
30
+ for (const message of isDenseArray(field(context, "messages", true))) {
31
+ if (!isRecord(message))
32
+ fail("Invalid text context.");
33
+ const role = field(message, "role", true);
34
+ if (role === "toolResult")
35
+ fail("Tool history is not supported by this text-only provider.");
36
+ if (role !== "user" && role !== "assistant")
37
+ fail("Unsupported context role for this text-only provider.");
38
+ const text = textParts(field(message, "content", true));
39
+ textBytes += text.reduce((total, part) => total + byteLength(part.text), 0);
40
+ if (textBytes > MAX_TEXT_BYTES)
41
+ fail("Text context is too large.");
42
+ contents.push({ role: role === "assistant" ? "model" : "user", parts: text });
43
+ }
44
+ if (!contents.length)
45
+ fail("A text conversation is required.");
46
+ const systemInstruction = systemPrompt === undefined ? undefined : { parts: [{ text: systemPrompt }] };
47
+ const temperature = option(options, "temperature");
48
+ const maxTokens = option(options, "maxTokens");
49
+ return { project, model: WIRE_MODEL, request: { contents, ...(systemInstruction ? { systemInstruction } : {}), generationConfig: {
50
+ temperature: typeof temperature === "number" ? temperature : 1,
51
+ maxOutputTokens: typeof maxTokens === "number" ? maxTokens : 4096,
52
+ thinkingConfig: { thinkingLevel: "low", includeThoughts: false },
53
+ } }, requestType: "agent", userAgent: "antigravity", requestId };
54
+ }
55
+ catch (error) {
56
+ if (error instanceof ContextSerializationError)
57
+ throw error;
58
+ fail("Invalid text context.");
59
+ }
60
+ }
61
+ function textParts(content) {
62
+ if (typeof content === "string")
63
+ return content ? [{ text: content }] : fail("A text conversation is required.");
64
+ const parts = isDenseArray(content);
65
+ if (!parts.length)
66
+ fail("A text conversation is required.");
67
+ return parts.map((part) => {
68
+ if (!isRecord(part) || field(part, "type", true) !== "text")
69
+ fail("Only text context is supported by this provider.");
70
+ const text = field(part, "text", true);
71
+ if (typeof text !== "string" || !text)
72
+ fail("Only text context is supported by this provider.");
73
+ return { text };
74
+ });
75
+ }
76
+ function validateOptions(options) {
77
+ if (options === undefined)
78
+ return;
79
+ if (!isRecord(options))
80
+ fail("Invalid generation options.");
81
+ const reasoning = option(options, "reasoning"), budgets = option(options, "thinkingBudgets"), deferred = option(options, "deferred"), toolChoice = option(options, "toolChoice"), sampling = option(options, "samplingParams"), temperature = option(options, "temperature"), maxTokens = option(options, "maxTokens"), headers = option(options, "headers");
82
+ if (reasoning !== undefined || budgets !== undefined || deferred)
83
+ fail("Reasoning and deferred requests are not supported.");
84
+ if (toolChoice !== undefined && toolChoice !== "none")
85
+ fail("Tools are not supported by this text-only provider.");
86
+ if (sampling !== undefined)
87
+ fail("Custom generation options are not supported.");
88
+ if (temperature !== undefined && (typeof temperature !== "number" || !Number.isFinite(temperature) || temperature < 0 || temperature > 2))
89
+ fail("Temperature must be finite and between 0 and 2.");
90
+ if (maxTokens !== undefined && (typeof maxTokens !== "number" || !Number.isInteger(maxTokens) || maxTokens <= 0 || maxTokens > MAX_OUTPUT_TOKENS))
91
+ fail("maxTokens must be a positive integer no greater than 65536.");
92
+ if (headers !== undefined && !isRecord(headers))
93
+ fail("Invalid generation options.");
94
+ for (const name of headers ? Object.keys(headers) : [])
95
+ if (RESERVED_HEADERS.has(name.toLowerCase()))
96
+ fail("Custom headers cannot replace protected request headers.");
97
+ }
98
+ function byteLength(value) {
99
+ return new TextEncoder().encode(value).byteLength;
100
+ }
101
+ function isRecord(value) {
102
+ if (typeof value !== "object" || value === null)
103
+ return false;
104
+ const prototype = Object.getPrototypeOf(value);
105
+ return prototype === Object.prototype || prototype === null;
106
+ }
107
+ function field(record, name, required = false) {
108
+ const descriptor = Object.getOwnPropertyDescriptor(record, name);
109
+ if (!descriptor)
110
+ return required ? fail("Invalid text context.") : undefined;
111
+ return "value" in descriptor ? descriptor.value : fail("Invalid text context.");
112
+ }
113
+ function option(options, name) {
114
+ return isRecord(options) ? field(options, name) : undefined;
115
+ }
116
+ function isDenseArray(value) {
117
+ if (!Array.isArray(value))
118
+ fail("Invalid text context.");
119
+ for (let index = 0; index < value.length; index++) {
120
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
121
+ if (!descriptor || !("value" in descriptor))
122
+ fail("Invalid text context.");
123
+ }
124
+ return value;
125
+ }
126
+ function fail(message) {
127
+ throw new ContextSerializationError(message);
128
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export default function extension(pi: Pick<ExtensionAPI, "registerProvider">): void;
@@ -0,0 +1,4 @@
1
+ import { registerAntigravityProvider } from "./provider.js";
2
+ export default function extension(pi) {
3
+ registerAntigravityProvider(pi);
4
+ }
@@ -0,0 +1,45 @@
1
+ export type LoopbackOutcome = {
2
+ kind: "callback";
3
+ code: string;
4
+ } | {
5
+ kind: "denied";
6
+ } | {
7
+ kind: "rejected";
8
+ reason: "invalid-callback";
9
+ } | {
10
+ kind: "manual";
11
+ reason: "manual" | "listener-failure" | "timeout";
12
+ } | {
13
+ kind: "cancelled";
14
+ };
15
+ export interface LoopbackRequest {
16
+ method?: string;
17
+ host?: string;
18
+ remoteAddress?: string;
19
+ url?: string;
20
+ }
21
+ export type CallbackValidation = {
22
+ kind: "callback";
23
+ code: string;
24
+ } | {
25
+ kind: "denied";
26
+ } | {
27
+ kind: "reject";
28
+ } | {
29
+ kind: "ignore";
30
+ status: 404 | 405;
31
+ };
32
+ export interface LoopbackReceiver {
33
+ ready: Promise<void>;
34
+ result: Promise<LoopbackOutcome>;
35
+ cancel(): void;
36
+ }
37
+ export interface LoopbackOptions {
38
+ state: string;
39
+ mode?: "loopback" | "manual";
40
+ signal?: AbortSignal;
41
+ loopbackWindowMs?: number;
42
+ totalDeadlineMs?: number;
43
+ }
44
+ export declare function validateLoopbackCallback(request: LoopbackRequest, state: string): CallbackValidation;
45
+ export declare function openLoopbackReceiver(options: LoopbackOptions): LoopbackReceiver;
@@ -0,0 +1,171 @@
1
+ import { createServer } from "node:http";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ const ADDRESS = "127.0.0.1";
4
+ const PORT = 51121;
5
+ const HOST = "localhost:51121";
6
+ const PATH = "/oauth-callback";
7
+ const MAX_URL_BYTES = 8 * 1024;
8
+ const LOOPBACK_WINDOW_MS = 30_000;
9
+ const TOTAL_DEADLINE_MS = 5 * 60_000;
10
+ const RESPONSE = "<!doctype html><title>Authorization complete</title><p>You may close this window.</p>";
11
+ function hasOne(values) {
12
+ return values.length === 1 && values[0].length > 0;
13
+ }
14
+ function isWellFormed(value) {
15
+ for (let index = 0; index < value.length; index++) {
16
+ const code = value.charCodeAt(index);
17
+ if (code >= 0xd800 && code <= 0xdbff) {
18
+ if (++index === value.length || value.charCodeAt(index) < 0xdc00 || value.charCodeAt(index) > 0xdfff)
19
+ return false;
20
+ }
21
+ else if (code >= 0xdc00 && code <= 0xdfff)
22
+ return false;
23
+ }
24
+ return true;
25
+ }
26
+ function stateMatches(expected, actual) {
27
+ if (!isWellFormed(expected) || !isWellFormed(actual))
28
+ return false;
29
+ const expectedBytes = Buffer.from(expected, "utf8");
30
+ const actualBytes = Buffer.from(actual, "utf8");
31
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
32
+ }
33
+ export function validateLoopbackCallback(request, state) {
34
+ try {
35
+ const target = request.url;
36
+ if (!target?.startsWith("/") || Buffer.byteLength(target, "utf8") > MAX_URL_BYTES)
37
+ return { kind: "reject" };
38
+ if (target.includes("#"))
39
+ return { kind: "reject" };
40
+ const queryAt = target.indexOf("?");
41
+ const rawPath = target.slice(0, queryAt === -1 ? target.length : queryAt);
42
+ if (rawPath !== PATH) {
43
+ if (rawPath.startsWith("//") || rawPath.includes("..") || rawPath.includes("%"))
44
+ return { kind: "reject" };
45
+ return { kind: "ignore", status: 404 };
46
+ }
47
+ const rawQuery = queryAt === -1 ? "" : target.slice(queryAt + 1);
48
+ if (/%(?![0-9a-f]{2})/i.test(rawQuery))
49
+ return { kind: "reject" };
50
+ try {
51
+ decodeURIComponent(rawQuery.replace(/\+/g, "%20"));
52
+ }
53
+ catch {
54
+ return { kind: "reject" };
55
+ }
56
+ if (request.method !== "GET")
57
+ return { kind: "ignore", status: 405 };
58
+ if (request.host !== HOST || request.remoteAddress !== ADDRESS)
59
+ return { kind: "reject" };
60
+ const parameters = new URLSearchParams(rawQuery);
61
+ const codes = parameters.getAll("code");
62
+ const states = parameters.getAll("state");
63
+ const errors = parameters.getAll("error");
64
+ if (!hasOne(states) || !stateMatches(state, states[0]))
65
+ return { kind: "reject" };
66
+ if (hasOne(codes) === hasOne(errors))
67
+ return { kind: "reject" };
68
+ return hasOne(codes) ? { kind: "callback", code: codes[0] } : { kind: "denied" };
69
+ }
70
+ catch {
71
+ return { kind: "reject" };
72
+ }
73
+ }
74
+ export function openLoopbackReceiver(options) {
75
+ let server;
76
+ let timer;
77
+ let settled = false;
78
+ let readyResolve;
79
+ let resolve;
80
+ const ready = new Promise((done) => { readyResolve = done; });
81
+ const result = new Promise((done) => { resolve = done; });
82
+ const sockets = new Set();
83
+ const cleanup = () => new Promise((done) => {
84
+ if (timer)
85
+ clearTimeout(timer);
86
+ options.signal?.removeEventListener("abort", cancel);
87
+ for (const socket of sockets)
88
+ socket.destroy();
89
+ sockets.clear();
90
+ if (!server)
91
+ return done();
92
+ server.close(() => done());
93
+ });
94
+ const claim = () => {
95
+ if (settled)
96
+ return false;
97
+ settled = true;
98
+ return true;
99
+ };
100
+ const settle = (outcome) => {
101
+ if (!claim())
102
+ return;
103
+ void cleanup().finally(() => resolve(outcome));
104
+ };
105
+ const cancel = () => settle({ kind: "cancelled" });
106
+ const fallback = (reason) => settle({ kind: "manual", reason });
107
+ if (options.signal?.aborted) {
108
+ readyResolve();
109
+ cancel();
110
+ }
111
+ else if (options.mode === "manual") {
112
+ readyResolve();
113
+ settle({ kind: "manual", reason: "manual" });
114
+ }
115
+ else if (options.totalDeadlineMs !== undefined && options.totalDeadlineMs <= 0) {
116
+ readyResolve();
117
+ fallback("timeout");
118
+ }
119
+ else {
120
+ server = createServer((request, response) => {
121
+ let validation;
122
+ try {
123
+ validation = validateLoopbackCallback({
124
+ method: request.method,
125
+ host: request.headers.host,
126
+ remoteAddress: request.socket.remoteAddress,
127
+ url: request.url,
128
+ }, options.state);
129
+ }
130
+ catch {
131
+ validation = { kind: "reject" };
132
+ }
133
+ if (validation.kind === "ignore") {
134
+ response.writeHead(validation.status).end();
135
+ return;
136
+ }
137
+ const outcome = validation.kind === "callback"
138
+ ? validation
139
+ : validation.kind === "denied"
140
+ ? validation
141
+ : { kind: "rejected", reason: "invalid-callback" };
142
+ if (!claim()) {
143
+ response.writeHead(409).end();
144
+ return;
145
+ }
146
+ response.writeHead(validation.kind === "callback" ? 200 : 400, {
147
+ "cache-control": "no-store",
148
+ "content-security-policy": "default-src 'none'; base-uri 'none'; frame-ancestors 'none'",
149
+ "content-type": "text/html; charset=utf-8",
150
+ }).end(RESPONSE, () => {
151
+ setImmediate(() => void cleanup().finally(() => resolve(outcome)));
152
+ });
153
+ });
154
+ server.on("connection", (socket) => {
155
+ sockets.add(socket);
156
+ socket.once("close", () => sockets.delete(socket));
157
+ });
158
+ server.once("error", () => {
159
+ readyResolve();
160
+ fallback("listener-failure");
161
+ });
162
+ server.listen(PORT, ADDRESS, () => {
163
+ readyResolve();
164
+ const total = Math.min(options.totalDeadlineMs ?? TOTAL_DEADLINE_MS, TOTAL_DEADLINE_MS);
165
+ const window = Math.min(options.loopbackWindowMs ?? LOOPBACK_WINDOW_MS, total);
166
+ timer = setTimeout(() => fallback("timeout"), Math.max(0, window));
167
+ });
168
+ options.signal?.addEventListener("abort", cancel, { once: true });
169
+ }
170
+ return { ready, result, cancel };
171
+ }
@@ -0,0 +1,21 @@
1
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai/oauth";
2
+ import { exchangeAuthorizationCode, refreshCredentials } from "./auth-http.ts";
3
+ import { openLoopbackReceiver } from "./loopback.ts";
4
+ import type { LoopbackReceiver } from "./loopback.ts";
5
+ import { resolveLoginProject } from "./project.ts";
6
+ import type { LoginProject } from "./project.ts";
7
+ export interface PiOAuthDependencies {
8
+ fetch: typeof globalThis.fetch;
9
+ now: () => number;
10
+ randomBytes?: (size: number) => Uint8Array;
11
+ exchange?: typeof exchangeAuthorizationCode;
12
+ refresh?: typeof refreshCredentials;
13
+ project?: (options: Parameters<typeof resolveLoginProject>[0]) => Promise<LoginProject>;
14
+ openLoopback?: (options: Parameters<typeof openLoopbackReceiver>[0]) => LoopbackReceiver;
15
+ }
16
+ export interface PiOAuthLifecycle {
17
+ login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
18
+ refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>;
19
+ getApiKey(credentials: OAuthCredentials): string;
20
+ }
21
+ export declare function createPiOAuthLifecycle(dependencies: PiOAuthDependencies): PiOAuthLifecycle;