@nathapp/nax 0.42.5 → 0.42.7
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/dist/nax.js +121 -75
- package/package.json +1 -1
- package/src/agents/acp/adapter.ts +9 -3
- package/src/agents/acp/spawn-client.ts +77 -31
- package/src/agents/types-extended.ts +2 -0
- package/src/cli/plan.ts +4 -0
- package/src/execution/crash-signals.ts +3 -2
- package/src/execution/lock.ts +12 -14
- package/src/utils/path-security.ts +21 -4
package/dist/nax.js
CHANGED
|
@@ -18963,6 +18963,8 @@ class SpawnAcpSession {
|
|
|
18963
18963
|
timeoutSeconds;
|
|
18964
18964
|
permissionMode;
|
|
18965
18965
|
env;
|
|
18966
|
+
pidRegistry;
|
|
18967
|
+
activeProc = null;
|
|
18966
18968
|
constructor(opts) {
|
|
18967
18969
|
this.agentName = opts.agentName;
|
|
18968
18970
|
this.sessionName = opts.sessionName;
|
|
@@ -18971,6 +18973,7 @@ class SpawnAcpSession {
|
|
|
18971
18973
|
this.timeoutSeconds = opts.timeoutSeconds;
|
|
18972
18974
|
this.permissionMode = opts.permissionMode;
|
|
18973
18975
|
this.env = opts.env;
|
|
18976
|
+
this.pidRegistry = opts.pidRegistry;
|
|
18974
18977
|
}
|
|
18975
18978
|
async prompt(text) {
|
|
18976
18979
|
const cmd = [
|
|
@@ -18997,35 +19000,50 @@ class SpawnAcpSession {
|
|
|
18997
19000
|
stderr: "pipe",
|
|
18998
19001
|
env: this.env
|
|
18999
19002
|
});
|
|
19000
|
-
proc
|
|
19001
|
-
proc.
|
|
19002
|
-
|
|
19003
|
-
const stdout = await new Response(proc.stdout).text();
|
|
19004
|
-
const stderr = await new Response(proc.stderr).text();
|
|
19005
|
-
if (exitCode !== 0) {
|
|
19006
|
-
getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
|
|
19007
|
-
stderr: stderr.slice(0, 200)
|
|
19008
|
-
});
|
|
19009
|
-
return {
|
|
19010
|
-
messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
|
|
19011
|
-
stopReason: "error"
|
|
19012
|
-
};
|
|
19013
|
-
}
|
|
19003
|
+
this.activeProc = proc;
|
|
19004
|
+
const processPid = proc.pid;
|
|
19005
|
+
await this.pidRegistry?.register(processPid);
|
|
19014
19006
|
try {
|
|
19015
|
-
|
|
19016
|
-
|
|
19017
|
-
|
|
19018
|
-
|
|
19019
|
-
|
|
19020
|
-
|
|
19021
|
-
|
|
19022
|
-
|
|
19023
|
-
|
|
19024
|
-
|
|
19025
|
-
|
|
19007
|
+
proc.stdin.write(text);
|
|
19008
|
+
proc.stdin.end();
|
|
19009
|
+
const exitCode = await proc.exited;
|
|
19010
|
+
const stdout = await new Response(proc.stdout).text();
|
|
19011
|
+
const stderr = await new Response(proc.stderr).text();
|
|
19012
|
+
if (exitCode !== 0) {
|
|
19013
|
+
getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
|
|
19014
|
+
stderr: stderr.slice(0, 200)
|
|
19015
|
+
});
|
|
19016
|
+
return {
|
|
19017
|
+
messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
|
|
19018
|
+
stopReason: "error"
|
|
19019
|
+
};
|
|
19020
|
+
}
|
|
19021
|
+
try {
|
|
19022
|
+
const parsed = parseAcpxJsonOutput(stdout);
|
|
19023
|
+
return {
|
|
19024
|
+
messages: [{ role: "assistant", content: parsed.text || "" }],
|
|
19025
|
+
stopReason: "end_turn",
|
|
19026
|
+
cumulative_token_usage: parsed.tokenUsage
|
|
19027
|
+
};
|
|
19028
|
+
} catch (err) {
|
|
19029
|
+
getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
|
|
19030
|
+
stderr: stderr.slice(0, 200)
|
|
19031
|
+
});
|
|
19032
|
+
throw err;
|
|
19033
|
+
}
|
|
19034
|
+
} finally {
|
|
19035
|
+
this.activeProc = null;
|
|
19036
|
+
await this.pidRegistry?.unregister(processPid);
|
|
19026
19037
|
}
|
|
19027
19038
|
}
|
|
19028
19039
|
async close() {
|
|
19040
|
+
if (this.activeProc) {
|
|
19041
|
+
try {
|
|
19042
|
+
this.activeProc.kill(15);
|
|
19043
|
+
getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
|
|
19044
|
+
} catch {}
|
|
19045
|
+
this.activeProc = null;
|
|
19046
|
+
}
|
|
19029
19047
|
const cmd = ["acpx", this.agentName, "sessions", "close", this.sessionName];
|
|
19030
19048
|
getSafeLogger()?.debug("acp-adapter", `Closing session: ${this.sessionName}`);
|
|
19031
19049
|
const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
@@ -19039,6 +19057,12 @@ class SpawnAcpSession {
|
|
|
19039
19057
|
}
|
|
19040
19058
|
}
|
|
19041
19059
|
async cancelActivePrompt() {
|
|
19060
|
+
if (this.activeProc) {
|
|
19061
|
+
try {
|
|
19062
|
+
this.activeProc.kill(15);
|
|
19063
|
+
getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
|
|
19064
|
+
} catch {}
|
|
19065
|
+
}
|
|
19042
19066
|
const cmd = ["acpx", this.agentName, "cancel"];
|
|
19043
19067
|
getSafeLogger()?.debug("acp-adapter", `Cancelling active prompt: ${this.sessionName}`);
|
|
19044
19068
|
const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
@@ -19051,8 +19075,10 @@ class SpawnAcpClient {
|
|
|
19051
19075
|
model;
|
|
19052
19076
|
cwd;
|
|
19053
19077
|
timeoutSeconds;
|
|
19078
|
+
permissionMode;
|
|
19054
19079
|
env;
|
|
19055
|
-
|
|
19080
|
+
pidRegistry;
|
|
19081
|
+
constructor(cmdStr, cwd, timeoutSeconds, pidRegistry) {
|
|
19056
19082
|
const parts = cmdStr.split(/\s+/);
|
|
19057
19083
|
const modelIdx = parts.indexOf("--model");
|
|
19058
19084
|
this.model = modelIdx >= 0 && parts[modelIdx + 1] ? parts[modelIdx + 1] : "default";
|
|
@@ -19063,7 +19089,9 @@ class SpawnAcpClient {
|
|
|
19063
19089
|
this.agentName = lastToken;
|
|
19064
19090
|
this.cwd = cwd || process.cwd();
|
|
19065
19091
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
19092
|
+
this.permissionMode = "approve-reads";
|
|
19066
19093
|
this.env = buildAllowedEnv2();
|
|
19094
|
+
this.pidRegistry = pidRegistry;
|
|
19067
19095
|
}
|
|
19068
19096
|
async start() {}
|
|
19069
19097
|
async createSession(opts) {
|
|
@@ -19083,7 +19111,8 @@ class SpawnAcpClient {
|
|
|
19083
19111
|
model: this.model,
|
|
19084
19112
|
timeoutSeconds: this.timeoutSeconds,
|
|
19085
19113
|
permissionMode: opts.permissionMode,
|
|
19086
|
-
env: this.env
|
|
19114
|
+
env: this.env,
|
|
19115
|
+
pidRegistry: this.pidRegistry
|
|
19087
19116
|
});
|
|
19088
19117
|
}
|
|
19089
19118
|
async loadSession(sessionName, agentName) {
|
|
@@ -19099,14 +19128,15 @@ class SpawnAcpClient {
|
|
|
19099
19128
|
cwd: this.cwd,
|
|
19100
19129
|
model: this.model,
|
|
19101
19130
|
timeoutSeconds: this.timeoutSeconds,
|
|
19102
|
-
permissionMode:
|
|
19103
|
-
env: this.env
|
|
19131
|
+
permissionMode: this.permissionMode,
|
|
19132
|
+
env: this.env,
|
|
19133
|
+
pidRegistry: this.pidRegistry
|
|
19104
19134
|
});
|
|
19105
19135
|
}
|
|
19106
19136
|
async close() {}
|
|
19107
19137
|
}
|
|
19108
|
-
function createSpawnAcpClient(cmdStr, cwd, timeoutSeconds) {
|
|
19109
|
-
return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds);
|
|
19138
|
+
function createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry) {
|
|
19139
|
+
return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
|
|
19110
19140
|
}
|
|
19111
19141
|
var _spawnClientDeps;
|
|
19112
19142
|
var init_spawn_client = __esm(() => {
|
|
@@ -19367,7 +19397,7 @@ class AcpAgentAdapter {
|
|
|
19367
19397
|
}
|
|
19368
19398
|
async _runWithClient(options, startTime) {
|
|
19369
19399
|
const cmdStr = `acpx --model ${options.modelDef.model} ${this.name}`;
|
|
19370
|
-
const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds);
|
|
19400
|
+
const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds, options.pidRegistry);
|
|
19371
19401
|
await client.start();
|
|
19372
19402
|
let sessionName = options.acpSessionName;
|
|
19373
19403
|
if (!sessionName && options.featureName && options.storyId) {
|
|
@@ -19539,7 +19569,8 @@ class AcpAgentAdapter {
|
|
|
19539
19569
|
maxInteractionTurns: options.maxInteractionTurns,
|
|
19540
19570
|
featureName: options.featureName,
|
|
19541
19571
|
storyId: options.storyId,
|
|
19542
|
-
sessionRole: options.sessionRole
|
|
19572
|
+
sessionRole: options.sessionRole,
|
|
19573
|
+
pidRegistry: options.pidRegistry
|
|
19543
19574
|
});
|
|
19544
19575
|
if (!result.success) {
|
|
19545
19576
|
throw new Error(`[acp-adapter] plan() failed: ${result.output}`);
|
|
@@ -19609,8 +19640,8 @@ var init_adapter = __esm(() => {
|
|
|
19609
19640
|
async sleep(ms) {
|
|
19610
19641
|
await Bun.sleep(ms);
|
|
19611
19642
|
},
|
|
19612
|
-
createClient(cmdStr, cwd, timeoutSeconds) {
|
|
19613
|
-
return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds);
|
|
19643
|
+
createClient(cmdStr, cwd, timeoutSeconds, pidRegistry) {
|
|
19644
|
+
return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
|
|
19614
19645
|
}
|
|
19615
19646
|
};
|
|
19616
19647
|
});
|
|
@@ -20171,14 +20202,27 @@ var init_chain = __esm(() => {
|
|
|
20171
20202
|
});
|
|
20172
20203
|
|
|
20173
20204
|
// src/utils/path-security.ts
|
|
20174
|
-
import {
|
|
20205
|
+
import { realpathSync } from "fs";
|
|
20206
|
+
import { dirname, isAbsolute, join as join5, normalize, resolve } from "path";
|
|
20207
|
+
function safeRealpath(p) {
|
|
20208
|
+
try {
|
|
20209
|
+
return realpathSync(p);
|
|
20210
|
+
} catch {
|
|
20211
|
+
try {
|
|
20212
|
+
const parent = realpathSync(dirname(p));
|
|
20213
|
+
return join5(parent, p.split("/").pop() ?? "");
|
|
20214
|
+
} catch {
|
|
20215
|
+
return p;
|
|
20216
|
+
}
|
|
20217
|
+
}
|
|
20218
|
+
}
|
|
20175
20219
|
function validateModulePath(modulePath, allowedRoots) {
|
|
20176
20220
|
if (!modulePath) {
|
|
20177
20221
|
return { valid: false, error: "Module path is empty" };
|
|
20178
20222
|
}
|
|
20179
|
-
const normalizedRoots = allowedRoots.map((r) => resolve(r));
|
|
20223
|
+
const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
|
|
20180
20224
|
if (isAbsolute(modulePath)) {
|
|
20181
|
-
const absoluteTarget = normalize(modulePath);
|
|
20225
|
+
const absoluteTarget = safeRealpath(normalize(modulePath));
|
|
20182
20226
|
const isWithin = normalizedRoots.some((root) => {
|
|
20183
20227
|
return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
|
|
20184
20228
|
});
|
|
@@ -20187,7 +20231,7 @@ function validateModulePath(modulePath, allowedRoots) {
|
|
|
20187
20231
|
}
|
|
20188
20232
|
} else {
|
|
20189
20233
|
for (const root of normalizedRoots) {
|
|
20190
|
-
const absoluteTarget = resolve(join5(root, modulePath));
|
|
20234
|
+
const absoluteTarget = safeRealpath(resolve(join5(root, modulePath)));
|
|
20191
20235
|
if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
|
|
20192
20236
|
return { valid: true, absolutePath: absoluteTarget };
|
|
20193
20237
|
}
|
|
@@ -20467,7 +20511,7 @@ function isPlainObject2(value) {
|
|
|
20467
20511
|
}
|
|
20468
20512
|
|
|
20469
20513
|
// src/config/path-security.ts
|
|
20470
|
-
import { existsSync as existsSync4, lstatSync, realpathSync } from "fs";
|
|
20514
|
+
import { existsSync as existsSync4, lstatSync, realpathSync as realpathSync2 } from "fs";
|
|
20471
20515
|
import { isAbsolute as isAbsolute2, normalize as normalize2, resolve as resolve3 } from "path";
|
|
20472
20516
|
function validateDirectory(dirPath, baseDir) {
|
|
20473
20517
|
const resolved = resolve3(dirPath);
|
|
@@ -20476,7 +20520,7 @@ function validateDirectory(dirPath, baseDir) {
|
|
|
20476
20520
|
}
|
|
20477
20521
|
let realPath;
|
|
20478
20522
|
try {
|
|
20479
|
-
realPath =
|
|
20523
|
+
realPath = realpathSync2(resolved);
|
|
20480
20524
|
} catch (error48) {
|
|
20481
20525
|
throw new Error(`Failed to resolve path: ${dirPath} (${error48.message})`);
|
|
20482
20526
|
}
|
|
@@ -20490,7 +20534,7 @@ function validateDirectory(dirPath, baseDir) {
|
|
|
20490
20534
|
}
|
|
20491
20535
|
if (baseDir) {
|
|
20492
20536
|
const resolvedBase = resolve3(baseDir);
|
|
20493
|
-
const realBase = existsSync4(resolvedBase) ?
|
|
20537
|
+
const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
|
|
20494
20538
|
if (!isWithinDirectory(realPath, realBase)) {
|
|
20495
20539
|
throw new Error(`Path is outside allowed directory: ${dirPath} (resolved to ${realPath}, base: ${realBase})`);
|
|
20496
20540
|
}
|
|
@@ -20514,19 +20558,19 @@ function validateFilePath(filePath, baseDir) {
|
|
|
20514
20558
|
if (!existsSync4(resolved)) {
|
|
20515
20559
|
const parent = resolve3(resolved, "..");
|
|
20516
20560
|
if (existsSync4(parent)) {
|
|
20517
|
-
const realParent =
|
|
20561
|
+
const realParent = realpathSync2(parent);
|
|
20518
20562
|
realPath = resolve3(realParent, filePath.split("/").pop() || "");
|
|
20519
20563
|
} else {
|
|
20520
20564
|
realPath = resolved;
|
|
20521
20565
|
}
|
|
20522
20566
|
} else {
|
|
20523
|
-
realPath =
|
|
20567
|
+
realPath = realpathSync2(resolved);
|
|
20524
20568
|
}
|
|
20525
20569
|
} catch (error48) {
|
|
20526
20570
|
throw new Error(`Failed to resolve path: ${filePath} (${error48.message})`);
|
|
20527
20571
|
}
|
|
20528
20572
|
const resolvedBase = resolve3(baseDir);
|
|
20529
|
-
const realBase = existsSync4(resolvedBase) ?
|
|
20573
|
+
const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
|
|
20530
20574
|
if (!isWithinDirectory(realPath, realBase)) {
|
|
20531
20575
|
throw new Error(`Path is outside allowed directory: ${filePath} (resolved to ${realPath}, base: ${realBase})`);
|
|
20532
20576
|
}
|
|
@@ -21870,7 +21914,7 @@ var package_default;
|
|
|
21870
21914
|
var init_package = __esm(() => {
|
|
21871
21915
|
package_default = {
|
|
21872
21916
|
name: "@nathapp/nax",
|
|
21873
|
-
version: "0.42.
|
|
21917
|
+
version: "0.42.7",
|
|
21874
21918
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
21875
21919
|
type: "module",
|
|
21876
21920
|
bin: {
|
|
@@ -21943,8 +21987,8 @@ var init_version = __esm(() => {
|
|
|
21943
21987
|
NAX_VERSION = package_default.version;
|
|
21944
21988
|
NAX_COMMIT = (() => {
|
|
21945
21989
|
try {
|
|
21946
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
21947
|
-
return "
|
|
21990
|
+
if (/^[0-9a-f]{6,10}$/.test("e8a2a25"))
|
|
21991
|
+
return "e8a2a25";
|
|
21948
21992
|
} catch {}
|
|
21949
21993
|
try {
|
|
21950
21994
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -24423,7 +24467,7 @@ var init_constitution = __esm(() => {
|
|
|
24423
24467
|
});
|
|
24424
24468
|
|
|
24425
24469
|
// src/pipeline/stages/constitution.ts
|
|
24426
|
-
import { dirname } from "path";
|
|
24470
|
+
import { dirname as dirname2 } from "path";
|
|
24427
24471
|
var constitutionStage;
|
|
24428
24472
|
var init_constitution2 = __esm(() => {
|
|
24429
24473
|
init_constitution();
|
|
@@ -24433,7 +24477,7 @@ var init_constitution2 = __esm(() => {
|
|
|
24433
24477
|
enabled: (ctx) => ctx.config.constitution.enabled,
|
|
24434
24478
|
async execute(ctx) {
|
|
24435
24479
|
const logger = getLogger();
|
|
24436
|
-
const ngentDir = ctx.featureDir ?
|
|
24480
|
+
const ngentDir = ctx.featureDir ? dirname2(dirname2(ctx.featureDir)) : `${ctx.workdir}/nax`;
|
|
24437
24481
|
const result = await loadConstitution(ngentDir, ctx.config.constitution);
|
|
24438
24482
|
if (result) {
|
|
24439
24483
|
ctx.constitution = result;
|
|
@@ -25230,6 +25274,7 @@ var init_story_context = __esm(() => {
|
|
|
25230
25274
|
});
|
|
25231
25275
|
|
|
25232
25276
|
// src/execution/lock.ts
|
|
25277
|
+
import { unlink } from "fs/promises";
|
|
25233
25278
|
import path8 from "path";
|
|
25234
25279
|
function getSafeLogger3() {
|
|
25235
25280
|
try {
|
|
@@ -25263,7 +25308,7 @@ async function acquireLock(workdir) {
|
|
|
25263
25308
|
});
|
|
25264
25309
|
const fs2 = await import("fs/promises");
|
|
25265
25310
|
await fs2.unlink(lockPath).catch(() => {});
|
|
25266
|
-
lockData2 =
|
|
25311
|
+
lockData2 = null;
|
|
25267
25312
|
}
|
|
25268
25313
|
if (lockData2) {
|
|
25269
25314
|
const lockPid = lockData2.pid;
|
|
@@ -25301,18 +25346,14 @@ async function acquireLock(workdir) {
|
|
|
25301
25346
|
async function releaseLock(workdir) {
|
|
25302
25347
|
const lockPath = path8.join(workdir, "nax.lock");
|
|
25303
25348
|
try {
|
|
25304
|
-
|
|
25305
|
-
const exists = await file2.exists();
|
|
25306
|
-
if (exists) {
|
|
25307
|
-
const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
|
|
25308
|
-
await proc.exited;
|
|
25309
|
-
await Bun.sleep(10);
|
|
25310
|
-
}
|
|
25349
|
+
await unlink(lockPath);
|
|
25311
25350
|
} catch (error48) {
|
|
25312
|
-
|
|
25313
|
-
|
|
25314
|
-
|
|
25315
|
-
|
|
25351
|
+
if (error48.code !== "ENOENT") {
|
|
25352
|
+
const logger = getSafeLogger3();
|
|
25353
|
+
logger?.warn("execution", "Failed to release lock", {
|
|
25354
|
+
error: error48.message
|
|
25355
|
+
});
|
|
25356
|
+
}
|
|
25316
25357
|
}
|
|
25317
25358
|
}
|
|
25318
25359
|
var init_lock = __esm(() => {
|
|
@@ -25655,17 +25696,17 @@ async function autoCommitIfDirty(workdir, stage, role, storyId) {
|
|
|
25655
25696
|
});
|
|
25656
25697
|
const gitRoot = (await new Response(topLevelProc.stdout).text()).trim();
|
|
25657
25698
|
await topLevelProc.exited;
|
|
25658
|
-
const { realpathSync:
|
|
25699
|
+
const { realpathSync: realpathSync4 } = await import("fs");
|
|
25659
25700
|
const realWorkdir = (() => {
|
|
25660
25701
|
try {
|
|
25661
|
-
return
|
|
25702
|
+
return realpathSync4(workdir);
|
|
25662
25703
|
} catch {
|
|
25663
25704
|
return workdir;
|
|
25664
25705
|
}
|
|
25665
25706
|
})();
|
|
25666
25707
|
const realGitRoot = (() => {
|
|
25667
25708
|
try {
|
|
25668
|
-
return
|
|
25709
|
+
return realpathSync4(gitRoot);
|
|
25669
25710
|
} catch {
|
|
25670
25711
|
return gitRoot;
|
|
25671
25712
|
}
|
|
@@ -26957,7 +26998,7 @@ var init_session_runner = __esm(() => {
|
|
|
26957
26998
|
});
|
|
26958
26999
|
|
|
26959
27000
|
// src/tdd/verdict-reader.ts
|
|
26960
|
-
import { unlink } from "fs/promises";
|
|
27001
|
+
import { unlink as unlink2 } from "fs/promises";
|
|
26961
27002
|
import path9 from "path";
|
|
26962
27003
|
function isValidVerdict(obj) {
|
|
26963
27004
|
if (!obj || typeof obj !== "object")
|
|
@@ -27157,7 +27198,7 @@ async function readVerdict(workdir) {
|
|
|
27157
27198
|
async function cleanupVerdict(workdir) {
|
|
27158
27199
|
const verdictPath = path9.join(workdir, VERDICT_FILE);
|
|
27159
27200
|
try {
|
|
27160
|
-
await
|
|
27201
|
+
await unlink2(verdictPath);
|
|
27161
27202
|
} catch {}
|
|
27162
27203
|
}
|
|
27163
27204
|
var VERDICT_FILE = ".nax-verifier-verdict.json";
|
|
@@ -30883,14 +30924,15 @@ function installSignalHandlers(ctx) {
|
|
|
30883
30924
|
process.on("SIGINT", sigintHandler);
|
|
30884
30925
|
process.on("SIGHUP", sighupHandler);
|
|
30885
30926
|
process.on("uncaughtException", uncaughtExceptionHandler);
|
|
30886
|
-
|
|
30927
|
+
const rejectionWrapper = (reason) => unhandledRejectionHandler(reason);
|
|
30928
|
+
process.on("unhandledRejection", rejectionWrapper);
|
|
30887
30929
|
logger?.debug("crash-recovery", "Signal handlers installed");
|
|
30888
30930
|
return () => {
|
|
30889
30931
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
30890
30932
|
process.removeListener("SIGINT", sigintHandler);
|
|
30891
30933
|
process.removeListener("SIGHUP", sighupHandler);
|
|
30892
30934
|
process.removeListener("uncaughtException", uncaughtExceptionHandler);
|
|
30893
|
-
process.removeListener("unhandledRejection",
|
|
30935
|
+
process.removeListener("unhandledRejection", rejectionWrapper);
|
|
30894
30936
|
logger?.debug("crash-recovery", "Signal handlers unregistered");
|
|
30895
30937
|
};
|
|
30896
30938
|
}
|
|
@@ -33666,7 +33708,7 @@ var init_sequential_executor = __esm(() => {
|
|
|
33666
33708
|
});
|
|
33667
33709
|
|
|
33668
33710
|
// src/execution/status-file.ts
|
|
33669
|
-
import { rename, unlink as
|
|
33711
|
+
import { rename, unlink as unlink3 } from "fs/promises";
|
|
33670
33712
|
import { resolve as resolve8 } from "path";
|
|
33671
33713
|
function countProgress(prd) {
|
|
33672
33714
|
const stories = prd.userStories;
|
|
@@ -33715,7 +33757,7 @@ async function writeStatusFile(filePath, status) {
|
|
|
33715
33757
|
}
|
|
33716
33758
|
const tmpPath = `${resolvedPath}.tmp`;
|
|
33717
33759
|
try {
|
|
33718
|
-
await
|
|
33760
|
+
await unlink3(tmpPath);
|
|
33719
33761
|
} catch {}
|
|
33720
33762
|
await Bun.write(tmpPath, JSON.stringify(status, null, 2));
|
|
33721
33763
|
await rename(tmpPath, resolvedPath);
|
|
@@ -65602,6 +65644,7 @@ init_registry();
|
|
|
65602
65644
|
import { existsSync as existsSync9 } from "fs";
|
|
65603
65645
|
import { join as join10 } from "path";
|
|
65604
65646
|
import { createInterface } from "readline";
|
|
65647
|
+
init_pid_registry();
|
|
65605
65648
|
init_logger2();
|
|
65606
65649
|
|
|
65607
65650
|
// src/prd/schema.ts
|
|
@@ -65805,6 +65848,7 @@ async function planCommand(workdir, config2, options) {
|
|
|
65805
65848
|
if (!adapter)
|
|
65806
65849
|
throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
65807
65850
|
const interactionBridge = createCliInteractionBridge();
|
|
65851
|
+
const pidRegistry = new PidRegistry(workdir);
|
|
65808
65852
|
logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
|
|
65809
65853
|
try {
|
|
65810
65854
|
await adapter.plan({
|
|
@@ -65817,9 +65861,11 @@ async function planCommand(workdir, config2, options) {
|
|
|
65817
65861
|
modelTier: config2?.plan?.model ?? "balanced",
|
|
65818
65862
|
dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
|
|
65819
65863
|
maxInteractionTurns: config2?.agent?.maxInteractionTurns,
|
|
65820
|
-
featureName: options.feature
|
|
65864
|
+
featureName: options.feature,
|
|
65865
|
+
pidRegistry
|
|
65821
65866
|
});
|
|
65822
65867
|
} finally {
|
|
65868
|
+
await pidRegistry.killAll().catch(() => {});
|
|
65823
65869
|
logger?.info("plan", "Interactive session ended");
|
|
65824
65870
|
}
|
|
65825
65871
|
if (!_deps2.existsSync(outputPath)) {
|
|
@@ -66107,7 +66153,7 @@ import { join as join13 } from "path";
|
|
|
66107
66153
|
// src/commands/common.ts
|
|
66108
66154
|
init_path_security2();
|
|
66109
66155
|
init_errors3();
|
|
66110
|
-
import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as
|
|
66156
|
+
import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync3 } from "fs";
|
|
66111
66157
|
import { join as join11, resolve as resolve6 } from "path";
|
|
66112
66158
|
function resolveProject(options = {}) {
|
|
66113
66159
|
const { dir, feature } = options;
|
|
@@ -66115,7 +66161,7 @@ function resolveProject(options = {}) {
|
|
|
66115
66161
|
let naxDir;
|
|
66116
66162
|
let configPath;
|
|
66117
66163
|
if (dir) {
|
|
66118
|
-
projectRoot =
|
|
66164
|
+
projectRoot = realpathSync3(resolve6(dir));
|
|
66119
66165
|
naxDir = join11(projectRoot, "nax");
|
|
66120
66166
|
if (!existsSync10(naxDir)) {
|
|
66121
66167
|
throw new NaxError(`Directory does not contain a nax project: ${projectRoot}
|
|
@@ -66174,7 +66220,7 @@ function findProjectRoot(startDir) {
|
|
|
66174
66220
|
const naxDir = join11(current, "nax");
|
|
66175
66221
|
const configPath = join11(naxDir, "config.json");
|
|
66176
66222
|
if (existsSync10(configPath)) {
|
|
66177
|
-
return
|
|
66223
|
+
return realpathSync3(current);
|
|
66178
66224
|
}
|
|
66179
66225
|
const parent = join11(current, "..");
|
|
66180
66226
|
if (parent === current) {
|
package/package.json
CHANGED
|
@@ -114,8 +114,13 @@ export const _acpAdapterDeps = {
|
|
|
114
114
|
* Default: spawn-based client (shells out to acpx CLI).
|
|
115
115
|
* Override in tests via: _acpAdapterDeps.createClient = mock(...)
|
|
116
116
|
*/
|
|
117
|
-
createClient(
|
|
118
|
-
|
|
117
|
+
createClient(
|
|
118
|
+
cmdStr: string,
|
|
119
|
+
cwd?: string,
|
|
120
|
+
timeoutSeconds?: number,
|
|
121
|
+
pidRegistry?: import("../../execution/pid-registry").PidRegistry,
|
|
122
|
+
): AcpClient {
|
|
123
|
+
return createSpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
|
|
119
124
|
},
|
|
120
125
|
};
|
|
121
126
|
|
|
@@ -436,7 +441,7 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
436
441
|
|
|
437
442
|
private async _runWithClient(options: AgentRunOptions, startTime: number): Promise<AgentResult> {
|
|
438
443
|
const cmdStr = `acpx --model ${options.modelDef.model} ${this.name}`;
|
|
439
|
-
const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds);
|
|
444
|
+
const client = _acpAdapterDeps.createClient(cmdStr, options.workdir, options.timeoutSeconds, options.pidRegistry);
|
|
440
445
|
await client.start();
|
|
441
446
|
|
|
442
447
|
// 1. Resolve session name: explicit > sidecar > derived
|
|
@@ -673,6 +678,7 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
673
678
|
featureName: options.featureName,
|
|
674
679
|
storyId: options.storyId,
|
|
675
680
|
sessionRole: options.sessionRole,
|
|
681
|
+
pidRegistry: options.pidRegistry,
|
|
676
682
|
});
|
|
677
683
|
|
|
678
684
|
if (!result.success) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* acpx <agent> cancel → session.cancelActivePrompt()
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { PidRegistry } from "../../execution/pid-registry";
|
|
15
16
|
import { getSafeLogger } from "../../logger";
|
|
16
17
|
import type { AcpClient, AcpSession, AcpSessionResponse } from "./adapter";
|
|
17
18
|
import { parseAcpxJsonOutput } from "./parser";
|
|
@@ -96,6 +97,8 @@ class SpawnAcpSession implements AcpSession {
|
|
|
96
97
|
private readonly timeoutSeconds: number;
|
|
97
98
|
private readonly permissionMode: string;
|
|
98
99
|
private readonly env: Record<string, string | undefined>;
|
|
100
|
+
private readonly pidRegistry?: PidRegistry;
|
|
101
|
+
private activeProc: { pid: number; kill(signal?: number): void } | null = null;
|
|
99
102
|
|
|
100
103
|
constructor(opts: {
|
|
101
104
|
agentName: string;
|
|
@@ -105,6 +108,7 @@ class SpawnAcpSession implements AcpSession {
|
|
|
105
108
|
timeoutSeconds: number;
|
|
106
109
|
permissionMode: string;
|
|
107
110
|
env: Record<string, string | undefined>;
|
|
111
|
+
pidRegistry?: PidRegistry;
|
|
108
112
|
}) {
|
|
109
113
|
this.agentName = opts.agentName;
|
|
110
114
|
this.sessionName = opts.sessionName;
|
|
@@ -113,6 +117,7 @@ class SpawnAcpSession implements AcpSession {
|
|
|
113
117
|
this.timeoutSeconds = opts.timeoutSeconds;
|
|
114
118
|
this.permissionMode = opts.permissionMode;
|
|
115
119
|
this.env = opts.env;
|
|
120
|
+
this.pidRegistry = opts.pidRegistry;
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
async prompt(text: string): Promise<AcpSessionResponse> {
|
|
@@ -143,40 +148,60 @@ class SpawnAcpSession implements AcpSession {
|
|
|
143
148
|
env: this.env,
|
|
144
149
|
});
|
|
145
150
|
|
|
146
|
-
proc
|
|
147
|
-
proc.
|
|
151
|
+
this.activeProc = proc;
|
|
152
|
+
const processPid = proc.pid;
|
|
153
|
+
await this.pidRegistry?.register(processPid);
|
|
148
154
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
try {
|
|
156
|
+
proc.stdin.write(text);
|
|
157
|
+
proc.stdin.end();
|
|
152
158
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
});
|
|
157
|
-
// Return error response so the adapter can handle it
|
|
158
|
-
return {
|
|
159
|
-
messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
|
|
160
|
-
stopReason: "error",
|
|
161
|
-
};
|
|
162
|
-
}
|
|
159
|
+
const exitCode = await proc.exited;
|
|
160
|
+
const stdout = await new Response(proc.stdout).text();
|
|
161
|
+
const stderr = await new Response(proc.stderr).text();
|
|
163
162
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
163
|
+
if (exitCode !== 0) {
|
|
164
|
+
getSafeLogger()?.warn("acp-adapter", `Session prompt exited with code ${exitCode}`, {
|
|
165
|
+
stderr: stderr.slice(0, 200),
|
|
166
|
+
});
|
|
167
|
+
// Return error response so the adapter can handle it
|
|
168
|
+
return {
|
|
169
|
+
messages: [{ role: "assistant", content: stderr || `Exit code ${exitCode}` }],
|
|
170
|
+
stopReason: "error",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const parsed = parseAcpxJsonOutput(stdout);
|
|
176
|
+
return {
|
|
177
|
+
messages: [{ role: "assistant", content: parsed.text || "" }],
|
|
178
|
+
stopReason: "end_turn",
|
|
179
|
+
cumulative_token_usage: parsed.tokenUsage,
|
|
180
|
+
};
|
|
181
|
+
} catch (err) {
|
|
182
|
+
getSafeLogger()?.warn("acp-adapter", "Failed to parse session prompt response", {
|
|
183
|
+
stderr: stderr.slice(0, 200),
|
|
184
|
+
});
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
this.activeProc = null;
|
|
189
|
+
await this.pidRegistry?.unregister(processPid);
|
|
176
190
|
}
|
|
177
191
|
}
|
|
178
192
|
|
|
179
193
|
async close(): Promise<void> {
|
|
194
|
+
// Kill in-flight prompt process first (if any)
|
|
195
|
+
if (this.activeProc) {
|
|
196
|
+
try {
|
|
197
|
+
this.activeProc.kill(15); // SIGTERM
|
|
198
|
+
getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
|
|
199
|
+
} catch {
|
|
200
|
+
// Process may have already exited
|
|
201
|
+
}
|
|
202
|
+
this.activeProc = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
const cmd = ["acpx", this.agentName, "sessions", "close", this.sessionName];
|
|
181
206
|
getSafeLogger()?.debug("acp-adapter", `Closing session: ${this.sessionName}`);
|
|
182
207
|
|
|
@@ -193,6 +218,16 @@ class SpawnAcpSession implements AcpSession {
|
|
|
193
218
|
}
|
|
194
219
|
|
|
195
220
|
async cancelActivePrompt(): Promise<void> {
|
|
221
|
+
// Kill in-flight prompt process directly (faster than acpx cancel)
|
|
222
|
+
if (this.activeProc) {
|
|
223
|
+
try {
|
|
224
|
+
this.activeProc.kill(15); // SIGTERM
|
|
225
|
+
getSafeLogger()?.debug("acp-adapter", `Killed active prompt process PID ${this.activeProc.pid}`);
|
|
226
|
+
} catch {
|
|
227
|
+
// Process may have already exited
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
196
231
|
const cmd = ["acpx", this.agentName, "cancel"];
|
|
197
232
|
getSafeLogger()?.debug("acp-adapter", `Cancelling active prompt: ${this.sessionName}`);
|
|
198
233
|
|
|
@@ -219,9 +254,11 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
219
254
|
private readonly model: string;
|
|
220
255
|
private readonly cwd: string;
|
|
221
256
|
private readonly timeoutSeconds: number;
|
|
257
|
+
private readonly permissionMode: string;
|
|
222
258
|
private readonly env: Record<string, string | undefined>;
|
|
259
|
+
private readonly pidRegistry?: PidRegistry;
|
|
223
260
|
|
|
224
|
-
constructor(cmdStr: string, cwd?: string, timeoutSeconds?: number) {
|
|
261
|
+
constructor(cmdStr: string, cwd?: string, timeoutSeconds?: number, pidRegistry?: PidRegistry) {
|
|
225
262
|
// Parse: "acpx --model <model> <agentName>"
|
|
226
263
|
const parts = cmdStr.split(/\s+/);
|
|
227
264
|
const modelIdx = parts.indexOf("--model");
|
|
@@ -234,7 +271,9 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
234
271
|
this.agentName = lastToken;
|
|
235
272
|
this.cwd = cwd || process.cwd();
|
|
236
273
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
274
|
+
this.permissionMode = "approve-reads";
|
|
237
275
|
this.env = buildAllowedEnv();
|
|
276
|
+
this.pidRegistry = pidRegistry;
|
|
238
277
|
}
|
|
239
278
|
|
|
240
279
|
async start(): Promise<void> {
|
|
@@ -268,6 +307,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
268
307
|
timeoutSeconds: this.timeoutSeconds,
|
|
269
308
|
permissionMode: opts.permissionMode,
|
|
270
309
|
env: this.env,
|
|
310
|
+
pidRegistry: this.pidRegistry,
|
|
271
311
|
});
|
|
272
312
|
}
|
|
273
313
|
|
|
@@ -288,8 +328,9 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
288
328
|
cwd: this.cwd,
|
|
289
329
|
model: this.model,
|
|
290
330
|
timeoutSeconds: this.timeoutSeconds,
|
|
291
|
-
permissionMode:
|
|
331
|
+
permissionMode: this.permissionMode,
|
|
292
332
|
env: this.env,
|
|
333
|
+
pidRegistry: this.pidRegistry,
|
|
293
334
|
});
|
|
294
335
|
}
|
|
295
336
|
|
|
@@ -306,6 +347,11 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
306
347
|
* Create a spawn-based ACP client. This is the default production factory.
|
|
307
348
|
* The cmdStr format is: "acpx --model <model> <agentName>"
|
|
308
349
|
*/
|
|
309
|
-
export function createSpawnAcpClient(
|
|
310
|
-
|
|
350
|
+
export function createSpawnAcpClient(
|
|
351
|
+
cmdStr: string,
|
|
352
|
+
cwd?: string,
|
|
353
|
+
timeoutSeconds?: number,
|
|
354
|
+
pidRegistry?: PidRegistry,
|
|
355
|
+
): AcpClient {
|
|
356
|
+
return new SpawnAcpClient(cmdStr, cwd, timeoutSeconds, pidRegistry);
|
|
311
357
|
}
|
|
@@ -55,6 +55,8 @@ export interface PlanOptions {
|
|
|
55
55
|
* Used to persist the name to status.json for plan→run session continuity.
|
|
56
56
|
*/
|
|
57
57
|
onAcpSessionCreated?: (sessionName: string) => Promise<void> | void;
|
|
58
|
+
/** PID registry for tracking spawned agent processes — cleanup on crash/SIGTERM */
|
|
59
|
+
pidRegistry?: import("../execution/pid-registry").PidRegistry;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/**
|
package/src/cli/plan.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { AgentAdapter } from "../agents/types";
|
|
|
15
15
|
import { scanCodebase } from "../analyze/scanner";
|
|
16
16
|
import type { CodebaseScan } from "../analyze/types";
|
|
17
17
|
import type { NaxConfig } from "../config";
|
|
18
|
+
import { PidRegistry } from "../execution/pid-registry";
|
|
18
19
|
import { getLogger } from "../logger";
|
|
19
20
|
import { validatePlanOutput } from "../prd/schema";
|
|
20
21
|
|
|
@@ -123,6 +124,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
123
124
|
const adapter = _deps.getAgent(agentName, config);
|
|
124
125
|
if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
125
126
|
const interactionBridge = createCliInteractionBridge();
|
|
127
|
+
const pidRegistry = new PidRegistry(workdir);
|
|
126
128
|
logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
|
|
127
129
|
try {
|
|
128
130
|
await adapter.plan({
|
|
@@ -136,8 +138,10 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
136
138
|
dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
|
|
137
139
|
maxInteractionTurns: config?.agent?.maxInteractionTurns,
|
|
138
140
|
featureName: options.feature,
|
|
141
|
+
pidRegistry,
|
|
139
142
|
});
|
|
140
143
|
} finally {
|
|
144
|
+
await pidRegistry.killAll().catch(() => {});
|
|
141
145
|
logger?.info("plan", "Interactive session ended");
|
|
142
146
|
}
|
|
143
147
|
// Read back from file written by agent
|
|
@@ -134,7 +134,8 @@ export function installSignalHandlers(ctx: SignalHandlerContext): () => void {
|
|
|
134
134
|
process.on("SIGINT", sigintHandler);
|
|
135
135
|
process.on("SIGHUP", sighupHandler);
|
|
136
136
|
process.on("uncaughtException", uncaughtExceptionHandler);
|
|
137
|
-
|
|
137
|
+
const rejectionWrapper = (reason: unknown) => unhandledRejectionHandler(reason);
|
|
138
|
+
process.on("unhandledRejection", rejectionWrapper);
|
|
138
139
|
|
|
139
140
|
logger?.debug("crash-recovery", "Signal handlers installed");
|
|
140
141
|
|
|
@@ -143,7 +144,7 @@ export function installSignalHandlers(ctx: SignalHandlerContext): () => void {
|
|
|
143
144
|
process.removeListener("SIGINT", sigintHandler);
|
|
144
145
|
process.removeListener("SIGHUP", sighupHandler);
|
|
145
146
|
process.removeListener("uncaughtException", uncaughtExceptionHandler);
|
|
146
|
-
process.removeListener("unhandledRejection",
|
|
147
|
+
process.removeListener("unhandledRejection", rejectionWrapper);
|
|
147
148
|
logger?.debug("crash-recovery", "Signal handlers unregistered");
|
|
148
149
|
};
|
|
149
150
|
}
|
package/src/execution/lock.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Prevents concurrent runs in the same directory.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { getLogger } from "../logger";
|
|
10
11
|
|
|
@@ -49,7 +50,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
49
50
|
if (exists) {
|
|
50
51
|
// Read lock data
|
|
51
52
|
const lockContent = await lockFile.text();
|
|
52
|
-
let lockData: { pid: number };
|
|
53
|
+
let lockData: { pid: number } | null;
|
|
53
54
|
try {
|
|
54
55
|
lockData = JSON.parse(lockContent);
|
|
55
56
|
} catch {
|
|
@@ -61,7 +62,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
61
62
|
const fs = await import("node:fs/promises");
|
|
62
63
|
await fs.unlink(lockPath).catch(() => {});
|
|
63
64
|
// Fall through to create a new lock
|
|
64
|
-
lockData =
|
|
65
|
+
lockData = null;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
if (lockData) {
|
|
@@ -88,6 +89,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
88
89
|
pid: process.pid,
|
|
89
90
|
timestamp: Date.now(),
|
|
90
91
|
};
|
|
92
|
+
// NOTE: Node.js fs used intentionally — Bun.file()/Bun.write() lacks O_CREAT|O_EXCL atomic exclusive create
|
|
91
93
|
const fs = await import("node:fs");
|
|
92
94
|
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o644);
|
|
93
95
|
fs.writeSync(fd, JSON.stringify(lockData));
|
|
@@ -114,18 +116,14 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
114
116
|
export async function releaseLock(workdir: string): Promise<void> {
|
|
115
117
|
const lockPath = path.join(workdir, "nax.lock");
|
|
116
118
|
try {
|
|
117
|
-
|
|
118
|
-
const exists = await file.exists();
|
|
119
|
-
if (exists) {
|
|
120
|
-
const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
|
|
121
|
-
await proc.exited;
|
|
122
|
-
// Wait a bit for filesystem to sync (prevents race in tests)
|
|
123
|
-
await Bun.sleep(10);
|
|
124
|
-
}
|
|
119
|
+
await unlink(lockPath);
|
|
125
120
|
} catch (error) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
// Ignore ENOENT (already gone), log others
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
123
|
+
const logger = getSafeLogger();
|
|
124
|
+
logger?.warn("execution", "Failed to release lock", {
|
|
125
|
+
error: (error as Error).message,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
130
128
|
}
|
|
131
129
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Path security utilities for nax (SEC-1, SEC-2).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Result of a path validation.
|
|
@@ -23,16 +24,32 @@ export interface PathValidationResult {
|
|
|
23
24
|
* @param allowedRoots - Array of absolute paths that are allowed as roots
|
|
24
25
|
* @returns Validation result
|
|
25
26
|
*/
|
|
27
|
+
/** Resolve symlinks for a path that may not exist yet (fall back to parent dir). */
|
|
28
|
+
function safeRealpath(p: string): string {
|
|
29
|
+
try {
|
|
30
|
+
return realpathSync(p);
|
|
31
|
+
} catch {
|
|
32
|
+
// Path doesn't exist — resolve the parent directory instead
|
|
33
|
+
try {
|
|
34
|
+
const parent = realpathSync(dirname(p));
|
|
35
|
+
return join(parent, p.split("/").pop() ?? "");
|
|
36
|
+
} catch {
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export function validateModulePath(modulePath: string, allowedRoots: string[]): PathValidationResult {
|
|
27
43
|
if (!modulePath) {
|
|
28
44
|
return { valid: false, error: "Module path is empty" };
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
// Resolve symlinks in each root
|
|
48
|
+
const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
|
|
32
49
|
|
|
33
50
|
// If absolute, just check against roots
|
|
34
51
|
if (isAbsolute(modulePath)) {
|
|
35
|
-
const absoluteTarget = normalize(modulePath);
|
|
52
|
+
const absoluteTarget = safeRealpath(normalize(modulePath));
|
|
36
53
|
const isWithin = normalizedRoots.some((root) => {
|
|
37
54
|
return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
|
|
38
55
|
});
|
|
@@ -42,7 +59,7 @@ export function validateModulePath(modulePath: string, allowedRoots: string[]):
|
|
|
42
59
|
} else {
|
|
43
60
|
// If relative, check if it's within any root when resolved relative to that root
|
|
44
61
|
for (const root of normalizedRoots) {
|
|
45
|
-
const absoluteTarget = resolve(join(root, modulePath));
|
|
62
|
+
const absoluteTarget = safeRealpath(resolve(join(root, modulePath)));
|
|
46
63
|
if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
|
|
47
64
|
return { valid: true, absolutePath: absoluteTarget };
|
|
48
65
|
}
|