kiwi-code 0.0.511__tar.gz → 0.0.512__tar.gz

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 (143) hide show
  1. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/commands.py +95 -2
  4. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/handlers.py +7 -2
  5. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/link.py +15 -4
  6. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/sessions.py +10 -0
  7. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/tui_bridge.py +22 -4
  8. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_connection.py +157 -1
  9. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/uv.lock +1 -1
  10. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/.github/workflows/publish.yml +0 -0
  11. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/.github/workflows/test.yml +0 -0
  12. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/.gitignore +0 -0
  13. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/.python-version +0 -0
  14. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/Makefile +0 -0
  15. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/README.md +0 -0
  16. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/docs/runtime_audit.html +0 -0
  17. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/docs/social_login_e2e.md +0 -0
  18. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/scripts/set_edition.py +0 -0
  19. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/scripts/ws_auth_check.py +0 -0
  20. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/common/__init__.py +0 -0
  21. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/common/banner.py +0 -0
  22. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/__init__.py +0 -0
  23. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/_edition.py +0 -0
  24. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/auth.py +0 -0
  25. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/autocode.py +0 -0
  26. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/checkpoints.py +0 -0
  27. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/cli.py +0 -0
  28. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/client.py +0 -0
  29. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/commands.py +0 -0
  30. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/edition.py +0 -0
  31. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/human_questions.py +0 -0
  32. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/io_threads.py +0 -0
  33. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/logger.py +0 -0
  34. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/models.py +0 -0
  35. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/oauth.py +0 -0
  36. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/run_activity.py +0 -0
  37. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/runtime_manager.py +0 -0
  38. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/server.py +0 -0
  39. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/subagents.py +0 -0
  40. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_cli/terminal_mode.py +0 -0
  41. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/__init__.py +0 -0
  42. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/__main__.py +0 -0
  43. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/auth.py +0 -0
  44. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/checkpoints.py +0 -0
  45. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/cli.py +0 -0
  46. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/constants.py +0 -0
  47. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/__init__.py +0 -0
  48. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/batch.py +0 -0
  49. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/codelens.py +0 -0
  50. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/context.py +0 -0
  51. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/edits.py +0 -0
  52. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/handler.py +0 -0
  53. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/paths.py +0 -0
  54. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/read.py +0 -0
  55. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/search.py +0 -0
  56. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/text.py +0 -0
  57. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/upload.py +0 -0
  58. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/fs/write.py +0 -0
  59. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/main.py +0 -0
  60. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/restricted.py +0 -0
  61. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/state.py +0 -0
  62. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/ui.py +0 -0
  63. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_runtime/uploads.py +0 -0
  64. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/__init__.py +0 -0
  65. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/attention.py +0 -0
  66. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/autoupdate.py +0 -0
  67. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/inline_file_picker.py +0 -0
  68. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/latex_unicode.py +0 -0
  69. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/main.py +0 -0
  70. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/random_words.py +0 -0
  71. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/role_mode.py +0 -0
  72. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/runtime_agent.py +0 -0
  73. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/__init__.py +0 -0
  74. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/attach_content.py +0 -0
  75. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/command_result.py +0 -0
  76. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/detach_files.py +0 -0
  77. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/file_browser.py +0 -0
  78. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/help.py +0 -0
  79. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/id_picker.py +0 -0
  80. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/login.py +0 -0
  81. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  82. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  83. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/slash_picker.py +0 -0
  84. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_activity_view.py +0 -0
  85. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_dashboard.py +0 -0
  86. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_dashboard_css.py +0 -0
  87. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_dashboard_helpers.py +0 -0
  88. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  89. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  90. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_status_line.py +0 -0
  91. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  92. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/slash_commands.py +0 -0
  93. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/status_words.py +0 -0
  94. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/term_app.py +0 -0
  95. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/terminal_theme.py +0 -0
  96. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/ui_settings.py +0 -0
  97. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/widgets.py +0 -0
  98. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/src/kiwi_tui/worktrees.py +0 -0
  99. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/test_hello.py +0 -0
  100. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/__init__.py +0 -0
  101. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/conftest.py +0 -0
  102. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_account_server.py +0 -0
  103. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_attention.py +0 -0
  104. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_autocode_actions.py +0 -0
  105. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_autoupdate.py +0 -0
  106. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_batch_fs_tool.py +0 -0
  107. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_checkpoints.py +0 -0
  108. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_cli_help.py +0 -0
  109. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_command_tips.py +0 -0
  110. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_copy_path_fs_tool.py +0 -0
  111. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_fs_edit_fidelity.py +0 -0
  112. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_human_questions.py +0 -0
  113. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_imports.py +0 -0
  114. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_io_threads.py +0 -0
  115. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_login_social.py +0 -0
  116. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_math_and_tables.py +0 -0
  117. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_oauth.py +0 -0
  118. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_read_file_streaming.py +0 -0
  119. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_reexec_kiwi.py +0 -0
  120. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_reply_selection.py +0 -0
  121. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_role_mode_injection.py +0 -0
  122. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_run_activity.py +0 -0
  123. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_run_steering.py +0 -0
  124. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_cleanup_per_account.py +0 -0
  125. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_edit_diffs.py +0 -0
  126. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_fixes.py +0 -0
  127. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_imports_on_windows.py +0 -0
  128. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_log_trimming.py +0 -0
  129. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_runtime_social_login.py +0 -0
  130. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_search_in_files_fs_tool.py +0 -0
  131. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_slash_commands.py +0 -0
  132. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_stats_command.py +0 -0
  133. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_status_line.py +0 -0
  134. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_subagents.py +0 -0
  135. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_term_dashboard_ui.py +0 -0
  136. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_terminal_mode.py +0 -0
  137. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_terminal_theme.py +0 -0
  138. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_toasts.py +0 -0
  139. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_tokens.py +0 -0
  140. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_tui_interactive_runtime.py +0 -0
  141. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_tui_palette.py +0 -0
  142. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_unified_diff.py +0 -0
  143. {kiwi_code-0.0.511 → kiwi_code-0.0.512}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kiwi-code
3
- Version: 0.0.511
3
+ Version: 0.0.512
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://kiwicode.ai
6
6
  Author-email: Anurag <anurag@meetkiwi.co>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.511"
3
+ version = "0.0.512"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -1,6 +1,8 @@
1
1
  """One-off and interactive commands."""
2
2
  import asyncio
3
+ import json
3
4
  import os
5
+ import re
4
6
  import shlex
5
7
  import signal
6
8
  import subprocess
@@ -9,6 +11,7 @@ import time
9
11
  import uuid
10
12
  from collections import deque
11
13
  from typing import Any
14
+ from urllib.parse import unquote, urlsplit
12
15
 
13
16
  from kiwi_runtime.constants import (
14
17
  IS_WINDOWS,
@@ -222,13 +225,94 @@ async def _kill_command(proc: asyncio.subprocess.Process) -> None:
222
225
  pass
223
226
 
224
227
 
228
+ #: Values shorter than this are not masked: too likely to match ordinary output.
229
+ _MIN_MASKED_LEN = 6
230
+ #: What a forwarded variable's name may be; anything else is refused.
231
+ _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
232
+ #: At most this many forwarded variables, each at most this long.
233
+ _MAX_ENV_VARS = 64
234
+ _MAX_ENV_VALUE = 16_384
235
+
236
+
237
+ def command_env(value: Any) -> dict[str, str] | None:
238
+ """
239
+ The `env` a one-off or interactive command message carries (connection credentials
240
+ the backend resolved for it), checked: string names a shell accepts, string values, a
241
+ bounded number. Anything else is dropped rather than half-applied. None when there is none.
242
+ """
243
+ if not isinstance(value, dict) or not value:
244
+ return None
245
+ env: dict[str, str] = {}
246
+ for key, val in list(value.items())[:_MAX_ENV_VARS]:
247
+ if isinstance(key, str) and _ENV_NAME.match(key) and isinstance(val, str) and len(val) <= _MAX_ENV_VALUE:
248
+ env[key] = val
249
+ return env or None
250
+
251
+
252
+ #: Strings in a JSON credential (a service account's private key and the like) shorter
253
+ #: than this are not masked on their own: field names, types, project ids.
254
+ _MIN_JSON_SECRET_LEN = 16
255
+
256
+
257
+ def _secret_parts(value: str) -> list[str]:
258
+ """
259
+ What of a forwarded value a command could print and must not: the value itself; for a
260
+ header like "Bearer <token>", the token; for a database connection string, its
261
+ password; for a JSON credential (BigQuery), its long string fields (a private key).
262
+ Kept in step with the backend's terminal_tool._secret_parts and the extension's
263
+ runtime/commandEnv.ts.
264
+ """
265
+ parts = [value]
266
+ _, _, token = value.partition(" ")
267
+ parts.append(token)
268
+ if "://" in value:
269
+ try:
270
+ password = urlsplit(value).password
271
+ except ValueError:
272
+ password = None
273
+ if password:
274
+ parts += [password, unquote(password)]
275
+ if value.lstrip().startswith("{"):
276
+ try:
277
+ loaded = json.loads(value)
278
+ except ValueError:
279
+ loaded = None
280
+ if isinstance(loaded, dict):
281
+ for field in loaded.values():
282
+ if isinstance(field, str) and len(field) >= _MIN_JSON_SECRET_LEN:
283
+ parts += [field, field.replace("\n", "\\n")]
284
+ return parts
285
+
286
+
287
+ def mask_secrets(text: str, env: dict[str, str] | None) -> str:
288
+ """
289
+ Every forwarded value in `text` replaced by its variable's name, in case the command
290
+ printed one: each value whole, and the parts of it that are secrets on their own
291
+ (`_secret_parts`). (The backend masks again; this keeps it from leaving the machine
292
+ at all.)
293
+ """
294
+ if not text or not env:
295
+ return text
296
+ pairs = [
297
+ (part, key)
298
+ for key, value in env.items()
299
+ for part in _secret_parts(value)
300
+ if len(part) >= _MIN_MASKED_LEN
301
+ ]
302
+ for value, key in sorted(pairs, key=lambda p: len(p[0]), reverse=True):
303
+ text = text.replace(value, f"[{key}]")
304
+ return text
305
+
306
+
225
307
  async def run_command(
226
308
  command: str,
227
309
  timeout: int = 30,
228
310
  mode: str = "restricted",
229
311
  allowed_dirs: list[str] | None = None,
312
+ env: dict[str, str] | None = None,
230
313
  ) -> dict:
231
- """Execute a shell command and return stdout, stderr, exit_code."""
314
+ """Execute a shell command and return stdout, stderr, exit_code. `env` is added to this
315
+ command's environment only, on top of the runtime's own; it is not logged."""
232
316
  # Validate in restricted mode
233
317
  if mode == "restricted" and allowed_dirs:
234
318
  ok, reason = validate_command(command, allowed_dirs)
@@ -248,6 +332,8 @@ async def run_command(
248
332
  "stdin": asyncio.subprocess.DEVNULL,
249
333
  "cwd": cwd,
250
334
  }
335
+ if env:
336
+ popen_kwargs["env"] = {**os.environ, **env}
251
337
  if not IS_WINDOWS:
252
338
  popen_kwargs["start_new_session"] = True
253
339
 
@@ -294,8 +380,11 @@ async def run_interactive_command(
294
380
  mode: str = "restricted",
295
381
  allowed_dirs: list[str] | None = None,
296
382
  request_id: str | None = None,
383
+ env: dict[str, str] | None = None,
297
384
  ) -> dict:
298
- """Execute a command with the user's terminal attached for interactive I/O."""
385
+ """Execute a command with the user's terminal attached for interactive I/O. `env` is
386
+ added to this command's environment only, as for run_command. What the command prints
387
+ goes to the user's terminal, never to the server: the server gets only the exit code."""
299
388
  if mode == "restricted" and allowed_dirs:
300
389
  ok, reason = validate_command(command, allowed_dirs)
301
390
  if not ok:
@@ -317,6 +406,8 @@ async def run_interactive_command(
317
406
  mode=mode,
318
407
  allowed_dirs=allowed_dirs,
319
408
  timeout_sec=timeout_eff,
409
+ env=env,
410
+ mask=(lambda text: mask_secrets(text, env)) if env else None,
320
411
  )
321
412
 
322
413
  try:
@@ -346,6 +437,8 @@ async def run_interactive_command(
346
437
  old_term = None
347
438
 
348
439
  popen_kwargs: dict[str, Any] = {"shell": True, "cwd": cwd}
440
+ if env:
441
+ popen_kwargs["env"] = {**os.environ, **env}
349
442
  if not IS_WINDOWS:
350
443
  popen_kwargs["start_new_session"] = True
351
444
  # The command writes to the terminal directly; what was printed goes first.
@@ -5,7 +5,7 @@ import sys
5
5
  from typing import Any, Awaitable, Callable
6
6
 
7
7
  from kiwi_runtime.checkpoints import _set_current_run_id
8
- from kiwi_runtime.commands import _looks_interactive_oneoff, run_interactive_command
8
+ from kiwi_runtime.commands import _looks_interactive_oneoff, command_env, run_interactive_command
9
9
  from kiwi_runtime.constants import IS_WINDOWS
10
10
  from kiwi_runtime.fs.handler import FS_HANDLERS, handle_fs_request
11
11
  from kiwi_runtime.restricted import validate_command
@@ -79,8 +79,10 @@ async def _command(state: _RuntimeState, msg: dict[str, Any]) -> None:
79
79
  timeout_sec = int(msg.get("timeout_sec", 30) or 30)
80
80
  except Exception:
81
81
  timeout_sec = 30
82
+ # App connection credentials for this command only (the "command_env" capability).
83
+ env = command_env(msg.get("env"))
82
84
  try:
83
- state.command_queue.put_nowait((str(request_id), str(command), timeout_sec))
85
+ state.command_queue.put_nowait((str(request_id), str(command), timeout_sec, env))
84
86
  except asyncio.QueueFull:
85
87
  print_cmd_log(request_id, f"{RED}FAILED{RESET}", False)
86
88
  await state.reply(_command_result(request_id, "Runtime command queue full; please retry."))
@@ -90,6 +92,8 @@ async def _interactive(state: _RuntimeState, msg: dict[str, Any]) -> None:
90
92
  request_id = msg.get("request_id", "")
91
93
  command = msg.get("command", "")
92
94
  print_cmd_log(request_id, f"$ {BOLD}{command}{RESET} {GREY}(interactive){RESET}")
95
+ # Connection credentials for this command only; never logged.
96
+ env = command_env(msg.get("env"))
93
97
 
94
98
  try:
95
99
  timeout_sec = int(msg.get("timeout_sec", 120) or 120)
@@ -103,6 +107,7 @@ async def _interactive(state: _RuntimeState, msg: dict[str, Any]) -> None:
103
107
  mode=state.mode,
104
108
  allowed_dirs=state.allowed_dirs,
105
109
  request_id=request_id,
110
+ env=env,
106
111
  )
107
112
  exit_code = result["exit_code"]
108
113
  success = exit_code == 0
@@ -11,7 +11,7 @@ from pathlib import Path
11
11
  import websockets
12
12
 
13
13
  from kiwi_runtime.auth import _get_valid_access_token, login
14
- from kiwi_runtime.commands import run_command
14
+ from kiwi_runtime.commands import mask_secrets, run_command
15
15
  from kiwi_runtime.constants import MAX_INBOUND_FRAME_BYTES
16
16
  from kiwi_runtime.fs.handler import FS_MAX_CONCURRENCY
17
17
  from kiwi_runtime.handlers import handle_message
@@ -32,7 +32,10 @@ from kiwi_runtime.ui import (
32
32
  # goodbye sends {"type": "goodbye"} before an intentional exit
33
33
  # outbox keeps what it could not send and delivers it after reconnecting
34
34
  # pty_survive keeps its PTY sessions across a reconnect and lists them at auth
35
- RUNTIME_CAPABILITIES = ["goodbye", "outbox", "pty_survive"]
35
+ # command_env takes an `env` mapping on a one-off or interactive command (connection
36
+ # credentials), adds it to that command's environment only, and masks it in
37
+ # the output
38
+ RUNTIME_CAPABILITIES = ["goodbye", "outbox", "pty_survive", "command_env"]
36
39
 
37
40
  RECONNECT_BASE_DELAY_SEC = 1.5
38
41
  RECONNECT_MAX_DELAY_SEC = 30.0
@@ -89,11 +92,11 @@ async def _command_worker(state: _RuntimeState, mode: str, allowed_dirs: list[st
89
92
  """Runs one-off commands from the queue; a result outlives the connection it was asked
90
93
  on. Each command is its own shell, so several workers run them side by side."""
91
94
  while True:
92
- request_id, command, timeout_sec = await state.command_queue.get()
95
+ request_id, command, timeout_sec, env = await state.command_queue.get()
93
96
  state.running_commands.append(command)
94
97
  try:
95
98
  result = await run_command(
96
- command, timeout=timeout_sec or 30, mode=mode, allowed_dirs=allowed_dirs
99
+ command, timeout=timeout_sec or 30, mode=mode, allowed_dirs=allowed_dirs, env=env
97
100
  )
98
101
  try:
99
102
  exit_code = int(result.get("exit_code", 0))
@@ -111,6 +114,14 @@ async def _command_worker(state: _RuntimeState, mode: str, allowed_dirs: list[st
111
114
  finally:
112
115
  state.running_commands.remove(command)
113
116
  state.command_queue.task_done()
117
+ if env:
118
+ # A credential the command printed must not reach the server, the model or the
119
+ # chat: each value is replaced by its variable's name.
120
+ result = {
121
+ **result,
122
+ "stdout": mask_secrets(result.get("stdout", ""), env),
123
+ "stderr": mask_secrets(result.get("stderr", ""), env),
124
+ }
114
125
  await state.link.send(json.dumps({"type": "result", "request_id": request_id, **result}))
115
126
 
116
127
 
@@ -149,9 +149,12 @@ if not IS_WINDOWS:
149
149
  cols: int = 120,
150
150
  rows: int = 40,
151
151
  on_exit: Callable[[], None] | None = None,
152
+ env: dict[str, str] | None = None,
152
153
  ):
153
154
  self.session_id = session_id
154
155
  self.command = command
156
+ # Added to this process's environment only (connection credentials); not logged.
157
+ self.env = env
155
158
  self.cols = cols
156
159
  self.rows = rows
157
160
  self.master_fd = None
@@ -190,6 +193,7 @@ if not IS_WINDOWS:
190
193
  stderr=self.slave_fd,
191
194
  preexec_fn=_own_terminal,
192
195
  cwd=cwd,
196
+ env={**os.environ, **self.env} if self.env else None,
193
197
  )
194
198
 
195
199
  os.close(self.slave_fd)
@@ -422,9 +426,12 @@ class PipeProcess:
422
426
  rows: int = 40,
423
427
  mode: str = "restricted",
424
428
  allowed_dirs: list[str] | None = None,
429
+ env: dict[str, str] | None = None,
425
430
  ):
426
431
  self.session_id = session_id
427
432
  self.command = command
433
+ # Added to this process's environment only (connection credentials); not logged.
434
+ self.env = env
428
435
  self.cols = cols
429
436
  self.rows = rows
430
437
  self.proc = None
@@ -444,6 +451,7 @@ class PipeProcess:
444
451
  self.cwd = cwd
445
452
  self.ws = ws
446
453
  self._sentinel_buffer = ""
454
+ env = {**os.environ, **self.env} if self.env else None
447
455
 
448
456
  if IS_WINDOWS:
449
457
  shell = os.environ.get("COMSPEC", "cmd.exe")
@@ -457,6 +465,7 @@ class PipeProcess:
457
465
  stdout=asyncio.subprocess.PIPE,
458
466
  stderr=asyncio.subprocess.STDOUT,
459
467
  cwd=cwd,
468
+ env=env,
460
469
  creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
461
470
  )
462
471
  else:
@@ -467,6 +476,7 @@ class PipeProcess:
467
476
  stdout=asyncio.subprocess.PIPE,
468
477
  stderr=asyncio.subprocess.STDOUT,
469
478
  cwd=cwd,
479
+ env=env,
470
480
  )
471
481
 
472
482
  self._read_task = asyncio.create_task(self._read_loop(ws))
@@ -5,7 +5,7 @@ import os
5
5
  import uuid
6
6
  from datetime import datetime
7
7
  from pathlib import Path
8
- from typing import Any
8
+ from typing import Any, Callable
9
9
 
10
10
  from kiwi_runtime.constants import (
11
11
  IS_WINDOWS,
@@ -30,8 +30,17 @@ def _tui_managed_control_dir() -> Path | None:
30
30
 
31
31
 
32
32
  class _LocalInteractivePTYBridge:
33
- def __init__(self, *, control_dir: Path, request_id: str, command: str) -> None:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ control_dir: Path,
37
+ request_id: str,
38
+ command: str,
39
+ mask: Callable[[str], str] | None = None,
40
+ ) -> None:
34
41
  self.control_dir = control_dir
42
+ # Hides forwarded credentials in what is written to output.log for the TUI to show.
43
+ self._mask = mask
35
44
  self.request_id = request_id
36
45
  self.command = command
37
46
  self.session_dir = control_dir / "interactive"
@@ -75,6 +84,10 @@ class _LocalInteractivePTYBridge:
75
84
  def append_output(self, data: str) -> None:
76
85
  if not data:
77
86
  return
87
+ if self._mask is not None:
88
+ # Chunk by chunk: a value split across two reads is not caught. The file stays on
89
+ # this machine and nothing of it is sent to the server.
90
+ data = self._mask(data)
78
91
  with open(self.output_path, "a", encoding="utf-8", errors="replace") as fp:
79
92
  fp.write(data)
80
93
  fp.flush()
@@ -179,6 +192,8 @@ async def run_tui_managed_interactive_command(
179
192
  mode: str = "restricted",
180
193
  allowed_dirs: list[str] | None = None,
181
194
  timeout_sec: int | None = None,
195
+ env: dict[str, str] | None = None,
196
+ mask: Callable[[str], str] | None = None,
182
197
  ) -> dict:
183
198
  if mode == "restricted" and allowed_dirs:
184
199
  ok, reason = validate_command(command, allowed_dirs)
@@ -186,7 +201,9 @@ async def run_tui_managed_interactive_command(
186
201
  return {"stdout": "", "stderr": reason, "exit_code": 1}
187
202
 
188
203
  cwd = allowed_dirs[0] if allowed_dirs else None
189
- bridge = _LocalInteractivePTYBridge(control_dir=control_dir, request_id=request_id, command=command)
204
+ bridge = _LocalInteractivePTYBridge(
205
+ control_dir=control_dir, request_id=request_id, command=command, mask=mask
206
+ )
190
207
  await asyncio.to_thread(bridge.activate)
191
208
  await asyncio.to_thread(bridge.append_output, f"$ {command}\r\n")
192
209
 
@@ -202,9 +219,10 @@ async def run_tui_managed_interactive_command(
202
219
  40,
203
220
  mode=mode,
204
221
  allowed_dirs=allowed_dirs,
222
+ env=env,
205
223
  )
206
224
  else:
207
- pty_proc = PTYProcess(session_id, command, 120, 40)
225
+ pty_proc = PTYProcess(session_id, command, 120, 40, env=env)
208
226
  await pty_proc.start(bridge, cwd=cwd)
209
227
  input_task = asyncio.create_task(bridge.pump_inputs(pty_proc))
210
228
 
@@ -313,7 +313,7 @@ async def test_it_states_what_it_can_do_when_it_authenticates(backend, run_runti
313
313
  run_runtime()
314
314
  auth = (await until(lambda: backend.auths))[0]
315
315
 
316
- assert auth["capabilities"] == ["goodbye", "outbox", "pty_survive"]
316
+ assert auth["capabilities"] == ["goodbye", "outbox", "pty_survive", "command_env"]
317
317
  assert auth["pty_sessions"] == []
318
318
  assert auth["max_frame_bytes"] == runtime.MAX_INBOUND_FRAME_BYTES
319
319
 
@@ -642,6 +642,162 @@ async def test_one_off_commands_sent_together_run_side_by_side(backend, run_runt
642
642
  assert {m["request_id"] for m in backend.messages("result")} == {f"r{n}" for n in range(runtime.COMMAND_WORKERS)}
643
643
 
644
644
 
645
+ # --- app connection credentials on a one-off command ("command_env") ----------------------
646
+
647
+
648
+ async def test_a_command_gets_the_variables_it_was_sent_and_its_output_is_masked(backend, run_runtime):
649
+ """The backend resolved the user's saved connections into `env`: they reach that one
650
+ command, and a value it printed comes back as its variable's name."""
651
+ run_runtime()
652
+ await until(lambda: backend.sockets)
653
+ env = {
654
+ "AUTOBOTS_CONN_WORKDAY_PROD_AUTHORIZATION": "Bearer wd-secret-token-123",
655
+ "AWS_SECRET_ACCESS_KEY": "aws-secret-key-0001",
656
+ }
657
+
658
+ await backend.send({
659
+ "type": "command",
660
+ "request_id": "with-env",
661
+ "command": 'echo "$AUTOBOTS_CONN_WORKDAY_PROD_AUTHORIZATION"; echo "token=${AUTOBOTS_CONN_WORKDAY_PROD_AUTHORIZATION#Bearer }"; echo "$AWS_SECRET_ACCESS_KEY" >&2',
662
+ "timeout_sec": 10,
663
+ "env": env,
664
+ })
665
+ await until(lambda: backend.messages("result"))
666
+ result = backend.messages("result")[0]
667
+
668
+ assert result["exit_code"] == 0
669
+ assert result["stdout"] == "[AUTOBOTS_CONN_WORKDAY_PROD_AUTHORIZATION]\ntoken=[AUTOBOTS_CONN_WORKDAY_PROD_AUTHORIZATION]\n"
670
+ assert result["stderr"] == "[AWS_SECRET_ACCESS_KEY]\n"
671
+
672
+
673
+ async def test_the_variables_are_for_that_command_only(backend, run_runtime):
674
+ run_runtime()
675
+ await until(lambda: backend.sockets)
676
+
677
+ await backend.send({"type": "command", "request_id": "a", "command": "echo set", "timeout_sec": 10,
678
+ "env": {"AUTOBOTS_CONN_X_AUTHORIZATION": "secret-value-1"}})
679
+ await until(lambda: backend.messages("result"))
680
+ await backend.send({"type": "command", "request_id": "b", "command": 'echo "[${AUTOBOTS_CONN_X_AUTHORIZATION:-}]"', "timeout_sec": 10})
681
+ await until(lambda: len(backend.messages("result")) == 2)
682
+
683
+ later = next(m for m in backend.messages("result") if m["request_id"] == "b")
684
+ assert later["stdout"] == "[]\n"
685
+
686
+
687
+ @pytest.mark.skipif(sys.platform == "win32", reason="PTY")
688
+ async def test_an_interactive_command_under_the_tui_gets_the_variables_masked_on_screen(tmp_path, monkeypatch):
689
+ """Under the TUI an interactive command runs on a PTY whose output the TUI reads from
690
+ output.log: the variables reach it, a printed value is masked there, and the server is
691
+ told only that it ended."""
692
+ from kiwi_runtime.commands import run_interactive_command
693
+
694
+ monkeypatch.setenv("KIWI_TUI_MANAGED", "1")
695
+ monkeypatch.setenv("KIWI_RUNTIME_CONTROL_DIR", str(tmp_path))
696
+ result = await run_interactive_command(
697
+ 'test "$AUTOBOTS_CONN_GH_AUTHORIZATION" = "Bearer gh-secret-token-9" && echo "ok $AUTOBOTS_CONN_GH_AUTHORIZATION"',
698
+ timeout_sec=10,
699
+ mode="full",
700
+ request_id="i1",
701
+ env={"AUTOBOTS_CONN_GH_AUTHORIZATION": "Bearer gh-secret-token-9"},
702
+ )
703
+
704
+ shown = (tmp_path / "interactive" / "output.log").read_text()
705
+ assert result == {"stdout": "(interactive session completed)", "stderr": "", "exit_code": 0}
706
+ assert "ok [AUTOBOTS_CONN_GH_AUTHORIZATION]" in shown
707
+ assert "gh-secret-token-9" not in shown
708
+
709
+
710
+ async def test_an_interactive_command_in_a_plain_terminal_gets_the_variables(tmp_path, monkeypatch):
711
+ from kiwi_runtime.commands import run_interactive_command
712
+
713
+ monkeypatch.delenv("KIWI_TUI_MANAGED", raising=False)
714
+ out = tmp_path / "seen"
715
+ result = await run_interactive_command(
716
+ f'printf %s "$AUTOBOTS_DB_PG_URL" > "{out}"',
717
+ timeout_sec=10,
718
+ mode="full",
719
+ env={"AUTOBOTS_DB_PG_URL": "fake-connection-string-0001"},
720
+ )
721
+
722
+ assert result["exit_code"] == 0
723
+ assert out.read_text() == "fake-connection-string-0001"
724
+ assert "AUTOBOTS_DB_PG_URL" not in os.environ
725
+
726
+
727
+ async def test_an_interactive_message_carries_its_variables_to_the_command(monkeypatch):
728
+ seen: dict[str, Any] = {}
729
+
730
+ async def fake_run(command, **kwargs):
731
+ seen.update(kwargs)
732
+ return {"stdout": "(interactive session completed)", "stderr": "", "exit_code": 0}
733
+
734
+ monkeypatch.setattr(handlers, "run_interactive_command", fake_run)
735
+ replies: list[dict] = []
736
+ ran: list[Any] = []
737
+
738
+ class _Requests:
739
+ def submit(self, lane, job):
740
+ ran.append(job)
741
+
742
+ class _State:
743
+ mode = "full"
744
+ allowed_dirs: list[str] = []
745
+ requests = _Requests()
746
+
747
+ async def reply(self, msg):
748
+ replies.append(msg)
749
+
750
+ await handlers._interactive(_State(), {"type": "interactive", "request_id": "r", "command": "gh auth status",
751
+ "env": {"AUTOBOTS_CONN_GH_AUTHORIZATION": "Bearer t-123456", "1BAD": "x"}})
752
+ await ran[0]()
753
+
754
+ assert seen["env"] == {"AUTOBOTS_CONN_GH_AUTHORIZATION": "Bearer t-123456"}
755
+ assert replies[0]["exit_code"] == 0
756
+
757
+
758
+ def test_only_well_formed_variables_are_taken():
759
+ from kiwi_runtime.commands import command_env
760
+
761
+ assert command_env(None) is None
762
+ assert command_env({}) is None
763
+ assert command_env("A=B") is None
764
+ assert command_env({"GOOD_1": "v", "1BAD": "v", "BAD-NAME": "v", "NOT_TEXT": 3, "LONG": "x" * 20_000}) == {"GOOD_1": "v"}
765
+
766
+
767
+ def test_a_printed_database_password_or_private_key_is_masked():
768
+ """forward_databases_direct: a connection string's password and a BigQuery key, printed
769
+ on their own, are as secret as the whole value."""
770
+ import json as _json
771
+
772
+ from kiwi_runtime.commands import mask_secrets
773
+
774
+ # Made up, and put together from parts so secret scanners do not take them for real.
775
+ pem, body = "PRIVATE" + " KEY", "fake-key-body-for-tests-0001"
776
+ key = f"-----BEGIN {pem}-----\n{body}\n-----END {pem}-----\n"
777
+ word, encoded = "fake@word-99", "fake%40word-99"
778
+ env = {
779
+ "AUTOBOTS_DB_WAREHOUSE_URL": "postgresql://" + f"analyst:{encoded}@db.internal:5432/sales",
780
+ "AUTOBOTS_DB_BQ_CREDENTIALS_JSON": _json.dumps({"type": "service_account", "private_key": key}),
781
+ }
782
+ printed = f"pw={word} enc={encoded}\nraw: {key}json: {_json.dumps(key)}\n"
783
+
784
+ masked = mask_secrets(printed, env)
785
+
786
+ for secret in [word, encoded, body]:
787
+ assert secret not in masked
788
+ assert masked.startswith("pw=[AUTOBOTS_DB_WAREHOUSE_URL] enc=[AUTOBOTS_DB_WAREHOUSE_URL]\n")
789
+ assert "raw: [AUTOBOTS_DB_BQ_CREDENTIALS_JSON]" in masked
790
+ assert 'json: "[AUTOBOTS_DB_BQ_CREDENTIALS_JSON]"' in masked
791
+
792
+
793
+ def test_masking_leaves_short_values_and_ordinary_text_alone():
794
+ from kiwi_runtime.commands import mask_secrets
795
+
796
+ assert mask_secrets("value is abc", {"SHORT": "abc"}) == "value is abc"
797
+ assert mask_secrets("no secrets here", {"K": "long-secret-value"}) == "no secrets here"
798
+ assert mask_secrets("x long-secret-value y", None) == "x long-secret-value y"
799
+
800
+
645
801
  # --- requests do not hold up the receive loop ---------------------------------------------
646
802
 
647
803
 
@@ -412,7 +412,7 @@ wheels = [
412
412
 
413
413
  [[package]]
414
414
  name = "kiwi-code"
415
- version = "0.0.511"
415
+ version = "0.0.512"
416
416
  source = { editable = "." }
417
417
  dependencies = [
418
418
  { name = "codeglass" },
File without changes
File without changes
File without changes
File without changes
File without changes