@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.
- package/README.md +80 -15
- package/dist/assets/claude/.claude-plugin/plugin.json +1 -1
- package/dist/assets/claude/hooks/context-compaction.ts +1100 -77
- package/dist/assets/claude/hooks/native-host-contract-runtime.ts +18 -0
- package/dist/assets/claude/hooks/native-host-contract.test.ts +51 -0
- package/dist/assets/codex-context-compaction.mjs +2179 -194
- package/dist/assets/jev-paid-pilot-terminal.mjs +334 -35
- package/dist/claude-compatibility.mjs +506 -0
- package/dist/claude-observation.mjs +117 -3
- package/dist/cli.mjs +105 -28
- package/dist/codex-compatibility.mjs +481 -0
- package/dist/codex-diagnostics.mjs +39 -3
- package/dist/codex-protocol-fingerprint.json +131 -0
- package/dist/engineering-pack-public-key.json +10 -0
- package/dist/engineering-pack.mjs +722 -0
- package/dist/engineering.mjs +194 -0
- package/dist/launch.mjs +71 -14
- package/dist/mcp.mjs +8 -8
- package/dist/package.json +1 -1
- package/dist/platform.mjs +47 -3
- package/package.json +3 -2
|
@@ -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
|
+
}
|