@vincemakes/kiso-code 0.30.0 → 0.31.1

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.
@@ -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,203 @@
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
+ // OR-4 (owner, 2026-09-09): the URL is opened for the person when the
168
+ // caller can — the printed line stays the fallback and the paste path.
169
+ if (interaction.open !== undefined) {
170
+ interaction.open(flow.url);
171
+ interaction.notify("(opened in your browser — if nothing opened, copy the URL above)");
172
+ }
173
+ let code = null;
174
+ if (server !== null) {
175
+ interaction.notify(`waiting for the browser to come back to ${redirectUri} … (or paste the redirected URL here)`);
176
+ const pasted = interaction.prompt("").then((line) => {
177
+ const parsed = parseAuthorizationInput(line);
178
+ if (parsed.state !== undefined && parsed.state !== flow.state)
179
+ throw new Error("ChatGPT sign-in: the pasted state does not match this sign-in");
180
+ return parsed.code ?? null;
181
+ });
182
+ code = await Promise.race([server.waitForCode(), pasted]);
183
+ server.close();
184
+ }
185
+ else {
186
+ interaction.notify(`port ${cb.port} is busy: after signing in, paste the redirected URL (or the code) here`);
187
+ const parsed = parseAuthorizationInput(await interaction.prompt("redirected URL or code: "));
188
+ if (parsed.state !== undefined && parsed.state !== flow.state)
189
+ throw new Error("ChatGPT sign-in: the pasted state does not match this sign-in");
190
+ code = parsed.code ?? null;
191
+ }
192
+ if (code === null || code === "")
193
+ throw new Error("ChatGPT sign-in: no authorization code arrived");
194
+ const tokens = await exchangeCode(code, flow.verifier, redirectUri, interaction.tokenUrl, interaction.signal);
195
+ return credentialFromTokens(tokens);
196
+ },
197
+ async refresh(credential, tokenUrl) {
198
+ const tokens = await refreshTokens(credential.refresh, tokenUrl);
199
+ const fresh = credentialFromTokens(tokens);
200
+ const accountId = accountIdOf(tokens.access) ?? credential.accountId;
201
+ return { ...fresh, ...(accountId !== undefined ? { accountId } : {}) };
202
+ },
203
+ };
@@ -0,0 +1,37 @@
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
+ /** OR-4: open the authorize URL in the person's browser, when there is
18
+ * one. OPTIONAL — the CLI passes it on a TTY; tests and pipes never do,
19
+ * so nothing opens a browser under a test rig. The URL is always
20
+ * printed too: this is a convenience, never the only path. */
21
+ readonly open?: (url: string) => void;
22
+ readonly signal?: AbortSignal;
23
+ /** Test seam: the callback listener's host/port (defaults 127.0.0.1:1455). */
24
+ readonly callback?: {
25
+ readonly host: string;
26
+ readonly port: number;
27
+ };
28
+ /** Test seam: the token endpoint (defaults to the vendor's). */
29
+ readonly tokenUrl?: string;
30
+ }
31
+ export interface OAuthFlow {
32
+ readonly providerId: string;
33
+ /** What the product says beside this method (an honest label). */
34
+ readonly label: string;
35
+ login(interaction: LoginInteraction): Promise<OAuthCredential>;
36
+ refresh(credential: OAuthCredential, tokenUrl?: string): Promise<OAuthCredential>;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export declare function generatePkce(): {
2
+ readonly verifier: string;
3
+ readonly challenge: string;
4
+ };
5
+ export declare function randomState(): string;
@@ -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
@@ -38,6 +38,15 @@ export declare function estimateCtxRatio(session: AgentSession): number;
38
38
  * their continuation envelopes, the output reserve when known) over the
39
39
  * window. Still ~ (chars/4). The auto-compact policy does NOT read this
40
40
  * (see autoCompactRatio) — moving the policy's number is A1b's. */
41
+ /** OR-8 (owner, 2026-09-09): the status row names the effort next to the
42
+ * model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
43
+ * the provider's default shows as the bare model, as before. The effort
44
+ * is the session's own (the durable profile), never the CLI's memory. */
45
+ export declare function statusModelLabel(session: {
46
+ readonly reasoning?: {
47
+ readonly effort: string;
48
+ };
49
+ }): string;
41
50
  export declare function displayCtxRatio(session: AgentSession): number;
42
51
  /** A1a: the number the auto-compact decision reads — the pre-A1a estimate,
43
52
  * unchanged this round, named so a gate can pin that it did not move. */
@@ -85,7 +94,7 @@ export interface UsageDelta {
85
94
  * absent provider resolves like the tracer's "adapter" identity — the
86
95
  * total convention (INPUT_CONVENTIONS), never a crash.
87
96
  */
88
- export declare function usageFromEvent(route: string | undefined, ev: import("@vincemakes/kiso-core").Usage, prevTotal: number | null, model?: string): UsageDelta;
97
+ export declare function usageFromEvent(route: string | undefined, ev: import("@vincemakes/kiso-core").Usage, prevTotal: number | null, model?: string, endpoint?: string): UsageDelta;
89
98
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
90
99
  * is gone) — docked only, 200ms rotation between the request and the
91
100
  * first event. */