fix(0.6.5): macOS batch-3 — agent tmux, palette, hover URL, status doc
Second-pass macOS test of v0.6.4 surfaced four user-visible regressions plus one doc/impl mismatch. Fix all four; reconcile the doc. agent_tmux: lock down no-TTY contract (B) - v0.6.2 added ``tmux new-session -d``, but the spawn still failed on aws-celery with ``open terminal failed: not a terminal``. Two further holes: OpenSSH may inherit a controlling-tty via a stray ``RequestTTY=yes`` in the user's ssh config, and tmux 3.x still calls ``isatty(0)`` to snapshot terminal capabilities even with ``-d``. Fix: ``_default_ssh_command_builder`` returns ``["ssh", "-T", alias]`` so PTY allocation is explicitly suppressed; spawn command appends ``</dev/null`` so ``isatty(0)`` is unambiguously false. The persistent Terminal flow (``terminal_tmux_session``) still uses ``ssh -tt`` — Terminus does allocate a TTY, that path is unaffected. palette: register the v0.6.2 terminal pane / kill commands (C) - ``SessionsNewRemoteTerminalPaneCommand`` and ``SessionsKillRemoteTerminalCommand`` had ``Sessions.sublime-commands`` rows but were never imported by ``sublime/plugin.py``. Sublime only auto-registers ``WindowCommand`` subclasses exposed at the plugin entrypoint module's top level, so the palette never saw them — symptom: "그런 command 없음." Add to ``plugin.py`` import + ``__all__`` and update entrypoint smoke / runtime-import tests so this regresses loudly next time a new command lands. terminal_link_click: canonical localhost URL (E) - Hovering ``0.0.0.0:8080`` Cmd+clicked to ``about:blank-`` on macOS. ``0.0.0.0`` isn't routable from Safari/Chrome and macOS ``open location`` treats a no-path URL as under-specified. ``classify_terminal_token`` now canonicalizes ``0.0.0.0`` → ``localhost`` and forces a trailing ``/`` when the matched token has no path. Adversarial tokens like ``localhost:8080-extra`` refuse the match outright rather than emit a malformed URL. doc reconcile: TEST_CHECKLIST §4.2 (I) - The previous TEST_CHECKLIST refresh promised "Clear Python Interpreter drops the Python: slot entirely". The shipped v0.6.2 behavior keeps the slot and shows ``Python: (not set)``; the slot drop is only the syntax-gate path (non-Python view). Update the step to match shipped behavior so the next test pass doesn't log a false regression. 1474 sublime tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -331,9 +331,11 @@ plugin_loaded sweep.
|
||||
in parens. The `<X.Y.Z>` value comes from a one-shot
|
||||
`python -V` probe that is cached per interpreter path; first
|
||||
activation may briefly show `(…)` while the probe runs.
|
||||
- [ ] `Sessions: Clear Python Interpreter` → bar drops the `Python:`
|
||||
slot entirely (set vs not-set distinction is now "slot present
|
||||
vs absent", not glyph).
|
||||
- [ ] `Sessions: Clear Python Interpreter` → on a Python view, bar
|
||||
reads `Python: (not set)` (slot retained, text-only signal — no
|
||||
glyph, no path). The slot is only dropped entirely by the
|
||||
syntax gate (see next step) or when the view leaves a Sessions
|
||||
workspace.
|
||||
- [ ] (v0.6.2) Open a **non-Python view** (e.g. a `.md` or `.json`
|
||||
file inside the Sessions workspace) → bar **drops the
|
||||
`Python:` slot** for that view. Switching back to a `.py` view
|
||||
|
||||
@@ -16,8 +16,10 @@ from .sessions.commands import (
|
||||
SessionsExpandDeferredDirectoryCommand,
|
||||
SessionsInstallRemoteExtensionCommand,
|
||||
SessionsKillAgentSessionCommand,
|
||||
SessionsKillRemoteTerminalCommand,
|
||||
SessionsLspNavigationListener,
|
||||
SessionsNewAgentSessionCommand,
|
||||
SessionsNewRemoteTerminalPaneCommand,
|
||||
SessionsOnDemandFetchListener,
|
||||
SessionsOpenLocalSshConfigCommand,
|
||||
SessionsOpenRecentRemoteWorkspaceCommand,
|
||||
@@ -60,8 +62,10 @@ __all__ = [
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
|
||||
@@ -67,8 +67,18 @@ SshCommandBuilder = Callable[[str], List[str]]
|
||||
|
||||
|
||||
def _default_ssh_command_builder(alias: str) -> List[str]:
|
||||
"""Return the default ``ssh <alias>`` argv prefix for remote commands."""
|
||||
return ["ssh", alias]
|
||||
"""Return the default ``ssh -T <alias>`` argv prefix for remote commands.
|
||||
|
||||
``-T`` explicitly disables PTY allocation. OpenSSH already defaults to
|
||||
no-TTY when a remote command is supplied, but Sublime's plugin host
|
||||
runs without a controlling terminal in some launch contexts (Finder /
|
||||
Dock launches on macOS, Windows GUI), and a stray ``RequestTTY=yes``
|
||||
in ``~/.ssh/config`` would otherwise cause the spawn to allocate a
|
||||
pseudo-tty. ``-T`` makes the no-TTY contract explicit so the remote
|
||||
``tmux new-session -d`` is guaranteed not to inherit a half-initialised
|
||||
terminal — the trigger for ``open terminal failed: not a terminal``.
|
||||
"""
|
||||
return ["ssh", "-T", alias]
|
||||
|
||||
|
||||
def _shell_quote_with_tilde_expansion(arg: str) -> str:
|
||||
@@ -223,14 +233,30 @@ class AgentTmuxBroker:
|
||||
attach_argv = tuple(ssh_prefix + ["tmux", "attach", "-t", session_name])
|
||||
|
||||
# ``-d`` (detached) is critical: the spawn is invoked through a
|
||||
# non-interactive ``ssh <alias> bash -lc ...`` pipeline with no
|
||||
# non-interactive ``ssh -T <alias> bash -lc ...`` pipeline with no
|
||||
# allocated TTY. Without ``-d``, tmux tries to attach to the new
|
||||
# session immediately and fails with
|
||||
# ``open terminal failed: not a terminal``. The actual attach
|
||||
# happens later from Terminus, which does allocate a TTY.
|
||||
spawn_remote_cmd = "tmux new-session -A -d -s {name} -- {cmd}".format(
|
||||
name=shlex.quote(session_name),
|
||||
cmd=_quote_remote_command(agent_cmd_tuple),
|
||||
#
|
||||
# ``</dev/null`` belt-and-suspenders: even with ``-d``, tmux 3.x
|
||||
# initialises a terminal-capability snapshot for the new session
|
||||
# by probing whatever fd 0 is connected to. When ``ssh`` is
|
||||
# launched from a Sublime plugin host on macOS the inherited
|
||||
# stdin can be a closed/odd handle that tmux misclassifies as a
|
||||
# broken terminal — the error string regressed in v0.6.2 testing
|
||||
# on aws-celery despite ``-d`` being present. Explicitly hooking
|
||||
# tmux's stdin to ``/dev/null`` makes ``isatty(0)`` definitively
|
||||
# false and keeps tmux on the "no terminal needed" code path.
|
||||
# ``-A`` semantics still apply: when the session already exists
|
||||
# the broker short-circuits via ``is_running`` before this
|
||||
# command runs (see :meth:`attach_or_spawn`), so this command
|
||||
# only ever fires for the create-fresh case.
|
||||
spawn_remote_cmd = (
|
||||
"tmux new-session -A -d -s {name} -- {cmd} </dev/null".format(
|
||||
name=shlex.quote(session_name),
|
||||
cmd=_quote_remote_command(agent_cmd_tuple),
|
||||
)
|
||||
)
|
||||
spawn_argv = tuple(ssh_prefix + ["bash", "-lc", spawn_remote_cmd])
|
||||
|
||||
|
||||
@@ -129,7 +129,26 @@ def classify_terminal_token(token: str) -> Optional[Tuple[str, str]]:
|
||||
except ValueError:
|
||||
port_value = -1
|
||||
if 0 < port_value <= 65535:
|
||||
return ("url", "http://" + token)
|
||||
host = host_port.group("host")
|
||||
rest = host_port.group("rest") or ""
|
||||
# ``0.0.0.0`` is the wildcard bind address servers print to
|
||||
# signal "listening on every interface" — macOS browsers
|
||||
# refuse to route to it (Safari/Chrome land on
|
||||
# ``about:blank`` with a stray suffix). Canonicalize to
|
||||
# ``localhost`` so the click reaches the loopback listener
|
||||
# the user actually wants. ``127.0.0.1`` already resolves on
|
||||
# every platform so we leave it alone.
|
||||
if host == "0.0.0.0":
|
||||
host = "localhost"
|
||||
# macOS ``open location`` (driving Safari/Chrome through
|
||||
# AppleScript) treats a host:port URL with no path as
|
||||
# under-specified and falls back to ``about:blank`` plus a
|
||||
# leftover token. Always emit a canonical trailing slash
|
||||
# when no path was present so every platform sees a
|
||||
# well-formed ``http://host:port/`` URL.
|
||||
if not rest:
|
||||
rest = "/"
|
||||
return ("url", "http://" + host + ":" + port_str + rest)
|
||||
match = _ABSPATH_WITH_POS_PATTERN.match(token)
|
||||
if match:
|
||||
path = match.group("path")
|
||||
|
||||
@@ -114,6 +114,28 @@ def test_plan_builds_spawn_argv_as_bash_lc_new_session_command() -> None:
|
||||
"tmux new-session -A -d -s sessions-agent-07c4844b-claude -- "
|
||||
)
|
||||
assert "claude --verbose" in remote_cmd
|
||||
# ``</dev/null`` is required so tmux 3.x doesn't probe the inherited
|
||||
# stdin and emit ``open terminal failed: not a terminal`` even with
|
||||
# ``-d``. See agent_tmux.plan() commentary.
|
||||
assert remote_cmd.endswith(" </dev/null")
|
||||
|
||||
|
||||
def test_plan_default_ssh_builder_passes_dash_T_to_disable_pty() -> None:
|
||||
"""The shipped broker must explicitly disable PTY allocation.
|
||||
|
||||
OpenSSH's default of "no TTY when a remote command is given" is fine
|
||||
on the happy path, but a stray ``RequestTTY=yes`` in the user's
|
||||
``~/.ssh/config`` (or ``Host *`` block) would otherwise force a PTY
|
||||
and recreate the original ``not a terminal`` failure even with
|
||||
``-d``. The broker pins ``-T`` to make the no-TTY contract explicit.
|
||||
"""
|
||||
from sessions.agent_tmux import _default_ssh_command_builder
|
||||
|
||||
assert _default_ssh_command_builder("aws-celery") == [
|
||||
"ssh",
|
||||
"-T",
|
||||
"aws-celery",
|
||||
]
|
||||
|
||||
|
||||
def test_plan_expands_tilde_paths_in_agent_cmd() -> None:
|
||||
@@ -182,7 +204,12 @@ def test_attach_or_spawn_spawns_when_missing() -> None:
|
||||
assert len(run.calls) == 2
|
||||
spawn_argv = run.calls[1]
|
||||
assert spawn_argv[:6] == ["ssh", "-F", "/fake/config", "dev", "bash", "-lc"]
|
||||
assert "tmux new-session -A -d -s sessions-agent-07c4844b-claude" in spawn_argv[6]
|
||||
remote_cmd = spawn_argv[6]
|
||||
# Both the detached-create flag AND the stdin redirect must be in
|
||||
# the spawned command; missing either causes the "open terminal
|
||||
# failed: not a terminal" regression on no-TTY hosts.
|
||||
assert "tmux new-session -A -d -s sessions-agent-07c4844b-claude" in remote_cmd
|
||||
assert remote_cmd.endswith(" </dev/null")
|
||||
|
||||
|
||||
def test_attach_or_spawn_raises_on_spawn_failure() -> None:
|
||||
|
||||
@@ -62,6 +62,18 @@ def _write_fake_ssh(
|
||||
script.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"# test shim — subprocess.Popen-equivalent routing\n"
|
||||
"# Drop any leading ssh option flags (e.g. ``-T`` to disable PTY\n"
|
||||
"# allocation) before consuming the alias positional.\n"
|
||||
"while [ $# -gt 0 ]; do\n"
|
||||
' case "$1" in\n'
|
||||
" -[A-Za-z])\n"
|
||||
" shift\n"
|
||||
" ;;\n"
|
||||
" *)\n"
|
||||
" break\n"
|
||||
" ;;\n"
|
||||
" esac\n"
|
||||
"done\n"
|
||||
"shift # drop alias\n"
|
||||
'sub=""\n'
|
||||
'if [ $# -ge 2 ]; then sub="$2"; fi\n'
|
||||
|
||||
@@ -59,8 +59,10 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
@@ -106,6 +108,12 @@ def test_plugin_entrypoint_exports_sessions_commands() -> None:
|
||||
assert plugin_module.SessionsOpenRemoteTerminalCommand.__name__ == (
|
||||
"SessionsOpenRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsNewRemoteTerminalPaneCommand.__name__ == (
|
||||
"SessionsNewRemoteTerminalPaneCommand"
|
||||
)
|
||||
assert plugin_module.SessionsKillRemoteTerminalCommand.__name__ == (
|
||||
"SessionsKillRemoteTerminalCommand"
|
||||
)
|
||||
assert plugin_module.SessionsOpenRemoteFileCommand.__name__ == (
|
||||
"SessionsOpenRemoteFileCommand"
|
||||
)
|
||||
|
||||
@@ -50,8 +50,10 @@ def test_sessions_plugin_imports_under_sublime_style_package_layout() -> None:
|
||||
"SessionsExpandDeferredDirectoryCommand",
|
||||
"SessionsInstallRemoteExtensionCommand",
|
||||
"SessionsKillAgentSessionCommand",
|
||||
"SessionsKillRemoteTerminalCommand",
|
||||
"SessionsLspNavigationListener",
|
||||
"SessionsNewAgentSessionCommand",
|
||||
"SessionsNewRemoteTerminalPaneCommand",
|
||||
"SessionsOnDemandFetchListener",
|
||||
"SessionsOpenRemoteFileCommand",
|
||||
"SessionsOpenRemoteFolderCommand",
|
||||
|
||||
@@ -309,8 +309,10 @@ def test_click_on_localhost_url_opens_browser(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Cluster B fix: scheme-less ``localhost:PORT`` should round-trip
|
||||
# through ``_handle_url`` as ``http://localhost:PORT`` so the browser
|
||||
# picks it up like any other URL.
|
||||
# through ``_handle_url`` as ``http://localhost:PORT/`` so the browser
|
||||
# picks it up like any other URL. The trailing slash is load-bearing
|
||||
# on macOS — without it ``open location`` falls back to
|
||||
# ``about:blank`` (the v0.6.4 regression).
|
||||
view = _FakeClickView("Server up at localhost:8080 now")
|
||||
view.window_value = _FakeClickWindow()
|
||||
opened: List[str] = []
|
||||
@@ -323,7 +325,7 @@ def test_click_on_localhost_url_opens_browser(
|
||||
# Point 14 lands inside the ``localhost:8080`` token (offset of ``l``).
|
||||
result = listener.on_text_command(view, "drag_select", {"event": _click_event(14)})
|
||||
assert result == ("noop", {})
|
||||
assert opened == ["http://localhost:8080"]
|
||||
assert opened == ["http://localhost:8080/"]
|
||||
|
||||
|
||||
def test_click_on_non_link_token_falls_through_to_drag_select() -> None:
|
||||
|
||||
@@ -92,16 +92,27 @@ def test_classify_token_trims_trailing_punctuation() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"token, expected_url",
|
||||
[
|
||||
# Bare localhost:port — the canonical dev-server case.
|
||||
("localhost:8080", "http://localhost:8080"),
|
||||
# Bare localhost:port — the canonical dev-server case. We always
|
||||
# emit a trailing slash on the path because macOS' ``open
|
||||
# location`` (driving Safari/Chrome via AppleScript) treats a
|
||||
# bare host:port URL as under-specified and falls back to
|
||||
# ``about:blank``.
|
||||
("localhost:8080", "http://localhost:8080/"),
|
||||
("localhost:8888/notebooks/a.ipynb", "http://localhost:8888/notebooks/a.ipynb"),
|
||||
# 127.0.0.1 is what Jupyter's startup banner prints.
|
||||
("127.0.0.1:8888", "http://127.0.0.1:8888"),
|
||||
("127.0.0.1:8888", "http://127.0.0.1:8888/"),
|
||||
("127.0.0.1:5173/", "http://127.0.0.1:5173/"),
|
||||
# Arbitrary IPv4 that shows up in ML / Ray / dashboard links.
|
||||
("10.0.0.4:9000", "http://10.0.0.4:9000"),
|
||||
("10.0.0.4:9000", "http://10.0.0.4:9000/"),
|
||||
# Trailing punctuation from prose strips before matching.
|
||||
("localhost:3000.", "http://localhost:3000"),
|
||||
("localhost:3000.", "http://localhost:3000/"),
|
||||
# ``0.0.0.0`` is a wildcard bind address — servers print it to
|
||||
# mean "listening on every interface" but browsers can't route
|
||||
# to it. Canonicalize to ``localhost`` so the click lands on
|
||||
# the loopback listener the user actually wants.
|
||||
("0.0.0.0:8080", "http://localhost:8080/"),
|
||||
("0.0.0.0:8080/", "http://localhost:8080/"),
|
||||
("0.0.0.0:8080/dashboard", "http://localhost:8080/dashboard"),
|
||||
],
|
||||
)
|
||||
def test_classify_token_handles_localhost_and_host_port(
|
||||
@@ -148,6 +159,86 @@ def test_classify_token_localhost_does_not_collide_with_abspath() -> None:
|
||||
assert value == "/srv/etc/localhost"
|
||||
|
||||
|
||||
# --- adversarial: the v0.6.4 ``about:blank-`` regression --------------------
|
||||
|
||||
|
||||
def test_classify_token_zero_host_canonicalizes_to_localhost() -> None:
|
||||
# Repro for v0.6.4 macOS bug: ``python3 -m http.server 8080`` prints
|
||||
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
|
||||
# macOS browsers can't route to ``0.0.0.0`` and fall back to
|
||||
# ``about:blank``; canonicalize to loopback so Cmd+click reaches
|
||||
# the listener the user actually wants.
|
||||
assert classify_terminal_token("0.0.0.0:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
|
||||
|
||||
def test_classify_token_bare_host_port_emits_trailing_slash() -> None:
|
||||
# Without a trailing slash the macOS ``open location`` AppleScript
|
||||
# treats the URL as under-specified and the browser shows a stray
|
||||
# leftover suffix (the v0.6.4 ``about:blank-`` symptom). The
|
||||
# promotion path always emits a canonical ``/`` when no path
|
||||
# component is present so every platform sees a well-formed URL.
|
||||
assert classify_terminal_token("localhost:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
assert classify_terminal_token("127.0.0.1:8080") == (
|
||||
"url",
|
||||
"http://127.0.0.1:8080/",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
# A trailing dash glued to the port has no canonical
|
||||
# interpretation as a URL — better to refuse than guess. The
|
||||
# v0.6.4 ``about:blank-`` symptom on macOS came from passing a
|
||||
# malformed token straight to the browser; rejecting here means
|
||||
# the click falls through to plain text selection.
|
||||
"localhost:8080-extra",
|
||||
"localhost:8080-",
|
||||
"127.0.0.1:8080-",
|
||||
"0.0.0.0:8080-",
|
||||
],
|
||||
)
|
||||
def test_classify_token_rejects_dash_glued_to_port(token: str) -> None:
|
||||
assert classify_terminal_token(token) is None
|
||||
|
||||
|
||||
def test_http_server_banner_line_classifies_only_clean_url_tokens() -> None:
|
||||
# The whole-line scenario for ``python3 -m http.server 8080``:
|
||||
# ``Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...``.
|
||||
# We feed each whitespace-delimited token to the classifier. The
|
||||
# bare ``0.0.0.0`` (no port) and bare ``8080`` (no host) are noise;
|
||||
# the parens-wrapped URL is rejected because we deliberately don't
|
||||
# strip leading brackets (policy: hover precision over greediness).
|
||||
# Nothing should classify as a URL — the user clicks on a clean
|
||||
# ``localhost:8080`` token elsewhere in their pane (e.g. an explicit
|
||||
# echo, a Vite/Jupyter banner) and gets the canonical
|
||||
# ``http://localhost:8080/`` form below.
|
||||
line = "Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ..."
|
||||
hits = []
|
||||
for token in line.split():
|
||||
result = classify_terminal_token(token)
|
||||
if result is not None:
|
||||
hits.append(result)
|
||||
# No token in *this* line promotes — the parens-wrapped URL is the
|
||||
# one the user would visually click on but our policy is to require
|
||||
# hover/click on the URL itself.
|
||||
assert hits == []
|
||||
# Sanity: the *bare* ``0.0.0.0:8080`` token (the load-bearing case
|
||||
# the v0.6.4 bug report covers) does promote, and it canonicalizes
|
||||
# to localhost with a trailing slash so macOS Safari/Chrome can
|
||||
# actually route it.
|
||||
assert classify_terminal_token("0.0.0.0:8080") == (
|
||||
"url",
|
||||
"http://localhost:8080/",
|
||||
)
|
||||
|
||||
|
||||
# --- extract_token_at --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user