pidriver 0.4.1__tar.gz → 0.4.2__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 (80) hide show
  1. {pidriver-0.4.1 → pidriver-0.4.2}/PKG-INFO +6 -2
  2. {pidriver-0.4.1 → pidriver-0.4.2}/README.md +5 -1
  3. {pidriver-0.4.1 → pidriver-0.4.2}/pyproject.toml +1 -1
  4. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/__init__.py +1 -1
  5. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/repository.py +58 -15
  6. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_repository.py +73 -0
  7. {pidriver-0.4.1 → pidriver-0.4.2}/.gitignore +0 -0
  8. {pidriver-0.4.1 → pidriver-0.4.2}/.sc/last-chat-settings.json +0 -0
  9. {pidriver-0.4.1 → pidriver-0.4.2}/LICENSE +0 -0
  10. {pidriver-0.4.1 → pidriver-0.4.2}/examples/basic_prompt.py +0 -0
  11. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/_install.py +0 -0
  12. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/_transport.py +0 -0
  13. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/assets/pi_login.mjs +0 -0
  14. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/auth.py +0 -0
  15. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/client.py +0 -0
  16. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/config.py +0 -0
  17. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/errors.py +0 -0
  18. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/events.py +0 -0
  19. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/instances.py +0 -0
  20. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/interaction.py +0 -0
  21. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/manager.py +0 -0
  22. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/models.py +0 -0
  23. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/py.typed +0 -0
  24. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/session.py +0 -0
  25. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/staging.py +0 -0
  26. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/store.py +0 -0
  27. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/supervisor.py +0 -0
  28. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/usage.py +0 -0
  29. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/web.py +0 -0
  30. {pidriver-0.4.1 → pidriver-0.4.2}/src/pidriver/workspace.py +0 -0
  31. {pidriver-0.4.1 → pidriver-0.4.2}/tests/conftest.py +0 -0
  32. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/README.md +0 -0
  33. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/_capture_driver.py +0 -0
  34. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/agent_end.json +0 -0
  35. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/agent_start.json +0 -0
  36. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.confirm.json +0 -0
  37. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.editor.json +0 -0
  38. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.input.json +0 -0
  39. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.select.approval.json +0 -0
  40. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.select.ask.json +0 -0
  41. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_request.setWidget.json +0 -0
  42. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.approve.json +0 -0
  43. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.confirm.json +0 -0
  44. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.editor.json +0 -0
  45. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.input.json +0 -0
  46. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/extension_ui_response.value.json +0 -0
  47. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/message_start_assistant.json +0 -0
  48. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/message_start_user.json +0 -0
  49. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/message_update.text_delta.json +0 -0
  50. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/message_update.thinking_delta.json +0 -0
  51. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/message_update.toolcall_end.json +0 -0
  52. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/message_update.toolcall_start.json +0 -0
  53. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/ready.json +0 -0
  54. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/response_prompt_ack.json +0 -0
  55. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_end.json +0 -0
  56. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_start.json +0 -0
  57. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/tool_execution_update.json +0 -0
  58. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/turn_end.json +0 -0
  59. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/frames/turn_start.json +0 -0
  60. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/payloads/get_session_stats.response.json +0 -0
  61. {pidriver-0.4.1 → pidriver-0.4.2}/tests/fixtures/payloads/get_state.response.json +0 -0
  62. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_auth.py +0 -0
  63. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_config.py +0 -0
  64. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_event_summary.py +0 -0
  65. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_events.py +0 -0
  66. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_fixtures_replay.py +0 -0
  67. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_install.py +0 -0
  68. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_install_resolve.py +0 -0
  69. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_manager.py +0 -0
  70. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_models.py +0 -0
  71. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_replay_session.py +0 -0
  72. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_session.py +0 -0
  73. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_staging.py +0 -0
  74. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_store.py +0 -0
  75. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_supervisor.py +0 -0
  76. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_transport.py +0 -0
  77. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_ui_methods.py +0 -0
  78. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_usage.py +0 -0
  79. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_web.py +0 -0
  80. {pidriver-0.4.1 → pidriver-0.4.2}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pidriver
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work.
5
5
  Project-URL: Homepage, https://github.com/bobuk/pidriver
6
6
  Project-URL: Repository, https://github.com/bobuk/pidriver
@@ -543,6 +543,7 @@ session, workspace = await start_repository_task(
543
543
  client, "Fix the failing tests", "git+ssh://git@github.com/org/project.git",
544
544
  root=Path("/srv/pi-jobs"), job_id="task-123",
545
545
  ssh_key=Path("/srv/keys/project_ed25519"),
546
+ known_hosts=Path("/srv/keys/known_hosts"), # optional: pin host keys to this file
546
547
  )
547
548
  # Drain the session's events, then close it before finalizing the workspace.
548
549
  async for event in session:
@@ -563,7 +564,10 @@ untrusted requests: the caller must enforce an allowlist. Empty repositories and
563
564
  second job with the same ID are rejected. SSH also accepts standard `ssh://` and
564
565
  `git@host:path` URLs. The key path (not its contents) is saved by `to_state()` for
565
566
  later pushes; the key must still exist then. SSH uses strict host-key checking and
566
- non-interactive authentication; configure `known_hosts` on the host in advance.
567
+ non-interactive authentication. Pass `known_hosts=Path(...)` to pin host keys to that
568
+ file alone (`UserKnownHostsFile=<file>`, `GlobalKnownHostsFile=/dev/null`; the path is
569
+ saved by `to_state()` too) — useful in containers where ssh's own `~/.ssh` isn't yours
570
+ to manage; otherwise configure ssh's default `known_hosts` on the host in advance.
567
571
 
568
572
  ## Workspace jail
569
573
 
@@ -517,6 +517,7 @@ session, workspace = await start_repository_task(
517
517
  client, "Fix the failing tests", "git+ssh://git@github.com/org/project.git",
518
518
  root=Path("/srv/pi-jobs"), job_id="task-123",
519
519
  ssh_key=Path("/srv/keys/project_ed25519"),
520
+ known_hosts=Path("/srv/keys/known_hosts"), # optional: pin host keys to this file
520
521
  )
521
522
  # Drain the session's events, then close it before finalizing the workspace.
522
523
  async for event in session:
@@ -537,7 +538,10 @@ untrusted requests: the caller must enforce an allowlist. Empty repositories and
537
538
  second job with the same ID are rejected. SSH also accepts standard `ssh://` and
538
539
  `git@host:path` URLs. The key path (not its contents) is saved by `to_state()` for
539
540
  later pushes; the key must still exist then. SSH uses strict host-key checking and
540
- non-interactive authentication; configure `known_hosts` on the host in advance.
541
+ non-interactive authentication. Pass `known_hosts=Path(...)` to pin host keys to that
542
+ file alone (`UserKnownHostsFile=<file>`, `GlobalKnownHostsFile=/dev/null`; the path is
543
+ saved by `to_state()` too) — useful in containers where ssh's own `~/.ssh` isn't yours
544
+ to manage; otherwise configure ssh's default `known_hosts` on the host in advance.
541
545
 
542
546
  ## Workspace jail
543
547
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pidriver"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Async Python driver for the pi coding agent (pi --mode rpc), built for isolated, headless project work."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -123,7 +123,7 @@ from .supervisor import PiInstanceSupervisor
123
123
  from .usage import UsageTotals
124
124
  from .workspace import ProjectDir, list_candidate_projects, list_projects, resolve_project
125
125
 
126
- __version__ = "0.4.1"
126
+ __version__ = "0.4.2"
127
127
 
128
128
  __all__ = [
129
129
  "__version__",
@@ -21,11 +21,17 @@ class GitRepositoryWorkspace:
21
21
  """Isolated clone and worktree; publishing requires an explicit merge()."""
22
22
 
23
23
  def __init__(
24
- self, workspace: StagedWorkspace, *, branch: str, ssh_key: Path | None = None
24
+ self,
25
+ workspace: StagedWorkspace,
26
+ *,
27
+ branch: str,
28
+ ssh_key: Path | None = None,
29
+ known_hosts: Path | None = None,
25
30
  ) -> None:
26
31
  self.workspace = workspace
27
32
  self.branch = branch
28
33
  self.ssh_key = ssh_key
34
+ self.known_hosts = known_hosts
29
35
 
30
36
  @property
31
37
  def path(self) -> Path:
@@ -49,7 +55,7 @@ class GitRepositoryWorkspace:
49
55
  "origin",
50
56
  f"HEAD:refs/heads/{self.branch}",
51
57
  check=False,
52
- env=_ssh_env(self.ssh_key),
58
+ env=_ssh_env(self.ssh_key, self.known_hosts),
53
59
  )
54
60
  if push.returncode != 0:
55
61
  raise MergeConflict(
@@ -76,6 +82,7 @@ class GitRepositoryWorkspace:
76
82
  "workspace": self.workspace.to_state(),
77
83
  "branch": self.branch,
78
84
  "ssh_key": str(self.ssh_key) if self.ssh_key is not None else None,
85
+ "known_hosts": str(self.known_hosts) if self.known_hosts is not None else None,
79
86
  }
80
87
 
81
88
  @classmethod
@@ -84,41 +91,70 @@ class GitRepositoryWorkspace:
84
91
  if not isinstance(workspace, dict):
85
92
  raise StagingError("invalid repository workspace state")
86
93
  key = state.get("ssh_key")
94
+ known_hosts = state.get("known_hosts")
87
95
  return cls(
88
96
  StagedWorkspace.from_state(workspace),
89
97
  branch=str(state["branch"]),
90
98
  ssh_key=Path(str(key)) if key is not None else None,
99
+ known_hosts=Path(str(known_hosts)) if known_hosts is not None else None,
91
100
  )
92
101
 
93
102
 
94
- def _ssh_env(ssh_key: Path | None) -> dict[str, str] | None:
95
- if ssh_key is None:
103
+ def _known_hosts_file(known_hosts: Path | None) -> Path | None:
104
+ if known_hosts is None:
96
105
  return None
97
- key = Path(ssh_key).expanduser()
98
- if not key.is_file():
99
- raise StagingError(f"SSH key is not a file: {key}")
106
+ path = Path(known_hosts).expanduser().resolve()
107
+ if not path.is_file():
108
+ raise StagingError(f"known_hosts is not a file: {path}")
109
+ if any(ch.isspace() for ch in str(path)):
110
+ # ssh reads UserKnownHostsFile as a whitespace-separated list of files.
111
+ raise StagingError(f"known_hosts path must not contain whitespace: {path}")
112
+ return path
113
+
114
+
115
+ def _ssh_env(ssh_key: Path | None, known_hosts: Path | None = None) -> dict[str, str] | None:
116
+ """Git's environment for SSH remotes: the given key only, strict host-key checking against the
117
+ given known_hosts file only (else ssh's defaults), never a prompt. None: Git's own defaults."""
118
+ if ssh_key is None and known_hosts is None:
119
+ return None
120
+ command = ["ssh"]
121
+ if ssh_key is not None:
122
+ key = Path(ssh_key).expanduser()
123
+ if not key.is_file():
124
+ raise StagingError(f"SSH key is not a file: {key}")
125
+ command += ["-i", shlex.quote(str(key.resolve())), "-o", "IdentitiesOnly=yes"]
126
+ command += ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes"]
127
+ pinned = _known_hosts_file(known_hosts)
128
+ if pinned is not None:
129
+ command += [
130
+ "-o", f"UserKnownHostsFile={shlex.quote(str(pinned))}",
131
+ "-o", "GlobalKnownHostsFile=/dev/null",
132
+ ]
100
133
  return {
101
134
  **os.environ,
102
- "GIT_SSH_COMMAND": (
103
- f"ssh -i {shlex.quote(str(key.resolve()))} "
104
- "-o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes"
105
- ),
135
+ "GIT_SSH_COMMAND": " ".join(command),
106
136
  "GIT_SSH_VARIANT": "ssh",
107
137
  "GIT_TERMINAL_PROMPT": "0",
108
138
  }
109
139
 
110
140
 
111
141
  def stage_repository(
112
- url: str, *, root: Path, job_id: str, ssh_key: Path | None = None
142
+ url: str,
143
+ *,
144
+ root: Path,
145
+ job_id: str,
146
+ ssh_key: Path | None = None,
147
+ known_hosts: Path | None = None,
113
148
  ) -> GitRepositoryWorkspace:
114
149
  """Clone a trusted URL into root/job_id and make an isolated pi worktree.
115
150
 
116
151
  Repository URLs must be selected by the host, not supplied by untrusted users.
117
- Git credentials are inherited from the host. Origin stays unchanged until merge().
152
+ Git credentials are inherited from the host unless ``ssh_key`` is given; ``known_hosts``
153
+ pins the SSH host keys to that file alone. Origin stays unchanged until merge().
118
154
  """
119
155
  if not job_id or job_id != Path(job_id).name or job_id in (".", "..") or "\\" in job_id:
120
156
  raise StagingError(f"invalid job_id: {job_id!r}")
121
- ssh_env = _ssh_env(ssh_key)
157
+ ssh_env = _ssh_env(ssh_key, known_hosts)
122
158
  root = Path(root).resolve()
123
159
  job = root / job_id
124
160
  if job.exists():
@@ -146,6 +182,7 @@ def stage_repository(
146
182
  workspace,
147
183
  branch=branch,
148
184
  ssh_key=Path(ssh_key).expanduser().resolve() if ssh_key is not None else None,
185
+ known_hosts=_known_hosts_file(known_hosts),
149
186
  )
150
187
  except BaseException:
151
188
  shutil.rmtree(job, ignore_errors=True)
@@ -160,6 +197,7 @@ async def start_repository_task(
160
197
  root: Path,
161
198
  job_id: str,
162
199
  ssh_key: Path | None = None,
200
+ known_hosts: Path | None = None,
163
201
  ) -> tuple[PiSession, GitRepositoryWorkspace]:
164
202
  """Clone, stage and start pi; return session and durable workspace.
165
203
 
@@ -167,7 +205,12 @@ async def start_repository_task(
167
205
  when approval can happen after a host restart.
168
206
  """
169
207
  workspace = await asyncio.to_thread(
170
- stage_repository, url, root=root, job_id=job_id, ssh_key=ssh_key
208
+ stage_repository,
209
+ url,
210
+ root=root,
211
+ job_id=job_id,
212
+ ssh_key=ssh_key,
213
+ known_hosts=known_hosts,
171
214
  )
172
215
  try:
173
216
  session = await client.start(task, cwd=workspace.path)
@@ -120,6 +120,79 @@ def test_ssh_key_used_for_clone_and_push_after_restore(
120
120
  ws.cleanup()
121
121
 
122
122
 
123
+ def test_known_hosts_pins_clone_and_push_after_restore(
124
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
125
+ ) -> None:
126
+ import pidriver.repository as repository
127
+ import pidriver.staging as staging
128
+
129
+ bare = remote(tmp_path)
130
+ key = tmp_path / "id_ed25519"
131
+ key.write_text("fake private key")
132
+ pinned = tmp_path / "known_hosts"
133
+ pinned.write_text("example.invalid ssh-ed25519 AAAA\n")
134
+ real_run = subprocess.run
135
+ commands: list[str] = []
136
+
137
+ def capture(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
138
+ if "clone" in argv:
139
+ commands.append(kwargs["env"]["GIT_SSH_COMMAND"])
140
+ argv = [*argv[:3], str(bare), argv[4]]
141
+ elif "push" in argv:
142
+ commands.append(kwargs["env"]["GIT_SSH_COMMAND"])
143
+ return real_run(argv, **kwargs)
144
+
145
+ monkeypatch.setattr(repository.subprocess, "run", capture)
146
+ monkeypatch.setattr(staging.subprocess, "run", capture)
147
+ ws = stage_repository(
148
+ "git@example.invalid:project.git",
149
+ root=tmp_path / "jobs",
150
+ job_id="pinned-1",
151
+ ssh_key=key,
152
+ known_hosts=pinned,
153
+ )
154
+ (ws.path / "a.txt").write_text("pinned\n")
155
+ state = ws.to_state()
156
+ assert state["known_hosts"] == str(pinned.resolve())
157
+ GitRepositoryWorkspace.from_state(state).merge()
158
+ assert git(bare, "show", "main:a.txt") == "pinned"
159
+ assert len(commands) == 2
160
+ for command in commands:
161
+ assert f"UserKnownHostsFile={pinned.resolve()}" in command
162
+ assert "GlobalKnownHostsFile=/dev/null" in command
163
+ assert "StrictHostKeyChecking=yes" in command
164
+ ws.cleanup()
165
+
166
+
167
+ def test_state_without_known_hosts_still_restores(tmp_path: Path) -> None:
168
+ bare = remote(tmp_path)
169
+ ws = stage_repository(str(bare), root=tmp_path / "jobs", job_id="old-state")
170
+ state = ws.to_state()
171
+ del state["known_hosts"] # a 0.4.1 snapshot
172
+ restored = GitRepositoryWorkspace.from_state(state)
173
+ assert restored.known_hosts is None and restored.ssh_key is None
174
+ restored.discard()
175
+
176
+
177
+ @pytest.mark.parametrize("name", ["missing", "with space"])
178
+ def test_bad_known_hosts_rejected_before_cloning(tmp_path: Path, name: str) -> None:
179
+ key = tmp_path / "id_ed25519"
180
+ key.write_text("fake private key")
181
+ pinned = tmp_path / name / "known_hosts"
182
+ if name != "missing":
183
+ pinned.parent.mkdir()
184
+ pinned.write_text("")
185
+ with pytest.raises(StagingError, match="known_hosts"):
186
+ stage_repository(
187
+ "git@host:repo.git",
188
+ root=tmp_path / "jobs",
189
+ job_id="ssh-3",
190
+ ssh_key=key,
191
+ known_hosts=pinned,
192
+ )
193
+ assert not (tmp_path / "jobs" / "ssh-3").exists()
194
+
195
+
123
196
  def test_missing_ssh_key_rejected(tmp_path: Path) -> None:
124
197
  with pytest.raises(StagingError, match="SSH key"):
125
198
  stage_repository(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes