aegis-desktop 0.8.20 → 0.8.21

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.
@@ -70,6 +70,25 @@ try {
70
70
 
71
71
  const OUTPUT_CAP = 30_000; // chars fed back to the model per tool result
72
72
 
73
+ /**
74
+ * What every path argument accepts, stated ONCE so the ten schemas below cannot
75
+ * drift from each other or from what normalizeArgs actually does.
76
+ *
77
+ * It names the platform requirement explicitly because the failure it prevents
78
+ * was a model guessing posix on a Windows host (see client/platform.js
79
+ * resolvePath): `/home/you/Desktop/x.txt` there is drive-relative, so the write
80
+ * SUCCEEDED at `C:\home\you\Desktop\x.txt` and the user found nothing on their
81
+ * Desktop. The schema is the first place to say so — a refusal after the fact
82
+ * costs the model a round, a correct description costs it nothing.
83
+ */
84
+ const PATH_ARG_DESC =
85
+ 'Absolute path in THIS machine\'s own form (the Environment block names the platform and home ' +
86
+ 'directory — a win32 host needs C:\\Users\\you\\file.txt, never /home/you/file.txt), or "~" / ' +
87
+ '"~/…" for the home directory. A relative path resolves against the working directory.';
88
+
89
+ /** `<what>. <the path rules>` — the shape every path argument's description takes. */
90
+ const pathDesc = (what) => `${what}. ${PATH_ARG_DESC}`;
91
+
73
92
  // The dialect probe (Phase 30.3), from the same module the session lives in: a
74
93
  // one-shot `exec` must run the command in a shell that was PROBED to speak the
75
94
  // dialect the command is written in, never in an unprobed `$SHELL`. Lives next
@@ -111,11 +130,11 @@ const fail = (error) => ({ ok: false, error: cap(error) });
111
130
  const SCHEMAS = {
112
131
  readFile: {
113
132
  name: 'readFile',
114
- description: 'Read a file. Absolute path. Returns line-numbered content.',
133
+ description: 'Read a file. Returns line-numbered content.',
115
134
  parameters: {
116
135
  type: 'object',
117
136
  properties: {
118
- file_path: { type: 'string', description: 'Absolute path' },
137
+ file_path: { type: 'string', description: pathDesc('The file to read') },
119
138
  offset: { type: 'number', description: 'Start line (0-based)' },
120
139
  limit: { type: 'number', description: `The number of lines to read (max 10000, default ${READ_LINE_CAP})` },
121
140
  },
@@ -130,7 +149,7 @@ const SCHEMAS = {
130
149
  parameters: {
131
150
  type: 'object',
132
151
  properties: {
133
- file_path: { type: 'string', description: 'Absolute path' },
152
+ file_path: { type: 'string', description: pathDesc('The file to write') },
134
153
  content: { type: 'string', description: 'Full file contents' },
135
154
  },
136
155
  required: ['file_path', 'content'],
@@ -145,7 +164,7 @@ const SCHEMAS = {
145
164
  parameters: {
146
165
  type: 'object',
147
166
  properties: {
148
- file_path: { type: 'string', description: 'Absolute path' },
167
+ file_path: { type: 'string', description: pathDesc('The file to edit') },
149
168
  old_string: { type: 'string', description: 'Text to replace; must be unique unless replace_all' },
150
169
  new_string: { type: 'string', description: 'Replacement text' },
151
170
  replace_all: { type: 'boolean', description: 'Replace every occurrence' },
@@ -160,7 +179,7 @@ const SCHEMAS = {
160
179
  parameters: {
161
180
  type: 'object',
162
181
  properties: {
163
- path: { type: 'string', description: 'The directory to list (default: the working directory)' },
182
+ path: { type: 'string', description: pathDesc('The directory to list (default: the working directory)') },
164
183
  },
165
184
  required: [],
166
185
  additionalProperties: false,
@@ -174,7 +193,7 @@ const SCHEMAS = {
174
193
  type: 'object',
175
194
  properties: {
176
195
  pattern: { type: 'string', description: 'Glob, e.g. "**/*.test.js"' },
177
- path: { type: 'string', description: 'Search root (default: cwd)' },
196
+ path: { type: 'string', description: pathDesc('Search root (default: the working directory)') },
178
197
  },
179
198
  required: ['pattern'],
180
199
  additionalProperties: false,
@@ -187,7 +206,7 @@ const SCHEMAS = {
187
206
  type: 'object',
188
207
  properties: {
189
208
  pattern: { type: 'string', description: 'The regular expression to search for' },
190
- path: { type: 'string', description: 'The directory to search in (default: the working directory)' },
209
+ path: { type: 'string', description: pathDesc('The directory to search in (default: the working directory)') },
191
210
  },
192
211
  required: ['pattern'],
193
212
  additionalProperties: false,
@@ -202,7 +221,7 @@ const SCHEMAS = {
202
221
  type: 'object',
203
222
  properties: {
204
223
  command: { type: 'string', description: 'Command to run' },
205
- cwd: { type: 'string', description: 'Run this one command elsewhere; session cwd unchanged' },
224
+ cwd: { type: 'string', description: pathDesc('Run this one command elsewhere; session cwd unchanged') },
206
225
  timeout: { type: 'number', description: `Timeout in milliseconds (max ${EXEC_TIMEOUT_CAP}, default ${EXEC_TIMEOUT_DEFAULT})` },
207
226
  description: { type: 'string', description: 'A brief description of what the command does (for display)' },
208
227
  },
@@ -221,7 +240,7 @@ const SCHEMAS = {
221
240
  type: 'object',
222
241
  properties: {
223
242
  sub: { type: 'string', enum: ['status', 'list'], description: 'status (default) or the full config listing' },
224
- cwd: { type: 'string', description: 'Repo to inspect (default: the working directory)' },
243
+ cwd: { type: 'string', description: pathDesc('Repo to inspect (default: the working directory)') },
225
244
  },
226
245
  required: [],
227
246
  additionalProperties: false,
@@ -237,7 +256,7 @@ const SCHEMAS = {
237
256
  parameters: {
238
257
  type: 'object',
239
258
  properties: {
240
- cwd: { type: 'string', description: 'Repo to install into (default: the working directory)' },
259
+ cwd: { type: 'string', description: pathDesc('Repo to install into (default: the working directory)') },
241
260
  dry_run: { type: 'boolean', description: 'Print the commands instead of running them' },
242
261
  },
243
262
  required: [],
@@ -301,6 +320,85 @@ function toolsFor({ includeSubagent = true } = {}) {
301
320
  return openaiTools({ includeSubagent });
302
321
  }
303
322
 
323
+ // ── Path normalisation ──────────────────────────────────────────────────────
324
+
325
+ /**
326
+ * Which argument of each tool is a path on this machine. `task` has none (its
327
+ * argument is a prompt), and `exec`'s `command` is deliberately absent: a shell
328
+ * command is not a path and its contents are the shell's business, not ours.
329
+ */
330
+ const TOOL_PATH_ARGS = Object.freeze({
331
+ readFile: ['file_path'],
332
+ writeFile: ['file_path'],
333
+ editFile: ['file_path'],
334
+ listDir: ['path'],
335
+ glob: ['path'],
336
+ grep: ['path'],
337
+ exec: ['cwd'],
338
+ hooks: ['cwd'],
339
+ installHooks: ['cwd'],
340
+ });
341
+
342
+ /**
343
+ * Resolve every path argument of one tool call against THIS machine, returning
344
+ * `{ ok, args, error }` — `args` is a copy with each path replaced by its real
345
+ * absolute form, and a single refusal fails the whole call.
346
+ *
347
+ * Three reasons this is one function applied to the call, rather than a
348
+ * `resolvePath` sprinkled through each executor:
349
+ *
350
+ * 1. The GUARDS read the path too. tools/agent-gate.mjs does
351
+ * `path.resolve(cwd, args.file_path)` to decide whether the target sits in
352
+ * an agent-gated tree, and engine.js's covenant check inspects `args`
353
+ * before anything runs. A `~/…` reaching them unexpanded means they rule on
354
+ * `<cwd>/~/…` — a different file than the one that would be written. The
355
+ * only way the guard and the write can agree is for the path to be real
356
+ * BEFORE either looks at it.
357
+ * 2. The approval gate shows a diff for one path and then writes to another
358
+ * otherwise: previewMutation and applyChecked must resolve identically, and
359
+ * sharing this function is what makes that true by construction.
360
+ * 3. An absent or empty argument is left ALONE, so each executor keeps its own
361
+ * "required" error and its own default (listDir with no `path` still means
362
+ * the working directory). Normalising only what was actually supplied is
363
+ * what keeps this a path fix and not a behaviour change.
364
+ *
365
+ * Idempotent, because resolvePath is: engine.js normalises early for the guards
366
+ * and executeTool normalises again for a direct caller, and the second pass is a
367
+ * no-op rather than a second resolution.
368
+ */
369
+ function normalizeArgs(name, args, ctx = {}) {
370
+ const input = args && typeof args === 'object' ? args : {};
371
+ const keys = TOOL_PATH_ARGS[name];
372
+ if (!keys) return { ok: true, args: input, error: null };
373
+
374
+ let out = input;
375
+ for (const key of keys) {
376
+ const value = input[key];
377
+ if (typeof value !== 'string' || !value.trim()) continue; // absent → the executor's default
378
+ const res = platform.resolvePath(value, { cwd: ctx && ctx.cwd ? ctx.cwd : undefined });
379
+ if (!res.ok) return { ok: false, args: input, error: `${name}: ${key} — ${res.error}` };
380
+ if (res.path !== value) {
381
+ if (out === input) out = { ...input };
382
+ out[key] = res.path;
383
+ }
384
+ }
385
+ return { ok: true, args: out, error: null };
386
+ }
387
+
388
+ /**
389
+ * The directory a tool with no explicit `path` works in: this TURN's working
390
+ * directory, not the host process's.
391
+ *
392
+ * `process.cwd()` is wrong here and was visibly wrong off Linux: the Electron
393
+ * main process inherits whatever the OS launched the app from — `/` from a .desktop
394
+ * entry, `C:\Program Files\AEGIS Desktop` from a Windows shortcut — so a bare
395
+ * `listDir`/`glob`/`grep` searched the install directory instead of the user's
396
+ * repo. It stays the fallback for a direct call that supplies no ctx.
397
+ */
398
+ function baseDir(ctx) {
399
+ return (ctx && typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd();
400
+ }
401
+
304
402
  // ── Executors ───────────────────────────────────────────────────────────────
305
403
 
306
404
  function readFile({ file_path, offset = 0, limit } = {}) {
@@ -538,9 +636,11 @@ function previewEditFile({ file_path, old_string, new_string, replace_all } = {}
538
636
 
539
637
  /** Dispatch a preview by tool name. Only writeFile/editFile have one — exec
540
638
  * has nothing to diff, and the approval gate skips this call for it. */
541
- function previewMutation(name, args) {
542
- if (name === 'writeFile') return previewWriteFile(args);
543
- if (name === 'editFile') return previewEditFile(args);
639
+ function previewMutation(name, args, ctx = {}) {
640
+ const norm = normalizeArgs(name, args, ctx);
641
+ if (!norm.ok) return { ok: false, error: norm.error };
642
+ if (name === 'writeFile') return previewWriteFile(norm.args);
643
+ if (name === 'editFile') return previewEditFile(norm.args);
544
644
  return { ok: false, error: `no diff preview for ${name}` };
545
645
  }
546
646
 
@@ -591,16 +691,18 @@ function applyEditChecked({ file_path, after } = {}, expectedHash) {
591
691
  /** Apply an approved writeFile/editFile call using the hash captured in its
592
692
  * `preview` (see previewMutation) — the single entry point engine.js calls
593
693
  * once the user has said yes. */
594
- function applyChecked(name, args, preview) {
595
- if (name === 'writeFile') return applyWriteChecked(args, preview.hash);
596
- return applyEditChecked({ file_path: args.file_path, after: preview.after }, preview.hash);
694
+ function applyChecked(name, args, preview, ctx = {}) {
695
+ const norm = normalizeArgs(name, args, ctx);
696
+ if (!norm.ok) return fail(norm.error);
697
+ if (name === 'writeFile') return applyWriteChecked(norm.args, preview.hash);
698
+ return applyEditChecked({ file_path: norm.args.file_path, after: preview.after }, preview.hash);
597
699
  }
598
700
 
599
701
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.aegiscode']);
600
702
 
601
- function listDir({ path: dir } = {}) {
703
+ function listDir({ path: dir } = {}, ctx = {}) {
602
704
  try {
603
- const base = dir || process.cwd();
705
+ const base = dir || baseDir(ctx);
604
706
  const entries = fs.readdirSync(base, { withFileTypes: true });
605
707
  const rows = entries
606
708
  .filter((e) => !IGNORED_DIRS.has(e.name))
@@ -645,10 +747,10 @@ function walk(dir, fn, depth = 0) {
645
747
  }
646
748
  }
647
749
 
648
- function glob({ pattern, path: root } = {}) {
750
+ function glob({ pattern, path: root } = {}, ctx = {}) {
649
751
  try {
650
752
  if (!pattern) return fail('pattern is required');
651
- const base = root || process.cwd();
753
+ const base = root || baseDir(ctx);
652
754
  const re = globToRegex(String(pattern));
653
755
  const hits = [];
654
756
  walk(base, (full) => {
@@ -662,11 +764,11 @@ function glob({ pattern, path: root } = {}) {
662
764
  }
663
765
  }
664
766
 
665
- function grep({ pattern, path: root } = {}) {
767
+ function grep({ pattern, path: root } = {}, ctx = {}) {
666
768
  try {
667
769
  if (!pattern) return fail('pattern is required');
668
770
  const re = new RegExp(pattern);
669
- const base = root || process.cwd();
771
+ const base = root || baseDir(ctx);
670
772
  const hits = [];
671
773
  walk(base, (full) => {
672
774
  if (hits.length >= MATCH_CAP) return;
@@ -990,7 +1092,12 @@ function nearestManifest(start) {
990
1092
 
991
1093
  async function executeTool(name, args, ctx) {
992
1094
  if (!isTool(name)) return fail(`unknown tool "${name}" (known: ${toolNames().join(', ')})`);
993
- const input = args && typeof args === 'object' ? args : {};
1095
+ // Paths become real BEFORE the agent-key gate below resolves them itself —
1096
+ // see normalizeArgs. A `~/…` that reached the gate unexpanded was ruled on as
1097
+ // `<cwd>/~/…`, i.e. a different file than the one about to be written.
1098
+ const norm = normalizeArgs(name, args, ctx || {});
1099
+ if (!norm.ok) return fail(norm.error);
1100
+ const input = norm.args;
994
1101
  // ── agent-key gate (tools/agent-gate.mjs) ──────────────────────────────
995
1102
  // The in-process half of the lock whose other half is .githooks/. A tree
996
1103
  // that declares .aegis/guard.json must not be mutated through this app
@@ -1060,6 +1167,10 @@ module.exports = {
1060
1167
  MUTATING_TOOLS,
1061
1168
  previewMutation,
1062
1169
  applyChecked,
1170
+ // path handling (client/platform.js resolvePath applied to a tool call)
1171
+ normalizeArgs,
1172
+ TOOL_PATH_ARGS,
1173
+ PATH_ARG_DESC,
1063
1174
  // limits (unit tests assert against them instead of hard-coding numbers)
1064
1175
  OUTPUT_CAP,
1065
1176
  READ_LINE_CAP,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.20",
4
+ "version": "0.8.21",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",