mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
Compare commits
6 Commits
a79039f06f
...
4676b9af32
| Author | SHA1 | Date | |
|---|---|---|---|
| 4676b9af32 | |||
| 865d314234 | |||
| 9139655e54 | |||
| 741d1a8526 | |||
| 44b6ab90ed | |||
| fb77fd676b |
@@ -84,7 +84,9 @@ back through a surface that outlives it.
|
||||
A prompt that can be answered from the notification reads both surfaces while it
|
||||
blocks, from the first moment — the notification itself only shows up after
|
||||
`grace_seconds`, so waiting for it to appear before listening would block on the
|
||||
terminal alone. Typing at the terminal always wins; a readline-only convenience
|
||||
terminal alone. The terminal keeps its own prompt line with the keys it accepts —
|
||||
a button elsewhere does not say what typing here would do. Typing at the terminal
|
||||
always wins; a readline-only convenience
|
||||
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
|
||||
|
||||
A prompt that *follows* an answer given on the notification — the "why denied"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "plyngent"
|
||||
version = "0.14.1"
|
||||
version = "0.14.2"
|
||||
description = "LLM chat and agent toolkit"
|
||||
authors = [
|
||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||
|
||||
@@ -232,6 +232,11 @@ def _decide_continue_limit(reason: str) -> bool:
|
||||
else:
|
||||
# Same read as the timed confirms: stdin or the notification,
|
||||
# whichever answers first, with the terminal's default on silence.
|
||||
# The prompt line still goes to the terminal: the buttons elsewhere
|
||||
# do not say what typing here would do.
|
||||
with contextlib.suppress(OSError):
|
||||
_ = sys.stderr.write(_CONTINUE_PROMPT)
|
||||
_ = sys.stderr.flush()
|
||||
token = (_read_answer(None, wait) or "").strip().lower()
|
||||
if not token:
|
||||
token = "y"
|
||||
|
||||
@@ -445,9 +445,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
|
||||
candidate = root / candidate
|
||||
resolved = candidate.expanduser().resolve()
|
||||
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
||||
# A refused *write* is often a scratch file the model meant to keep in
|
||||
# the workspace: name that way out too, next to asking the human.
|
||||
scratch = (
|
||||
"; if the file is only scratch, call new_temporary_workspace and write under the absolute path it returns"
|
||||
if required is AccessMode.WRITE
|
||||
else ""
|
||||
)
|
||||
msg = (
|
||||
f"path escapes workspace root ({root}): {path}; "
|
||||
f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
|
||||
f"{scratch}"
|
||||
)
|
||||
raise WorkspaceError(msg)
|
||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
||||
|
||||
@@ -396,6 +396,15 @@ def test_a_limit_prompt_can_stop_the_turn_from_the_notification() -> None:
|
||||
assert limits.prompt_continue_limit("hit a wall") is False
|
||||
|
||||
|
||||
def test_a_limit_prompt_still_names_its_answers_at_the_terminal(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Buttons elsewhere do not say what typing here would do."""
|
||||
from plyngent.cli import limits
|
||||
|
||||
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
||||
assert limits.prompt_continue_limit("hit a wall") is True
|
||||
assert "[Y/n/yyy]" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> None:
|
||||
"""``s`` (this chat) is a button, not only a terminal token."""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -50,6 +50,21 @@ def test_escape_error_names_the_mode_to_request(workspace: object, required: Acc
|
||||
_ = resolve_path("../outside", required=required)
|
||||
|
||||
|
||||
def test_escape_error_offers_a_temporary_workspace_for_a_write(workspace: object) -> None:
|
||||
"""A refused write is often scratch, which never needs a grant."""
|
||||
del workspace
|
||||
with pytest.raises(WorkspaceError, match="new_temporary_workspace"):
|
||||
_ = resolve_path("../scratch.txt", required=AccessMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.EXEC])
|
||||
def test_escape_error_hides_the_scratch_hint_from_other_modes(workspace: object, required: AccessMode) -> None:
|
||||
del workspace
|
||||
with pytest.raises(WorkspaceError) as refusal:
|
||||
_ = resolve_path("../scratch.txt", required=required)
|
||||
assert "new_temporary_workspace" not in str(refusal.value)
|
||||
|
||||
|
||||
def test_access_mode_parse_and_covers() -> None:
|
||||
assert parse_access_mode(" Read ") is AccessMode.READ
|
||||
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
||||
|
||||
Reference in New Issue
Block a user