From d06a2a99dce245705fdbe0f8085d38c9250380b5 Mon Sep 17 00:00:00 2001 From: Andy Young Date: Mon, 31 Aug 2026 11:18:13 -0700 Subject: [PATCH] feat(code): add `dcode://` URL scheme support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register dcode as the operating system's handler for `dcode://` links, so a page can offer to open a project or resume a thread in dcode. `dcode url install` writes a user-scoped handler per platform: an AppleScript applet in `~/Applications` on macOS, an XDG desktop entry on Linux, and the `HKCU` protocol key on Windows. `uninstall` and `status` complete the group, and `dcode url open ` is the verb a registration points at. Registration is opt-in and never happens on package install, since claiming a URL scheme changes how the user's browser behaves. `url_scheme.request` is the trust boundary: one action, a closed parameter set (`dir`, `thread`, `agent`, `prompt`), and refusal β€” rather than silent repair β€” of unknown or repeated keys, non-UUID thread ids, relative or absent directories, and text carrying control characters or deceptive Unicode. `url_scheme.handler` then shows the whole request in a terminal and waits for an explicit approval with the cancel row preselected, because a browser's own prompt names an application rather than a request and can be remembered away. The launch argv holds only `-a`, `-r`, and `-m`, so a link cannot alter the session's approval mode, model, or sandbox. Co-Authored-By: Claude Opus 5 (1M context) --- libs/code/README.md | 19 + libs/code/THREAT_MODEL.md | 16 + .../deepagents_code/client/commands/url.py | 365 ++++++++++ libs/code/deepagents_code/main.py | 12 + libs/code/deepagents_code/ui.py | 149 ++++ .../deepagents_code/url_scheme/__init__.py | 65 ++ .../code/deepagents_code/url_scheme/_linux.py | 446 ++++++++++++ .../code/deepagents_code/url_scheme/_macos.py | 514 ++++++++++++++ .../deepagents_code/url_scheme/_windows.py | 214 ++++++ .../deepagents_code/url_scheme/handler.py | 385 +++++++++++ .../url_scheme/registration.py | 283 ++++++++ .../deepagents_code/url_scheme/request.py | 443 ++++++++++++ libs/code/pyproject.toml | 12 + libs/code/tests/unit_tests/test_url_scheme.py | 647 ++++++++++++++++++ 14 files changed, 3570 insertions(+) create mode 100644 libs/code/deepagents_code/client/commands/url.py create mode 100644 libs/code/deepagents_code/url_scheme/__init__.py create mode 100644 libs/code/deepagents_code/url_scheme/_linux.py create mode 100644 libs/code/deepagents_code/url_scheme/_macos.py create mode 100644 libs/code/deepagents_code/url_scheme/_windows.py create mode 100644 libs/code/deepagents_code/url_scheme/handler.py create mode 100644 libs/code/deepagents_code/url_scheme/registration.py create mode 100644 libs/code/deepagents_code/url_scheme/request.py create mode 100644 libs/code/tests/unit_tests/test_url_scheme.py diff --git a/libs/code/README.md b/libs/code/README.md index b80829e091..a7addb8679 100644 --- a/libs/code/README.md +++ b/libs/code/README.md @@ -42,6 +42,25 @@ The fastest way to start using Deep Agents. `deepagents-code` is a pre-built cod - **Headless mode** β€” run non-interactively for scripting and CI - **Human-in-the-loop** β€” approve or reject tool calls before execution +## πŸ”— Open from the browser + +Register `dcode` as the handler for `dcode://` links, and a page can offer to open a project or resume a thread in dcode: + +```bash +dcode url install # register the handler (per-user, no sudo) +dcode url status # see what your system does with dcode:// links +dcode url uninstall # give the scheme back +``` + +```text +dcode://open?dir=/path/to/project +dcode://open?dir=~/proj&agent=research +dcode://open?dir=~/proj&thread= +dcode://open?dir=~/proj&prompt=review%20the%20diff +``` + +Two gates stand between a link and a session. Your browser asks whether to hand the link to dcode; then dcode opens a terminal, shows the whole request β€” directory, agent, thread, and the prompt text in full β€” and waits for you to approve it, with "Cancel" preselected. Nothing is skippable: a link cannot change the session's approval mode, model, or sandbox, and unknown parameters are refused rather than ignored. + ## πŸ”’ Security model By default, `dcode` trusts the directory you run it in. Human-in-the-loop approval gates model-requested tool calls, but project artifacts are read before any approval prompt. diff --git a/libs/code/THREAT_MODEL.md b/libs/code/THREAT_MODEL.md index c8c9a5956a..68a52ea9c1 100644 --- a/libs/code/THREAT_MODEL.md +++ b/libs/code/THREAT_MODEL.md @@ -232,6 +232,7 @@ | TB12 | Goal/Rubric State β†’ Model Context | Persisted user- and agent-controlled goal state becomes a synthetic `HumanMessage` in a primary-model request | State projection, lifecycle filtering, notice fingerprinting, raw-character limits, HTML escaping of boundary tags | Natural-language instructions, sensitivity, post-escape size, and provider-specific byte/token budgets | | TB13 | Managed Config β†’ Runtime | A fixed administrator-deployed TOML file overrides CLI, environment, and user preferences | Fixed non-redirectable path; the CLI never writes the file; fail-closed startup for every command except diagnostics; typed resolution; model-policy checks before credentials/imports/construction; diagnostics remain available | Filesystem ownership/mode and privileged deployment are outside the CLI; a host administrator can weaken or strengthen policy | | TB14 | Launch Env β†’ User Trust Root | Inherited `DEEPAGENTS_HOME` selects the profile whose config, credentials, and user MCP file are trusted | Captured and normalized once before dotenv loading; absolute/`~/` validation; denied from every dotenv layer; propagated unchanged to the server | A user can deliberately select a profile inside or above a checkout; only the exact profile `.mcp.json` receives user provenance | +| TB15 | Browser / OS URL Dispatch β†’ dcode Launch | Where a `dcode://` link published by a web page becomes a local dcode launch, after the desktop hands it to the registered handler | Explicit opt-in registration (`dcode url install`, never on package install); one action and a closed parameter set (`url_scheme.request`); refusal of unknown, repeated, or session-altering parameters; refusal of control characters and deceptive Unicode rather than stripping; UUID-only thread ids; existing-absolute-directory-only paths; an unconditional, cancel-first in-terminal confirmation showing the whole request (`url_scheme.handler`); a launch argv carrying no approval, model, or sandbox flags | Link content β€” any page, message, or email can emit one; whether the browser still prompts (browsers offer to remember the answer); which application the desktop binds to the scheme | ### Boundary Details @@ -390,10 +391,16 @@ | DF27 | C18 Server Offload Boundary | C8 Sessions | Checkpoint message read; summarization event and additive cost update (never a messages write) | DC2 | None | In-process LangGraph SDK | | DF28 | User / Host FS | C19 Goal/Rubric State Notice | Goal objective, criteria, and status notes; `/rubric file` content | DC2 | TB1, TB12 | TUI command + local file read + checkpoint update | | DF29 | C19 Goal/Rubric State Notice | External LLM | Synthetic user-role message containing actionable objective, active criteria, and status note | DC2 | TB12, TB7 | LangChain model request over configured provider transport | +| DF30 | External web page | C2 TUI (via `url_scheme.handler`) | `dcode://open` link: target directory, agent name, thread id, first-message prompt text | β€” | TB15, TB1 | OS URL dispatch β†’ registered handler argv β†’ user approval β†’ `execv` | | DF30 | Administrator | C9 Config | Managed TOML policy | DC1 | TB13 | Fixed local file read | ### Flow Details +#### TB15: Browser / OS URL Dispatch β†’ dcode Launch + +- **Inside**: `url_scheme.registration` claims the scheme only when the user runs `dcode url install`; nothing on the package-install path registers a handler. `url_scheme.request.parse_open_url` admits one action (`open`) and a closed parameter set (`dir`, `thread`, `agent`, `prompt`), refusing unknown or repeated keys, non-UUID thread ids (which also keeps the `-r __MOST_RECENT__` sentinel unreachable), non-absolute or non-existent directories, and prompt text carrying control characters or deceptive Unicode. `url_scheme.handler._confirm` prints the resolved directory, agent, thread, and full prompt and requires an explicit approval with the cancel row preselected; it fails closed on a non-interactive terminal, an interrupt, or an unavailable picker. `url_scheme.handler._launch` builds the argv from validated fields only β€” `-a`, `-r`, `-m` β€” so no approval, model, or sandbox flag can originate in a link. Per-platform command construction avoids shell interpolation: the desktop entry's `%u` and the Windows `"%1"` expand into an argument vector, and the macOS applet quotes the dcode path with `shlex.quote` at build time and the link with AppleScript's `quoted form of` at dispatch time. +- **Outside**: Link content β€” any page, message, or email can emit a `dcode://` link. Whether the browser prompts at all, since browsers offer to remember the answer. Which application the desktop ultimately binds to the scheme. What the opened project's files then do, which is TB5/TB4/TB11's subject: opening a directory does not trust it, and project hooks, MCP servers, and extensions still face their own trust prompts. + #### DF8/DF9: External Web Content β†’ Agent Context - **Data**: Arbitrary HTML/JSON from the internet, converted to markdown by `markdownify`. Can be megabytes. @@ -458,6 +465,7 @@ | T15 | DF28, DF29 | DC2 | Stored prompt injection through a goal, rubric, or status note influences later primary-model tool requests | TB12 | Medium | Likely | `goal_state_notice.build_goal_state_notice`, `goal_tools.GoalToolsMiddleware._request_with_goal_notice` | | T16 | DF28, DF29 | DC2 | Sensitive local-file content, up to the 12,000-character rubric limit, is automatically persisted and transmitted to the configured model provider as rubric criteria | TB12 | Medium | Verified | `app.DeepAgentsApp._set_rubric_from_file`, `goal_state_notice.build_goal_state_notice` | | T17 | DF28, DF29 | DC2 | Character-bounded goal/rubric/status-note text can still exceed provider context budgets after escaping or tokenization | TB12 | Medium | Verified | `goal_state_limits`, `goal_state_notice.build_goal_state_notice`, `goal_tools.GoalToolsMiddleware._request_with_goal_notice` | +| T18 | DF30 | β€” | A web page emits a `dcode://open` link that starts a session in an attacker-chosen directory, or submits attacker-written prompt text as the session's first message | TB15 | Medium | Verified | `url_scheme.request.parse_open_url`, `url_scheme.handler._confirm`, `url_scheme.handler._launch` | ### Threat Details @@ -485,6 +493,12 @@ - **Description**: Direct, file-loaded, generated, and tool-authored goal-state paths enforce raw-character limits before persistence or notice construction. HTML escaping happens afterward and can expand the rendered notice (for example, `&` becomes `&`), while provider tokenization and available context budgets vary. The middleware restores or re-pins the current notice after compaction. A valid near-limit notice therefore remains recurring model-request overhead. This increases spend. It can also contribute to a provider context-limit failure. - **Preconditions**: (1) A user, file, or model-supplied status note produces a valid near-limit notice; (2) its escaped or tokenized representation is large relative to the configured provider's available context; (3) the corresponding goal or rubric remains model-visible. +#### T18: Attacker-Chosen Session Target or First Message via a `dcode://` Link + +- **Flow**: DF30 (web page link β†’ OS URL dispatch β†’ registered handler β†’ user approval β†’ session launch) +- **Description**: With the handler registered, any page the user visits can emit `dcode://open?...`. Two consequences are worth separating. Opening a session in an attacker-named directory brings that directory's project artifacts into a launch, which is the same exposure as running `dcode` there by hand (see TB5, TB4, TB11) rather than a new one. Submitting a `prompt` is the sharper case: the text becomes the session's first message, so it reaches the model as though the user had typed it, and the model may request tool calls on the strength of it. The browser's own prompt does not cover either case, because it names an application rather than a request and browsers offer to remember the answer. +- **Preconditions**: (1) The user has registered the handler with `dcode url install`; (2) the user follows an attacker-supplied link and lets the browser hand it to dcode; (3) the user approves dcode's own confirmation, which shows the resolved directory and the entire prompt text with the cancel row preselected; (4) for the prompt case, the model then requests an action and the user approves that too, since a link cannot alter the session's approval mode. + #### T2: Shell Allow-List Bypass via `SHELL_ALLOW_ALL` - **Flow**: DF7 (LLM tool call) β†’ C4 Tools (execute) @@ -578,6 +592,7 @@ | Input Source | Data Flows | Threats | Validation Points | Responsibility | Gaps | |-----------------------|-----------------------|---------------|----------------------------------------------------------------------------|----------------|----------------------------------------------------------------------------------------------| | User direct input | DF1, DF2 | None (TB1) | None β€” prompts accepted verbatim | User | No content filtering β€” intentional; HITL gates downstream tool calls | +| `dcode://` link | DF30 | T18 | Closed action and parameter set; unknown/repeated key refusal; UUID-only thread ids; absolute existing-directory-only paths; control-character and deceptive-Unicode refusal; length bounds; unconditional cancel-first confirmation showing the whole request; argv restricted to `-a`/`-r`/`-m` | Shared | Prompt text remains untrusted prose once approved; the browser's own prompt can be remembered away, so dcode's confirmation is the only per-request gate | | LLM output | DF6, DF7 | T1, T2, T3, T4, T13, T14| HITL gate; shell allow-list; Unicode/URL warnings on tool args; Auto classifier review | Project | LLM-generated tool args not scanned for injection beyond Unicode/URL; shell allow-list matches only the command's first token, so allow-listed interpreters/wrappers bypass it (T13); Auto classifier review quality follows the user-selected classifier model, and the classifier reads untrusted tool arguments, prior output, and model-authored `ask_user` question text (T14) | | Tool/function results | DF9, DF11 | T1 | Unicode warning on URL args; `markdownify` HTML conversion | Shared | Tool *results* pass to context without prompt-injection scan | | URL-fetched content | DF8, DF9 | T1 | `check_url_safety` on URL arg; HTMLβ†’markdown conversion | Shared | Markup-embedded instructions survive markdownify; no LLM-layer guardrail | @@ -668,3 +683,4 @@ Threats that appear valid in isolation but fall outside project responsibility b | 2026-08-24 | langster-threat-model (diff) | Removed the client-seeded `/offload` fallback. `/offload` is now available only through C18 on built-in servers; local in-process and ACP agents do not support it, and custom or older servers without the route fail at the HTTP boundary. Updated DC5, TB2, TB10, and T6 to remove the client self-approval and synthetic-message attack surface. The server route, hook behavior, archive guard, and state-only persistence controls are unchanged; no new threat was identified. | | 2026-08-24 | manual update | The C18 boundary now strips endpoint/proxy/transport keys (`base_url`, `openai_proxy`, `http_client`, and similar) from client-supplied `model_params` before they reach `config.create_model` (`offload_api._strip_transport_model_params`), closing the credential-redirection consequence of T6 for this route. Client-supplied `model` and behavioral params still flow through; in-process `CLIContextSchema` model params remain trusted and unfiltered | | 2026-08-25 | manual update | Stopped replacing bounded superseded goal-state notices in model requests while retaining bounded same-index stand-ins for oversized legacy notices. Goal/rubric history now remains append-only for prompt-cache stability where safe, and the latest notice explicitly supersedes earlier notices. Updated T15 to record the residual risk that a model can still attend to older bounded goal text until compaction. | +| 2026-08-31 | manual update | Added TB15, DF30, and T18 for the opt-in `dcode://` URL scheme (`dcode url install`). Recorded the boundary's controls: a closed action and parameter set in `url_scheme.request` that refuses unknown, repeated, and session-altering parameters as well as control characters and deceptive Unicode; UUID-only thread ids, which also keep the `-r __MOST_RECENT__` sentinel out of a link's reach; an unconditional cancel-first confirmation in `url_scheme.handler` that shows the resolved directory and the whole prompt and fails closed on a non-interactive terminal; and a launch argv limited to `-a`/`-r`/`-m` so a link cannot change approval mode, model, or sandbox. Noted that per-platform dispatch avoids shell interpolation (`%u`, `"%1"`, and `shlex.quote` plus AppleScript `quoted form of`), and that opening a directory does not trust it β€” project hooks, MCP servers, and extensions still face TB5/TB4/TB11. Added the matching input-source coverage row | diff --git a/libs/code/deepagents_code/client/commands/url.py b/libs/code/deepagents_code/client/commands/url.py new file mode 100644 index 0000000000..689459fbdf --- /dev/null +++ b/libs/code/deepagents_code/client/commands/url.py @@ -0,0 +1,365 @@ +"""The `dcode url` command group: own the `dcode://` scheme, and handle a link. + +`dcode url install` registers dcode as the operating system's handler for +`dcode://` links, so a page can offer to open a project or resume a thread in +dcode. `uninstall` gives the scheme back, and `status` reports what the desktop +currently does with it. + +`dcode url open ` is the verb a registration points at. It is what the +browser ends up running, so it takes the link and nothing else: no approval, +model, or sandbox flags exist on this parser to be smuggled in through a crafted +link, and the confirmation in `url_scheme.handler` cannot be turned off. + +Help rendering for `dcode url -h` and each subcommand is served by the +`ui.show_url_*_help` screens, which do not import this module, so the help path +stays light. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from deepagents_code.output import write_json + +if TYPE_CHECKING: + import argparse + from collections.abc import Callable, Sequence + + from deepagents_code.output import OutputFormat + from deepagents_code.url_scheme import HandlerStatus + +logger = logging.getLogger(__name__) + + +def _lazy_ui_help(fn_name: str) -> Callable[[], None]: + """Return a callable that lazily imports and invokes a `ui` help function. + + Args: + fn_name: Name of the `show_*_help` function to invoke. + + Returns: + The deferred callable. + """ + + def _show() -> None: + from deepagents_code import ui + + getattr(ui, fn_name)() + + return _show + + +def setup_url_parser( + subparsers: Any, # noqa: ANN401 + *, + make_help_action: Callable[[Callable[[], None]], type[argparse.Action]], +) -> None: + """Register the `dcode url` command group. + + Args: + subparsers: The `argparse` subparsers object from the top-level CLI + parser, onto which the `url` command group is attached. + make_help_action: Factory that wraps a `show_*` callable into an + `argparse.Action` so `-h/--help` renders the hand-maintained help + screens from `deepagents_code.ui`. + """ + from deepagents_code.output import add_json_output_arg + + url_parser = subparsers.add_parser( + "url", + help="Manage the dcode:// URL scheme", + add_help=False, + ) + url_parser.add_argument( + "-h", "--help", action=make_help_action(_lazy_ui_help("show_url_help")) + ) + add_json_output_arg(url_parser) + url_sub = url_parser.add_subparsers(dest="url_command") + + install_parser = url_sub.add_parser( + "install", + help="Register dcode as the handler for dcode:// links", + add_help=False, + ) + install_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_url_install_help")), + ) + install_parser.add_argument( + "--terminal", + choices=["auto", "terminal", "iterm"], + default="auto", + help=( + "macOS only: terminal a link opens the session in. " + "'auto' matches the terminal you are running this from." + ), + ) + add_json_output_arg(install_parser) + + uninstall_parser = url_sub.add_parser( + "uninstall", + help="Remove dcode's dcode:// handler", + add_help=False, + ) + uninstall_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_url_uninstall_help")), + ) + add_json_output_arg(uninstall_parser) + + status_parser = url_sub.add_parser( + "status", + help="Show what the system does with dcode:// links", + add_help=False, + ) + status_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_url_status_help")), + ) + add_json_output_arg(status_parser) + + # The verb a registered handler runs. Deliberately the smallest parser in + # the CLI: one positional and a help flag. A browser-supplied link that + # smuggles an extra token onto the command line has nothing to reach. + open_parser = url_sub.add_parser( + "open", + help="Open a dcode:// link (used by the system handler)", + add_help=False, + ) + open_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_url_open_help")), + ) + open_parser.add_argument("url", help="The dcode:// link to open") + + +def run_url_command(args: argparse.Namespace) -> int: + """Dispatch a `dcode url` subcommand. + + Args: + args: Parsed CLI namespace. + + Returns: + Process exit code. + """ + subcommand = getattr(args, "url_command", None) + if subcommand == "install": + return _run_install(args) + if subcommand == "uninstall": + return _run_uninstall(args) + if subcommand == "status": + return _run_status(args) + if subcommand == "open": + return _run_open(args) + + # `cli_main`'s bare-group help fast path handles `dcode url` with no + # subcommand, so this is only reached for an unexpected value. + from deepagents_code import ui + + ui.show_url_help() + return 0 + + +def _run_open(args: argparse.Namespace) -> int: + """Handle one `dcode://` link. + + Args: + args: Parsed CLI namespace. Only `url` is read. + + Returns: + Process exit code. On POSIX an approved link replaces this process with + the session, so this does not return in that case. + """ + from deepagents_code.url_scheme import open_from_url + + return open_from_url(args.url) + + +def _run_install(args: argparse.Namespace) -> int: + """Register dcode as the system's `dcode://` handler. + + Args: + args: Parsed CLI namespace. Reads `output_format` and `terminal`. + + Returns: + `0` when the handler is registered, `1` when registration failed. + """ + from deepagents_code.url_scheme import ( + RegistrationError, + TerminalChoice, + install_handler, + ) + + output_format: OutputFormat = getattr(args, "output_format", "text") + terminal = TerminalChoice(getattr(args, "terminal", "auto")) + try: + status = install_handler(terminal=terminal) + except RegistrationError as exc: + return _emit_error(output_format, command="url install", message=str(exc)) + + return _emit_status( + output_format, + command="url install", + status=status, + message=_install_message(status), + ) + + +def _install_message(status: HandlerStatus) -> str: + """Build the success message for `dcode url install`. + + Args: + status: Status collected after registering. + + Returns: + The message, including a link the user can try. + """ + from deepagents_code.url_scheme import URL_SCHEME, build_open_url + + example = build_open_url("~/your/project") + return ( + f"Registered dcode as the handler for {URL_SCHEME}:// links.\n" + f"{status.detail}\n" + f"Try one: {example}\n" + "Your browser will ask before handing a link to dcode, and dcode asks " + "again before opening it." + ) + + +def _run_uninstall(args: argparse.Namespace) -> int: + """Remove dcode's `dcode://` handler. + + Args: + args: Parsed CLI namespace. Only `output_format` is read. + + Returns: + `0` when nothing is registered any more (including when nothing was), + `1` when removal failed. + """ + from deepagents_code.url_scheme import RegistrationError, uninstall_handler + + output_format: OutputFormat = getattr(args, "output_format", "text") + try: + status, removed = uninstall_handler() + except RegistrationError as exc: + return _emit_error(output_format, command="url uninstall", message=str(exc)) + + return _emit_status( + output_format, + command="url uninstall", + status=status, + message=_uninstall_message(removed), + removed=removed, + ) + + +def _uninstall_message(removed: Sequence[str]) -> str: + """Build the message for `dcode url uninstall`. + + Args: + removed: Artifacts that were removed. + + Returns: + The message. + """ + from deepagents_code.url_scheme import URL_SCHEME + + if not removed: + return f"No dcode {URL_SCHEME}:// handler was installed; nothing to remove." + listed = "\n".join(f" {item}" for item in removed) + return f"Removed dcode's {URL_SCHEME}:// handler:\n{listed}" + + +def _run_status(args: argparse.Namespace) -> int: + """Report what the system does with `dcode://` links. + + Args: + args: Parsed CLI namespace. Only `output_format` is read. + + Returns: + `0` always. Status is a diagnostic: "not installed" is an answer, not a + failure, so scripts read `installed` from `--json` rather than the + exit code. + """ + from deepagents_code.url_scheme import handler_status + + output_format: OutputFormat = getattr(args, "output_format", "text") + status = handler_status() + return _emit_status( + output_format, command="url status", status=status, message=status.detail + ) + + +def _emit_status( + output_format: OutputFormat, + *, + command: str, + status: HandlerStatus, + message: str, + removed: Sequence[str] | None = None, +) -> int: + """Print a handler status as text or JSON. + + Args: + output_format: `"json"` for machine-readable output, else text. + command: Command label for the JSON envelope. + status: Status to report. + message: Human-readable summary for text output. + removed: Artifacts removed, for `uninstall`. + + Returns: + `0`. + """ + if output_format == "json": + payload: dict[str, object] = { + "ok": True, + "scheme": status.scheme, + "platform": status.platform, + "supported": status.supported, + "installed": status.installed, + "handler_path": status.handler_path, + "launcher": None if status.launcher is None else str(status.launcher), + "default_handler": status.default_handler, + "detail": status.detail, + } + if removed is not None: + payload["removed"] = list(removed) + write_json(command, payload) + return 0 + + from deepagents_code.config import console + + console.print() + console.print(message, markup=False, highlight=False) + if status.installed and status.handler_path: + console.print(f"Handler: {status.handler_path}", style="dim", markup=False) + console.print() + return 0 + + +def _emit_error(output_format: OutputFormat, *, command: str, message: str) -> int: + """Print a failure as text or JSON. + + Args: + output_format: `"json"` for machine-readable output, else text. + command: Command label for the JSON envelope. + message: Explanation of what could not be done. + + Returns: + `1`. + """ + if output_format == "json": + write_json(command, {"ok": False, "error": message}) + return 1 + + from deepagents_code.config import console + + console.print() + console.print(message, style="bold red", markup=False, highlight=False) + console.print() + return 1 diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index bc7b494e24..d225f2fbdf 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -2083,6 +2083,7 @@ async def _preload_session_mcp_server_info( "mcp": ("mcp_command", "show_mcp_help"), "auth": ("auth_command", "show_auth_help"), "tools": ("tools_command", "show_tools_help"), + "url": ("url_command", "show_url_help"), } """Maps top-level command names to their startup-fast-path help dispatch. @@ -2150,6 +2151,7 @@ def parse_args() -> argparse.Namespace: from deepagents_code.client.commands.auth import setup_auth_parser from deepagents_code.client.commands.config import setup_config_parser from deepagents_code.client.commands.mcp import setup_mcp_parsers + from deepagents_code.client.commands.url import setup_url_parser from deepagents_code.output import add_json_output_arg from deepagents_code.skills import setup_skills_parser @@ -2298,6 +2300,11 @@ def help_parent(help_fn: Callable[[], None]) -> list[argparse.ArgumentParser]: make_help_action=_make_help_action, ) + setup_url_parser( + subparsers, + make_help_action=_make_help_action, + ) + threads_parser = subparsers.add_parser( "threads", help="Manage conversation threads", @@ -5272,6 +5279,11 @@ def cli_main() -> None: sys.exit(run_tools_command(args)) + if command == "url": + from deepagents_code.client.commands.url import run_url_command + + sys.exit(run_url_command(args)) + if command == "install": from deepagents_code.client.commands.extras import run_install_command diff --git a/libs/code/deepagents_code/ui.py b/libs/code/deepagents_code/ui.py index 98ec264cdf..cdd0d3b5df 100644 --- a/libs/code/deepagents_code/ui.py +++ b/libs/code/deepagents_code/ui.py @@ -146,6 +146,9 @@ def show_help() -> None: console.print( " dcode tools Manage managed tools (ripgrep)" ) + console.print( + " dcode url Manage the dcode:// URL scheme" + ) console.print(" dcode install NAME Install optional extras") console.print( " dcode uninstall NAME Remove an optional extra" @@ -709,6 +712,152 @@ def show_tools_install_help() -> None: console.print() +def show_url_help() -> None: + """Show help information for the `url` subcommand.""" + console.print() + console.print("[bold]Usage:[/bold]", style=theme.PRIMARY) + console.print(" dcode url [options]") + console.print() + console.print( + "Let pages offer to open a project in dcode. With the handler", + ) + console.print( + "registered, a dcode:// link asks your browser to hand it to dcode,", + ) + console.print( + "and dcode shows the request and waits for your approval.", + ) + console.print() + console.print("[bold]Commands:[/bold]", style=theme.PRIMARY) + console.print(" install Register dcode as the dcode:// handler") + console.print(" uninstall Remove dcode's dcode:// handler") + console.print(" status Show what the system does with dcode:// links") + console.print(" open URL Open a link (what the system handler runs)") + console.print() + console.print("[bold]Links:[/bold]", style=theme.PRIMARY) + console.print(" dcode://open?dir=/path/to/project") + console.print(" dcode://open?dir=~/proj&agent=research") + console.print(" dcode://open?dir=~/proj&thread=") + console.print(" dcode://open?dir=~/proj&prompt=review%20the%20diff") + console.print() + _print_option_section() + console.print() + console.print("[bold]Examples:[/bold]", style=theme.PRIMARY) + console.print(" dcode url install") + console.print(" dcode url status --json") + console.print(" dcode url uninstall") + console.print() + + +def show_url_install_help() -> None: + """Show help information for the `url install` subcommand.""" + console.print() + console.print("[bold]Usage:[/bold]", style=theme.PRIMARY) + console.print(" dcode url install [options]") + console.print() + console.print( + "Register dcode as this user's handler for dcode:// links. Needs no", + ) + console.print( + "sudo: it writes an app bundle in ~/Applications on macOS, a desktop", + ) + console.print( + "entry on Linux, or a per-user registry key on Windows. Re-run it to", + ) + console.print( + "update an existing registration, after moving your dcode install.", + ) + console.print() + _print_option_section( + " --terminal CHOICE macOS: auto (default), terminal, or iterm", + ) + console.print() + console.print("[bold]Examples:[/bold]", style=theme.PRIMARY) + console.print(" dcode url install") + console.print(" dcode url install --terminal iterm") + console.print() + + +def show_url_uninstall_help() -> None: + """Show help information for the `url uninstall` subcommand.""" + console.print() + console.print("[bold]Usage:[/bold]", style=theme.PRIMARY) + console.print(" dcode url uninstall [options]") + console.print() + console.print( + "Remove dcode's dcode:// handler. Only artifacts dcode created are", + ) + console.print( + "removed, and removing a handler that is not installed succeeds.", + ) + console.print() + _print_option_section() + console.print() + console.print("[bold]Examples:[/bold]", style=theme.PRIMARY) + console.print(" dcode url uninstall") + console.print(" dcode url uninstall --json") + console.print() + + +def show_url_status_help() -> None: + """Show help information for the `url status` subcommand.""" + console.print() + console.print("[bold]Usage:[/bold]", style=theme.PRIMARY) + console.print(" dcode url status [options]") + console.print() + console.print( + "Report whether dcode's dcode:// handler is installed, which dcode it", + ) + console.print( + "runs, and β€” where the platform can answer β€” what the desktop opens", + ) + console.print( + "the scheme with. Always exits 0; read `installed` from --json.", + ) + console.print() + _print_option_section() + console.print() + console.print("[bold]Examples:[/bold]", style=theme.PRIMARY) + console.print(" dcode url status") + console.print(" dcode url status --json") + console.print() + + +def show_url_open_help() -> None: + """Show help information for the `url open` subcommand.""" + console.print() + console.print("[bold]Usage:[/bold]", style=theme.PRIMARY) + console.print(" dcode url open URL") + console.print() + console.print( + "Open a dcode:// link. This is what a registered handler runs; you can", + ) + console.print( + "also run it yourself to see what a link would do.", + ) + console.print() + console.print( + "The request is shown in full β€” directory, agent, thread, and the", + ) + console.print( + "prompt text β€” and nothing starts until you approve it. There is no", + ) + console.print( + "flag that skips the confirmation, and a link cannot change the", + ) + console.print( + "session's approval mode, model, or sandbox.", + ) + console.print() + # No --json: this verb's output is a confirmation prompt, not a report. + console.print("[bold]Options:[/bold]", style=theme.PRIMARY) + console.print(_HELP_OPTION_LINE) + console.print() + console.print("[bold]Examples:[/bold]", style=theme.PRIMARY) + console.print(" dcode url open 'dcode://open?dir=~/proj'") + console.print() + + def show_install_help() -> None: """Show help information for the `install` subcommand.""" console.print() diff --git a/libs/code/deepagents_code/url_scheme/__init__.py b/libs/code/deepagents_code/url_scheme/__init__.py new file mode 100644 index 0000000000..7fde629476 --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/__init__.py @@ -0,0 +1,65 @@ +"""`dcode://` URL-scheme support: registration, parsing, and handling. + +A page can offer to open a directory or resume a thread in dcode by linking to +`dcode://open?dir=/path/to/project`, the way a page offers to open an editor or +a chat client. The browser asks whether to hand the link to dcode; dcode then +asks whether to honor this particular request. + +The feature is three pieces, each with its own module docstring: + +- `request` validates a link. It is the trust boundary: one action, a closed + parameter set, and no parameter that could weaken the session a link opens. +- `registration` claims and releases the scheme with the operating system, via + one backend per desktop stack (`_macos`, `_linux`, `_windows`). +- `handler` runs in the terminal the desktop opened: it shows the request in + full, waits for an explicit approval, and then launches the session. + +Nothing here runs unless the user asks for it twice β€” once by registering the +scheme, and again for each link they approve. +""" + +from __future__ import annotations + +from deepagents_code.url_scheme.handler import ( + EXIT_DECLINED, + EXIT_REFUSED, + open_from_url, +) +from deepagents_code.url_scheme.registration import ( + HandlerStatus, + RegistrationError, + TerminalChoice, + handler_status, + install_handler, + resolve_launcher, + uninstall_handler, +) +from deepagents_code.url_scheme.request import ( + MAX_PROMPT_CHARS, + OPEN_ACTION, + URL_SCHEME, + OpenRequest, + UrlRequestError, + build_open_url, + parse_open_url, +) + +__all__ = [ + "EXIT_DECLINED", + "EXIT_REFUSED", + "MAX_PROMPT_CHARS", + "OPEN_ACTION", + "URL_SCHEME", + "HandlerStatus", + "OpenRequest", + "RegistrationError", + "TerminalChoice", + "UrlRequestError", + "build_open_url", + "handler_status", + "install_handler", + "open_from_url", + "parse_open_url", + "resolve_launcher", + "uninstall_handler", +] diff --git a/libs/code/deepagents_code/url_scheme/_linux.py b/libs/code/deepagents_code/url_scheme/_linux.py new file mode 100644 index 0000000000..a7fcb219bc --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/_linux.py @@ -0,0 +1,446 @@ +"""Linux `dcode://` registration through an XDG desktop entry. + +A desktop entry declaring `x-scheme-handler/dcode` is what browsers and portals +consult, so that is the whole artifact. `Terminal=true` asks the desktop to run +the command in the user's terminal, which is how a TUI gets a usable window +without dcode having to guess which terminal emulator is installed. + +Nothing on this path goes through a shell: the desktop launcher expands `%u` to +the link as a single argument vector element, so the link cannot be read as +shell syntax no matter what it contains. + +`xdg-mime` sets the default when it is available. When it is not β€” a minimal +container, a system without `xdg-utils` β€” the same association is written +directly to `mimeapps.list`, editing only dcode's own keys so a hand-tuned file +survives. +""" + +from __future__ import annotations + +import configparser +import logging +import os +import shutil +import subprocess # noqa: S404 # fixed-argv desktop integration tools +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from deepagents_code.url_scheme.registration import ( + HandlerStatus, + RegistrationError, + build_status, +) +from deepagents_code.url_scheme.request import URL_SCHEME + +if TYPE_CHECKING: + from collections.abc import Sequence + +logger = logging.getLogger(__name__) + +DESKTOP_FILE_NAME: Final = "dcode-url-handler.desktop" +"""Desktop entry filename, also the id `xdg-mime` associates with the scheme.""" + +MIME_TYPE: Final = f"x-scheme-handler/{URL_SCHEME}" +"""The pseudo-MIME type XDG uses to name a URL scheme.""" + +_DEFAULT_SECTION: Final = "Default Applications" +_ADDED_SECTION: Final = "Added Associations" +_COMMAND_TIMEOUT: Final = 30.0 + + +def desktop_file_path() -> Path: + """Return the desktop entry location. + + Returns: + Path of the entry under the XDG data directory, whether or not it + exists. + """ + return _data_home() / "applications" / DESKTOP_FILE_NAME + + +def install(launcher: Path) -> Path: + """Write the desktop entry and make it the scheme's default. + + Args: + launcher: Absolute path of the dcode console script to run. + + Returns: + Path of the installed desktop entry. + + Raises: + RegistrationError: The dcode path cannot be expressed in a desktop entry, + the entry could not be written, or the default could not be set. + """ + entry = desktop_file_path() + try: + entry.parent.mkdir(parents=True, exist_ok=True) + entry.write_text(_desktop_entry(launcher), encoding="utf-8") + except OSError as exc: + msg = f"Could not write {entry}: {exc}" + raise RegistrationError(msg) from exc + + _update_desktop_database(entry.parent) + if not _set_default_with_xdg_mime(): + _write_mimeapps_default() + return entry + + +def uninstall() -> Sequence[str]: + """Remove the desktop entry and dcode's scheme associations. + + Returns: + The artifacts that were changed or removed. + + Raises: + RegistrationError: The desktop entry exists but could not be removed. + """ + removed: list[str] = [] + entry = desktop_file_path() + if entry.exists(): + try: + entry.unlink() + except OSError as exc: + msg = f"Could not remove {entry}: {exc}" + raise RegistrationError(msg) from exc + removed.append(str(entry)) + _update_desktop_database(entry.parent) + + if _clear_mimeapps_default(): + removed.append(str(_mimeapps_path())) + return removed + + +def status() -> HandlerStatus: + """Report the desktop entry's state and the scheme's current default. + + Returns: + Current status, including what `xdg-mime` reports as the scheme's + default handler when it can be queried. + """ + entry = desktop_file_path() + installed = entry.is_file() + default = _query_default_with_xdg_mime() + if default is None: + default = _mimeapps_default() + + if not installed: + detail = ( + f"No {URL_SCHEME}:// handler installed. Run `dcode url install` to " + "register one." + ) + if default: + detail += f" The scheme is currently associated with {default}." + return build_status( + installed=False, + handler_path=None, + default_handler=default, + detail=detail, + ) + + if default == DESKTOP_FILE_NAME: + detail = "Handler installed and set as the default for the scheme." + elif default: + detail = ( + f"Handler installed, but the desktop opens the scheme with " + f"{default}. Re-run `dcode url install` to take it back." + ) + else: + detail = ( + "Handler installed. The scheme's default could not be read; your " + "desktop may ask which application to use." + ) + return build_status( + installed=True, + handler_path=str(entry), + default_handler=default, + detail=detail, + ) + + +def _data_home() -> Path: + """Return `$XDG_DATA_HOME`, defaulting per the XDG base directory spec. + + Returns: + The data home directory. + """ + raw = os.environ.get("XDG_DATA_HOME", "").strip() + if raw.startswith("/"): + return Path(raw) + return Path.home() / ".local" / "share" + + +def _config_home() -> Path: + """Return `$XDG_CONFIG_HOME`, defaulting per the XDG base directory spec. + + Returns: + The config home directory. + """ + raw = os.environ.get("XDG_CONFIG_HOME", "").strip() + if raw.startswith("/"): + return Path(raw) + return Path.home() / ".config" + + +def _mimeapps_path() -> Path: + """Return the user's `mimeapps.list` location. + + Returns: + Path of the file, whether or not it exists. + """ + return _config_home() / "mimeapps.list" + + +def _desktop_entry(launcher: Path) -> str: + """Build the desktop entry text. + + Propagates `RegistrationError` from `_exec_value` when the dcode path holds + a character the `Exec` grammar cannot carry. + + Args: + launcher: Absolute path of the dcode console script. + + Returns: + Desktop entry file contents. + """ + return "\n".join( + ( + "[Desktop Entry]", + "Type=Application", + "Version=1.0", + # Browsers show this name when they ask whether to open the link. + "Name=dcode", + "GenericName=deepagents code", + f"Comment=Open a deepagents code session from a {URL_SCHEME}:// link", + f"Exec={_exec_value(launcher)}", + # The desktop supplies the terminal window a TUI needs. + "Terminal=true", + "StartupNotify=false", + "NoDisplay=false", + "Categories=Development;", + f"MimeType={MIME_TYPE};", + "", + ) + ) + + +def _exec_value(launcher: Path) -> str: + """Build the entry's `Exec` value. + + `%u` hands the link to dcode as one argument, so the desktop launcher never + builds a shell command line out of it. + + Args: + launcher: Absolute path of the dcode console script. + + Returns: + The `Exec` value. + + Raises: + RegistrationError: The path holds a character the `Exec` grammar reserves + and cannot represent safely. + """ + path = str(launcher) + # The desktop entry spec escapes these inside a quoted argument, but a path + # holding one is so unlikely that refusing beats emitting an entry whose + # correctness nobody can check. + forbidden = {'"', "\\", "`", "$", "\n", "\r"} + found = sorted(forbidden.intersection(path)) + if found: + msg = ( + f"Cannot register {path}: the path contains {''.join(found)!r}, which " + "a desktop entry's Exec line cannot carry. Install dcode at a path " + "without it." + ) + raise RegistrationError(msg) + return f'"{path}" url open %u' + + +def _update_desktop_database(applications_dir: Path) -> None: + """Refresh the desktop database so the new association is visible. + + Best-effort: the file itself is authoritative, and desktops re-read it on + their own schedule. + + Args: + applications_dir: Directory holding the desktop entry. + """ + tool = shutil.which("update-desktop-database") + if tool is None: + logger.debug("update-desktop-database not found; skipping") + return + _run([tool, str(applications_dir)], what="refresh the desktop database") + + +def _set_default_with_xdg_mime() -> bool: + """Make dcode's entry the scheme's default via `xdg-mime`. + + Returns: + Whether `xdg-mime` was available and succeeded. + """ + tool = shutil.which("xdg-mime") + if tool is None: + logger.debug("xdg-mime not found; writing mimeapps.list directly") + return False + return _run( + [tool, "default", DESKTOP_FILE_NAME, MIME_TYPE], + what="set the scheme's default handler", + ) + + +def _query_default_with_xdg_mime() -> str | None: + """Ask `xdg-mime` which entry currently handles the scheme. + + Returns: + The desktop entry id, or `None` when `xdg-mime` is unavailable, failed, + or reported no association. + """ + tool = shutil.which("xdg-mime") + if tool is None: + return None + try: + result = subprocess.run( # noqa: S603 # resolved tool path, fixed argv + [tool, "query", "default", MIME_TYPE], + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.debug("xdg-mime query failed: %s", exc) + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def _load_mimeapps() -> configparser.RawConfigParser: + """Read `mimeapps.list` into a parser that preserves its keys verbatim. + + Returns: + The parser, empty when the file is absent or unreadable. + """ + parser = configparser.RawConfigParser(delimiters=("=",)) + # MIME types and desktop ids are case-sensitive; the default lower-casing + # would rewrite every key in the user's file. + parser.optionxform = str # ty: ignore[invalid-assignment] + path = _mimeapps_path() + if not path.is_file(): + return parser + try: + parser.read(path, encoding="utf-8") + except (OSError, configparser.Error): + logger.warning("Could not parse %s; leaving it alone", path, exc_info=True) + return configparser.RawConfigParser(delimiters=("=",)) + return parser + + +def _save_mimeapps(parser: configparser.RawConfigParser) -> None: + """Write `mimeapps.list` back. + + Args: + parser: Parser holding the file's new contents. + + Raises: + RegistrationError: The file could not be written. + """ + path = _mimeapps_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + parser.write(handle, space_around_delimiters=False) + except OSError as exc: + msg = f"Could not write {path}: {exc}" + raise RegistrationError(msg) from exc + + +def _write_mimeapps_default() -> None: + """Associate the scheme with dcode's entry directly in `mimeapps.list`. + + The fallback for systems without `xdg-utils`. Only dcode's own key is + touched. Propagates `RegistrationError` from `_save_mimeapps` when the file + cannot be written. + """ + parser = _load_mimeapps() + if not parser.has_section(_DEFAULT_SECTION): + parser.add_section(_DEFAULT_SECTION) + parser.set(_DEFAULT_SECTION, MIME_TYPE, DESKTOP_FILE_NAME) + _save_mimeapps(parser) + + +def _clear_mimeapps_default() -> bool: + """Remove dcode's scheme associations from `mimeapps.list`. + + Only entries naming dcode's own desktop file are removed, so an association + the user pointed somewhere else is left as they set it. Propagates + `RegistrationError` from `_save_mimeapps` when the file cannot be written. + + Returns: + Whether the file was changed. + """ + parser = _load_mimeapps() + changed = False + for section in (_DEFAULT_SECTION, _ADDED_SECTION): + if not parser.has_section(section): + continue + value = parser.get(section, MIME_TYPE, fallback="") + entries = [item for item in value.split(";") if item] + remaining = [item for item in entries if item != DESKTOP_FILE_NAME] + if remaining == entries: + continue + if remaining: + parser.set(section, MIME_TYPE, ";".join(remaining) + ";") + else: + parser.remove_option(section, MIME_TYPE) + changed = True + + if changed: + _save_mimeapps(parser) + return changed + + +def _mimeapps_default() -> str | None: + """Read the scheme's default from `mimeapps.list`. + + The fallback for reporting status where `xdg-mime` is unavailable. + + Returns: + The first desktop entry id associated with the scheme, or `None`. + """ + parser = _load_mimeapps() + for section in (_DEFAULT_SECTION, _ADDED_SECTION): + value = parser.get(section, MIME_TYPE, fallback="") + for item in value.split(";"): + if item: + return item + return None + + +def _run(argv: list[str], *, what: str) -> bool: + """Run a resolved desktop-integration tool. + + Args: + argv: Argument vector. Every element is a resolved tool path or a + constant this module owns, never link-derived text. + what: Phrase naming the step, used in the log message. + + Returns: + Whether the command succeeded. + """ + try: + result = subprocess.run( # noqa: S603 # resolved tool path, fixed argv + argv, + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("Could not %s: %s", what, exc) + return False + if result.returncode == 0: + return True + detail = (result.stderr or result.stdout or "").strip().splitlines() + logger.warning( + "Could not %s: %s", what, detail[-1] if detail else result.returncode + ) + return False diff --git a/libs/code/deepagents_code/url_scheme/_macos.py b/libs/code/deepagents_code/url_scheme/_macos.py new file mode 100644 index 0000000000..e852c53176 --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/_macos.py @@ -0,0 +1,514 @@ +"""macOS `dcode://` registration through an AppleScript applet. + +Launch Services only dispatches a URL scheme to an application bundle, so a +console script cannot claim one on its own. The bundle here is the smallest +thing that can: an AppleScript applet whose `on open location` handler asks a +terminal to run `dcode url open `, with `CFBundleURLTypes` in its +`Info.plist` declaring the scheme. + +The applet is the only place in this feature where a link is interpolated into a +shell command, so it is built to make that safe by construction. The dcode path +is quoted with `shlex.quote` when the applet is generated, and the link is quoted +at dispatch time by AppleScript's `quoted form of`, which is the language's own +POSIX-shell quoter. No other part of the link reaches the command line: the +applet does not inspect, split, or reassemble it. + +The bundle lives in `~/Applications`, so installing needs no administrator +rights and uninstalling is a directory removal. +""" + +from __future__ import annotations + +import logging +import os +import plistlib +import shlex +import shutil +import subprocess # noqa: S404 # fixed-argv macOS system tools +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Final + +from deepagents_code.url_scheme.registration import ( + HandlerStatus, + RegistrationError, + TerminalChoice, + build_status, +) +from deepagents_code.url_scheme.request import URL_SCHEME + +if TYPE_CHECKING: + from collections.abc import Sequence + +logger = logging.getLogger(__name__) + +BUNDLE_ID: Final = "com.langchain.deepagents-code.url-handler" +"""Bundle identifier, also the marker that a bundle is ours to replace.""" + +APP_NAME: Final = "dcode.app" +"""Bundle name. + +Browsers label their "open this link?" prompt with the handler's display name, +so the bundle is named after the command it runs: the prompt reads "Open dcode?" +rather than naming some helper the user has never heard of. +""" + +_LAUNCHER_KEY: Final = "DcodeUrlHandlerLauncher" +_TERMINAL_KEY: Final = "DcodeUrlHandlerTerminal" +"""Custom `Info.plist` keys recording what the applet was built to run. + +`dcode url status` reads them back to report which dcode an installed handler +launches, which is how it notices a handler left behind pointing at a dcode that +has since moved. +""" + +_OSACOMPILE: Final = Path("/usr/bin/osacompile") +_OSASCRIPT: Final = Path("/usr/bin/osascript") +_LSREGISTER: Final = Path( + "/System/Library/Frameworks/CoreServices.framework/Frameworks" + "/LaunchServices.framework/Support/lsregister" +) +_COMMAND_TIMEOUT: Final = 60.0 + + +def app_path() -> Path: + """Return the bundle location. + + Returns: + Path of the applet bundle, whether or not it exists. + """ + return Path.home() / "Applications" / APP_NAME + + +def install(launcher: Path, *, terminal: TerminalChoice) -> Path: + """Build and register the applet bundle. + + Args: + launcher: Absolute path of the dcode console script to run. + terminal: Terminal the applet should open the session in. + + Returns: + Path of the installed bundle. + + Raises: + RegistrationError: `osacompile` is unavailable, compilation failed, the + bundle could not be written, or an unrelated application already + occupies the bundle path. + """ + resolved_terminal = _resolve_terminal(terminal) + bundle = app_path() + _clear_existing_bundle(bundle) + + if not _OSACOMPILE.is_file(): + msg = ( + f"Cannot build the URL handler: {_OSACOMPILE} is missing. It ships " + "with macOS, so this may be a stripped-down system image." + ) + raise RegistrationError(msg) + + source = _applet_source(launcher, resolved_terminal) + with tempfile.TemporaryDirectory(prefix="dcode-url-scheme-") as tmp: + script = Path(tmp) / "handler.applescript" + script.write_text(source, encoding="utf-8") + _run( + [str(_OSACOMPILE), "-o", str(bundle), str(script)], + what="compile the URL handler applet", + ) + + _write_bundle_metadata(bundle, launcher=launcher, terminal=resolved_terminal) + _register_with_launch_services(bundle) + return bundle + + +def uninstall() -> Sequence[str]: + """Unregister and delete the applet bundle. + + Returns: + The bundle path when one was removed, otherwise an empty sequence. + + Raises: + RegistrationError: The bundle exists but could not be removed. + """ + bundle = app_path() + if not bundle.exists(): + return () + if _bundle_identifier(bundle) != BUNDLE_ID: + msg = ( + f"Refusing to remove {bundle}: it is not dcode's URL handler. Remove " + "it yourself if that is what you intended." + ) + raise RegistrationError(msg) + + if _LSREGISTER.is_file(): + # Best-effort: Launch Services also drops handlers whose bundle is gone. + _run( + [str(_LSREGISTER), "-u", str(bundle)], + what="unregister the URL handler", + required=False, + ) + try: + shutil.rmtree(bundle) + except OSError as exc: + msg = f"Could not remove {bundle}: {exc}" + raise RegistrationError(msg) from exc + return (str(bundle),) + + +def status() -> HandlerStatus: + """Report the applet bundle's state. + + Returns: + Current status. `default_handler` is always `None`: macOS exposes no + first-party command that reports the application bound to a scheme, so + the report covers dcode's own artifact and leaves the binding to the + browser's own prompt. + """ + bundle = app_path() + plist = _read_plist(bundle) + installed = plist is not None and plist.get("CFBundleIdentifier") == BUNDLE_ID + if not installed: + return build_status( + installed=False, + handler_path=None, + default_handler=None, + detail=( + f"No {URL_SCHEME}:// handler installed. Run `dcode url install` " + "to register one." + ), + ) + + assert plist is not None # noqa: S101 # `installed` implies a parsed plist + recorded = plist.get(_LAUNCHER_KEY) + terminal = plist.get(_TERMINAL_KEY, "unknown") + detail = f"Handler installed; links open in {terminal}." + if isinstance(recorded, str) and recorded: + detail += f" It runs {recorded}." + return build_status( + installed=True, + handler_path=str(bundle), + default_handler=None, + detail=detail, + ) + + +def _resolve_terminal(terminal: TerminalChoice) -> TerminalChoice: + """Resolve `AUTO` to a concrete terminal, and check an explicit one. + + `osacompile` resolves an application's AppleScript terminology at build + time, so a dispatch to a terminal that is not installed cannot be compiled. + Terminal.app is always present; iTerm has to be checked. `AUTO` degrades to + Terminal.app so it cannot fail, while an explicit `--terminal iterm` says + what is wrong instead of surfacing a compiler error. + + Args: + terminal: Requested terminal. + + Returns: + The terminal to build the applet for. Terminals with no scriptable + `do script` equivalent are not offered, and resolve to Terminal.app. + + Raises: + RegistrationError: `ITERM` was requested explicitly but iTerm is not + installed. + """ + if terminal is TerminalChoice.ITERM: + if _iterm_available(): + return TerminalChoice.ITERM + msg = ( + "Cannot build an iTerm handler: iTerm does not appear to be " + "installed, so its AppleScript terminology cannot be resolved. " + "Install iTerm, or use `--terminal terminal`." + ) + raise RegistrationError(msg) + if terminal is TerminalChoice.TERMINAL: + return TerminalChoice.TERMINAL + + from deepagents_code._env_vars import LAUNCH_TERM_PROGRAM + + # The snapshot `cli_main` takes at entry, so a project `.env` cannot decide + # which terminal gets baked into an installed handler. + launch_term = os.environ.get(LAUNCH_TERM_PROGRAM, "").strip().lower() + if launch_term.startswith("iterm") and _iterm_available(): + return TerminalChoice.ITERM + return TerminalChoice.TERMINAL + + +def _iterm_available() -> bool: + """Report whether iTerm's AppleScript terminology can be resolved. + + Asks for the application's bundle id, which is the same lookup + `osacompile` performs for a `tell application "iTerm"` block. + + Returns: + Whether iTerm was found. + """ + if not _OSASCRIPT.is_file(): + return False + try: + result = subprocess.run( # noqa: S603 # fixed argv, absolute system path + [str(_OSASCRIPT), "-e", 'id of application "iTerm"'], + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError): + logger.debug("Could not probe for iTerm", exc_info=True) + return False + return result.returncode == 0 + + +def _applet_source(launcher: Path, terminal: TerminalChoice) -> str: + """Build the applet's AppleScript source. + + Args: + launcher: Absolute path of the dcode console script. + terminal: Terminal to open the session in. + + Returns: + AppleScript source text. + """ + command = _applescript_string(f"exec {shlex.quote(str(launcher))} url open ") + dispatch = ( + _ITERM_DISPATCH if terminal is TerminalChoice.ITERM else _TERMINAL_DISPATCH + ) + return _APPLET_TEMPLATE.format( + command=command, + dispatch=dispatch, + run_message=_applescript_string(_RUN_MESSAGE), + ) + + +def _applescript_string(value: str) -> str: + """Quote `value` as an AppleScript string literal. + + Args: + value: Text to embed in generated source. + + Returns: + The quoted literal, backslashes and double quotes escaped. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _clear_existing_bundle(bundle: Path) -> None: + """Remove a previous dcode bundle so `osacompile` can write a fresh one. + + Args: + bundle: Bundle path to clear. + + Raises: + RegistrationError: The path holds something that is not dcode's handler, + or it could not be removed. + """ + if not bundle.exists(): + try: + bundle.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + msg = f"Could not create {bundle.parent}: {exc}" + raise RegistrationError(msg) from exc + return + + identifier = _bundle_identifier(bundle) + if identifier is not None and identifier != BUNDLE_ID: + msg = ( + f"Refusing to replace {bundle}: it belongs to another application " + f"({identifier}). Move it aside and run this command again." + ) + raise RegistrationError(msg) + try: + shutil.rmtree(bundle) + except OSError as exc: + msg = f"Could not replace {bundle}: {exc}" + raise RegistrationError(msg) from exc + + +def _plist_path(bundle: Path) -> Path: + """Return a bundle's `Info.plist` path. + + Args: + bundle: Bundle path. + + Returns: + Path of the bundle's `Info.plist`. + """ + return bundle / "Contents" / "Info.plist" + + +def _read_plist(bundle: Path) -> dict[str, object] | None: + """Read a bundle's `Info.plist`. + + Args: + bundle: Bundle path. + + Returns: + The parsed plist, or `None` when it is absent or unreadable. + """ + try: + with _plist_path(bundle).open("rb") as handle: + parsed = plistlib.load(handle) + except (OSError, plistlib.InvalidFileException, ValueError): + logger.debug("Could not read %s", _plist_path(bundle), exc_info=True) + return None + return parsed if isinstance(parsed, dict) else None + + +def _bundle_identifier(bundle: Path) -> str | None: + """Read a bundle's identifier. + + Args: + bundle: Bundle path. + + Returns: + The `CFBundleIdentifier` value, or `None` when it cannot be read. + """ + plist = _read_plist(bundle) + identifier = None if plist is None else plist.get("CFBundleIdentifier") + return identifier if isinstance(identifier, str) else None + + +def _write_bundle_metadata( + bundle: Path, *, launcher: Path, terminal: TerminalChoice +) -> None: + """Declare the URL scheme and identity in the compiled bundle's plist. + + `osacompile` writes a generic applet plist, so the scheme declaration, the + identifier, and the display name browsers show in their prompt are added + afterwards. + + Args: + bundle: Compiled bundle to update. + launcher: dcode path the applet runs, recorded for `dcode url status`. + terminal: Terminal the applet opens, recorded for `dcode url status`. + + Raises: + RegistrationError: The plist could not be read or written. + """ + plist = _read_plist(bundle) + if plist is None: + msg = f"Compiled bundle at {bundle} has no readable Info.plist." + raise RegistrationError(msg) + + plist.update( + { + "CFBundleIdentifier": BUNDLE_ID, + "CFBundleName": "dcode", + "CFBundleDisplayName": "dcode", + # The applet has no interface of its own; it hands off to a terminal + # and exits, so it stays out of the Dock and the app switcher. + "LSUIElement": True, + "CFBundleURLTypes": [ + { + "CFBundleURLName": "deepagents code session", + "CFBundleTypeRole": "Viewer", + "CFBundleURLSchemes": [URL_SCHEME], + } + ], + _LAUNCHER_KEY: str(launcher), + _TERMINAL_KEY: str(terminal), + } + ) + try: + with _plist_path(bundle).open("wb") as handle: + plistlib.dump(plist, handle) + except OSError as exc: + msg = f"Could not write {_plist_path(bundle)}: {exc}" + raise RegistrationError(msg) from exc + + +def _register_with_launch_services(bundle: Path) -> None: + """Tell Launch Services about the bundle now, rather than on first scan. + + Best-effort: Launch Services also picks up a bundle in `~/Applications` on + its own schedule, so a failure here delays the registration instead of + breaking it. + + Args: + bundle: Bundle to register. + """ + if not _LSREGISTER.is_file(): + logger.debug("lsregister not found at %s; skipping", _LSREGISTER) + return + _run( + [str(_LSREGISTER), "-f", str(bundle)], + what="register the URL handler with Launch Services", + required=False, + ) + + +def _run(argv: list[str], *, what: str, required: bool = True) -> None: + """Run a fixed-path macOS command. + + Args: + argv: Argument vector. Every element is either an absolute path this + module owns or a path dcode resolved, never link-derived text. + what: Phrase naming the step, used in the error message. + required: When `False`, a failure is logged instead of raised. + + Raises: + RegistrationError: The command failed, timed out, or could not be run + while `required` is `True`. + """ + try: + result = subprocess.run( # noqa: S603 # fixed argv, absolute system paths + argv, + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + if not required: + logger.warning("Could not %s: %s", what, exc) + return + msg = f"Could not {what}: {exc}" + raise RegistrationError(msg) from exc + + if result.returncode == 0: + return + detail = (result.stderr or result.stdout or "").strip().splitlines() + reason = detail[-1] if detail else f"exit code {result.returncode}" + if not required: + logger.warning("Could not %s: %s", what, reason) + return + msg = f"Could not {what}: {reason}" + raise RegistrationError(msg) + + +_TERMINAL_DISPATCH: Final = """\ + tell application "Terminal" + activate + do script sessionCommand + end tell""" + +_ITERM_DISPATCH: Final = """\ + tell application "iTerm" + activate + create window with default profile command sessionCommand + end tell""" + +_RUN_MESSAGE: Final = ( + "This helper opens dcode:// links in a terminal. There is nothing to open " + "here - follow a dcode:// link, or run dcode from your terminal." +) + +_APPLET_TEMPLATE: Final = """\ +-- deepagents code URL handler. +-- Generated by `dcode url install`; re-run that command to rebuild it. +-- +-- `quoted form of` is AppleScript's POSIX-shell quoter: the link becomes one +-- quoted argument, so no part of it can be read as shell syntax. The dcode +-- path was quoted when this applet was generated. +-- +-- Kept to ASCII on purpose: this source is handed to `osacompile`. + +on open location this_URL + set sessionCommand to {command} & quoted form of this_URL +{dispatch} +end open location + +on run + display alert "dcode URL handler" message {run_message} as informational +end run +""" diff --git a/libs/code/deepagents_code/url_scheme/_windows.py b/libs/code/deepagents_code/url_scheme/_windows.py new file mode 100644 index 0000000000..0624bc8598 --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/_windows.py @@ -0,0 +1,214 @@ +r"""Windows `dcode://` registration through the per-user protocol key. + +Windows resolves a URL scheme through a `URL Protocol` key under +`Software\Classes`. dcode writes the `HKEY_CURRENT_USER` copy, which needs no +elevation and takes precedence over a machine-wide entry for this user, so +uninstalling is a key deletion rather than a repair. + +`dcode` is a console application, so the shell gives the launch its own console +window and the TUI has somewhere to draw. No terminal has to be chosen or +installed, and no shell sits between the browser and dcode: the registered +command is dcode itself, with `"%1"` expanded into its argument vector. + +`request` refuses every parameter that could weaken a session, and `handler` +requires an approval before launching, which is what keeps that last point from +mattering: even where the shell's quoting rules let a crafted link push an extra +token into the command line, there is no dangerous token to push. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final + +from deepagents_code.url_scheme.registration import ( + HandlerStatus, + RegistrationError, + build_status, +) +from deepagents_code.url_scheme.request import URL_SCHEME + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + +logger = logging.getLogger(__name__) + +KEY_PATH: Final = f"Software\\Classes\\{URL_SCHEME}" +"""Per-user protocol key dcode owns.""" + +_COMMAND_SUBKEY: Final = "shell\\open\\command" +_MARKER_NAME: Final = "DcodeUrlHandler" +"""Value marking the key as dcode's, so uninstall cannot delete someone else's. + +Windows has no per-key ownership, and the scheme name is short enough that +another tool could plausibly have claimed it. The marker is what makes removal +safe to automate. +""" + + +def install(launcher: Path) -> str: + """Write the protocol key. + + Args: + launcher: Absolute path of the dcode console script to run. + + Returns: + The registry key path that was written. + + Raises: + RegistrationError: The registry could not be written, or the key already + belongs to another application. + """ + import winreg + + existing_owner = _key_owner() + if existing_owner is False: + msg = ( + f"Refusing to replace HKCU\\{KEY_PATH}: another application already " + f"handles {URL_SCHEME}://. Remove that registration first." + ) + raise RegistrationError(msg) + + command = f'"{launcher}" url open "%1"' + try: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, KEY_PATH) as key: + winreg.SetValueEx(key, None, 0, winreg.REG_SZ, f"URL:{URL_SCHEME} protocol") + # Presence, not content, is what marks a scheme key as a protocol. + winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "") + winreg.SetValueEx(key, _MARKER_NAME, 0, winreg.REG_SZ, str(launcher)) + with winreg.CreateKey( + winreg.HKEY_CURRENT_USER, f"{KEY_PATH}\\{_COMMAND_SUBKEY}" + ) as key: + winreg.SetValueEx(key, None, 0, winreg.REG_SZ, command) + except OSError as exc: + msg = f"Could not write HKCU\\{KEY_PATH}: {exc}" + raise RegistrationError(msg) from exc + return f"HKCU\\{KEY_PATH}" + + +def uninstall() -> Sequence[str]: + """Delete the protocol key. + + Returns: + A one-element sequence naming the removed key, or an empty sequence when + nothing was installed. + + Raises: + RegistrationError: The key exists but is not dcode's, or it could not be + removed. + """ + import winreg + + owner = _key_owner() + if owner is None: + return () + if owner is False: + msg = f"Refusing to remove HKCU\\{KEY_PATH}: it belongs to another application." + raise RegistrationError(msg) + + # `DeleteKey` only removes a key with no subkeys, so unwind depth-first. + subkeys = ("shell\\open\\command", "shell\\open", "shell") + try: + for subkey in subkeys: + _delete_key_if_present(f"{KEY_PATH}\\{subkey}") + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, KEY_PATH) + except OSError as exc: + msg = f"Could not remove HKCU\\{KEY_PATH}: {exc}" + raise RegistrationError(msg) from exc + return (f"HKCU\\{KEY_PATH}",) + + +def status() -> HandlerStatus: + """Report the protocol key's state. + + Returns: + Current status. `default_handler` names the application the key points + at, which on Windows is the same question as which handler is + registered. + """ + owner = _key_owner() + if owner is None: + return build_status( + installed=False, + handler_path=None, + default_handler=None, + detail=( + f"No {URL_SCHEME}:// handler installed. Run `dcode url install` " + "to register one." + ), + ) + if owner is False: + return build_status( + installed=False, + handler_path=None, + default_handler=_command_value(), + detail=( + f"HKCU\\{KEY_PATH} belongs to another application, so " + f"{URL_SCHEME}:// links do not reach dcode." + ), + ) + return build_status( + installed=True, + handler_path=f"HKCU\\{KEY_PATH}", + default_handler=_command_value(), + detail=f"Handler installed at HKCU\\{KEY_PATH}.", + ) + + +def _key_owner() -> bool | None: + """Report who owns the protocol key. + + Returns: + `True` when the key is dcode's, `False` when it exists but belongs to + another application, and `None` when it does not exist. + """ + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, KEY_PATH) as key: + try: + winreg.QueryValueEx(key, _MARKER_NAME) + except OSError: + return False + return True + except FileNotFoundError: + return None + except OSError: + logger.debug("Could not read HKCU\\%s", KEY_PATH, exc_info=True) + return None + + +def _command_value() -> str | None: + """Read the command the protocol key runs. + + Returns: + The command string, or `None` when it cannot be read. + """ + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, f"{KEY_PATH}\\{_COMMAND_SUBKEY}" + ) as key: + value, _ = winreg.QueryValueEx(key, "") + except OSError: + return None + return value if isinstance(value, str) else None + + +def _delete_key_if_present(path: str) -> None: + """Delete a registry key, ignoring one that is already gone. + + Propagates `OSError` when the key exists but cannot be deleted; the caller + turns that into a `RegistrationError` naming the whole key. + + Args: + path: Key path under `HKEY_CURRENT_USER`. + """ + import winreg + + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path) + except FileNotFoundError: + return diff --git a/libs/code/deepagents_code/url_scheme/handler.py b/libs/code/deepagents_code/url_scheme/handler.py new file mode 100644 index 0000000000..f54ce39ec9 --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/handler.py @@ -0,0 +1,385 @@ +"""Handling one `dcode://` link: confirm with the user, then launch. + +This runs in the terminal the desktop opened, with the link the browser passed. +Its whole job is the gate between the two. + +Why the gate is here and not in the browser: a browser's "open this in dcode?" +prompt names an application, not a request. It does not show which directory +would be opened or what would be typed into the session, and browsers offer to +remember the answer, after which the prompt stops appearing at all. So the +browser's question is "do you trust dcode with links", and this one is "do you +want *this*" β€” the directory, the agent, the thread, and the prompt text in full, +every time, with the safe answer preselected. + +The gate is unconditional. There is no flag, environment variable, or +configuration key that skips it, and no approval-mode parameter a link can +carry: `request` refuses the whole link if it names one. A link therefore cannot +do anything the user could not have done by typing the equivalent command. + +Everything a link can ask for is something the session then gates on its own +terms. Opening an unfamiliar directory does not trust it: project hooks, MCP +servers, and extensions still go through their own trust prompts on launch, and +a submitted prompt is a message to the agent, whose tool calls still need +approval. +""" + +from __future__ import annotations + +import logging +import os +import subprocess # noqa: S404 # fixed-argv session launch on Windows +import sys +from typing import TYPE_CHECKING + +from deepagents_code.url_scheme.registration import RegistrationError, resolve_launcher +from deepagents_code.url_scheme.request import UrlRequestError, parse_open_url + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_processor import KeyPressEvent + + from deepagents_code.url_scheme.request import OpenRequest + +logger = logging.getLogger(__name__) + +EXIT_DECLINED = 1 +"""The user declined the request, or there was no way to ask them.""" + +EXIT_REFUSED = 2 +"""The link was malformed or asked for something a link may not ask for.""" + +_PROMPT_PREVIEW_LINES = 12 +"""Prompt lines shown before the rest is summarized as a count.""" + + +def open_from_url(raw: str) -> int: + """Confirm a `dcode://` link with the user and launch the session it names. + + On POSIX this replaces the process with the session on approval, so a + successful call does not return. + + Args: + raw: The link as the operating system delivered it. + + Returns: + A process exit code: `EXIT_REFUSED` for a link that was not accepted, + `EXIT_DECLINED` when the user said no or could not be asked, and the + session's own exit code on Windows. + """ + try: + request = parse_open_url(raw) + except UrlRequestError as exc: + return _refuse(str(exc)) + + if not _confirm(request): + return _decline() + + try: + return _launch(request) + except RegistrationError as exc: + return _refuse(str(exc)) + + +def _launch(request: OpenRequest) -> int: + """Start the session the request names. + + The session is launched by the same absolute dcode path a registration + would record, with only the fields `request` produced. Nothing about the + approval posture, sandbox, model, or tool set is passed, so the session runs + with exactly the configuration it would have had if the user had run dcode + in that directory themselves. + + Propagates `OSError` from `os.execv` when the process cannot be replaced; + the caller reports it the same way it reports a refused link. + + Args: + request: The approved request. + + Returns: + The session's exit code on Windows. On POSIX the process is replaced and + this does not return. + + Raises: + RegistrationError: The dcode command could not be located, the directory + could not be entered, or process replacement failed or returned. + """ + launcher = resolve_launcher() + argv = [str(launcher)] + if request.agent: + argv += ["-a", request.agent] + if request.thread: + argv += ["-r", request.thread] + if request.prompt: + argv += ["-m", request.prompt] + + try: + os.chdir(request.directory) + except OSError as exc: + msg = f"Could not enter {request.directory}: {exc}" + raise RegistrationError(msg) from exc + + if sys.platform == "win32": + # No `execv` worth having on Windows: it would orphan the console the + # shell just created for this process. + completed = subprocess.run(argv, check=False) # noqa: S603 # resolved dcode path, no shell + return completed.returncode + + # The argv is dcode's own resolved path plus fields `request` validated, so + # S606's concern (untrusted arguments to a spawned executable) is covered by + # the closed parameter set and the approval the user just gave. + try: + os.execv(argv[0], argv) # noqa: S606 + except OSError as exc: + msg = f"Could not start {launcher}: {exc}" + raise RegistrationError(msg) from exc + msg = "os.execv returned unexpectedly" + raise RegistrationError(msg) + + +def _confirm(request: OpenRequest) -> bool: + """Show the request in full and ask whether to open it. + + Args: + request: The validated request. + + Returns: + Whether the user explicitly approved. Fail-closed: a terminal that + cannot be asked, an interrupt, or a picker that will not run all + return `False`. + """ + _print_request(request) + if not (sys.stdin.isatty() and sys.stderr.isatty()): + from deepagents_code.config import console + + console.print( + "Not opening: a link has to be confirmed in an interactive " + "terminal, and this one is not interactive.", + style="bold red", + ) + return False + + label = ( + "Open session and send this prompt" if request.prompt else "Open this session" + ) + choice = _pick(label) + if choice is None: + return _confirm_by_typing(label) + return choice + + +def _print_request(request: OpenRequest) -> None: + """Print every part of the request, plus where it came from. + + Values are printed with Rich markup disabled: `request` has already refused + control characters and deceptive Unicode, and this keeps square brackets in + a path or prompt from being read as styling. + + Args: + request: The validated request. + """ + from deepagents_code.config import console + + console.print() + console.print("A dcode:// link is asking to open a session.", style="bold") + console.print() + rows = [("Directory", str(request.directory))] + if request.agent: + rows.append(("Agent", request.agent)) + if request.thread: + rows.append(("Thread", f"resume {request.thread}")) + width = max(len(label) for label, _ in rows) + for label, value in rows: + console.print(f" {label.ljust(width)} {value}", markup=False, highlight=False) + + if request.prompt: + console.print() + console.print(" First message, sent as soon as the session starts:") + for line in _prompt_preview(request.prompt): + console.print(f" {line}", markup=False, highlight=False, style="cyan") + + console.print() + console.print( + "Any web page can open a link like this. Open it only if you recognize " + "the directory above.", + style="yellow", + ) + console.print() + + +def _prompt_preview(prompt: str) -> list[str]: + """Split a prompt into display lines, summarizing an overlong tail. + + Args: + prompt: The prompt text. + + Returns: + Lines to print. + """ + lines = prompt.splitlines() or [prompt] + if len(lines) <= _PROMPT_PREVIEW_LINES: + return lines + hidden = len(lines) - _PROMPT_PREVIEW_LINES + return [ + *lines[:_PROMPT_PREVIEW_LINES], + f"... {hidden} more line{'s' if hidden != 1 else ''} not shown", + ] + + +def _pick(approve_label: str) -> bool | None: + """Run the inline two-choice picker, cancel first. + + Args: + approve_label: Label for the option that opens the session. + + Returns: + The decision, or `None` when the picker could not run and the caller + should fall back to typed confirmation. + """ + try: + from prompt_toolkit import Application + from prompt_toolkit.formatted_text import FormattedText + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.layout import Layout + from prompt_toolkit.layout.containers import Window + from prompt_toolkit.layout.controls import FormattedTextControl + from prompt_toolkit.output.defaults import create_output + from prompt_toolkit.styles import Style + + from deepagents_code.config import get_glyphs + + glyphs = get_glyphs() + # Cancel leads, so a bare Enter or an Esc declines. + choices: list[tuple[bool, str]] = [(False, "Cancel"), (True, approve_label)] + selected = 0 + + def rows() -> FormattedText: + fragments: list[tuple[str, str]] = [ + ( + "class:prompt.help", + ( + f"{glyphs.arrow_up}/{glyphs.arrow_down}/Tab move " + f"{glyphs.separator} Enter select {glyphs.separator} " + f"Esc cancel\n" + ), + ) + ] + for index, (_value, label) in enumerate(choices): + active = index == selected + cursor = glyphs.cursor if active else " " + suffix = "\n" if index < len(choices) - 1 else "" + fragments.append( + ( + "class:item.current" if active else "class:item", + f"{cursor} {label}{suffix}", + ) + ) + return FormattedText(fragments) + + bindings = KeyBindings() + + @bindings.add("up") + @bindings.add("s-tab") + def move_up(_event: KeyPressEvent) -> None: + nonlocal selected + selected = (selected - 1) % len(choices) + + @bindings.add("down") + @bindings.add("tab") + def move_down(_event: KeyPressEvent) -> None: + nonlocal selected + selected = (selected + 1) % len(choices) + + @bindings.add("enter") + def choose(event: KeyPressEvent) -> None: + event.app.exit(result=choices[selected][0]) + + @bindings.add("escape") + @bindings.add("c-c") + @bindings.add("c-d") + def cancel(event: KeyPressEvent) -> None: + event.app.exit(result=False) + + app: Application[bool] = Application( + layout=Layout( + Window( + FormattedTextControl(rows, show_cursor=False), + height=len(choices) + 1, + dont_extend_height=True, + ) + ), + key_bindings=bindings, + style=Style.from_dict( + {"prompt.help": "ansibrightblack", "item.current": "reverse"} + ), + full_screen=False, + erase_when_done=True, + output=create_output(stdout=sys.stderr), + ) + except ImportError: + logger.debug("Link confirmation picker unavailable", exc_info=True) + return None + + try: + return bool(app.run()) + except (EOFError, OSError, RuntimeError): + logger.debug("Link confirmation picker could not run", exc_info=True) + return None + except KeyboardInterrupt: + return False + + +def _confirm_by_typing(approve_label: str) -> bool: + """Ask for a typed confirmation when the picker is unavailable. + + Args: + approve_label: Label describing what approval does. + + Returns: + Whether the user typed an explicit yes. + """ + from deepagents_code.config import console + + console.print(f"{approve_label}? Type 'yes' to continue, anything else to cancel.") + try: + answer = input("> ") + except (EOFError, KeyboardInterrupt, OSError): + return False + return answer.strip().lower() == "yes" + + +def _decline() -> int: + """Report that nothing was opened. + + Returns: + `EXIT_DECLINED`. + """ + from deepagents_code.config import console + + console.print("No session opened.", style="dim") + return EXIT_DECLINED + + +def _refuse(message: str) -> int: + """Report a link that was not accepted, and keep the window readable. + + A link opens a fresh terminal, so a process that exits immediately takes the + explanation with it. When the terminal is interactive, this waits for a + keypress so the user gets to read why nothing happened. + + Args: + message: Explanation to show. + + Returns: + `EXIT_REFUSED`. + """ + from deepagents_code.config import console + + console.print() + console.print(f"Not opening this link: {message}", style="bold red", markup=False) + console.print() + if sys.stdin.isatty(): + console.print("Press Enter to close.", style="dim") + try: + input() + except (EOFError, KeyboardInterrupt, OSError): + logger.debug("Could not wait for acknowledgement", exc_info=True) + return EXIT_REFUSED diff --git a/libs/code/deepagents_code/url_scheme/registration.py b/libs/code/deepagents_code/url_scheme/registration.py new file mode 100644 index 0000000000..6edb2590f4 --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/registration.py @@ -0,0 +1,283 @@ +r"""Registering and unregistering dcode as the `dcode://` handler. + +Registration is always an explicit user action (`dcode url install`). Installing +dcode does not claim the scheme, because taking over a URL scheme changes how +the user's browser behaves and is not a side effect an install should have. + +Each platform gets the artifact its desktop stack actually reads, all of them +user-scoped so no step needs administrator rights and uninstall is a file or key +removal: + +- macOS: an AppleScript applet in `~/Applications` whose `Info.plist` declares + `CFBundleURLTypes`, registered with Launch Services. +- Linux: a `.desktop` entry in the XDG data directory declaring + `x-scheme-handler/dcode`, made the default through `xdg-mime`. +- Windows: the `HKCU\Software\Classes\dcode` protocol key. + +The handler command every backend writes is the same shape β€” the absolute path +of this dcode's console script, then `url open`, then the link β€” so the parsing, +confirmation, and launch rules in `request` and `handler` apply no matter which +desktop stack delivered the link. +""" + +from __future__ import annotations + +import logging +import shutil +import sys +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING + +from deepagents_code.url_scheme.request import URL_SCHEME + +if TYPE_CHECKING: + from collections.abc import Sequence + +logger = logging.getLogger(__name__) + +_SUPPORTED_PLATFORMS = "macOS, Linux, and Windows" + + +class RegistrationError(RuntimeError): + """Registration could not be completed. + + The message reaches the user and says what could not be done, so it names + the tool or path that failed rather than only reporting failure. + """ + + +class TerminalChoice(StrEnum): + """Terminal a macOS link opens the session in. + + A link arrives with no terminal attached, so the macOS applet has to ask one + to run the session. Both supported values are scriptable terminals that ship + an AppleScript `do script` command; `AUTO` reads `TERM_PROGRAM` at install + time so the choice matches the terminal the user was in when they installed. + """ + + AUTO = "auto" + TERMINAL = "terminal" + ITERM = "iterm" + + +@dataclass(frozen=True) +class HandlerStatus: + """What the operating system currently knows about the `dcode://` scheme. + + Attributes: + scheme: The scheme this status describes. + platform: `sys.platform` value the status was collected on. + supported: Whether registration is implemented for this platform. + installed: Whether dcode's own handler artifact is present. + handler_path: The artifact β€” app bundle path, desktop entry path, or + registry key β€” or `None` when nothing is installed. A string because + not every platform's artifact is a filesystem path. + launcher: Absolute path of the dcode console script the handler runs, or + `None` when it could not be resolved. + default_handler: What the desktop currently opens the scheme with, where + the platform can answer that; `None` when it cannot be queried. + detail: One-line human-readable summary. + """ + + scheme: str + platform: str + supported: bool + installed: bool + handler_path: str | None + launcher: Path | None + default_handler: str | None + detail: str + + +def resolve_launcher() -> Path: + """Resolve the absolute path of the dcode console script to register. + + A link is delivered by the browser or desktop launcher, whose environment + has little to do with the user's shell, so a bare command name in a + registered handler is the standard way this breaks. Everything written to + disk gets an absolute path instead. + + The path is deliberately *not* symlink-resolved. A `uv tool` or `pipx` + install exposes `~/.local/bin/dcode` as a symlink into a versioned + environment; recording the symlink keeps the handler pointing at whatever + dcode the user has installed, while recording its target would pin the + handler to today's version and break it on the next upgrade. + + Returns: + Absolute path of the console script. + + Raises: + RegistrationError: No dcode console script could be located. + """ + from deepagents_code._invocation import STANDARD_INVOKED_NAMES, invoked_name + + argv0 = Path(sys.argv[0]) if sys.argv and sys.argv[0] else None + if ( + argv0 is not None + and argv0.is_absolute() + and argv0.suffix.lower() != ".py" + and argv0.is_file() + ): + return argv0 + + for name in (invoked_name(), *sorted(STANDARD_INVOKED_NAMES)): + found = shutil.which(name) + if found: + return Path(found) + + msg = ( + "Could not find the dcode command to register. Install dcode so that " + "`dcode` is on PATH (for example `uv tool install deepagents-code`), " + "then run this command again." + ) + raise RegistrationError(msg) + + +def install_handler(*, terminal: TerminalChoice = TerminalChoice.AUTO) -> HandlerStatus: + """Register dcode as the operating system's `dcode://` handler. + + Idempotent: an existing registration is replaced with one built from the + current dcode path and options. + + Args: + terminal: Terminal a link should open the session in. macOS only; other + platforms have a desktop-level or shell-level answer already. + + Returns: + Status collected after registering. + + Raises: + RegistrationError: The platform is unsupported, the dcode command could + not be located, or a registration step failed. + """ + launcher = resolve_launcher() + if sys.platform == "darwin": + from deepagents_code.url_scheme import _macos + + _macos.install(launcher, terminal=terminal) + elif sys.platform.startswith("linux"): + from deepagents_code.url_scheme import _linux + + _linux.install(launcher) + elif sys.platform == "win32": + from deepagents_code.url_scheme import _windows + + _windows.install(launcher) + else: + raise RegistrationError(_unsupported_message()) + return handler_status() + + +def uninstall_handler() -> tuple[HandlerStatus, Sequence[str]]: + """Remove dcode's `dcode://` handler. + + Idempotent: removing a handler that is not installed succeeds and reports + that nothing was removed. + + Returns: + The status collected after removal, and the artifacts that were removed. + + Raises: + RegistrationError: The platform is unsupported, or an artifact exists but + could not be removed. + """ + if sys.platform == "darwin": + from deepagents_code.url_scheme import _macos + + removed = _macos.uninstall() + elif sys.platform.startswith("linux"): + from deepagents_code.url_scheme import _linux + + removed = _linux.uninstall() + elif sys.platform == "win32": + from deepagents_code.url_scheme import _windows + + removed = _windows.uninstall() + else: + raise RegistrationError(_unsupported_message()) + return handler_status(), removed + + +def handler_status() -> HandlerStatus: + """Report what the operating system knows about the `dcode://` scheme. + + Never raises: status is the command a user runs when something is wrong, so + an unresolvable launcher or an unsupported platform is part of the report + rather than an error. + + Returns: + The current status. + """ + if sys.platform == "darwin": + from deepagents_code.url_scheme import _macos + + return _macos.status() + if sys.platform.startswith("linux"): + from deepagents_code.url_scheme import _linux + + return _linux.status() + if sys.platform == "win32": + from deepagents_code.url_scheme import _windows + + return _windows.status() + return HandlerStatus( + scheme=URL_SCHEME, + platform=sys.platform, + supported=False, + installed=False, + handler_path=None, + launcher=None, + default_handler=None, + detail=_unsupported_message(), + ) + + +def build_status( + *, + installed: bool, + handler_path: str | None, + default_handler: str | None, + detail: str, +) -> HandlerStatus: + """Assemble a `HandlerStatus` for the running platform. + + Shared by the platform backends so every status carries the same scheme, + platform tag, and best-effort launcher path. + + Args: + installed: Whether dcode's handler artifact is present. + handler_path: The artifact's path or registry key, or `None`. + default_handler: What the desktop opens the scheme with, where known. + detail: One-line human-readable summary. + + Returns: + The assembled status. + """ + try: + launcher: Path | None = resolve_launcher() + except RegistrationError: + launcher = None + return HandlerStatus( + scheme=URL_SCHEME, + platform=sys.platform, + supported=True, + installed=installed, + handler_path=handler_path, + launcher=launcher, + default_handler=default_handler, + detail=detail, + ) + + +def _unsupported_message() -> str: + """Return the message for a platform with no registration backend. + + Returns: + The message. + """ + return ( + f"Registering the {URL_SCHEME}:// scheme is supported on " + f"{_SUPPORTED_PLATFORMS}, not on {sys.platform!r}." + ) diff --git a/libs/code/deepagents_code/url_scheme/request.py b/libs/code/deepagents_code/url_scheme/request.py new file mode 100644 index 0000000000..b16f721b4f --- /dev/null +++ b/libs/code/deepagents_code/url_scheme/request.py @@ -0,0 +1,443 @@ +"""Parsing and validation for `dcode://` links. + +A link can be published by any web page, chat message, or email, so this module +is the outer edge of the handler's trust boundary. It decides what a link is +allowed to say before any of it reaches a launch, and the rules are deliberately +narrow: + +- One action, `open`. An unrecognized action is refused, not ignored. +- A closed parameter set. An unknown key is refused, so a link written for a + newer dcode fails loudly here instead of being silently half-honored. +- One value per key. A repeated key is refused rather than resolved by a + first- or last-wins rule the link's author cannot predict. +- No approval, sandbox, model, or hook parameters, and no way to express one. A + link cannot weaken the approval posture of the session it opens, because + `handler` passes the launch only the fields this module produces. +- No silent repair. Text carrying control characters or deceptive Unicode is + refused instead of stripped, so the request the user reads in the + confirmation is the request the session receives. + +Query values are decoded as `application/x-www-form-urlencoded`, which is what +browsers and `URLSearchParams` produce and what `build_open_url` emits. One +consequence is worth knowing when writing a link by hand: `+` decodes to a +space, so a path or prompt containing a literal `+` has to be sent as `%2B`. +`_directory` says so when a decoded path holds a space and does not exist. + +Everything here is shape validation. Text that survives it is still untrusted +prose: `handler` shows the whole request to the user and waits for an explicit +approval before anything runs. +""" + +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Final +from urllib.parse import parse_qsl, urlencode, urlsplit + +URL_SCHEME: Final = "dcode" +"""Scheme dcode registers with the operating system. + +A private scheme in the sense of RFC 7595 section 3.8 β€” not IANA-registered, +and named after the console script so a link reads as the command it runs. +""" + +OPEN_ACTION: Final = "open" +"""The only action a link may request.""" + +MAX_URL_CHARS: Final = 8192 +"""Longest link accepted, matching the URL length browsers reliably carry.""" + +MAX_PROMPT_CHARS: Final = 2000 +"""Longest `prompt` accepted. + +The bound is readability, not capacity: the user has to read the whole prompt in +the confirmation before approving it, and a wall of text is a prompt nobody +reads. Longer instructions belong in the session, typed by the user. +""" + +_PARAMS: Final = frozenset({"dir", "thread", "agent", "prompt"}) +"""Every parameter `open` accepts. Anything else is refused.""" + +_ACTION_RE: Final = re.compile(r"\A[a-z][a-z0-9-]*\Z") +"""Shape of an action token, checked before it is compared or reported.""" + +_AGENT_RE: Final = re.compile(r"\A[A-Za-z0-9_\- ]{1,64}\Z") +"""Shape of an agent name. + +Mirrors the character class `_paths._validate_agent_name` enforces, with a +length bound added. Whether the agent exists is the launch's question, not this +module's. +""" + +_PROMPT_ALLOWED_CONTROLS: Final = frozenset({"\t", "\n"}) +"""Control characters a prompt may contain, so multi-line prompts stay legible.""" + +_C0_END: Final = 0x20 +_C1_START: Final = 0x7F +_C1_END: Final = 0x9F + + +class UrlRequestError(ValueError): + """A link is malformed, or asks for something a link may not ask for. + + The message reaches the user, so it says which part of the link was refused + rather than only that the link was refused. + """ + + +@dataclass(frozen=True) +class OpenRequest: + """A validated `dcode://open` request. + + Attributes: + directory: Existing directory to start the session in, absolute and + symlink-resolved so the confirmation names the real target. + thread: Thread to resume, in canonical UUID form, or `None` for a new + thread. + agent: Agent to launch, or `None` for the configured default. + prompt: Text to submit as the session's first message, or `None`. + """ + + directory: Path + thread: str | None = None + agent: str | None = None + prompt: str | None = None + + +def parse_open_url(raw: str) -> OpenRequest: + """Validate a `dcode://` link and return the request it makes. + + Args: + raw: The link as the operating system delivered it. + + Returns: + The validated request. + + Raises: + UrlRequestError: The link is not a `dcode://open` link this version + understands, or a parameter is malformed, unsupported, or unsafe. + """ + if len(raw) > MAX_URL_CHARS: + msg = f"Link is too long ({len(raw)} characters, limit {MAX_URL_CHARS})." + raise UrlRequestError(msg) + + parts = urlsplit(raw) + if parts.scheme.lower() != URL_SCHEME: + msg = f"Not a {URL_SCHEME}:// link." + raise UrlRequestError(msg) + if parts.fragment: + msg = f"{URL_SCHEME}:// links do not take a fragment." + raise UrlRequestError(msg) + + _require_open_action(parts.netloc, parts.path) + return _build_request(_query_params(parts.query)) + + +def build_open_url( + directory: Path | str, + *, + thread: str | None = None, + agent: str | None = None, + prompt: str | None = None, +) -> str: + """Build a `dcode://open` link. + + The inverse of `parse_open_url`, used for the examples `dcode url status` + prints and by callers that publish links. It encodes rather than validates: + a link it returns is still checked in full when the handler receives it. + + Args: + directory: Directory the link should open. + thread: Thread to resume. + agent: Agent to launch. + prompt: First message to submit. + + Returns: + The encoded link. + """ + query: list[tuple[str, str]] = [("dir", str(directory))] + query += [ + (key, value) + for key, value in (("thread", thread), ("agent", agent), ("prompt", prompt)) + if value is not None + ] + return f"{URL_SCHEME}://{OPEN_ACTION}?{urlencode(query)}" + + +def _require_open_action(netloc: str, path: str) -> None: + """Check that the link names the `open` action and nothing after it. + + Browsers hand over whatever the page wrote, so both spellings a link author + may reach for are accepted: `dcode://open?...` carries the action in the + authority component, and `dcode:open?...` carries it in the path. + + Args: + netloc: Authority component of the link. + path: Path component of the link. + + Raises: + UrlRequestError: The action is missing, unrecognized, carries authority + syntax such as userinfo or a port, or is followed by a path. + """ + if netloc: + action, trailing = netloc, path.strip("/") + else: + action, _, rest = path.lstrip("/").partition("/") + trailing = rest.strip("/") + + if not action: + msg = f"Link names no action; expected {URL_SCHEME}://{OPEN_ACTION}?..." + raise UrlRequestError(msg) + if not _ACTION_RE.match(action): + msg = f"Link action is not a recognized action name: {action[:32]!r}." + raise UrlRequestError(msg) + if action != OPEN_ACTION: + msg = ( + f"Unsupported action {action!r}. This version of dcode handles " + f"{URL_SCHEME}://{OPEN_ACTION} only." + ) + raise UrlRequestError(msg) + if trailing: + msg = f"{URL_SCHEME}://{OPEN_ACTION} takes no path; use query parameters." + raise UrlRequestError(msg) + + +def _query_params(query: str) -> dict[str, str]: + """Decode the query string into at most one value per supported key. + + Args: + query: Raw query component of the link. + + Returns: + Decoded parameters. + + Raises: + UrlRequestError: The query is malformed, repeats a key, or names a key + this version does not support. + """ + if not query: + return {} + try: + pairs = parse_qsl(query, keep_blank_values=True, strict_parsing=True) + except ValueError as exc: + msg = f"Link query could not be decoded: {exc}" + raise UrlRequestError(msg) from exc + + params: dict[str, str] = {} + for key, value in pairs: + if key not in _PARAMS: + supported = ", ".join(sorted(_PARAMS)) + msg = ( + f"Unsupported parameter {key[:32]!r}. Supported parameters: " + f"{supported}. A link written for a newer dcode may need a " + "dcode update." + ) + raise UrlRequestError(msg) + if key in params: + msg = f"Parameter {key!r} appears more than once." + raise UrlRequestError(msg) + params[key] = value + return params + + +def _build_request(params: dict[str, str]) -> OpenRequest: + """Validate decoded parameters into an `OpenRequest`. + + Args: + params: Decoded query parameters. + + Returns: + The validated request. + + Raises: + UrlRequestError: `dir` is missing or unusable, or another parameter is + malformed. + """ + raw_dir = params.get("dir") + if not raw_dir: + msg = ( + "Link is missing the 'dir' parameter, which says where to open the session." + ) + raise UrlRequestError(msg) + return OpenRequest( + directory=_directory(raw_dir), + thread=_thread(params.get("thread")), + agent=_agent(params.get("agent")), + prompt=_prompt(params.get("prompt")), + ) + + +def _directory(value: str) -> Path: + """Resolve the `dir` parameter to an existing directory. + + A relative path is refused rather than joined onto something: the handler + runs in whatever directory the desktop launcher happened to choose, which + has nothing to do with the link's author, so a relative path has no meaning + here. + + Args: + value: Raw `dir` value. + + Returns: + The absolute, symlink-resolved directory. + + Raises: + UrlRequestError: The path is not absolute, does not exist, is + unreadable, or is not a directory. + """ + _reject_control_chars(value, field="dir", allowed=frozenset()) + try: + expanded = Path(value).expanduser() + except (RuntimeError, ValueError) as exc: + # `expanduser` raises when `~` cannot be resolved to a home directory. + msg = f"Link directory could not be read as a path: {value[:120]!r}" + raise UrlRequestError(msg) from exc + + if not expanded.is_absolute(): + msg = ( + "Link directory must be an absolute path (or start with '~'): " + f"{value[:120]!r}" + ) + raise UrlRequestError(msg) + + try: + resolved = expanded.resolve(strict=True) + is_dir = resolved.is_dir() + except OSError as exc: + msg = f"Link directory does not exist or is unreadable: {expanded}" + if " " in value: + # The likeliest cause of a path that gained a space: a query decodes + # `+` as a space, so a literal `+` has to be written as `%2B`. + msg += " (a '+' in a path must be encoded as %2B)" + raise UrlRequestError(msg) from exc + if not is_dir: + msg = f"Link directory is not a directory: {resolved}" + raise UrlRequestError(msg) + return resolved + + +def _thread(value: str | None) -> str | None: + """Validate the `thread` parameter as a thread identifier. + + Thread ids are UUIDs (`sessions.generate_thread_id` mints UUID7), so the + value is parsed as one and returned in canonical form. That also keeps the + resume sentinels the CLI understands out of a link's reach: `-r` reads + `__MOST_RECENT__` as "resume whatever I last worked on", which is not a + thread a link's author is in a position to name. + + Args: + value: Raw `thread` value, or `None`. + + Returns: + The canonical UUID string, or `None`. + + Raises: + UrlRequestError: The value is not a UUID. + """ + if value is None: + return None + try: + return str(uuid.UUID(value)) + except ValueError as exc: + msg = f"Link thread id is not a valid thread id: {value[:64]!r}" + raise UrlRequestError(msg) from exc + + +def _agent(value: str | None) -> str | None: + """Validate the `agent` parameter as an agent name. + + Args: + value: Raw `agent` value, or `None`. + + Returns: + The agent name, or `None`. + + Raises: + UrlRequestError: The name has an unusable shape, or is reserved for + dcode's own state. + """ + if value is None: + return None + if not _AGENT_RE.match(value): + msg = ( + f"Link agent name is not a usable agent name: {value[:64]!r}. Agent " + "names hold letters, numbers, hyphens, underscores, and spaces." + ) + raise UrlRequestError(msg) + from deepagents_code._reserved_names import is_reserved_agent_dir_name + + if is_reserved_agent_dir_name(value): + msg = f"Link agent name {value!r} is reserved for dcode's own state." + raise UrlRequestError(msg) + return value + + +def _prompt(value: str | None) -> str | None: + """Validate the `prompt` parameter as text safe to display and to submit. + + The prompt is the one parameter that is prose rather than a shape, and the + confirmation shows it verbatim. Anything that could make the rendered text + disagree with the submitted text β€” escape sequences, bidi overrides, + invisible code points β€” is refused rather than stripped, so the two cannot + diverge. + + Args: + value: Raw `prompt` value, or `None`. + + Returns: + The prompt text, or `None` when absent or blank. + + Raises: + UrlRequestError: The prompt is too long, or carries control characters + or deceptive Unicode. + """ + if value is None or not value.strip(): + return None + if len(value) > MAX_PROMPT_CHARS: + msg = ( + f"Link prompt is too long ({len(value)} characters, limit " + f"{MAX_PROMPT_CHARS}). Open the session and type the rest." + ) + raise UrlRequestError(msg) + _reject_control_chars(value, field="prompt", allowed=_PROMPT_ALLOWED_CONTROLS) + + from deepagents_code.unicode_security import ( + detect_dangerous_unicode, + summarize_issues, + ) + + issues = detect_dangerous_unicode(value) + if issues: + msg = ( + "Link prompt contains hidden or direction-changing characters " + f"({summarize_issues(issues)}), so what you would read is not " + "necessarily what would be sent." + ) + raise UrlRequestError(msg) + return value + + +def _reject_control_chars(value: str, *, field: str, allowed: frozenset[str]) -> None: + """Refuse text carrying control characters. + + Args: + value: Text to check. + field: Parameter name, named in the message. + allowed: Control characters this field may contain. + + Raises: + UrlRequestError: The text holds a C0 or C1 control character outside + `allowed`. + """ + for char in value: + code = ord(char) + if char in allowed or not (code < _C0_END or _C1_START <= code <= _C1_END): + continue + msg = ( + f"Link {field} contains a control character (U+{code:04X}), which a " + "link may not carry." + ) + raise UrlRequestError(msg) diff --git a/libs/code/pyproject.toml b/libs/code/pyproject.toml index ebd546817e..ab1471707f 100644 --- a/libs/code/pyproject.toml +++ b/libs/code/pyproject.toml @@ -230,6 +230,18 @@ extra-paths = [ "../partners/vercel", ] +[[tool.ty.overrides]] +# `winreg` ships only on Windows and `ty` resolves the standard library against +# the platform it is checking on, so every member access in the Windows-only +# URL-scheme backend is unresolvable when CI checks on Linux or macOS. This is +# categorical for the whole module rather than a hidden per-line exception: +# `url_scheme.registration` reaches it only under `sys.platform == "win32"`, and +# every `winreg` use is a call-time import inside a function for that reason. +include = ["deepagents_code/url_scheme/_windows.py"] + +[tool.ty.overrides.rules] +unresolved-attribute = "ignore" + [tool.ty.rules] # https://docs.astral.sh/ty/rules/ division-by-zero = "error" diff --git a/libs/code/tests/unit_tests/test_url_scheme.py b/libs/code/tests/unit_tests/test_url_scheme.py new file mode 100644 index 0000000000..a217482f80 --- /dev/null +++ b/libs/code/tests/unit_tests/test_url_scheme.py @@ -0,0 +1,647 @@ +"""Tests for `dcode://` link validation, registration, and handling.""" + +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +import pytest + +from deepagents_code.url_scheme import ( + MAX_PROMPT_CHARS, + URL_SCHEME, + RegistrationError, + TerminalChoice, + UrlRequestError, + _linux, + _macos, + _windows, + build_open_url, + handler, + handler_status, + parse_open_url, + resolve_launcher, +) +from deepagents_code.url_scheme.registration import install_handler, uninstall_handler + +THREAD_ID = str(uuid.uuid4()) + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + directory = tmp_path / "project" + directory.mkdir() + return directory.resolve() + + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + return home + + +class TestParseAccepts: + def test_directory_only(self, project: Path) -> None: + assert parse_open_url(f"dcode://open?dir={project}").directory == project + + def test_opaque_form_without_authority(self, project: Path) -> None: + """`dcode:open?...` is what some link authors write; both spellings work.""" + request = parse_open_url(f"dcode:open?dir={project}") + assert request.directory == project + + def test_trailing_slash_after_action(self, project: Path) -> None: + assert parse_open_url(f"dcode://open/?dir={project}").directory == project + + def test_scheme_is_case_insensitive(self, project: Path) -> None: + assert parse_open_url(f"DCode://open?dir={project}").directory == project + + def test_all_parameters(self, project: Path) -> None: + url = build_open_url( + project, thread=THREAD_ID, agent="research", prompt="review the diff" + ) + request = parse_open_url(url) + assert request.directory == project + assert request.thread == THREAD_ID + assert request.agent == "research" + assert request.prompt == "review the diff" + + def test_plus_in_a_path_needs_percent_encoding(self, tmp_path: Path) -> None: + """Form decoding turns `+` into a space, as a browser would.""" + directory = tmp_path / "c++proj" + directory.mkdir() + encoded = str(directory).replace("+", "%2B") + assert parse_open_url(f"dcode://open?dir={encoded}").directory == ( + directory.resolve() + ) + + def test_percent_and_plus_encoding_decode(self, project: Path) -> None: + request = parse_open_url(f"dcode://open?dir={project}&prompt=a+b%2Bc%20d") + assert request.prompt == "a b+c d" + + def test_tilde_expands_to_home(self, fake_home: Path) -> None: + (fake_home / "proj").mkdir() + request = parse_open_url("dcode://open?dir=~/proj") + assert request.directory == (fake_home / "proj").resolve() + + def test_symlinked_directory_resolves_to_target(self, tmp_path: Path) -> None: + """The confirmation has to name the directory that is actually opened.""" + target = tmp_path / "real" + target.mkdir() + link = tmp_path / "link" + link.symlink_to(target) + assert parse_open_url(f"dcode://open?dir={link}").directory == target.resolve() + + def test_blank_prompt_is_dropped(self, project: Path) -> None: + assert parse_open_url(f"dcode://open?dir={project}&prompt=%20").prompt is None + + def test_multiline_prompt_is_kept(self, project: Path) -> None: + url = build_open_url(project, prompt="first\nsecond") + assert parse_open_url(url).prompt == "first\nsecond" + + def test_thread_id_is_canonicalized(self, project: Path) -> None: + upper = THREAD_ID.upper() + assert parse_open_url(f"dcode://open?dir={project}&thread={upper}").thread == ( + THREAD_ID + ) + + def test_round_trips_through_build(self, project: Path) -> None: + request = parse_open_url( + build_open_url(project, thread=THREAD_ID, agent="a b", prompt="x&y=z?") + ) + assert request.agent == "a b" + assert request.prompt == "x&y=z?" + assert request.thread == THREAD_ID + + +class TestParseRefuses: + def test_other_scheme(self) -> None: + with pytest.raises(UrlRequestError, match="Not a dcode:// link"): + parse_open_url("https://example.com") + + def test_unknown_action(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="Unsupported action 'run'"): + parse_open_url(f"dcode://run?dir={project}") + + def test_missing_action(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="names no action"): + parse_open_url(f"dcode://?dir={project}") + + def test_authority_syntax_in_action(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="not a recognized action name"): + parse_open_url(f"dcode://user@open?dir={project}") + + def test_path_after_action(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="takes no path"): + parse_open_url(f"dcode://open/extra?dir={project}") + + def test_fragment(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="do not take a fragment"): + parse_open_url(f"dcode://open?dir={project}#frag") + + def test_unknown_parameter(self, project: Path) -> None: + """Forward compatibility is traded away on purpose: unknown keys fail.""" + with pytest.raises(UrlRequestError, match="Unsupported parameter 'yolo'"): + parse_open_url(f"dcode://open?dir={project}&yolo=1") + + @pytest.mark.parametrize( + "smuggled", + ["auto-approve=1", "approval=yolo", "sandbox=daytona", "model=gpt-5.5"], + ) + def test_session_altering_parameters(self, project: Path, smuggled: str) -> None: + with pytest.raises(UrlRequestError, match="Unsupported parameter"): + parse_open_url(f"dcode://open?dir={project}&{smuggled}") + + def test_repeated_parameter(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="appears more than once"): + parse_open_url(f"dcode://open?dir={project}&dir={project}") + + def test_missing_dir(self) -> None: + with pytest.raises(UrlRequestError, match="missing the 'dir' parameter"): + parse_open_url("dcode://open?agent=research") + + def test_relative_dir(self) -> None: + with pytest.raises(UrlRequestError, match="must be an absolute path"): + parse_open_url("dcode://open?dir=../elsewhere") + + def test_missing_dir_on_disk(self, tmp_path: Path) -> None: + with pytest.raises(UrlRequestError, match="does not exist or is unreadable"): + parse_open_url(f"dcode://open?dir={tmp_path / 'absent'}") + + def test_unencoded_plus_explains_itself(self, tmp_path: Path) -> None: + """The decoded path gains a space, which is a confusing symptom alone.""" + directory = tmp_path / "c++proj" + directory.mkdir() + with pytest.raises(UrlRequestError, match=r"encoded as %2B"): + parse_open_url(f"dcode://open?dir={directory}") + + def test_dir_that_is_a_file(self, tmp_path: Path) -> None: + target = tmp_path / "file.txt" + target.write_text("x", encoding="utf-8") + with pytest.raises(UrlRequestError, match="is not a directory"): + parse_open_url(f"dcode://open?dir={target}") + + def test_non_uuid_thread(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="not a valid thread id"): + parse_open_url(f"dcode://open?dir={project}&thread=latest") + + def test_resume_sentinel_is_unreachable(self, project: Path) -> None: + """`-r __MOST_RECENT__` is not a thread a link's author can name.""" + with pytest.raises(UrlRequestError, match="not a valid thread id"): + parse_open_url(f"dcode://open?dir={project}&thread=__MOST_RECENT__") + + def test_agent_with_path_separator(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="not a usable agent name"): + parse_open_url(f"dcode://open?dir={project}&agent=../../etc") + + @pytest.mark.parametrize("reserved", ["bin", "Bin", "plugins"]) + def test_reserved_agent_name(self, project: Path, reserved: str) -> None: + with pytest.raises(UrlRequestError, match="reserved"): + parse_open_url(f"dcode://open?dir={project}&agent={reserved}") + + @pytest.mark.parametrize("control", ["%1B", "%00", "%07", "%C2%9B"]) + def test_control_characters_in_prompt(self, project: Path, control: str) -> None: + """An escape sequence could redraw the confirmation it is shown in.""" + with pytest.raises(UrlRequestError, match="control character"): + parse_open_url(f"dcode://open?dir={project}&prompt=hi{control}there") + + def test_control_characters_in_dir(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="control character"): + parse_open_url(f"dcode://open?dir={project}%00/etc") + + def test_bidi_override_in_prompt(self, project: Path) -> None: + """Displayed text and submitted text must not be able to disagree.""" + url = f"dcode://open?dir={project}&prompt=delete%20%E2%80%AEtxt.evil" + with pytest.raises(UrlRequestError, match="hidden or direction-changing"): + parse_open_url(url) + + def test_overlong_prompt(self, project: Path) -> None: + long_prompt = "x" * (MAX_PROMPT_CHARS + 1) + with pytest.raises(UrlRequestError, match="prompt is too long"): + parse_open_url(build_open_url(project, prompt=long_prompt)) + + def test_overlong_url(self, project: Path) -> None: + with pytest.raises(UrlRequestError, match="Link is too long"): + parse_open_url(f"dcode://open?dir={project}&prompt={'x' * 9000}") + + +class TestResolveLauncher: + def test_prefers_absolute_argv0( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + script = tmp_path / "dcode" + script.write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setattr(sys, "argv", [str(script), "url", "install"]) + assert resolve_launcher() == script + + def test_falls_back_to_path_lookup( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """`python -m deepagents_code` reports a module file, not a command.""" + script = tmp_path / "dcode" + script.write_text("#!/bin/sh\n", encoding="utf-8") + script.chmod(0o755) + monkeypatch.setattr(sys, "argv", ["__main__.py"]) + monkeypatch.setenv("PATH", str(tmp_path)) + assert resolve_launcher() == script + + def test_raises_when_nothing_is_installed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sys, "argv", ["__main__.py"]) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + with pytest.raises(RegistrationError, match="Could not find the dcode command"): + resolve_launcher() + + +class TestUnsupportedPlatform: + def test_status_reports_unsupported(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "sunos5") + status = handler_status() + assert not status.supported + assert not status.installed + assert "sunos5" in status.detail + + def test_install_refuses(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "sunos5") + with pytest.raises(RegistrationError, match="sunos5"): + install_handler() + + def test_uninstall_refuses(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "sunos5") + with pytest.raises(RegistrationError, match="sunos5"): + uninstall_handler() + + +class TestLinuxBackend: + @pytest.mark.usefixtures("fake_home") + def test_desktop_entry_declares_the_scheme(self) -> None: + entry = _linux._desktop_entry(Path("/usr/bin/dcode")) + assert f"MimeType=x-scheme-handler/{URL_SCHEME};" in entry + assert 'Exec="/usr/bin/dcode" url open %u' in entry + # A TUI needs a terminal window, which the desktop supplies. + assert "Terminal=true" in entry + + def test_exec_quotes_a_path_with_spaces(self) -> None: + assert ( + _linux._exec_value(Path("/opt/my tools/dcode")) + == '"/opt/my tools/dcode" url open %u' + ) + + @pytest.mark.parametrize("bad", ['/opt/we"ird/dcode', "/opt/back\\slash/dcode"]) + def test_exec_refuses_unrepresentable_paths(self, bad: str) -> None: + with pytest.raises(RegistrationError, match="Exec line cannot carry"): + _linux._exec_value(Path(bad)) + + def test_install_writes_entry_and_mimeapps_fallback( + self, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without `xdg-utils`, the association is written directly.""" + monkeypatch.setattr(_linux.shutil, "which", lambda _name: None) + entry = _linux.install(Path("/usr/bin/dcode")) + + assert ( + entry == fake_home / ".local/share/applications/dcode-url-handler.desktop" + ) + assert entry.is_file() + mimeapps = (fake_home / ".config/mimeapps.list").read_text(encoding="utf-8") + assert "[Default Applications]" in mimeapps + assert f"x-scheme-handler/{URL_SCHEME}={_linux.DESKTOP_FILE_NAME}" in mimeapps + + @pytest.mark.usefixtures("fake_home") + def test_install_honors_xdg_data_home( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(_linux.shutil, "which", lambda _name: None) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data")) + entry = _linux.install(Path("/usr/bin/dcode")) + assert entry.parent == tmp_path / "xdg-data" / "applications" + + def test_install_uses_xdg_mime_when_present( + self, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + calls: list[list[str]] = [] + monkeypatch.setattr(_linux.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr( + _linux, + "_run", + lambda argv, *, what: calls.append(argv) or True, # noqa: ARG005 + ) + _linux.install(Path("/usr/bin/dcode")) + + assert [ + "/usr/bin/xdg-mime", + "default", + _linux.DESKTOP_FILE_NAME, + f"x-scheme-handler/{URL_SCHEME}", + ] in calls + # `xdg-mime` owns the association, so mimeapps.list is left alone. + assert not (fake_home / ".config/mimeapps.list").exists() + + def test_mimeapps_edit_preserves_other_entries( + self, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(_linux.shutil, "which", lambda _name: None) + mimeapps = fake_home / ".config" / "mimeapps.list" + mimeapps.parent.mkdir(parents=True) + mimeapps.write_text( + "[Default Applications]\ntext/html=firefox.desktop\n", encoding="utf-8" + ) + + _linux.install(Path("/usr/bin/dcode")) + assert "text/html=firefox.desktop" in mimeapps.read_text(encoding="utf-8") + + _linux.uninstall() + after = mimeapps.read_text(encoding="utf-8") + assert "text/html=firefox.desktop" in after + assert _linux.DESKTOP_FILE_NAME not in after + + def test_uninstall_leaves_a_foreign_association_alone( + self, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(_linux.shutil, "which", lambda _name: None) + mimeapps = fake_home / ".config" / "mimeapps.list" + mimeapps.parent.mkdir(parents=True) + mimeapps.write_text( + f"[Default Applications]\nx-scheme-handler/{URL_SCHEME}=other.desktop\n", + encoding="utf-8", + ) + _linux.uninstall() + assert "other.desktop" in mimeapps.read_text(encoding="utf-8") + + @pytest.mark.usefixtures("fake_home") + def test_uninstall_is_idempotent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_linux.shutil, "which", lambda _name: None) + assert _linux.uninstall() == [] + _linux.install(Path("/usr/bin/dcode")) + assert len(_linux.uninstall()) >= 1 + assert _linux.uninstall() == [] + + def test_status_notices_a_foreign_default( + self, fake_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(_linux.shutil, "which", lambda _name: None) + _linux.install(Path("/usr/bin/dcode")) + mimeapps = fake_home / ".config" / "mimeapps.list" + mimeapps.write_text( + f"[Default Applications]\nx-scheme-handler/{URL_SCHEME}=other.desktop\n", + encoding="utf-8", + ) + status = _linux.status() + assert status.installed + assert status.default_handler == "other.desktop" + assert "other.desktop" in status.detail + + +class TestMacosBackend: + def test_applet_quotes_the_launcher_path(self) -> None: + source = _macos._applet_source( + Path("/opt/my tools/dcode"), TerminalChoice.TERMINAL + ) + assert "'/opt/my tools/dcode' url open" in source + # The link itself is quoted by AppleScript at dispatch time. + assert "quoted form of this_URL" in source + + def test_applet_escapes_quotes_in_the_launcher_path(self) -> None: + """A quote in the path must survive both quoting layers intact.""" + source = _macos._applet_source(Path("/opt/we'ird/dcode"), TerminalChoice.ITERM) + # `shlex.quote` closes and reopens the single-quoted run around the + # quote; each resulting double quote is then escaped for AppleScript. + assert r"""'/opt/we'\"'\"'ird/dcode'""" in source + assert "iTerm" in source + + def test_applet_source_is_ascii(self) -> None: + """`osacompile` reads the generated source; ASCII keeps that portable.""" + source = _macos._applet_source(Path("/usr/bin/dcode"), TerminalChoice.TERMINAL) + assert source.isascii() + + def test_explicit_iterm_without_iterm_reports_why( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(_macos, "_iterm_available", lambda: False) + with pytest.raises(RegistrationError, match="iTerm does not appear"): + _macos._resolve_terminal(TerminalChoice.ITERM) + + def test_auto_falls_back_when_iterm_is_absent( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from deepagents_code._env_vars import LAUNCH_TERM_PROGRAM + + monkeypatch.setenv(LAUNCH_TERM_PROGRAM, "iTerm.app") + monkeypatch.setattr(_macos, "_iterm_available", lambda: False) + assert _macos._resolve_terminal(TerminalChoice.AUTO) is TerminalChoice.TERMINAL + + def test_auto_picks_iterm_when_available( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from deepagents_code._env_vars import LAUNCH_TERM_PROGRAM + + monkeypatch.setenv(LAUNCH_TERM_PROGRAM, "iTerm.app") + monkeypatch.setattr(_macos, "_iterm_available", lambda: True) + assert _macos._resolve_terminal(TerminalChoice.AUTO) is TerminalChoice.ITERM + + def test_metadata_declares_the_scheme(self, tmp_path: Path) -> None: + import plistlib + + bundle = tmp_path / "dcode.app" + plist_path = bundle / "Contents" / "Info.plist" + plist_path.parent.mkdir(parents=True) + with plist_path.open("wb") as handle: + plistlib.dump({"CFBundleName": "applet"}, handle) + + _macos._write_bundle_metadata( + bundle, launcher=Path("/usr/bin/dcode"), terminal=TerminalChoice.TERMINAL + ) + plist = plistlib.loads(plist_path.read_bytes()) + assert plist["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == [URL_SCHEME] + assert plist["CFBundleIdentifier"] == _macos.BUNDLE_ID + # Browsers label their prompt with the display name. + assert plist["CFBundleDisplayName"] == "dcode" + + def test_refuses_to_replace_another_application(self, tmp_path: Path) -> None: + import plistlib + + bundle = tmp_path / "dcode.app" + plist_path = bundle / "Contents" / "Info.plist" + plist_path.parent.mkdir(parents=True) + with plist_path.open("wb") as handle: + plistlib.dump({"CFBundleIdentifier": "com.example.other"}, handle) + + with pytest.raises(RegistrationError, match="belongs to another application"): + _macos._clear_existing_bundle(bundle) + + @pytest.mark.usefixtures("fake_home") + def test_uninstall_refuses_a_foreign_bundle(self) -> None: + import plistlib + + bundle = _macos.app_path() + plist_path = bundle / "Contents" / "Info.plist" + plist_path.parent.mkdir(parents=True) + with plist_path.open("wb") as handle: + plistlib.dump({"CFBundleIdentifier": "com.example.other"}, handle) + + with pytest.raises(RegistrationError, match="not dcode's URL handler"): + _macos.uninstall() + assert bundle.exists() + + @pytest.mark.usefixtures("fake_home") + def test_macos_uninstall_is_idempotent(self) -> None: + assert _macos.uninstall() == () + + @pytest.mark.skipif(sys.platform != "darwin", reason="needs osacompile") + @pytest.mark.usefixtures("fake_home") + def test_generated_applet_compiles(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Guards the AppleScript itself: a syntax slip only shows up here.""" + if not _macos._OSACOMPILE.is_file(): + pytest.skip("osacompile is unavailable") + # Keep Launch Services out of the test's way; the applet build is the + # subject, and registering a temp-directory bundle is a real side effect. + monkeypatch.setattr(_macos, "_LSREGISTER", Path("/nonexistent/lsregister")) + + bundle = _macos.install( + Path("/usr/bin/dcode"), terminal=TerminalChoice.TERMINAL + ) + assert bundle.is_dir() + status = _macos.status() + assert status.installed + assert status.handler_path == str(bundle) + assert _macos.uninstall() == (str(bundle),) + + +class TestWindowsBackend: + def test_key_path_is_per_user(self) -> None: + assert f"Software\\Classes\\{URL_SCHEME}" == _windows.KEY_PATH + + +class TestHandler: + def test_refuses_a_bad_link_without_launching( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr( + handler, "_launch", lambda _request: pytest.fail("must not launch") + ) + assert handler.open_from_url("dcode://open?dir=relative") == ( + handler.EXIT_REFUSED + ) + + def test_declines_when_the_terminal_cannot_be_asked( + self, project: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Fail closed: no interactive terminal means no approval, so no launch.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr( + handler, "_launch", lambda _request: pytest.fail("must not launch") + ) + assert handler.open_from_url(f"dcode://open?dir={project}") == ( + handler.EXIT_DECLINED + ) + + def test_declines_when_the_user_says_no( + self, project: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(handler, "_confirm", lambda _request: False) + monkeypatch.setattr( + handler, "_launch", lambda _request: pytest.fail("must not launch") + ) + assert handler.open_from_url(f"dcode://open?dir={project}") == ( + handler.EXIT_DECLINED + ) + + def test_launches_in_the_requested_directory( + self, project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + launcher = tmp_path / "dcode" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + recorded: dict[str, object] = {} + + def fake_execv(path: str, argv: list[str]) -> None: + recorded["path"] = path + recorded["argv"] = argv + raise SystemExit(0) + + monkeypatch.setattr(handler, "_confirm", lambda _request: True) + monkeypatch.setattr(handler, "resolve_launcher", lambda: launcher) + monkeypatch.setattr( + os, "chdir", lambda target: recorded.setdefault("cwd", target) + ) + monkeypatch.setattr(os, "execv", fake_execv) + + url = build_open_url(project, thread=THREAD_ID, agent="research", prompt="hi") + with pytest.raises(SystemExit): + handler.open_from_url(url) + + assert recorded["cwd"] == project + assert recorded["argv"] == [ + str(launcher), + "-a", + "research", + "-r", + THREAD_ID, + "-m", + "hi", + ] + + def test_launch_argv_carries_no_approval_flags( + self, project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A link must not be able to widen what the session may do unattended.""" + launcher = tmp_path / "dcode" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + recorded: list[str] = [] + + def fake_execv(_path: str, argv: list[str]) -> None: + recorded.extend(argv) + raise SystemExit(0) + + monkeypatch.setattr(handler, "_confirm", lambda _request: True) + monkeypatch.setattr(handler, "resolve_launcher", lambda: launcher) + monkeypatch.setattr(os, "chdir", lambda _target: None) + monkeypatch.setattr(os, "execv", fake_execv) + + with pytest.raises(SystemExit): + handler.open_from_url(build_open_url(project, prompt="go")) + + forbidden = {"-y", "--auto-approve", "--yolo", "--sandbox", "-M", "--model"} + assert forbidden.isdisjoint(recorded) + + def test_reports_a_missing_dcode_command( + self, project: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + def raise_missing() -> Path: + msg = "Could not find the dcode command to register." + raise RegistrationError(msg) + + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(handler, "_confirm", lambda _request: True) + monkeypatch.setattr(handler, "resolve_launcher", raise_missing) + assert handler.open_from_url(f"dcode://open?dir={project}") == ( + handler.EXIT_REFUSED + ) + + def test_typed_confirmation_requires_an_explicit_yes( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("builtins.input", lambda *_args: "y") + assert not handler._confirm_by_typing("Open this session") + monkeypatch.setattr("builtins.input", lambda *_args: " YES ") + assert handler._confirm_by_typing("Open this session") + + def test_typed_confirmation_declines_on_eof( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + def raise_eof(*_args: object) -> str: + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + assert not handler._confirm_by_typing("Open this session") + + def test_prompt_preview_summarizes_a_long_tail(self) -> None: + lines = handler._prompt_preview("\n".join(f"line {n}" for n in range(30))) + assert len(lines) == handler._PROMPT_PREVIEW_LINES + 1 + assert "more line" in lines[-1]