pcli-agent 0.1.0__py3-none-any.whl
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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
pcli/scheduler/runner.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Executes one task end-to-end against the headless runtime - the shared
|
|
2
|
+
implementation behind both `pcli run` (cli.py) and a scheduled job firing
|
|
3
|
+
(scheduler/daemon.py). Factored out so the two entry points can't drift:
|
|
4
|
+
both build the same AgentRuntime, load/create the session the same way, run
|
|
5
|
+
the same run_headless_task, and send the same optional Telegram
|
|
6
|
+
notification.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcli.agent.headless import (
|
|
14
|
+
HeadlessTurnResult,
|
|
15
|
+
ProgressCallback,
|
|
16
|
+
new_headless_session,
|
|
17
|
+
run_headless_task,
|
|
18
|
+
)
|
|
19
|
+
from pcli.agent.runtime import build_agent_runtime, build_permission_manager
|
|
20
|
+
from pcli.config.settings import Settings
|
|
21
|
+
from pcli.session.store import SessionNotFoundError, SessionStore
|
|
22
|
+
from pcli.telegram.bot import notify_telegram
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScheduledSessionNotFoundError(Exception):
|
|
26
|
+
"""Raised when a job's session_id no longer resolves to a real session
|
|
27
|
+
(e.g. it was deleted via `pcli sessions`) - the caller decides whether
|
|
28
|
+
that's fatal for this run or should fall back to a fresh session."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def run_task_once(
|
|
32
|
+
task: str,
|
|
33
|
+
*,
|
|
34
|
+
settings: Settings,
|
|
35
|
+
store: SessionStore,
|
|
36
|
+
cwd: Path,
|
|
37
|
+
session_id: str | None = None,
|
|
38
|
+
quiet: bool = True,
|
|
39
|
+
headed: bool = False,
|
|
40
|
+
notify_telegram_flag: bool = False,
|
|
41
|
+
on_progress: ProgressCallback = lambda _line: None,
|
|
42
|
+
) -> HeadlessTurnResult:
|
|
43
|
+
"""Builds a fresh AgentRuntime, loads/creates the session, runs the
|
|
44
|
+
task, optionally notifies Telegram, and cleanly closes the runtime -
|
|
45
|
+
the same sequence `pcli run` has always done, now also used by a fired
|
|
46
|
+
scheduled job. Raises ScheduledSessionNotFoundError if session_id is
|
|
47
|
+
given but doesn't resolve (the runtime is still cleaned up first)."""
|
|
48
|
+
if session_id:
|
|
49
|
+
try:
|
|
50
|
+
session = store.load(session_id)
|
|
51
|
+
except SessionNotFoundError:
|
|
52
|
+
raise ScheduledSessionNotFoundError(session_id) from None
|
|
53
|
+
else:
|
|
54
|
+
session = new_headless_session(store, settings, cwd)
|
|
55
|
+
|
|
56
|
+
runtime = await build_agent_runtime(settings, cwd, browser_headless=not headed)
|
|
57
|
+
try:
|
|
58
|
+
result = await run_headless_task(
|
|
59
|
+
task,
|
|
60
|
+
session=session,
|
|
61
|
+
runtime=runtime,
|
|
62
|
+
settings=settings,
|
|
63
|
+
permission_manager=build_permission_manager(settings),
|
|
64
|
+
cwd=cwd,
|
|
65
|
+
store=store,
|
|
66
|
+
on_progress=on_progress,
|
|
67
|
+
)
|
|
68
|
+
finally:
|
|
69
|
+
await runtime.client.aclose()
|
|
70
|
+
await runtime.browser_session.close()
|
|
71
|
+
|
|
72
|
+
if notify_telegram_flag:
|
|
73
|
+
if not settings.is_telegram_configured():
|
|
74
|
+
on_progress(
|
|
75
|
+
"notify_telegram was requested but telegram_bot_token/telegram_chat_id aren't "
|
|
76
|
+
"configured - skipping the notification."
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
try:
|
|
80
|
+
await notify_telegram(settings, result.final_text)
|
|
81
|
+
except Exception as exc: # noqa: BLE001 - a failed notification shouldn't fail the run
|
|
82
|
+
on_progress(f"Failed to send the Telegram notification: {exc}")
|
|
83
|
+
|
|
84
|
+
return result
|
pcli/scheduler/store.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""On-disk persistence for `pcli schedule`'s job list (scheduler/models.py) -
|
|
2
|
+
mirrors memory/store.py's shape (a single JSON file under data_dir(),
|
|
3
|
+
atomic-written, loaded fresh on every call rather than cached) so `pcli
|
|
4
|
+
schedule add` from one process and `pcli schedule run`'s daemon loop in
|
|
5
|
+
another always see each other's latest writes without needing to share
|
|
6
|
+
in-memory state or restart anything."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcli.config.paths import schedule_file
|
|
14
|
+
from pcli.scheduler.models import ScheduleJob, ScheduleStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
20
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
21
|
+
os.replace(tmp_path, path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_schedule() -> ScheduleStore:
|
|
25
|
+
path = schedule_file()
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return ScheduleStore()
|
|
28
|
+
return ScheduleStore.model_validate_json(path.read_text(encoding="utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_schedule(store: ScheduleStore) -> None:
|
|
32
|
+
_atomic_write(schedule_file(), store.model_dump_json(indent=2))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def add_job(job: ScheduleJob) -> ScheduleJob:
|
|
36
|
+
store = read_schedule()
|
|
37
|
+
store.jobs.append(job)
|
|
38
|
+
write_schedule(store)
|
|
39
|
+
return job
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def remove_job(job_id: str) -> bool:
|
|
43
|
+
"""Returns whether anything was removed."""
|
|
44
|
+
store = read_schedule()
|
|
45
|
+
before = len(store.jobs)
|
|
46
|
+
store.jobs = [j for j in store.jobs if j.id != job_id]
|
|
47
|
+
if len(store.jobs) == before:
|
|
48
|
+
return False
|
|
49
|
+
write_schedule(store)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_job(job_id: str) -> ScheduleJob | None:
|
|
54
|
+
return next((j for j in read_schedule().jobs if j.id == job_id), None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_job_enabled(job_id: str, enabled: bool) -> bool:
|
|
58
|
+
"""Returns whether a matching job was found and updated."""
|
|
59
|
+
store = read_schedule()
|
|
60
|
+
for job in store.jobs:
|
|
61
|
+
if job.id == job_id:
|
|
62
|
+
job.enabled = enabled
|
|
63
|
+
write_schedule(store)
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def update_job(job: ScheduleJob) -> None:
|
|
69
|
+
"""Replaces the stored job with the same id as `job` - used to persist
|
|
70
|
+
daemon-side bookkeeping (next_run_at/last_run_at/last_status/last_error)
|
|
71
|
+
after a run, without the caller needing to hand-roll the read/mutate/
|
|
72
|
+
write cycle itself."""
|
|
73
|
+
store = read_schedule()
|
|
74
|
+
store.jobs = [job if j.id == job.id else j for j in store.jobs]
|
|
75
|
+
write_schedule(store)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Signature computation for the two event-based ScheduleJob trigger kinds
|
|
2
|
+
(scheduler/models.py's "file_change"/"git_commit") - both are polled by
|
|
3
|
+
scheduler/daemon.py exactly like a cron job's next_run_at, just comparing a
|
|
4
|
+
cheap signature instead of a timestamp. No new dependency: file signatures
|
|
5
|
+
are plain os.stat() calls (no file contents read, binary-safe), and git
|
|
6
|
+
commit lookups just shell out to `git rev-parse` - there's nothing here a
|
|
7
|
+
file-watching library or GitPython would meaningfully simplify for a single
|
|
8
|
+
poll-driven check."""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_MISSING_PATH_SENTINEL = "missing"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_path_signature(path: Path) -> str:
|
|
23
|
+
"""A sha256 hex digest over the sorted (relative_path, mtime_ns, size)
|
|
24
|
+
of every file under `path` (or just `path` itself if it's a file) -
|
|
25
|
+
changes whenever a watched file is added, removed, or modified,
|
|
26
|
+
without reading any file's actual contents. Any path component named
|
|
27
|
+
".git" is skipped, so watching a repo's working tree doesn't fire on
|
|
28
|
+
the repo's own internal bookkeeping. A path that doesn't exist yet (or
|
|
29
|
+
no longer exists) gets a constant sentinel signature, so "still
|
|
30
|
+
missing" is never treated as a change - only the path reappearing is."""
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return _MISSING_PATH_SENTINEL
|
|
33
|
+
|
|
34
|
+
if path.is_file():
|
|
35
|
+
files = [path]
|
|
36
|
+
else:
|
|
37
|
+
files = sorted(p for p in path.rglob("*") if p.is_file() and ".git" not in p.parts)
|
|
38
|
+
|
|
39
|
+
entries = []
|
|
40
|
+
for file_path in files:
|
|
41
|
+
try:
|
|
42
|
+
stat = file_path.stat()
|
|
43
|
+
except OSError:
|
|
44
|
+
# Deleted between the rglob listing and this stat() call -
|
|
45
|
+
# treat as simply absent from this signature, not an error.
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
relative = file_path.relative_to(path)
|
|
49
|
+
except ValueError:
|
|
50
|
+
relative = file_path
|
|
51
|
+
entries.append(f"{relative.as_posix()}:{stat.st_mtime_ns}:{stat.st_size}")
|
|
52
|
+
|
|
53
|
+
digest = hashlib.sha256("\n".join(sorted(entries)).encode("utf-8"))
|
|
54
|
+
return digest.hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_git_head_sha(repo_path: Path, branch: str | None) -> str | None:
|
|
58
|
+
"""Runs `git rev-parse {branch or 'HEAD'}` in repo_path. Returns None
|
|
59
|
+
(logged as a warning, not raised) if git isn't installed, repo_path
|
|
60
|
+
isn't a git repo, or branch doesn't exist - the daemon just skips that
|
|
61
|
+
job for this poll rather than marking it an error, since this is
|
|
62
|
+
typically a transient/config issue that resolves itself (e.g. the repo
|
|
63
|
+
hasn't been cloned yet) rather than a real job failure."""
|
|
64
|
+
ref = branch or "HEAD"
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["git", "rev-parse", ref],
|
|
68
|
+
cwd=repo_path,
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
timeout=10,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
75
|
+
logger.warning("Couldn't run git in %s: %s", repo_path, exc)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
logger.warning(
|
|
80
|
+
"git rev-parse %s failed in %s: %s", ref, repo_path, result.stderr.strip()
|
|
81
|
+
)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
return result.stdout.strip()
|
pcli/session/__init__.py
ADDED
|
File without changes
|
pcli/session/audit.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Tamper-evident audit log: a hash-chained record of permission decisions
|
|
2
|
+
and tool calls, kept alongside - not instead of - Session.tool_invocations/
|
|
3
|
+
permission_grants. Opt-in (Settings.audit_mode_enabled), for regulated-
|
|
4
|
+
industry use where a user needs to prove, after the fact, what an
|
|
5
|
+
unattended agent actually did and that the record wasn't edited later.
|
|
6
|
+
|
|
7
|
+
The chain primitive is deliberately simple: each entry's hash covers its
|
|
8
|
+
own content plus the previous entry's hash, same as a git commit or a
|
|
9
|
+
certificate-transparency log. No new dependency (stdlib hashlib/json
|
|
10
|
+
only), no signing/external timestamping service - that would be real
|
|
11
|
+
complexity for marginal benefit over what a plain SHA-256 chain already
|
|
12
|
+
gives: editing, reordering, or deleting an old entry breaks the chain from
|
|
13
|
+
that point forward, and the break can't be silently repaired without
|
|
14
|
+
re-deriving every hash after it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
from pcli.util.ids import new_id
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from pcli.session.models import Session
|
|
31
|
+
|
|
32
|
+
_GENESIS_HASH = "0" * 64
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _utcnow() -> datetime:
|
|
36
|
+
return datetime.now(UTC)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuditEntry(BaseModel):
|
|
40
|
+
id: str = Field(default_factory=lambda: new_id("audit_"))
|
|
41
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
42
|
+
kind: Literal["tool_call", "permission_decision"]
|
|
43
|
+
summary: str
|
|
44
|
+
"""Human-readable one-liner, e.g. 'read_file allowed' or 'run_shell
|
|
45
|
+
denied: command matched denylist entry'."""
|
|
46
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
"""kind-specific structured payload - see append_audit_entry's callers
|
|
48
|
+
(agent/runtime.py's record_tool_invocation, permissions/manager.py's
|
|
49
|
+
check_with_reason) for the exact fields each kind carries."""
|
|
50
|
+
prev_hash: str
|
|
51
|
+
"""The previous entry's entry_hash, or _GENESIS_HASH for the first
|
|
52
|
+
entry in a session's chain."""
|
|
53
|
+
entry_hash: str
|
|
54
|
+
"""sha256 over a canonical (sort_keys=True) JSON serialization of
|
|
55
|
+
every field above except this one - see _compute_hash."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AuditVerification:
|
|
60
|
+
valid: bool
|
|
61
|
+
entry_count: int
|
|
62
|
+
broken_at_index: int | None = None
|
|
63
|
+
reason: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _hashable_payload(entry: AuditEntry) -> dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"id": entry.id,
|
|
69
|
+
"created_at": entry.created_at.isoformat(),
|
|
70
|
+
"kind": entry.kind,
|
|
71
|
+
"summary": entry.summary,
|
|
72
|
+
"detail": entry.detail,
|
|
73
|
+
"prev_hash": entry.prev_hash,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _compute_hash(payload: dict[str, Any]) -> str:
|
|
78
|
+
canonical = json.dumps(payload, sort_keys=True, default=str)
|
|
79
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def append_audit_entry(
|
|
83
|
+
session: Session, *, kind: Literal["tool_call", "permission_decision"], summary: str, detail: dict[str, Any]
|
|
84
|
+
) -> AuditEntry:
|
|
85
|
+
"""Appends one entry to session.audit_log, chained onto whatever's
|
|
86
|
+
already there. Callers gate this behind Settings.audit_mode_enabled
|
|
87
|
+
themselves (agent/runtime.py's record_tool_invocation, permissions/
|
|
88
|
+
manager.py's PermissionManager) - this function always appends
|
|
89
|
+
unconditionally when called, same as Session.decisions/
|
|
90
|
+
tool_invocations never gate themselves either."""
|
|
91
|
+
prev_hash = session.audit_log[-1].entry_hash if session.audit_log else _GENESIS_HASH
|
|
92
|
+
entry = AuditEntry(kind=kind, summary=summary, detail=detail, prev_hash=prev_hash, entry_hash="")
|
|
93
|
+
entry.entry_hash = _compute_hash(_hashable_payload(entry))
|
|
94
|
+
session.audit_log.append(entry)
|
|
95
|
+
return entry
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def verify_audit_chain(session: Session) -> AuditVerification:
|
|
99
|
+
"""Recomputes every entry's hash from its recorded fields and checks
|
|
100
|
+
prev_hash linkage - an empty log is valid (nothing to verify, not an
|
|
101
|
+
error: audit_mode_enabled may simply have been off for this session)."""
|
|
102
|
+
expected_prev = _GENESIS_HASH
|
|
103
|
+
for index, entry in enumerate(session.audit_log):
|
|
104
|
+
if entry.prev_hash != expected_prev:
|
|
105
|
+
return AuditVerification(
|
|
106
|
+
valid=False,
|
|
107
|
+
entry_count=len(session.audit_log),
|
|
108
|
+
broken_at_index=index,
|
|
109
|
+
reason="prev_hash does not match the preceding entry's hash - an entry was "
|
|
110
|
+
"likely reordered or deleted",
|
|
111
|
+
)
|
|
112
|
+
recomputed = _compute_hash(_hashable_payload(entry))
|
|
113
|
+
if recomputed != entry.entry_hash:
|
|
114
|
+
return AuditVerification(
|
|
115
|
+
valid=False,
|
|
116
|
+
entry_count=len(session.audit_log),
|
|
117
|
+
broken_at_index=index,
|
|
118
|
+
reason="entry_hash does not match the recomputed hash - this entry's content "
|
|
119
|
+
"was modified after it was recorded",
|
|
120
|
+
)
|
|
121
|
+
expected_prev = entry.entry_hash
|
|
122
|
+
return AuditVerification(valid=True, entry_count=len(session.audit_log))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Warns (never blocks) when a session is resumed from a different directory
|
|
2
|
+
than where it was created - every relative path in its tool-call history
|
|
3
|
+
resolves against that original directory, so a mismatch is worth surfacing
|
|
4
|
+
before continuing, not silently ignoring. Deliberately its own tiny module
|
|
5
|
+
(not a method on Session) so both the TUI's session picker and the CLI's
|
|
6
|
+
--resume flag can share the exact same check without depending on Textual
|
|
7
|
+
or Typer."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcli.session.models import Session
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def directory_mismatch(session: Session, cwd: Path) -> str | None:
|
|
17
|
+
"""None if there's nothing to warn about (no recorded working_dir, or it
|
|
18
|
+
matches cwd); otherwise a human-readable warning message."""
|
|
19
|
+
if not session.working_dir:
|
|
20
|
+
return None
|
|
21
|
+
stored = Path(session.working_dir)
|
|
22
|
+
if stored.expanduser().resolve() == cwd.expanduser().resolve():
|
|
23
|
+
return None
|
|
24
|
+
return (
|
|
25
|
+
f"This session was last run in {session.working_dir}, but you're currently in "
|
|
26
|
+
f"{cwd}. Relative paths in its history (and any new tool calls) will resolve "
|
|
27
|
+
"against the current directory, not the original one."
|
|
28
|
+
)
|
pcli/session/export.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exports a session to a portable, self-contained .pcli-session.json[.gz] file.
|
|
2
|
+
|
|
3
|
+
The gateway API key is never part of the Session model in the first place, so
|
|
4
|
+
there is nothing to redact there. `permission_grants` travels with the export
|
|
5
|
+
for fidelity, but importer.py drops it by default (see importer.py docstring)
|
|
6
|
+
so an imported session can't silently grant shell access on the new machine.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import gzip
|
|
12
|
+
import json
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pcli import __version__
|
|
17
|
+
from pcli.session.models import Session
|
|
18
|
+
from pcli.session.store import SessionStore
|
|
19
|
+
|
|
20
|
+
FORMAT_NAME = "pcli-session"
|
|
21
|
+
FORMAT_VERSION = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def export_session(
|
|
25
|
+
session: Session,
|
|
26
|
+
out_path: Path,
|
|
27
|
+
*,
|
|
28
|
+
store: SessionStore | None = None,
|
|
29
|
+
use_gzip: bool | None = None,
|
|
30
|
+
) -> Path:
|
|
31
|
+
store = store or SessionStore()
|
|
32
|
+
if use_gzip is None:
|
|
33
|
+
use_gzip = out_path.suffix == ".gz"
|
|
34
|
+
|
|
35
|
+
blobs: dict[str, str] = {}
|
|
36
|
+
for invocation in session.tool_invocations:
|
|
37
|
+
if invocation.full_result_ref:
|
|
38
|
+
blobs[invocation.full_result_ref] = store.read_blob(
|
|
39
|
+
session.id, invocation.full_result_ref
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
envelope = {
|
|
43
|
+
"format": FORMAT_NAME,
|
|
44
|
+
"format_version": FORMAT_VERSION,
|
|
45
|
+
"exported_at": datetime.now(UTC).isoformat(),
|
|
46
|
+
"pcli_version": __version__,
|
|
47
|
+
"session": json.loads(session.model_dump_json()),
|
|
48
|
+
"blobs": blobs,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
body = json.dumps(envelope, indent=2).encode("utf-8")
|
|
52
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
if use_gzip:
|
|
54
|
+
out_path.write_bytes(gzip.compress(body))
|
|
55
|
+
else:
|
|
56
|
+
out_path.write_bytes(body)
|
|
57
|
+
return out_path
|
pcli/session/importer.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Imports a session exported by session/export.py.
|
|
2
|
+
|
|
3
|
+
Three deliberate safety choices:
|
|
4
|
+
- `permission_grants` is dropped unless `restore_grants=True` is passed
|
|
5
|
+
explicitly, so an imported session can't silently carry "always allow
|
|
6
|
+
shell" grants onto a new machine.
|
|
7
|
+
- The session always gets a *new* id on import (the original id is kept in
|
|
8
|
+
metadata for traceability) so importing never collides with / overwrites
|
|
9
|
+
an existing local session.
|
|
10
|
+
- `working_dir` is always cleared: it names a directory on whatever machine
|
|
11
|
+
originally created the session, essentially never the importing one, so
|
|
12
|
+
keeping it would just make every resume trip the directory-mismatch
|
|
13
|
+
warning (session/directory_check.py) for no reason - export/import is a
|
|
14
|
+
deliberate move to a new place, not an accidental one.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import gzip
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pcli.session.export import FORMAT_NAME, FORMAT_VERSION
|
|
26
|
+
from pcli.session.models import Session
|
|
27
|
+
from pcli.session.store import SessionStore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionImportError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Each entry migrates a raw session dict from its key version to key+1.
|
|
35
|
+
# e.g. {1: _migrate_v1_to_v2} once format_version 2 exists.
|
|
36
|
+
_MIGRATIONS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_envelope(path: Path) -> dict[str, Any]:
|
|
40
|
+
raw = path.read_bytes()
|
|
41
|
+
if raw[:2] == b"\x1f\x8b": # gzip magic bytes
|
|
42
|
+
raw = gzip.decompress(raw)
|
|
43
|
+
return json.loads(raw.decode("utf-8"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def import_session(
|
|
47
|
+
path: Path,
|
|
48
|
+
*,
|
|
49
|
+
store: SessionStore | None = None,
|
|
50
|
+
restore_grants: bool = False,
|
|
51
|
+
) -> Session:
|
|
52
|
+
store = store or SessionStore()
|
|
53
|
+
envelope = _read_envelope(path)
|
|
54
|
+
|
|
55
|
+
if envelope.get("format") != FORMAT_NAME:
|
|
56
|
+
raise SessionImportError(f"Not a pcli session export: {path}")
|
|
57
|
+
|
|
58
|
+
version = envelope.get("format_version")
|
|
59
|
+
if not isinstance(version, int):
|
|
60
|
+
raise SessionImportError(f"Missing/invalid format_version in {path}")
|
|
61
|
+
if version > FORMAT_VERSION:
|
|
62
|
+
raise SessionImportError(
|
|
63
|
+
f"{path} was exported by a newer pcli (format_version={version}); "
|
|
64
|
+
f"this pcli supports up to {FORMAT_VERSION}. Upgrade pcli to import it."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
session_data = envelope["session"]
|
|
68
|
+
while version < FORMAT_VERSION:
|
|
69
|
+
migrate = _MIGRATIONS.get(version)
|
|
70
|
+
if migrate is None:
|
|
71
|
+
raise SessionImportError(f"No migration registered from format_version {version}")
|
|
72
|
+
session_data = migrate(session_data)
|
|
73
|
+
version += 1
|
|
74
|
+
|
|
75
|
+
original_id = session_data.pop("id", None)
|
|
76
|
+
if not restore_grants:
|
|
77
|
+
session_data["permission_grants"] = []
|
|
78
|
+
session_data["working_dir"] = None
|
|
79
|
+
|
|
80
|
+
session = Session.model_validate(session_data)
|
|
81
|
+
session.metadata["imported_from_id"] = original_id
|
|
82
|
+
session.metadata["imported_from_file"] = str(path)
|
|
83
|
+
|
|
84
|
+
blobs: dict[str, str] = envelope.get("blobs", {})
|
|
85
|
+
for invocation in session.tool_invocations:
|
|
86
|
+
if invocation.full_result_ref and invocation.full_result_ref in blobs:
|
|
87
|
+
store.write_blob_named(
|
|
88
|
+
session.id, invocation.full_result_ref, blobs[invocation.full_result_ref]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
store.save(session)
|
|
92
|
+
return session
|