@byokit/accounts 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/README.md +32 -0
- package/dist/accounts.d.ts +126 -0
- package/dist/accounts.js +332 -0
- package/dist/catalogue.d.ts +21 -0
- package/dist/catalogue.js +14 -0
- package/dist/catalogue.json +52 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/isolate.d.ts +7 -0
- package/dist/isolate.js +16 -0
- package/dist/limits.d.ts +8 -0
- package/dist/limits.js +20 -0
- package/dist/stores.d.ts +5 -0
- package/dist/stores.js +42 -0
- package/dist/testing/index.d.ts +19 -0
- package/dist/testing/index.js +50 -0
- package/dist/testing/trace-fs.d.ts +1 -0
- package/dist/testing/trace-fs.js +30 -0
- package/dist/words.d.ts +14 -0
- package/dist/words.js +28 -0
- package/dist/words.json +22 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @byokit/accounts
|
|
2
|
+
|
|
3
|
+
Sign in with the AI plan you already pay for (ChatGPT, OpenRouter; Grok and GitHub Copilot on request), inside your
|
|
4
|
+
own app, into your app's own store. Built on Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai)
|
|
5
|
+
sign-in flows, pinned exactly.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { isolate } from '@byokit/accounts/isolate'; // first, before any Pi import
|
|
9
|
+
isolate('/path/to/app/engine'); // scrub inherited Pi settings and provider keys
|
|
10
|
+
import { Accounts, fileStore } from '@byokit/accounts';
|
|
11
|
+
|
|
12
|
+
const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json`) });
|
|
13
|
+
const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: 'waiting', code, url }
|
|
14
|
+
// show shown.code and shown.url; the sign-in finishes by itself
|
|
15
|
+
(await accounts.status(1, 'chatgpt')).words; // "ChatGPT is connected."
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Catalogue** (`catalogue.json`): each provider with its terms status (`allowed`, `grey`, `partner`), a one-line reason
|
|
19
|
+
and a source. The kit labels; your app decides what to offer (`new Accounts({ offer: ['chatgpt', 'grok'] })`).
|
|
20
|
+
Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
|
|
21
|
+
- **Sign-in**: the provider's own page by default. For ChatGPT, whose page returns to this computer's port 1455, the kit
|
|
22
|
+
listens there itself, so the tab shows your app's words (`new Accounts({ app: 'My App' })`) and only once they are
|
|
23
|
+
true. A code takes over when asked ("Having trouble?"), when the page never comes back, or when the port is taken by
|
|
24
|
+
another sign-in. A 15-minute cap, nothing kept unless the engine can use it, and every failure is one plain sentence
|
|
25
|
+
(`words.json`) with a `why` for apps that word it themselves. `plan(member)` tells a work ChatGPT from a personal one.
|
|
26
|
+
- **One person, one store**: `memoryStore()` or `fileStore(path)` (0600, the same shape as Pi's `auth.json`). Never a
|
|
27
|
+
shared fallback. Using another engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)`.
|
|
28
|
+
- **Limits**: `failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that
|
|
29
|
+
doesn't include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable
|
|
30
|
+
account; `keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
|
|
31
|
+
- **Isolation**: ambient discovery is off (no environment variable or credential file is ever consulted), and
|
|
32
|
+
`@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { CredentialStore, Models } from '@earendil-works/pi-ai';
|
|
2
|
+
import { type Provider } from './catalogue.ts';
|
|
3
|
+
import { type Kind } from './limits.ts';
|
|
4
|
+
import { type Why } from './words.ts';
|
|
5
|
+
/** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
|
|
6
|
+
export type AuthHost = Pick<Models, 'login' | 'logout' | 'checkAuth' | 'getAuth'>;
|
|
7
|
+
export type Member = string | number;
|
|
8
|
+
/** What the person sees while signing in: the provider's own page to open (`via: 'browser'`), or a code to type there
|
|
9
|
+
* (`via: 'code'`), never the engine's own prompts. `why` names how a failed one failed, for apps that word it themselves. */
|
|
10
|
+
export type SignIn = {
|
|
11
|
+
state: 'waiting' | 'done' | 'failed';
|
|
12
|
+
via?: 'browser' | 'code';
|
|
13
|
+
url?: string;
|
|
14
|
+
code?: string;
|
|
15
|
+
expiresAt?: number;
|
|
16
|
+
error?: string;
|
|
17
|
+
why?: Why;
|
|
18
|
+
};
|
|
19
|
+
export type Status = {
|
|
20
|
+
account: string;
|
|
21
|
+
name: string;
|
|
22
|
+
state: 'ready' | 'signing' | 'resting' | 'signed_out' | 'needs_again' | 'not_included';
|
|
23
|
+
until?: number;
|
|
24
|
+
words: string;
|
|
25
|
+
};
|
|
26
|
+
export type AccountsOptions<M extends Member = Member> = {
|
|
27
|
+
/** The accounts this app offers, in order. Default: every provider not hidden (ChatGPT, OpenRouter). */
|
|
28
|
+
offer?: readonly string[];
|
|
29
|
+
/** Each member's own store. Default: in memory. */
|
|
30
|
+
store?: (member: M) => CredentialStore;
|
|
31
|
+
/** The app's name, for the page the provider's sign-in sends the browser back to. */
|
|
32
|
+
app?: string;
|
|
33
|
+
/** Longest a sign-in may wait: longer than any provider's code lives. */
|
|
34
|
+
signInMs?: number;
|
|
35
|
+
/** No redirect back by then: the page is probably stuck (or on a phone), so a code takes over by itself. */
|
|
36
|
+
redirectMs?: number;
|
|
37
|
+
/** Listen here for the provider's redirect instead of its fixed port (tests, so they never meet a real sign-in). */
|
|
38
|
+
callbackPort?: number;
|
|
39
|
+
};
|
|
40
|
+
/** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
|
|
41
|
+
export declare function planOf(access: string): {
|
|
42
|
+
plan: string;
|
|
43
|
+
email: string;
|
|
44
|
+
work: boolean;
|
|
45
|
+
};
|
|
46
|
+
export declare class Accounts<R extends AuthHost = AuthHost, M extends Member = Member> {
|
|
47
|
+
readonly providers: Provider[];
|
|
48
|
+
private opts;
|
|
49
|
+
private runtimes;
|
|
50
|
+
private stores;
|
|
51
|
+
private flows;
|
|
52
|
+
private ready;
|
|
53
|
+
private lapsed;
|
|
54
|
+
/** Signed in, but the plan doesn't include this use (ChatGPT's own "usage not included"). ponytail: in memory, so a
|
|
55
|
+
* restart simply tries once more. */
|
|
56
|
+
private without;
|
|
57
|
+
private rests;
|
|
58
|
+
onChange?: (member: M, key: string) => void;
|
|
59
|
+
/** A sign-in just finished and works. */
|
|
60
|
+
onSignedIn?: (member: M, key: string) => void;
|
|
61
|
+
/** Said once when a sign-in can no longer be refreshed. */
|
|
62
|
+
onExpired?: (member: M, key: string) => void;
|
|
63
|
+
constructor(opts?: AccountsOptions<M>);
|
|
64
|
+
/** A member's own store. */
|
|
65
|
+
protected store(member: M): CredentialStore;
|
|
66
|
+
/** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
|
|
67
|
+
protected open(member: M): Promise<R>;
|
|
68
|
+
runtime(member: M): Promise<R>;
|
|
69
|
+
private offer;
|
|
70
|
+
/** Signed in, from the engine's own side-effect-free check. */
|
|
71
|
+
signedIn(member: M, key: string): Promise<boolean>;
|
|
72
|
+
/** Which ChatGPT the member signed in with: its plan, email, and whether it is a work account. Null when not signed in. */
|
|
73
|
+
plan(member: M): Promise<{
|
|
74
|
+
plan: string;
|
|
75
|
+
email: string;
|
|
76
|
+
work: boolean;
|
|
77
|
+
} | null>;
|
|
78
|
+
/** Whether the member's plan lacks this use; `on` records what the provider said, or that the person changed plans. */
|
|
79
|
+
notIncluded(member: M, key: string, on?: boolean): boolean;
|
|
80
|
+
/** Known to be unusable: signed out, or a plan without this use. An unchecked account counts as usable, so a first run still tries. */
|
|
81
|
+
unready(member: M, key: string): boolean;
|
|
82
|
+
/** The account turned a request away (its sign-in lapsed): signed out until the person signs in again. */
|
|
83
|
+
forget(member: M, key: string): void;
|
|
84
|
+
/** 0 when the account is available; otherwise when it stops resting. */
|
|
85
|
+
restingUntil(member: M, key: string): number;
|
|
86
|
+
/** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
|
|
87
|
+
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
88
|
+
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
|
89
|
+
* or null for an error that is not about the account; `network` changes nothing. */
|
|
90
|
+
failed(member: M, key: string, error: string): Promise<{
|
|
91
|
+
kind: Kind;
|
|
92
|
+
until: number;
|
|
93
|
+
} | null>;
|
|
94
|
+
/** The first choice whose account is neither resting nor known to be unusable: the fallback ladder. */
|
|
95
|
+
ladder<T>(member: M, choices: readonly T[], key?: (c: T) => string): T | undefined;
|
|
96
|
+
/** Where one account stands, in one plain sentence every app shows the same way. */
|
|
97
|
+
status(member: M, key: string): Promise<Status>;
|
|
98
|
+
/** Start "Sign in with …". The provider's own page by default: where it has a fixed redirect back to this computer
|
|
99
|
+
* (ChatGPT), the kit listens there itself, so the tab shows the app's words, and only once they are true. The code is
|
|
100
|
+
* the fallback: asked for (`via: 'code'`, "Having trouble?", even mid-way), or by itself when no redirect has come back
|
|
101
|
+
* in time, or when a browser sign-in could not return at all. `fresh` asks the page which account again. A flow that
|
|
102
|
+
* stalls times out; nothing is kept unless the engine then sees a working sign-in; every failure ends in one plain
|
|
103
|
+
* sentence. Returns as soon as there is a page to open or a code to show (or it is over); the rest carries on by itself. */
|
|
104
|
+
login(member: M, key: string, body?: {
|
|
105
|
+
via?: 'code' | 'browser';
|
|
106
|
+
fresh?: boolean;
|
|
107
|
+
}): Promise<SignIn | null>;
|
|
108
|
+
/** The whole sign-in, for when the caller wants to wait for its end (tests do). */
|
|
109
|
+
finished(member: M, key: string): Promise<void>;
|
|
110
|
+
private toCode;
|
|
111
|
+
private signIn;
|
|
112
|
+
/** Listen where the provider sends the browser back; rejects if something else on this computer already listens there. */
|
|
113
|
+
private catchRedirect;
|
|
114
|
+
/** The redirect address (or a code) pasted back, for when the browser couldn't return to this computer by itself. */
|
|
115
|
+
paste(member: M, key: string, text: string): void;
|
|
116
|
+
/** Stop a sign-in and forget it; nothing it started is kept. */
|
|
117
|
+
cancel(member: M, key: string): void;
|
|
118
|
+
/** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
|
|
119
|
+
* nobody is looking. Only the provider refusing signs it out, and `onExpired` says so once; a network hiccup doesn't. */
|
|
120
|
+
keepFresh(members: readonly M[]): Promise<void>;
|
|
121
|
+
/** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
|
|
122
|
+
recheck(member: M, key: string): Promise<boolean>;
|
|
123
|
+
logout(member: M, key: string): Promise<void>;
|
|
124
|
+
view(member: M, key: string): SignIn | null;
|
|
125
|
+
stop(): void;
|
|
126
|
+
}
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Sign in with the AI plan you already pay for, one person at a time, into that person's own store. Sharing one
|
|
2
|
+
// person's plan breaks the vendors' terms, so every sign-in, rest and refresh is keyed by member and account.
|
|
3
|
+
// Pi's own sign-in flows do the work; the app only shows the provider's page to open or the code to type.
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
|
6
|
+
import { offered, provider } from "./catalogue.js";
|
|
7
|
+
import { emptyAuthContext } from "./isolate.js";
|
|
8
|
+
import { classify, REST_MS } from "./limits.js";
|
|
9
|
+
import { memoryStore } from "./stores.js";
|
|
10
|
+
import { callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
11
|
+
/** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
|
|
12
|
+
export function planOf(access) {
|
|
13
|
+
let claims = {};
|
|
14
|
+
try {
|
|
15
|
+
claims = JSON.parse(Buffer.from(access.split('.')[1] ?? '', 'base64url').toString());
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
const plan = String(claims['https://api.openai.com/auth']?.chatgpt_plan_type ?? '').toLowerCase();
|
|
19
|
+
return { plan, email: String(claims['https://api.openai.com/profile']?.email ?? claims.email ?? ''), work: /^(team|business|enterprise|edu|education|k12)/.test(plan) };
|
|
20
|
+
}
|
|
21
|
+
const offline = (e) => failure(String(e?.message)) === 'offline';
|
|
22
|
+
export class Accounts {
|
|
23
|
+
providers;
|
|
24
|
+
opts;
|
|
25
|
+
runtimes = new Map();
|
|
26
|
+
stores = new Map();
|
|
27
|
+
flows = new Map();
|
|
28
|
+
ready = new Map();
|
|
29
|
+
lapsed = new Set();
|
|
30
|
+
/** Signed in, but the plan doesn't include this use (ChatGPT's own "usage not included"). ponytail: in memory, so a
|
|
31
|
+
* restart simply tries once more. */
|
|
32
|
+
without = new Set();
|
|
33
|
+
rests = new Map();
|
|
34
|
+
onChange;
|
|
35
|
+
/** A sign-in just finished and works. */
|
|
36
|
+
onSignedIn;
|
|
37
|
+
/** Said once when a sign-in can no longer be refreshed. */
|
|
38
|
+
onExpired;
|
|
39
|
+
constructor(opts = {}) { this.opts = opts; this.providers = offered(opts.offer); }
|
|
40
|
+
/** A member's own store. */
|
|
41
|
+
store(member) {
|
|
42
|
+
let s = this.stores.get(String(member));
|
|
43
|
+
if (!s)
|
|
44
|
+
this.stores.set(String(member), s = (this.opts.store ?? memoryStore)(member));
|
|
45
|
+
return s;
|
|
46
|
+
}
|
|
47
|
+
/** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
|
|
48
|
+
open(member) {
|
|
49
|
+
return Promise.resolve(builtinModels({ credentials: this.store(member), authContext: emptyAuthContext }));
|
|
50
|
+
}
|
|
51
|
+
runtime(member) {
|
|
52
|
+
let r = this.runtimes.get(String(member));
|
|
53
|
+
if (!r)
|
|
54
|
+
this.runtimes.set(String(member), r = this.open(member));
|
|
55
|
+
return r;
|
|
56
|
+
}
|
|
57
|
+
offer(key) {
|
|
58
|
+
const p = provider(key);
|
|
59
|
+
if (!this.providers.includes(p))
|
|
60
|
+
throw Object.assign(new Error('AI account not offered here'), { status: 404 });
|
|
61
|
+
return p;
|
|
62
|
+
}
|
|
63
|
+
/** Signed in, from the engine's own side-effect-free check. */
|
|
64
|
+
async signedIn(member, key) {
|
|
65
|
+
const ok = !!(await (await this.runtime(member)).checkAuth(this.offer(key).pi).catch(() => undefined));
|
|
66
|
+
this.ready.set(`${member}:${key}`, ok);
|
|
67
|
+
if (ok)
|
|
68
|
+
this.lapsed.delete(`${member}:${key}`);
|
|
69
|
+
return ok;
|
|
70
|
+
}
|
|
71
|
+
/** Which ChatGPT the member signed in with: its plan, email, and whether it is a work account. Null when not signed in. */
|
|
72
|
+
async plan(member) {
|
|
73
|
+
const c = await this.store(member).read(this.offer('chatgpt').pi).catch(() => undefined);
|
|
74
|
+
return c?.type === 'oauth' ? planOf(c.access) : null;
|
|
75
|
+
}
|
|
76
|
+
/** Whether the member's plan lacks this use; `on` records what the provider said, or that the person changed plans. */
|
|
77
|
+
notIncluded(member, key, on) {
|
|
78
|
+
const id = `${member}:${key}`;
|
|
79
|
+
if (on !== undefined) {
|
|
80
|
+
if (on)
|
|
81
|
+
this.without.add(id);
|
|
82
|
+
else
|
|
83
|
+
this.without.delete(id);
|
|
84
|
+
this.onChange?.(member, key);
|
|
85
|
+
}
|
|
86
|
+
return this.without.has(id);
|
|
87
|
+
}
|
|
88
|
+
/** Known to be unusable: signed out, or a plan without this use. An unchecked account counts as usable, so a first run still tries. */
|
|
89
|
+
unready(member, key) { return this.ready.get(`${member}:${key}`) === false || this.without.has(`${member}:${key}`); }
|
|
90
|
+
/** The account turned a request away (its sign-in lapsed): signed out until the person signs in again. */
|
|
91
|
+
forget(member, key) {
|
|
92
|
+
this.ready.set(`${member}:${key}`, false);
|
|
93
|
+
this.lapsed.add(`${member}:${key}`);
|
|
94
|
+
this.onChange?.(member, key);
|
|
95
|
+
}
|
|
96
|
+
/** 0 when the account is available; otherwise when it stops resting. */
|
|
97
|
+
restingUntil(member, key) {
|
|
98
|
+
const r = this.rests.get(`${member}:${key}`);
|
|
99
|
+
return r && r.until > Date.now() ? r.until : 0;
|
|
100
|
+
}
|
|
101
|
+
/** An account's error, acted on. A limit or overload rests it (until when it said, or a default). A plan without this
|
|
102
|
+
* use is marked so. A refusal is checked: a sign-in that no longer refreshes is signed out for real, one that still
|
|
103
|
+
* does was a passing refusal and rests a few minutes (kind `overloaded`) rather than loop. Returns the kind acted on,
|
|
104
|
+
* or null for an error that is not about the account; `network` changes nothing. */
|
|
105
|
+
async failed(member, key, error) {
|
|
106
|
+
const c = classify(error);
|
|
107
|
+
if (!c || c.kind === 'network')
|
|
108
|
+
return c;
|
|
109
|
+
if (c.kind === 'signed_out' && await this.recheck(member, key))
|
|
110
|
+
c.kind = 'overloaded';
|
|
111
|
+
if (c.kind === 'not_included')
|
|
112
|
+
this.notIncluded(member, key, true);
|
|
113
|
+
else if (c.kind !== 'signed_out') {
|
|
114
|
+
c.until ||= Date.now() + REST_MS[c.kind];
|
|
115
|
+
this.rests.set(`${member}:${key}`, { until: c.until, kind: c.kind });
|
|
116
|
+
this.onChange?.(member, key);
|
|
117
|
+
}
|
|
118
|
+
return c;
|
|
119
|
+
}
|
|
120
|
+
/** The first choice whose account is neither resting nor known to be unusable: the fallback ladder. */
|
|
121
|
+
ladder(member, choices, key = String) {
|
|
122
|
+
return choices.find((c) => !this.restingUntil(member, key(c)) && !this.unready(member, key(c)));
|
|
123
|
+
}
|
|
124
|
+
/** Where one account stands, in one plain sentence every app shows the same way. */
|
|
125
|
+
async status(member, key) {
|
|
126
|
+
const { name } = this.offer(key);
|
|
127
|
+
const id = `${member}:${key}`;
|
|
128
|
+
const s = (state, w, until) => ({ account: key, name, state, until, words: say(w, { name, until: until ? clock(until) : '' }) });
|
|
129
|
+
if (this.flows.get(id)?.state === 'waiting')
|
|
130
|
+
return s('signing', 'status.signing');
|
|
131
|
+
const until = this.restingUntil(member, key);
|
|
132
|
+
if (until)
|
|
133
|
+
return s('resting', this.rests.get(id).kind === 'rate_limit' ? 'status.resting' : 'status.busy', until);
|
|
134
|
+
if (!(this.ready.get(id) ?? await this.signedIn(member, key)))
|
|
135
|
+
return this.lapsed.has(id) ? s('needs_again', 'status.needsAgain') : s('signed_out', 'status.signedOut');
|
|
136
|
+
return this.without.has(id) ? s('not_included', 'status.notIncluded') : s('ready', 'status.ready');
|
|
137
|
+
}
|
|
138
|
+
/** Start "Sign in with …". The provider's own page by default: where it has a fixed redirect back to this computer
|
|
139
|
+
* (ChatGPT), the kit listens there itself, so the tab shows the app's words, and only once they are true. The code is
|
|
140
|
+
* the fallback: asked for (`via: 'code'`, "Having trouble?", even mid-way), or by itself when no redirect has come back
|
|
141
|
+
* in time, or when a browser sign-in could not return at all. `fresh` asks the page which account again. A flow that
|
|
142
|
+
* stalls times out; nothing is kept unless the engine then sees a working sign-in; every failure ends in one plain
|
|
143
|
+
* sentence. Returns as soon as there is a page to open or a code to show (or it is over); the rest carries on by itself. */
|
|
144
|
+
async login(member, key, body = {}) {
|
|
145
|
+
this.offer(key);
|
|
146
|
+
const id = `${member}:${key}`;
|
|
147
|
+
const now = this.flows.get(id);
|
|
148
|
+
if (now?.state === 'waiting' && body.via === 'code' && now.via === 'browser') {
|
|
149
|
+
// "Having trouble?": the same sign-in carries on with a code instead.
|
|
150
|
+
const visible = new Promise((r) => (now.shown = r));
|
|
151
|
+
this.toCode(now);
|
|
152
|
+
await Promise.race([visible, now.done]);
|
|
153
|
+
}
|
|
154
|
+
else if (now?.state !== 'waiting') {
|
|
155
|
+
const flow = { state: 'waiting', abort: new AbortController() };
|
|
156
|
+
this.flows.set(id, flow);
|
|
157
|
+
const visible = new Promise((r) => (flow.shown = r));
|
|
158
|
+
flow.done = this.signIn(member, key, body, flow);
|
|
159
|
+
await Promise.race([visible, flow.done]);
|
|
160
|
+
}
|
|
161
|
+
return this.view(member, key);
|
|
162
|
+
}
|
|
163
|
+
/** The whole sign-in, for when the caller wants to wait for its end (tests do). */
|
|
164
|
+
finished(member, key) { return this.flows.get(`${member}:${key}`)?.done ?? Promise.resolve(); }
|
|
165
|
+
toCode(flow) {
|
|
166
|
+
if (flow.state !== 'waiting' || flow.via !== 'browser' || flow.toCode)
|
|
167
|
+
return;
|
|
168
|
+
flow.toCode = true;
|
|
169
|
+
flow.refuse?.(new Error('switching to a code'));
|
|
170
|
+
}
|
|
171
|
+
async signIn(member, key, body, flow) {
|
|
172
|
+
const p = this.offer(key);
|
|
173
|
+
const id = `${member}:${key}`;
|
|
174
|
+
const rt = await this.runtime(member);
|
|
175
|
+
let codeOffered = false;
|
|
176
|
+
const attempt = (via) => rt.login(p.pi, 'oauth', {
|
|
177
|
+
signal: flow.abort.signal,
|
|
178
|
+
prompt: (q) => {
|
|
179
|
+
if (q.type === 'select') {
|
|
180
|
+
const device = q.options.find((o) => /device/i.test(o.id));
|
|
181
|
+
codeOffered = !!device;
|
|
182
|
+
return Promise.resolve((via === 'code' && device ? device : q.options.find((o) => o !== device) ?? q.options[0]).id);
|
|
183
|
+
}
|
|
184
|
+
if (q.type === 'text')
|
|
185
|
+
return Promise.resolve(''); // GitHub Enterprise domain: never, for a household
|
|
186
|
+
// "Paste the redirect address": the kit's own listener (or the person) hands the engine the address the browser landed on.
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
Object.assign(flow, { paste: resolve, refuse: reject });
|
|
189
|
+
q.signal?.addEventListener('abort', () => reject(new Error('answered elsewhere')));
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
notify: (e) => {
|
|
193
|
+
if (e.type === 'auth_url') {
|
|
194
|
+
const url = new URL(e.url);
|
|
195
|
+
if (body.fresh)
|
|
196
|
+
url.searchParams.set('prompt', 'login'); // "Use my personal account": ask which account, again
|
|
197
|
+
Object.assign(flow, { via: 'browser', url: url.toString(), code: undefined, oauthState: url.searchParams.get('state') ?? undefined });
|
|
198
|
+
}
|
|
199
|
+
if (e.type === 'device_code')
|
|
200
|
+
Object.assign(flow, { via: 'code', code: e.userCode, url: e.verificationUri, expiresAt: e.expiresInSeconds ? Date.now() + e.expiresInSeconds * 1000 : undefined });
|
|
201
|
+
if (flow.url)
|
|
202
|
+
flow.shown?.();
|
|
203
|
+
this.onChange?.(member, key);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
const timer = setTimeout(() => { flow.timedOut = true; flow.abort.abort(); }, this.opts.signInMs ?? 15 * 60_000);
|
|
207
|
+
const stuck = setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
|
|
208
|
+
// Listen where the provider sends the browser back (the engine then finds the port taken and waits to be handed the address).
|
|
209
|
+
const port = p.callbackPort && (this.opts.callbackPort ?? p.callbackPort);
|
|
210
|
+
const catcher = port && body.via !== 'code' ? await this.catchRedirect(flow, p.name, port).catch(() => null) : undefined;
|
|
211
|
+
try {
|
|
212
|
+
if (catcher === null)
|
|
213
|
+
throw Object.assign(new Error('port busy'), { why: 'busy' });
|
|
214
|
+
try {
|
|
215
|
+
await attempt(body.via ?? (catcher ? 'browser' : undefined));
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
// The code instead: asked for, or the page never came back. Also when a browser sign-in could not return here at all.
|
|
219
|
+
if (!flow.toCode && (catcher || body.via === 'code' || !codeOffered || flow.abort.signal.aborted))
|
|
220
|
+
throw e;
|
|
221
|
+
Object.assign(flow, { url: undefined, code: undefined, via: 'code' });
|
|
222
|
+
catcher?.close();
|
|
223
|
+
catcher?.closeIdleConnections();
|
|
224
|
+
await attempt('code');
|
|
225
|
+
}
|
|
226
|
+
// Never half signed in: only a sign-in the engine can use counts.
|
|
227
|
+
if (!(await rt.checkAuth(p.pi).catch(() => undefined))) {
|
|
228
|
+
await rt.logout(p.pi).catch(() => { });
|
|
229
|
+
throw new Error('no usable credential');
|
|
230
|
+
}
|
|
231
|
+
flow.state = 'done';
|
|
232
|
+
this.ready.set(id, true);
|
|
233
|
+
for (const s of [this.lapsed, this.without])
|
|
234
|
+
s.delete(id);
|
|
235
|
+
this.rests.delete(id);
|
|
236
|
+
this.onSignedIn?.(member, key);
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
if (flow.state !== 'waiting')
|
|
240
|
+
return; // cancelled: already settled
|
|
241
|
+
const error = String(e?.message ?? e);
|
|
242
|
+
console.error(`sign-in ${key} for member ${member}:`, error);
|
|
243
|
+
const why = e?.why ?? (flow.timedOut ? 'tooLong' : failure(error));
|
|
244
|
+
Object.assign(flow, { state: 'failed', url: undefined, code: undefined, expiresAt: undefined, why,
|
|
245
|
+
error: why === 'busy' || why === 'tooLong' ? say(`signIn.${why}`, { name: p.name }) : signInError(p.name, error) });
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
clearTimeout(stuck);
|
|
250
|
+
catcher?.close();
|
|
251
|
+
catcher?.closeIdleConnections();
|
|
252
|
+
this.onChange?.(member, key);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Listen where the provider sends the browser back; rejects if something else on this computer already listens there. */
|
|
256
|
+
catchRedirect(flow, name, port) {
|
|
257
|
+
const app = this.opts.app ?? 'the app';
|
|
258
|
+
const server = createServer(async (req, res) => {
|
|
259
|
+
const q = new URL(req.url ?? '/', 'http://localhost').searchParams;
|
|
260
|
+
// No keep-alive: a browser must never land on a listener from an earlier try.
|
|
261
|
+
const page = (status, words, close = false) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', connection: 'close' }); res.end(callbackPage(this.opts.app ?? name, words, close)); };
|
|
262
|
+
if (!flow.oauthState || q.get('state') !== flow.oauthState || flow.state !== 'waiting')
|
|
263
|
+
return page(400, say('callback.outOfDate', { app, name }));
|
|
264
|
+
if (q.get('error'))
|
|
265
|
+
flow.refuse?.(new Error(q.get('error')));
|
|
266
|
+
// The engine only reads the query; the address it expects is the provider's registered one, on its fixed port.
|
|
267
|
+
else
|
|
268
|
+
flow.paste?.(`http://localhost:${port}${req.url}`);
|
|
269
|
+
// The tab waits for the real outcome (a few seconds at most), so it never says "signed in" before it is.
|
|
270
|
+
await Promise.race([flow.done, new Promise((r) => setTimeout(r, 30_000).unref())]);
|
|
271
|
+
const end = flow.state;
|
|
272
|
+
if (end === 'done')
|
|
273
|
+
return page(200, say('callback.done', { app }), true);
|
|
274
|
+
if (flow.why === 'declined')
|
|
275
|
+
return page(200, say('callback.declined', { app }), true);
|
|
276
|
+
page(200, end === 'failed' ? say('callback.failed', { app, error: flow.error ?? '' }) : say('callback.nearly', { app }));
|
|
277
|
+
});
|
|
278
|
+
return new Promise((resolve, reject) => { server.once('error', reject).listen(port, '127.0.0.1', () => resolve(server)); });
|
|
279
|
+
}
|
|
280
|
+
/** The redirect address (or a code) pasted back, for when the browser couldn't return to this computer by itself. */
|
|
281
|
+
paste(member, key, text) {
|
|
282
|
+
const f = this.flows.get(`${member}:${key}`);
|
|
283
|
+
if (f?.state !== 'waiting' || !f.paste)
|
|
284
|
+
throw Object.assign(new Error('no sign-in is waiting'), { status: 409 });
|
|
285
|
+
f.paste(text.trim());
|
|
286
|
+
}
|
|
287
|
+
/** Stop a sign-in and forget it; nothing it started is kept. */
|
|
288
|
+
cancel(member, key) {
|
|
289
|
+
const f = this.flows.get(`${member}:${key}`);
|
|
290
|
+
if (f?.state === 'waiting') {
|
|
291
|
+
f.state = 'failed';
|
|
292
|
+
f.abort.abort();
|
|
293
|
+
f.refuse?.(new Error('cancelled'));
|
|
294
|
+
}
|
|
295
|
+
this.flows.delete(`${member}:${key}`);
|
|
296
|
+
this.onChange?.(member, key);
|
|
297
|
+
}
|
|
298
|
+
/** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
|
|
299
|
+
* nobody is looking. Only the provider refusing signs it out, and `onExpired` says so once; a network hiccup doesn't. */
|
|
300
|
+
async keepFresh(members) {
|
|
301
|
+
for (const m of members)
|
|
302
|
+
for (const p of this.providers) {
|
|
303
|
+
if (this.ready.get(`${m}:${p.key}`) !== true)
|
|
304
|
+
continue;
|
|
305
|
+
const ok = await (await this.runtime(m)).getAuth(p.pi, { minOAuthValidityMs: 60 * 60_000 }).then(Boolean, offline);
|
|
306
|
+
if (!ok) {
|
|
307
|
+
this.forget(m, p.key);
|
|
308
|
+
this.onExpired?.(m, p.key);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
|
|
313
|
+
async recheck(member, key) {
|
|
314
|
+
const ok = await (await this.runtime(member)).getAuth(this.offer(key).pi, { minOAuthValidityMs: 365 * 86_400_000 }).then(Boolean, offline);
|
|
315
|
+
if (!ok) {
|
|
316
|
+
await this.logout(member, key).catch(() => { });
|
|
317
|
+
this.forget(member, key);
|
|
318
|
+
}
|
|
319
|
+
return ok;
|
|
320
|
+
}
|
|
321
|
+
async logout(member, key) {
|
|
322
|
+
await (await this.runtime(member)).logout(this.offer(key).pi);
|
|
323
|
+
this.ready.set(`${member}:${key}`, false);
|
|
324
|
+
this.onChange?.(member, key);
|
|
325
|
+
}
|
|
326
|
+
view(member, key) {
|
|
327
|
+
const f = this.flows.get(`${member}:${key}`);
|
|
328
|
+
return f ? { state: f.state, via: f.via, url: f.url, code: f.code, expiresAt: f.state === 'waiting' ? f.expiresAt : undefined, error: f.error, why: f.why } : null;
|
|
329
|
+
}
|
|
330
|
+
stop() { for (const f of this.flows.values())
|
|
331
|
+
f.abort.abort(); }
|
|
332
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Terms = 'allowed' | 'grey' | 'partner' | 'forbidden';
|
|
2
|
+
/** `callbackPort`: where the provider sends the browser back after its own sign-in page, fixed for the client Pi signs in as. */
|
|
3
|
+
export type Provider = {
|
|
4
|
+
key: string;
|
|
5
|
+
pi: string;
|
|
6
|
+
name: string;
|
|
7
|
+
company: string;
|
|
8
|
+
models: {
|
|
9
|
+
strong: string;
|
|
10
|
+
fast?: string;
|
|
11
|
+
};
|
|
12
|
+
callbackPort?: number;
|
|
13
|
+
terms: Terms;
|
|
14
|
+
hidden: boolean;
|
|
15
|
+
why: string;
|
|
16
|
+
source: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const PROVIDERS: Record<string, Provider>;
|
|
19
|
+
export declare function provider(key: string): Provider;
|
|
20
|
+
/** What an app offers: the keys it names, in its order, or every provider not hidden by default. */
|
|
21
|
+
export declare const offered: (keys?: readonly string[]) => Provider[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The AI accounts a person can bring, by the name they know, with each provider's terms status as data (catalogue.json,
|
|
2
|
+
// readable from Kotlin too). The kit labels; the app decides what to offer. Claude plan sign-in is absent on purpose:
|
|
3
|
+
// Anthropic reserves it for its own apps, so no app can wire it by mistake. Meta and Kimi are left out too (a
|
|
4
|
+
// competitor by the owner's choice; Kimi refuses anything but coding agents).
|
|
5
|
+
import CATALOGUE from './catalogue.json' with { type: 'json' };
|
|
6
|
+
export const PROVIDERS = Object.fromEntries(Object.entries(CATALOGUE).map(([key, p]) => [key, { key, ...p }]));
|
|
7
|
+
export function provider(key) {
|
|
8
|
+
const p = PROVIDERS[key];
|
|
9
|
+
if (!p)
|
|
10
|
+
throw Object.assign(new Error('no such AI account'), { status: 404 });
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
/** What an app offers: the keys it names, in its order, or every provider not hidden by default. */
|
|
14
|
+
export const offered = (keys) => keys ? keys.map(provider) : Object.values(PROVIDERS).filter((p) => !p.hidden);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chatgpt": {
|
|
3
|
+
"pi": "openai-codex",
|
|
4
|
+
"name": "ChatGPT",
|
|
5
|
+
"company": "OpenAI",
|
|
6
|
+
"models": {
|
|
7
|
+
"strong": "gpt-6-sol",
|
|
8
|
+
"fast": "gpt-6-luna"
|
|
9
|
+
},
|
|
10
|
+
"callbackPort": 1455,
|
|
11
|
+
"terms": "grey",
|
|
12
|
+
"hidden": false,
|
|
13
|
+
"why": "Signs in through Codex's own sign-in. OpenAI documents it for Codex, not for other apps, and has endorsed one other app using it.",
|
|
14
|
+
"source": "https://developers.openai.com/codex/auth"
|
|
15
|
+
},
|
|
16
|
+
"openrouter": {
|
|
17
|
+
"pi": "openrouter",
|
|
18
|
+
"name": "OpenRouter",
|
|
19
|
+
"company": "OpenRouter",
|
|
20
|
+
"models": {
|
|
21
|
+
"strong": "moonshotai/kimi-k2.6"
|
|
22
|
+
},
|
|
23
|
+
"terms": "allowed",
|
|
24
|
+
"hidden": false,
|
|
25
|
+
"why": "Documented sign-in for any app, no registration. Pay-as-you-go credits, not a subscription.",
|
|
26
|
+
"source": "https://openrouter.ai/docs/guides/overview/auth/oauth"
|
|
27
|
+
},
|
|
28
|
+
"grok": {
|
|
29
|
+
"pi": "xai",
|
|
30
|
+
"name": "Grok",
|
|
31
|
+
"company": "xAI",
|
|
32
|
+
"models": {
|
|
33
|
+
"strong": "grok-4.7"
|
|
34
|
+
},
|
|
35
|
+
"terms": "partner",
|
|
36
|
+
"hidden": true,
|
|
37
|
+
"why": "xAI allows plan sign-in only in apps it has partnered with.",
|
|
38
|
+
"source": "https://x.ai/news/grok-opencode"
|
|
39
|
+
},
|
|
40
|
+
"copilot": {
|
|
41
|
+
"pi": "github-copilot",
|
|
42
|
+
"name": "GitHub Copilot",
|
|
43
|
+
"company": "GitHub",
|
|
44
|
+
"models": {
|
|
45
|
+
"strong": "gpt-5.4"
|
|
46
|
+
},
|
|
47
|
+
"terms": "partner",
|
|
48
|
+
"hidden": true,
|
|
49
|
+
"why": "GitHub allows Copilot sign-in only in apps it has partnered with; this uses VS Code's client.",
|
|
50
|
+
"source": "https://github.blog/changelog/2026-01-16-github-copilot-now-supports-opencode"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Accounts, planOf, type AccountsOptions, type AuthHost, type Member, type SignIn, type Status } from './accounts.ts';
|
|
2
|
+
export { PROVIDERS, offered, provider, type Provider, type Terms } from './catalogue.ts';
|
|
3
|
+
export { INHERITED, emptyAuthContext, isolate } from './isolate.ts';
|
|
4
|
+
export { REST_MS, classify, type Kind } from './limits.ts';
|
|
5
|
+
export { fileStore, memoryStore } from './stores.ts';
|
|
6
|
+
export { WORDS, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Accounts, planOf } from "./accounts.js";
|
|
2
|
+
export { PROVIDERS, offered, provider } from "./catalogue.js";
|
|
3
|
+
export { INHERITED, emptyAuthContext, isolate } from "./isolate.js";
|
|
4
|
+
export { REST_MS, classify } from "./limits.js";
|
|
5
|
+
export { fileStore, memoryStore } from "./stores.js";
|
|
6
|
+
export { WORDS, callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AuthContext } from '@earendil-works/pi-ai';
|
|
2
|
+
/** Inherited Pi settings, and provider keys that would let the app run on someone's account without signing in. */
|
|
3
|
+
export declare const INHERITED: RegExp;
|
|
4
|
+
/** Ambient discovery switched off: no environment variable or credential file is ever consulted. */
|
|
5
|
+
export declare const emptyAuthContext: AuthContext;
|
|
6
|
+
/** Scrub inherited settings and keys from this process, and pin Pi's agent folder to `dir` (never ~/.pi). Returns `dir`. */
|
|
7
|
+
export declare function isolate(dir: string): string;
|
package/dist/isolate.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Nothing from the person's own AI tools or shell may reach the app's engine. No Pi import here: an app imports this
|
|
2
|
+
// first, because Pi's coding agent reads some of these at import time.
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
/** Inherited Pi settings, and provider keys that would let the app run on someone's account without signing in. */
|
|
5
|
+
export const INHERITED = /^(PI_|AI_AGENT$|ANTHROPIC_|AWS_|AZURE_|GOOGLE_|GCLOUD_|CLOUDFLARE_)|_API_KEY$|^(COPILOT_GITHUB_TOKEN|GH_TOKEN|GITHUB_TOKEN|HF_TOKEN)$/;
|
|
6
|
+
/** Ambient discovery switched off: no environment variable or credential file is ever consulted. */
|
|
7
|
+
export const emptyAuthContext = { env: async () => undefined, fileExists: async () => false };
|
|
8
|
+
/** Scrub inherited settings and keys from this process, and pin Pi's agent folder to `dir` (never ~/.pi). Returns `dir`. */
|
|
9
|
+
export function isolate(dir) {
|
|
10
|
+
for (const k of Object.keys(process.env))
|
|
11
|
+
if (INHERITED.test(k))
|
|
12
|
+
delete process.env[k];
|
|
13
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
Object.assign(process.env, { PI_CODING_AGENT_DIR: dir, PI_OFFLINE: '1', PI_TELEMETRY: '0', PI_SKIP_VERSION_CHECK: '1' });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Kind = 'rate_limit' | 'overloaded' | 'signed_out' | 'not_included' | 'network';
|
|
2
|
+
/** How long an account rests when its error didn't say. */
|
|
3
|
+
export declare const REST_MS: Record<Kind, number>;
|
|
4
|
+
/** `until` is 0 when the error didn't say. Anything unrecognised is null: the app fails that one request. */
|
|
5
|
+
export declare function classify(error: string): {
|
|
6
|
+
kind: Kind;
|
|
7
|
+
until: number;
|
|
8
|
+
} | null;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** How long an account rests when its error didn't say. */
|
|
2
|
+
export const REST_MS = { rate_limit: 60 * 60_000, overloaded: 5 * 60_000, signed_out: 0, not_included: 0, network: 0 };
|
|
3
|
+
/** `until` is 0 when the error didn't say. Anything unrecognised is null: the app fails that one request. */
|
|
4
|
+
export function classify(error) {
|
|
5
|
+
const m = /try again in ~?(\d+)\s*(min|h)/i.exec(error);
|
|
6
|
+
const until = m ? Date.now() + Number(m[1]) * (m[2].toLowerCase() === 'h' ? 3_600_000 : 60_000) : 0;
|
|
7
|
+
// ChatGPT words "your plan doesn't include this" (usage_not_included) like a limit, but with no time to come back.
|
|
8
|
+
// ponytail: told apart by the missing "try again"; a real limit always says when it resets.
|
|
9
|
+
if (/usage limit/i.test(error) && !m)
|
|
10
|
+
return { kind: 'not_included', until };
|
|
11
|
+
if (/usage limit|rate.?limit|quota|too many requests|\b429\b/i.test(error))
|
|
12
|
+
return { kind: 'rate_limit', until };
|
|
13
|
+
if (/overloaded|high demand|\b50[234]\b|unavailable/i.test(error))
|
|
14
|
+
return { kind: 'overloaded', until };
|
|
15
|
+
if (/unauthori[sz]ed|\b40[13]\b|sign in again|expired|invalid.*token|authentication/i.test(error))
|
|
16
|
+
return { kind: 'signed_out', until };
|
|
17
|
+
if (/fetch failed|network|ENOTFOUND|EAI_AGAIN|ECONN|socket hang up/i.test(error))
|
|
18
|
+
return { kind: 'network', until };
|
|
19
|
+
return null;
|
|
20
|
+
}
|
package/dist/stores.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CredentialStore } from '@earendil-works/pi-ai';
|
|
2
|
+
export declare const memoryStore: () => CredentialStore;
|
|
3
|
+
/** One person's sign-ins in a JSON file the app chooses (0600, in a 0700 folder), in the same shape as Pi's auth.json.
|
|
4
|
+
* ponytail: writes are serialized within this process only; add a file lock if two processes ever share one file. */
|
|
5
|
+
export declare function fileStore(path: string): CredentialStore;
|
package/dist/stores.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Credential stores behind Pi's own CredentialStore seam: one per person, never a shared fallback.
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { InMemoryCredentialStore } from '@earendil-works/pi-ai';
|
|
5
|
+
export const memoryStore = () => new InMemoryCredentialStore();
|
|
6
|
+
/** One person's sign-ins in a JSON file the app chooses (0600, in a 0700 folder), in the same shape as Pi's auth.json.
|
|
7
|
+
* ponytail: writes are serialized within this process only; add a file lock if two processes ever share one file. */
|
|
8
|
+
export function fileStore(path) {
|
|
9
|
+
let chain = Promise.resolve();
|
|
10
|
+
const serial = (fn) => { const r = chain.then(fn); chain = r.catch(() => { }); return r; };
|
|
11
|
+
const load = () => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (e?.code === 'ENOENT')
|
|
17
|
+
return {};
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const save = (data) => {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
24
|
+
renameSync(`${path}.tmp`, path);
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
read: async (id) => load()[id],
|
|
28
|
+
list: async () => Object.entries(load()).map(([providerId, c]) => ({ providerId, type: c.type })),
|
|
29
|
+
modify: (id, fn) => serial(async () => {
|
|
30
|
+
const current = load()[id];
|
|
31
|
+
const next = await fn(current);
|
|
32
|
+
if (next === undefined)
|
|
33
|
+
return current;
|
|
34
|
+
save({ ...load(), [id]: next }); // re-read: a sign-in can take minutes, and other providers may have changed meanwhile
|
|
35
|
+
return next;
|
|
36
|
+
}),
|
|
37
|
+
delete: (id) => serial(async () => { const data = load(); if (id in data) {
|
|
38
|
+
delete data[id];
|
|
39
|
+
save(data);
|
|
40
|
+
} }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const CANARY = "canary-byokit-7f3a9c";
|
|
2
|
+
/** Preload this with `node --import` to record touches under `TRACE_ROOTS` in `TRACE_LOG` (both set in `decoy().env`). */
|
|
3
|
+
export declare const traceFs: string;
|
|
4
|
+
export declare function decoy(root?: string): {
|
|
5
|
+
root: string;
|
|
6
|
+
home: string;
|
|
7
|
+
roots: string[];
|
|
8
|
+
marks: string;
|
|
9
|
+
/** For a child process: the decoy HOME, the tracer's settings, and what a shell inside someone's Pi inherits. */
|
|
10
|
+
env: Record<string, string>;
|
|
11
|
+
/** Paths the tracer saw touched, one per line; empty when nothing was. */
|
|
12
|
+
touched: () => string;
|
|
13
|
+
/** Decoy files whose bytes or modification time changed, or that appeared or vanished. */
|
|
14
|
+
changed: () => string[];
|
|
15
|
+
/** Files under `dir` carrying a canary: a key or sign-in from the decoy leaked into the app's own storage. */
|
|
16
|
+
leaks: (dir: string) => string[];
|
|
17
|
+
/** Marks left by the decoy's extension or CLIs: something ran code from someone else's setup. */
|
|
18
|
+
ran: () => string[];
|
|
19
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The isolation harness every byokit package (and every app built on it) tests with: a throwaway HOME holding a decoy
|
|
2
|
+
// of each agent setup a person may already have signed in (Pi, Codex, Claude, shared agent skills), the environment a
|
|
3
|
+
// shell inside one of them hands down, and an fs tracer. A run must leave the decoys byte for byte as they were, never
|
|
4
|
+
// touch them at all, and never carry a canary into the app's own files.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
export const CANARY = 'canary-byokit-7f3a9c';
|
|
11
|
+
/** Preload this with `node --import` to record touches under `TRACE_ROOTS` in `TRACE_LOG` (both set in `decoy().env`). */
|
|
12
|
+
export const traceFs = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './trace-fs.ts' : './trace-fs.js', import.meta.url));
|
|
13
|
+
const put = (p, text) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text); };
|
|
14
|
+
const files = (dir) => existsSync(dir)
|
|
15
|
+
? readdirSync(dir, { recursive: true, withFileTypes: true }).filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name)) : [];
|
|
16
|
+
const hashes = (dirs) => Object.fromEntries(dirs.flatMap(files).map((p) => [p, createHash('sha256').update(readFileSync(p)).digest('hex') + statSync(p).mtimeMs]));
|
|
17
|
+
export function decoy(root = mkdtempSync(join(tmpdir(), 'byokit-decoy-'))) {
|
|
18
|
+
const home = join(root, 'home');
|
|
19
|
+
const marks = join(root, 'marks');
|
|
20
|
+
const trace = join(root, 'trace.log');
|
|
21
|
+
const pi = join(home, '.pi'), codex = join(home, '.codex'), claude = join(home, '.claude'), agents = join(home, '.agents');
|
|
22
|
+
const token = { type: 'oauth', access: `${CANARY}-access`, refresh: `${CANARY}-refresh`, expires: Date.now() + 86_400_000 };
|
|
23
|
+
put(join(pi, 'agent', 'auth.json'), JSON.stringify({ 'openai-codex': token, xai: token }));
|
|
24
|
+
put(join(pi, 'agent', 'settings.json'), JSON.stringify({ defaultProvider: 'openai-codex', defaultModel: 'gpt-6-sol', packages: ['npm:evil'] }));
|
|
25
|
+
put(join(pi, 'agent', 'extensions', 'evil.ts'), `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(join(marks, 'extension'))}, 'loaded'); export default () => {};`);
|
|
26
|
+
put(join(codex, 'auth.json'), JSON.stringify({ tokens: { access_token: `${CANARY}-codex`, refresh_token: `${CANARY}-codex-refresh` } }));
|
|
27
|
+
put(join(claude, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: `${CANARY}-claude` } }));
|
|
28
|
+
put(join(agents, 'skills', 'owner-skill', 'SKILL.md'), '---\nname: owner-skill\ndescription: the person\'s own skill\n---\n');
|
|
29
|
+
mkdirSync(marks, { recursive: true });
|
|
30
|
+
writeFileSync(trace, '');
|
|
31
|
+
const roots = [pi, codex, claude, agents];
|
|
32
|
+
const before = hashes(roots);
|
|
33
|
+
return {
|
|
34
|
+
root, home, roots, marks,
|
|
35
|
+
/** For a child process: the decoy HOME, the tracer's settings, and what a shell inside someone's Pi inherits. */
|
|
36
|
+
env: {
|
|
37
|
+
HOME: home, TRACE_ROOTS: roots.join(':'), TRACE_LOG: trace,
|
|
38
|
+
PI_CODING_AGENT_DIR: join(pi, 'agent'), PI_PROVIDER: 'openai-codex', PI_MODEL: 'gpt-6-sol', PI_CODING_AGENT: 'true', AI_AGENT: 'pi',
|
|
39
|
+
OPENAI_API_KEY: `${CANARY}-key`, XAI_API_KEY: `${CANARY}-xai`, OPENROUTER_API_KEY: `${CANARY}-or`, GH_TOKEN: `${CANARY}-gh`,
|
|
40
|
+
},
|
|
41
|
+
/** Paths the tracer saw touched, one per line; empty when nothing was. */
|
|
42
|
+
touched: () => readFileSync(trace, 'utf8').trim(),
|
|
43
|
+
/** Decoy files whose bytes or modification time changed, or that appeared or vanished. */
|
|
44
|
+
changed: () => { const now = hashes(roots); return [...new Set([...Object.keys(before), ...Object.keys(now)])].filter((p) => before[p] !== now[p]); },
|
|
45
|
+
/** Files under `dir` carrying a canary: a key or sign-in from the decoy leaked into the app's own storage. */
|
|
46
|
+
leaks: (dir) => files(dir).filter((f) => readFileSync(f).includes(CANARY)),
|
|
47
|
+
/** Marks left by the decoy's extension or CLIs: something ran code from someone else's setup. */
|
|
48
|
+
ran: () => readdirSync(marks),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Preload (node --import) that records every file-system call naming a path under TRACE_ROOTS (colon-separated), one
|
|
2
|
+
// line per touch in TRACE_LOG. It keeps the original appendFileSync for itself, so it never traces its own writes.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
const roots = (process.env.TRACE_ROOTS ?? '').split(':').filter(Boolean);
|
|
7
|
+
const log = process.env.TRACE_LOG;
|
|
8
|
+
const append = fs.appendFileSync;
|
|
9
|
+
const hit = (p) => {
|
|
10
|
+
if (typeof p === 'number' || p == null)
|
|
11
|
+
return;
|
|
12
|
+
const s = resolve(String(p instanceof URL ? p.pathname : p));
|
|
13
|
+
if (roots.some((r) => s === r || s.startsWith(r + '/')))
|
|
14
|
+
append(log, `${s}\n`);
|
|
15
|
+
};
|
|
16
|
+
const wrap = (obj, name) => {
|
|
17
|
+
const f = obj[name];
|
|
18
|
+
if (typeof f !== 'function' || (name === 'appendFileSync' && obj === fs))
|
|
19
|
+
return;
|
|
20
|
+
obj[name] = function (a, b, ...rest) { hit(a); if (/rename|copy|link|symlink|cp/i.test(name))
|
|
21
|
+
hit(b); return f.call(this, a, b, ...rest); };
|
|
22
|
+
};
|
|
23
|
+
if (log && roots.length) {
|
|
24
|
+
for (const name of Object.keys(fs))
|
|
25
|
+
if (/^[a-z]/.test(name) && !['watch', 'watchFile', 'unwatchFile'].includes(name))
|
|
26
|
+
wrap(fs, name);
|
|
27
|
+
for (const name of Object.keys(fs.promises))
|
|
28
|
+
wrap(fs.promises, name);
|
|
29
|
+
syncBuiltinESMExports();
|
|
30
|
+
}
|
package/dist/words.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import WORDS from './words.json';
|
|
2
|
+
export type WordKey = keyof typeof WORDS;
|
|
3
|
+
export { WORDS };
|
|
4
|
+
/** A sentence with its `{slots}` filled. */
|
|
5
|
+
export declare const say: (key: WordKey, slots?: Record<string, string>) => string;
|
|
6
|
+
/** "3:40pm", or "Fri 3:40pm" when it isn't today. */
|
|
7
|
+
export declare const clock: (t: number) => string;
|
|
8
|
+
/** Why a sign-in failed, from the engine's own error. */
|
|
9
|
+
export type Why = 'expired' | 'declined' | 'offline' | 'deviceCodeOff' | 'busy' | 'tooLong' | 'failed';
|
|
10
|
+
export declare function failure(error: string): Why;
|
|
11
|
+
/** A failed sign-in in one plain sentence with one next step. */
|
|
12
|
+
export declare const signInError: (name: string, error: string) => string;
|
|
13
|
+
/** The app's own page for the browser tab a provider sends back: it says how it really went, never "success" before it is. */
|
|
14
|
+
export declare const callbackPage: (title: string, words: string, close?: boolean) => string;
|
package/dist/words.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Every sentence a person can see, in one file other languages can read too (words.json). Plain words only: no codes,
|
|
2
|
+
// commands, paths, model ids or percentages; test/words.test.ts holds the line.
|
|
3
|
+
import WORDS from './words.json' with { type: 'json' };
|
|
4
|
+
export { WORDS };
|
|
5
|
+
/** A sentence with its `{slots}` filled. */
|
|
6
|
+
export const say = (key, slots = {}) => WORDS[key].replace(/\{(\w+)\}/g, (_, k) => slots[k] ?? '');
|
|
7
|
+
/** "3:40pm", or "Fri 3:40pm" when it isn't today. */
|
|
8
|
+
export const clock = (t) => (new Date(t).toDateString() === new Date().toDateString() ? '' : new Date(t).toLocaleDateString('en-US', { weekday: 'short' }) + ' ') +
|
|
9
|
+
new Date(t).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).toLowerCase();
|
|
10
|
+
export function failure(error) {
|
|
11
|
+
if (/token exchange failed|missing fields|accountId/i.test(error))
|
|
12
|
+
return 'failed'; // said yes on the page, refused after
|
|
13
|
+
if (/expired|expire/i.test(error))
|
|
14
|
+
return 'expired';
|
|
15
|
+
if (/denied|declined|access_denied|rejected|cancel/i.test(error))
|
|
16
|
+
return 'declined';
|
|
17
|
+
if (/fetch failed|network|ENOTFOUND|EAI_AGAIN|ECONN|timed? ?out|socket/i.test(error))
|
|
18
|
+
return 'offline';
|
|
19
|
+
if (/device code.*(disabled|not enabled)|enable device/i.test(error))
|
|
20
|
+
return 'deviceCodeOff';
|
|
21
|
+
return 'failed';
|
|
22
|
+
}
|
|
23
|
+
/** A failed sign-in in one plain sentence with one next step. */
|
|
24
|
+
export const signInError = (name, error) => say(`signIn.${failure(error)}`, { name });
|
|
25
|
+
/** The app's own page for the browser tab a provider sends back: it says how it really went, never "success" before it is. */
|
|
26
|
+
export const callbackPage = (title, words, close = false) => '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">' +
|
|
27
|
+
`<title>${title.replace(/[<&]/g, '')}</title><body style="font:18px system-ui;margin:3em auto;max-width:26em;padding:0 1em;text-align:center;color:#2e2a40">${words.replace(/[<&]/g, '')}` +
|
|
28
|
+
(close ? '<script>setTimeout(() => window.close(), 1500)</script>' : '') + '</body>';
|
package/dist/words.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"signIn.expired": "The code expired before it was used. Tap Sign in with {name} for a new one.",
|
|
3
|
+
"signIn.declined": "The sign-in was declined on the {name} page. Tap Sign in with {name} to try again.",
|
|
4
|
+
"signIn.offline": "Couldn't reach {name}. Check the internet connection, then tap Sign in again.",
|
|
5
|
+
"signIn.deviceCodeOff": "{name} needs device sign-in turned on first: in {name}, Settings, Security, turn on device code sign-in, then try again.",
|
|
6
|
+
"signIn.tooLong": "The sign-in took too long. Tap Sign in with {name} to start again.",
|
|
7
|
+
"signIn.busy": "Something else on this computer is signing in to {name}. Try again in a minute.",
|
|
8
|
+
"signIn.failed": "{name} didn't finish the sign-in. Tap Sign in with {name} to try again.",
|
|
9
|
+
"status.ready": "{name} is connected.",
|
|
10
|
+
"status.signing": "Signing in to {name}…",
|
|
11
|
+
"status.resting": "{name} is resting until {until}.",
|
|
12
|
+
"status.busy": "{name} is busy right now.",
|
|
13
|
+
"status.signedOut": "{name} isn't signed in yet.",
|
|
14
|
+
"status.needsAgain": "{name} needs you to sign in again.",
|
|
15
|
+
"terms.grey": "Uses your {name} plan. {company} may change this at any time.",
|
|
16
|
+
"status.notIncluded": "Your {name} plan doesn't include this yet.",
|
|
17
|
+
"callback.done": "You're signed in. You can go back to {app} now.",
|
|
18
|
+
"callback.declined": "No problem. Nothing was changed. You can go back to {app}.",
|
|
19
|
+
"callback.failed": "{error} Go back to {app}.",
|
|
20
|
+
"callback.nearly": "Nearly there. Go back to {app} to finish.",
|
|
21
|
+
"callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again."
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@byokit/accounts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sign in with the AI plan you already pay for, into your app's own store.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/umeranjum17/byokit.git",
|
|
10
|
+
"directory": "packages/accounts"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.18"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./isolate": {
|
|
21
|
+
"types": "./dist/isolate.d.ts",
|
|
22
|
+
"default": "./dist/isolate.js"
|
|
23
|
+
},
|
|
24
|
+
"./testing": {
|
|
25
|
+
"types": "./dist/testing/index.d.ts",
|
|
26
|
+
"default": "./dist/testing/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"prepack": "tsc -b"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@earendil-works/pi-ai": "0.87.1"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|