Skip to content

feat(code): add dcode:// URL scheme support - #6005

Draft
Andy Young (ayoung19) wants to merge 1 commit into
mainfrom
andyyoung/code/url-scheme
Draft

feat(code): add dcode:// URL scheme support#6005
Andy Young (ayoung19) wants to merge 1 commit into
mainfrom
andyyoung/code/url-scheme

Conversation

@ayoung19

Copy link
Copy Markdown

Pages can now offer to open a project in dcode: run dcode url install and a dcode://open?dir=/path/to/project link opens a session there, after your browser asks whether to hand the link over and dcode shows you the whole request and waits for your approval.


Editors, chat clients, and Claude Code itself are reachable from a browser through a private URL scheme, and dcode had no equivalent. This adds one, as a dcode url command group: install, uninstall, and status, plus the open verb a registration points at.

A registered handler is a remote-reachable entry point — any page, chat message, or email can emit a link — so the design puts the interesting parts in two places and keeps everything else dumb.

url_scheme.request is the trust boundary. It admits one action and a closed parameter set (dir, thread, agent, prompt) and refuses rather than repairs: an unknown or repeated key, a non-UUID thread id, a relative or non-existent directory, prompt text carrying control characters or deceptive Unicode. Refusing unknown keys trades forward compatibility for the guarantee that a link written for a newer dcode cannot be half-honored by an older one. Refusing rather than stripping deceptive text means the request the user reads is the request the session receives. Requiring a UUID also keeps the -r __MOST_RECENT__ sentinel out of a link's reach.

url_scheme.handler is the gate. A browser's own prompt names an application rather than a request — it does not show which directory would be opened or what would be typed — and browsers offer to remember the answer, after which it stops appearing. So dcode asks its own question in the terminal it was given: the resolved directory, agent, thread, and full prompt text, with the cancel row preselected, failing closed on a non-interactive terminal or an unavailable picker. There is no flag that skips it. The launch argv holds only -a, -r, and -m, so a link cannot change the session's approval mode, model, or sandbox; everything a link can ask for is something the session still gates on its own terms, including the project-hook, MCP, and extension trust prompts.

Registration is per-user and needs no elevation, one backend per desktop stack — an AppleScript applet in ~/Applications declaring CFBundleURLTypes, an XDG desktop entry with Terminal=true, the HKCU protocol key — and it never happens on package install, because claiming a URL scheme changes how the user's browser behaves. Each backend also avoids handing link text to a shell: %u and "%1" expand into an argument vector, and the macOS applet quotes the dcode path with shlex.quote when generated and the link with AppleScript's own quoted form of at dispatch. Uninstall only removes artifacts dcode created, and refuses a bundle or registry key belonging to something else.

macOS resolves an application's AppleScript terminology at build time, so --terminal iterm is checked before the applet is compiled and auto degrades to Terminal.app; otherwise an absent iTerm surfaced as a compiler error.

pyproject.toml gains a ty override scoped to the Windows-only backend: winreg ships only on Windows and ty resolves the standard library against the platform it checks on, so every member access there is unresolvable on Linux and macOS runners. It is recorded as a whole-module policy rather than twenty per-line suppressions, since the module is reachable only under sys.platform == "win32".

THREAT_MODEL.md records the new boundary (TB15), flow (DF30), and threat (T18) with its controls, and separates the two consequences worth distinguishing: opening an attacker-named directory is the same exposure as running dcode there by hand, while an attacker-written first message is the sharper case.

No new dependencies — the whole feature is standard library (plistlib, configparser, winreg, urllib.parse).

Testing

82 unit tests in tests/unit_tests/test_url_scheme.py; the full package suite passes (7744 passed, 1 skipped). Coverage includes the accepted and refused link shapes, attempts to smuggle session-altering parameters, launcher resolution, the Linux desktop entry and its mimeapps.list fallback (including leaving a foreign association alone), the macOS applet's quoting and plist, and the handler's fail-closed paths and launch argv.

Verified by hand on macOS beyond the suite: the generated applet compiles under osacompile and round-trips install/status/uninstall against a throwaway HOME; on a real pty the confirmation renders, a bare Enter declines with exit 1, and approving replaces the process with a real dcode session in the same pid.

One note for reviewers: the new help screens write [options] in their usage line, which Rich swallows as markup — the existing dcode tools --help and friends have the same behavior, so these match the surrounding code rather than fixing it here.

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 <link>` 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) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Banned trailer in a PR commit message.

An individual commit message matches Co-authored-by: ... <noreply@anthropic.com>. A squash merge drops individual commit messages, but a rebase or merge-commit pushes them to main, where an organization ruleset rejects any commit whose message matches that pattern. Remove the trailer so the PR can be merged by any method.

Found:

commit d06a2a99dce2, line 24: Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Fix

If the trailer appears in an individual commit, rewrite that commit message to remove the offending line.

@github-actions

Copy link
Copy Markdown
Contributor

This PR edits a project README but is not a docs PR.

  • libs/code/README.md

Change the PR title type to docs if the PR is documentation-only. Otherwise, apply the readme: acknowledged label to confirm these landing-page changes are intentional.

@github-actions github-actions Bot added dcode Related to `deepagents-code` dependencies Pull requests that update a dependency file feature New feature/enhancement or request for one internal User is a member of the `langchain-ai` GitHub organization size: XL 1000+ LOC labels Aug 31, 2026

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open SWE Review found 4 potential issues.

Open in WebView Open SWE trace

Comment on lines +313 to +321
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Install can delete an unrelated app bundle

When ~/Applications/dcode.app exists but its Info.plist is missing, malformed, unreadable, or simply lacks CFBundleIdentifier, _bundle_identifier() returns None. This condition only refuses a non-None foreign identifier, so installation proceeds to recursively delete the entire existing bundle. Refuse replacement unless the identifier is exactly BUNDLE_ID; otherwise an explicit install can destroy unrelated user data at this path.

(Refers to lines 313-321)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

Comment on lines +329 to +333
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=("=",))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Malformed MIME config gets overwritten

If mimeapps.list is unreadable or malformed, this returns a fresh empty parser after logging that the file will be “left alone.” On systems without a working xdg-mime, _write_mimeapps_default() then adds only dcode's entry and _save_mimeapps() overwrites the original file, discarding all of the user's existing default-application associations. Propagate a read/parse failure (or skip the fallback write) instead of representing it as an empty configuration.

(Refers to lines 329-333)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

Comment on lines +208 to +224
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",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Hidden prompt tail bypasses confirmation

A URL can put benign text in the first 12 lines and attacker-controlled instructions after it. This preview replaces that tail with a count, but _launch still passes the complete prompt via -m, so the user can approve a first message they were never shown. That breaks the confirmation gate for the most security-sensitive field; render every accepted line (or reject prompts that cannot be shown in full) before offering approval.

(Refers to lines 208-224)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

Comment on lines +291 to +293
_reject_control_chars(value, field="dir", allowed=frozenset())
try:
expanded = Path(value).expanduser()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Deceptive Unicode remains allowed in directories

The directory is the value the warning explicitly asks the user to recognize, but this path only rejects C0/C1 controls. An existing directory whose name contains bidi overrides or invisible characters therefore renders misleadingly in the confirmation and is then used as the working directory, even though the same URL rejects those characters in prompts to keep displayed and acted-on text identical. Apply the dangerous-Unicode validation to dir before resolving it as well.

(Refers to lines 291-293)


Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.

@corridor-security corridor-security Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new dcode:// URL handler truncates prompt previews at 12 lines during the confirmation dialog, but passes the complete prompt to the session on approval — allowing an attacker to hide malicious instructions in lines beyond the visible window while the user sees only benign content.

if len(lines) <= _PROMPT_PREVIEW_LINES:
return lines
hidden = len(lines) - _PROMPT_PREVIEW_LINES
return [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The confirmation dialog shows at most 12 lines of the URL-supplied prompt, but _launch() passes the full text unconditionally. An attacker can hide malicious instructions beyond the visible window and obtain approval for content the user never read.

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",
]

Remediation: Either reject prompts with more lines than _PROMPT_PREVIEW_LINES (add a line-count check in _prompt() in request.py), or display the full prompt in the confirmation UI. Do not submit URL-supplied prompt text that was not shown in full before approval.

Attack Path
  1. Attacker crafts a dcode://open?dir=/victim/project&prompt=<payload> link where the first 12 lines are benign and line 13+ contains malicious instructions.
  2. Victim clicks the link; OS passes it to the dcode URL-scheme handler.
  3. parse_open_url() validates the URL and returns an OpenRequest with the full prompt intact.
  4. _print_request() calls _prompt_preview(), which shows only the first 12 lines.
  5. Victim approves based on the visible benign content.
  6. _launch() passes the full prompt via -m to dcode, including the hidden instructions.

For more details, see the finding in Corridor.

Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.

@mdrxy
Mason Daugherty (mdrxy) marked this pull request as draft September 3, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dcode Related to `deepagents-code` dependencies Pull requests that update a dependency file feature New feature/enhancement or request for one internal User is a member of the `langchain-ai` GitHub organization size: XL 1000+ LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant