@brunobracaioli/aibrain 0.4.0-beta.1 → 0.4.0-beta.3

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,845 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * AISAC Engineering pack for Claude Code (ADR-0047 wave E4b) and Codex (E4c),
5
+ * spec FR4/FR8.
6
+ *
7
+ * One request per launch fetches the account's pack; it doubles as the live
8
+ * license check. The pack is trusted instruction for the user's agent, so it
9
+ * is accepted only when it is signed by a pinned key, matches the requested
10
+ * locale and passes the FR3 rules again here. It is written into the launch's
11
+ * private directory and handed to Claude Code as a second `--plugin-dir`, or
12
+ * to the Codex app-server this launch owns (see codex-engineering.mjs).
13
+ * Any failure starts the session without the pack; nothing here can block a
14
+ * launch or treat a failure as licensed.
15
+ */
16
+
17
+ import { createHash, createPublicKey, verify } from 'node:crypto';
18
+ import { constants } from 'node:fs';
19
+ import {
20
+ lstat,
21
+ mkdir,
22
+ open,
23
+ readFile,
24
+ readdir,
25
+ rename,
26
+ rm,
27
+ stat,
28
+ unlink,
29
+ writeFile,
30
+ } from 'node:fs/promises';
31
+ import { dirname, join, resolve, sep } from 'node:path';
32
+ import {
33
+ isPurchaseUrl,
34
+ isRecord,
35
+ isTimeout,
36
+ readBoundedText,
37
+ unavailableReasonFor,
38
+ } from './engineering.mjs';
39
+ import { atomicJson } from './state.mjs';
40
+ import {
41
+ AGENT_NAME,
42
+ CODEX_AGENTS_DIRECTORY,
43
+ MAX_AGENT_DESCRIPTION_LENGTH,
44
+ tomlBasicString,
45
+ validateCodexEngineering,
46
+ } from './codex-engineering.mjs';
47
+
48
+ const PACK_PATH = '/engineering/v1/pack';
49
+ export const PACK_TIMEOUT_MS = 10_000;
50
+ /** Vercel caps a function response at 4.5 MB; the pack itself is at most 2 MiB. */
51
+ const MAX_PACK_BODY_BYTES = 5 * 1024 * 1024;
52
+ const MAX_ERROR_BODY_BYTES = 16 * 1024;
53
+ const PACK_SCHEMA_VERSION = 1;
54
+ export const PACK_LOCALES = /** @type {const} */ (['pt-BR', 'en']);
55
+ const DEFAULT_LOCALE = 'pt-BR';
56
+ const DEFAULT_PURCHASE_URL = 'https://aisac.community/';
57
+
58
+ // FR3 bounds, mirrored from the server; served files carry one extra
59
+ // watermark line in their front matter.
60
+ const MAX_PACK_FILES = 200;
61
+ const MAX_PACK_FILE_BYTES = 64 * 1024;
62
+ const MAX_PACK_TOTAL_BYTES = 2 * 1024 * 1024;
63
+ const WATERMARK_ALLOWANCE_BYTES = 64;
64
+ const MAX_PACK_PATH_LENGTH = 256;
65
+ const PATH_CHARACTERS = /^[A-Za-z0-9._/-]+$/u;
66
+ const MARKDOWN_EXTENSION = '.md';
67
+ const PACK_VERSION = /^sha256-[0-9a-f]{12}$/u;
68
+ const HEX_16 = /^[0-9a-f]{16}$/u;
69
+ const SHA256_HEX = /^[0-9a-f]{64}$/u;
70
+ const BASE64 = /^[A-Za-z0-9+/]{86}==$/u;
71
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
72
+ const ED25519_SIGNATURE_BYTES = 64;
73
+ const ED25519_RAW_KEY_BYTES = 32;
74
+ const KEY_ID_HEX_LENGTH = 16;
75
+ const MAX_PINNED_KEYS = 4;
76
+ const DYNAMIC_SHELL_MARKER = '!`';
77
+ const FRONT_MATTER_FENCE = '---\n';
78
+ const FRONT_MATTER_CLOSE = '\n---\n';
79
+ const FRONT_MATTER_TRAILING_CLOSE = '\n---';
80
+ const FRONT_MATTER_KEY = /^([A-Za-z0-9_-]+)[ \t]*:/u;
81
+ const FORBIDDEN_FRONT_MATTER_KEYS = new Set([
82
+ 'allowed-tools',
83
+ 'allowed_tools',
84
+ 'hooks',
85
+ 'mcpservers',
86
+ 'mcp-servers',
87
+ 'mcp_servers',
88
+ 'permissionmode',
89
+ 'permission-mode',
90
+ 'permission_mode',
91
+ 'permissions',
92
+ ]);
93
+
94
+ const PLUGIN_NAME = 'aisac-engineering';
95
+ const PLUGIN_LICENSE = 'LicenseRef-AISAC-Engineering';
96
+ const PACK_DIRECTORY = 'engineering';
97
+ const OUTPUT_STYLES_PREFIX = 'output-styles/';
98
+ const SKILLS_DIRECTORY = 'skills';
99
+ const AGENT_FILE = /^agents\/([a-z][a-z0-9-]{0,63})\.md$/u;
100
+ const WATERMARK_KEY = 'aisac_watermark';
101
+ const WATERMARK_VALUE = /^[0-9a-f]{16}$/u;
102
+ export const PUBLIC_KEY_FILE = 'engineering-pack-public-key.json';
103
+ const LOCALE_FILE = 'engineering-locale.json';
104
+ const LAST_LAUNCH_FILE = 'engineering-last-launch.json';
105
+ export const LAUNCH_MARKER_FILE = 'launch.json';
106
+ const MAX_SMALL_STATE_BYTES = 4 * 1024;
107
+ const LAUNCH_DIRECTORY = /^(?:claude|codex)-[A-Za-z0-9]{6}$/u;
108
+ const SAFE_REASON = /^[a-z][a-z0-9_]{0,63}$/u;
109
+
110
+ /** @typedef {(typeof PACK_LOCALES)[number]} PackLocale */
111
+ /** @typedef {{ path: string, sha256: string, content: string }} PackFile */
112
+ /**
113
+ * @typedef {{
114
+ * packVersion: string,
115
+ * locale: PackLocale,
116
+ * issuedAt: string,
117
+ * keyId: string,
118
+ * files: PackFile[]
119
+ * }} VerifiedPack
120
+ */
121
+ /** @typedef {{ keyId: string, key: import('node:crypto').KeyObject }} PinnedKey */
122
+ /**
123
+ * @typedef {{ outcome: 'loaded', pack: VerifiedPack }
124
+ * | { outcome: 'inactive', purchaseUrl: string }
125
+ * | { outcome: 'not_offered' }
126
+ * | { outcome: 'unavailable', reason: string }
127
+ * | { outcome: 'rejected', reason: string }} PackOutcome
128
+ */
129
+ /** @typedef {'claude' | 'codex'} PackClient */
130
+ /**
131
+ * @typedef {{
132
+ * schemaVersion: 1,
133
+ * at: string,
134
+ * client: PackClient,
135
+ * state: PackOutcome['outcome'],
136
+ * reason: string | null,
137
+ * packVersion: string | null,
138
+ * locale: PackLocale
139
+ * }} LastLaunch
140
+ */
141
+
142
+ /** @param {unknown} value @returns {value is PackLocale} */
143
+ export function isPackLocale(value) {
144
+ return value === 'pt-BR' || value === 'en';
145
+ }
146
+
147
+ /**
148
+ * The stored choice first, then the OS locale, then pt-BR (spec FR3).
149
+ * @param {string | null} stored
150
+ * @param {NodeJS.ProcessEnv} [env]
151
+ * @returns {PackLocale}
152
+ */
153
+ export function resolvePackLocale(stored, env = process.env) {
154
+ if (isPackLocale(stored)) return stored;
155
+ const candidates = [
156
+ env.LC_ALL,
157
+ env.LC_MESSAGES,
158
+ env.LANG,
159
+ Intl.DateTimeFormat().resolvedOptions().locale,
160
+ ];
161
+ for (const candidate of candidates) {
162
+ if (typeof candidate !== 'string') continue;
163
+ const value = candidate.trim().toLowerCase();
164
+ if (value === '' || value === 'c' || value === 'posix' || value.startsWith('c.')) continue;
165
+ if (value.startsWith('pt')) return 'pt-BR';
166
+ if (value.startsWith('en')) return 'en';
167
+ return DEFAULT_LOCALE;
168
+ }
169
+ return DEFAULT_LOCALE;
170
+ }
171
+
172
+ /**
173
+ * Read a small owned JSON state file; null when it is missing or not a plain,
174
+ * owned, private file.
175
+ * @param {string} path
176
+ * @returns {Promise<unknown>}
177
+ */
178
+ async function readSmallStateJson(path) {
179
+ let handle;
180
+ try {
181
+ const link = await lstat(path);
182
+ if (!link.isFile() || link.size > MAX_SMALL_STATE_BYTES) return null;
183
+ if (typeof process.getuid === 'function' && link.uid !== process.getuid()) return null;
184
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
185
+ return JSON.parse(await handle.readFile('utf8'));
186
+ } catch {
187
+ return null;
188
+ } finally {
189
+ await handle?.close().catch(() => undefined);
190
+ }
191
+ }
192
+
193
+ /** @param {string} root @returns {Promise<PackLocale | null>} */
194
+ export async function readStoredLocale(root) {
195
+ const value = await readSmallStateJson(join(root, LOCALE_FILE));
196
+ return isRecord(value) && value.schemaVersion === 1 && isPackLocale(value.locale)
197
+ ? value.locale
198
+ : null;
199
+ }
200
+
201
+ /** @param {string} root @param {PackLocale} locale */
202
+ export async function writeStoredLocale(root, locale) {
203
+ if (!isPackLocale(locale)) throw new Error('invalid_lang');
204
+ await atomicJson(join(root, LOCALE_FILE), { schemaVersion: 1, locale });
205
+ }
206
+
207
+ /** @param {string} root @returns {Promise<LastLaunch | null>} */
208
+ export async function readLastLaunch(root) {
209
+ const value = await readSmallStateJson(join(root, LAST_LAUNCH_FILE));
210
+ if (
211
+ !isRecord(value) ||
212
+ value.schemaVersion !== 1 ||
213
+ typeof value.at !== 'string' ||
214
+ !ISO_INSTANT.test(value.at) ||
215
+ (value.client !== 'claude' && value.client !== 'codex') ||
216
+ !['loaded', 'inactive', 'not_offered', 'unavailable', 'rejected'].includes(
217
+ /** @type {string} */ (value.state),
218
+ ) ||
219
+ (value.reason !== null &&
220
+ (typeof value.reason !== 'string' || !SAFE_REASON.test(value.reason))) ||
221
+ (value.packVersion !== null &&
222
+ (typeof value.packVersion !== 'string' || !PACK_VERSION.test(value.packVersion))) ||
223
+ !isPackLocale(value.locale)
224
+ )
225
+ return null;
226
+ return /** @type {LastLaunch} */ (value);
227
+ }
228
+
229
+ /** @param {string} endpoint @param {PackLocale} locale */
230
+ export function engineeringPackUrl(endpoint, locale) {
231
+ const url = new URL(PACK_PATH, endpoint);
232
+ url.searchParams.set('locale', locale);
233
+ return url.toString();
234
+ }
235
+
236
+ /** @param {Buffer} raw */
237
+ function keyIdOfRaw(raw) {
238
+ return createHash('sha256').update(raw).digest('hex').slice(0, KEY_ID_HEX_LENGTH);
239
+ }
240
+
241
+ /**
242
+ * Parse the pinned public keys shipped with the client release.
243
+ * @param {string} text
244
+ * @returns {PinnedKey[]}
245
+ */
246
+ export function parsePinnedKeys(text) {
247
+ const document = JSON.parse(text);
248
+ if (
249
+ !isRecord(document) ||
250
+ document.schema_version !== 1 ||
251
+ !Array.isArray(document.keys) ||
252
+ document.keys.length < 1 ||
253
+ document.keys.length > MAX_PINNED_KEYS
254
+ )
255
+ throw new Error('pinned_keys_invalid');
256
+ return document.keys.map((entry) => {
257
+ if (!isRecord(entry) || typeof entry.key_id !== 'string' || typeof entry.x !== 'string')
258
+ throw new Error('pinned_keys_invalid');
259
+ const raw = Buffer.from(entry.x, 'base64url');
260
+ if (raw.length !== ED25519_RAW_KEY_BYTES || raw.toString('base64url') !== entry.x)
261
+ throw new Error('pinned_keys_invalid');
262
+ if (keyIdOfRaw(raw) !== entry.key_id) throw new Error('pinned_key_id_mismatch');
263
+ const key = createPublicKey({
264
+ key: { kty: 'OKP', crv: 'Ed25519', x: entry.x },
265
+ format: 'jwk',
266
+ });
267
+ return { keyId: entry.key_id, key };
268
+ });
269
+ }
270
+
271
+ /**
272
+ * The keys ship inside the installed package, which a global install may own
273
+ * as root, so only the size is bounded here (unlike private state files).
274
+ * @param {string} packageRoot
275
+ * @returns {Promise<PinnedKey[]>}
276
+ */
277
+ export async function loadPinnedKeys(packageRoot) {
278
+ const path = join(packageRoot, PUBLIC_KEY_FILE);
279
+ const entry = await stat(path);
280
+ if (!entry.isFile() || entry.size > MAX_SMALL_STATE_BYTES) throw new Error('pinned_keys_invalid');
281
+ return parsePinnedKeys(await readFile(path, 'utf8'));
282
+ }
283
+
284
+ /**
285
+ * Canonical JSON, identical to the server's: object keys sorted by UTF-16
286
+ * code unit, no whitespace, JSON.stringify string escaping, safe integers.
287
+ * @param {unknown} value
288
+ * @returns {string}
289
+ */
290
+ export function canonicalJson(value) {
291
+ if (typeof value === 'number') {
292
+ if (!Number.isSafeInteger(value)) throw new Error('canonical_json_number');
293
+ return JSON.stringify(value);
294
+ }
295
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
296
+ return JSON.stringify(value);
297
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
298
+ if (isRecord(value)) {
299
+ const keys = Object.keys(value).sort();
300
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
301
+ }
302
+ throw new Error('canonical_json_value');
303
+ }
304
+
305
+ /** Top-level front-matter keys, lower-cased (same parser as the server). @param {string} content */
306
+ export function frontMatterKeys(content) {
307
+ if (!content.startsWith(FRONT_MATTER_FENCE)) return [];
308
+ const searchFrom = FRONT_MATTER_FENCE.length - 1;
309
+ let end = content.indexOf(FRONT_MATTER_CLOSE, searchFrom);
310
+ if (end === -1 && content.endsWith(FRONT_MATTER_TRAILING_CLOSE))
311
+ end = content.length - FRONT_MATTER_TRAILING_CLOSE.length;
312
+ if (end < searchFrom) return [];
313
+ return content
314
+ .slice(FRONT_MATTER_FENCE.length, end + 1)
315
+ .split('\n')
316
+ .flatMap((line) => {
317
+ const key = FRONT_MATTER_KEY.exec(line)?.[1];
318
+ return key === undefined ? [] : [key.toLowerCase()];
319
+ });
320
+ }
321
+
322
+ /** Relative, `[A-Za-z0-9._/-]`, `.md`, no empty, dot or hidden segment. @param {unknown} path */
323
+ export function isValidPackPath(path) {
324
+ if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PACK_PATH_LENGTH)
325
+ return false;
326
+ if (!PATH_CHARACTERS.test(path) || !path.endsWith(MARKDOWN_EXTENSION)) return false;
327
+ return path.split('/').every((segment) => segment.length > 0 && !segment.startsWith('.'));
328
+ }
329
+
330
+ /** @param {string} reason @returns {{ ok: false, reason: string }} */
331
+ function refused(reason) {
332
+ return { ok: false, reason };
333
+ }
334
+
335
+ /**
336
+ * FR3 rules re-applied on the client, then each file's hash.
337
+ * @param {unknown} files
338
+ * @returns {{ ok: true, files: PackFile[] } | { ok: false, reason: string }}
339
+ */
340
+ function checkFiles(files) {
341
+ if (!Array.isArray(files) || files.length === 0) return refused('invalid_response');
342
+ if (files.length > MAX_PACK_FILES) return refused('bounds_exceeded');
343
+ const seen = new Set();
344
+ /** @type {PackFile[]} */
345
+ const accepted = [];
346
+ let total = 0;
347
+ for (const file of files) {
348
+ if (!isRecord(file) || typeof file.content !== 'string' || typeof file.sha256 !== 'string')
349
+ return refused('invalid_response');
350
+ if (!isValidPackPath(file.path)) return refused('invalid_path');
351
+ const path = /** @type {string} */ (file.path);
352
+ if (seen.has(path)) return refused('invalid_path');
353
+ seen.add(path);
354
+ if (!SHA256_HEX.test(file.sha256)) return refused('invalid_response');
355
+ const bytes = Buffer.byteLength(file.content, 'utf8');
356
+ if (bytes > MAX_PACK_FILE_BYTES + WATERMARK_ALLOWANCE_BYTES) return refused('bounds_exceeded');
357
+ total += bytes;
358
+ if (file.content.includes('\r')) return refused('forbidden_content');
359
+ if (frontMatterKeys(file.content).some((key) => FORBIDDEN_FRONT_MATTER_KEYS.has(key)))
360
+ return refused('forbidden_content');
361
+ if (file.content.includes(DYNAMIC_SHELL_MARKER)) return refused('forbidden_content');
362
+ const digest = createHash('sha256').update(file.content, 'utf8').digest('hex');
363
+ if (digest !== file.sha256) return refused('file_hash_mismatch');
364
+ accepted.push({ path, sha256: file.sha256, content: file.content });
365
+ }
366
+ if (total > MAX_PACK_TOTAL_BYTES + MAX_PACK_FILES * WATERMARK_ALLOWANCE_BYTES)
367
+ return refused('bounds_exceeded');
368
+ return { ok: true, files: accepted };
369
+ }
370
+
371
+ /**
372
+ * Verify a `200` pack body. Any failure rejects the whole pack.
373
+ * @param {string} text
374
+ * @param {readonly PinnedKey[]} keys
375
+ * @param {PackLocale} expectedLocale
376
+ * @returns {{ ok: true, pack: VerifiedPack } | { ok: false, reason: string }}
377
+ */
378
+ export function verifyPack(text, keys, expectedLocale) {
379
+ let body;
380
+ try {
381
+ body = JSON.parse(text);
382
+ } catch {
383
+ return refused('invalid_response');
384
+ }
385
+ if (!isRecord(body)) return refused('invalid_response');
386
+ if (body.schema_version !== PACK_SCHEMA_VERSION)
387
+ return refused(
388
+ Number.isSafeInteger(body.schema_version) &&
389
+ /** @type {number} */ (body.schema_version) > PACK_SCHEMA_VERSION
390
+ ? 'client_outdated'
391
+ : 'invalid_response',
392
+ );
393
+ const { signature, ...unsigned } = body;
394
+ if (typeof signature !== 'string' || !BASE64.test(signature)) return refused('invalid_response');
395
+ if (typeof body.key_id !== 'string' || !HEX_16.test(body.key_id))
396
+ return refused('invalid_response');
397
+ const keyId = body.key_id;
398
+ const pinned = keys.find((candidate) => candidate.keyId === keyId);
399
+ if (pinned === undefined) return refused('unknown_key');
400
+ let message;
401
+ try {
402
+ message = canonicalJson(unsigned);
403
+ } catch {
404
+ return refused('invalid_response');
405
+ }
406
+ const signatureBytes = Buffer.from(signature, 'base64');
407
+ if (signatureBytes.length !== ED25519_SIGNATURE_BYTES) return refused('invalid_response');
408
+ let valid;
409
+ try {
410
+ valid = verify(null, Buffer.from(message, 'utf8'), pinned.key, signatureBytes);
411
+ } catch {
412
+ valid = false;
413
+ }
414
+ if (!valid) return refused('bad_signature');
415
+ if (body.locale !== expectedLocale) return refused('locale_mismatch');
416
+ if (
417
+ typeof body.pack_version !== 'string' ||
418
+ !PACK_VERSION.test(body.pack_version) ||
419
+ typeof body.issued_at !== 'string' ||
420
+ !ISO_INSTANT.test(body.issued_at) ||
421
+ typeof body.watermark !== 'string' ||
422
+ !HEX_16.test(body.watermark)
423
+ )
424
+ return refused('invalid_response');
425
+ const checked = checkFiles(body.files);
426
+ if (!checked.ok) return checked;
427
+ return {
428
+ ok: true,
429
+ pack: {
430
+ packVersion: body.pack_version,
431
+ locale: expectedLocale,
432
+ issuedAt: body.issued_at,
433
+ keyId,
434
+ files: checked.files,
435
+ },
436
+ };
437
+ }
438
+
439
+ /** @param {string} text */
440
+ function errorCodeOf(text) {
441
+ try {
442
+ const value = JSON.parse(text);
443
+ return isRecord(value) ? value : null;
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Ask the server for the pack. The answer is the live license decision.
451
+ * @param {string} endpoint
452
+ * @param {string} token
453
+ * @param {PackLocale} locale
454
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
455
+ * @returns {Promise<PackOutcome | { outcome: 'received', text: string }>}
456
+ */
457
+ export async function fetchPack(endpoint, token, locale, options = {}) {
458
+ const { fetchImpl = fetch, timeoutMs = PACK_TIMEOUT_MS } = options;
459
+ let url;
460
+ try {
461
+ url = engineeringPackUrl(endpoint, locale);
462
+ } catch {
463
+ return { outcome: 'unavailable', reason: 'invalid_endpoint' };
464
+ }
465
+ let response;
466
+ try {
467
+ response = await fetchImpl(url, {
468
+ method: 'GET',
469
+ redirect: 'error',
470
+ signal: AbortSignal.timeout(timeoutMs),
471
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
472
+ });
473
+ } catch (error) {
474
+ return { outcome: 'unavailable', reason: isTimeout(error) ? 'timeout' : 'unreachable' };
475
+ }
476
+ if (response.status === 404) {
477
+ await response.body?.cancel().catch(() => undefined);
478
+ return { outcome: 'not_offered' };
479
+ }
480
+ let text;
481
+ try {
482
+ text = await readBoundedText(
483
+ response,
484
+ response.status === 200 ? MAX_PACK_BODY_BYTES : MAX_ERROR_BODY_BYTES,
485
+ );
486
+ } catch (error) {
487
+ return {
488
+ outcome: 'unavailable',
489
+ reason: isTimeout(error) ? 'timeout' : 'invalid_response',
490
+ };
491
+ }
492
+ if (response.status === 200) return { outcome: 'received', text };
493
+ const error = errorCodeOf(text);
494
+ if (response.status === 403 && error?.error === 'license_inactive') {
495
+ return {
496
+ outcome: 'inactive',
497
+ purchaseUrl: isPurchaseUrl(error.purchase_url) ? error.purchase_url : DEFAULT_PURCHASE_URL,
498
+ };
499
+ }
500
+ if (response.status === 503 && error?.error === 'pack_unavailable')
501
+ return { outcome: 'unavailable', reason: 'pack_unavailable' };
502
+ if (response.status === 403) return { outcome: 'unavailable', reason: 'unexpected_status' };
503
+ return { outcome: 'unavailable', reason: unavailableReasonFor(response.status) };
504
+ }
505
+
506
+ /**
507
+ * @param {{ endpoint: string, token: string, locale: PackLocale, packageRoot: string,
508
+ * fetchImpl?: typeof fetch, timeoutMs?: number }} input
509
+ * @returns {Promise<PackOutcome>}
510
+ */
511
+ export async function obtainPack(input) {
512
+ let keys;
513
+ try {
514
+ keys = await loadPinnedKeys(input.packageRoot);
515
+ } catch {
516
+ return { outcome: 'rejected', reason: 'pinned_keys_invalid' };
517
+ }
518
+ const answer = await fetchPack(input.endpoint, input.token, input.locale, {
519
+ ...(input.fetchImpl === undefined ? {} : { fetchImpl: input.fetchImpl }),
520
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
521
+ });
522
+ if (answer.outcome !== 'received') return answer;
523
+ const verified = verifyPack(answer.text, keys, input.locale);
524
+ return verified.ok
525
+ ? { outcome: 'loaded', pack: verified.pack }
526
+ : { outcome: 'rejected', reason: verified.reason };
527
+ }
528
+
529
+ /** @type {Readonly<Record<PackLocale, string>>} */
530
+ const PLUGIN_DESCRIPTION = {
531
+ 'pt-BR': 'Método AISAC Engineering, licenciado para a sua conta. Não redistribua.',
532
+ en: 'AISAC Engineering method, licensed to your account. Do not redistribute.',
533
+ };
534
+
535
+ /**
536
+ * Write the pack files into `engineering/` of the launch's private directory:
537
+ * `0700` directories, `0600` files, exclusive creation, contained paths.
538
+ * @param {VerifiedPack} pack
539
+ * @param {string} launchDirectory
540
+ * @returns {Promise<string>} the pack root
541
+ */
542
+ async function writePackFiles(pack, launchDirectory) {
543
+ const base = resolve(launchDirectory, PACK_DIRECTORY);
544
+ await mkdir(base, { mode: 0o700 });
545
+ for (const file of pack.files) {
546
+ if (!isValidPackPath(file.path)) throw new Error('pack_path_invalid');
547
+ const target = resolve(base, ...file.path.split('/'));
548
+ if (!target.startsWith(`${base}${sep}`)) throw new Error('pack_path_escape');
549
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
550
+ await writeFile(target, file.content, { mode: 0o600, flag: 'wx' });
551
+ }
552
+ return base;
553
+ }
554
+
555
+ /**
556
+ * Write a verified pack as a Claude Code plugin inside the launch's private
557
+ * directory and return the plugin root. The manifest is the client's own.
558
+ * @param {VerifiedPack} pack
559
+ * @param {string} launchDirectory
560
+ */
561
+ export async function materializePack(pack, launchDirectory) {
562
+ const base = await writePackFiles(pack, launchDirectory);
563
+ const manifest = {
564
+ name: PLUGIN_NAME,
565
+ version: `0.0.0-${pack.packVersion}`,
566
+ description: PLUGIN_DESCRIPTION[pack.locale],
567
+ author: { name: 'AISAC' },
568
+ license: PLUGIN_LICENSE,
569
+ ...(pack.files.some((file) => file.path.startsWith(OUTPUT_STYLES_PREFIX))
570
+ ? { outputStyles: `./${OUTPUT_STYLES_PREFIX}` }
571
+ : {}),
572
+ };
573
+ await mkdir(join(base, '.claude-plugin'), { mode: 0o700 });
574
+ await writeFile(
575
+ join(base, '.claude-plugin', 'plugin.json'),
576
+ `${JSON.stringify(manifest, null, 2)}\n`,
577
+ { mode: 0o600, flag: 'wx' },
578
+ );
579
+ return base;
580
+ }
581
+
582
+ /**
583
+ * Front-matter values (one `key: value` per line, lower-cased keys, matching
584
+ * outer quotes removed) and the body after it; null without front matter.
585
+ * @param {string} content
586
+ * @returns {{ values: Map<string, string>, body: string } | null}
587
+ */
588
+ export function splitFrontMatter(content) {
589
+ if (!content.startsWith(FRONT_MATTER_FENCE)) return null;
590
+ const end = content.indexOf(FRONT_MATTER_CLOSE, FRONT_MATTER_FENCE.length - 1);
591
+ if (end === -1) return null;
592
+ /** @type {Map<string, string>} */
593
+ const values = new Map();
594
+ for (const line of content.slice(FRONT_MATTER_FENCE.length, end + 1).split('\n')) {
595
+ const key = FRONT_MATTER_KEY.exec(line)?.[1];
596
+ if (key === undefined) continue;
597
+ let value = line.slice(line.indexOf(':') + 1).trim();
598
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0])
599
+ value = value.slice(1, -1);
600
+ values.set(key.toLowerCase(), value);
601
+ }
602
+ return { values, body: content.slice(end + FRONT_MATTER_CLOSE.length).replace(/^\n+/u, '') };
603
+ }
604
+
605
+ /**
606
+ * A Codex agent role file from a pack agent (`agents/<name>.md`): the front
607
+ * matter's name and description, the body as developer instructions, and the
608
+ * origin mark kept as a comment. Null when the agent cannot be a role.
609
+ * @param {PackFile} file
610
+ * @returns {{ name: string, description: string, toml: string } | null}
611
+ */
612
+ export function codexRoleOf(file) {
613
+ const fileName = AGENT_FILE.exec(file.path)?.[1];
614
+ const parsed = splitFrontMatter(file.content);
615
+ if (fileName === undefined || parsed === null) return null;
616
+ const name = parsed.values.get('name');
617
+ const description = parsed.values.get('description');
618
+ if (name !== fileName || !AGENT_NAME.test(name)) return null;
619
+ if (
620
+ description === undefined ||
621
+ description.length === 0 ||
622
+ description.length > MAX_AGENT_DESCRIPTION_LENGTH ||
623
+ description === '>' ||
624
+ description === '|' ||
625
+ parsed.body.trim().length === 0
626
+ )
627
+ return null;
628
+ const watermark = parsed.values.get(WATERMARK_KEY);
629
+ const lines = [
630
+ '# AISAC Engineering. Licensed to your account; do not redistribute.',
631
+ ...(watermark !== undefined && WATERMARK_VALUE.test(watermark)
632
+ ? [`# ${WATERMARK_KEY}: ${watermark}`]
633
+ : []),
634
+ `name = ${tomlBasicString(name)}`,
635
+ `description = ${tomlBasicString(description)}`,
636
+ `developer_instructions = ${tomlBasicString(parsed.body)}`,
637
+ ];
638
+ return { name, description, toml: `${lines.join('\n')}\n` };
639
+ }
640
+
641
+ /**
642
+ * Write a verified pack for the Codex app-server this launch owns: the files
643
+ * as for Claude (without the Claude manifest), one role file per agent in
644
+ * `codex-agents/`, and the description the monitor turns into session
645
+ * arguments. Output styles have no Codex equivalent and stay unused.
646
+ * @param {VerifiedPack} pack
647
+ * @param {string} launchDirectory
648
+ * @returns {Promise<import('./codex-engineering.mjs').CodexEngineering>}
649
+ */
650
+ export async function materializeCodexPack(pack, launchDirectory) {
651
+ const base = await writePackFiles(pack, launchDirectory);
652
+ const skillsRoot = join(base, SKILLS_DIRECTORY);
653
+ await mkdir(skillsRoot, { recursive: true, mode: 0o700 });
654
+ const rolesDirectory = join(base, CODEX_AGENTS_DIRECTORY);
655
+ await mkdir(rolesDirectory, { mode: 0o700 });
656
+ /** @type {import('./codex-engineering.mjs').CodexAgentRole[]} */
657
+ const agents = [];
658
+ for (const file of pack.files) {
659
+ const role = codexRoleOf(file);
660
+ if (role === null) continue;
661
+ const configFile = join(rolesDirectory, `${role.name}.toml`);
662
+ await writeFile(configFile, role.toml, { mode: 0o600, flag: 'wx' });
663
+ agents.push({ name: role.name, description: role.description, configFile });
664
+ }
665
+ return validateCodexEngineering({ schemaVersion: 1, skillsRoot, agents });
666
+ }
667
+
668
+ /**
669
+ * One line for the launch, in the pack locale; null when the capability is
670
+ * not offered, so memory-only users see no change.
671
+ * @param {PackOutcome} outcome
672
+ * @param {PackLocale} locale
673
+ * @returns {string | null}
674
+ */
675
+ export function packNotice(outcome, locale) {
676
+ const pt = locale === 'pt-BR';
677
+ const without = pt ? 'a sessão começa sem o método.' : 'the session starts without the method.';
678
+ switch (outcome.outcome) {
679
+ case 'not_offered':
680
+ return null;
681
+ case 'loaded':
682
+ return pt
683
+ ? `AISAC Engineering: método carregado (${outcome.pack.packVersion}, ${locale}).`
684
+ : `AISAC Engineering: method loaded (${outcome.pack.packVersion}, ${locale}).`;
685
+ case 'inactive':
686
+ return pt
687
+ ? `AISAC Engineering: licença inativa — ${outcome.purchaseUrl}`
688
+ : `AISAC Engineering: license inactive — ${outcome.purchaseUrl}`;
689
+ case 'unavailable':
690
+ return pt
691
+ ? `AISAC Engineering: indisponível (${outcome.reason}); ${without}`
692
+ : `AISAC Engineering: unavailable (${outcome.reason}); ${without}`;
693
+ case 'rejected':
694
+ if (outcome.reason === 'client_outdated')
695
+ return pt
696
+ ? `AISAC Engineering: atualize o aisac (npm install -g @aisac/cli@beta); ${without}`
697
+ : `AISAC Engineering: update aisac (npm install -g @aisac/cli@beta); ${without}`;
698
+ return pt
699
+ ? `AISAC Engineering: pacote recusado (${outcome.reason}); ${without}`
700
+ : `AISAC Engineering: pack rejected (${outcome.reason}); ${without}`;
701
+ default:
702
+ return null;
703
+ }
704
+ }
705
+
706
+ /**
707
+ * @param {PackOutcome} outcome
708
+ * @param {PackLocale} locale
709
+ * @param {PackClient} client
710
+ * @returns {LastLaunch}
711
+ */
712
+ function lastLaunchOf(outcome, locale, client) {
713
+ return {
714
+ schemaVersion: 1,
715
+ at: new Date().toISOString(),
716
+ client,
717
+ state: outcome.outcome,
718
+ reason:
719
+ outcome.outcome === 'unavailable' || outcome.outcome === 'rejected' ? outcome.reason : null,
720
+ packVersion: outcome.outcome === 'loaded' ? outcome.pack.packVersion : null,
721
+ locale,
722
+ };
723
+ }
724
+
725
+ /**
726
+ * Fetch, verify and write the pack for one Claude Code or Codex launch. Never
727
+ * throws. `hostSupported: false` (a Codex release older than the verified
728
+ * mechanism) still checks the license but loads nothing.
729
+ * @param {{ endpoint: string, token: string, root: string, directory: string,
730
+ * packageRoot: string, client?: PackClient, hostSupported?: boolean,
731
+ * env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, timeoutMs?: number }} input
732
+ * @returns {Promise<{ pluginRoot: string | null,
733
+ * codex: import('./codex-engineering.mjs').CodexEngineering | null, line: string | null }>}
734
+ */
735
+ export async function prepareEngineeringPack(input) {
736
+ const client = input.client ?? 'claude';
737
+ const locale = resolvePackLocale(await readStoredLocale(input.root), input.env);
738
+ /** @type {PackOutcome} */
739
+ let outcome;
740
+ try {
741
+ outcome = await obtainPack({
742
+ endpoint: input.endpoint,
743
+ token: input.token,
744
+ locale,
745
+ packageRoot: input.packageRoot,
746
+ ...(input.fetchImpl === undefined ? {} : { fetchImpl: input.fetchImpl }),
747
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
748
+ });
749
+ } catch {
750
+ outcome = { outcome: 'unavailable', reason: 'client_error' };
751
+ }
752
+ if (outcome.outcome === 'loaded' && input.hostSupported === false)
753
+ outcome = { outcome: 'unavailable', reason: `${client}_outdated` };
754
+ let pluginRoot = null;
755
+ /** @type {import('./codex-engineering.mjs').CodexEngineering | null} */
756
+ let codex = null;
757
+ if (outcome.outcome === 'loaded') {
758
+ try {
759
+ if (client === 'codex') codex = await materializeCodexPack(outcome.pack, input.directory);
760
+ else pluginRoot = await materializePack(outcome.pack, input.directory);
761
+ } catch {
762
+ codex = null;
763
+ await rm(join(input.directory, PACK_DIRECTORY), { recursive: true, force: true }).catch(
764
+ () => undefined,
765
+ );
766
+ outcome = { outcome: 'rejected', reason: 'materialize_failed' };
767
+ }
768
+ }
769
+ await atomicJson(join(input.root, LAST_LAUNCH_FILE), lastLaunchOf(outcome, locale, client)).catch(
770
+ () => undefined,
771
+ );
772
+ return { pluginRoot, codex, line: packNotice(outcome, locale) };
773
+ }
774
+
775
+ /**
776
+ * Record who owns a launch directory, so a later launch can sweep it once
777
+ * both processes are gone (spec FR4 step 7).
778
+ * @param {string} directory
779
+ * @param {{ launcherPid: number, childPid: number | null }} owners
780
+ */
781
+ export async function writeLaunchMarker(directory, owners) {
782
+ const path = join(directory, LAUNCH_MARKER_FILE);
783
+ const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
784
+ try {
785
+ await writeFile(
786
+ temp,
787
+ `${JSON.stringify({ schemaVersion: 1, ...owners, startedAt: new Date().toISOString() })}\n`,
788
+ { mode: 0o600, flag: 'wx' },
789
+ );
790
+ await rename(temp, path);
791
+ } catch (error) {
792
+ await unlink(temp).catch(() => undefined);
793
+ throw error;
794
+ }
795
+ }
796
+
797
+ /** @param {number} pid */
798
+ export function processAlive(pid) {
799
+ try {
800
+ process.kill(pid, 0);
801
+ return true;
802
+ } catch (error) {
803
+ return isRecord(error) && error.code === 'EPERM';
804
+ }
805
+ }
806
+
807
+ /** @param {unknown} value */
808
+ function isPid(value) {
809
+ return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0;
810
+ }
811
+
812
+ /**
813
+ * Remove launch directories left by crashed launches: only directories with
814
+ * a marker whose launcher and child are both gone. Never throws.
815
+ * @param {string} root
816
+ * @param {(pid: number) => boolean} [alive]
817
+ * @returns {Promise<number>} directories removed
818
+ */
819
+ export async function sweepStaleLaunches(root, alive = processAlive) {
820
+ const runtime = join(root, 'runtime');
821
+ let entries;
822
+ try {
823
+ entries = await readdir(runtime, { withFileTypes: true });
824
+ } catch {
825
+ return 0;
826
+ }
827
+ let removed = 0;
828
+ for (const entry of entries) {
829
+ if (!entry.isDirectory() || !LAUNCH_DIRECTORY.test(entry.name)) continue;
830
+ const directory = join(runtime, entry.name);
831
+ const marker = await readSmallStateJson(join(directory, LAUNCH_MARKER_FILE));
832
+ if (!isRecord(marker) || marker.schemaVersion !== 1 || !isPid(marker.launcherPid)) continue;
833
+ const launcherPid = /** @type {number} */ (marker.launcherPid);
834
+ if (marker.childPid !== null && !isPid(marker.childPid)) continue;
835
+ const childPid = /** @type {number | null} */ (marker.childPid);
836
+ if (alive(launcherPid) || (childPid !== null && alive(childPid))) continue;
837
+ try {
838
+ await rm(directory, { recursive: true, force: true });
839
+ removed += 1;
840
+ } catch {
841
+ // Left for a later sweep.
842
+ }
843
+ }
844
+ return removed;
845
+ }