praxis-agent 0.46.2 → 0.46.4

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
@@ -153,6 +153,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
153
153
  structured JSON/JSONL, context compaction, tool loops, and bounded execution.
154
154
  - **Built-in tools** — read, write, edit, glob, search, shell, notebook, PDF,
155
155
  image, web, scheduled prompts, workflows, and worktrees.
156
+ - **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
157
+ validated final working directory across calls in the same session without
158
+ leaking state across sessions or overriding an explicit `/cd`.
156
159
  - **Permission boundary** — local allow/ask/deny rules, safe and bare modes,
157
160
  searchable scoped-rule creation/removal, local/project/user atomic settings
158
161
  writes, tool-specific Bash/PowerShell/file/notebook/WebFetch/Skill approval
@@ -5,6 +5,7 @@ export interface ProcessResult {
5
5
  code: number;
6
6
  timedOut: boolean;
7
7
  truncated: boolean;
8
+ controlOutput?: string;
8
9
  }
9
10
  export interface BoundedProcessRunnerOptions {
10
11
  cwd: string;
@@ -18,6 +19,9 @@ export interface RunProcessOptions {
18
19
  signal?: AbortSignal;
19
20
  onOutput?: (output: string) => void | Promise<void>;
20
21
  env?: Readonly<Record<string, string>>;
22
+ controlOutputBytes?: number;
23
+ controlOutputFd?: number;
24
+ scriptInput?: string;
21
25
  }
22
26
  export declare function joinedProcessOutput(result: ProcessResult): string;
23
27
  export declare class BoundedProcessRunner {
@@ -87,15 +87,35 @@ export class BoundedProcessRunner {
87
87
  const sensitiveValues = sensitiveEnvironmentValues(process.env);
88
88
  const longestSensitiveValueBytes = sensitiveValues.reduce((longest, value) => Math.max(longest, Buffer.byteLength(value)), 0);
89
89
  const rawOutputLimit = this.options.maxOutputBytes + Math.max(3, longestSensitiveValueBytes);
90
+ const controlOutputFd = options.controlOutputBytes === undefined
91
+ ? undefined
92
+ : (options.controlOutputFd ?? 3);
93
+ const scriptInputFd = options.scriptInput === undefined ? undefined : 4;
94
+ if (controlOutputFd !== undefined &&
95
+ scriptInputFd !== undefined &&
96
+ controlOutputFd === scriptInputFd) {
97
+ reject(new Error('Process control output and script input FDs conflict'));
98
+ return;
99
+ }
100
+ const highestFd = Math.max(2, controlOutputFd ?? 2, scriptInputFd ?? 2);
101
+ const stdio = Array.from({ length: highestFd + 1 }, () => 'ignore');
102
+ stdio[1] = 'pipe';
103
+ stdio[2] = 'pipe';
104
+ if (controlOutputFd !== undefined)
105
+ stdio[controlOutputFd] = 'pipe';
106
+ if (scriptInputFd !== undefined)
107
+ stdio[scriptInputFd] = 'pipe';
90
108
  const child = spawn(options.command, options.args, {
91
109
  cwd: options.cwd ?? this.options.cwd,
92
110
  detached: process.platform !== 'win32',
93
111
  env: { ...sanitizeChildEnvironment(), ...options.env },
94
- stdio: ['ignore', 'pipe', 'pipe'],
112
+ stdio,
95
113
  });
96
114
  const chunks = { stdout: [], stderr: [] };
97
115
  const combined = [];
98
116
  const retainedBytes = { stdout: 0, stderr: 0, combined: 0 };
117
+ const controlChunks = [];
118
+ let controlBytes = 0;
99
119
  let outputBytes = 0;
100
120
  let timedOut = false;
101
121
  let settled = false;
@@ -136,8 +156,41 @@ export class BoundedProcessRunner {
136
156
  }
137
157
  updateLiveOutput();
138
158
  };
159
+ if (!child.stdout || !child.stderr) {
160
+ reject(new Error('Process output streams were not created'));
161
+ return;
162
+ }
139
163
  child.stdout.on('data', (chunk) => retain('stdout', chunk));
140
164
  child.stderr.on('data', (chunk) => retain('stderr', chunk));
165
+ const controlStream = controlOutputFd === undefined ? undefined : child.stdio[controlOutputFd];
166
+ if (controlStream) {
167
+ controlStream.on('data', (chunk) => {
168
+ const remaining = Math.max(0, (options.controlOutputBytes ?? 0) - controlBytes);
169
+ if (remaining <= 0)
170
+ return;
171
+ const retained = chunk.subarray(0, remaining);
172
+ controlChunks.push(retained);
173
+ controlBytes += retained.length;
174
+ });
175
+ child.once('exit', () => {
176
+ setImmediate(() => controlStream.destroy());
177
+ });
178
+ }
179
+ const scriptStream = scriptInputFd === undefined ? undefined : child.stdio[scriptInputFd];
180
+ if (scriptStream) {
181
+ scriptStream.on('error', () => {
182
+ // The child process result remains authoritative if it exits before
183
+ // consuming the complete script.
184
+ });
185
+ if (!('end' in scriptStream)) {
186
+ reject(new Error('Process script input stream is not writable'));
187
+ return;
188
+ }
189
+ scriptStream.end(options.scriptInput);
190
+ child.once('exit', () => {
191
+ setImmediate(() => scriptStream.destroy());
192
+ });
193
+ }
141
194
  const kill = () => {
142
195
  if (child.pid === undefined)
143
196
  return;
@@ -200,6 +253,11 @@ export class BoundedProcessRunner {
200
253
  code: code ?? 1,
201
254
  timedOut,
202
255
  truncated,
256
+ ...(options.controlOutputBytes === undefined
257
+ ? {}
258
+ : {
259
+ controlOutput: Buffer.concat(controlChunks).toString('utf8'),
260
+ }),
203
261
  });
204
262
  }, reject);
205
263
  });
@@ -21,6 +21,7 @@ export interface SandboxBackend {
21
21
  export interface SandboxCommandInput {
22
22
  command: string;
23
23
  dangerouslyDisableSandbox?: boolean;
24
+ executionCommand?: string;
24
25
  }
25
26
  export declare class ClaudeSandboxRuntime {
26
27
  private readonly backend;
@@ -136,7 +136,7 @@ export class ClaudeSandboxRuntime {
136
136
  throw new Error('Sandbox has not been initialized');
137
137
  if (!this.shouldUseSandbox(input))
138
138
  return input.command;
139
- return this.backend.wrapWithSandbox(input.command, options.shell, undefined, options.signal, {
139
+ return this.backend.wrapWithSandbox(input.executionCommand ?? input.command, options.shell, undefined, options.signal, {
140
140
  ...(options.commandId ? { commandId: options.commandId } : {}),
141
141
  commandText: input.command,
142
142
  });
@@ -26,6 +26,7 @@ export interface BashSandboxRuntime {
26
26
  wrapCommand(input: {
27
27
  command: string;
28
28
  dangerouslyDisableSandbox?: boolean;
29
+ executionCommand?: string;
29
30
  }, options?: {
30
31
  shell?: string;
31
32
  signal?: AbortSignal;
@@ -43,6 +44,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
43
44
  private readonly maxOutputBytes;
44
45
  private readonly maxFileBytes;
45
46
  private readonly maxShellTimeoutMs;
47
+ private readonly maxSearchTimeoutMs;
46
48
  private readonly processRunner;
47
49
  private readonly enableReportFindings;
48
50
  private readonly environment;
@@ -50,11 +52,14 @@ export declare class LocalToolRegistry implements ToolRegistry {
50
52
  private readonly sandbox;
51
53
  private readonly homeDirectory;
52
54
  private readonly configRoot;
55
+ private readonly sessionCwds;
53
56
  private readonly protectedWriteReason;
57
+ private readonly mutationTargetExisted;
54
58
  constructor(options: LocalToolRegistryOptions);
55
59
  private assertProtectedWritePath;
56
60
  private assertProtectedBashCommand;
57
61
  private currentCwd;
62
+ private currentBashCwd;
58
63
  definitions(): readonly ModelToolDefinition[];
59
64
  schedulingPolicy(call: ModelToolCall): {
60
65
  concurrency: "concurrent";
@@ -78,7 +83,9 @@ export declare class LocalToolRegistry implements ToolRegistry {
78
83
  private workspacePath;
79
84
  private filePath;
80
85
  private resolvePath;
86
+ private pathExists;
81
87
  private assertStablePath;
88
+ private assertStableCreationParent;
82
89
  private globRoot;
83
90
  private wasSuccessfullyRead;
84
91
  private read;
@@ -1,4 +1,5 @@
1
1
  import { constants } from 'node:fs';
2
+ import { randomBytes, randomInt } from 'node:crypto';
2
3
  import { mkdir, mkdtemp, open, realpath, stat, writeFile, } from 'node:fs/promises';
3
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from 'node:path';
4
5
  import { homedir, tmpdir } from 'node:os';
@@ -247,6 +248,20 @@ function stringInput(input, name, allowEmpty = false) {
247
248
  }
248
249
  return value;
249
250
  }
251
+ function stripShellControlTrace(content, token) {
252
+ if (!content.includes(token))
253
+ return content;
254
+ return content
255
+ .split('\n')
256
+ .filter((line) => !line.includes(token))
257
+ .join('\n');
258
+ }
259
+ function commandShellSyntaxArguments(command) {
260
+ const args = [...commandShellArguments(command)];
261
+ const commandIndex = args.indexOf('-c');
262
+ args.splice(commandIndex, 0, '-n');
263
+ return args;
264
+ }
250
265
  function optionalString(input, name) {
251
266
  const value = input[name];
252
267
  if (value === undefined)
@@ -480,6 +495,14 @@ function formatKilobytes(bytes) {
480
495
  const value = bytes / 1024;
481
496
  return `${value < 10 ? value.toFixed(1) : Math.round(value)}KB`.replace('.0KB', 'KB');
482
497
  }
498
+ function newlineStyle(content) {
499
+ return content.match(/\r\n|\n|\r/u)?.[0];
500
+ }
501
+ function normalizeNewlines(content, style) {
502
+ if (style === undefined)
503
+ return content;
504
+ return content.replace(/\r\n|\n|\r/gu, style);
505
+ }
483
506
  function parsePdfPages(value) {
484
507
  const match = /^(\d+)(?:-(\d+))?$/u.exec(value);
485
508
  if (!match)
@@ -506,6 +529,7 @@ export class LocalToolRegistry {
506
529
  maxOutputBytes;
507
530
  maxFileBytes;
508
531
  maxShellTimeoutMs;
532
+ maxSearchTimeoutMs;
509
533
  processRunner;
510
534
  enableReportFindings;
511
535
  environment;
@@ -513,7 +537,9 @@ export class LocalToolRegistry {
513
537
  sandbox;
514
538
  homeDirectory;
515
539
  configRoot;
540
+ sessionCwds = new Map();
516
541
  protectedWriteReason;
542
+ mutationTargetExisted = new WeakMap();
517
543
  constructor(options) {
518
544
  this.cwd = resolve(options.cwd);
519
545
  this.cwdProvider = options.cwdProvider;
@@ -524,7 +550,8 @@ export class LocalToolRegistry {
524
550
  this.additionalReadDirectories = (options.additionalReadDirectories ?? []).map((directory) => resolve(directory));
525
551
  this.maxOutputBytes = options.maxOutputBytes ?? 128 * 1024;
526
552
  this.maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
527
- this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
553
+ this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 600_000;
554
+ this.maxSearchTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
528
555
  this.enableReportFindings = options.enableReportFindings ?? false;
529
556
  this.environment = options.environment;
530
557
  this.sessionEnvironment = options.sessionEnvironment;
@@ -551,7 +578,7 @@ export class LocalToolRegistry {
551
578
  }
552
579
  }
553
580
  assertProtectedBashCommand(command, context) {
554
- const cwd = this.currentCwd(context);
581
+ const cwd = this.currentBashCwd(context);
555
582
  const protectedWrite = context?.preToolUseAllowed
556
583
  ? (filePath) => {
557
584
  const reason = this.protectedWriteReason(filePath);
@@ -576,6 +603,18 @@ export class LocalToolRegistry {
576
603
  currentCwd(context) {
577
604
  return resolve(context?.cwd || this.cwdProvider?.() || this.cwd);
578
605
  }
606
+ currentBashCwd(context) {
607
+ const hostCwd = this.currentCwd(context);
608
+ if (context?.sessionId) {
609
+ const sessionCwd = this.sessionCwds.get(context.sessionId);
610
+ if (sessionCwd) {
611
+ if (sessionCwd.hostCwd === hostCwd)
612
+ return sessionCwd.cwd;
613
+ this.sessionCwds.delete(context.sessionId);
614
+ }
615
+ }
616
+ return hostCwd;
617
+ }
579
618
  definitions() {
580
619
  const definitions = this.enableReportFindings
581
620
  ? [...TOOL_DEFINITIONS, REPORT_FINDINGS_DEFINITION]
@@ -646,28 +685,48 @@ export class LocalToolRegistry {
646
685
  },
647
686
  };
648
687
  }
649
- case 'Write':
650
- return {
688
+ case 'Write': {
689
+ const filePath = await this.filePath(stringInput(call.input, 'file_path'), true, false, context);
690
+ this.assertProtectedWritePath(filePath);
691
+ const targetExisted = await this.pathExists(filePath);
692
+ if (targetExisted &&
693
+ !(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
694
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
695
+ }
696
+ const prepared = {
651
697
  ...call,
652
698
  input: {
653
- file_path: await this.filePath(stringInput(call.input, 'file_path'), true, false, context),
699
+ file_path: filePath,
654
700
  content: stringInput(call.input, 'content', true),
655
701
  },
656
702
  };
703
+ this.mutationTargetExisted.set(prepared, targetExisted);
704
+ return prepared;
705
+ }
657
706
  case 'Edit': {
658
707
  const replaceAll = call.input.replace_all;
659
708
  if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
660
709
  throw new Error('replace_all must be a boolean');
661
710
  }
662
- return {
711
+ const oldString = stringInput(call.input, 'old_string', true);
712
+ const filePath = await this.filePath(stringInput(call.input, 'file_path'), oldString === '', false, context);
713
+ this.assertProtectedWritePath(filePath);
714
+ const targetExisted = await this.pathExists(filePath);
715
+ if (targetExisted &&
716
+ !(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
717
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
718
+ }
719
+ const prepared = {
663
720
  ...call,
664
721
  input: {
665
- file_path: await this.filePath(stringInput(call.input, 'file_path'), false, false, context),
666
- old_string: stringInput(call.input, 'old_string'),
722
+ file_path: filePath,
723
+ old_string: oldString,
667
724
  new_string: stringInput(call.input, 'new_string', true),
668
725
  replace_all: replaceAll ?? false,
669
726
  },
670
727
  };
728
+ this.mutationTargetExisted.set(prepared, targetExisted);
729
+ return prepared;
671
730
  }
672
731
  case 'NotebookEdit': {
673
732
  const requestedPath = stringInput(call.input, 'notebook_path');
@@ -759,17 +818,24 @@ export class LocalToolRegistry {
759
818
  async execute(call, context) {
760
819
  if (context.signal?.aborted)
761
820
  throw abortError();
821
+ const approvedTargetExisted = this.mutationTargetExisted.get(call);
762
822
  const prepared = await this.prepare(call, context);
763
823
  if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
764
824
  throw new Error('Tool input changed after permission approval');
765
825
  }
826
+ const executionTargetExisted = this.mutationTargetExisted.get(prepared);
827
+ if (approvedTargetExisted !== undefined &&
828
+ executionTargetExisted !== approvedTargetExisted) {
829
+ throw new Error('Tool input changed after permission approval');
830
+ }
831
+ const targetExisted = approvedTargetExisted ?? executionTargetExisted;
766
832
  switch (prepared.name) {
767
833
  case 'Read':
768
834
  return this.read(prepared, context);
769
835
  case 'Write':
770
- return this.write(prepared);
836
+ return this.write(prepared, context, targetExisted);
771
837
  case 'Edit':
772
- return this.edit(prepared);
838
+ return this.edit(prepared, targetExisted);
773
839
  case 'NotebookEdit':
774
840
  return this.notebookEdit(prepared);
775
841
  case 'Glob':
@@ -864,11 +930,28 @@ export class LocalToolRegistry {
864
930
  return join(parent, basename(candidate));
865
931
  }
866
932
  }
933
+ async pathExists(filePath) {
934
+ try {
935
+ await stat(filePath);
936
+ return true;
937
+ }
938
+ catch (error) {
939
+ if (error.code === 'ENOENT')
940
+ return false;
941
+ throw error;
942
+ }
943
+ }
867
944
  async assertStablePath(filePath) {
868
945
  if ((await realpath(filePath)) !== filePath) {
869
946
  throw new Error('Tool input changed after permission approval');
870
947
  }
871
948
  }
949
+ async assertStableCreationParent(filePath) {
950
+ const parent = dirname(filePath);
951
+ if ((await realpath(parent)) !== parent) {
952
+ throw new Error('Tool input changed after permission approval');
953
+ }
954
+ }
872
955
  async globRoot(requestedPath, context) {
873
956
  const displayedPath = isAbsolute(requestedPath)
874
957
  ? resolve(requestedPath)
@@ -1053,21 +1136,19 @@ export class LocalToolRegistry {
1053
1136
  await mkdir(parent, { recursive: true });
1054
1137
  return mkdtemp(join(parent, 'pdf-'));
1055
1138
  }
1056
- async write(call) {
1139
+ async write(call, context, preparedTargetExisted) {
1057
1140
  const filePath = stringInput(call.input, 'file_path');
1058
1141
  this.assertProtectedWritePath(filePath);
1059
- const content = stringInput(call.input, 'content', true);
1060
- if (Buffer.byteLength(content) > this.maxFileBytes) {
1061
- throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1062
- }
1142
+ const requestedContent = stringInput(call.input, 'content', true);
1063
1143
  let handle;
1064
1144
  let newFile = false;
1065
- try {
1066
- handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1067
- }
1068
- catch (error) {
1069
- if (error.code !== 'ENOENT')
1070
- throw error;
1145
+ const hasRead = await this.wasSuccessfullyRead(filePath, context.messages ?? [], context);
1146
+ const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
1147
+ if (!targetExisted) {
1148
+ if (Buffer.byteLength(requestedContent) > this.maxFileBytes) {
1149
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1150
+ }
1151
+ await this.assertStableCreationParent(filePath);
1071
1152
  try {
1072
1153
  handle = await open(filePath, constants.O_RDWR |
1073
1154
  constants.O_CREAT |
@@ -1076,19 +1157,40 @@ export class LocalToolRegistry {
1076
1157
  newFile = true;
1077
1158
  }
1078
1159
  catch (createError) {
1079
- if (createError.code !== 'EEXIST') {
1080
- throw createError;
1160
+ if (createError.code === 'EEXIST') {
1161
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
1081
1162
  }
1163
+ throw createError;
1164
+ }
1165
+ }
1166
+ else {
1167
+ if (!hasRead) {
1168
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
1169
+ }
1170
+ try {
1082
1171
  handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1083
1172
  }
1173
+ catch (error) {
1174
+ if (error.code === 'ENOENT') {
1175
+ throw new Error('Tool input changed after permission approval');
1176
+ }
1177
+ throw error;
1178
+ }
1084
1179
  }
1085
1180
  try {
1086
1181
  await this.assertStablePath(filePath);
1087
1182
  const metadata = await handle.stat();
1088
1183
  if (!metadata.isFile())
1089
1184
  throw new Error(`Not a file: ${filePath}`);
1185
+ if (!newFile && metadata.size > this.maxFileBytes) {
1186
+ throw new Error(`File exceeds ${this.maxFileBytes} byte write limit`);
1187
+ }
1090
1188
  const preContent = newFile ? '' : await handle.readFile('utf8');
1189
+ const content = normalizeNewlines(requestedContent, newlineStyle(preContent));
1091
1190
  const encoded = Buffer.from(content);
1191
+ if (encoded.length > this.maxFileBytes) {
1192
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1193
+ }
1092
1194
  const { linesAdded, linesRemoved } = countLineChanges(preContent, content, preContent === '' ? { newFile: true } : undefined);
1093
1195
  await handle.write(encoded, 0, encoded.length, 0);
1094
1196
  await handle.truncate(encoded.length);
@@ -1098,28 +1200,84 @@ export class LocalToolRegistry {
1098
1200
  isError: false,
1099
1201
  linesAdded,
1100
1202
  linesRemoved,
1203
+ nativeToolUseResult: { filePath, content },
1101
1204
  };
1102
1205
  }
1103
1206
  finally {
1104
1207
  await handle.close();
1105
1208
  }
1106
1209
  }
1107
- async edit(call) {
1210
+ async edit(call, preparedTargetExisted) {
1108
1211
  const filePath = stringInput(call.input, 'file_path');
1109
1212
  this.assertProtectedWritePath(filePath);
1110
- const oldString = stringInput(call.input, 'old_string');
1213
+ const oldString = stringInput(call.input, 'old_string', true);
1111
1214
  const newString = stringInput(call.input, 'new_string', true);
1112
- const handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1215
+ if (oldString === '' && Buffer.byteLength(newString) > this.maxFileBytes) {
1216
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1217
+ }
1218
+ let handle;
1219
+ let newFile = false;
1220
+ if (oldString === '') {
1221
+ const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
1222
+ if (targetExisted) {
1223
+ handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1224
+ }
1225
+ else {
1226
+ await this.assertStableCreationParent(filePath);
1227
+ try {
1228
+ handle = await open(filePath, constants.O_RDWR |
1229
+ constants.O_CREAT |
1230
+ constants.O_EXCL |
1231
+ constants.O_NOFOLLOW, 0o666);
1232
+ newFile = true;
1233
+ }
1234
+ catch (error) {
1235
+ if (error.code === 'EEXIST') {
1236
+ throw new Error('Tool input changed after permission approval');
1237
+ }
1238
+ throw error;
1239
+ }
1240
+ }
1241
+ }
1242
+ else {
1243
+ handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1244
+ }
1113
1245
  try {
1114
1246
  await this.assertStablePath(filePath);
1115
1247
  const metadata = await handle.stat();
1116
1248
  if (!metadata.isFile())
1117
1249
  throw new Error(`Not a file: ${filePath}`);
1250
+ if (newFile) {
1251
+ const content = newString;
1252
+ const encoded = Buffer.from(content);
1253
+ if (encoded.length > this.maxFileBytes) {
1254
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1255
+ }
1256
+ const { linesAdded, linesRemoved } = countLineChanges('', content, {
1257
+ newFile: true,
1258
+ });
1259
+ await handle.write(encoded, 0, encoded.length, 0);
1260
+ await handle.truncate(encoded.length);
1261
+ await handle.sync();
1262
+ return {
1263
+ content: `Replaced 1 occurrence(s)`,
1264
+ isError: false,
1265
+ linesAdded,
1266
+ linesRemoved,
1267
+ nativeToolUseResult: { filePath, content },
1268
+ };
1269
+ }
1270
+ if (oldString === '') {
1271
+ throw new Error('Edit with an empty old_string is only valid for creating a missing file');
1272
+ }
1118
1273
  if (metadata.size > this.maxFileBytes) {
1119
1274
  throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
1120
1275
  }
1121
1276
  const source = await handle.readFile('utf8');
1122
- const occurrences = source.split(oldString).length - 1;
1277
+ const style = newlineStyle(source);
1278
+ const normalizedOldString = normalizeNewlines(oldString, style);
1279
+ const normalizedNewString = normalizeNewlines(newString, style);
1280
+ const occurrences = source.split(normalizedOldString).length - 1;
1123
1281
  if (occurrences === 0)
1124
1282
  throw new Error('old_string was not found');
1125
1283
  if (call.input.replace_all !== true && occurrences !== 1) {
@@ -1128,13 +1286,14 @@ export class LocalToolRegistry {
1128
1286
  const replacementCount = call.input.replace_all === true ? occurrences : 1;
1129
1287
  const outputBytes = Buffer.byteLength(source) +
1130
1288
  replacementCount *
1131
- (Buffer.byteLength(newString) - Buffer.byteLength(oldString));
1289
+ (Buffer.byteLength(normalizedNewString) -
1290
+ Buffer.byteLength(normalizedOldString));
1132
1291
  if (outputBytes > this.maxFileBytes) {
1133
1292
  throw new Error(`Edited content exceeds ${this.maxFileBytes} bytes`);
1134
1293
  }
1135
1294
  const output = call.input.replace_all === true
1136
- ? source.replaceAll(oldString, newString)
1137
- : source.replace(oldString, newString);
1295
+ ? source.replaceAll(normalizedOldString, normalizedNewString)
1296
+ : source.replace(normalizedOldString, normalizedNewString);
1138
1297
  const { linesAdded, linesRemoved } = countLineChanges(source, output);
1139
1298
  const encoded = Buffer.from(output);
1140
1299
  await handle.write(encoded, 0, encoded.length, 0);
@@ -1145,6 +1304,7 @@ export class LocalToolRegistry {
1145
1304
  isError: false,
1146
1305
  linesAdded,
1147
1306
  linesRemoved,
1307
+ nativeToolUseResult: { filePath, content: output },
1148
1308
  };
1149
1309
  }
1150
1310
  finally {
@@ -1191,7 +1351,7 @@ export class LocalToolRegistry {
1191
1351
  async glob(call, context) {
1192
1352
  const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
1193
1353
  const root = await this.globRoot(requestedPath, context);
1194
- const timeoutSignal = AbortSignal.timeout(this.maxShellTimeoutMs);
1354
+ const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
1195
1355
  const searchSignal = context.signal
1196
1356
  ? AbortSignal.any([context.signal, timeoutSignal])
1197
1357
  : timeoutSignal;
@@ -1215,7 +1375,7 @@ export class LocalToolRegistry {
1215
1375
  throw abortError();
1216
1376
  if (timeoutSignal.aborted) {
1217
1377
  return {
1218
- content: `Search timed out after ${this.maxShellTimeoutMs}ms`,
1378
+ content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1219
1379
  isError: true,
1220
1380
  };
1221
1381
  }
@@ -1240,14 +1400,14 @@ export class LocalToolRegistry {
1240
1400
  const result = await this.processRunner.run({
1241
1401
  command: 'rg',
1242
1402
  args,
1243
- timeoutMs: this.maxShellTimeoutMs,
1403
+ timeoutMs: this.maxSearchTimeoutMs,
1244
1404
  cwd: this.currentCwd(context),
1245
1405
  ...(this.environment ? { env: this.environment } : {}),
1246
1406
  ...(context.signal ? { signal: context.signal } : {}),
1247
1407
  });
1248
1408
  if (result.timedOut) {
1249
1409
  return {
1250
- content: `Search timed out after ${this.maxShellTimeoutMs}ms`,
1410
+ content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1251
1411
  isError: true,
1252
1412
  };
1253
1413
  }
@@ -1274,39 +1434,131 @@ export class LocalToolRegistry {
1274
1434
  };
1275
1435
  const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
1276
1436
  const shell = commandShell();
1437
+ let cwdToken;
1438
+ let sandboxCommandAttempted = false;
1277
1439
  let result;
1278
1440
  try {
1279
1441
  const sessionEnvironment = context.sessionId
1280
1442
  ? await this.sessionEnvironment?.(context.sessionId)
1281
1443
  : undefined;
1282
- const command = sandboxed
1283
- ? await this.sandbox?.wrapCommand(sandboxPolicyInput, {
1284
- shell,
1444
+ const processEnvironment = this.environment || sessionEnvironment
1445
+ ? { env: { ...this.environment, ...sessionEnvironment } }
1446
+ : {};
1447
+ let executionTimeout = timeout;
1448
+ let syntaxTimeoutResult;
1449
+ if (context.sessionId) {
1450
+ const syntaxStartedAt = Date.now();
1451
+ const syntaxResult = await this.processRunner.run({
1452
+ command: shell,
1453
+ args: commandShellSyntaxArguments(rawCommand),
1454
+ timeoutMs: timeout,
1455
+ cwd: this.currentBashCwd(context),
1456
+ ...processEnvironment,
1285
1457
  ...(context.signal ? { signal: context.signal } : {}),
1286
- commandId: call.id,
1287
- })
1458
+ });
1459
+ executionTimeout = Math.max(1, timeout - (Date.now() - syntaxStartedAt));
1460
+ if (syntaxResult.timedOut) {
1461
+ syntaxTimeoutResult = syntaxResult;
1462
+ }
1463
+ else if (syntaxResult.code === 0) {
1464
+ cwdToken = randomBytes(16).toString('hex');
1465
+ }
1466
+ }
1467
+ const controlOutputFd = cwdToken ? randomInt(5, 10) : undefined;
1468
+ const statusVariable = cwdToken
1469
+ ? `_praxis_exit_status_${cwdToken}`
1470
+ : undefined;
1471
+ const trapStatusVariable = cwdToken
1472
+ ? `_praxis_trap_status_${cwdToken}`
1473
+ : undefined;
1474
+ const cwdReportCommand = cwdToken && controlOutputFd
1475
+ ? `{ printf "%s%s\\0" "${cwdToken}" "$(pwd -P 2>/dev/null)" >&${controlOutputFd}; } 2>/dev/null`
1476
+ : undefined;
1477
+ const executionCommand = cwdToken && trapStatusVariable && cwdReportCommand
1478
+ ? `if [ -n "\${ZSH_VERSION-}" ]; then 0=${JSON.stringify(shell)}; fi
1479
+ trap '${trapStatusVariable}=$?; ${cwdReportCommand}; exit "$${trapStatusVariable}"' EXIT
1480
+ ${rawCommand}
1481
+ ${statusVariable}=$?
1482
+ ${cwdReportCommand}
1483
+ exit "$${statusVariable}"`
1288
1484
  : rawCommand;
1289
- result = await this.processRunner.run({
1290
- command: shell,
1291
- args: commandShellArguments(command ?? rawCommand),
1292
- timeoutMs: timeout,
1293
- cwd: this.currentCwd(context),
1294
- ...(this.environment || sessionEnvironment
1295
- ? { env: { ...this.environment, ...sessionEnvironment } }
1296
- : {}),
1297
- ...(context.signal ? { signal: context.signal } : {}),
1298
- });
1485
+ const scriptLoaderCommand = 'eval "$(cat <&4)"';
1486
+ if (syntaxTimeoutResult) {
1487
+ result = syntaxTimeoutResult;
1488
+ }
1489
+ else {
1490
+ let command;
1491
+ if (sandboxed) {
1492
+ sandboxCommandAttempted = true;
1493
+ command = await this.sandbox?.wrapCommand(cwdToken
1494
+ ? { ...sandboxPolicyInput, executionCommand: scriptLoaderCommand }
1495
+ : sandboxPolicyInput, {
1496
+ shell,
1497
+ ...(context.signal ? { signal: context.signal } : {}),
1498
+ commandId: call.id,
1499
+ });
1500
+ }
1501
+ else {
1502
+ command = cwdToken ? scriptLoaderCommand : rawCommand;
1503
+ }
1504
+ result = await this.processRunner.run({
1505
+ command: shell,
1506
+ args: commandShellArguments(command ?? rawCommand),
1507
+ timeoutMs: executionTimeout,
1508
+ cwd: this.currentBashCwd(context),
1509
+ ...processEnvironment,
1510
+ ...(context.signal ? { signal: context.signal } : {}),
1511
+ ...(cwdToken && controlOutputFd
1512
+ ? {
1513
+ controlOutputBytes: 8192,
1514
+ controlOutputFd,
1515
+ scriptInput: executionCommand,
1516
+ }
1517
+ : {}),
1518
+ });
1519
+ }
1299
1520
  }
1300
1521
  finally {
1301
- if (sandboxed)
1522
+ if (sandboxCommandAttempted)
1302
1523
  this.sandbox?.cleanupAfterCommand();
1303
1524
  }
1304
- if (sandboxed && this.sandbox) {
1525
+ if (cwdToken) {
1526
+ result = {
1527
+ ...result,
1528
+ stdout: stripShellControlTrace(result.stdout, cwdToken),
1529
+ stderr: stripShellControlTrace(result.stderr, cwdToken),
1530
+ output: stripShellControlTrace(result.output, cwdToken),
1531
+ };
1532
+ }
1533
+ if (sandboxCommandAttempted && this.sandbox) {
1305
1534
  result = {
1306
1535
  ...result,
1307
1536
  stderr: this.sandbox.annotateStderr(call.id, result.stderr),
1308
1537
  };
1309
1538
  }
1539
+ if (cwdToken && !result.timedOut && result.controlOutput) {
1540
+ const controlRecord = result.controlOutput.startsWith(cwdToken)
1541
+ ? result.controlOutput.slice(cwdToken.length)
1542
+ : undefined;
1543
+ const terminator = controlRecord?.indexOf('\0') ?? -1;
1544
+ const record = controlRecord && terminator > 0
1545
+ ? controlRecord.slice(0, terminator)
1546
+ : undefined;
1547
+ if (record) {
1548
+ try {
1549
+ const canonicalCwd = await realpath(record);
1550
+ if ((await stat(canonicalCwd)).isDirectory() && context.sessionId) {
1551
+ this.sessionCwds.set(context.sessionId, {
1552
+ cwd: canonicalCwd,
1553
+ hostCwd: resolve(context.cwd || this.cwdProvider?.() || this.cwd),
1554
+ });
1555
+ }
1556
+ }
1557
+ catch {
1558
+ // A command may remove or replace its final directory. Keep prior state.
1559
+ }
1560
+ }
1561
+ }
1310
1562
  if (result.timedOut) {
1311
1563
  return {
1312
1564
  content: `Command timed out after ${timeout}ms`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.46.2",
3
+ "version": "0.46.4",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",