@kaelio/ktx 0.1.0 → 0.1.1

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 (63) hide show
  1. package/assets/python/{kaelio_ktx-0.1.0-py3-none-any.whl → kaelio_ktx-0.1.1-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/commands/setup-commands.js +14 -26
  4. package/dist/doctor.test.js +3 -4
  5. package/dist/index.test.js +26 -10
  6. package/dist/ingest-depth.js +0 -1
  7. package/dist/ingest.test-utils.js +2 -2
  8. package/dist/ingest.test.js +4 -4
  9. package/dist/managed-local-embeddings.d.ts +2 -0
  10. package/dist/managed-local-embeddings.js +2 -0
  11. package/dist/managed-local-embeddings.test.js +2 -0
  12. package/dist/managed-mcp-daemon.js +3 -2
  13. package/dist/managed-mcp-daemon.test.js +25 -0
  14. package/dist/managed-python-command.test.js +1 -0
  15. package/dist/managed-python-daemon.js +3 -2
  16. package/dist/managed-python-daemon.test.js +20 -0
  17. package/dist/managed-python-runtime.d.ts +4 -0
  18. package/dist/managed-python-runtime.js +47 -3
  19. package/dist/managed-python-runtime.test.js +51 -21
  20. package/dist/proxy-env.d.ts +1 -0
  21. package/dist/proxy-env.js +23 -0
  22. package/dist/proxy-env.test.d.ts +1 -0
  23. package/dist/proxy-env.test.js +17 -0
  24. package/dist/runtime.test.js +1 -0
  25. package/dist/setup-agents.js +3 -1
  26. package/dist/setup-agents.test.js +34 -0
  27. package/dist/setup-embeddings.d.ts +1 -0
  28. package/dist/setup-embeddings.js +28 -6
  29. package/dist/setup-embeddings.test.js +46 -4
  30. package/dist/setup-models.d.ts +0 -1
  31. package/dist/setup-models.js +2 -3
  32. package/dist/setup-models.test.js +8 -10
  33. package/dist/setup-project.d.ts +9 -1
  34. package/dist/setup-project.js +52 -25
  35. package/dist/setup-project.test.js +8 -8
  36. package/dist/setup-runtime.test.js +2 -0
  37. package/dist/setup.d.ts +1 -2
  38. package/dist/setup.js +21 -5
  39. package/dist/setup.test.js +160 -43
  40. package/dist/sl.test.js +2 -1
  41. package/dist/standalone-smoke.test.js +2 -3
  42. package/dist/status-project.js +1 -10
  43. package/node_modules/@ktx/context/dist/ingest/adapters/historic-sql/local-ingest-acceptance.test.js +1 -1
  44. package/node_modules/@ktx/context/dist/ingest/local-bundle-ingest.test.js +8 -8
  45. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.js +1 -1
  46. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.test.js +3 -3
  47. package/node_modules/@ktx/context/dist/ingest/local-embedding-provider.integration.test.js +9 -10
  48. package/node_modules/@ktx/context/dist/llm/local-config.js +2 -15
  49. package/node_modules/@ktx/context/dist/llm/local-config.test.js +3 -7
  50. package/node_modules/@ktx/context/dist/project/config.d.ts +0 -5
  51. package/node_modules/@ktx/context/dist/project/config.js +5 -5
  52. package/node_modules/@ktx/context/dist/project/config.test.js +4 -7
  53. package/node_modules/@ktx/context/dist/scan/enrichment-state.test.js +4 -4
  54. package/node_modules/@ktx/context/dist/scan/index.d.ts +1 -1
  55. package/node_modules/@ktx/context/dist/scan/local-enrichment.d.ts +2 -6
  56. package/node_modules/@ktx/context/dist/scan/local-enrichment.js +31 -47
  57. package/node_modules/@ktx/context/dist/scan/local-enrichment.test.js +35 -18
  58. package/node_modules/@ktx/context/dist/scan/local-scan.test.js +2 -3
  59. package/node_modules/@ktx/llm/dist/embedding-provider.d.ts +0 -7
  60. package/node_modules/@ktx/llm/dist/embedding-provider.js +12 -138
  61. package/node_modules/@ktx/llm/dist/embedding-provider.test.js +10 -25
  62. package/node_modules/@ktx/llm/dist/types.d.ts +1 -1
  63. package/package.json +1 -1
@@ -2,10 +2,10 @@
2
2
  "schemaVersion": 1,
3
3
  "distributionName": "kaelio-ktx",
4
4
  "normalizedName": "kaelio_ktx",
5
- "version": "0.1.0",
5
+ "version": "0.1.1",
6
6
  "wheel": {
7
- "file": "kaelio_ktx-0.1.0-py3-none-any.whl",
8
- "sha256": "1145cf0c90879270dc335a8d0ca49a598f81e0f47a37fdf646fc6c6798fe3c8b",
9
- "bytes": 80523
7
+ "file": "kaelio_ktx-0.1.1-py3-none-any.whl",
8
+ "sha256": "2f4089f27519dbc10b0fdc1b03d5324cd69984171589907647961d1edbcab01b",
9
+ "bytes": 80519
10
10
  }
11
11
  }
@@ -79,8 +79,6 @@ function shouldShowSetupEntryMenu(options, command) {
79
79
  return false;
80
80
  }
81
81
  return ![
82
- 'new',
83
- 'existing',
84
82
  'agents',
85
83
  'target',
86
84
  'global',
@@ -92,7 +90,6 @@ function shouldShowSetupEntryMenu(options, command) {
92
90
  'anthropicApiKeyEnv',
93
91
  'anthropicApiKeyFile',
94
92
  'llmModel',
95
- 'anthropicModel',
96
93
  'vertexProject',
97
94
  'vertexLocation',
98
95
  'skipLlm',
@@ -100,7 +97,6 @@ function shouldShowSetupEntryMenu(options, command) {
100
97
  'embeddingApiKeyEnv',
101
98
  'embeddingApiKeyFile',
102
99
  'skipEmbeddings',
103
- 'newDatabaseConnectionId',
104
100
  'databaseUrl',
105
101
  'enableQueryHistory',
106
102
  'disableQueryHistory',
@@ -132,8 +128,6 @@ export function registerSetupCommands(program, context) {
132
128
  .command('setup')
133
129
  .description('Set up or resume a local KTX project')
134
130
  .addOption(new Option('--project-dir <path>', 'KTX project directory').hideHelp())
135
- .addOption(new Option('--new', 'Create a new KTX project before setup').hideHelp().default(false))
136
- .addOption(new Option('--existing', 'Use an existing KTX project').hideHelp().default(false))
137
131
  .option('--agents', 'Install agent integration only', false)
138
132
  .addOption(new Option('--target <target>', 'Agent target').choices([
139
133
  'claude-code',
@@ -146,13 +140,12 @@ export function registerSetupCommands(program, context) {
146
140
  .option('--global', 'Install agent integration into the global target scope', false)
147
141
  .option('--local', 'Install Claude Code MCP config into the private per-project ~/.claude.json scope', false)
148
142
  .addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false))
149
- .option('--yes', 'Accept safe defaults in non-interactive setup', false)
143
+ .option('--yes', 'Accept project creation and runtime install defaults where setup confirms', false)
150
144
  .option('--no-input', 'Disable interactive terminal input')
151
145
  .addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend).hideHelp())
152
146
  .addOption(new Option('--anthropic-api-key-env <name>', 'Environment variable containing the Anthropic API key').hideHelp())
153
147
  .addOption(new Option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key').hideHelp())
154
148
  .addOption(new Option('--llm-model <model>', 'LLM model ID or backend model alias').hideHelp())
155
- .addOption(new Option('--anthropic-model <model>', 'Anthropic model ID to validate and save').hideHelp())
156
149
  .addOption(new Option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
157
150
  .addOption(new Option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
158
151
  .addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
@@ -169,14 +162,6 @@ export function registerSetupCommands(program, context) {
169
162
  .addOption(new Option('--database-connection-id <id>', 'Existing selected connection id or new connection id')
170
163
  .argParser((value, previous) => [...previous, value])
171
164
  .default([])
172
- .hideHelp())
173
- .addOption(new Option('--new-database-connection-id <id>', 'Connection id for one new database connection')
174
- .argParser((value) => {
175
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
176
- throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
177
- }
178
- return value;
179
- })
180
165
  .hideHelp())
181
166
  .addOption(new Option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection').hideHelp())
182
167
  .addOption(new Option('--database-schema <schema>', 'Database schema to include; repeatable')
@@ -238,11 +223,6 @@ export function registerSetupCommands(program, context) {
238
223
  context.setExitCode(1);
239
224
  return;
240
225
  }
241
- if (options.llmModel && options.anthropicModel) {
242
- context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n');
243
- context.setExitCode(1);
244
- return;
245
- }
246
226
  if (options.llmBackend &&
247
227
  options.llmBackend !== 'anthropic' &&
248
228
  (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) {
@@ -285,12 +265,17 @@ export function registerSetupCommands(program, context) {
285
265
  context.setExitCode(1);
286
266
  return;
287
267
  }
288
- const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
268
+ const creatingDatabaseConnection = options.database.length > 0 || options.databaseUrl !== undefined;
269
+ if (creatingDatabaseConnection && options.databaseConnectionId.length > 1) {
270
+ context.io.stderr.write('Choose only one new database connection id when configuring a database.\n');
271
+ context.setExitCode(1);
272
+ return;
273
+ }
289
274
  const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
290
275
  await runSetupArgs(context, {
291
276
  command: 'run',
292
277
  projectDir: resolveCommandProjectDir(command),
293
- mode,
278
+ mode: 'auto',
294
279
  agents: options.agents === true,
295
280
  ...(options.target ? { target: options.target } : {}),
296
281
  agentScope: resolvedAgentScope,
@@ -302,7 +287,6 @@ export function registerSetupCommands(program, context) {
302
287
  ...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
303
288
  ...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
304
289
  ...(options.llmModel ? { llmModel: options.llmModel } : {}),
305
- ...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
306
290
  ...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
307
291
  ...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
308
292
  skipLlm: options.skipLlm === true,
@@ -311,8 +295,12 @@ export function registerSetupCommands(program, context) {
311
295
  ...(options.embeddingApiKeyFile ? { embeddingApiKeyFile: options.embeddingApiKeyFile } : {}),
312
296
  skipEmbeddings: options.skipEmbeddings === true,
313
297
  ...(options.database.length > 0 ? { databaseDrivers: options.database } : {}),
314
- ...(options.databaseConnectionId.length > 0 ? { databaseConnectionIds: options.databaseConnectionId } : {}),
315
- ...(options.newDatabaseConnectionId ? { databaseConnectionId: options.newDatabaseConnectionId } : {}),
298
+ ...(options.databaseConnectionId.length > 0 && creatingDatabaseConnection
299
+ ? { databaseConnectionId: options.databaseConnectionId[0] }
300
+ : {}),
301
+ ...(options.databaseConnectionId.length > 0 && !creatingDatabaseConnection
302
+ ? { databaseConnectionIds: options.databaseConnectionId }
303
+ : {}),
316
304
  ...(options.databaseUrl ? { databaseUrl: options.databaseUrl } : {}),
317
305
  databaseSchemas: options.databaseSchema,
318
306
  ...(options.enableQueryHistory ? { enableQueryHistory: true } : {}),
@@ -498,16 +498,15 @@ describe('runKtxDoctor', () => {
498
498
  ' adapters:',
499
499
  ' - live-database',
500
500
  ' embeddings:',
501
- ' backend: deterministic',
502
- ' model: deterministic',
501
+ ' backend: none',
503
502
  ' dimensions: 8',
504
503
  '',
505
504
  ].join('\n'), 'utf-8');
506
505
  const testIo = makeIo();
507
506
  await expect(runKtxDoctor({ command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, testIo.io, {})).resolves.toBe(0);
508
507
  expect(testIo.stdout()).toContain('Embeddings');
509
- expect(testIo.stdout()).toContain('deterministic');
510
- expect(testIo.stdout()).toContain('semantic search degraded');
508
+ expect(testIo.stdout()).toContain('none');
509
+ expect(testIo.stdout()).toContain('semantic search will be skipped');
511
510
  delete process.env.ANTHROPIC_API_KEY;
512
511
  });
513
512
  describe('command: validate', () => {
@@ -318,8 +318,6 @@ describe('runKtxCli', () => {
318
318
  expect(stdout).not.toContain('setup demo');
319
319
  expect(stdout).not.toContain('setup context');
320
320
  for (const hiddenFlag of [
321
- '--new',
322
- '--existing',
323
321
  '--agent-scope',
324
322
  '--skip-agents',
325
323
  '--llm-backend',
@@ -328,7 +326,6 @@ describe('runKtxCli', () => {
328
326
  '--embedding-backend',
329
327
  '--database ',
330
328
  '--database-connection-id',
331
- '--new-database-connection-id',
332
329
  '--enable-historic-sql',
333
330
  '--historic-sql-min-executions',
334
331
  '--enable-query-history',
@@ -612,8 +609,12 @@ describe('runKtxCli', () => {
612
609
  it('rejects removed setup options', async () => {
613
610
  const setup = vi.fn(async () => 0);
614
611
  const cases = [
612
+ ['setup', '--new'],
613
+ ['setup', '--existing'],
615
614
  ['setup', '--project'],
616
615
  ['setup', '--agent-scope', 'global'],
616
+ ['setup', '--anthropic-model', 'claude-sonnet-4-6'],
617
+ ['setup', '--new-database-connection-id', 'warehouse'],
617
618
  ['setup', '--skip-initial-source-ingest'],
618
619
  ];
619
620
  for (const args of cases) {
@@ -778,7 +779,7 @@ describe('runKtxCli', () => {
778
779
  '--no-input',
779
780
  '--anthropic-api-key-env',
780
781
  'ANTHROPIC_API_KEY',
781
- '--anthropic-model',
782
+ '--llm-model',
782
783
  'claude-sonnet-4-6',
783
784
  ], setupIo.io, { setup })).resolves.toBe(0);
784
785
  expect(setup).toHaveBeenCalledWith(expect.objectContaining({
@@ -787,7 +788,7 @@ describe('runKtxCli', () => {
787
788
  inputMode: 'disabled',
788
789
  cliVersion: '0.1.0-rc.1',
789
790
  anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
790
- anthropicModel: 'claude-sonnet-4-6',
791
+ llmModel: 'claude-sonnet-4-6',
791
792
  skipLlm: false,
792
793
  }), setupIo.io);
793
794
  });
@@ -805,7 +806,7 @@ describe('runKtxCli', () => {
805
806
  'local-gcp-project',
806
807
  '--vertex-location',
807
808
  'us-east5',
808
- '--anthropic-model',
809
+ '--llm-model',
809
810
  'claude-sonnet-4-6',
810
811
  ], setupIo.io, { setup })).resolves.toBe(0);
811
812
  expect(setup).toHaveBeenCalledWith(expect.objectContaining({
@@ -816,7 +817,7 @@ describe('runKtxCli', () => {
816
817
  llmBackend: 'vertex',
817
818
  vertexProject: 'local-gcp-project',
818
819
  vertexLocation: 'us-east5',
819
- anthropicModel: 'claude-sonnet-4-6',
820
+ llmModel: 'claude-sonnet-4-6',
820
821
  skipLlm: false,
821
822
  }), setupIo.io);
822
823
  });
@@ -895,7 +896,7 @@ describe('runKtxCli', () => {
895
896
  '--skip-embeddings',
896
897
  '--database',
897
898
  'postgres',
898
- '--new-database-connection-id',
899
+ '--database-connection-id',
899
900
  'warehouse',
900
901
  '--database-url',
901
902
  'env:DATABASE_URL',
@@ -928,11 +929,26 @@ describe('runKtxCli', () => {
928
929
  it('dispatches setup database connection ids that match former ingest subcommand names', async () => {
929
930
  const testIo = makeIo();
930
931
  const setup = vi.fn(async () => 0);
931
- await expect(runKtxCli(['setup', '--new-database-connection-id', 'status', '--no-input'], testIo.io, { setup })).resolves.toBe(0);
932
+ await expect(runKtxCli(['setup', '--database-connection-id', 'status', '--no-input'], testIo.io, { setup })).resolves.toBe(0);
933
+ expect(setup).toHaveBeenCalledWith(expect.objectContaining({
934
+ command: 'run',
935
+ databaseConnectionIds: ['status'],
936
+ }), testIo.io);
937
+ });
938
+ it('dispatches non-TTY agents setup with target without requiring --no-input or --yes', async () => {
939
+ const testIo = makeIo({ stdoutIsTty: false });
940
+ const setup = vi.fn(async () => 0);
941
+ await expect(runKtxCli(['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code'], testIo.io, { setup })).resolves.toBe(0);
932
942
  expect(setup).toHaveBeenCalledWith(expect.objectContaining({
933
943
  command: 'run',
934
- databaseConnectionId: 'status',
944
+ projectDir: tempDir,
945
+ agents: true,
946
+ target: 'claude-code',
947
+ agentScope: 'project',
948
+ inputMode: 'auto',
949
+ yes: false,
935
950
  }), testIo.io);
951
+ expect(testIo.stderr()).toBe('');
936
952
  });
937
953
  it('dispatches setup source flags', async () => {
938
954
  const setup = vi.fn(async () => 0);
@@ -46,7 +46,6 @@ export function deepReadinessGaps(config) {
46
46
  const embeddings = config.scan.enrichment.embeddings;
47
47
  if (!embeddings ||
48
48
  embeddings.backend === 'none' ||
49
- embeddings.backend === 'deterministic' ||
50
49
  !embeddings.model ||
51
50
  embeddings.dimensions <= 0) {
52
51
  gaps.push('scan embeddings');
@@ -84,7 +84,7 @@ export async function writeMetabaseConfig(projectDir) {
84
84
  ' adapters:',
85
85
  ' - metabase',
86
86
  ' embeddings:',
87
- ' backend: deterministic',
87
+ ' backend: none',
88
88
  '',
89
89
  ].join('\n'), 'utf-8');
90
90
  }
@@ -418,7 +418,7 @@ export async function runPublicMetabaseSyncModeCase(tempDir, input) {
418
418
  ' adapters:',
419
419
  ' - metabase',
420
420
  ' embeddings:',
421
- ' backend: deterministic',
421
+ ' backend: none',
422
422
  '',
423
423
  ].join('\n'), 'utf-8');
424
424
  const project = await loadKtxProject({ projectDir });
@@ -182,7 +182,7 @@ describe('runKtxIngest', () => {
182
182
  await expect(runKtxSetup({
183
183
  command: 'run',
184
184
  projectDir,
185
- mode: 'new',
185
+ mode: 'auto',
186
186
  agents: false,
187
187
  agentScope: 'project',
188
188
  skipAgents: true,
@@ -227,7 +227,7 @@ describe('runKtxIngest', () => {
227
227
  expect(runIo.stderr()).toContain('ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.');
228
228
  expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:');
229
229
  expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`);
230
- expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`);
230
+ expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`);
231
231
  });
232
232
  it('routes metabase scheduled pulls to the fan-out runner and prints child summaries', async () => {
233
233
  const projectDir = join(tempDir, 'project');
@@ -620,7 +620,7 @@ describe('runKtxIngest', () => {
620
620
  ' adapters:',
621
621
  ' - metabase',
622
622
  ' embeddings:',
623
- ' backend: deterministic',
623
+ ' backend: none',
624
624
  '',
625
625
  ].join('\n'), 'utf-8');
626
626
  const project = await loadKtxProject({ projectDir });
@@ -1449,7 +1449,7 @@ describe('runKtxIngest', () => {
1449
1449
  ' adapters:',
1450
1450
  ' - looker',
1451
1451
  ' embeddings:',
1452
- ' backend: deterministic',
1452
+ ' backend: none',
1453
1453
  '',
1454
1454
  ].join('\n'), 'utf-8');
1455
1455
  const project = await loadKtxProject({ projectDir });
@@ -6,6 +6,8 @@ import { type KtxManagedPythonInstallPolicy, type ManagedPythonCommandRuntime }
6
6
  import { type ManagedPythonDaemonStartResult } from './managed-python-daemon.js';
7
7
  export interface ManagedLocalEmbeddingsDaemon {
8
8
  baseUrl: string;
9
+ stdoutLog: string;
10
+ stderrLog: string;
9
11
  env: Record<typeof MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, string>;
10
12
  }
11
13
  export interface ManagedLocalEmbeddingsOptions {
@@ -42,6 +42,8 @@ export async function ensureManagedLocalEmbeddingsDaemon(options) {
42
42
  options.io.stderr.write(`${verb} KTX local embeddings daemon: ${daemon.baseUrl}\n`);
43
43
  return {
44
44
  baseUrl: daemon.baseUrl,
45
+ stdoutLog: daemon.state.stdoutLog,
46
+ stderrLog: daemon.state.stderrLog,
45
47
  env: {
46
48
  [MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: daemon.baseUrl,
47
49
  },
@@ -128,6 +128,8 @@ describe('ensureManagedLocalEmbeddingsDaemon', () => {
128
128
  startDaemon,
129
129
  })).resolves.toEqual({
130
130
  baseUrl: 'http://127.0.0.1:61234',
131
+ stdoutLog: '/work/proj/.ktx/runtime/daemon.stdout.log',
132
+ stderrLog: '/work/proj/.ktx/runtime/daemon.stderr.log',
131
133
  env: {
132
134
  [MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV]: 'http://127.0.0.1:61234',
133
135
  },
@@ -4,6 +4,7 @@ import { createServer } from 'node:net';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { z } from 'zod';
7
+ import { sanitizeChildProxyEnv } from './proxy-env.js';
7
8
  const stateSchema = z.object({
8
9
  schemaVersion: z.literal(1),
9
10
  pid: z.number().int().positive(),
@@ -118,11 +119,11 @@ export async function startKtxMcpDaemon(options) {
118
119
  const child = (options.spawnDaemon ?? defaultSpawnDaemon)(process.execPath, args, {
119
120
  detached: true,
120
121
  stdio: ['ignore', log.fd, log.fd],
121
- env: {
122
+ env: sanitizeChildProxyEnv({
122
123
  ...process.env,
123
124
  KTX_CLI_VERSION: options.cliVersion,
124
125
  ...(options.token ? { KTX_MCP_TOKEN: options.token } : {}),
125
- },
126
+ }),
126
127
  });
127
128
  if (!child.pid) {
128
129
  throw new Error('Failed to start KTX MCP daemon: child process pid was not available.');
@@ -28,6 +28,7 @@ describe('managed MCP daemon lifecycle', () => {
28
28
  await mkdir(projectDir, { recursive: true });
29
29
  });
30
30
  afterEach(async () => {
31
+ vi.unstubAllEnvs();
31
32
  await rm(tempDir, { recursive: true, force: true });
32
33
  });
33
34
  it('uses the spec state and log paths', () => {
@@ -72,6 +73,30 @@ describe('managed MCP daemon lifecycle', () => {
72
73
  }));
73
74
  expect(JSON.stringify(JSON.parse(await readFile(join(projectDir, '.ktx/mcp.json'), 'utf8')))).not.toContain('secret-token');
74
75
  });
76
+ it('sanitizes IPv6 CIDR entries from child NO_PROXY env', async () => {
77
+ vi.stubEnv('NO_PROXY', 'localhost,fd07:b51a:cc66:f0::/64');
78
+ vi.stubEnv('no_proxy', '::1,fd00::/8,*.orb.local');
79
+ const spawnDaemon = vi.fn(() => child(5555));
80
+ await startKtxMcpDaemon({
81
+ projectDir,
82
+ cliVersion: '0.0.0-test',
83
+ host: '127.0.0.1',
84
+ port: 7879,
85
+ allowedHosts: [],
86
+ allowedOrigins: [],
87
+ binPath: '/repo/packages/cli/dist/bin.js',
88
+ spawnDaemon,
89
+ processAlive: vi.fn(() => false),
90
+ portAvailable: vi.fn(async () => true),
91
+ now: () => new Date('2026-05-14T00:00:00.000Z'),
92
+ });
93
+ const env = spawnDaemon.mock.calls[0]?.[2].env;
94
+ if (!env) {
95
+ throw new Error('Expected MCP daemon spawn env');
96
+ }
97
+ expect(env.NO_PROXY).toBe('localhost,::1,*.orb.local');
98
+ expect(env.no_proxy).toBe(env.NO_PROXY);
99
+ });
75
100
  it('returns already-running without spawning when the daemon is alive at the same host/port', async () => {
76
101
  await mkdir(join(projectDir, '.ktx'), { recursive: true });
77
102
  await writeFile(join(projectDir, '.ktx/mcp.json'), `${JSON.stringify(state(projectDir), null, 2)}\n`);
@@ -81,6 +81,7 @@ function installResult(features = ['core']) {
81
81
  asset: {
82
82
  manifest: installedManifest.asset,
83
83
  wheelPath: '/assets/python/kaelio_ktx-0.2.0-py3-none-any.whl',
84
+ requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
84
85
  },
85
86
  manifest: installedManifest,
86
87
  };
@@ -5,6 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { promisify } from 'node:util';
6
6
  import { z } from 'zod';
7
7
  import { installManagedPythonRuntime, managedPythonDaemonLayout, runtimeFeatureSchema, } from './managed-python-runtime.js';
8
+ import { sanitizeChildProxyEnv } from './proxy-env.js';
8
9
  const execFileAsync = promisify(execFile);
9
10
  const daemonStateSchema = z.object({
10
11
  schemaVersion: z.literal(1),
@@ -483,10 +484,10 @@ export async function startManagedPythonDaemon(options) {
483
484
  const child = spawnDaemon(installed.manifest.python.daemonExecutable, ['serve-http', '--host', '127.0.0.1', '--port', String(port)], {
484
485
  detached: true,
485
486
  stdio: ['ignore', stdout.fd, stderr.fd],
486
- env: {
487
+ env: sanitizeChildProxyEnv({
487
488
  ...process.env,
488
489
  KTX_DAEMON_VERSION: options.cliVersion,
489
- },
490
+ }),
490
491
  });
491
492
  child.unref();
492
493
  if (!child.pid) {
@@ -59,6 +59,7 @@ function installResult(root, features = ['core']) {
59
59
  asset: {
60
60
  manifest: manifest(root, features).asset,
61
61
  wheelPath: join(root, 'assets', 'python', 'kaelio_ktx-0.2.0-py3-none-any.whl'),
62
+ requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
62
63
  },
63
64
  manifest: manifest(root, features),
64
65
  };
@@ -105,6 +106,7 @@ describe('managed Python daemon lifecycle', () => {
105
106
  tempDir = await mkdtemp(join(tmpdir(), 'ktx-managed-daemon-'));
106
107
  });
107
108
  afterEach(async () => {
109
+ vi.unstubAllEnvs();
108
110
  await rm(tempDir, { recursive: true, force: true });
109
111
  });
110
112
  it('reports stopped when no daemon state exists', async () => {
@@ -150,6 +152,24 @@ describe('managed Python daemon lifecycle', () => {
150
152
  stderrLog: layout(tempDir).daemonStderrPath,
151
153
  });
152
154
  });
155
+ it('sanitizes IPv6 CIDR entries from child NO_PROXY env', async () => {
156
+ vi.stubEnv('NO_PROXY', 'localhost,fd07:b51a:cc66:f0::/64,127.0.0.0/8');
157
+ vi.stubEnv('no_proxy', '::1,fd00::/8,*.orb.local');
158
+ const spawnDaemon = makeSpawn(5555);
159
+ await startManagedPythonDaemon({
160
+ ...daemonOptionsBase(tempDir),
161
+ features: ['local-embeddings'],
162
+ installRuntime: vi.fn(async () => installResult(tempDir, ['core', 'local-embeddings'])),
163
+ spawnDaemon,
164
+ fetch: makeFetch(),
165
+ allocatePort: vi.fn(async () => 61234),
166
+ now: () => new Date('2026-05-11T00:00:00.000Z'),
167
+ pollIntervalMs: 1,
168
+ });
169
+ const env = vi.mocked(spawnDaemon).mock.calls[0]?.[2].env;
170
+ expect(env?.NO_PROXY).toBe('localhost,127.0.0.0/8,::1,*.orb.local');
171
+ expect(env?.no_proxy).toBe(env?.NO_PROXY);
172
+ });
153
173
  it('makes a final health probe before reporting startup failure', async () => {
154
174
  const spawnDaemon = makeSpawn(5556);
155
175
  const installRuntime = vi.fn(async () => installResult(tempDir));
@@ -75,6 +75,10 @@ export interface ManagedPythonDaemonLayout extends ManagedPythonRuntimeLayout {
75
75
  export interface ManagedRuntimeAsset {
76
76
  manifest: KtxRuntimeAssetManifest;
77
77
  wheelPath: string;
78
+ requiresPython: {
79
+ specifier: string;
80
+ minimumVersion: string;
81
+ };
78
82
  }
79
83
  export type ManagedPythonRuntimeExec = (command: string, args: string[], options?: {
80
84
  cwd?: string;
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { basename, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { promisify } from 'node:util';
8
+ import { strFromU8, unzipSync } from 'fflate';
8
9
  import { z } from 'zod';
9
10
  const execFileAsync = promisify(execFile);
10
11
  export const runtimeFeatureSchema = z.enum(['core', 'local-embeddings']);
@@ -100,6 +101,35 @@ async function readJsonFile(path) {
100
101
  function isErrnoException(error, code) {
101
102
  return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
102
103
  }
104
+ function parseRequiresPythonFromWheel(input) {
105
+ let files;
106
+ try {
107
+ files = unzipSync(new Uint8Array(input.contents));
108
+ }
109
+ catch (error) {
110
+ throw new Error(`Unable to read bundled Python runtime wheel metadata: ${error instanceof Error ? error.message : String(error)}`);
111
+ }
112
+ const metadataEntry = Object.entries(files).find(([path]) => path.endsWith('.dist-info/METADATA'));
113
+ if (!metadataEntry) {
114
+ throw new Error(`Bundled Python runtime wheel metadata is missing: ${input.wheelPath}`);
115
+ }
116
+ const metadata = strFromU8(metadataEntry[1]);
117
+ const requiresPython = metadata
118
+ .split(/\r?\n/)
119
+ .map((line) => line.match(/^Requires-Python:\s*(.+)\s*$/i)?.[1]?.trim())
120
+ .find((value) => typeof value === 'string' && value.length > 0);
121
+ if (!requiresPython) {
122
+ throw new Error('Bundled Python runtime wheel metadata is missing Requires-Python');
123
+ }
124
+ const minimumMatch = requiresPython.match(/(?:^|[,\s])>=\s*([0-9]+)\.([0-9]+)(?:\.[0-9]+)?\b/);
125
+ if (!minimumMatch) {
126
+ throw new Error(`Unsupported bundled Python runtime Requires-Python: ${requiresPython}`);
127
+ }
128
+ return {
129
+ specifier: requiresPython,
130
+ minimumVersion: `${minimumMatch[1]}.${minimumMatch[2]}`,
131
+ };
132
+ }
103
133
  export async function verifyRuntimeAsset(input) {
104
134
  const manifestPath = join(input.assetDir, 'manifest.json');
105
135
  let manifestData;
@@ -124,7 +154,7 @@ export async function verifyRuntimeAsset(input) {
124
154
  if (sha256 !== manifest.wheel.sha256 || wheel.byteLength !== manifest.wheel.bytes) {
125
155
  throw new Error(`Bundled Python runtime wheel checksum mismatch: ${wheelPath}`);
126
156
  }
127
- return { manifest, wheelPath };
157
+ return { manifest, wheelPath, requiresPython: parseRequiresPythonFromWheel({ wheelPath, contents: wheel }) };
128
158
  }
129
159
  function normalizeFeatures(features) {
130
160
  const requested = new Set(['core', ...features]);
@@ -155,6 +185,13 @@ function errorOutput(error) {
155
185
  stderr: typeof value.stderr === 'string' ? value.stderr : '',
156
186
  };
157
187
  }
188
+ function installFailureMessage(input) {
189
+ const output = [input.stderr.trim(), input.stdout.trim()].filter((part) => part.length > 0).join('\n');
190
+ if (!output) {
191
+ return `Python runtime install failed. Install log: ${input.logPath}`;
192
+ }
193
+ return `Python runtime install failed.\n${output}\nInstall log: ${input.logPath}`;
194
+ }
158
195
  async function runLogged(input) {
159
196
  await appendFile(input.logPath, `$ ${input.command} ${input.args.join(' ')}\n`);
160
197
  try {
@@ -175,7 +212,7 @@ async function runLogged(input) {
175
212
  if (output.stderr) {
176
213
  await appendFile(input.logPath, output.stderr.endsWith('\n') ? output.stderr : `${output.stderr}\n`);
177
214
  }
178
- throw new Error(`Python runtime install failed. Install log: ${input.logPath}`);
215
+ throw new Error(installFailureMessage({ logPath: input.logPath, stdout: output.stdout, stderr: output.stderr }));
179
216
  }
180
217
  }
181
218
  function managedRuntimeUvEnv(baseEnv) {
@@ -214,7 +251,14 @@ export async function installManagedPythonRuntime(options) {
214
251
  exec,
215
252
  logPath: layout.installLogPath,
216
253
  command: 'uv',
217
- args: ['venv', layout.venvDir],
254
+ args: ['python', 'install', asset.requiresPython.minimumVersion],
255
+ env: uvEnv,
256
+ });
257
+ await runLogged({
258
+ exec,
259
+ logPath: layout.installLogPath,
260
+ command: 'uv',
261
+ args: ['venv', '--python', asset.requiresPython.minimumVersion, layout.venvDir],
218
262
  env: uvEnv,
219
263
  });
220
264
  const wheelSpec = features.includes('local-embeddings') ? `${asset.wheelPath}[local-embeddings]` : asset.wheelPath;