klyro 1.0.16 → 1.0.18
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/agent/anthropic-adapter.js +26 -1
- package/dist/agent/provider-adapter.d.ts +7 -0
- package/dist/agent/provider-adapter.js +26 -1
- package/dist/agent/runtime.d.ts +14 -0
- package/dist/agent/runtime.js +59 -22
- package/dist/chat.js +16 -56
- package/dist/checkpoints/store.d.ts +15 -1
- package/dist/checkpoints/store.js +7 -2
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +10 -0
- package/dist/cli/completion.js +0 -3
- package/dist/cli/doctor.d.ts +13 -0
- package/dist/cli/doctor.js +133 -1
- package/dist/cli/errors.js +1 -1
- package/dist/cli/eval.d.ts +32 -0
- package/dist/cli/eval.js +83 -6
- package/dist/cli/markdown.js +1 -1
- package/dist/cli/repl.js +13 -24
- package/dist/cli/run.d.ts +37 -0
- package/dist/cli/run.js +81 -9
- package/dist/cli/update.js +7 -1
- package/dist/context/accounting.js +1 -1
- package/dist/context/klyro-md.d.ts +11 -0
- package/dist/context/klyro-md.js +59 -4
- package/dist/context/level7.d.ts +4 -0
- package/dist/context/level7.js +20 -1
- package/dist/context/project-map.js +14 -13
- package/dist/eval/baseline.d.ts +47 -0
- package/dist/eval/baseline.js +55 -0
- package/dist/eval/harness.d.ts +14 -2
- package/dist/eval/harness.js +42 -4
- package/dist/index.js +57 -3
- package/dist/persistence/store.js +2 -2
- package/dist/policy/approval.js +3 -0
- package/dist/policy/path-guard.d.ts +9 -4
- package/dist/policy/path-guard.js +47 -23
- package/dist/providers/endpoints.js +8 -0
- package/dist/providers/model-info.d.ts +1 -0
- package/dist/providers/model-info.js +19 -0
- package/dist/providers.js +6 -2
- package/dist/renderers/terminal.d.ts +7 -0
- package/dist/renderers/terminal.js +52 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +24 -5
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/proxy.d.ts +37 -0
- package/dist/shared/proxy.js +360 -0
- package/dist/tools/agent/task-list.d.ts +1 -1
- package/dist/tools/fs/apply-patch.js +4 -4
- package/dist/tools/fs/edit-file.js +2 -4
- package/dist/tools/fs/list-dir.js +1 -1
- package/dist/tools/fs/multi-edit.js +2 -2
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/git/git-blame.d.ts +5 -0
- package/dist/tools/git/git-blame.js +22 -0
- package/dist/tools/git/git-diff.js +3 -20
- package/dist/tools/git/git-log.js +5 -19
- package/dist/tools/git/git-show.d.ts +5 -0
- package/dist/tools/git/git-show.js +22 -0
- package/dist/tools/git/git-status.js +4 -21
- package/dist/tools/git/run-git.d.ts +5 -0
- package/dist/tools/git/run-git.js +18 -0
- package/dist/tools/lsp/diagnostics.js +2 -3
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +4 -0
- package/dist/tools/search/dependencies.d.ts +4 -4
- package/dist/tools/search/dependencies.js +1 -1
- package/dist/tools/search/glob.js +9 -4
- package/dist/tools/search/grep.js +11 -4
- package/dist/tools/search/ignore.d.ts +8 -0
- package/dist/tools/search/ignore.js +49 -0
- package/dist/tools/search/recent-files.js +1 -1
- package/dist/tools/search/search-files.js +11 -4
- package/dist/tools/web/web-fetch.js +2 -1
- package/dist/tools/web/web-search.js +3 -1
- package/dist/tui/app.js +117 -10
- package/dist/tui/app.test.js +65 -4
- package/dist/tui/measure.js +1 -0
- package/dist/tui/mouse.d.ts +7 -5
- package/dist/tui/mouse.js +9 -6
- package/dist/tui/scroll-flow.test.js +0 -1
- package/dist/tui/transcript-commands.d.ts +14 -0
- package/dist/tui/transcript-commands.js +31 -0
- package/dist/util/log.d.ts +19 -10
- package/dist/util/log.js +87 -52
- package/dist/verification/classify.d.ts +1 -1
- package/dist/verification/classify.js +2 -2
- package/dist/verification/engine.js +0 -6
- package/dist/verification/scoped.js +6 -4
- package/package.json +6 -1
package/dist/eval/harness.js
CHANGED
|
@@ -122,7 +122,7 @@ export async function runTask(t, opts = {}) {
|
|
|
122
122
|
try {
|
|
123
123
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
124
124
|
}
|
|
125
|
-
catch { }
|
|
125
|
+
catch { /* ignore — best-effort tmp cleanup */ }
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
}
|
|
@@ -177,10 +177,46 @@ export async function loadFileFixture(dir) {
|
|
|
177
177
|
catch { /* no script → static fixture */ }
|
|
178
178
|
return { dir, task: task.trim(), checkSh, meta, ...(repo ? { repo } : {}), ...(script ? { script } : {}) };
|
|
179
179
|
}
|
|
180
|
-
|
|
180
|
+
/**
|
|
181
|
+
* 5.4b — sanitized env for `check.sh`: PATH + home/locale + temp vars
|
|
182
|
+
* (+ Windows shell vars so the shell can spawn). Everything else —
|
|
183
|
+
* secrets, `KLYRO_*` config, caller sentinels — is dropped. Exported for tests.
|
|
184
|
+
*/
|
|
185
|
+
const FIXTURE_ENV_ALLOW = new Set([
|
|
186
|
+
'PATH', 'PATHEXT', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP',
|
|
187
|
+
'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC',
|
|
188
|
+
]);
|
|
189
|
+
export function sanitizedFixtureEnv(base = process.env) {
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const k of FIXTURE_ENV_ALLOW) {
|
|
192
|
+
const v = base[k];
|
|
193
|
+
if (v !== undefined)
|
|
194
|
+
out[k] = v;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 5.4b — guard: fixture workdirs must stay inside `os.tmpdir()` (the
|
|
200
|
+
* harness relies on that isolation). Throws otherwise unless the caller
|
|
201
|
+
* explicitly passes `allowWorkdirOutsideTmp`. Exported for tests.
|
|
202
|
+
*/
|
|
203
|
+
export function assertTmpWorkdir(dir, opts = {}) {
|
|
204
|
+
if (opts.allowWorkdirOutsideTmp)
|
|
205
|
+
return;
|
|
206
|
+
const root = path.resolve(os.tmpdir());
|
|
207
|
+
const resolved = path.resolve(dir);
|
|
208
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
209
|
+
throw new Error(`fixture workdir escapes os.tmpdir(): ${resolved} (pass allowWorkdirOutsideTmp to override)`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export async function runFileFixture(fixture, _opts = {}) {
|
|
181
213
|
const start = Date.now();
|
|
182
214
|
const tmp = path.join(os.tmpdir(), 'klyro-eval-file-' + Math.random().toString(36).slice(2));
|
|
215
|
+
assertTmpWorkdir(tmp, _opts);
|
|
183
216
|
await fs.mkdir(tmp, { recursive: true });
|
|
217
|
+
// Restrictive creation (umask-style): the tmp workdir is owner-only.
|
|
218
|
+
// Best-effort — chmod is a no-op for ACLs on Windows but harmless.
|
|
219
|
+
await fs.chmod(tmp, 0o700).catch(() => undefined);
|
|
184
220
|
// Copy repo if exists
|
|
185
221
|
if (fixture.repo) {
|
|
186
222
|
const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
|
|
@@ -192,7 +228,7 @@ export async function runFileFixture(fixture, opts = {}) {
|
|
|
192
228
|
// Run check.sh via shell
|
|
193
229
|
const { spawn } = await import('node:child_process');
|
|
194
230
|
const result = await new Promise((resolve) => {
|
|
195
|
-
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
|
|
231
|
+
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
|
|
196
232
|
const sink = cappedOutput(MAX_CHECK_BYTES);
|
|
197
233
|
child.stdout?.on('data', (b) => sink.push(b));
|
|
198
234
|
child.stderr?.on('data', (b) => sink.push(b));
|
|
@@ -226,7 +262,9 @@ export async function runAgentFixture(fixture, opts = {}) {
|
|
|
226
262
|
return { id, status: 'fail', details: 'agent fixture needs script.json', durationMs: Date.now() - start };
|
|
227
263
|
}
|
|
228
264
|
const tmp = path.join(os.tmpdir(), 'klyro-eval-agent-' + Math.random().toString(36).slice(2));
|
|
265
|
+
assertTmpWorkdir(tmp, opts);
|
|
229
266
|
await fs.mkdir(tmp, { recursive: true });
|
|
267
|
+
await fs.chmod(tmp, 0o700).catch(() => undefined);
|
|
230
268
|
try {
|
|
231
269
|
if (fixture.repo) {
|
|
232
270
|
const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
|
|
@@ -250,7 +288,7 @@ export async function runAgentFixture(fixture, opts = {}) {
|
|
|
250
288
|
// Structural pass — now assert real filesystem outcomes.
|
|
251
289
|
const { spawn } = await import('node:child_process');
|
|
252
290
|
const out = await new Promise((resolve) => {
|
|
253
|
-
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
|
|
291
|
+
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
|
|
254
292
|
const sink = cappedOutput(MAX_CHECK_BYTES);
|
|
255
293
|
child.stdout?.on('data', (b) => sink.push(b));
|
|
256
294
|
child.stderr?.on('data', (b) => sink.push(b));
|
package/dist/index.js
CHANGED
|
@@ -145,6 +145,27 @@ async function main() {
|
|
|
145
145
|
const code = await runUpdate({ apply: !!opts.apply });
|
|
146
146
|
process.exit(code);
|
|
147
147
|
});
|
|
148
|
+
program
|
|
149
|
+
.command('version')
|
|
150
|
+
.description('Print the version number')
|
|
151
|
+
.action(() => {
|
|
152
|
+
process.stdout.write(`${VERSION}\n`);
|
|
153
|
+
});
|
|
154
|
+
program
|
|
155
|
+
.command('help [command]')
|
|
156
|
+
.description('Print help (same as --help)')
|
|
157
|
+
.action(async (cmd) => {
|
|
158
|
+
if (cmd) {
|
|
159
|
+
const target = program.commands.find((c) => c.name() === cmd);
|
|
160
|
+
if (!target) {
|
|
161
|
+
process.stderr.write(`klyro: unknown command: ${cmd}\n`);
|
|
162
|
+
process.exit(2);
|
|
163
|
+
}
|
|
164
|
+
target.outputHelp();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
program.outputHelp();
|
|
168
|
+
});
|
|
148
169
|
program
|
|
149
170
|
.command('login')
|
|
150
171
|
.description('Login and store API key (masked, 0600) — provider auto-select')
|
|
@@ -191,7 +212,12 @@ async function main() {
|
|
|
191
212
|
.command('doctor')
|
|
192
213
|
.description('Run diagnostics (node, config, provider, sessions, git, tools)')
|
|
193
214
|
.option('--json', 'Output JSON')
|
|
215
|
+
.option('--keys', 'Interactive key probe: show raw bytes your terminal sends (arrows, paste, wheel)')
|
|
194
216
|
.action(async (opts) => {
|
|
217
|
+
if (opts.keys) {
|
|
218
|
+
const { runKeysProbe } = await import('./cli/doctor.js');
|
|
219
|
+
process.exit(await runKeysProbe());
|
|
220
|
+
}
|
|
195
221
|
const code = await runDoctor({ json: !!opts.json });
|
|
196
222
|
process.exit(code);
|
|
197
223
|
});
|
|
@@ -297,8 +323,16 @@ async function main() {
|
|
|
297
323
|
.description('Run a one-shot autonomous task. Streams text to stdout; tool calls to stderr.')
|
|
298
324
|
.option('-m, --model <id>', 'Model id (default: env KLYRO_MODEL)')
|
|
299
325
|
.option('--max-steps <n>', 'Max agent steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
|
|
326
|
+
.option('--max-turns <n>', 'Alias for --max-steps (smaller wins when both are set)', (v) => parsePositiveInt('--max-turns', v))
|
|
300
327
|
.option('--max-tokens <n>', 'Max output tokens per step', (v) => parsePositiveInt('--max-tokens', v))
|
|
301
328
|
.option('--temperature <n>', 'Sampling temperature (0-2)', parseTemperature)
|
|
329
|
+
.option('--reasoning-effort <level>', 'Reasoning effort for reasoning models: low|medium|high')
|
|
330
|
+
.option('--system-prompt <text>', 'Replace the assembled system prompt wholesale')
|
|
331
|
+
.option('--append-system-prompt <text>', 'Extra instructions appended after the system prompt')
|
|
332
|
+
.option('--permission-mode <mode>', 'Policy mode: default|accept-edits|plan|auto')
|
|
333
|
+
.option('--allowed-tools <list>', 'Comma-separated tool names pre-approved for this run')
|
|
334
|
+
.option('--disallowed-tools <list>', 'Comma-separated tool names denied for this run (deny always wins)')
|
|
335
|
+
.option('--add-dir <path>', 'Extra sandbox directory (repeatable/comma-separated), like /add-dir')
|
|
302
336
|
.option('--timeout <ms>', 'Request timeout in ms (default: env KLYRO_TIMEOUT_MS or 60000)', (v) => parsePositiveInt('--timeout', v))
|
|
303
337
|
.option('--base-url <url>', 'Override KLYRO_BASE_URL')
|
|
304
338
|
.option('--api-key <key>', 'Override KLYRO_API_KEY')
|
|
@@ -316,6 +350,7 @@ async function main() {
|
|
|
316
350
|
.option('--agent <name>', 'Run under an orchestrator context enabling spawn_agent/task_list/task_get (explorer|implementer|tester|reviewer)')
|
|
317
351
|
.option('--max-depth <n>', 'Max spawn depth for child agents (default 1)', (v) => parsePositiveInt('--max-depth', v))
|
|
318
352
|
.option('--bare', 'Deterministic runs: skip MCP, hooks, memory/KLYRO.md/context, persistence')
|
|
353
|
+
.option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
|
|
319
354
|
.action(async (prompt, opts) => {
|
|
320
355
|
const model = opts.model ?? process.env.KLYRO_MODEL;
|
|
321
356
|
if (!model) {
|
|
@@ -332,6 +367,16 @@ async function main() {
|
|
|
332
367
|
process.stderr.write(`klyro: invalid --verify-mode: ${opts.verifyMode} (expected strict|advisory|off)\n`);
|
|
333
368
|
process.exit(2);
|
|
334
369
|
}
|
|
370
|
+
const reasoningEffort = opts.reasoningEffort;
|
|
371
|
+
if (reasoningEffort !== undefined && reasoningEffort !== 'low' && reasoningEffort !== 'medium' && reasoningEffort !== 'high') {
|
|
372
|
+
process.stderr.write(`klyro: invalid --reasoning-effort: ${opts.reasoningEffort} (expected low|medium|high)\n`);
|
|
373
|
+
process.exit(2);
|
|
374
|
+
}
|
|
375
|
+
const permissionMode = opts.permissionMode;
|
|
376
|
+
if (permissionMode !== undefined && permissionMode !== 'default' && permissionMode !== 'accept-edits' && permissionMode !== 'plan' && permissionMode !== 'auto') {
|
|
377
|
+
process.stderr.write(`klyro: invalid --permission-mode: ${opts.permissionMode} (expected default|accept-edits|plan|auto)\n`);
|
|
378
|
+
process.exit(2);
|
|
379
|
+
}
|
|
335
380
|
// Provider validation is handled inside runOnce (single source of truth)
|
|
336
381
|
try {
|
|
337
382
|
const code = await runOnce({
|
|
@@ -339,8 +384,16 @@ async function main() {
|
|
|
339
384
|
cwd: process.cwd(),
|
|
340
385
|
model,
|
|
341
386
|
maxSteps: opts.maxSteps,
|
|
387
|
+
maxTurns: opts.maxTurns,
|
|
342
388
|
maxTokens: opts.maxTokens,
|
|
343
389
|
temperature: opts.temperature,
|
|
390
|
+
reasoningEffort,
|
|
391
|
+
systemPromptText: opts.systemPrompt,
|
|
392
|
+
appendSystemPrompt: opts.appendSystemPrompt,
|
|
393
|
+
permissionMode,
|
|
394
|
+
allowedTools: opts.allowedTools,
|
|
395
|
+
disallowedTools: opts.disallowedTools,
|
|
396
|
+
addDir: opts.addDir,
|
|
344
397
|
timeoutMs: opts.timeout,
|
|
345
398
|
baseUrl: opts.baseUrl,
|
|
346
399
|
apiKey: opts.apiKey,
|
|
@@ -358,6 +411,7 @@ async function main() {
|
|
|
358
411
|
agent: opts.agent,
|
|
359
412
|
maxDepth: opts.maxDepth,
|
|
360
413
|
bare: !!opts.bare,
|
|
414
|
+
autoAnswer: opts.autoAnswer,
|
|
361
415
|
});
|
|
362
416
|
process.exit(code);
|
|
363
417
|
}
|
|
@@ -391,17 +445,18 @@ async function main() {
|
|
|
391
445
|
.option('--model <id>', 'Model for eval')
|
|
392
446
|
.option('--judge-model <id>', 'Live model id for grading judge.rubric (needs endpoint + key)')
|
|
393
447
|
.option('--cwd <path>', 'Shared scenario workdir (default: isolated tmp per scenario)')
|
|
448
|
+
.option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
|
|
394
449
|
.action(async (input, opts) => {
|
|
395
450
|
const output = (opts.output ?? 'human');
|
|
396
451
|
if (opts.suite) {
|
|
397
|
-
const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
|
|
452
|
+
const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
|
|
398
453
|
process.exit(code);
|
|
399
454
|
}
|
|
400
455
|
if (!input) {
|
|
401
456
|
process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
|
|
402
457
|
process.exit(2);
|
|
403
458
|
}
|
|
404
|
-
const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd });
|
|
459
|
+
const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
|
|
405
460
|
process.exit(code);
|
|
406
461
|
});
|
|
407
462
|
program
|
|
@@ -729,7 +784,6 @@ async function main() {
|
|
|
729
784
|
}
|
|
730
785
|
main().catch((err) => {
|
|
731
786
|
// Last-resort: anything that escaped the command handlers lands here.
|
|
732
|
-
// eslint-disable-next-line no-console
|
|
733
787
|
console.error(`klyro: ${err instanceof Error ? err.message : String(err)}`);
|
|
734
788
|
process.exit(1);
|
|
735
789
|
});
|
|
@@ -180,11 +180,11 @@ export class SessionStore {
|
|
|
180
180
|
try {
|
|
181
181
|
pIdx = JSON.parse(await fs.readFile(pp, 'utf-8'));
|
|
182
182
|
}
|
|
183
|
-
catch { }
|
|
183
|
+
catch { /* missing index — start fresh */ }
|
|
184
184
|
pIdx[id] = record;
|
|
185
185
|
await this.writePerProjectIndex(pp, pIdx);
|
|
186
186
|
}
|
|
187
|
-
catch { }
|
|
187
|
+
catch { /* best-effort — the primary index above already persisted */ }
|
|
188
188
|
return record;
|
|
189
189
|
});
|
|
190
190
|
}
|
package/dist/policy/approval.js
CHANGED
|
@@ -16,8 +16,11 @@ import { patternForCall } from './patterns.js';
|
|
|
16
16
|
*/
|
|
17
17
|
export function sanitizeForPrompt(s) {
|
|
18
18
|
const noAnsi = s
|
|
19
|
+
// eslint-disable-next-line no-control-regex -- intentional: stripping CSI + OSC sequences
|
|
19
20
|
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
21
|
+
// eslint-disable-next-line no-control-regex -- intentional: stripping OSC sequences
|
|
20
22
|
.replace(/\x1b\][^\x07]*\x07/g, '');
|
|
23
|
+
// eslint-disable-next-line no-control-regex -- intentional: stripping C0 controls
|
|
21
24
|
const clean = noAnsi.replace(/[\x00-\x08\x0b-\x0d\x0e-\x1f\x7f]/g, '');
|
|
22
25
|
const LIMIT = 500;
|
|
23
26
|
if (clean.length > LIMIT) {
|
|
@@ -21,13 +21,18 @@ export declare class PathGuardError extends Error {
|
|
|
21
21
|
/**
|
|
22
22
|
* Resolve a (possibly relative) path against cwd and assert it stays inside.
|
|
23
23
|
* Does NOT follow symlinks — call `resolveAndFollowSymlinks` for that.
|
|
24
|
+
* `extraRoots` (--add-dir / agent sandbox extensions, relative entries
|
|
25
|
+
* resolve against cwd) are tried in order when the path escapes cwd.
|
|
24
26
|
*/
|
|
25
|
-
export declare function resolveWithinCwd(cwd: string, requested: string): PathGuardResult;
|
|
27
|
+
export declare function resolveWithinCwd(cwd: string, requested: string, extraRoots?: readonly string[]): PathGuardResult;
|
|
26
28
|
/**
|
|
27
|
-
* Resolve, follow symlinks, and assert the target stays inside cwd
|
|
28
|
-
* Use this for read_file / write_file to defeat
|
|
29
|
+
* Resolve, follow symlinks, and assert the target stays inside cwd (or one
|
|
30
|
+
* of `extraRoots`). Use this for read_file / write_file to defeat
|
|
31
|
+
* symlink-based escapes. Symlink checks run against the winning root, so a
|
|
32
|
+
* link inside an added dir pointing outside it is refused exactly like a
|
|
33
|
+
* cwd escape.
|
|
29
34
|
*/
|
|
30
|
-
export declare function resolveAndFollowSymlinks(cwd: string, requested: string): Promise<PathGuardResult>;
|
|
35
|
+
export declare function resolveAndFollowSymlinks(cwd: string, requested: string, extraRoots?: readonly string[]): Promise<PathGuardResult>;
|
|
31
36
|
/**
|
|
32
37
|
* Symlink-swap guard: refuse when the FINAL path itself is a symlink.
|
|
33
38
|
*
|
|
@@ -33,31 +33,55 @@ function normalizeForCompare(p) {
|
|
|
33
33
|
/**
|
|
34
34
|
* Resolve a (possibly relative) path against cwd and assert it stays inside.
|
|
35
35
|
* Does NOT follow symlinks — call `resolveAndFollowSymlinks` for that.
|
|
36
|
+
* `extraRoots` (--add-dir / agent sandbox extensions, relative entries
|
|
37
|
+
* resolve against cwd) are tried in order when the path escapes cwd.
|
|
36
38
|
*/
|
|
37
|
-
export function resolveWithinCwd(cwd, requested) {
|
|
39
|
+
export function resolveWithinCwd(cwd, requested, extraRoots) {
|
|
40
|
+
return resolveWithinRoots(cwd, requested, extraRoots);
|
|
41
|
+
}
|
|
42
|
+
function tryContain(root, requested) {
|
|
43
|
+
const resolved = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested);
|
|
44
|
+
// Use relative to test containment. path.relative throws on different
|
|
45
|
+
// drives on Windows; normalize to handle that.
|
|
46
|
+
const rel = path.relative(root, resolved);
|
|
47
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
|
|
48
|
+
return { resolved };
|
|
49
|
+
// Different drive on Windows: relative starts with a drive letter
|
|
50
|
+
if (isWindows() && /^[A-Za-z]:[\\/]/.test(rel))
|
|
51
|
+
return { drive: true };
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function resolveWithinRoots(cwd, requested, extraRoots) {
|
|
55
|
+
const roots = extraRoots ?? [];
|
|
38
56
|
if (!requested || requested.length === 0) {
|
|
39
57
|
throw new PathGuardError(TOOL_ERROR_CODES.INVALID_INPUT, 'Path is empty');
|
|
40
58
|
}
|
|
41
59
|
const absCwd = path.resolve(cwd);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
const first = tryContain(absCwd, requested);
|
|
61
|
+
if (first && !('drive' in first))
|
|
62
|
+
return { resolved: first.resolved, root: absCwd };
|
|
63
|
+
for (const extra of roots) {
|
|
64
|
+
const root = path.isAbsolute(extra) ? path.resolve(extra) : path.resolve(absCwd, extra);
|
|
65
|
+
if (root === absCwd)
|
|
66
|
+
continue;
|
|
67
|
+
const hit = tryContain(root, requested);
|
|
68
|
+
if (hit && !('drive' in hit))
|
|
69
|
+
return { resolved: hit.resolved, root };
|
|
48
70
|
}
|
|
49
|
-
|
|
50
|
-
if (isWindows() && /^[A-Za-z]:[\\/]/.test(rel)) {
|
|
71
|
+
if (first && 'drive' in first) {
|
|
51
72
|
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd (different drive): ${requested}`);
|
|
52
73
|
}
|
|
53
74
|
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd: ${requested}`);
|
|
54
75
|
}
|
|
55
76
|
/**
|
|
56
|
-
* Resolve, follow symlinks, and assert the target stays inside cwd
|
|
57
|
-
* Use this for read_file / write_file to defeat
|
|
77
|
+
* Resolve, follow symlinks, and assert the target stays inside cwd (or one
|
|
78
|
+
* of `extraRoots`). Use this for read_file / write_file to defeat
|
|
79
|
+
* symlink-based escapes. Symlink checks run against the winning root, so a
|
|
80
|
+
* link inside an added dir pointing outside it is refused exactly like a
|
|
81
|
+
* cwd escape.
|
|
58
82
|
*/
|
|
59
|
-
export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
60
|
-
const { resolved } =
|
|
83
|
+
export async function resolveAndFollowSymlinks(cwd, requested, extraRoots) {
|
|
84
|
+
const { resolved, root } = resolveWithinRoots(cwd, requested, extraRoots);
|
|
61
85
|
let real;
|
|
62
86
|
let realParent;
|
|
63
87
|
try {
|
|
@@ -77,21 +101,21 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
|
77
101
|
return { resolved };
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
|
-
const
|
|
104
|
+
const absRoot = await fs.realpath(root).catch(() => path.resolve(root));
|
|
81
105
|
const cmpReal = normalizeForCompare(real);
|
|
82
|
-
const
|
|
83
|
-
if (cmpReal !==
|
|
106
|
+
const cmpRoot = normalizeForCompare(absRoot);
|
|
107
|
+
if (cmpReal !== cmpRoot && !cmpReal.startsWith(cmpRoot + path.sep)) {
|
|
84
108
|
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Symlink target escapes cwd: ${requested} -> ${real}`);
|
|
85
109
|
}
|
|
86
110
|
// Defense-in-depth: ensure the parent directory of the resolved target
|
|
87
|
-
// is also inside
|
|
88
|
-
// walks through a symlinked parent (e.g. `<
|
|
89
|
-
// and `real` happens to land back inside
|
|
90
|
-
// Skip when the target itself is
|
|
91
|
-
//
|
|
92
|
-
if (real !==
|
|
111
|
+
// is also inside the winning root. This catches the case where the
|
|
112
|
+
// requested path walks through a symlinked parent (e.g. `<root>/evil-link/../escape`)
|
|
113
|
+
// and `real` happens to land back inside the root but the parent was outside.
|
|
114
|
+
// Skip when the target itself is the root (realParent would be the parent of
|
|
115
|
+
// the root, which is necessarily outside).
|
|
116
|
+
if (real !== absRoot) {
|
|
93
117
|
const cmpRealParent = normalizeForCompare(realParent);
|
|
94
|
-
if (cmpRealParent !==
|
|
118
|
+
if (cmpRealParent !== cmpRoot && !cmpRealParent.startsWith(cmpRoot + path.sep)) {
|
|
95
119
|
throw new PathGuardError(TOOL_ERROR_CODES.PATH_ESCAPE, `Path escapes cwd via parent symlink: ${requested} -> ${realParent}`);
|
|
96
120
|
}
|
|
97
121
|
}
|
|
@@ -47,6 +47,14 @@ const BUILTINS = [
|
|
|
47
47
|
defaultModel: 'local-model',
|
|
48
48
|
probePath: '/models',
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
id: 'openrouter',
|
|
52
|
+
label: 'OpenRouter',
|
|
53
|
+
kind: 'openai-compatible',
|
|
54
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
55
|
+
defaultModel: 'openai/gpt-4o-mini',
|
|
56
|
+
apiKeyEnv: ['KLYRO_API_KEY', 'OPENROUTER_API_KEY'],
|
|
57
|
+
},
|
|
50
58
|
];
|
|
51
59
|
const custom = new Map();
|
|
52
60
|
function validateEndpoint(def) {
|
|
@@ -15,6 +15,7 @@ export interface ModelInfo {
|
|
|
15
15
|
}
|
|
16
16
|
export declare const MODEL_REGISTRY: Record<string, ModelInfo>;
|
|
17
17
|
export declare function getModelInfo(id: string): ModelInfo;
|
|
18
|
+
export declare function resolveModelAlias(id: string): string;
|
|
18
19
|
/**
|
|
19
20
|
* Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
|
|
20
21
|
* prices live in exactly one place. Local/self-hosted entries are $0.
|
|
@@ -12,6 +12,25 @@ export const MODEL_REGISTRY = {
|
|
|
12
12
|
export function getModelInfo(id) {
|
|
13
13
|
return MODEL_REGISTRY[id] ?? { id, contextWindow: 100000, maxOutput: 4096, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } };
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* CLI model aliases (2.2): short names resolving against the registry so
|
|
17
|
+
* they never point at retired generations. Exact registry hits and unknown
|
|
18
|
+
* ids pass through untouched (the provider reports those errors).
|
|
19
|
+
*/
|
|
20
|
+
const MODEL_ALIASES = {
|
|
21
|
+
sonnet: 'claude-3-5-sonnet-20240620',
|
|
22
|
+
// No opus generation is pinned in the registry; closest capable Claude.
|
|
23
|
+
opus: 'claude-3-5-sonnet-20240620',
|
|
24
|
+
haiku: 'claude-3-haiku-20240307',
|
|
25
|
+
gpt: 'gpt-4o-mini',
|
|
26
|
+
local: 'local-model',
|
|
27
|
+
};
|
|
28
|
+
export function resolveModelAlias(id) {
|
|
29
|
+
if (!id || MODEL_REGISTRY[id])
|
|
30
|
+
return id;
|
|
31
|
+
const hit = MODEL_ALIASES[id.toLowerCase()];
|
|
32
|
+
return hit ?? id;
|
|
33
|
+
}
|
|
15
34
|
/**
|
|
16
35
|
* Single-source per-1K-token rate table, derived from MODEL_REGISTRY so
|
|
17
36
|
* prices live in exactly one place. Local/self-hosted entries are $0.
|
package/dist/providers.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { assertSafeBaseURL, normalizeBaseURL } from './chat.js';
|
|
15
15
|
import { localProbeEndpoints } from './providers/endpoints.js';
|
|
16
|
+
import { resolveModelAlias } from './providers/model-info.js';
|
|
16
17
|
/**
|
|
17
18
|
* Local probe candidates, derived from the single provider registry
|
|
18
19
|
* (`providers/endpoints.ts`) — adding a local server there automatically
|
|
@@ -49,7 +50,9 @@ export async function resolveProvider() {
|
|
|
49
50
|
};
|
|
50
51
|
const envBaseURL = process.env.KLYRO_BASE_URL;
|
|
51
52
|
const envKey = process.env.KLYRO_API_KEY;
|
|
52
|
-
|
|
53
|
+
// Model aliases (2.2: sonnet/opus/haiku/gpt/local) resolve against the
|
|
54
|
+
// registry here so every consumer gets canonical ids.
|
|
55
|
+
const envModel = process.env.KLYRO_MODEL ? resolveModelAlias(process.env.KLYRO_MODEL) : undefined;
|
|
53
56
|
// Persisted config loads first: it carries the allowInsecure opt-in that
|
|
54
57
|
// also governs env URLs, and supplies model/key fallbacks everywhere.
|
|
55
58
|
let cfg = {};
|
|
@@ -61,7 +64,8 @@ export async function resolveProvider() {
|
|
|
61
64
|
const allowInsecure = cfg.allowInsecure === true;
|
|
62
65
|
const cfgBase = (cfg.baseUrl ?? cfg.baseURL);
|
|
63
66
|
const cfgProvider = cfg.provider;
|
|
64
|
-
const
|
|
67
|
+
const cfgModelRaw = (cfg.model ?? cfg['model.default']);
|
|
68
|
+
const cfgModel = cfgModelRaw ? resolveModelAlias(cfgModelRaw) : undefined;
|
|
65
69
|
const cfgKey = (cfg.apiKey ?? cfg.api_key);
|
|
66
70
|
// An explicitly configured endpoint that fails policy is a hard error —
|
|
67
71
|
// fail loud with the reason instead of silently guessing another endpoint
|
|
@@ -7,3 +7,10 @@ export declare class TerminalRenderer {
|
|
|
7
7
|
handle(ev: KlyroEvent): void;
|
|
8
8
|
renderMarkdown(text: string): void;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Drain-aware stdout write shared by legacy streaming paths (3.1: all
|
|
12
|
+
* human output flows through the terminal renderer module). Sanitizes
|
|
13
|
+
* first, then waits for drain. Returns false if stdout closed (e.g. piped
|
|
14
|
+
* to `head`) or the abort signal fires — callers must stop producing.
|
|
15
|
+
*/
|
|
16
|
+
export declare function writeStdoutDrained(chunk: string, signal?: AbortSignal): Promise<boolean>;
|
|
@@ -40,3 +40,55 @@ export class TerminalRenderer {
|
|
|
40
40
|
process.stdout.write(sanitizeTerminalText(out));
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Drain-aware stdout write shared by legacy streaming paths (3.1: all
|
|
45
|
+
* human output flows through the terminal renderer module). Sanitizes
|
|
46
|
+
* first, then waits for drain. Returns false if stdout closed (e.g. piped
|
|
47
|
+
* to `head`) or the abort signal fires — callers must stop producing.
|
|
48
|
+
*/
|
|
49
|
+
export async function writeStdoutDrained(chunk, signal) {
|
|
50
|
+
const clean = sanitizeTerminalText(chunk);
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
if (signal?.aborted) {
|
|
53
|
+
resolve(false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!process.stdout.write(clean)) {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const cleanup = () => {
|
|
59
|
+
process.stdout.off('drain', onDrain);
|
|
60
|
+
process.stdout.off('error', onError);
|
|
61
|
+
if (signal)
|
|
62
|
+
signal.removeEventListener('abort', onAbort);
|
|
63
|
+
};
|
|
64
|
+
const onDrain = () => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
cleanup();
|
|
69
|
+
resolve(true);
|
|
70
|
+
};
|
|
71
|
+
const onError = () => {
|
|
72
|
+
if (settled)
|
|
73
|
+
return;
|
|
74
|
+
settled = true;
|
|
75
|
+
cleanup();
|
|
76
|
+
resolve(false);
|
|
77
|
+
};
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
if (settled)
|
|
80
|
+
return;
|
|
81
|
+
settled = true;
|
|
82
|
+
cleanup();
|
|
83
|
+
resolve(false);
|
|
84
|
+
};
|
|
85
|
+
process.stdout.once('drain', onDrain);
|
|
86
|
+
process.stdout.once('error', onError);
|
|
87
|
+
if (signal)
|
|
88
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
resolve(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
package/dist/repl.d.ts
CHANGED
|
@@ -17,6 +17,15 @@ export interface Turn {
|
|
|
17
17
|
role: 'user' | 'assistant';
|
|
18
18
|
content: string;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Trailing-backslash continuation for the legacy REPL (1.4): a line ending
|
|
22
|
+
* in an odd run of backslashes joins the next line (one backslash removed,
|
|
23
|
+
* newline kept); anything else completes the prompt. Exported for tests.
|
|
24
|
+
*/
|
|
25
|
+
export declare function splitContinuedLine(pending: string, line: string): {
|
|
26
|
+
pending: string;
|
|
27
|
+
complete?: string;
|
|
28
|
+
};
|
|
20
29
|
export declare function repl(system: string): Promise<void>;
|
|
21
30
|
/** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
|
|
22
31
|
export declare function trimHistory(history: Turn[]): void;
|
package/dist/repl.js
CHANGED
|
@@ -22,6 +22,17 @@ import { estimateTokens } from './context/tokenizer.js';
|
|
|
22
22
|
const MAX_HISTORY_TURNS = 40;
|
|
23
23
|
/** Approximate char budget for the user side of history. */
|
|
24
24
|
const MAX_HISTORY_CHARS = 80_000;
|
|
25
|
+
/**
|
|
26
|
+
* Trailing-backslash continuation for the legacy REPL (1.4): a line ending
|
|
27
|
+
* in an odd run of backslashes joins the next line (one backslash removed,
|
|
28
|
+
* newline kept); anything else completes the prompt. Exported for tests.
|
|
29
|
+
*/
|
|
30
|
+
export function splitContinuedLine(pending, line) {
|
|
31
|
+
const m = /\\+$/.exec(line);
|
|
32
|
+
if (m && m[0].length % 2 === 1)
|
|
33
|
+
return { pending: pending + line.slice(0, -1) + '\n' };
|
|
34
|
+
return { pending: '', complete: pending + line };
|
|
35
|
+
}
|
|
25
36
|
export async function repl(system) {
|
|
26
37
|
const provider = await resolveProvider();
|
|
27
38
|
if (!provider) {
|
|
@@ -33,7 +44,7 @@ export async function repl(system) {
|
|
|
33
44
|
console.error(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2');
|
|
34
45
|
exit(2);
|
|
35
46
|
}
|
|
36
|
-
const { baseURL, apiKey, model
|
|
47
|
+
const { baseURL, apiKey, model } = provider;
|
|
37
48
|
// Legacy REPL — prefer `klyro tui`; history truncation is approximate.
|
|
38
49
|
process.stderr.write('klyro: legacy REPL — prefer `klyro tui`; history truncation is approximate.\n');
|
|
39
50
|
const history = [];
|
|
@@ -62,26 +73,34 @@ export async function repl(system) {
|
|
|
62
73
|
});
|
|
63
74
|
if (isTTY)
|
|
64
75
|
console.log(`klyro REPL — type :quit to exit, :clear to reset history ${providerHelp(provider)}`);
|
|
76
|
+
// Trailing-backslash continuation (1.4): a line ending in an odd run of
|
|
77
|
+
// backslashes joins the next line (one backslash removed, newline kept).
|
|
78
|
+
let continued = '';
|
|
65
79
|
try {
|
|
66
80
|
while (true) {
|
|
67
81
|
// In TTY mode, readline handles the prompt and echoes the user's typing.
|
|
68
82
|
// In non-TTY mode, we write the prompt ourselves.
|
|
83
|
+
const tag = continued ? '... ' : (isTTY ? '> ' : '');
|
|
69
84
|
if (!isTTY)
|
|
70
|
-
process.stdout.write(
|
|
85
|
+
process.stdout.write(tag);
|
|
71
86
|
let line;
|
|
72
87
|
try {
|
|
73
88
|
// Pass the prompt only when readline is in charge (TTY). In non-TTY
|
|
74
89
|
// mode the prompt is suppressed (output: undefined) so passing '' is
|
|
75
90
|
// equivalent and avoids doubling.
|
|
76
|
-
line = await rl.question(isTTY ?
|
|
91
|
+
line = await rl.question(isTTY ? tag : '');
|
|
77
92
|
}
|
|
78
|
-
catch
|
|
93
|
+
catch {
|
|
79
94
|
// EOF on stdin (Ctrl-D) or close — exit cleanly.
|
|
80
95
|
break;
|
|
81
96
|
}
|
|
82
97
|
if (!line)
|
|
83
98
|
break; // empty line on EOF
|
|
84
|
-
const
|
|
99
|
+
const step = splitContinuedLine(continued, line);
|
|
100
|
+
continued = step.pending;
|
|
101
|
+
if (step.complete === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
const prompt = step.complete.trim();
|
|
85
104
|
if (!prompt)
|
|
86
105
|
continue;
|
|
87
106
|
if (prompt === ':quit' || prompt === ':exit')
|
package/dist/shared/index.d.ts
CHANGED