@brunobracaioli/aibrain 0.3.0-beta.7 → 0.4.0-beta.2

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