mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
Compare commits
6 Commits
v0.14.1
...
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
|
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
|
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
|
`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.
|
(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"
|
A prompt that *follows* an answer given on the notification — the "why denied"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "plyngent"
|
name = "plyngent"
|
||||||
version = "0.14.1"
|
version = "0.14.2"
|
||||||
description = "LLM chat and agent toolkit"
|
description = "LLM chat and agent toolkit"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
{ name = "HivertMoZara", email = "worldmozara@163.com" },
|
||||||
|
|||||||
@@ -232,6 +232,11 @@ def _decide_continue_limit(reason: str) -> bool:
|
|||||||
else:
|
else:
|
||||||
# Same read as the timed confirms: stdin or the notification,
|
# Same read as the timed confirms: stdin or the notification,
|
||||||
# whichever answers first, with the terminal's default on silence.
|
# 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()
|
token = (_read_answer(None, wait) or "").strip().lower()
|
||||||
if not token:
|
if not token:
|
||||||
token = "y"
|
token = "y"
|
||||||
|
|||||||
@@ -445,9 +445,17 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) -
|
|||||||
candidate = root / candidate
|
candidate = root / candidate
|
||||||
resolved = candidate.expanduser().resolve()
|
resolved = candidate.expanduser().resolve()
|
||||||
if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required):
|
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 = (
|
msg = (
|
||||||
f"path escapes workspace root ({root}): {path}; "
|
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"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace"
|
||||||
|
f"{scratch}"
|
||||||
)
|
)
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
# Normalize separators so denylist entries like ``/secrets/`` match on Windows.
|
# 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
|
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:
|
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."""
|
"""``s`` (this chat) is a button, not only a terminal token."""
|
||||||
from pathlib import Path
|
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)
|
_ = 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:
|
def test_access_mode_parse_and_covers() -> None:
|
||||||
assert parse_access_mode(" Read ") is AccessMode.READ
|
assert parse_access_mode(" Read ") is AccessMode.READ
|
||||||
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
assert parse_access_mode("EXEC") is AccessMode.EXEC
|
||||||
|
|||||||
Reference in New Issue
Block a user