kiwi-code 0.0.511__tar.gz → 0.0.513__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.513}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/autocode.py +33 -0
  4. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/terminal_mode.py +3 -2
  5. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/commands.py +95 -2
  6. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/handlers.py +7 -2
  7. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/link.py +15 -4
  8. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/sessions.py +10 -0
  9. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/tui_bridge.py +22 -4
  10. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_dashboard.py +12 -5
  11. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_autocode_actions.py +49 -0
  12. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_connection.py +157 -1
  13. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_term_dashboard_ui.py +53 -0
  14. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/uv.lock +1 -1
  15. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/.github/workflows/publish.yml +0 -0
  16. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/.github/workflows/test.yml +0 -0
  17. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/.gitignore +0 -0
  18. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/.python-version +0 -0
  19. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/Makefile +0 -0
  20. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/README.md +0 -0
  21. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/docs/runtime_audit.html +0 -0
  22. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/docs/social_login_e2e.md +0 -0
  23. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/scripts/set_edition.py +0 -0
  24. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/scripts/ws_auth_check.py +0 -0
  25. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/common/__init__.py +0 -0
  26. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/common/banner.py +0 -0
  27. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/__init__.py +0 -0
  28. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/_edition.py +0 -0
  29. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/auth.py +0 -0
  30. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/checkpoints.py +0 -0
  31. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/cli.py +0 -0
  32. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/client.py +0 -0
  33. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/commands.py +0 -0
  34. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/edition.py +0 -0
  35. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/human_questions.py +0 -0
  36. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/io_threads.py +0 -0
  37. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/logger.py +0 -0
  38. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/models.py +0 -0
  39. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/oauth.py +0 -0
  40. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/run_activity.py +0 -0
  41. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/runtime_manager.py +0 -0
  42. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/server.py +0 -0
  43. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_cli/subagents.py +0 -0
  44. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/__init__.py +0 -0
  45. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/__main__.py +0 -0
  46. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/auth.py +0 -0
  47. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/checkpoints.py +0 -0
  48. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/cli.py +0 -0
  49. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/constants.py +0 -0
  50. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/__init__.py +0 -0
  51. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/batch.py +0 -0
  52. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/codelens.py +0 -0
  53. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/context.py +0 -0
  54. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/edits.py +0 -0
  55. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/handler.py +0 -0
  56. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/paths.py +0 -0
  57. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/read.py +0 -0
  58. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/search.py +0 -0
  59. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/text.py +0 -0
  60. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/upload.py +0 -0
  61. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/fs/write.py +0 -0
  62. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/main.py +0 -0
  63. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/restricted.py +0 -0
  64. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/state.py +0 -0
  65. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/ui.py +0 -0
  66. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_runtime/uploads.py +0 -0
  67. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/__init__.py +0 -0
  68. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/attention.py +0 -0
  69. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/autoupdate.py +0 -0
  70. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/inline_file_picker.py +0 -0
  71. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/latex_unicode.py +0 -0
  72. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/main.py +0 -0
  73. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/random_words.py +0 -0
  74. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/role_mode.py +0 -0
  75. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/runtime_agent.py +0 -0
  76. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/__init__.py +0 -0
  77. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/attach_content.py +0 -0
  78. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/command_result.py +0 -0
  79. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/detach_files.py +0 -0
  80. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/file_browser.py +0 -0
  81. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/help.py +0 -0
  82. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/id_picker.py +0 -0
  83. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/login.py +0 -0
  84. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  85. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  86. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/slash_picker.py +0 -0
  87. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_activity_view.py +0 -0
  88. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_dashboard_css.py +0 -0
  89. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_dashboard_helpers.py +0 -0
  90. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  91. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  92. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_status_line.py +0 -0
  93. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  94. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/slash_commands.py +0 -0
  95. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/status_words.py +0 -0
  96. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/term_app.py +0 -0
  97. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/terminal_theme.py +0 -0
  98. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/ui_settings.py +0 -0
  99. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/widgets.py +0 -0
  100. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/src/kiwi_tui/worktrees.py +0 -0
  101. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/test_hello.py +0 -0
  102. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/__init__.py +0 -0
  103. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/conftest.py +0 -0
  104. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_account_server.py +0 -0
  105. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_attention.py +0 -0
  106. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_autoupdate.py +0 -0
  107. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_batch_fs_tool.py +0 -0
  108. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_checkpoints.py +0 -0
  109. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_cli_help.py +0 -0
  110. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_command_tips.py +0 -0
  111. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_copy_path_fs_tool.py +0 -0
  112. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_fs_edit_fidelity.py +0 -0
  113. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_human_questions.py +0 -0
  114. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_imports.py +0 -0
  115. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_io_threads.py +0 -0
  116. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_login_social.py +0 -0
  117. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_math_and_tables.py +0 -0
  118. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_oauth.py +0 -0
  119. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_read_file_streaming.py +0 -0
  120. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_reexec_kiwi.py +0 -0
  121. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_reply_selection.py +0 -0
  122. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_role_mode_injection.py +0 -0
  123. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_run_activity.py +0 -0
  124. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_run_steering.py +0 -0
  125. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_cleanup_per_account.py +0 -0
  126. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_edit_diffs.py +0 -0
  127. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_fixes.py +0 -0
  128. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_imports_on_windows.py +0 -0
  129. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_log_trimming.py +0 -0
  130. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_runtime_social_login.py +0 -0
  131. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_search_in_files_fs_tool.py +0 -0
  132. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_slash_commands.py +0 -0
  133. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_stats_command.py +0 -0
  134. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_status_line.py +0 -0
  135. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_subagents.py +0 -0
  136. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_terminal_mode.py +0 -0
  137. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_terminal_theme.py +0 -0
  138. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_toasts.py +0 -0
  139. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_tokens.py +0 -0
  140. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_tui_interactive_runtime.py +0 -0
  141. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_tui_palette.py +0 -0
  142. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/tests/test_unified_diff.py +0 -0
  143. {kiwi_code-0.0.511 → kiwi_code-0.0.513}/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.513
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.513"
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"
@@ -8,6 +8,11 @@ contains "autocode" in any spelling: "Autocode", "AutoCodeClaude",
8
8
  When two of them have the same name ignoring case, spaces and punctuation
9
9
  ("AutocodeClaude" and "AutoCodeClaude", "Autocode OpenAI Flash" and
10
10
  "AutocodeOpenaiFlash"), only the most recently created one is kept.
11
+
12
+ Which one a new session starts with depends on the account (``default_id_for``):
13
+ Work (Enterprise, api.meetkiwi.ai) starts with "Autocode OpenAI Flash", Personal
14
+ (Subscription, api.runkai.ai) and every other server with "Autocode". The VS Code
15
+ extension makes the same choice (src/defaultAgent.ts).
11
16
  """
12
17
 
13
18
  import re
@@ -17,11 +22,17 @@ from datetime import datetime, timezone
17
22
  from pykiwiai import AuthenticatedClient
18
23
  from pykiwiai.api.actions import list_system_actions_v1_actions_system_actions_get
19
24
 
25
+ from kiwi_cli.server import SERVER_PRESETS
26
+
20
27
  # Fetch every system action in one page: paging with offset is not reliable on
21
28
  # the servers (offset > 0 returns an empty page).
22
29
  _SYSTEM_ACTIONS_LIMIT = 500
23
30
 
24
31
 
32
+ #: The default on the Work (Enterprise) server, compared as _normalize compares names.
33
+ _ENTERPRISE_DEFAULT = "autocodeopenaiflash"
34
+
35
+
25
36
  def _normalize(name: object) -> str:
26
37
  return re.sub(r"[^a-z0-9]", "", str(name or "").lower())
27
38
 
@@ -64,6 +75,28 @@ class AutocodeActions:
64
75
  return action.id
65
76
  return self.actions[0].id if self.actions else None
66
77
 
78
+ def name_of(self, action_id: object) -> str | None:
79
+ """The name of the AutoCode action ``action_id``, or None if it is not one."""
80
+ for action in self.actions:
81
+ if action.id == action_id:
82
+ return action.name
83
+ return None
84
+
85
+ def default_id_for(self, backend_url: object) -> str | None:
86
+ """The action a new session starts with on ``backend_url``: "Autocode OpenAI
87
+ Flash" on the Work (Enterprise) server when it has one, else ``default_id``."""
88
+ if is_enterprise_backend(backend_url):
89
+ for action in self.actions:
90
+ if _normalize(action.name) == _ENTERPRISE_DEFAULT:
91
+ return action.id
92
+ return self.default_id
93
+
94
+
95
+ def is_enterprise_backend(backend_url: object) -> bool:
96
+ """True for the Work (Enterprise) server, api.meetkiwi.ai."""
97
+ url = str(backend_url or "").strip().rstrip("/").lower()
98
+ return url == SERVER_PRESETS["work"]["http"]
99
+
67
100
 
68
101
  def _created(action: AutocodeAction) -> datetime:
69
102
  """When the action was created. Falls back to the timestamp in its ObjectId."""
@@ -227,10 +227,11 @@ def _default_action_id(session: _TerminalSession) -> str:
227
227
  found = fetch_autocode_actions(session.client.client)
228
228
  if found.status_code in (401, 403) and _refresh_tokens(session):
229
229
  found = fetch_autocode_actions(session.client.client)
230
- if found.default_id is None:
230
+ default_id = found.default_id_for(session.backend_url)
231
+ if default_id is None:
231
232
  detail = "no AutoCode actions on this server" if found.ok else f"HTTP {found.status_code}"
232
233
  raise TerminalModeError(f"Could not find the AutoCode action ({detail}). Pass --action-id to choose one.")
233
- return found.default_id
234
+ return default_id
234
235
 
235
236
 
236
237
  def _build_authenticated_session(server: str | None) -> _TerminalSession:
@@ -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
 
@@ -1503,7 +1503,8 @@ class TermDashboardScreen(Screen):
1503
1503
  cwd = str(Path.cwd())
1504
1504
  cwd_short = cwd if len(cwd) <= 44 else f"{cwd[:20]}…{cwd[-20:]}"
1505
1505
 
1506
- action = self.current_action_name or self.current_action_id or ""
1506
+ # While its name loads, an action shows as "…", not as its id.
1507
+ action = self.current_action_name or ("…" if self.current_action_id else "")
1507
1508
  action = str(action)
1508
1509
  action_short = action if len(action) <= 16 else (action[:12] + "…")
1509
1510
 
@@ -1643,7 +1644,7 @@ class TermDashboardScreen(Screen):
1643
1644
  def _refresh_welcome_meta(self) -> None:
1644
1645
  """Update action name + run id displayed in the welcome header."""
1645
1646
  try:
1646
- action = self.current_action_name or self.current_action_id or "—"
1647
+ action = self.current_action_name or ("…" if self.current_action_id else "—")
1647
1648
  run = self.current_run_id or "—"
1648
1649
  self.query_one("#term-welcome-account", Static).update(self._account_text())
1649
1650
  self.query_one("#term-welcome-action", Static).update(f"Action: {action}")
@@ -1764,9 +1765,14 @@ class TermDashboardScreen(Screen):
1764
1765
  except Exception as e:
1765
1766
  logger.debug(f"Loading AutoCode actions failed: {e}")
1766
1767
  if not self.current_action_id and self._autocode is not None:
1767
- self.current_action_id = self._autocode.default_id or ""
1768
+ self.current_action_id = self._autocode.default_id_for(self._autocode_backend) or ""
1768
1769
  requested_id = self.current_action_id
1769
- if not requested_id:
1770
+ # An AutoCode action's name is already in the list just fetched: show it with the
1771
+ # id, rather than the id first and the name a request later.
1772
+ known = self._autocode.name_of(requested_id) if self._autocode is not None and requested_id else None
1773
+ if known and not self.current_action_name:
1774
+ self.current_action_name = known
1775
+ if not requested_id or known:
1770
1776
  self._refresh_top_bar()
1771
1777
  self._refresh_hint_model()
1772
1778
  self._refresh_input_label()
@@ -1779,7 +1785,8 @@ class TermDashboardScreen(Screen):
1779
1785
  if resp.status_code == 200 and resp.parsed:
1780
1786
  self.current_action_name = getattr(resp.parsed, "name", None) or requested_id
1781
1787
  else:
1782
- self.current_action_name = None
1788
+ # The name could not be found: the id is all there is to show.
1789
+ self.current_action_name = requested_id
1783
1790
  self._refresh_top_bar()
1784
1791
  self._refresh_hint_model()
1785
1792
  self._refresh_input_label()
@@ -154,3 +154,52 @@ def test_the_default_autocode_is_also_the_latest_one(monkeypatch):
154
154
  )
155
155
  found = fetch_autocode_actions(client=None)
156
156
  assert [a.id for a in found.actions] == ["new"] and found.default_id == "new"
157
+
158
+
159
+ # The default by account: Work (Enterprise, api.meetkiwi.ai) starts with Autocode OpenAI
160
+ # Flash, Personal (Subscription, api.runkai.ai) and every other server with Autocode. The
161
+ # VS Code extension makes the same choice (src/defaultAgent.ts).
162
+
163
+ WORK = "https://api.meetkiwi.ai"
164
+ PERSONAL = "https://api.runkai.ai"
165
+
166
+
167
+ def _flash_and_autocode(monkeypatch):
168
+ _respond(
169
+ monkeypatch,
170
+ 200,
171
+ [
172
+ _doc("a", "Autocode"),
173
+ _doc("f1", "Autocode OpenAI Flash", created_at=datetime(2026, 5, 1)),
174
+ _doc("f2", "AutocodeOpenaiFlash", created_at=datetime(2026, 10, 24)),
175
+ _doc("c", "AutoCodeClaude", "claude"),
176
+ ],
177
+ )
178
+ return fetch_autocode_actions(client=None)
179
+
180
+
181
+ @pytest.mark.parametrize("url", [WORK, WORK + "/", "https://API.meetkiwi.ai"])
182
+ def test_work_starts_with_the_latest_flash(monkeypatch, url):
183
+ assert _flash_and_autocode(monkeypatch).default_id_for(url) == "f2"
184
+
185
+
186
+ @pytest.mark.parametrize("url", [PERSONAL, "https://dev.api.myautobots.com", "http://localhost:8000", "", None])
187
+ def test_personal_and_other_servers_start_with_autocode(monkeypatch, url):
188
+ assert _flash_and_autocode(monkeypatch).default_id_for(url) == "a"
189
+
190
+
191
+ def test_work_without_flash_starts_with_autocode(monkeypatch):
192
+ _respond(monkeypatch, 200, [_doc("a", "Autocode"), _doc("c", "AutoCodeClaude", "claude")])
193
+ assert fetch_autocode_actions(client=None).default_id_for(WORK) == "a"
194
+
195
+
196
+ def test_no_actions_means_no_default_on_work_either():
197
+ assert AutocodeActions(status_code=200).default_id_for(WORK) is None
198
+
199
+
200
+ def test_name_of_gives_an_autocode_actions_name(monkeypatch):
201
+ found = _flash_and_autocode(monkeypatch)
202
+ assert found.name_of("f2") == "AutocodeOpenaiFlash"
203
+ assert found.name_of("a") == "Autocode"
204
+ assert found.name_of("unknown") is None
205
+ assert found.name_of(None) is None