@cortexkit/common-auth 0.2.4 → 0.2.5
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-menu/accounts.d.ts +88 -0
- package/dist/auth-menu/accounts.js +401 -0
- package/dist/auth-menu/ansi.d.ts +24 -0
- package/dist/auth-menu/ansi.js +67 -0
- package/dist/auth-menu/confirm.d.ts +7 -0
- package/dist/auth-menu/confirm.js +21 -0
- package/dist/auth-menu/doctor.d.ts +52 -0
- package/dist/auth-menu/doctor.js +81 -0
- package/dist/auth-menu/index.d.ts +17 -1
- package/dist/auth-menu/index.js +12 -2
- package/dist/auth-menu/login.d.ts +47 -0
- package/dist/auth-menu/login.js +63 -0
- package/dist/auth-menu/menu.d.ts +59 -0
- package/dist/auth-menu/menu.js +73 -0
- package/dist/auth-menu/opencode-v1.d.ts +49 -0
- package/dist/auth-menu/opencode-v1.js +47 -0
- package/dist/auth-menu/select.d.ts +21 -0
- package/dist/auth-menu/select.js +153 -0
- package/dist/auth-menu/terminal.d.ts +43 -0
- package/dist/auth-menu/terminal.js +19 -0
- package/dist/cachekeep/index.d.ts +4 -1
- package/dist/cachekeep/index.js +2 -2
- package/dist/cachekeep/manager.d.ts +214 -0
- package/dist/cachekeep/manager.js +424 -0
- package/dist/cachekeep/window.d.ts +13 -0
- package/dist/cachekeep/window.js +33 -0
- package/dist/dump/index.d.ts +90 -1
- package/dist/dump/index.js +292 -2
- package/package.json +1 -1
package/dist/dump/index.js
CHANGED
|
@@ -1,2 +1,292 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createRedactor } from '../logger/redact.js';
|
|
5
|
+
const PREVIOUS_BODY_LIMIT = 100;
|
|
6
|
+
const UNKNOWN_SESSION = 'session-unknown';
|
|
7
|
+
function shortSession(session) {
|
|
8
|
+
return session.length <= 16 ? session : `${session.slice(0, 12)}…`;
|
|
9
|
+
}
|
|
10
|
+
function fileSegment(value, fallback) {
|
|
11
|
+
const normalized = value
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '');
|
|
15
|
+
if (!normalized)
|
|
16
|
+
return fallback;
|
|
17
|
+
return normalized.slice(0, 80);
|
|
18
|
+
}
|
|
19
|
+
function hashText(value) {
|
|
20
|
+
return createHash('sha256').update(value).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
function normalizeKey(key) {
|
|
23
|
+
return key.toLowerCase().replace(/[-_]/g, '');
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Where two bodies first and last differ, in string offsets into the dumped
|
|
30
|
+
* (redacted) text. Offsets count UTF-16 units, as the plugins' cache analysis
|
|
31
|
+
* always has.
|
|
32
|
+
*/
|
|
33
|
+
export function diffDumpBodies(previous, current) {
|
|
34
|
+
if (previous === undefined)
|
|
35
|
+
return null;
|
|
36
|
+
if (previous === current) {
|
|
37
|
+
return {
|
|
38
|
+
changed: false,
|
|
39
|
+
firstByte: -1,
|
|
40
|
+
lastPreviousByte: -1,
|
|
41
|
+
lastCurrentByte: -1,
|
|
42
|
+
previousBytes: previous.length,
|
|
43
|
+
currentBytes: current.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
let firstByte = 0;
|
|
47
|
+
while (firstByte < previous.length &&
|
|
48
|
+
firstByte < current.length &&
|
|
49
|
+
previous[firstByte] === current[firstByte]) {
|
|
50
|
+
firstByte++;
|
|
51
|
+
}
|
|
52
|
+
let previousTail = previous.length - 1;
|
|
53
|
+
let currentTail = current.length - 1;
|
|
54
|
+
while (previousTail >= firstByte &&
|
|
55
|
+
currentTail >= firstByte &&
|
|
56
|
+
previous[previousTail] === current[currentTail]) {
|
|
57
|
+
previousTail--;
|
|
58
|
+
currentTail--;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
changed: true,
|
|
62
|
+
firstByte,
|
|
63
|
+
lastPreviousByte: previousTail,
|
|
64
|
+
lastCurrentByte: currentTail,
|
|
65
|
+
changedPreviousBytes: previousTail - firstByte + 1,
|
|
66
|
+
changedCurrentBytes: currentTail - firstByte + 1,
|
|
67
|
+
previousBytes: previous.length,
|
|
68
|
+
currentBytes: current.length,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function createDumper(options) {
|
|
72
|
+
let enabled = options.enabled === true;
|
|
73
|
+
let counter = 0;
|
|
74
|
+
const pid = options.pid ?? process.pid;
|
|
75
|
+
const now = options.now ?? Date.now;
|
|
76
|
+
const log = options.logger;
|
|
77
|
+
const extraKeys = new Set((options.secretKeys ?? []).map(normalizeKey));
|
|
78
|
+
const redactor = createRedactor({
|
|
79
|
+
extraSecretKeys: (normalized) => extraKeys.has(normalized),
|
|
80
|
+
extraValuePatterns: [...(options.secretPatterns ?? [])],
|
|
81
|
+
});
|
|
82
|
+
const schemaKeys = new Set(options.schemaKeys ?? ['tools']);
|
|
83
|
+
/**
|
|
84
|
+
* Last dumped (redacted) body per baseline key, in memory for this process.
|
|
85
|
+
* A key missing here, on its first dump or after eviction, is looked up on
|
|
86
|
+
* disk.
|
|
87
|
+
*/
|
|
88
|
+
const previousBodies = new Map();
|
|
89
|
+
const resolveDir = () => typeof options.dir === 'function' ? options.dir() : options.dir;
|
|
90
|
+
/** The mask for a secret key name, or undefined for an ordinary key. */
|
|
91
|
+
function maskFor(key) {
|
|
92
|
+
// The shared redactor masks a secret key whatever its value, so probing it
|
|
93
|
+
// with null answers "is this key a secret" without duplicating its rules.
|
|
94
|
+
const masked = redactor.redact({ [key]: null })[key];
|
|
95
|
+
return masked === null ? undefined : masked;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Redact by key name and scrub token-shaped strings, except below a schema
|
|
99
|
+
* key, where only strings are scrubbed: a tool parameter named `api_key`
|
|
100
|
+
* declares what the tool accepts, and masking that node would leave a
|
|
101
|
+
* schema that no longer parses. Key order is kept because dumps are diffed
|
|
102
|
+
* against each other.
|
|
103
|
+
*/
|
|
104
|
+
function scrub(value) {
|
|
105
|
+
if (Array.isArray(value))
|
|
106
|
+
return value.map(scrub);
|
|
107
|
+
if (isRecord(value)) {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
110
|
+
const mask = maskFor(key);
|
|
111
|
+
if (schemaKeys.has(key))
|
|
112
|
+
out[key] = redactor.redactStrings(entry);
|
|
113
|
+
else if (mask !== undefined)
|
|
114
|
+
out[key] = mask;
|
|
115
|
+
else
|
|
116
|
+
out[key] = scrub(entry);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
return redactor.redactStrings(value);
|
|
121
|
+
}
|
|
122
|
+
function scrubBody(bodyText) {
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(bodyText);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Not JSON: no keys to judge, but token-shaped strings still go.
|
|
129
|
+
return {
|
|
130
|
+
text: redactor.redactStrings(bodyText),
|
|
131
|
+
parsed: undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const scrubbed = scrub(parsed);
|
|
135
|
+
const scrubbedText = JSON.stringify(scrubbed);
|
|
136
|
+
// Nothing redacted: keep the original bytes, whitespace included, so the
|
|
137
|
+
// dump is exactly what was sent.
|
|
138
|
+
const text = JSON.stringify(parsed) === scrubbedText ? bodyText : scrubbedText;
|
|
139
|
+
return { text, parsed: isRecord(scrubbed) ? scrubbed : undefined };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The newest earlier dump of this baseline key, read back from the dump
|
|
143
|
+
* directory. The in-memory baseline dies with the process, and the first
|
|
144
|
+
* request after a restart is the one most likely to break the prompt cache,
|
|
145
|
+
* so it is the one whose diff matters most. Reading the dumps themselves
|
|
146
|
+
* needs no extra state, and clearing the directory clears the baseline with
|
|
147
|
+
* it. Any failure is no baseline.
|
|
148
|
+
*/
|
|
149
|
+
async function recoverBaseline(dir, session, channel, baselineKey) {
|
|
150
|
+
const marker = `-${fileSegment(session, UNKNOWN_SESSION)}-${fileSegment(channel, 'channel')}`;
|
|
151
|
+
let names;
|
|
152
|
+
try {
|
|
153
|
+
names = await readdir(dir);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
// Names start with an ISO timestamp, so reverse lexicographic order is
|
|
159
|
+
// newest first. The metadata's key decides the match: a filename segment
|
|
160
|
+
// is truncated and sanitised, so two sessions can share one.
|
|
161
|
+
const candidates = names
|
|
162
|
+
.filter((name) => name.endsWith('.meta.json') && name.includes(marker))
|
|
163
|
+
.sort()
|
|
164
|
+
.reverse();
|
|
165
|
+
for (const name of candidates) {
|
|
166
|
+
try {
|
|
167
|
+
const metadata = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
168
|
+
if (metadata?.baselineKey !== baselineKey)
|
|
169
|
+
continue;
|
|
170
|
+
return await readFile(join(dir, name.replace(/\.meta\.json$/, '.body.json')), 'utf8');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// A file removed or half-written by another process: try the next.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
function remember(key, text) {
|
|
179
|
+
if (!previousBodies.has(key)) {
|
|
180
|
+
while (previousBodies.size >= PREVIOUS_BODY_LIMIT) {
|
|
181
|
+
const oldest = previousBodies.keys().next().value;
|
|
182
|
+
if (oldest === undefined)
|
|
183
|
+
break;
|
|
184
|
+
previousBodies.delete(oldest);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
previousBodies.set(key, text);
|
|
188
|
+
}
|
|
189
|
+
async function dump(input) {
|
|
190
|
+
if (!enabled)
|
|
191
|
+
return undefined;
|
|
192
|
+
counter++;
|
|
193
|
+
const session = input.session?.trim() || UNKNOWN_SESSION;
|
|
194
|
+
const dir = resolveDir();
|
|
195
|
+
const createdAt = new Date(now()).toISOString();
|
|
196
|
+
// The pid makes names unique across processes: each process starts its
|
|
197
|
+
// counter at zero, so two processes dumping the same session in the same
|
|
198
|
+
// millisecond into a shared directory would otherwise pick the same name.
|
|
199
|
+
const id = [
|
|
200
|
+
createdAt.replace(/[:.]/g, '-'),
|
|
201
|
+
String(pid),
|
|
202
|
+
String(counter).padStart(6, '0'),
|
|
203
|
+
fileSegment(session, UNKNOWN_SESSION),
|
|
204
|
+
fileSegment(input.channel, 'channel'),
|
|
205
|
+
...(input.phase ? [fileSegment(input.phase, 'phase')] : []),
|
|
206
|
+
].join('-');
|
|
207
|
+
const prefix = join(dir, id);
|
|
208
|
+
const files = {
|
|
209
|
+
body: `${prefix}.body.json`,
|
|
210
|
+
metadata: `${prefix}.meta.json`,
|
|
211
|
+
request: `${prefix}.request.json`,
|
|
212
|
+
};
|
|
213
|
+
// Hashed so the metadata identifies the baseline without spelling out the
|
|
214
|
+
// full session id.
|
|
215
|
+
const baselineKey = hashText(`${input.channel}\u0000${session}`);
|
|
216
|
+
try {
|
|
217
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
218
|
+
// mkdir's mode does not apply to a directory that already exists.
|
|
219
|
+
await chmod(dir, 0o700);
|
|
220
|
+
let previous = previousBodies.get(baselineKey);
|
|
221
|
+
let baselineSource = previous === undefined ? undefined : 'memory';
|
|
222
|
+
if (previous === undefined) {
|
|
223
|
+
previous = await recoverBaseline(dir, session, input.channel, baselineKey);
|
|
224
|
+
if (previous !== undefined)
|
|
225
|
+
baselineSource = 'disk';
|
|
226
|
+
}
|
|
227
|
+
const body = scrubBody(input.bodyText);
|
|
228
|
+
const metadata = {
|
|
229
|
+
id,
|
|
230
|
+
createdAt,
|
|
231
|
+
pid,
|
|
232
|
+
session: shortSession(session),
|
|
233
|
+
channel: input.channel,
|
|
234
|
+
...(input.phase ? { phase: input.phase } : {}),
|
|
235
|
+
accountId: input.accountId,
|
|
236
|
+
status: input.status,
|
|
237
|
+
error: input.error === undefined
|
|
238
|
+
? undefined
|
|
239
|
+
: redactor.redactStrings(input.error),
|
|
240
|
+
baselineKey,
|
|
241
|
+
bodyBytes: Buffer.byteLength(input.bodyText, 'utf8'),
|
|
242
|
+
bodyHash: hashText(input.bodyText),
|
|
243
|
+
// Diffed on the redacted text that lands in the body file, so offsets
|
|
244
|
+
// index the file an operator can open, and a baseline read back from
|
|
245
|
+
// disk (also redacted) compares like with like.
|
|
246
|
+
diff: diffDumpBodies(previous, body.text),
|
|
247
|
+
baselineSource,
|
|
248
|
+
body: body.parsed
|
|
249
|
+
? { parseable: true, ...options.summarize?.(body.parsed) }
|
|
250
|
+
: { parseable: false },
|
|
251
|
+
...(input.meta ? { extra: scrub(input.meta) } : {}),
|
|
252
|
+
files,
|
|
253
|
+
};
|
|
254
|
+
const request = scrub({
|
|
255
|
+
url: input.url,
|
|
256
|
+
method: input.method,
|
|
257
|
+
accountId: input.accountId,
|
|
258
|
+
headers: input.headers === undefined
|
|
259
|
+
? undefined
|
|
260
|
+
: Object.fromEntries(new Headers(input.headers).entries()),
|
|
261
|
+
});
|
|
262
|
+
// `wx`: a name collision fails loudly instead of overwriting a dump.
|
|
263
|
+
const write = (path, text) => writeFile(path, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
264
|
+
await Promise.all([
|
|
265
|
+
write(files.body, body.text),
|
|
266
|
+
write(files.metadata, `${JSON.stringify(metadata, null, 2)}\n`),
|
|
267
|
+
write(files.request, `${JSON.stringify(request, null, 2)}\n`),
|
|
268
|
+
]);
|
|
269
|
+
remember(baselineKey, body.text);
|
|
270
|
+
log?.debug('dumped request', {
|
|
271
|
+
id,
|
|
272
|
+
session: shortSession(session),
|
|
273
|
+
body: files.body,
|
|
274
|
+
});
|
|
275
|
+
return { id, files };
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
log?.warn('request dump failed', {
|
|
279
|
+
session: shortSession(session),
|
|
280
|
+
error: error instanceof Error ? error.message : String(error),
|
|
281
|
+
});
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
isEnabled: () => enabled,
|
|
287
|
+
setEnabled: (value) => {
|
|
288
|
+
enabled = value;
|
|
289
|
+
},
|
|
290
|
+
dump,
|
|
291
|
+
};
|
|
292
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|