@brunobracaioli/aibrain 0.4.0-beta.1 → 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 +7 -0
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +37 -2
- package/dist/engineering-pack-public-key.json +10 -0
- package/dist/engineering-pack.mjs +722 -0
- package/dist/engineering.mjs +15 -11
- package/dist/launch.mjs +49 -5
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,13 @@ both provide the `aisac` and `aibrain` commands. State stays in `~/.aibrain`.
|
|
|
14
14
|
shows the answer and sends the request only to the configured AI Brain address.
|
|
15
15
|
`aisac status` stays offline.
|
|
16
16
|
|
|
17
|
+
For licensed accounts, `aisac claude` downloads the AISAC Engineering pack for
|
|
18
|
+
the session, verifies its Ed25519 signature against the key pinned in this
|
|
19
|
+
release, and loads it as the `aisac-engineering` plugin from the session's
|
|
20
|
+
private folder, which is deleted on exit. Any failure starts Claude without the
|
|
21
|
+
pack. `aisac setup --lang pt-BR|en` picks the pack language without a network
|
|
22
|
+
call. The pack is never part of this npm package.
|
|
23
|
+
|
|
17
24
|
Codex capture also requires the `flock` utility from `util-linux`, normally
|
|
18
25
|
included in Linux/WSL. The launcher and doctor check availability before a
|
|
19
26
|
monitored Codex session starts. Capture coordination is released by the kernel
|
|
@@ -16,7 +16,7 @@ export const MAX_LOG_FILES = 3;
|
|
|
16
16
|
const LOCK_WAIT_MS = 250;
|
|
17
17
|
const LOCK_STALE_MS = 30_000;
|
|
18
18
|
const ADMISSION_MS = 60 * 60 * 1000;
|
|
19
|
-
export const ADAPTER_VERSION = '0.4.0-beta.
|
|
19
|
+
export const ADAPTER_VERSION = '0.4.0-beta.2';
|
|
20
20
|
const MAX_DIAGNOSTIC_COUNT = 10_000_000;
|
|
21
21
|
const MAX_DIAGNOSTIC_BYTES = 128 * 1024 * 1024;
|
|
22
22
|
const CHECKPOINT_BACKENDS = new Set(['native_store', 'private_file']);
|
package/dist/cli.mjs
CHANGED
|
@@ -29,8 +29,15 @@ import { codexPolicyJson, makePolicy, publicPolicy, isProfile } from './policy.m
|
|
|
29
29
|
import { readDiagnostics } from './claude-observation.mjs';
|
|
30
30
|
import { readCodexDiagnostics } from './codex-diagnostics.mjs';
|
|
31
31
|
import { checkEngineeringLicense, licenseLine, unavailableLicense } from './engineering.mjs';
|
|
32
|
+
import {
|
|
33
|
+
isPackLocale,
|
|
34
|
+
readLastLaunch,
|
|
35
|
+
readStoredLocale,
|
|
36
|
+
resolvePackLocale,
|
|
37
|
+
writeStoredLocale,
|
|
38
|
+
} from './engineering-pack.mjs';
|
|
32
39
|
|
|
33
|
-
const VERSION = '0.4.0-beta.
|
|
40
|
+
const VERSION = '0.4.0-beta.2';
|
|
34
41
|
// The command is `aisac`; `aibrain` is an alias installed from the same entry.
|
|
35
42
|
const COMMAND = 'aisac';
|
|
36
43
|
const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
|
|
@@ -48,7 +55,7 @@ function usage() {
|
|
|
48
55
|
'Claude shadow: setup --claude-shadow on|off and --claude-diagnostics on|off (preview stays applied=false).',
|
|
49
56
|
);
|
|
50
57
|
console.log(
|
|
51
|
-
'AISAC Engineering: setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
|
|
58
|
+
'AISAC Engineering: `aisac claude` loads the licensed method; setup --lang pt-BR|en picks its language; setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
|
|
52
59
|
);
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -310,6 +317,26 @@ async function updateLocalOptOut(previous, args) {
|
|
|
310
317
|
return true;
|
|
311
318
|
}
|
|
312
319
|
|
|
320
|
+
/** Store the AISAC Engineering pack language; local only, no network call. @param {'pt-BR' | 'en'} locale */
|
|
321
|
+
async function setPackLocale(locale) {
|
|
322
|
+
const root = await ensureStateRoot();
|
|
323
|
+
await writeStoredLocale(root, locale);
|
|
324
|
+
console.log(
|
|
325
|
+
`AISAC Engineering pack language set to ${locale} for future launches; no network call was made.`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Offline view of the pack language and the last launch (status, doctor). */
|
|
330
|
+
async function engineeringPackState() {
|
|
331
|
+
const root = stateRoot();
|
|
332
|
+
const stored = await readStoredLocale(root);
|
|
333
|
+
return {
|
|
334
|
+
locale: resolvePackLocale(stored),
|
|
335
|
+
localeSource: stored === null ? 'system' : 'setup',
|
|
336
|
+
lastLaunch: await readLastLaunch(root),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
313
340
|
/** @param {string} endpoint */
|
|
314
341
|
function validateEndpoint(endpoint) {
|
|
315
342
|
if (endpoint.length > 2048) throw new Error('endpoint_too_long');
|
|
@@ -385,6 +412,7 @@ async function setup(args) {
|
|
|
385
412
|
'--telemetry',
|
|
386
413
|
'--claude-shadow',
|
|
387
414
|
'--claude-diagnostics',
|
|
415
|
+
'--lang',
|
|
388
416
|
]),
|
|
389
417
|
new Set([
|
|
390
418
|
'--client',
|
|
@@ -396,10 +424,14 @@ async function setup(args) {
|
|
|
396
424
|
'--telemetry',
|
|
397
425
|
'--claude-shadow',
|
|
398
426
|
'--claude-diagnostics',
|
|
427
|
+
'--lang',
|
|
399
428
|
]),
|
|
400
429
|
);
|
|
401
430
|
if (args.includes('--undo') && args.length !== 1)
|
|
402
431
|
throw new Error('setup_undo_cannot_have_options');
|
|
432
|
+
const lang = option(args, '--lang');
|
|
433
|
+
if (lang !== undefined && !isPackLocale(lang)) throw new Error('invalid_lang');
|
|
434
|
+
if (lang !== undefined && args.length === 2) return setPackLocale(lang);
|
|
403
435
|
const previous = await readConfig();
|
|
404
436
|
if (await updateLocalOptOut(previous, args)) return;
|
|
405
437
|
const localOptOut =
|
|
@@ -501,6 +533,7 @@ async function setup(args) {
|
|
|
501
533
|
console.log(
|
|
502
534
|
`Configured ${clients} in ${root}. Provider-free access verified; no provider batch was used.`,
|
|
503
535
|
);
|
|
536
|
+
if (lang !== undefined) await setPackLocale(lang);
|
|
504
537
|
// Display only: the server decides again on every pack request (ADR-0047).
|
|
505
538
|
const line = licenseLine(await checkEngineeringLicense(endpoint, token));
|
|
506
539
|
if (line !== null) console.log(line);
|
|
@@ -599,6 +632,7 @@ async function doctor(json) {
|
|
|
599
632
|
config?.claudeObservation?.disclosedAt !== undefined,
|
|
600
633
|
},
|
|
601
634
|
engineering,
|
|
635
|
+
engineeringPack: await engineeringPackState(),
|
|
602
636
|
paidOrPilot: probe?.ok
|
|
603
637
|
? 'provider_free_probe_ok; paid entitlement or pilot invitation remains server-enforced'
|
|
604
638
|
: config
|
|
@@ -634,6 +668,7 @@ async function status(json) {
|
|
|
634
668
|
config?.claudeObservation?.disclosedAt !== null &&
|
|
635
669
|
config?.claudeObservation?.disclosedAt !== undefined,
|
|
636
670
|
},
|
|
671
|
+
engineeringPack: await engineeringPackState(),
|
|
637
672
|
monitoredSession: null,
|
|
638
673
|
monitoringStatus: 'not_observed',
|
|
639
674
|
};
|
|
@@ -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
|
+
}
|
package/dist/engineering.mjs
CHANGED
|
@@ -47,12 +47,12 @@ export function engineeringLicenseUrl(endpoint) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
50
|
-
function isRecord(value) {
|
|
50
|
+
export function isRecord(value) {
|
|
51
51
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** @param {unknown} value */
|
|
55
|
-
function isPurchaseUrl(value) {
|
|
54
|
+
/** @param {unknown} value @returns {value is string} */
|
|
55
|
+
export function isPurchaseUrl(value) {
|
|
56
56
|
if (typeof value !== 'string' || value.length > MAX_URL_LENGTH) return false;
|
|
57
57
|
try {
|
|
58
58
|
const url = new URL(value);
|
|
@@ -97,10 +97,14 @@ export function parseLicenseBody(text) {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Read a response body as UTF-8, refusing anything above `limit` bytes.
|
|
102
|
+
* @param {Response} response
|
|
103
|
+
* @param {number} [limit]
|
|
104
|
+
*/
|
|
105
|
+
export async function readBoundedText(response, limit = MAX_LICENSE_BODY_BYTES) {
|
|
102
106
|
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
103
|
-
if (declared >
|
|
107
|
+
if (declared > limit) throw new Error('body_too_large');
|
|
104
108
|
if (response.body === null) return '';
|
|
105
109
|
const reader = response.body.getReader();
|
|
106
110
|
/** @type {Uint8Array[]} */
|
|
@@ -110,9 +114,9 @@ async function boundedText(response) {
|
|
|
110
114
|
const { done, value } = await reader.read();
|
|
111
115
|
if (done) break;
|
|
112
116
|
size += value.byteLength;
|
|
113
|
-
if (size >
|
|
117
|
+
if (size > limit) {
|
|
114
118
|
await reader.cancel().catch(() => undefined);
|
|
115
|
-
throw new Error('
|
|
119
|
+
throw new Error('body_too_large');
|
|
116
120
|
}
|
|
117
121
|
chunks.push(value);
|
|
118
122
|
}
|
|
@@ -120,12 +124,12 @@ async function boundedText(response) {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
/** @param {unknown} error */
|
|
123
|
-
function isTimeout(error) {
|
|
127
|
+
export function isTimeout(error) {
|
|
124
128
|
return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
/** @param {number} status */
|
|
128
|
-
function unavailableReasonFor(status) {
|
|
132
|
+
export function unavailableReasonFor(status) {
|
|
129
133
|
return (
|
|
130
134
|
UNAVAILABLE_BY_STATUS.get(status) ??
|
|
131
135
|
(status >= SERVER_ERROR_STATUS ? 'server_error' : 'unexpected_status')
|
|
@@ -165,7 +169,7 @@ export async function checkEngineeringLicense(endpoint, token, options = {}) {
|
|
|
165
169
|
}
|
|
166
170
|
let text;
|
|
167
171
|
try {
|
|
168
|
-
text = await
|
|
172
|
+
text = await readBoundedText(response);
|
|
169
173
|
} catch (error) {
|
|
170
174
|
return unavailableLicense(isTimeout(error) ? 'timeout' : 'invalid_response');
|
|
171
175
|
}
|
package/dist/launch.mjs
CHANGED
|
@@ -13,6 +13,11 @@ import {
|
|
|
13
13
|
telemetryConfigBinding,
|
|
14
14
|
} from './state.mjs';
|
|
15
15
|
import { codexPolicyJson, makePolicy } from './policy.mjs';
|
|
16
|
+
import {
|
|
17
|
+
prepareEngineeringPack,
|
|
18
|
+
sweepStaleLaunches,
|
|
19
|
+
writeLaunchMarker,
|
|
20
|
+
} from './engineering-pack.mjs';
|
|
16
21
|
import {
|
|
17
22
|
CODEX_LAUNCH_ENVELOPE,
|
|
18
23
|
CODEX_MONITOR_CWD,
|
|
@@ -361,14 +366,26 @@ async function assertClaudeCheckpointHelper(packageRoot) {
|
|
|
361
366
|
return helperPath;
|
|
362
367
|
}
|
|
363
368
|
|
|
364
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* A fresh private directory for one launch. Directories left by launches whose
|
|
371
|
+
* processes are gone are swept first; this one records its launcher.
|
|
372
|
+
* @param {string} root @param {string} prefix
|
|
373
|
+
*/
|
|
365
374
|
async function launchDirectory(root, prefix) {
|
|
375
|
+
await sweepStaleLaunches(root);
|
|
366
376
|
const parent = await privateDirectory(join(root, 'runtime'));
|
|
367
377
|
const directory = await mkdtemp(join(parent, `${prefix}-`));
|
|
368
378
|
await chmod(directory, 0o700);
|
|
379
|
+
await writeLaunchMarker(directory, { launcherPid: process.pid, childPid: null });
|
|
369
380
|
return directory;
|
|
370
381
|
}
|
|
371
382
|
|
|
383
|
+
/** @param {string} directory @param {number | undefined} childPid */
|
|
384
|
+
async function recordChild(directory, childPid) {
|
|
385
|
+
if (childPid === undefined) return;
|
|
386
|
+
await writeLaunchMarker(directory, { launcherPid: process.pid, childPid }).catch(() => undefined);
|
|
387
|
+
}
|
|
388
|
+
|
|
372
389
|
/** @param {import('node:child_process').ChildProcess} child @param {string} directory */
|
|
373
390
|
function waitForChild(child, directory) {
|
|
374
391
|
return new Promise((resolvePromise) => {
|
|
@@ -401,8 +418,19 @@ function waitForChild(child, directory) {
|
|
|
401
418
|
});
|
|
402
419
|
}
|
|
403
420
|
|
|
404
|
-
/**
|
|
405
|
-
|
|
421
|
+
/**
|
|
422
|
+
* @typedef {object} LaunchDependencies
|
|
423
|
+
* @property {typeof prepareEngineeringPack} [preparePack] AISAC Engineering pack loader (E4b)
|
|
424
|
+
*/
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @param {LaunchConfig} config
|
|
428
|
+
* @param {string} root
|
|
429
|
+
* @param {string[]} args
|
|
430
|
+
* @param {LaunchDependencies} [dependencies]
|
|
431
|
+
*/
|
|
432
|
+
export async function launchClaude(config, root, args, dependencies = {}) {
|
|
433
|
+
const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
|
|
406
434
|
const validated = assertConfig(config);
|
|
407
435
|
const launchArgs = await parseClaudeLaunchArgs(args, process.cwd());
|
|
408
436
|
assertClaudeArgs(launchArgs.args);
|
|
@@ -534,6 +562,15 @@ export async function launchClaude(config, root, args) {
|
|
|
534
562
|
await rm(directory, { recursive: true, force: true });
|
|
535
563
|
throw error;
|
|
536
564
|
}
|
|
565
|
+
// The pack never blocks the launch: every failure starts Claude without it.
|
|
566
|
+
const engineering = await preparePack({
|
|
567
|
+
endpoint: validated.endpoint,
|
|
568
|
+
token: validated.token,
|
|
569
|
+
root,
|
|
570
|
+
directory,
|
|
571
|
+
packageRoot: validated.packageRoot,
|
|
572
|
+
}).catch(() => ({ pluginRoot: null, line: null }));
|
|
573
|
+
if (engineering.line !== null) process.stderr.write(`${engineering.line}\n`);
|
|
537
574
|
const environment = childEnvironment(root);
|
|
538
575
|
environment.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = '1';
|
|
539
576
|
/** @type {import('node:child_process').ChildProcess} */
|
|
@@ -550,6 +587,7 @@ export async function launchClaude(config, root, args) {
|
|
|
550
587
|
requiredAllow.join(','),
|
|
551
588
|
'--plugin-dir',
|
|
552
589
|
pluginRoot,
|
|
590
|
+
...(engineering.pluginRoot === null ? [] : ['--plugin-dir', engineering.pluginRoot]),
|
|
553
591
|
...launchArgs.args,
|
|
554
592
|
],
|
|
555
593
|
{ cwd: process.cwd(), env: environment, stdio: 'inherit' },
|
|
@@ -558,7 +596,10 @@ export async function launchClaude(config, root, args) {
|
|
|
558
596
|
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
559
597
|
throw error;
|
|
560
598
|
}
|
|
561
|
-
|
|
599
|
+
// Listen for exit before any await, so a child that exits at once is not missed.
|
|
600
|
+
const finished = waitForChild(child, directory);
|
|
601
|
+
await recordChild(directory, child.pid);
|
|
602
|
+
return finished;
|
|
562
603
|
}
|
|
563
604
|
|
|
564
605
|
/** @param {LaunchConfig} config @param {string} root @param {string[]} args */
|
|
@@ -640,7 +681,10 @@ export async function launchCodex(config, root, args) {
|
|
|
640
681
|
env: environment,
|
|
641
682
|
stdio: 'inherit',
|
|
642
683
|
});
|
|
643
|
-
|
|
684
|
+
// Listen for exit before any await, so a child that exits at once is not missed.
|
|
685
|
+
const finished = waitForChild(child, directory);
|
|
686
|
+
await recordChild(directory, child.pid);
|
|
687
|
+
return finished;
|
|
644
688
|
}
|
|
645
689
|
|
|
646
690
|
export const launchInternals = { MCP_ALIAS, PLUGIN_NAME, PLUGIN_INLINE_NAME };
|
package/dist/package.json
CHANGED
package/package.json
CHANGED