aether-code 0.28.0 → 0.30.0
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/bin/aether-code.js +1 -1
- package/package.json +1 -1
- package/src/agent.js +12 -0
- package/src/api.js +65 -32
- package/src/repl.js +1 -1
- package/src/skills.js +6 -0
- package/src/tools.js +56 -3
package/bin/aether-code.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import readline from "node:readline";
|
|
29
29
|
import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
|
|
30
30
|
|
|
31
|
-
const VERSION = "0.
|
|
31
|
+
const VERSION = "0.30.0";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Try to start MCP servers from ~/.aether/mcp.json. Returns a started
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
|
|
5
5
|
"homepage": "https://trynoguard.com",
|
|
6
6
|
"repository": {
|
package/src/agent.js
CHANGED
|
@@ -18,6 +18,17 @@ function envContext(cwd) {
|
|
|
18
18
|
const home = os.homedir();
|
|
19
19
|
const desktop = path.join(home, "Desktop");
|
|
20
20
|
const documents = path.join(home, "Documents");
|
|
21
|
+
const win = process.platform === "win32";
|
|
22
|
+
// OS-specific shell guidance: gemma tends to emit Unix commands (mkdir -p,
|
|
23
|
+
// touch, &&-chains) which FAIL in Windows cmd.exe ("The syntax of the command
|
|
24
|
+
// is incorrect"). Steer it to the file tools (which are cross-platform and
|
|
25
|
+
// auto-create parent dirs) and OS-appropriate shell usage.
|
|
26
|
+
const shellNote = win
|
|
27
|
+
? `shell: Windows cmd.exe. To create files OR directories, use the write_file tool — ` +
|
|
28
|
+
`it creates any missing parent folders automatically. Do NOT use shell "mkdir -p", ` +
|
|
29
|
+
`"touch", "ls", "rm", "cp", "mv", or "&&"-chained Unix commands; they FAIL in cmd.exe. ` +
|
|
30
|
+
`Run programs/tests with their interpreter (python, node, java, etc.).`
|
|
31
|
+
: `shell: POSIX sh. Standard Unix commands are available. write_file still auto-creates parent dirs.`;
|
|
21
32
|
return (
|
|
22
33
|
`[environment]\n` +
|
|
23
34
|
`os: ${process.platform}\n` +
|
|
@@ -25,6 +36,7 @@ function envContext(cwd) {
|
|
|
25
36
|
`home: ${home}\n` +
|
|
26
37
|
`desktop: ${desktop}\n` +
|
|
27
38
|
`documents: ${documents}\n` +
|
|
39
|
+
`${shellNote}\n` +
|
|
28
40
|
`When the user names a location ("my desktop", "home", "documents"), write to ` +
|
|
29
41
|
`the matching ABSOLUTE path above (e.g. desktop -> ${desktop}). Otherwise ` +
|
|
30
42
|
`work under the cwd. Use absolute paths when a specific location is named.\n` +
|
package/src/api.js
CHANGED
|
@@ -33,6 +33,9 @@ export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 50
|
|
|
33
33
|
try {
|
|
34
34
|
const res = await fetch(url, options);
|
|
35
35
|
if (res.status >= 500 && attempt < retries) {
|
|
36
|
+
// Release the connection back to the pool before retrying — an
|
|
37
|
+
// unconsumed body keeps the socket open (undici won't reuse it).
|
|
38
|
+
res.body?.cancel().catch(() => {});
|
|
36
39
|
attempt++;
|
|
37
40
|
if (onRetry) onRetry(attempt, `HTTP ${res.status}`);
|
|
38
41
|
await sleep(baseDelay * 2 ** (attempt - 1));
|
|
@@ -175,39 +178,69 @@ export async function agentTurnStream({
|
|
|
175
178
|
const reader = res.body.getReader();
|
|
176
179
|
const decoder = new TextDecoder();
|
|
177
180
|
let buffer = "";
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
181
|
+
|
|
182
|
+
// Dispatch a single parsed SSE "data:" line.
|
|
183
|
+
const handleLine = (line) => {
|
|
184
|
+
if (!line.startsWith("data:")) return;
|
|
185
|
+
const json = line.slice(5).trim(); // .trim() also drops a CRLF trailing \r
|
|
186
|
+
if (!json) return;
|
|
187
|
+
let evt;
|
|
188
|
+
try { evt = JSON.parse(json); } catch { return; }
|
|
189
|
+
if (evt.kind === "delta") {
|
|
190
|
+
textBuf += evt.content;
|
|
191
|
+
if (onDelta) onDelta(evt.content);
|
|
192
|
+
} else if (evt.kind === "tool_call_delta") {
|
|
193
|
+
const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
|
|
194
|
+
if (evt.id) slot.id = evt.id;
|
|
195
|
+
if (evt.name) slot.name = evt.name;
|
|
196
|
+
if (evt.args_delta) slot.args += evt.args_delta;
|
|
197
|
+
toolBuf.set(evt.index, slot);
|
|
198
|
+
if (onToolCallDelta) onToolCallDelta(evt);
|
|
199
|
+
} else if (evt.kind === "finish") {
|
|
200
|
+
finishReason = evt.reason;
|
|
201
|
+
if (onFinish) onFinish(evt.reason);
|
|
202
|
+
} else if (evt.kind === "done") {
|
|
203
|
+
creditsCharged = evt.creditsCharged ?? 0;
|
|
204
|
+
balanceAfter = evt.balanceAfter ?? null;
|
|
205
|
+
usage = evt.usage ?? usage;
|
|
206
|
+
} else if (evt.kind === "error") {
|
|
207
|
+
streamError = evt.error;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Watchdog: if the open stream stalls (no bytes) for too long, abort instead
|
|
212
|
+
// of hanging the CLI forever.
|
|
213
|
+
const READ_TIMEOUT_MS = 120_000;
|
|
214
|
+
const readOnce = () => {
|
|
215
|
+
let timer;
|
|
216
|
+
const timeout = new Promise((_, reject) => {
|
|
217
|
+
timer = setTimeout(
|
|
218
|
+
() => reject(new AetherError("Stream stalled — no data for 120s", "STREAM_TIMEOUT", 0)),
|
|
219
|
+
READ_TIMEOUT_MS,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
while (true) {
|
|
227
|
+
const { value, done } = await readOnce();
|
|
228
|
+
if (done) break;
|
|
229
|
+
buffer += decoder.decode(value, { stream: true });
|
|
230
|
+
const lines = buffer.split("\n");
|
|
231
|
+
buffer = lines.pop() ?? "";
|
|
232
|
+
for (const line of lines) handleLine(line);
|
|
210
233
|
}
|
|
234
|
+
// Flush any multibyte bytes the decoder is holding, then process a final
|
|
235
|
+
// line that arrived without a trailing newline (else the "done" event with
|
|
236
|
+
// credits/usage is silently dropped).
|
|
237
|
+
buffer += decoder.decode();
|
|
238
|
+
if (buffer.trim()) handleLine(buffer);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
reader.cancel().catch(() => {});
|
|
241
|
+
throw e;
|
|
242
|
+
} finally {
|
|
243
|
+
try { reader.releaseLock(); } catch { /* already released / pending — ignore */ }
|
|
211
244
|
}
|
|
212
245
|
|
|
213
246
|
if (streamError) {
|
package/src/repl.js
CHANGED
|
@@ -16,7 +16,7 @@ import { c, errorLine, boxLines, sideBySide } from "./render.js";
|
|
|
16
16
|
import { checkForUpdate } from "./update-check.js";
|
|
17
17
|
import { promptBox, EXIT_SIGNAL } from "./box-input.js";
|
|
18
18
|
|
|
19
|
-
const VERSION = "0.
|
|
19
|
+
const VERSION = "0.30.0";
|
|
20
20
|
const MODEL_NAME = "Aether Core";
|
|
21
21
|
|
|
22
22
|
const SHORTCUTS = `
|
package/src/skills.js
CHANGED
|
@@ -134,6 +134,12 @@ export function loadSkillsFromDir(dir) {
|
|
|
134
134
|
if (!name.endsWith(".md")) continue;
|
|
135
135
|
const filePath = path.join(dir, name);
|
|
136
136
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
137
|
+
// Cap skill size: the body is injected into the prompt every turn it
|
|
138
|
+
// triggers, so a huge file would blow the context window / inflate cost.
|
|
139
|
+
if (raw.length > 50_000) {
|
|
140
|
+
process.stderr.write(` (skipping oversized skill ${name}: ${raw.length} bytes > 50KB)\n`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
137
143
|
skills.push(parseSkill(raw, filePath));
|
|
138
144
|
}
|
|
139
145
|
return skills;
|
package/src/tools.js
CHANGED
|
@@ -279,6 +279,7 @@ const MAX_GLOB_RESULTS = 300;
|
|
|
279
279
|
// relative path. ** spans directory separators; * does not.
|
|
280
280
|
export function globToRegExp(glob) {
|
|
281
281
|
let re = "";
|
|
282
|
+
let braceDepth = 0;
|
|
282
283
|
for (let i = 0; i < glob.length; i++) {
|
|
283
284
|
const ch = glob[i];
|
|
284
285
|
if (ch === "*") {
|
|
@@ -291,6 +292,16 @@ export function globToRegExp(glob) {
|
|
|
291
292
|
}
|
|
292
293
|
} else if (ch === "?") {
|
|
293
294
|
re += "[^/]";
|
|
295
|
+
} else if (ch === "{") {
|
|
296
|
+
// Brace expansion: {js,ts} -> (js|ts). Common in .gitignore (*.{js,ts});
|
|
297
|
+
// treating it literally silently failed to ignore those files.
|
|
298
|
+
re += "(";
|
|
299
|
+
braceDepth++;
|
|
300
|
+
} else if (ch === "}" && braceDepth > 0) {
|
|
301
|
+
re += ")";
|
|
302
|
+
braceDepth--;
|
|
303
|
+
} else if (ch === "," && braceDepth > 0) {
|
|
304
|
+
re += "|";
|
|
294
305
|
} else if ("\\^$+.()|{}[]".includes(ch)) {
|
|
295
306
|
re += "\\" + ch;
|
|
296
307
|
} else {
|
|
@@ -430,13 +441,22 @@ async function askUser(args, _opts) {
|
|
|
430
441
|
function readFile(args, opts) {
|
|
431
442
|
if (typeof args.path !== "string") return { ok: false, output: "path is required" };
|
|
432
443
|
const abs = resolveSafe(args.path, opts);
|
|
433
|
-
|
|
444
|
+
let stat;
|
|
445
|
+
try { stat = fs.statSync(abs); } catch (e) {
|
|
446
|
+
return { ok: false, output: `Cannot access ${args.path}: ${e.code || e.message}` };
|
|
447
|
+
}
|
|
434
448
|
if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
|
|
449
|
+
// Block non-regular files (named pipes, sockets, devices) — reading them can
|
|
450
|
+
// hang forever (no timeout on readFileSync).
|
|
451
|
+
if (!stat.isFile()) return { ok: false, output: `${args.path} is not a regular file.` };
|
|
435
452
|
if (stat.size > 1_000_000) {
|
|
436
453
|
return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
|
|
437
454
|
}
|
|
438
|
-
|
|
439
|
-
|
|
455
|
+
try {
|
|
456
|
+
return { ok: true, output: fs.readFileSync(abs, "utf8") };
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return { ok: false, output: `Cannot read ${args.path}: ${e.code || e.message}` };
|
|
459
|
+
}
|
|
440
460
|
}
|
|
441
461
|
|
|
442
462
|
function listDir(args, opts) {
|
|
@@ -497,6 +517,10 @@ function searchFiles(args, opts) {
|
|
|
497
517
|
} catch { continue; }
|
|
498
518
|
const lines = content.split("\n");
|
|
499
519
|
for (let i = 0; i < lines.length; i++) {
|
|
520
|
+
// Skip very long lines: the pattern is model-supplied and a
|
|
521
|
+
// pathological regex on a long line can backtrack catastrophically
|
|
522
|
+
// (ReDoS) and freeze the single-threaded process.
|
|
523
|
+
if (lines[i].length > 2000) continue;
|
|
500
524
|
if (regex.test(lines[i])) {
|
|
501
525
|
matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
|
|
502
526
|
if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
|
|
@@ -648,12 +672,33 @@ async function webSearch(args, opts) {
|
|
|
648
672
|
// Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
|
|
649
673
|
// tags, decodes common HTML entities. Not a full HTML parser; good enough for
|
|
650
674
|
// reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
|
|
675
|
+
// SSRF guard: refuse loopback / link-local / private hosts so web_fetch can't
|
|
676
|
+
// be steered (directly or via a redirect) at localhost or cloud-metadata
|
|
677
|
+
// (169.254.169.254) or an internal service.
|
|
678
|
+
function isBlockedHost(hostname) {
|
|
679
|
+
const h = (hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
|
680
|
+
if (!h || h === "localhost" || h === "0.0.0.0" || h === "::1" || h === "::") return true;
|
|
681
|
+
if (h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local")) return true;
|
|
682
|
+
if (/^127\./.test(h)) return true; // loopback
|
|
683
|
+
if (/^10\./.test(h)) return true; // private A
|
|
684
|
+
if (/^192\.168\./.test(h)) return true; // private C
|
|
685
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // private B
|
|
686
|
+
if (/^169\.254\./.test(h)) return true; // link-local incl. cloud metadata
|
|
687
|
+
if (/^(fe80:|fc|fd|::ffff:)/.test(h)) return true; // IPv6 link-local / ULA / mapped
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
651
691
|
async function webFetch(args, opts) {
|
|
652
692
|
void opts;
|
|
653
693
|
if (typeof args.url !== "string") return { ok: false, output: "url is required" };
|
|
654
694
|
if (!/^https?:\/\//i.test(args.url)) {
|
|
655
695
|
return { ok: false, output: "Only http:// and https:// URLs are allowed." };
|
|
656
696
|
}
|
|
697
|
+
let initialHost;
|
|
698
|
+
try { initialHost = new URL(args.url).hostname; } catch { return { ok: false, output: "Invalid URL." }; }
|
|
699
|
+
if (isBlockedHost(initialHost)) {
|
|
700
|
+
return { ok: false, output: "web_fetch blocked: refusing to fetch a private/internal/loopback address." };
|
|
701
|
+
}
|
|
657
702
|
const controller = new AbortController();
|
|
658
703
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
659
704
|
let res;
|
|
@@ -674,6 +719,14 @@ async function webFetch(args, opts) {
|
|
|
674
719
|
return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
|
|
675
720
|
}
|
|
676
721
|
clearTimeout(timeout);
|
|
722
|
+
// Re-check the FINAL url after any redirects — a public URL can 302 to an
|
|
723
|
+
// internal target, which the initial-URL check wouldn't catch.
|
|
724
|
+
try {
|
|
725
|
+
if (isBlockedHost(new URL(res.url).hostname)) {
|
|
726
|
+
res.body?.cancel().catch(() => {});
|
|
727
|
+
return { ok: false, output: "web_fetch blocked: redirect to a private/internal address." };
|
|
728
|
+
}
|
|
729
|
+
} catch { /* res.url unparseable — fall through */ }
|
|
677
730
|
if (!res.ok) {
|
|
678
731
|
return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
|
|
679
732
|
}
|