klyro 0.1.52 → 0.1.54

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.
Files changed (53) hide show
  1. package/dist/agent/anthropic-adapter.js +22 -2
  2. package/dist/agent/registry.js +11 -1
  3. package/dist/agent/retry.d.ts +5 -0
  4. package/dist/agent/retry.js +28 -1
  5. package/dist/agent/runtime.d.ts +2 -0
  6. package/dist/agent/runtime.js +48 -35
  7. package/dist/chat.js +2 -2
  8. package/dist/checkpoints/store.js +86 -7
  9. package/dist/cli/config.js +13 -1
  10. package/dist/cli/repl.js +234 -44
  11. package/dist/cli/run.js +88 -69
  12. package/dist/context/klyro-md.d.ts +5 -0
  13. package/dist/context/klyro-md.js +28 -6
  14. package/dist/context/tokenizer.js +8 -1
  15. package/dist/index.js +49 -14
  16. package/dist/persistence/session.d.ts +2 -0
  17. package/dist/persistence/session.js +11 -5
  18. package/dist/persistence/store.d.ts +7 -0
  19. package/dist/persistence/store.js +77 -28
  20. package/dist/policy/engine.d.ts +10 -1
  21. package/dist/policy/engine.js +33 -2
  22. package/dist/tools/fs/apply-patch.d.ts +16 -1
  23. package/dist/tools/fs/apply-patch.js +163 -75
  24. package/dist/tools/fs/edit-file.d.ts +13 -0
  25. package/dist/tools/fs/edit-file.js +96 -54
  26. package/dist/tools/fs/list-dir.d.ts +2 -0
  27. package/dist/tools/fs/list-dir.js +7 -5
  28. package/dist/tools/fs/multi-edit.js +12 -1
  29. package/dist/tools/fs/read-file.js +1 -1
  30. package/dist/tools/fs/read-history.d.ts +2 -2
  31. package/dist/tools/fs/read-history.js +39 -7
  32. package/dist/tools/fs/write-file.js +1 -1
  33. package/dist/tools/normalize.js +11 -1
  34. package/dist/tools/shell/background.d.ts +5 -0
  35. package/dist/tools/shell/background.js +29 -0
  36. package/dist/tools/shell/shell-exec.d.ts +9 -0
  37. package/dist/tools/shell/shell-exec.js +54 -8
  38. package/dist/tools/verify/run-verify.js +2 -2
  39. package/dist/tui/app.d.ts +2 -1
  40. package/dist/tui/app.js +38 -9
  41. package/dist/tui/app.test.js +57 -0
  42. package/dist/tui/approval.d.ts +1 -1
  43. package/dist/tui/approval.js +20 -5
  44. package/dist/tui/diff.d.ts +5 -19
  45. package/dist/tui/diff.js +9 -25
  46. package/dist/tui/measure.d.ts +2 -0
  47. package/dist/tui/measure.js +1 -1
  48. package/dist/tui/status.d.ts +4 -8
  49. package/dist/tui/status.js +8 -17
  50. package/dist/tui/transcript.d.ts +13 -13
  51. package/dist/tui/transcript.js +9 -74
  52. package/dist/verification/engine.js +4 -4
  53. package/package.json +1 -1
@@ -115,6 +115,8 @@ async function* streamAnthropic(req, opts) {
115
115
  const toolBuffers = new Map();
116
116
  // Map content_block index → tool_use id (persists after tool completes to handle late deltas)
117
117
  const indexToToolId = new Map();
118
+ // message_stop already yields message_end — don't emit a second one at EOF.
119
+ let sawMessageEnd = false;
118
120
  try {
119
121
  while (true) {
120
122
  const { value, done } = await reader.read();
@@ -152,8 +154,11 @@ async function* streamAnthropic(req, opts) {
152
154
  continue;
153
155
  }
154
156
  const out = translateSse(e.event, parsed, toolBuffers, indexToToolId);
155
- for (const ev of out)
157
+ for (const ev of out) {
158
+ if (ev.kind === 'message_end')
159
+ sawMessageEnd = true;
156
160
  yield ev;
161
+ }
157
162
  }
158
163
  }
159
164
  }
@@ -165,7 +170,8 @@ async function* streamAnthropic(req, opts) {
165
170
  finally {
166
171
  reader.releaseLock();
167
172
  }
168
- yield { kind: 'message_end', finishReason: 'stop' };
173
+ if (!sawMessageEnd)
174
+ yield { kind: 'message_end', finishReason: 'stop' };
169
175
  }
170
176
  function translateSse(event, parsed, toolBuffers, indexToToolId) {
171
177
  const out = [];
@@ -277,6 +283,20 @@ function toAnthropicMessages(messages) {
277
283
  }),
278
284
  };
279
285
  }
286
+ if (m.role === 'tool') {
287
+ // Anthropic has no tool role: tool_result blocks ride a user message,
288
+ // paired with the preceding assistant tool_use by tool_use_id.
289
+ const content = m.content.map((b) => {
290
+ if (b.kind === 'tool_result') {
291
+ const out = typeof b.output === 'string' ? b.output : JSON.stringify(b.output ?? '');
292
+ return { type: 'tool_result', tool_use_id: b.toolCallId, content: out, is_error: b.isError };
293
+ }
294
+ if (b.kind === 'text')
295
+ return { type: 'text', text: b.text };
296
+ return { type: 'text', text: '' };
297
+ });
298
+ return { role: 'user', content };
299
+ }
280
300
  // 'system' is hoisted to the top-level `system` field; never appears
281
301
  // in the messages array passed to the adapter.
282
302
  return { role: 'user', content: [{ type: 'text', text: '' }] };
@@ -19,6 +19,7 @@
19
19
  import * as fsSync from 'node:fs';
20
20
  import * as os from 'node:os';
21
21
  import * as path from 'node:path';
22
+ import { assertSafeBaseURL } from '../chat.js';
22
23
  import { httpChatAdapter } from './provider-adapter.js';
23
24
  import { anthropicAdapter } from './anthropic-adapter.js';
24
25
  import { retryingAdapter } from './retry.js';
@@ -103,11 +104,12 @@ function persistedProviderSettings() {
103
104
  const baseURL = (cfg.baseUrl ?? cfg.baseURL);
104
105
  const provider = cfg.provider;
105
106
  const model = (cfg.model ?? cfg['model.default']);
107
+ const allowInsecure = cfg.allowInsecure === true;
106
108
  const apiKey = (cfg.apiKey ?? cfg.api_key) ??
107
109
  (provider ? keyOf(provider) : undefined) ??
108
110
  keyOf('openai') ??
109
111
  keyOf('anthropic');
110
- return { baseURL, apiKey, provider, model };
112
+ return { baseURL, apiKey, provider, model, allowInsecure };
111
113
  }
112
114
  catch {
113
115
  return {};
@@ -118,6 +120,14 @@ export function buildProvider(opts = {}) {
118
120
  const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL ?? saved.baseURL;
119
121
  const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY ?? saved.apiKey;
120
122
  const timeoutMs = opts.timeoutMs ?? 60_000;
123
+ // Same bearer-token guard as the TUI path: refuse plaintext HTTP to remote
124
+ // hosts unless explicitly opted in (persisted allowInsecure or env).
125
+ // (chat.ts has no imports — this static import cannot cycle.)
126
+ if (baseURL) {
127
+ assertSafeBaseURL(baseURL, {
128
+ allowInsecure: saved.allowInsecure === true || process.env.KLYRO_ALLOW_INSECURE === '1',
129
+ });
130
+ }
121
131
  // Resolve provider (with aliases like 9router -> openrouter -> openai)
122
132
  let provider;
123
133
  const normalizedOpt = normalizeProviderName(opts.provider);
@@ -30,5 +30,10 @@ export interface RetryOptions {
30
30
  onAttempt?: (attempt: number) => void;
31
31
  }
32
32
  export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt'>>;
33
+ /**
34
+ * Sleep that resolves early when `signal` aborts (never rejects — callers
35
+ * check `signal.aborted` themselves after waking).
36
+ */
37
+ export declare function sleepAbortable(ms: number, sleep: (ms: number) => Promise<void>, signal?: AbortSignal): Promise<void>;
33
38
  export declare function computeBackoff(attempt: number, baseMs: number, maxMs: number): number;
34
39
  export declare function retryingAdapter(inner: ProviderAdapter, opts?: Partial<RetryOptions>): ProviderAdapter;
@@ -57,6 +57,31 @@ async function* streamWithAbort(source, signal) {
57
57
  }
58
58
  }
59
59
  }
60
+ /**
61
+ * Sleep that resolves early when `signal` aborts (never rejects — callers
62
+ * check `signal.aborted` themselves after waking).
63
+ */
64
+ export function sleepAbortable(ms, sleep, signal) {
65
+ if (ms <= 0)
66
+ return Promise.resolve();
67
+ // No signal: plain sleep (identical to the old behavior).
68
+ if (!signal)
69
+ return sleep(ms);
70
+ if (signal.aborted)
71
+ return Promise.resolve();
72
+ return new Promise((resolve) => {
73
+ let done = false;
74
+ const finish = () => {
75
+ if (done)
76
+ return;
77
+ done = true;
78
+ signal.removeEventListener('abort', finish);
79
+ resolve();
80
+ };
81
+ signal.addEventListener('abort', finish, { once: true });
82
+ void sleep(ms).then(finish);
83
+ });
84
+ }
60
85
  export function computeBackoff(attempt, baseMs, maxMs) {
61
86
  const exp = Math.min(maxMs, baseMs * 2 ** attempt);
62
87
  // Jitter: ±25% to spread thundering herds.
@@ -128,8 +153,10 @@ export function retryingAdapter(inner, opts = {}) {
128
153
  return;
129
154
  }
130
155
  const delay = computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
156
+ // Abort-aware backoff: Ctrl+C during the sleep must stop promptly
157
+ // instead of stalling up to maxMs before noticing.
131
158
  if (delay > 0)
132
- await sleep(delay);
159
+ await sleepAbortable(delay, sleep, effectiveSignal);
133
160
  }
134
161
  },
135
162
  };
@@ -173,6 +173,8 @@ export interface RunResult {
173
173
  toolCalls: number;
174
174
  finalText: string;
175
175
  transcript: Message[];
176
+ /** Whether any file-mutating tool ran (drives --require-verify semantics). */
177
+ hasEdits: boolean;
176
178
  usage: {
177
179
  input: number;
178
180
  output: number;
@@ -180,13 +180,13 @@ export async function run(opts, deps) {
180
180
  if (cost >= maxCost) {
181
181
  setPhase('limit');
182
182
  await closeTracer();
183
- return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max cost $${maxCost} reached (cost $${cost.toFixed(2)})`, transcript, usage, repairs, phase: 'limit' };
183
+ return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max cost $${maxCost} reached (cost $${cost.toFixed(2)})`, transcript, hasEdits, usage, repairs, phase: 'limit' };
184
184
  }
185
185
  }
186
186
  if (maxTimeMs !== undefined && Date.now() - startTime >= maxTimeMs) {
187
187
  setPhase('limit');
188
188
  await closeTracer();
189
- return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max time ${maxTimeMs}ms reached`, transcript, usage, repairs, phase: 'limit' };
189
+ return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max time ${maxTimeMs}ms reached`, transcript, hasEdits, usage, repairs, phase: 'limit' };
190
190
  }
191
191
  if (opts.signal?.aborted) {
192
192
  emit?.({ kind: 'aborted' });
@@ -197,7 +197,7 @@ export async function run(opts, deps) {
197
197
  catch { /* ignore */ }
198
198
  }
199
199
  await closeTracer();
200
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined, phase: 'blocked' };
200
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined, phase: 'blocked' };
201
201
  }
202
202
  steps++;
203
203
  // 5.1 phase transitions (model-narrated)
@@ -282,6 +282,7 @@ export async function run(opts, deps) {
282
282
  toolCalls: toolCallCount,
283
283
  finalText: textBuf,
284
284
  transcript,
285
+ hasEdits,
285
286
  usage,
286
287
  repairs,
287
288
  verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined,
@@ -321,7 +322,7 @@ export async function run(opts, deps) {
321
322
  catch { /* ignore */ }
322
323
  }
323
324
  await closeTracer();
324
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
325
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
325
326
  }
326
327
  if (finalizedCalls.length === 0) {
327
328
  finalText = textBuf;
@@ -340,35 +341,39 @@ export async function run(opts, deps) {
340
341
  const isScoped = !!scopedCmd;
341
342
  if (isScoped)
342
343
  emit?.({ kind: 'verification_started', command: cmdToRun });
343
- try {
344
- if (isScoped) {
345
- const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
346
- const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
347
- vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
344
+ // 6.3 — cheap sanity checks FIRST (fail fast before the suite runs).
345
+ // (Previously these ran after a green verify — pure waste.)
346
+ let sanityVResult;
347
+ for (const f of edited.slice(-3)) {
348
+ const sc = await syntaxCheck(opts.cwd, f);
349
+ if (!sc.ok) {
350
+ sanityVResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
351
+ break;
348
352
  }
349
- else {
350
- vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
353
+ const ic = checkImports(opts.cwd, f);
354
+ if (!ic.ok) {
355
+ sanityVResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
356
+ break;
351
357
  }
352
358
  }
353
- catch (e) {
354
- const msg = e instanceof Error ? e.message : String(e);
355
- vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
359
+ if (sanityVResult) {
360
+ vResult = sanityVResult;
356
361
  }
357
- // 6.3 — sanity checks before full verify
358
- if (vResult.ok) {
359
- // quick syntax/import guard for last edited files
360
- for (const f of edited.slice(-3)) {
361
- const sc = await syntaxCheck(opts.cwd, f);
362
- if (!sc.ok) {
363
- vResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
364
- break;
362
+ else {
363
+ try {
364
+ if (isScoped) {
365
+ const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
366
+ const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
367
+ vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
365
368
  }
366
- const ic = checkImports(opts.cwd, f);
367
- if (!ic.ok) {
368
- vResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
369
- break;
369
+ else {
370
+ vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
370
371
  }
371
372
  }
373
+ catch (e) {
374
+ const msg = e instanceof Error ? e.message : String(e);
375
+ vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
376
+ }
372
377
  }
373
378
  // If scoped passed but full may still fail, run full before declaring success
374
379
  if (vResult.ok && isScoped) {
@@ -390,7 +395,7 @@ export async function run(opts, deps) {
390
395
  catch { /* ignore */ }
391
396
  }
392
397
  await closeTracer();
393
- return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
398
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
394
399
  }
395
400
  // 6.4 — classify
396
401
  const baseline = await getBaseline(opts.cwd, verifyCmd);
@@ -408,7 +413,7 @@ export async function run(opts, deps) {
408
413
  catch { /* ignore */ }
409
414
  }
410
415
  await closeTracer();
411
- return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
416
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
412
417
  }
413
418
  if (cls === 'env') {
414
419
  // don't try to repair env failures with code edits
@@ -419,7 +424,7 @@ export async function run(opts, deps) {
419
424
  emit?.({ kind: 'verification_failed', step: String(steps), reason: `env: ${diagnosticForModel(vResult).slice(0, 600)}` });
420
425
  if (verificationAttempts >= maxRepairs) {
421
426
  await closeTracer();
422
- return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
427
+ return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
423
428
  }
424
429
  emit?.({ kind: 'step_end', step: steps });
425
430
  continue;
@@ -445,7 +450,7 @@ export async function run(opts, deps) {
445
450
  catch { /* ignore */ }
446
451
  }
447
452
  await closeTracer();
448
- return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
453
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
449
454
  }
450
455
  }
451
456
  // Failure → repair loop (introduced)
@@ -489,7 +494,7 @@ export async function run(opts, deps) {
489
494
  emit?.({ kind: 'final_text', text: finalText });
490
495
  emit?.({ kind: 'step_end', step: steps });
491
496
  await closeTracer();
492
- return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType } };
497
+ return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType } };
493
498
  }
494
499
  emit?.({ kind: 'step_end', step: steps });
495
500
  continue; // -> next iteration lets model repair
@@ -503,7 +508,7 @@ export async function run(opts, deps) {
503
508
  catch { /* ignore */ }
504
509
  }
505
510
  await closeTracer();
506
- return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: true, attempts: verificationAttempts } : undefined };
511
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: true, attempts: verificationAttempts } : undefined };
507
512
  }
508
513
  // 3.1 — emit turn events
509
514
  emitKlyro({ type: 'turn.start', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps, model: opts.model });
@@ -664,7 +669,7 @@ export async function run(opts, deps) {
664
669
  catch { /* ignore */ }
665
670
  }
666
671
  await closeTracer();
667
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
672
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
668
673
  }
669
674
  emit?.({ kind: 'final_text', text: finalText });
670
675
  if (store && sessionId) {
@@ -674,7 +679,7 @@ export async function run(opts, deps) {
674
679
  catch { /* ignore */ }
675
680
  }
676
681
  await closeTracer();
677
- return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
682
+ return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
678
683
  }
679
684
  function redactOutput(v) {
680
685
  if (typeof v === 'string')
@@ -702,7 +707,7 @@ function inferFileChanged(toolName, input, output) {
702
707
  const inPath = typeof input.path === 'string' ? input.path : null;
703
708
  if (!inPath)
704
709
  return null;
705
- if (toolName === 'write_file' || toolName === 'edit_file') {
710
+ if (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'multi_edit') {
706
711
  // Distinguish create vs modify by the output shape: write_file returns
707
712
  // { path, bytesWritten }; edit_file returns { path, replacements, diff }.
708
713
  // Both are "modified" semantically; we don't have the pre-state easily
@@ -710,6 +715,14 @@ function inferFileChanged(toolName, input, output) {
710
715
  // file before/after the call. For now: treat both as 'modified'.
711
716
  return { path: inPath, op: 'modified' };
712
717
  }
718
+ if (toolName === 'apply_patch') {
719
+ // apply_patch has no path input — infer from its patchedFiles output.
720
+ const files = output?.patchedFiles;
721
+ if (Array.isArray(files) && typeof files[0] === 'string') {
722
+ return { path: files[0], op: 'modified' };
723
+ }
724
+ return null;
725
+ }
713
726
  return null;
714
727
  }
715
728
  export function defaultSystemPrompt(ctx) {
package/dist/chat.js CHANGED
@@ -57,8 +57,8 @@ export function assertSafeBaseURL(url, opts) {
57
57
  if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(host))
58
58
  return;
59
59
  throw new Error(`Refusing to send KLYRO_API_KEY over plaintext HTTP to ${host}. ` +
60
- `Use https:// or a localhost/private URL, or set KLYRO_ALLOW_INSECURE=1 to allow insecure HTTP (not recommended). ` +
61
- `Example: $Env:KLYRO_ALLOW_INSECURE=\"1\"; klyro`);
60
+ `Use https:// or a localhost/private URL, or allow once via \`klyro config set allowInsecure true\` (persisted, only for hosts you trust), ` +
61
+ `or set KLYRO_ALLOW_INSECURE=1 for this terminal only (not recommended).`);
62
62
  }
63
63
  throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
64
64
  }
@@ -7,32 +7,97 @@ import * as crypto from 'node:crypto';
7
7
  function ckptDir(cwd) {
8
8
  return path.join(cwd, '.klyro', 'checkpoints');
9
9
  }
10
+ /**
11
+ * Resolve a checkpoint file list entry inside cwd. Returns null for anything
12
+ * escaping the project (no arbitrary read/write outside cwd, via either the
13
+ * source read, the snapshot copy, or a tampered .meta.json on undo).
14
+ */
15
+ function containedPath(cwd, base, rel) {
16
+ const out = path.resolve(base, rel);
17
+ const relOut = path.relative(cwd, out);
18
+ if (relOut.startsWith('..') || path.isAbsolute(relOut))
19
+ return null;
20
+ return out;
21
+ }
10
22
  export async function snapshot(cwd, files) {
11
23
  const dir = ckptDir(cwd);
12
24
  await fs.mkdir(dir, { recursive: true });
13
25
  const id = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
14
26
  const dest = path.join(dir, id);
15
27
  await fs.mkdir(dest, { recursive: true });
28
+ const missing = [];
29
+ const kept = [];
16
30
  for (const f of files) {
17
31
  try {
18
- const src = path.resolve(cwd, f);
32
+ const src = containedPath(cwd, cwd, f);
33
+ if (!src)
34
+ continue;
19
35
  const data = await fs.readFile(src);
20
36
  const rel = path.relative(cwd, src);
21
- const out = path.join(dest, rel);
37
+ const out = containedPath(cwd, dest, rel);
38
+ if (!out)
39
+ continue;
22
40
  await fs.mkdir(path.dirname(out), { recursive: true });
23
41
  await fs.writeFile(out, data);
42
+ kept.push(rel);
43
+ }
44
+ catch (e) {
45
+ // Record deletions so undo() can restore the deleted state.
46
+ if (e?.code === 'ENOENT')
47
+ missing.push(f);
24
48
  }
25
- catch { /* ignore missing */ }
26
49
  }
27
50
  // Save meta
28
- await fs.writeFile(path.join(dest, '.meta.json'), JSON.stringify({ id, files, ts: Date.now() }, null, 2));
51
+ await fs.writeFile(path.join(dest, '.meta.json'), JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
52
+ // Best-effort last.diff for the repair guard (guardRepair reads it).
53
+ try {
54
+ const { spawn } = await import('node:child_process');
55
+ const args = ['diff', '--', ...kept.slice(0, 20)];
56
+ const diffText = await new Promise((resolve) => {
57
+ const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
58
+ const chunks = [];
59
+ let done = false;
60
+ const t = setTimeout(() => {
61
+ if (!done) {
62
+ done = true;
63
+ try {
64
+ child.kill();
65
+ }
66
+ catch { /* ignore */ }
67
+ resolve('');
68
+ }
69
+ }, 10_000);
70
+ child.stdout.on('data', (b) => {
71
+ if (Buffer.concat(chunks).length < 20 * 1024)
72
+ chunks.push(b);
73
+ });
74
+ child.on('close', () => {
75
+ if (done)
76
+ return;
77
+ done = true;
78
+ clearTimeout(t);
79
+ resolve(Buffer.concat(chunks).toString('utf-8').slice(0, 20 * 1024));
80
+ });
81
+ child.on('error', () => {
82
+ if (done)
83
+ return;
84
+ done = true;
85
+ clearTimeout(t);
86
+ resolve('');
87
+ });
88
+ });
89
+ if (diffText)
90
+ await fs.writeFile(path.join(dir, 'last.diff'), diffText, 'utf-8');
91
+ }
92
+ catch { /* best-effort only */ }
29
93
  return id;
30
94
  }
31
95
  export async function listCheckpoints(cwd) {
32
96
  const dir = ckptDir(cwd);
33
97
  try {
34
98
  const entries = await fs.readdir(dir);
35
- return entries.filter((e) => !e.startsWith('.')).sort();
99
+ // last.diff is a guard artifact, not a checkpoint (must never be an undo target).
100
+ return entries.filter((e) => !e.startsWith('.') && e !== 'last.diff').sort();
36
101
  }
37
102
  catch {
38
103
  return [];
@@ -61,15 +126,29 @@ export async function undo(cwd, n = 1) {
61
126
  const srcDir = path.join(ckptDir(cwd), target);
62
127
  const metaRaw = await fs.readFile(path.join(srcDir, '.meta.json'), 'utf-8');
63
128
  const meta = JSON.parse(metaRaw);
129
+ // Restore modified/created files to their snapshotted content…
64
130
  for (const f of meta.files) {
65
- const src = path.join(srcDir, f);
66
- const dest = path.resolve(cwd, f);
131
+ const src = containedPath(cwd, srcDir, f);
132
+ const dest = src ? containedPath(cwd, cwd, f) : null;
133
+ if (!src || !dest)
134
+ continue;
67
135
  try {
68
136
  const data = await fs.readFile(src);
137
+ await fs.mkdir(path.dirname(dest), { recursive: true });
69
138
  await fs.writeFile(dest, data);
70
139
  }
71
140
  catch { /* ignore */ }
72
141
  }
142
+ // …and re-delete files that did not exist at snapshot time.
143
+ for (const f of meta.missing ?? []) {
144
+ const dest = containedPath(cwd, cwd, f);
145
+ if (!dest)
146
+ continue;
147
+ try {
148
+ await fs.unlink(dest);
149
+ }
150
+ catch { /* already gone */ }
151
+ }
73
152
  }
74
153
  export async function rewind(cwd) {
75
154
  return undo(cwd, 1);
@@ -159,8 +159,12 @@ function getByPath(obj, dotted) {
159
159
  }
160
160
  return cur;
161
161
  }
162
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
162
163
  function setByPath(obj, dotted, value) {
163
164
  const parts = dotted.split('.');
165
+ if (parts.some((p) => UNSAFE_KEYS.has(p))) {
166
+ throw new Error(`refusing to set prototype-polluting key: ${dotted}`);
167
+ }
164
168
  let cur = obj;
165
169
  for (let i = 0; i < parts.length - 1; i++) {
166
170
  const p = parts[i];
@@ -173,6 +177,8 @@ function setByPath(obj, dotted, value) {
173
177
  }
174
178
  function deleteByPath(obj, dotted) {
175
179
  const parts = dotted.split('.');
180
+ if (parts.some((p) => UNSAFE_KEYS.has(p)))
181
+ return false;
176
182
  let cur = obj;
177
183
  for (let i = 0; i < parts.length - 1; i++) {
178
184
  const p = parts[i];
@@ -302,8 +308,14 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
302
308
  const merged = {};
303
309
  for (const layer of layers) {
304
310
  for (const [k, v] of Object.entries(layer)) {
305
- if (v !== undefined)
311
+ if (v === undefined)
312
+ continue;
313
+ try {
306
314
  setByPath(merged, k, v);
315
+ }
316
+ catch {
317
+ continue; // e.g. prototype-polluting keys in a config file — skip, never crash startup
318
+ }
307
319
  }
308
320
  }
309
321
  return merged;