@nathapp/nax 0.42.6 → 0.42.8
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 +78 -44
- package/package.json +1 -1
- package/src/agents/acp/adapter.ts +5 -0
- package/src/agents/acp/spawn-client.ts +8 -1
- package/src/cli/plan.ts +15 -4
- 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
|
@@ -18992,6 +18992,11 @@ class SpawnAcpSession {
|
|
|
18992
18992
|
"--file",
|
|
18993
18993
|
"-"
|
|
18994
18994
|
];
|
|
18995
|
+
getSafeLogger()?.info("acp-adapter", "Sending prompt", {
|
|
18996
|
+
session: this.sessionName,
|
|
18997
|
+
permission: this.permissionMode,
|
|
18998
|
+
cmd: cmd.join(" ")
|
|
18999
|
+
});
|
|
18995
19000
|
getSafeLogger()?.debug("acp-adapter", `Sending prompt to session: ${this.sessionName}`);
|
|
18996
19001
|
const proc = _spawnClientDeps.spawn(cmd, {
|
|
18997
19002
|
cwd: this.cwd,
|
|
@@ -19075,6 +19080,7 @@ class SpawnAcpClient {
|
|
|
19075
19080
|
model;
|
|
19076
19081
|
cwd;
|
|
19077
19082
|
timeoutSeconds;
|
|
19083
|
+
permissionMode;
|
|
19078
19084
|
env;
|
|
19079
19085
|
pidRegistry;
|
|
19080
19086
|
constructor(cmdStr, cwd, timeoutSeconds, pidRegistry) {
|
|
@@ -19088,6 +19094,7 @@ class SpawnAcpClient {
|
|
|
19088
19094
|
this.agentName = lastToken;
|
|
19089
19095
|
this.cwd = cwd || process.cwd();
|
|
19090
19096
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
19097
|
+
this.permissionMode = "approve-reads";
|
|
19091
19098
|
this.env = buildAllowedEnv2();
|
|
19092
19099
|
this.pidRegistry = pidRegistry;
|
|
19093
19100
|
}
|
|
@@ -19126,7 +19133,7 @@ class SpawnAcpClient {
|
|
|
19126
19133
|
cwd: this.cwd,
|
|
19127
19134
|
model: this.model,
|
|
19128
19135
|
timeoutSeconds: this.timeoutSeconds,
|
|
19129
|
-
permissionMode:
|
|
19136
|
+
permissionMode: this.permissionMode,
|
|
19130
19137
|
env: this.env,
|
|
19131
19138
|
pidRegistry: this.pidRegistry
|
|
19132
19139
|
});
|
|
@@ -19403,6 +19410,11 @@ class AcpAgentAdapter {
|
|
|
19403
19410
|
}
|
|
19404
19411
|
sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
|
|
19405
19412
|
const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
|
|
19413
|
+
getSafeLogger()?.info("acp-adapter", "Permission mode resolved", {
|
|
19414
|
+
permission: permissionMode,
|
|
19415
|
+
dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
|
|
19416
|
+
stage: options.featureName ? "run" : "plan"
|
|
19417
|
+
});
|
|
19406
19418
|
const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
|
|
19407
19419
|
if (options.featureName && options.storyId) {
|
|
19408
19420
|
await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
|
|
@@ -20200,14 +20212,27 @@ var init_chain = __esm(() => {
|
|
|
20200
20212
|
});
|
|
20201
20213
|
|
|
20202
20214
|
// src/utils/path-security.ts
|
|
20203
|
-
import {
|
|
20215
|
+
import { realpathSync } from "fs";
|
|
20216
|
+
import { dirname, isAbsolute, join as join5, normalize, resolve } from "path";
|
|
20217
|
+
function safeRealpath(p) {
|
|
20218
|
+
try {
|
|
20219
|
+
return realpathSync(p);
|
|
20220
|
+
} catch {
|
|
20221
|
+
try {
|
|
20222
|
+
const parent = realpathSync(dirname(p));
|
|
20223
|
+
return join5(parent, p.split("/").pop() ?? "");
|
|
20224
|
+
} catch {
|
|
20225
|
+
return p;
|
|
20226
|
+
}
|
|
20227
|
+
}
|
|
20228
|
+
}
|
|
20204
20229
|
function validateModulePath(modulePath, allowedRoots) {
|
|
20205
20230
|
if (!modulePath) {
|
|
20206
20231
|
return { valid: false, error: "Module path is empty" };
|
|
20207
20232
|
}
|
|
20208
|
-
const normalizedRoots = allowedRoots.map((r) => resolve(r));
|
|
20233
|
+
const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
|
|
20209
20234
|
if (isAbsolute(modulePath)) {
|
|
20210
|
-
const absoluteTarget = normalize(modulePath);
|
|
20235
|
+
const absoluteTarget = safeRealpath(normalize(modulePath));
|
|
20211
20236
|
const isWithin = normalizedRoots.some((root) => {
|
|
20212
20237
|
return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
|
|
20213
20238
|
});
|
|
@@ -20216,7 +20241,7 @@ function validateModulePath(modulePath, allowedRoots) {
|
|
|
20216
20241
|
}
|
|
20217
20242
|
} else {
|
|
20218
20243
|
for (const root of normalizedRoots) {
|
|
20219
|
-
const absoluteTarget = resolve(join5(root, modulePath));
|
|
20244
|
+
const absoluteTarget = safeRealpath(resolve(join5(root, modulePath)));
|
|
20220
20245
|
if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
|
|
20221
20246
|
return { valid: true, absolutePath: absoluteTarget };
|
|
20222
20247
|
}
|
|
@@ -20496,7 +20521,7 @@ function isPlainObject2(value) {
|
|
|
20496
20521
|
}
|
|
20497
20522
|
|
|
20498
20523
|
// src/config/path-security.ts
|
|
20499
|
-
import { existsSync as existsSync4, lstatSync, realpathSync } from "fs";
|
|
20524
|
+
import { existsSync as existsSync4, lstatSync, realpathSync as realpathSync2 } from "fs";
|
|
20500
20525
|
import { isAbsolute as isAbsolute2, normalize as normalize2, resolve as resolve3 } from "path";
|
|
20501
20526
|
function validateDirectory(dirPath, baseDir) {
|
|
20502
20527
|
const resolved = resolve3(dirPath);
|
|
@@ -20505,7 +20530,7 @@ function validateDirectory(dirPath, baseDir) {
|
|
|
20505
20530
|
}
|
|
20506
20531
|
let realPath;
|
|
20507
20532
|
try {
|
|
20508
|
-
realPath =
|
|
20533
|
+
realPath = realpathSync2(resolved);
|
|
20509
20534
|
} catch (error48) {
|
|
20510
20535
|
throw new Error(`Failed to resolve path: ${dirPath} (${error48.message})`);
|
|
20511
20536
|
}
|
|
@@ -20519,7 +20544,7 @@ function validateDirectory(dirPath, baseDir) {
|
|
|
20519
20544
|
}
|
|
20520
20545
|
if (baseDir) {
|
|
20521
20546
|
const resolvedBase = resolve3(baseDir);
|
|
20522
|
-
const realBase = existsSync4(resolvedBase) ?
|
|
20547
|
+
const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
|
|
20523
20548
|
if (!isWithinDirectory(realPath, realBase)) {
|
|
20524
20549
|
throw new Error(`Path is outside allowed directory: ${dirPath} (resolved to ${realPath}, base: ${realBase})`);
|
|
20525
20550
|
}
|
|
@@ -20543,19 +20568,19 @@ function validateFilePath(filePath, baseDir) {
|
|
|
20543
20568
|
if (!existsSync4(resolved)) {
|
|
20544
20569
|
const parent = resolve3(resolved, "..");
|
|
20545
20570
|
if (existsSync4(parent)) {
|
|
20546
|
-
const realParent =
|
|
20571
|
+
const realParent = realpathSync2(parent);
|
|
20547
20572
|
realPath = resolve3(realParent, filePath.split("/").pop() || "");
|
|
20548
20573
|
} else {
|
|
20549
20574
|
realPath = resolved;
|
|
20550
20575
|
}
|
|
20551
20576
|
} else {
|
|
20552
|
-
realPath =
|
|
20577
|
+
realPath = realpathSync2(resolved);
|
|
20553
20578
|
}
|
|
20554
20579
|
} catch (error48) {
|
|
20555
20580
|
throw new Error(`Failed to resolve path: ${filePath} (${error48.message})`);
|
|
20556
20581
|
}
|
|
20557
20582
|
const resolvedBase = resolve3(baseDir);
|
|
20558
|
-
const realBase = existsSync4(resolvedBase) ?
|
|
20583
|
+
const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
|
|
20559
20584
|
if (!isWithinDirectory(realPath, realBase)) {
|
|
20560
20585
|
throw new Error(`Path is outside allowed directory: ${filePath} (resolved to ${realPath}, base: ${realBase})`);
|
|
20561
20586
|
}
|
|
@@ -21899,7 +21924,7 @@ var package_default;
|
|
|
21899
21924
|
var init_package = __esm(() => {
|
|
21900
21925
|
package_default = {
|
|
21901
21926
|
name: "@nathapp/nax",
|
|
21902
|
-
version: "0.42.
|
|
21927
|
+
version: "0.42.8",
|
|
21903
21928
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
21904
21929
|
type: "module",
|
|
21905
21930
|
bin: {
|
|
@@ -21972,8 +21997,8 @@ var init_version = __esm(() => {
|
|
|
21972
21997
|
NAX_VERSION = package_default.version;
|
|
21973
21998
|
NAX_COMMIT = (() => {
|
|
21974
21999
|
try {
|
|
21975
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
21976
|
-
return "
|
|
22000
|
+
if (/^[0-9a-f]{6,10}$/.test("5dc5b37"))
|
|
22001
|
+
return "5dc5b37";
|
|
21977
22002
|
} catch {}
|
|
21978
22003
|
try {
|
|
21979
22004
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -24452,7 +24477,7 @@ var init_constitution = __esm(() => {
|
|
|
24452
24477
|
});
|
|
24453
24478
|
|
|
24454
24479
|
// src/pipeline/stages/constitution.ts
|
|
24455
|
-
import { dirname } from "path";
|
|
24480
|
+
import { dirname as dirname2 } from "path";
|
|
24456
24481
|
var constitutionStage;
|
|
24457
24482
|
var init_constitution2 = __esm(() => {
|
|
24458
24483
|
init_constitution();
|
|
@@ -24462,7 +24487,7 @@ var init_constitution2 = __esm(() => {
|
|
|
24462
24487
|
enabled: (ctx) => ctx.config.constitution.enabled,
|
|
24463
24488
|
async execute(ctx) {
|
|
24464
24489
|
const logger = getLogger();
|
|
24465
|
-
const ngentDir = ctx.featureDir ?
|
|
24490
|
+
const ngentDir = ctx.featureDir ? dirname2(dirname2(ctx.featureDir)) : `${ctx.workdir}/nax`;
|
|
24466
24491
|
const result = await loadConstitution(ngentDir, ctx.config.constitution);
|
|
24467
24492
|
if (result) {
|
|
24468
24493
|
ctx.constitution = result;
|
|
@@ -25259,6 +25284,7 @@ var init_story_context = __esm(() => {
|
|
|
25259
25284
|
});
|
|
25260
25285
|
|
|
25261
25286
|
// src/execution/lock.ts
|
|
25287
|
+
import { unlink } from "fs/promises";
|
|
25262
25288
|
import path8 from "path";
|
|
25263
25289
|
function getSafeLogger3() {
|
|
25264
25290
|
try {
|
|
@@ -25292,7 +25318,7 @@ async function acquireLock(workdir) {
|
|
|
25292
25318
|
});
|
|
25293
25319
|
const fs2 = await import("fs/promises");
|
|
25294
25320
|
await fs2.unlink(lockPath).catch(() => {});
|
|
25295
|
-
lockData2 =
|
|
25321
|
+
lockData2 = null;
|
|
25296
25322
|
}
|
|
25297
25323
|
if (lockData2) {
|
|
25298
25324
|
const lockPid = lockData2.pid;
|
|
@@ -25330,18 +25356,14 @@ async function acquireLock(workdir) {
|
|
|
25330
25356
|
async function releaseLock(workdir) {
|
|
25331
25357
|
const lockPath = path8.join(workdir, "nax.lock");
|
|
25332
25358
|
try {
|
|
25333
|
-
|
|
25334
|
-
const exists = await file2.exists();
|
|
25335
|
-
if (exists) {
|
|
25336
|
-
const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
|
|
25337
|
-
await proc.exited;
|
|
25338
|
-
await Bun.sleep(10);
|
|
25339
|
-
}
|
|
25359
|
+
await unlink(lockPath);
|
|
25340
25360
|
} catch (error48) {
|
|
25341
|
-
|
|
25342
|
-
|
|
25343
|
-
|
|
25344
|
-
|
|
25361
|
+
if (error48.code !== "ENOENT") {
|
|
25362
|
+
const logger = getSafeLogger3();
|
|
25363
|
+
logger?.warn("execution", "Failed to release lock", {
|
|
25364
|
+
error: error48.message
|
|
25365
|
+
});
|
|
25366
|
+
}
|
|
25345
25367
|
}
|
|
25346
25368
|
}
|
|
25347
25369
|
var init_lock = __esm(() => {
|
|
@@ -25684,17 +25706,17 @@ async function autoCommitIfDirty(workdir, stage, role, storyId) {
|
|
|
25684
25706
|
});
|
|
25685
25707
|
const gitRoot = (await new Response(topLevelProc.stdout).text()).trim();
|
|
25686
25708
|
await topLevelProc.exited;
|
|
25687
|
-
const { realpathSync:
|
|
25709
|
+
const { realpathSync: realpathSync4 } = await import("fs");
|
|
25688
25710
|
const realWorkdir = (() => {
|
|
25689
25711
|
try {
|
|
25690
|
-
return
|
|
25712
|
+
return realpathSync4(workdir);
|
|
25691
25713
|
} catch {
|
|
25692
25714
|
return workdir;
|
|
25693
25715
|
}
|
|
25694
25716
|
})();
|
|
25695
25717
|
const realGitRoot = (() => {
|
|
25696
25718
|
try {
|
|
25697
|
-
return
|
|
25719
|
+
return realpathSync4(gitRoot);
|
|
25698
25720
|
} catch {
|
|
25699
25721
|
return gitRoot;
|
|
25700
25722
|
}
|
|
@@ -26986,7 +27008,7 @@ var init_session_runner = __esm(() => {
|
|
|
26986
27008
|
});
|
|
26987
27009
|
|
|
26988
27010
|
// src/tdd/verdict-reader.ts
|
|
26989
|
-
import { unlink } from "fs/promises";
|
|
27011
|
+
import { unlink as unlink2 } from "fs/promises";
|
|
26990
27012
|
import path9 from "path";
|
|
26991
27013
|
function isValidVerdict(obj) {
|
|
26992
27014
|
if (!obj || typeof obj !== "object")
|
|
@@ -27186,7 +27208,7 @@ async function readVerdict(workdir) {
|
|
|
27186
27208
|
async function cleanupVerdict(workdir) {
|
|
27187
27209
|
const verdictPath = path9.join(workdir, VERDICT_FILE);
|
|
27188
27210
|
try {
|
|
27189
|
-
await
|
|
27211
|
+
await unlink2(verdictPath);
|
|
27190
27212
|
} catch {}
|
|
27191
27213
|
}
|
|
27192
27214
|
var VERDICT_FILE = ".nax-verifier-verdict.json";
|
|
@@ -30912,14 +30934,15 @@ function installSignalHandlers(ctx) {
|
|
|
30912
30934
|
process.on("SIGINT", sigintHandler);
|
|
30913
30935
|
process.on("SIGHUP", sighupHandler);
|
|
30914
30936
|
process.on("uncaughtException", uncaughtExceptionHandler);
|
|
30915
|
-
|
|
30937
|
+
const rejectionWrapper = (reason) => unhandledRejectionHandler(reason);
|
|
30938
|
+
process.on("unhandledRejection", rejectionWrapper);
|
|
30916
30939
|
logger?.debug("crash-recovery", "Signal handlers installed");
|
|
30917
30940
|
return () => {
|
|
30918
30941
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
30919
30942
|
process.removeListener("SIGINT", sigintHandler);
|
|
30920
30943
|
process.removeListener("SIGHUP", sighupHandler);
|
|
30921
30944
|
process.removeListener("uncaughtException", uncaughtExceptionHandler);
|
|
30922
|
-
process.removeListener("unhandledRejection",
|
|
30945
|
+
process.removeListener("unhandledRejection", rejectionWrapper);
|
|
30923
30946
|
logger?.debug("crash-recovery", "Signal handlers unregistered");
|
|
30924
30947
|
};
|
|
30925
30948
|
}
|
|
@@ -33695,7 +33718,7 @@ var init_sequential_executor = __esm(() => {
|
|
|
33695
33718
|
});
|
|
33696
33719
|
|
|
33697
33720
|
// src/execution/status-file.ts
|
|
33698
|
-
import { rename, unlink as
|
|
33721
|
+
import { rename, unlink as unlink3 } from "fs/promises";
|
|
33699
33722
|
import { resolve as resolve8 } from "path";
|
|
33700
33723
|
function countProgress(prd) {
|
|
33701
33724
|
const stories = prd.userStories;
|
|
@@ -33744,7 +33767,7 @@ async function writeStatusFile(filePath, status) {
|
|
|
33744
33767
|
}
|
|
33745
33768
|
const tmpPath = `${resolvedPath}.tmp`;
|
|
33746
33769
|
try {
|
|
33747
|
-
await
|
|
33770
|
+
await unlink3(tmpPath);
|
|
33748
33771
|
} catch {}
|
|
33749
33772
|
await Bun.write(tmpPath, JSON.stringify(status, null, 2));
|
|
33750
33773
|
await rename(tmpPath, resolvedPath);
|
|
@@ -65836,7 +65859,18 @@ async function planCommand(workdir, config2, options) {
|
|
|
65836
65859
|
throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
65837
65860
|
const interactionBridge = createCliInteractionBridge();
|
|
65838
65861
|
const pidRegistry = new PidRegistry(workdir);
|
|
65839
|
-
|
|
65862
|
+
const dangerouslySkipPermissions = config2?.execution?.dangerouslySkipPermissions ?? false;
|
|
65863
|
+
const permissionMode = dangerouslySkipPermissions ? "approve-all" : "approve-reads";
|
|
65864
|
+
const resolvedModel = config2?.plan?.model ?? "balanced";
|
|
65865
|
+
logger?.info("plan", "Starting interactive planning session", {
|
|
65866
|
+
agent: agentName,
|
|
65867
|
+
model: resolvedModel,
|
|
65868
|
+
permission: permissionMode,
|
|
65869
|
+
workdir,
|
|
65870
|
+
feature: options.feature,
|
|
65871
|
+
timeoutSeconds
|
|
65872
|
+
});
|
|
65873
|
+
const planStartTime = Date.now();
|
|
65840
65874
|
try {
|
|
65841
65875
|
await adapter.plan({
|
|
65842
65876
|
prompt,
|
|
@@ -65845,15 +65879,15 @@ async function planCommand(workdir, config2, options) {
|
|
|
65845
65879
|
timeoutSeconds,
|
|
65846
65880
|
interactionBridge,
|
|
65847
65881
|
config: config2,
|
|
65848
|
-
modelTier:
|
|
65849
|
-
dangerouslySkipPermissions
|
|
65882
|
+
modelTier: resolvedModel,
|
|
65883
|
+
dangerouslySkipPermissions,
|
|
65850
65884
|
maxInteractionTurns: config2?.agent?.maxInteractionTurns,
|
|
65851
65885
|
featureName: options.feature,
|
|
65852
65886
|
pidRegistry
|
|
65853
65887
|
});
|
|
65854
65888
|
} finally {
|
|
65855
65889
|
await pidRegistry.killAll().catch(() => {});
|
|
65856
|
-
logger?.info("plan", "Interactive session ended");
|
|
65890
|
+
logger?.info("plan", "Interactive session ended", { durationMs: Date.now() - planStartTime });
|
|
65857
65891
|
}
|
|
65858
65892
|
if (!_deps2.existsSync(outputPath)) {
|
|
65859
65893
|
throw new Error(`[plan] Agent did not write PRD to ${outputPath}. Check agent logs for errors.`);
|
|
@@ -66140,7 +66174,7 @@ import { join as join13 } from "path";
|
|
|
66140
66174
|
// src/commands/common.ts
|
|
66141
66175
|
init_path_security2();
|
|
66142
66176
|
init_errors3();
|
|
66143
|
-
import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as
|
|
66177
|
+
import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync3 } from "fs";
|
|
66144
66178
|
import { join as join11, resolve as resolve6 } from "path";
|
|
66145
66179
|
function resolveProject(options = {}) {
|
|
66146
66180
|
const { dir, feature } = options;
|
|
@@ -66148,7 +66182,7 @@ function resolveProject(options = {}) {
|
|
|
66148
66182
|
let naxDir;
|
|
66149
66183
|
let configPath;
|
|
66150
66184
|
if (dir) {
|
|
66151
|
-
projectRoot =
|
|
66185
|
+
projectRoot = realpathSync3(resolve6(dir));
|
|
66152
66186
|
naxDir = join11(projectRoot, "nax");
|
|
66153
66187
|
if (!existsSync10(naxDir)) {
|
|
66154
66188
|
throw new NaxError(`Directory does not contain a nax project: ${projectRoot}
|
|
@@ -66207,7 +66241,7 @@ function findProjectRoot(startDir) {
|
|
|
66207
66241
|
const naxDir = join11(current, "nax");
|
|
66208
66242
|
const configPath = join11(naxDir, "config.json");
|
|
66209
66243
|
if (existsSync10(configPath)) {
|
|
66210
|
-
return
|
|
66244
|
+
return realpathSync3(current);
|
|
66211
66245
|
}
|
|
66212
66246
|
const parent = join11(current, "..");
|
|
66213
66247
|
if (parent === current) {
|
package/package.json
CHANGED
|
@@ -453,6 +453,11 @@ export class AcpAgentAdapter implements AgentAdapter {
|
|
|
453
453
|
|
|
454
454
|
// 2. Permission mode follows dangerouslySkipPermissions, default is "approve-reads". or should --deny-all be the default?
|
|
455
455
|
const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
|
|
456
|
+
getSafeLogger()?.info("acp-adapter", "Permission mode resolved", {
|
|
457
|
+
permission: permissionMode,
|
|
458
|
+
dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
|
|
459
|
+
stage: options.featureName ? "run" : "plan",
|
|
460
|
+
});
|
|
456
461
|
|
|
457
462
|
// 3. Ensure session (resume existing or create new)
|
|
458
463
|
const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
|
|
@@ -138,6 +138,11 @@ class SpawnAcpSession implements AcpSession {
|
|
|
138
138
|
"-",
|
|
139
139
|
];
|
|
140
140
|
|
|
141
|
+
getSafeLogger()?.info("acp-adapter", "Sending prompt", {
|
|
142
|
+
session: this.sessionName,
|
|
143
|
+
permission: this.permissionMode,
|
|
144
|
+
cmd: cmd.join(" "),
|
|
145
|
+
});
|
|
141
146
|
getSafeLogger()?.debug("acp-adapter", `Sending prompt to session: ${this.sessionName}`);
|
|
142
147
|
|
|
143
148
|
const proc = _spawnClientDeps.spawn(cmd, {
|
|
@@ -254,6 +259,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
254
259
|
private readonly model: string;
|
|
255
260
|
private readonly cwd: string;
|
|
256
261
|
private readonly timeoutSeconds: number;
|
|
262
|
+
private readonly permissionMode: string;
|
|
257
263
|
private readonly env: Record<string, string | undefined>;
|
|
258
264
|
private readonly pidRegistry?: PidRegistry;
|
|
259
265
|
|
|
@@ -270,6 +276,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
270
276
|
this.agentName = lastToken;
|
|
271
277
|
this.cwd = cwd || process.cwd();
|
|
272
278
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
279
|
+
this.permissionMode = "approve-reads";
|
|
273
280
|
this.env = buildAllowedEnv();
|
|
274
281
|
this.pidRegistry = pidRegistry;
|
|
275
282
|
}
|
|
@@ -326,7 +333,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
326
333
|
cwd: this.cwd,
|
|
327
334
|
model: this.model,
|
|
328
335
|
timeoutSeconds: this.timeoutSeconds,
|
|
329
|
-
permissionMode:
|
|
336
|
+
permissionMode: this.permissionMode,
|
|
330
337
|
env: this.env,
|
|
331
338
|
pidRegistry: this.pidRegistry,
|
|
332
339
|
});
|
package/src/cli/plan.ts
CHANGED
|
@@ -125,7 +125,18 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
125
125
|
if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
|
|
126
126
|
const interactionBridge = createCliInteractionBridge();
|
|
127
127
|
const pidRegistry = new PidRegistry(workdir);
|
|
128
|
-
|
|
128
|
+
const dangerouslySkipPermissions = config?.execution?.dangerouslySkipPermissions ?? false;
|
|
129
|
+
const permissionMode = dangerouslySkipPermissions ? "approve-all" : "approve-reads";
|
|
130
|
+
const resolvedModel = config?.plan?.model ?? "balanced";
|
|
131
|
+
logger?.info("plan", "Starting interactive planning session", {
|
|
132
|
+
agent: agentName,
|
|
133
|
+
model: resolvedModel,
|
|
134
|
+
permission: permissionMode,
|
|
135
|
+
workdir,
|
|
136
|
+
feature: options.feature,
|
|
137
|
+
timeoutSeconds,
|
|
138
|
+
});
|
|
139
|
+
const planStartTime = Date.now();
|
|
129
140
|
try {
|
|
130
141
|
await adapter.plan({
|
|
131
142
|
prompt,
|
|
@@ -134,15 +145,15 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
|
|
|
134
145
|
timeoutSeconds,
|
|
135
146
|
interactionBridge,
|
|
136
147
|
config,
|
|
137
|
-
modelTier:
|
|
138
|
-
dangerouslySkipPermissions
|
|
148
|
+
modelTier: resolvedModel,
|
|
149
|
+
dangerouslySkipPermissions,
|
|
139
150
|
maxInteractionTurns: config?.agent?.maxInteractionTurns,
|
|
140
151
|
featureName: options.feature,
|
|
141
152
|
pidRegistry,
|
|
142
153
|
});
|
|
143
154
|
} finally {
|
|
144
155
|
await pidRegistry.killAll().catch(() => {});
|
|
145
|
-
logger?.info("plan", "Interactive session ended");
|
|
156
|
+
logger?.info("plan", "Interactive session ended", { durationMs: Date.now() - planStartTime });
|
|
146
157
|
}
|
|
147
158
|
// Read back from file written by agent
|
|
148
159
|
if (!_deps.existsSync(outputPath)) {
|
|
@@ -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
|
}
|