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

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # aisac
2
2
 
3
- The AISAC client for Claude Code and Codex on Linux/WSL with Node.js 22 or newer:
3
+ The AISAC client for Claude Code and Codex on Linux, WSL and macOS, with
4
+ Node.js 22 or newer:
4
5
  AI Brain memory, context management and, for AISAC Founding Cohort members,
5
6
  AISAC Engineering.
6
7
 
@@ -14,8 +15,44 @@ both provide the `aisac` and `aibrain` commands. State stays in `~/.aibrain`.
14
15
  shows the answer and sends the request only to the configured AI Brain address.
15
16
  `aisac status` stays offline.
16
17
 
17
- Codex capture also requires the `flock` utility from `util-linux`, normally
18
- included in Linux/WSL. The launcher and doctor check availability before a
18
+ For licensed accounts, `aisac claude` downloads the AISAC Engineering pack for
19
+ the session, verifies its Ed25519 signature against the key pinned in this
20
+ release, and loads it as the `aisac-engineering` plugin from the session's
21
+ private folder, which is deleted on exit. Any failure starts Claude without the
22
+ pack. `aisac setup --lang pt-BR|en` picks the pack language without a network
23
+ call. The pack is never part of this npm package.
24
+
25
+ `aisac codex` (Codex 0.164.0 or later) loads the same verified pack for that
26
+ session only: its skills are added to the Codex app-server the launch owns
27
+ (call one with `$`, for example `$spec`) and its agents become session agent
28
+ roles. Nothing is written to `~/.codex`, `~/.agents` or the project, and the
29
+ session's folder is deleted on exit. An older Codex starts without the pack and
30
+ says `codex_outdated`.
31
+
32
+ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
33
+ `aisac-guardrails`.
34
+ - **Default on:** `secrets-guard` refuses secret files by name. Four more hooks
35
+ keep continuity: a repository snapshot at start and after a compaction, a
36
+ docs reminder, and the NOTES/spec/CHANGELOG pass.
37
+ - **Default off:** `git-guard`, `destructive-guard` and `format-on-edit`.
38
+ - **Changing them:** `aisac hooks` lists them, and `aisac hooks enable|disable
39
+ <id>` or `aisac hooks reset` changes the next session. The agent cannot change
40
+ them from inside a session.
41
+ - **Code:** it ships in this package and runs locally. Nothing comes from the
42
+ server.
43
+
44
+ Licensed sessions of `aisac claude` and `aisac codex` can also load reviewed MCP
45
+ servers: `github` (read-only, token in `GITHUB_PERSONAL_ACCESS_TOKEN`), `supabase`
46
+ (read-only), `vercel`, `playwright` (pinned, isolated) and `context7`.
47
+ - **Default:** every server is off. `aisac mcp` lists them, `aisac mcp enable|disable
48
+ <id>` or `aisac mcp reset` changes the next session, and `aisac mcp info <id>` says
49
+ what a server reaches and how to log in.
50
+ - **Login:** the host does it (`/mcp` in Claude Code, `aisac mcp login <id>` for Codex)
51
+ and keeps it in its own store. No token reaches `~/.aibrain` or AISAC.
52
+
53
+ On Linux/WSL, Codex capture also requires the `flock` utility from `util-linux`,
54
+ normally included. On macOS it uses `/usr/bin/perl`, `/usr/sbin/sysctl` and
55
+ `/bin/ps` instead. The launcher and doctor check availability before a
19
56
  monitored Codex session starts. Capture coordination is released by the kernel
20
57
  after a process exits unexpectedly. An unidentifiable empty lock left by an
21
58
  older client requires a verified support migration; do not delete checkpoints,
@@ -23,7 +60,7 @@ notes or lock files to work around a reported storage error.
23
60
 
24
61
  The reviewed native compatibility set is Claude Code 2.1.294, 2.1.295, 2.1.296,
25
62
  2.1.299, 2.1.300, 2.1.301, 2.1.302 or 2.1.303, and Codex CLI 0.161.0,
26
- 0.162.0, 0.162.1, 0.163.0 or 0.164.0. A newer stable Codex 0.x release may continue only
63
+ 0.162.0, 0.162.1, 0.163.0, 0.164.0 or 0.166.0 (0.165.0 was never qualified). A newer stable Codex 0.x release may continue only
27
64
  after its locally generated experimental protocol schemas and transport flags
28
65
  match the bundled adapter contract; prereleases, older releases and new majors
29
66
  remain rejected. Starting with beta14, newer stable Claude 2.1 patches can
@@ -39,6 +76,11 @@ record formats. Native launch/resume qualification is separate from a new
39
76
  model/JEV compaction qualification. A later native release that changes the
40
77
  consumed contract can still require an AI Brain compatibility update.
41
78
 
79
+ `0.4.0-beta.9` refreshes the bundled contract to Codex 0.166.0. Its delta is
80
+ additive: optional item timing, an image generation id, an attach-to-source fork
81
+ flag and two error codes. See the
82
+ [Codex 0.166 qualification record](../../docs/reference/codex-166-compatibility-2026-11-04.md).
83
+
42
84
  The Codex terminal bridge permits the native session picker's separate read-only
43
85
  connection while keeping the primary conversation and monitor intact. A picker
44
86
  cannot compact, start a turn, inject history or take ownership of the primary
@@ -61,8 +103,10 @@ Installation does not contact AI Brain, change native client configuration, or
61
103
  enable all paid accounts. Setup requires an existing AI Brain PAT, explicit
62
104
  versioned disclosure, and successful provider-free pilot access. Claude and
63
105
  Codex continue to own their own authentication. Windows native is unsupported;
64
- run the beta inside WSL. Pricing and general customer activation are outside
65
- this beta.
106
+ run the beta inside WSL. On macOS, Codex capture locks with the system
107
+ `/usr/bin/perl` (`flock(2)`) and identifies the lock owner with `sysctl` and
108
+ `ps`, so nothing extra needs installing. Pricing and general customer
109
+ activation are outside this beta.
66
110
 
67
111
  For reviewed noninteractive automation only, use a private token file and
68
112
  explicitly acknowledge the displayed disclosure with
@@ -151,7 +151,7 @@ type Register = (
151
151
  ) => void;
152
152
 
153
153
  const MAX_RESULT_BYTES = 256 * 1024;
154
- const CLAUDE_HOOK_VERSION = '0.4.0-beta.1';
154
+ const CLAUDE_HOOK_VERSION = '0.4.0-beta.7';
155
155
  const MAX_DIAGNOSTIC_BYTE_COUNT = 128 * 1024 * 1024;
156
156
  const COOLDOWN_MS = 60_000;
157
157
  const MAX_TRUNCATED_CODE_POINTS = 300;
@@ -463,6 +463,10 @@ function v2WireOptions(options: V2Options): Record<string, unknown> {
463
463
 
464
464
  function safeErrorCategory(error: unknown): string {
465
465
  const record = isRecord(error) ? error : {};
466
+ // A coded error names its own reason; the keyword guess below would read
467
+ // "AI Brain plan does not meet the minimum estimated reduction" as invalid_plan.
468
+ if (COMPACT_ERROR_CODES.includes(record.compactErrorCode as CompactErrorCode))
469
+ return record.compactErrorCode as CompactErrorCode;
466
470
  const code = typeof record.code === 'string' ? record.code.toLowerCase() : '';
467
471
  const name = typeof record.name === 'string' ? record.name.toLowerCase() : '';
468
472
  const message = typeof record.message === 'string' ? record.message.toLowerCase() : '';
@@ -54,6 +54,12 @@ const MAX_OWNED_REQUESTS = 1_000;
54
54
  const MAX_RETIRED_REQUESTS = 2_000;
55
55
  const MAX_CLIENT_REQUEST_BINDINGS = 128;
56
56
  const MAX_OWNED_THREAD_IDS = 256;
57
+ // AISAC Engineering (ADR-0047 E4c): the launcher's skills root is added to the
58
+ // owned app-server before the client sees its initialize answer.
59
+ const ENGINEERING_SKILLS_ROOT_ENV = 'AISAC_ENGINEERING_SKILLS_ROOT';
60
+ const ENGINEERING_SKILLS_ACK_TIMEOUT_MS = 2_000;
61
+ const MAX_ENGINEERING_PATH_LENGTH = 4_096;
62
+ const MAX_TRACKED_INITIALIZE_REQUESTS = 4;
57
63
  const MAX_RECOVERY_ITEMS = 128;
58
64
  const MAX_RECOVERY_SOURCE_INDEX = 65_536;
59
65
  const MAX_NOTE_BYTES = 128 * 1024;
@@ -1001,6 +1007,128 @@ async function processIdentity(pid) {
1001
1007
  }
1002
1008
  }
1003
1009
 
1010
+ // macOS has flock(2) but no util-linux helper and no /proc (ADR-0049, spec
1011
+ // codex-macos). The guard helper and the owner identity use fixed system paths:
1012
+ // never PATH lookups, never a shell.
1013
+ const DARWIN_CAPTURE_TOOLS = Object.freeze({
1014
+ perl: '/usr/bin/perl',
1015
+ sysctl: '/usr/sbin/sysctl',
1016
+ ps: '/bin/ps',
1017
+ });
1018
+ const DARWIN_FLOCK_ARGS = Object.freeze([
1019
+ '-MFcntl=:flock',
1020
+ '-e',
1021
+ 'open(my $f, "+<&=", 3) or exit 2; exit(flock($f, LOCK_EX | LOCK_NB) ? 0 : 1)',
1022
+ ]);
1023
+ const DARWIN_TOOL_ENV = Object.freeze({ LC_ALL: 'C', TZ: 'UTC', PATH: '/usr/bin:/bin:/usr/sbin' });
1024
+ const DARWIN_TOOL_TIMEOUT_MS = 2_000;
1025
+ const DARWIN_TOOL_MAX_OUTPUT_BYTES = 4096;
1026
+ const DARWIN_START_TIME = /^[\x20-\x7e]{1,64}$/u;
1027
+ const BOOT_SESSION = /^[a-f0-9-]{36}$/iu;
1028
+ const PS_NO_MATCHING_PROCESS = 1;
1029
+
1030
+ /** Platform and tool paths of the capture lock; replaced only by tests. */
1031
+ let captureLockHost = { platform: process.platform, tools: DARWIN_CAPTURE_TOOLS };
1032
+
1033
+ export function __testSetCaptureLockHost(host) {
1034
+ const previous = captureLockHost;
1035
+ captureLockHost = host;
1036
+ return previous;
1037
+ }
1038
+
1039
+ function guardHelperCommand() {
1040
+ return captureLockHost.platform === 'darwin'
1041
+ ? [captureLockHost.tools.perl, [...DARWIN_FLOCK_ARGS]]
1042
+ : ['flock', ['-x', '-n', '3']];
1043
+ }
1044
+
1045
+ /** @returns {Promise<{ code: number | null, stdout: string }>} */
1046
+ function runDarwinTool(command, args) {
1047
+ return new Promise((resolvePromise) => {
1048
+ const child = spawn(command, args, {
1049
+ env: DARWIN_TOOL_ENV,
1050
+ stdio: ['ignore', 'pipe', 'ignore'],
1051
+ });
1052
+ let stdout = '';
1053
+ let settled = false;
1054
+ const finish = (code) => {
1055
+ if (settled) return;
1056
+ settled = true;
1057
+ clearTimeout(timer);
1058
+ resolvePromise({ code, stdout });
1059
+ };
1060
+ const timer = setTimeout(() => {
1061
+ child.kill('SIGKILL');
1062
+ finish(null);
1063
+ }, DARWIN_TOOL_TIMEOUT_MS);
1064
+ child.stdout.setEncoding('utf8');
1065
+ child.stdout.on('data', (chunk) => {
1066
+ stdout += chunk;
1067
+ if (Buffer.byteLength(stdout) > DARWIN_TOOL_MAX_OUTPUT_BYTES) {
1068
+ child.kill('SIGKILL');
1069
+ finish(null);
1070
+ }
1071
+ });
1072
+ child.once('error', () => finish(null));
1073
+ child.once('close', (code) => finish(code));
1074
+ });
1075
+ }
1076
+
1077
+ async function darwinBootSession() {
1078
+ const result = await runDarwinTool(captureLockHost.tools.sysctl, ['-n', 'kern.bootsessionuuid']);
1079
+ const value = result.stdout.trim();
1080
+ return result.code === 0 && BOOT_SESSION.test(value) ? value.toUpperCase() : null;
1081
+ }
1082
+
1083
+ async function darwinProcessIdentity(pid) {
1084
+ if (!Number.isSafeInteger(pid) || pid <= 0) return { status: 'invalid' };
1085
+ const bootSession = await darwinBootSession();
1086
+ if (bootSession === null) return { status: 'unverifiable' };
1087
+ const result = await runDarwinTool(captureLockHost.tools.ps, [
1088
+ '-o',
1089
+ 'lstart=',
1090
+ '-p',
1091
+ String(pid),
1092
+ ]);
1093
+ const startTime = result.stdout.trim();
1094
+ // BSD ps exits 1 with no output when no process matches the pid.
1095
+ if (result.code === PS_NO_MATCHING_PROCESS && startTime === '') return { status: 'dead' };
1096
+ if (result.code !== 0 || !DARWIN_START_TIME.test(startTime)) return { status: 'unverifiable' };
1097
+ return { status: 'live', startTime, bootSession };
1098
+ }
1099
+
1100
+ async function darwinSentinelOwner(value) {
1101
+ if (
1102
+ !isRecord(value) ||
1103
+ !hasOnlyKeys(value, [
1104
+ 'schema',
1105
+ 'backend',
1106
+ 'platform',
1107
+ 'pid',
1108
+ 'startTime',
1109
+ 'bootSession',
1110
+ 'nonce',
1111
+ ]) ||
1112
+ value.schema !== 1 ||
1113
+ value.backend !== 'flock' ||
1114
+ value.platform !== 'darwin' ||
1115
+ !isSafeBoundedInteger(value.pid, 1, 4_000_000_000) ||
1116
+ typeof value.startTime !== 'string' ||
1117
+ !DARWIN_START_TIME.test(value.startTime) ||
1118
+ typeof value.bootSession !== 'string' ||
1119
+ !BOOT_SESSION.test(value.bootSession) ||
1120
+ typeof value.nonce !== 'string' ||
1121
+ !isUuid(value.nonce)
1122
+ )
1123
+ return { status: 'unverifiable' };
1124
+ const observer = await darwinBootSession();
1125
+ if (observer === null) return { status: 'unverifiable' };
1126
+ if (observer !== value.bootSession.toUpperCase()) return { status: 'dead' };
1127
+ const current = await darwinProcessIdentity(value.pid);
1128
+ if (current.status !== 'live') return { status: current.status };
1129
+ return current.startTime === value.startTime ? { status: 'live' } : { status: 'dead' };
1130
+ }
1131
+
1004
1132
  async function captureSentinelOwner(lockPath, fsOps) {
1005
1133
  let info;
1006
1134
  try {
@@ -1019,6 +1147,7 @@ async function captureSentinelOwner(lockPath, fsOps) {
1019
1147
  } catch {
1020
1148
  return { status: 'unverifiable' };
1021
1149
  }
1150
+ if (captureLockHost.platform === 'darwin') return darwinSentinelOwner(value);
1022
1151
  if (
1023
1152
  !isRecord(value) ||
1024
1153
  !hasOnlyKeys(value, [
@@ -1054,10 +1183,23 @@ async function captureSentinelOwner(lockPath, fsOps) {
1054
1183
  return current.startTime === value.startTime ? { status: 'live' } : { status: 'dead' };
1055
1184
  }
1056
1185
 
1057
- async function ensureCaptureSentinel(lockPath, task) {
1186
+ async function ownSentinelRecord() {
1187
+ if (captureLockHost.platform === 'darwin') {
1188
+ const identity = await darwinProcessIdentity(process.pid);
1189
+ if (identity.status !== 'live') throw captureLockError('capture_lock_owner_unverifiable');
1190
+ return {
1191
+ schema: 1,
1192
+ backend: 'flock',
1193
+ platform: 'darwin',
1194
+ pid: process.pid,
1195
+ startTime: identity.startTime,
1196
+ bootSession: identity.bootSession,
1197
+ nonce: randomUUID(),
1198
+ };
1199
+ }
1058
1200
  const identity = await processIdentity(process.pid);
1059
1201
  if (identity.status !== 'live') throw captureLockError('capture_lock_owner_unverifiable');
1060
- const metadata = `${JSON.stringify({
1202
+ return {
1061
1203
  schema: 1,
1062
1204
  backend: 'flock',
1063
1205
  pid: process.pid,
@@ -1065,7 +1207,11 @@ async function ensureCaptureSentinel(lockPath, task) {
1065
1207
  bootId: identity.bootId,
1066
1208
  pidNamespace: identity.pidNamespace,
1067
1209
  nonce: randomUUID(),
1068
- })}\n`;
1210
+ };
1211
+ }
1212
+
1213
+ async function ensureCaptureSentinel(lockPath, task) {
1214
+ const metadata = `${JSON.stringify(await ownSentinelRecord())}\n`;
1069
1215
  while (true) {
1070
1216
  const temporary = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
1071
1217
  let contention = false;
@@ -1143,7 +1289,8 @@ async function withAdvisoryCaptureLock(directory, task) {
1143
1289
  while (true) {
1144
1290
  let helper;
1145
1291
  try {
1146
- helper = spawn('flock', ['-x', '-n', '3'], {
1292
+ const [command, args] = guardHelperCommand();
1293
+ helper = spawn(command, args, {
1147
1294
  stdio: ['ignore', 'ignore', 'ignore', guardHandle.fd],
1148
1295
  });
1149
1296
  const acquired = await new Promise((resolvePromise, rejectPromise) => {
@@ -2707,6 +2854,8 @@ export function createCodexContextProxy({
2707
2854
  createDiagnosticSink = null,
2708
2855
  brainServer = process.env.AI_BRAIN_MCP_SERVER ?? 'brain',
2709
2856
  clientVersion = process.env.CODEX_CLIENT_VERSION ?? '0.161.0',
2857
+ engineeringSkillsRoot = null,
2858
+ engineeringAckTimeoutMs = ENGINEERING_SKILLS_ACK_TIMEOUT_MS,
2710
2859
  }) {
2711
2860
  const contextPolicy = normalizeContextPolicy(policy);
2712
2861
  const effectiveThreshold = contextPolicy?.compactAtPercent ?? threshold;
@@ -2719,6 +2868,15 @@ export function createCodexContextProxy({
2719
2868
  const telemetryByThread = new Map();
2720
2869
  const diagnosticSinks = new Map();
2721
2870
  let stopped = false;
2871
+ // AISAC Engineering skills root: 'off' without a root; 'waiting' for the
2872
+ // client's first successful initialize; 'holding' app-server output until
2873
+ // skills/extraRoots/set is answered (or times out); then 'done'.
2874
+ let engineeringState =
2875
+ typeof engineeringSkillsRoot === 'string' && engineeringSkillsRoot.length > 0
2876
+ ? 'waiting'
2877
+ : 'off';
2878
+ const initializeRequestKeys = new Set();
2879
+ let engineeringHeld = null;
2722
2880
  function emitDiagnostic(event) {
2723
2881
  try {
2724
2882
  const phase = typeof event?.phase === 'string' ? event.phase : '';
@@ -4131,6 +4289,11 @@ export function createCodexContextProxy({
4131
4289
  const owned = ownedRequests.get(message.id);
4132
4290
  ownedRequests.delete(message.id);
4133
4291
  retire(message.id);
4292
+ if (owned.kind === 'engineering') {
4293
+ // An error leaves the session without the pack skills; it is never shown.
4294
+ releaseEngineering();
4295
+ return false;
4296
+ }
4134
4297
  if (
4135
4298
  owned.kind === 'read' ||
4136
4299
  owned.kind === 'page' ||
@@ -4364,6 +4527,14 @@ export function createCodexContextProxy({
4364
4527
  if (!validRequestId) return;
4365
4528
  const requestKey = clientRequestKey(requestId);
4366
4529
  if (requestKey === null) return;
4530
+ if (
4531
+ message.method === 'initialize' &&
4532
+ engineeringState === 'waiting' &&
4533
+ initializeRequestKeys.size < MAX_TRACKED_INITIALIZE_REQUESTS
4534
+ ) {
4535
+ initializeRequestKeys.add(requestKey);
4536
+ return;
4537
+ }
4367
4538
  if (['thread/start', 'thread/resume', 'thread/fork'].includes(message.method)) {
4368
4539
  const threadId =
4369
4540
  isRecord(message.params) && typeof message.params.threadId === 'string'
@@ -4428,7 +4599,7 @@ export function createCodexContextProxy({
4428
4599
  let passthrough = false;
4429
4600
  let discardOversized = false;
4430
4601
  let pausedForBackpressure = false;
4431
- const forward = (text) => {
4602
+ const writeOut = (text) => {
4432
4603
  if (!stdout.write(text) && !pausedForBackpressure) {
4433
4604
  pausedForBackpressure = true;
4434
4605
  child.stdout.pause();
@@ -4438,6 +4609,54 @@ export function createCodexContextProxy({
4438
4609
  });
4439
4610
  }
4440
4611
  };
4612
+ const forward = (text) => {
4613
+ if (engineeringHeld === null) {
4614
+ writeOut(text);
4615
+ return;
4616
+ }
4617
+ engineeringHeld.lines.push(text);
4618
+ engineeringHeld.bytes += Buffer.byteLength(text, 'utf8');
4619
+ if (engineeringHeld.bytes > MAX_LINE_BYTES) releaseEngineering();
4620
+ };
4621
+ /** The client's first successful initialize answer, which starts the hold. */
4622
+ function isEngineeringTrigger(message) {
4623
+ if (engineeringState !== 'waiting' || !isRecord(message)) return false;
4624
+ if (message.method !== undefined || !('result' in message)) return false;
4625
+ const key = clientRequestKey(message.id);
4626
+ return key !== null && initializeRequestKeys.has(key);
4627
+ }
4628
+ function holdForEngineering(text) {
4629
+ engineeringState = 'holding';
4630
+ initializeRequestKeys.clear();
4631
+ engineeringHeld = {
4632
+ lines: [text],
4633
+ bytes: Buffer.byteLength(text, 'utf8'),
4634
+ id: null,
4635
+ cancel: null,
4636
+ };
4637
+ try {
4638
+ engineeringHeld.id = sendOwned(
4639
+ 'skills/extraRoots/set',
4640
+ { extraRoots: [engineeringSkillsRoot] },
4641
+ 'engineering',
4642
+ null,
4643
+ );
4644
+ } catch {
4645
+ releaseEngineering();
4646
+ return;
4647
+ }
4648
+ engineeringHeld.cancel = scheduleTimeout(() => releaseEngineering(), engineeringAckTimeoutMs);
4649
+ }
4650
+ /** Forward everything held, in order. Idempotent; the session never waits on the pack. */
4651
+ function releaseEngineering() {
4652
+ if (engineeringHeld === null) return;
4653
+ const held = engineeringHeld;
4654
+ engineeringHeld = null;
4655
+ engineeringState = 'done';
4656
+ held.cancel?.();
4657
+ if (held.id !== null && ownedRequests.has(held.id)) cleanupRequest(held.id);
4658
+ for (const text of held.lines) writeOut(text);
4659
+ }
4441
4660
  const processLine = (line) => {
4442
4661
  if (Buffer.byteLength(line, 'utf8') + 1 > MAX_LINE_BYTES) {
4443
4662
  const ownedId = internalRequestId(line);
@@ -4451,6 +4670,10 @@ export function createCodexContextProxy({
4451
4670
  try {
4452
4671
  const message = JSON.parse(line.endsWith('\r') ? line.slice(0, -1) : line);
4453
4672
  observeClientResponse(message);
4673
+ if (isEngineeringTrigger(message)) {
4674
+ holdForEngineering(`${line}\n`);
4675
+ return;
4676
+ }
4454
4677
  forwardLine = handle(message);
4455
4678
  } catch {
4456
4679
  /* preserve malformed external protocol lines */
@@ -4536,6 +4759,7 @@ export function createCodexContextProxy({
4536
4759
  }
4537
4760
  });
4538
4761
  child.stdout.on('end', () => {
4762
+ releaseEngineering();
4539
4763
  const tail = inputBuffer + decoder.end();
4540
4764
  if (tail && !discardOversized) forward(tail);
4541
4765
  inputBuffer = '';
@@ -4565,6 +4789,7 @@ export function createCodexContextProxy({
4565
4789
  observeClientRequest,
4566
4790
  observeClientResponse,
4567
4791
  async stop() {
4792
+ releaseEngineering();
4568
4793
  if (stopped) {
4569
4794
  await Promise.all(
4570
4795
  [...diagnosticSinks.values()].map((sink) =>
@@ -4594,6 +4819,18 @@ export function createCodexContextProxy({
4594
4819
  };
4595
4820
  }
4596
4821
 
4822
+ /**
4823
+ * The launcher's AISAC Engineering skills root, or null. Only an absolute,
4824
+ * normalized path without control characters is used.
4825
+ */
4826
+ export function engineeringSkillsRootFrom(value) {
4827
+ if (typeof value !== 'string' || value.length === 0) return null;
4828
+ if (value.length > MAX_ENGINEERING_PATH_LENGTH || /[\u0000-\u001f\u007f]/u.test(value))
4829
+ return null;
4830
+ if (!value.startsWith('/') || resolve(value) !== value) return null;
4831
+ return value;
4832
+ }
4833
+
4597
4834
  async function main() {
4598
4835
  if (process.argv.includes('--help')) {
4599
4836
  process.stderr.write(
@@ -4648,6 +4885,7 @@ async function main() {
4648
4885
  timeoutMs: numberEnv('CODEX_CONTEXT_COMPACT_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
4649
4886
  policy,
4650
4887
  createDiagnosticSink: diagnosticFactory,
4888
+ engineeringSkillsRoot: engineeringSkillsRootFrom(process.env[ENGINEERING_SKILLS_ROOT_ENV]),
4651
4889
  });
4652
4890
  const clientDecoder = new StringDecoder('utf8');
4653
4891
  let clientInputBuffer = '';
@@ -20,6 +20,8 @@ const MAX_AUXILIARY_IN_FLIGHT = 32;
20
20
  const MAX_AUXILIARY_NOTIFICATIONS = 256;
21
21
  const DEFAULT_AUXILIARY_IDLE_TIMEOUT_MS = 120_000;
22
22
  const NORMAL_DISCOVERY_CLOSE_CODES = new Set([1000, 1001, 1005]);
23
+ // Smallest sun_path among supported hosts: macOS 104 bytes, minus the terminator.
24
+ const UNIX_SOCKET_PATH_MAX_BYTES = 103;
23
25
 
24
26
  function repoRoot() {
25
27
  return fileURLToPath(new URL('..', import.meta.url)).replace(/\/$/u, '');
@@ -1022,6 +1024,10 @@ async function main() {
1022
1024
  });
1023
1025
  });
1024
1026
  try {
1027
+ // macOS sun_path holds 104 bytes including the terminator (Linux 108); refuse a
1028
+ // path that the kernel would truncate or reject instead of binding elsewhere.
1029
+ if (Buffer.byteLength(socketPath) > UNIX_SOCKET_PATH_MAX_BYTES)
1030
+ throw new Error('terminal_bridge_socket_path_too_long');
1025
1031
  await new Promise((resolve, reject) => {
1026
1032
  server.once('error', reject);
1027
1033
  server.listen(socketPath, async () => {
@@ -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.1';
19
+ export const ADAPTER_VERSION = '0.4.0-beta.11';
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']);