@vincemakes/kiso-code 0.29.0 → 0.31.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/dist/auth/credentials.d.ts +39 -0
- package/dist/auth/credentials.js +170 -0
- package/dist/auth/oauth/chatgpt.d.ts +40 -0
- package/dist/auth/oauth/chatgpt.js +197 -0
- package/dist/auth/oauth/index.d.ts +32 -0
- package/dist/auth/oauth/index.js +1 -0
- package/dist/auth/oauth/pkce.d.ts +5 -0
- package/dist/auth/oauth/pkce.js +12 -0
- package/dist/auth/refresh.d.ts +12 -0
- package/dist/auth/refresh.js +40 -0
- package/dist/auth/token.d.ts +17 -0
- package/dist/auth/token.js +26 -0
- package/dist/chat.d.ts +1 -1
- package/dist/chat.js +12 -6
- package/dist/config.d.ts +47 -6
- package/dist/config.js +83 -12
- package/dist/dispatch.js +45 -8
- package/dist/index.js +130 -4
- package/dist/mode.d.ts +2 -2
- package/dist/mode.js +2 -2
- package/dist/state.d.ts +7 -2
- package/dist/state.js +5 -1
- package/dist/trust-ui.d.ts +13 -0
- package/dist/trust-ui.js +17 -2
- package/package.json +15 -14
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type Credential = {
|
|
2
|
+
readonly type: "api-key";
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly savedAt: number;
|
|
5
|
+
} | {
|
|
6
|
+
readonly type: "oauth";
|
|
7
|
+
readonly access: string;
|
|
8
|
+
readonly refresh: string;
|
|
9
|
+
/** epoch milliseconds */
|
|
10
|
+
readonly expires: number;
|
|
11
|
+
readonly accountId?: string;
|
|
12
|
+
readonly savedAt: number;
|
|
13
|
+
};
|
|
14
|
+
export interface AuthFile {
|
|
15
|
+
readonly version: 1;
|
|
16
|
+
readonly credentials: Readonly<Record<string, Credential>>;
|
|
17
|
+
}
|
|
18
|
+
export declare class AuthError extends Error {
|
|
19
|
+
}
|
|
20
|
+
/** The providers a credential can be stored for (the runtime's built-in
|
|
21
|
+
* manifests; `custom` endpoints stay on env vars — a key for an unknown
|
|
22
|
+
* origin has no identity to own). */
|
|
23
|
+
export declare const KNOWN_PROVIDERS: readonly string[];
|
|
24
|
+
/** The providers whose sign-in is an OAuth flow rather than a key. */
|
|
25
|
+
export declare const OAUTH_PROVIDERS: readonly string[];
|
|
26
|
+
/** The provider identity a profile's credential is stored under, or null
|
|
27
|
+
* for an origin nobody recognizes (custom: env var only). */
|
|
28
|
+
export declare function providerIdOf(kind: string, baseUrl?: string): string | null;
|
|
29
|
+
export declare function authPath(): string;
|
|
30
|
+
export declare function readAuthFile(path?: string): AuthFile;
|
|
31
|
+
export declare function getCredential(providerId: string, path?: string): Credential | undefined;
|
|
32
|
+
/** The one write path: lock, read, transform, write atomically (mode 0600
|
|
33
|
+
* on creation; the home directory 0700 on creation). Returns what was written. */
|
|
34
|
+
export declare function modifyAuthFile(fn: (file: AuthFile) => AuthFile, path?: string): AuthFile;
|
|
35
|
+
export declare function setCredential(providerId: string, credential: Credential, path?: string): void;
|
|
36
|
+
/** Removes the credential; false when there was none. */
|
|
37
|
+
export declare function deleteCredential(providerId: string, path?: string): boolean;
|
|
38
|
+
/** The only rendering of a secret: its length class and last four characters. */
|
|
39
|
+
export declare function maskSecret(secret: string): string;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The credential store — sign-in's foundation (the sign-in plan, 2026-09-08).
|
|
3
|
+
*
|
|
4
|
+
* `~/.kiso/auth.json` holds one credential per provider: an API key, or an
|
|
5
|
+
* OAuth token set (the flows arrive in the next step; the shape is fixed now
|
|
6
|
+
* so a stored OAuth credential is recognized, never misread as a key). The
|
|
7
|
+
* file is written atomically (tmp + rename) with mode 0600 on creation; the
|
|
8
|
+
* ONE write path is `modifyAuthFile`, serialized across processes by a
|
|
9
|
+
* directory lock beside the file — no dependency, and a stale lock (an
|
|
10
|
+
* earlier process that died) is reclaimed by age.
|
|
11
|
+
*
|
|
12
|
+
* The resolve rule (the reference implementation's, kept as design): a
|
|
13
|
+
* stored credential OWNS its provider. When one exists it is used; when it is
|
|
14
|
+
* unusable (an OAuth token where the adapter needs a key; a malformed file)
|
|
15
|
+
* the failure is named and there is NO silent fall back to an env var — "which
|
|
16
|
+
* key paid for this" stays answerable. Only when nothing is stored does the
|
|
17
|
+
* profile's env var apply, as before.
|
|
18
|
+
*
|
|
19
|
+
* Keys never appear in stdout, in the session log, or in an error message —
|
|
20
|
+
* `maskSecret` is the only rendering.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { kisoHome } from "../state.js";
|
|
25
|
+
export class AuthError extends Error {
|
|
26
|
+
}
|
|
27
|
+
/** The providers a credential can be stored for (the runtime's built-in
|
|
28
|
+
* manifests; `custom` endpoints stay on env vars — a key for an unknown
|
|
29
|
+
* origin has no identity to own). */
|
|
30
|
+
export const KNOWN_PROVIDERS = ["anthropic", "openai", "deepseek", "zai", "chatgpt"];
|
|
31
|
+
/** The providers whose sign-in is an OAuth flow rather than a key. */
|
|
32
|
+
export const OAUTH_PROVIDERS = ["chatgpt"];
|
|
33
|
+
// Mirrors the runtime's known-origin table (packages/runtime/src/provider/
|
|
34
|
+
// manifest.ts, not on the SDK surface): a compat profile whose baseUrl origin
|
|
35
|
+
// is a built-in endpoint resolves to that provider's identity.
|
|
36
|
+
const ORIGIN_TO_PROVIDER = {
|
|
37
|
+
"https://api.openai.com": "openai",
|
|
38
|
+
"https://api.deepseek.com": "deepseek",
|
|
39
|
+
"https://api.z.ai": "zai",
|
|
40
|
+
"https://open.bigmodel.cn": "zai",
|
|
41
|
+
};
|
|
42
|
+
/** The ChatGPT backend's origin — the one that means the SUBSCRIPTION
|
|
43
|
+
* identity rather than OpenAI's. Mirrors the runtime's CHATGPT_ORIGIN.
|
|
44
|
+
* Absent from ORIGIN_TO_PROVIDER on purpose: that table resolves
|
|
45
|
+
* openai-CHAT profiles, and the subscription backend serves only the
|
|
46
|
+
* Responses dialect. */
|
|
47
|
+
const CHATGPT_ORIGIN = "https://chatgpt.com";
|
|
48
|
+
/** The provider identity a profile's credential is stored under, or null
|
|
49
|
+
* for an origin nobody recognizes (custom: env var only). */
|
|
50
|
+
export function providerIdOf(kind, baseUrl) {
|
|
51
|
+
if (kind === "anthropic")
|
|
52
|
+
return "anthropic";
|
|
53
|
+
// OR-1: exactly two identities serve the Responses dialect — the
|
|
54
|
+
// ChatGPT subscription backend, and OpenAI. There is no `custom`
|
|
55
|
+
// bucket: a Responses endpoint that is neither is still OpenAI's API
|
|
56
|
+
// shape behind a proxy, and its credential is OpenAI's.
|
|
57
|
+
if (kind === "openai-responses")
|
|
58
|
+
return originOf(baseUrl) === CHATGPT_ORIGIN ? "chatgpt" : "openai";
|
|
59
|
+
if (kind !== "openai-compat")
|
|
60
|
+
return null;
|
|
61
|
+
if (baseUrl === undefined)
|
|
62
|
+
return "openai";
|
|
63
|
+
const origin = originOf(baseUrl);
|
|
64
|
+
return origin === null ? null : (ORIGIN_TO_PROVIDER[origin] ?? null);
|
|
65
|
+
}
|
|
66
|
+
function originOf(baseUrl) {
|
|
67
|
+
if (baseUrl === undefined)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
return new URL(baseUrl).origin;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function authPath() {
|
|
77
|
+
return join(kisoHome(), "auth.json");
|
|
78
|
+
}
|
|
79
|
+
const EMPTY = { version: 1, credentials: {} };
|
|
80
|
+
export function readAuthFile(path = authPath()) {
|
|
81
|
+
if (!existsSync(path))
|
|
82
|
+
return EMPTY;
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
throw new AuthError(`${path} is not valid JSON (${err.message}) — fix or remove it; kiso never guesses at credentials`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1 || typeof parsed.credentials !== "object") {
|
|
91
|
+
throw new AuthError(`${path} has an unknown shape (expected version 1) — fix or remove it`);
|
|
92
|
+
}
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
export function getCredential(providerId, path = authPath()) {
|
|
96
|
+
return readAuthFile(path).credentials[providerId];
|
|
97
|
+
}
|
|
98
|
+
/** The lock: a directory beside the file. mkdir is atomic on every platform
|
|
99
|
+
* Node runs on; a lock older than STALE_MS belongs to a process that died
|
|
100
|
+
* and is reclaimed. Waits up to WAIT_MS, then refuses by name. */
|
|
101
|
+
const STALE_MS = 30_000;
|
|
102
|
+
const WAIT_MS = 10_000;
|
|
103
|
+
function sleepSync(ms) {
|
|
104
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
105
|
+
}
|
|
106
|
+
function withLock(path, fn) {
|
|
107
|
+
const lock = `${path}.lock`;
|
|
108
|
+
const deadline = Date.now() + WAIT_MS;
|
|
109
|
+
for (;;) {
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(lock);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err.code !== "EEXIST")
|
|
116
|
+
throw err;
|
|
117
|
+
let age = 0;
|
|
118
|
+
try {
|
|
119
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue; // it vanished between the mkdir and the stat — try again
|
|
123
|
+
}
|
|
124
|
+
if (age > STALE_MS) {
|
|
125
|
+
rmSync(lock, { recursive: true, force: true });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (Date.now() > deadline)
|
|
129
|
+
throw new AuthError(`${path} is locked by another kiso process (${lock}); try again, or remove the lock if no kiso is running`);
|
|
130
|
+
sleepSync(25);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return fn();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
rmSync(lock, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** The one write path: lock, read, transform, write atomically (mode 0600
|
|
141
|
+
* on creation; the home directory 0700 on creation). Returns what was written. */
|
|
142
|
+
export function modifyAuthFile(fn, path = authPath()) {
|
|
143
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
144
|
+
return withLock(path, () => {
|
|
145
|
+
const next = fn(readAuthFile(path));
|
|
146
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
147
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
148
|
+
renameSync(tmp, path);
|
|
149
|
+
return next;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function setCredential(providerId, credential, path = authPath()) {
|
|
153
|
+
modifyAuthFile((file) => ({ version: 1, credentials: { ...file.credentials, [providerId]: credential } }), path);
|
|
154
|
+
}
|
|
155
|
+
/** Removes the credential; false when there was none. */
|
|
156
|
+
export function deleteCredential(providerId, path = authPath()) {
|
|
157
|
+
let had = false;
|
|
158
|
+
modifyAuthFile((file) => {
|
|
159
|
+
had = providerId in file.credentials;
|
|
160
|
+
const { [providerId]: _gone, ...rest } = file.credentials;
|
|
161
|
+
return { version: 1, credentials: rest };
|
|
162
|
+
}, path);
|
|
163
|
+
return had;
|
|
164
|
+
}
|
|
165
|
+
/** The only rendering of a secret: its length class and last four characters. */
|
|
166
|
+
export function maskSecret(secret) {
|
|
167
|
+
if (secret.length <= 4)
|
|
168
|
+
return "••••";
|
|
169
|
+
return `••••${secret.slice(-4)}`;
|
|
170
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { OAuthCredential, OAuthFlow } from "./index.js";
|
|
2
|
+
export declare const CHATGPT: {
|
|
3
|
+
readonly providerId: "chatgpt";
|
|
4
|
+
readonly clientId: "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
5
|
+
readonly authorizeUrl: "https://auth.openai.com/oauth/authorize";
|
|
6
|
+
readonly tokenUrl: "https://auth.openai.com/oauth/token";
|
|
7
|
+
readonly redirectUri: "http://localhost:1455/auth/callback";
|
|
8
|
+
readonly scope: "openid profile email offline_access";
|
|
9
|
+
readonly jwtClaimPath: "https://api.openai.com/auth";
|
|
10
|
+
readonly originator: "kiso";
|
|
11
|
+
};
|
|
12
|
+
export interface AuthorizationFlow {
|
|
13
|
+
readonly verifier: string;
|
|
14
|
+
readonly state: string;
|
|
15
|
+
readonly url: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function createAuthorizationFlow(redirectUri?: string): AuthorizationFlow;
|
|
18
|
+
/** A pasted redirect URL, a `code#state` pair, a query string, or a bare code. */
|
|
19
|
+
export declare function parseAuthorizationInput(input: string): {
|
|
20
|
+
readonly code?: string | undefined;
|
|
21
|
+
readonly state?: string | undefined;
|
|
22
|
+
};
|
|
23
|
+
export declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
|
|
24
|
+
export declare function accountIdOf(accessToken: string): string | null;
|
|
25
|
+
interface TokenSet {
|
|
26
|
+
readonly access: string;
|
|
27
|
+
readonly refresh: string;
|
|
28
|
+
readonly expires: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function exchangeCode(code: string, verifier: string, redirectUri: string, tokenUrl?: string, signal?: AbortSignal): Promise<TokenSet>;
|
|
31
|
+
export declare function refreshTokens(refresh: string, tokenUrl?: string): Promise<TokenSet>;
|
|
32
|
+
export declare function credentialFromTokens(tokens: TokenSet): OAuthCredential;
|
|
33
|
+
/** The local callback: one code for one state, or null when the port cannot
|
|
34
|
+
* be bound (the paste path takes over). */
|
|
35
|
+
export declare function startCallbackServer(state: string, host: string, port: number): Promise<{
|
|
36
|
+
readonly waitForCode: () => Promise<string | null>;
|
|
37
|
+
readonly close: () => void;
|
|
38
|
+
} | null>;
|
|
39
|
+
export declare const chatgptFlow: OAuthFlow;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ChatGPT subscription sign-in — the flow the reference implementation
|
|
3
|
+
* uses (pi, `packages/ai/src/auth/oauth/openai-codex.ts` @ a79b37334, MIT,
|
|
4
|
+
* Mario Zechner; the constants and the sequence are reused as design, the
|
|
5
|
+
* code is ours). What is known and not known about it is recorded in
|
|
6
|
+
* kiso-doc/kiso-provider-mainline-2026-09-08.md §3; the owner accepted the
|
|
7
|
+
* terms risk on 2026-09-08. The product labels it as unofficial.
|
|
8
|
+
*
|
|
9
|
+
* Sequence: PKCE + state → the authorize URL (opened or printed) → the
|
|
10
|
+
* callback on 127.0.0.1:1455 (state checked; or a pasted redirect URL /
|
|
11
|
+
* code when the port cannot be bound) → the code exchanged at the token
|
|
12
|
+
* endpoint → {access, refresh, expires, accountId} where accountId is the
|
|
13
|
+
* `chatgpt_account_id` claim of the access token's JWT payload (a token
|
|
14
|
+
* without it is refused: the backend needs the header).
|
|
15
|
+
*
|
|
16
|
+
* The token is only usable against the ChatGPT backend's Responses
|
|
17
|
+
* endpoint — the Responses adapter is the second half of this step.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { generatePkce, randomState } from "./pkce.js";
|
|
21
|
+
export const CHATGPT = {
|
|
22
|
+
providerId: "chatgpt",
|
|
23
|
+
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
24
|
+
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
|
25
|
+
tokenUrl: "https://auth.openai.com/oauth/token",
|
|
26
|
+
redirectUri: "http://localhost:1455/auth/callback",
|
|
27
|
+
scope: "openid profile email offline_access",
|
|
28
|
+
jwtClaimPath: "https://api.openai.com/auth",
|
|
29
|
+
originator: "kiso",
|
|
30
|
+
};
|
|
31
|
+
export function createAuthorizationFlow(redirectUri = CHATGPT.redirectUri) {
|
|
32
|
+
const { verifier, challenge } = generatePkce();
|
|
33
|
+
const state = randomState();
|
|
34
|
+
const url = new URL(CHATGPT.authorizeUrl);
|
|
35
|
+
url.searchParams.set("response_type", "code");
|
|
36
|
+
url.searchParams.set("client_id", CHATGPT.clientId);
|
|
37
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
38
|
+
url.searchParams.set("scope", CHATGPT.scope);
|
|
39
|
+
url.searchParams.set("code_challenge", challenge);
|
|
40
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
41
|
+
url.searchParams.set("state", state);
|
|
42
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
43
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
44
|
+
url.searchParams.set("originator", CHATGPT.originator);
|
|
45
|
+
return { verifier, state, url: url.toString() };
|
|
46
|
+
}
|
|
47
|
+
/** A pasted redirect URL, a `code#state` pair, a query string, or a bare code. */
|
|
48
|
+
export function parseAuthorizationInput(input) {
|
|
49
|
+
const value = input.trim();
|
|
50
|
+
if (value === "")
|
|
51
|
+
return {};
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(value);
|
|
54
|
+
return { code: url.searchParams.get("code") ?? undefined, state: url.searchParams.get("state") ?? undefined };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// not a URL
|
|
58
|
+
}
|
|
59
|
+
if (value.includes("#")) {
|
|
60
|
+
const [code, state] = value.split("#", 2);
|
|
61
|
+
return { code, state };
|
|
62
|
+
}
|
|
63
|
+
if (value.includes("code=")) {
|
|
64
|
+
const params = new URLSearchParams(value);
|
|
65
|
+
return { code: params.get("code") ?? undefined, state: params.get("state") ?? undefined };
|
|
66
|
+
}
|
|
67
|
+
return { code: value };
|
|
68
|
+
}
|
|
69
|
+
export function decodeJwtPayload(token) {
|
|
70
|
+
const parts = token.split(".");
|
|
71
|
+
if (parts.length !== 3)
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function accountIdOf(accessToken) {
|
|
81
|
+
const payload = decodeJwtPayload(accessToken);
|
|
82
|
+
const auth = payload?.[CHATGPT.jwtClaimPath];
|
|
83
|
+
const id = auth?.chatgpt_account_id;
|
|
84
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
85
|
+
}
|
|
86
|
+
async function readTokenResponse(response, operation) {
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const text = await response.text().catch(() => "");
|
|
89
|
+
throw new Error(`ChatGPT sign-in: token ${operation} failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ""}`);
|
|
90
|
+
}
|
|
91
|
+
const json = (await response.json());
|
|
92
|
+
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
|
|
93
|
+
throw new Error(`ChatGPT sign-in: token ${operation} response is missing fields`);
|
|
94
|
+
}
|
|
95
|
+
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 };
|
|
96
|
+
}
|
|
97
|
+
export async function exchangeCode(code, verifier, redirectUri, tokenUrl = CHATGPT.tokenUrl, signal) {
|
|
98
|
+
const response = await fetch(tokenUrl, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
101
|
+
body: new URLSearchParams({ grant_type: "authorization_code", client_id: CHATGPT.clientId, code, code_verifier: verifier, redirect_uri: redirectUri }),
|
|
102
|
+
...(signal ? { signal } : {}),
|
|
103
|
+
});
|
|
104
|
+
return readTokenResponse(response, "exchange");
|
|
105
|
+
}
|
|
106
|
+
export async function refreshTokens(refresh, tokenUrl = CHATGPT.tokenUrl) {
|
|
107
|
+
const response = await fetch(tokenUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
110
|
+
body: new URLSearchParams({ grant_type: "refresh_token", client_id: CHATGPT.clientId, refresh_token: refresh }),
|
|
111
|
+
});
|
|
112
|
+
return readTokenResponse(response, "refresh");
|
|
113
|
+
}
|
|
114
|
+
export function credentialFromTokens(tokens) {
|
|
115
|
+
const accountId = accountIdOf(tokens.access);
|
|
116
|
+
if (accountId === null)
|
|
117
|
+
throw new Error("ChatGPT sign-in: the access token carries no chatgpt_account_id claim — signed in to something the backend will not serve");
|
|
118
|
+
return { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId, savedAt: Date.now() };
|
|
119
|
+
}
|
|
120
|
+
const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;padding:2rem"><h1>${title}</h1><p>${body}</p></body>`;
|
|
121
|
+
/** The local callback: one code for one state, or null when the port cannot
|
|
122
|
+
* be bound (the paste path takes over). */
|
|
123
|
+
export function startCallbackServer(state, host, port) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
let settle;
|
|
126
|
+
const waited = new Promise((r) => {
|
|
127
|
+
settle = r;
|
|
128
|
+
});
|
|
129
|
+
const server = createServer((req, res) => {
|
|
130
|
+
const url = new URL(req.url ?? "", `http://${host}`);
|
|
131
|
+
const reply = (status, title, body) => {
|
|
132
|
+
res.statusCode = status;
|
|
133
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
134
|
+
res.end(PAGE(title, body));
|
|
135
|
+
};
|
|
136
|
+
if (url.pathname !== "/auth/callback")
|
|
137
|
+
return reply(404, "Not found", "This is kiso's sign-in callback.");
|
|
138
|
+
if (url.searchParams.get("state") !== state)
|
|
139
|
+
return reply(400, "State mismatch", "The sign-in did not start from this kiso. Start again.");
|
|
140
|
+
const code = url.searchParams.get("code");
|
|
141
|
+
if (!code)
|
|
142
|
+
return reply(400, "Missing code", "The authorization server sent no code.");
|
|
143
|
+
reply(200, "Signed in", "You can close this window and return to kiso.");
|
|
144
|
+
settle?.(code);
|
|
145
|
+
});
|
|
146
|
+
server.on("error", () => resolve(null));
|
|
147
|
+
server.listen(port, host, () => {
|
|
148
|
+
resolve({
|
|
149
|
+
waitForCode: () => waited,
|
|
150
|
+
close: () => {
|
|
151
|
+
settle?.(null);
|
|
152
|
+
server.close();
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export const chatgptFlow = {
|
|
159
|
+
providerId: CHATGPT.providerId,
|
|
160
|
+
label: "ChatGPT subscription (Plus/Pro) — the Codex sign-in flow; unofficial for third-party tools",
|
|
161
|
+
async login(interaction) {
|
|
162
|
+
const cb = interaction.callback ?? { host: "127.0.0.1", port: 1455 };
|
|
163
|
+
const redirectUri = interaction.callback ? `http://localhost:${cb.port}/auth/callback` : CHATGPT.redirectUri;
|
|
164
|
+
const flow = createAuthorizationFlow(redirectUri);
|
|
165
|
+
const server = await startCallbackServer(flow.state, cb.host, cb.port);
|
|
166
|
+
interaction.notify(`open this URL to sign in:\n ${flow.url}`);
|
|
167
|
+
let code = null;
|
|
168
|
+
if (server !== null) {
|
|
169
|
+
interaction.notify(`waiting for the browser to come back to ${redirectUri} … (or paste the redirected URL here)`);
|
|
170
|
+
const pasted = interaction.prompt("").then((line) => {
|
|
171
|
+
const parsed = parseAuthorizationInput(line);
|
|
172
|
+
if (parsed.state !== undefined && parsed.state !== flow.state)
|
|
173
|
+
throw new Error("ChatGPT sign-in: the pasted state does not match this sign-in");
|
|
174
|
+
return parsed.code ?? null;
|
|
175
|
+
});
|
|
176
|
+
code = await Promise.race([server.waitForCode(), pasted]);
|
|
177
|
+
server.close();
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
interaction.notify(`port ${cb.port} is busy: after signing in, paste the redirected URL (or the code) here`);
|
|
181
|
+
const parsed = parseAuthorizationInput(await interaction.prompt("redirected URL or code: "));
|
|
182
|
+
if (parsed.state !== undefined && parsed.state !== flow.state)
|
|
183
|
+
throw new Error("ChatGPT sign-in: the pasted state does not match this sign-in");
|
|
184
|
+
code = parsed.code ?? null;
|
|
185
|
+
}
|
|
186
|
+
if (code === null || code === "")
|
|
187
|
+
throw new Error("ChatGPT sign-in: no authorization code arrived");
|
|
188
|
+
const tokens = await exchangeCode(code, flow.verifier, redirectUri, interaction.tokenUrl, interaction.signal);
|
|
189
|
+
return credentialFromTokens(tokens);
|
|
190
|
+
},
|
|
191
|
+
async refresh(credential, tokenUrl) {
|
|
192
|
+
const tokens = await refreshTokens(credential.refresh, tokenUrl);
|
|
193
|
+
const fresh = credentialFromTokens(tokens);
|
|
194
|
+
const accountId = accountIdOf(tokens.access) ?? credential.accountId;
|
|
195
|
+
return { ...fresh, ...(accountId !== undefined ? { accountId } : {}) };
|
|
196
|
+
},
|
|
197
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth interface — one shape for every subscription sign-in (the
|
|
3
|
+
* sign-in plan, 2026-09-08, step 2). A flow knows how to obtain a credential
|
|
4
|
+
* interactively and how to refresh one; the credential store owns
|
|
5
|
+
* persistence; `ensureFresh` (../refresh.ts) owns WHEN a refresh happens.
|
|
6
|
+
*/
|
|
7
|
+
import type { Credential } from "../credentials.js";
|
|
8
|
+
export type OAuthCredential = Extract<Credential, {
|
|
9
|
+
type: "oauth";
|
|
10
|
+
}>;
|
|
11
|
+
export interface LoginInteraction {
|
|
12
|
+
/** Print a line for the person signing in (the URL to open, the paste prompt). */
|
|
13
|
+
readonly notify: (line: string) => void;
|
|
14
|
+
/** Ask the person for a line (the pasted redirect URL or code when the
|
|
15
|
+
* local callback cannot be reached); resolves "" when unavailable. */
|
|
16
|
+
readonly prompt: (question: string) => Promise<string>;
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
/** Test seam: the callback listener's host/port (defaults 127.0.0.1:1455). */
|
|
19
|
+
readonly callback?: {
|
|
20
|
+
readonly host: string;
|
|
21
|
+
readonly port: number;
|
|
22
|
+
};
|
|
23
|
+
/** Test seam: the token endpoint (defaults to the vendor's). */
|
|
24
|
+
readonly tokenUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface OAuthFlow {
|
|
27
|
+
readonly providerId: string;
|
|
28
|
+
/** What the product says beside this method (an honest label). */
|
|
29
|
+
readonly label: string;
|
|
30
|
+
login(interaction: LoginInteraction): Promise<OAuthCredential>;
|
|
31
|
+
refresh(credential: OAuthCredential, tokenUrl?: string): Promise<OAuthCredential>;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** PKCE (RFC 7636) with S256 — the two values every browser sign-in needs.
|
|
2
|
+
* node:crypto only; base64url without padding, as the spec requires. */
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
const base64url = (bytes) => Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5
|
+
export function generatePkce() {
|
|
6
|
+
const verifier = base64url(randomBytes(32));
|
|
7
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
8
|
+
return { verifier, challenge };
|
|
9
|
+
}
|
|
10
|
+
export function randomState() {
|
|
11
|
+
return randomBytes(16).toString("hex");
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a refresh happens (the sign-in plan §2): an OAuth credential that
|
|
3
|
+
* expires within FRESH_WINDOW_MS is refreshed INSIDE the store's write path —
|
|
4
|
+
* re-read under the lock first, so two kiso processes never refresh the same
|
|
5
|
+
* token twice (the second sees the first's result). A failed refresh is a
|
|
6
|
+
* named error ("signed out of <provider>: run `kiso login <provider>`"),
|
|
7
|
+
* never a silent fall back to an API key or an env var.
|
|
8
|
+
*/
|
|
9
|
+
import { type Credential } from "./credentials.js";
|
|
10
|
+
import type { OAuthFlow } from "./oauth/index.js";
|
|
11
|
+
export declare const FRESH_WINDOW_MS: number;
|
|
12
|
+
export declare function ensureFresh(providerId: string, flow: OAuthFlow, path?: string, now?: number, tokenUrl?: string): Promise<Credential>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a refresh happens (the sign-in plan §2): an OAuth credential that
|
|
3
|
+
* expires within FRESH_WINDOW_MS is refreshed INSIDE the store's write path —
|
|
4
|
+
* re-read under the lock first, so two kiso processes never refresh the same
|
|
5
|
+
* token twice (the second sees the first's result). A failed refresh is a
|
|
6
|
+
* named error ("signed out of <provider>: run `kiso login <provider>`"),
|
|
7
|
+
* never a silent fall back to an API key or an env var.
|
|
8
|
+
*/
|
|
9
|
+
import { AuthError, modifyAuthFile, readAuthFile } from "./credentials.js";
|
|
10
|
+
export const FRESH_WINDOW_MS = 5 * 60_000;
|
|
11
|
+
export async function ensureFresh(providerId, flow, path, now = Date.now(), tokenUrl) {
|
|
12
|
+
const current = readAuthFile(path).credentials[providerId];
|
|
13
|
+
if (current === undefined)
|
|
14
|
+
throw new AuthError(`not signed in to ${providerId}: run \`kiso login ${providerId}\``);
|
|
15
|
+
if (current.type !== "oauth" || current.expires - now > FRESH_WINDOW_MS)
|
|
16
|
+
return current;
|
|
17
|
+
let refreshed;
|
|
18
|
+
let error;
|
|
19
|
+
// the double check: another process may have refreshed while we waited for the lock
|
|
20
|
+
const again = readAuthFile(path).credentials[providerId];
|
|
21
|
+
if (again !== undefined && again.type === "oauth" && again.expires - now > FRESH_WINDOW_MS)
|
|
22
|
+
return again;
|
|
23
|
+
try {
|
|
24
|
+
refreshed = await flow.refresh(current, tokenUrl);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
error = err instanceof Error ? err : new Error(String(err));
|
|
28
|
+
}
|
|
29
|
+
if (refreshed === undefined)
|
|
30
|
+
throw new AuthError(`signed out of ${providerId} (the token could not be refreshed: ${error?.message ?? "unknown"}) — run \`kiso login ${providerId}\``);
|
|
31
|
+
const written = refreshed;
|
|
32
|
+
modifyAuthFile((file) => {
|
|
33
|
+
const latest = file.credentials[providerId];
|
|
34
|
+
// if a concurrent refresh already wrote a fresher token, keep it
|
|
35
|
+
if (latest !== undefined && latest.type === "oauth" && latest.expires > written.expires)
|
|
36
|
+
return file;
|
|
37
|
+
return { version: 1, credentials: { ...file.credentials, [providerId]: written } };
|
|
38
|
+
}, path);
|
|
39
|
+
return readAuthFile(path).credentials[providerId];
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OR-1 — the token thunk the Responses adapter's ChatGPT target is built
|
|
3
|
+
* from.
|
|
4
|
+
*
|
|
5
|
+
* The adapter awaits this ONCE PER REQUEST, which is the whole point: a
|
|
6
|
+
* subscription token expires on the hour, and a session that resolved its
|
|
7
|
+
* token at startup would die mid-task. `ensureFresh` re-reads the store
|
|
8
|
+
* under its own lock and refreshes only inside the expiry window, so the
|
|
9
|
+
* per-request call is a file read in the common case and a refresh
|
|
10
|
+
* exactly when one is due.
|
|
11
|
+
*
|
|
12
|
+
* A failure here is the store's own named error ("signed out of chatgpt:
|
|
13
|
+
* run `kiso login chatgpt`") — the adapter turns it into a failed turn
|
|
14
|
+
* rather than guessing at a fallback credential.
|
|
15
|
+
*/
|
|
16
|
+
import type { ResponsesOAuthToken } from "@vincemakes/kiso-provider-openai-responses";
|
|
17
|
+
export declare function oauthTokenThunk(providerId: string): () => Promise<ResponsesOAuthToken>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AuthError } from "./credentials.js";
|
|
2
|
+
import { ensureFresh } from "./refresh.js";
|
|
3
|
+
/** The OAuth flows a provider id can be refreshed through. One entry
|
|
4
|
+
* today; a second sign-in registers here rather than in every caller. */
|
|
5
|
+
async function flowFor(providerId) {
|
|
6
|
+
if (providerId === "chatgpt") {
|
|
7
|
+
const { chatgptFlow } = await import("./oauth/chatgpt.js");
|
|
8
|
+
return chatgptFlow;
|
|
9
|
+
}
|
|
10
|
+
throw new AuthError(`no OAuth flow is registered for ${providerId}`);
|
|
11
|
+
}
|
|
12
|
+
export function oauthTokenThunk(providerId) {
|
|
13
|
+
return async () => {
|
|
14
|
+
const credential = await ensureFresh(providerId, await flowFor(providerId));
|
|
15
|
+
if (credential.type !== "oauth") {
|
|
16
|
+
throw new AuthError(`${providerId}: the stored credential is an API key, not a sign-in — run \`kiso login ${providerId}\``);
|
|
17
|
+
}
|
|
18
|
+
if (credential.accountId === undefined) {
|
|
19
|
+
// The backend refuses a request without the account header, so
|
|
20
|
+
// a token that carries no account id is unusable — said here,
|
|
21
|
+
// once, rather than as an opaque 400 per turn.
|
|
22
|
+
throw new AuthError(`${providerId}: the stored sign-in carries no account id — run \`kiso login ${providerId}\` again`);
|
|
23
|
+
}
|
|
24
|
+
return { access: credential.access, accountId: credential.accountId };
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/chat.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface UsageDelta {
|
|
|
85
85
|
* absent provider resolves like the tracer's "adapter" identity — the
|
|
86
86
|
* total convention (INPUT_CONVENTIONS), never a crash.
|
|
87
87
|
*/
|
|
88
|
-
export declare function usageFromEvent(route: string | undefined, ev: import("@vincemakes/kiso-core").Usage, prevTotal: number | null, model?: string): UsageDelta;
|
|
88
|
+
export declare function usageFromEvent(route: string | undefined, ev: import("@vincemakes/kiso-core").Usage, prevTotal: number | null, model?: string, endpoint?: string): UsageDelta;
|
|
89
89
|
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
90
90
|
* is gone) — docked only, 200ms rotation between the request and the
|
|
91
91
|
* first event. */
|
package/dist/chat.js
CHANGED
|
@@ -11,7 +11,7 @@ import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-n
|
|
|
11
11
|
import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
|
|
12
12
|
import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
|
|
13
13
|
import { dispatch } from "./dispatch.js";
|
|
14
|
-
import { agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
|
|
14
|
+
import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
|
|
15
15
|
import { attachImages } from "./attachments.js";
|
|
16
16
|
import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
|
|
17
17
|
import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
|
|
@@ -47,8 +47,10 @@ export function contextWindowTokens() {
|
|
|
47
47
|
// window (and the microcompact threshold derived from it) without an
|
|
48
48
|
// env var. agentModel is the same live binding the status row shows;
|
|
49
49
|
// an unknown model keeps the 200k default — the registry never
|
|
50
|
-
// guesses, so neither do we.
|
|
51
|
-
|
|
50
|
+
// guesses, so neither do we. OR-1: the ENDPOINT narrows the row —
|
|
51
|
+
// gpt-5.5 is 1,050,000 at the first-party API and 272,000 at the
|
|
52
|
+
// subscription backend; the two are set together (setAgentModel).
|
|
53
|
+
const known = lookupModelMetadata(agentModel, agentBaseUrl)?.capabilities.contextWindow;
|
|
52
54
|
if (known !== undefined && known !== null)
|
|
53
55
|
return known;
|
|
54
56
|
return DEFAULT_CONTEXT_WINDOW;
|
|
@@ -110,8 +112,12 @@ export function usageFromEvent(route, ev, prevTotal,
|
|
|
110
112
|
// metadata entry (an unpriced model shows null, never a route-table
|
|
111
113
|
// guess); omitted, the legacy route-keyed path stands (old callers,
|
|
112
114
|
// old tests, unchanged bytes).
|
|
113
|
-
model
|
|
114
|
-
|
|
115
|
+
model,
|
|
116
|
+
// OR-1: the live ENDPOINT — the same model id is priced at the
|
|
117
|
+
// first-party API and unpriced at the subscription backend; the
|
|
118
|
+
// session hands it over next to `provider` (one binding, one row).
|
|
119
|
+
endpoint) {
|
|
120
|
+
const c = model === undefined ? canonicalizeUsage(route ?? "adapter", ev) : canonicalizeUsageForModel(model, endpoint, route ?? "adapter", ev);
|
|
115
121
|
const total = c.input + c.cacheRead + (c.cacheWrite ?? 0);
|
|
116
122
|
let missed = null;
|
|
117
123
|
// R-C item 4: min(prevTotal, total) is the part that could have been
|
|
@@ -636,7 +642,7 @@ submitTurn) {
|
|
|
636
642
|
body.notice("stream interrupted — the draft above is abandoned");
|
|
637
643
|
break;
|
|
638
644
|
case "usage": {
|
|
639
|
-
const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel);
|
|
645
|
+
const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel, session.baseUrl);
|
|
640
646
|
usage = delta.usage;
|
|
641
647
|
prevTotal = delta.total;
|
|
642
648
|
missed = delta.missed;
|