diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index 1abccc1dfed5..020f9a550ed5 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -1207,6 +1207,12 @@ jobs: python3 tests/test_ci_cli_contract_verb_guard.py python3 scripts/check-cli-contract-verbs.py + - name: Validate socket capability parity + if: ${{ matrix.group == 'quality-determinism' }} + run: | + python3 tests/test_ci_socket_capability_guard.py + python3 scripts/check-socket-capabilities.py + - name: Validate test determinism gate if: ${{ matrix.group == 'quality-determinism' }} run: | diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index ad6829995dfe..5ac3c8450aa6 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -64,9 +64,14 @@ extension TerminalController { "mobile.panel.artifact.stat", "mobile.panel.artifact.thumbnail", "mobile.workspace.list", + "mobile.workspace.changes.files", + "mobile.workspace.changes.file_diff", + "mobile.workspace.changes.file_stat", + "mobile.workspace.changes.file_fetch", "mobile.terminal.create", "mobile.terminal.input", "mobile.terminal.paste", + "mobile.terminal.paste_image", "mobile.terminal.replay", "mobile.browser.list", "mobile.browser.create", @@ -83,12 +88,15 @@ extension TerminalController { "mobile.browser.back", "mobile.browser.forward", "mobile.browser.reload", - "mobile.terminal.viewport", "mobile.events.subscribe", "mobile.events.unsubscribe", + "mobile.terminal.viewport", "mobile.terminal.scroll", "mobile.terminal.mouse", + "mobile.events.subscribe", "mobile.events.unsubscribe", + "mobile.status.cycle", "terminal.create", "terminal.input", "terminal.paste", + "terminal.paste_image", "terminal.replay", - "terminal.viewport", + "terminal.viewport", "terminal.scroll", "terminal.mouse", "auth.login", "auth.status", "auth.sign_in_url", @@ -117,11 +125,18 @@ extension TerminalController { "vm.stats", "vm.resize", "vm.rename", + "vm.pause", + "vm.resume", "vm.snapshot", + "vm.snapshot_list", + "vm.snapshot_delete", "vm.fork", "vm.restore", "vm.destroy", "vm.exec", + "vm.env_set", + "vm.file_put", + "vm.reflection", "vm.open_port", "vm.attach_info", "vm.cmux_remote_info", @@ -143,6 +158,8 @@ extension TerminalController { "vm.terminal_write", "vm.terminal_read", "vm.terminal_wait", + "vm.terminal_output", + "vm.terminal_wait_exit", "vm.desktop_open", "vm.port_open", "vm.link_socket", @@ -158,6 +175,7 @@ extension TerminalController { "current.list", "surface.project", "surface.new_terminal", + "surface.ssh_session_attach.resolve", "aiAccounts.list", "aiAccounts.upload", "aiAccounts.remove", @@ -190,7 +208,20 @@ extension TerminalController { "workspace.select", "workspace.current", "workspace.close", + "workspace.move", "workspace.move_to_window", + "workspace.status.get", + "workspace.status.set", + "workspace.status.cycle", + "workspace.todo.list", + "workspace.todo.add", + "workspace.todo.edit", + "workspace.todo.remove", + "workspace.todo.move", + "workspace.todo.open", + "workspace.todo.set", + "workspace.todo.set_state", + "workspace.todo.clear", "workspace.reorder", "workspace.reorder_many", "workspace.prompt_submit", @@ -214,6 +245,7 @@ extension TerminalController { "workspace.group.set_icon", "workspace.group.move", "workspace.group.focus", + "workspace.group.action", "workspace.action", "extension.sidebar.snapshot", "workspace.next", @@ -231,6 +263,11 @@ extension TerminalController { "workspace.remote.terminal_session_launching", "workspace.remote.terminal_session_connected", "workspace.remote.terminal_session_end", "remote.tmux.sessions", "remote.tmux.attach", "remote.tmux.detach", "remote.tmux.state", "remote.tmux.mirror", "remote.tmux.window", "remote.tmux.pane_grids", "remote.tmux.pane_surfaces", + "remotes.list", "remotes.add", "remotes.remove", + "layout.save", "layout.list", "layout.get", "layout.open", "layout.delete", + "canvas.align", "canvas.break", "canvas.info", "canvas.join", "canvas.new_pane", + "canvas.overview", "canvas.reveal", "canvas.select_tab", "canvas.set_frame", + "canvas.set_mode", "canvas.set_viewport", "canvas.zoom", "session.restore_previous", "session.agent_recovery.list", "session.agent_recovery.restore", @@ -312,6 +349,10 @@ extension TerminalController { "notification.mark_read", "notification.open", "notification.jump_to_unread", + "notification.feed.list", + "notification.feed.mark_read", + "notification.feed.mark_unread", + "notification.feed.mark_all_read", "app.focus_override.set", "app.simulate_active", "file.open", @@ -381,6 +422,13 @@ extension TerminalController { "browser.storage.get", "browser.storage.set", "browser.storage.clear", + "browser.profiles.list", + "browser.profiles.create", + "browser.profiles.rename", + "browser.profiles.clear", + "browser.profiles.delete", + "browser.import.cookies", + "browser.import.dialog", "browser.tab.new", "browser.tab.list", "browser.tab.switch", @@ -415,6 +463,17 @@ extension TerminalController { ] methods.removeAll { taskComposerMethods.contains($0) } } + // Discovery runs off-main; read the same flag snapshot as mobile host + // capabilities rather than crossing to the main-actor flag store. + if !CmuxFeatureFlags.offMainEffectiveValue(for: CmuxFeatureFlags.mobileWorkspaceChangesFlag) { + let workspaceChangesMethods: Set = [ + "mobile.workspace.changes.files", + "mobile.workspace.changes.file_diff", + "mobile.workspace.changes.file_stat", + "mobile.workspace.changes.file_fetch", + ] + methods.removeAll { workspaceChangesMethods.contains($0) } + } methods.append(contentsOf: ControlCommandExecutionPolicy.simulatorMethods) #if DEBUG methods.append(contentsOf: Self.v2DebugMethodNames) diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index bbfcf927aed2..d3900d2e3ebf 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -1179,6 +1179,42 @@ final class TerminalControllerSocketSecurityTests { "mobile.panel.artifact.thumbnail", "mobile.events.subscribe", "mobile.events.unsubscribe", + // Cloud VM methods used by the CLI must stay discoverable so + // capability-aware agents do not reject valid operations. + "vm.env_set", + "vm.file_put", + "vm.pause", + "vm.resume", + "vm.reflection", + "vm.snapshot_list", + "vm.snapshot_delete", + "vm.terminal_output", + "vm.terminal_wait_exit", + "browser.profiles.list", + "browser.profiles.create", + "browser.profiles.rename", + "browser.profiles.clear", + "browser.profiles.delete", + "browser.import.cookies", + "browser.import.dialog", + "workspace.move", + "workspace.status.get", + "workspace.status.set", + "workspace.status.cycle", + "workspace.todo.list", + "workspace.todo.add", + "workspace.todo.edit", + "workspace.todo.remove", + "workspace.todo.move", + "workspace.todo.open", + "workspace.todo.set", + "workspace.todo.set_state", + "workspace.todo.clear", + "notification.feed.list", + "notification.feed.mark_read", + "notification.feed.mark_unread", + "notification.feed.mark_all_read", + "surface.ssh_session_attach.resolve", ] XCTAssertTrue( expectedMethods.isSubset(of: advertisedMethods), diff --git a/scripts/check-socket-capabilities.py b/scripts/check-socket-capabilities.py new file mode 100644 index 000000000000..c686bfe60ab4 --- /dev/null +++ b/scripts/check-socket-capabilities.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Check socket dispatcher cases against v2 capability discovery. + +The dispatcher contains a few deliberately private test, debug, and host +bridges. They are listed explicitly below; every other dispatcher method must +be discoverable so a new public socket method cannot silently bypass the +capability catalog. +""" + +import argparse +import pathlib +import re +import sys + +# These methods are internal host/debug/test surfaces, not public socket API. +# Keep the list exact: a new dispatcher method outside this set must be added to +# v2Capabilities() or this guard fails. +INTENTIONALLY_UNADVERTISED_METHODS = frozenset(""" +agent.hook.barrier +agent.hook.enqueue +chat.sessions.dump +debug.app.activate +debug.bonsplit_underflow.count +debug.bonsplit_underflow.reset +debug.browser.address_bar_focused +debug.browser.favicon +debug.canvas.command_scroll_hint +debug.cloudtree.gallery +debug.cloudtree.spacing +debug.command_palette.rename_input.delete_backward +debug.command_palette.rename_input.interact +debug.command_palette.rename_input.select_all +debug.command_palette.rename_input.selection +debug.command_palette.rename_tab.open +debug.command_palette.results +debug.command_palette.selection +debug.command_palette.toggle +debug.command_palette.visible +debug.empty_panel.count +debug.empty_panel.reset +debug.flash.count +debug.flash.reset +debug.layout +debug.mobile.transport.disconnect +debug.mobile.transport.reconnect_loop +debug.notification.emit +debug.notification.focus +debug.notification.mode +debug.notification.status +debug.panel_snapshot +debug.panel_snapshot.reset +debug.portal.stats +debug.pro_welcome_checklist.show +debug.right_sidebar.focus +debug.session_snapshot_benchmark +debug.session_snapshot_seed_scrollback +debug.shortcut.set +debug.shortcut.simulate +debug.sidebar.simulate_drag +debug.sidebar.visible +debug.terminal.is_focused +debug.terminal.read_text +debug.terminal.render_stats +debug.terminal.simulate_file_drop +debug.textbox.inline_fixture +debug.textbox.interact +debug.type +debug.window.screenshot +debug.workspace_todo.checklist_add_field +dogfood.feedback.submit +feed.text +mobile.dev_stack_auth.configure +mobile.directory.list +mobile.directory.search +mobile.rpc.methods +mobile.status.set +mobile.surface.focus +mobile.sync.fetch +mobile.terminal.close +mobile.terminal.participant.disconnect +mobile.terminal.reattach +mobile.terminal.rename +mobile.terminal.size_policy.set +mobile.workspace.changes.summary +notification.reconcile +phone_push.settings.update +phone_push.status.get +phone_push.test +project.get_state +project.open +project.set_configuration +project.set_scheme +project.set_selected_file +project.set_selected_target +project.set_settings_filter +project.set_tab +remote.tmux.root_frames +remote.tmux.sizing_settled +remote.tmux.test_exec +remote.tmux.test_perturb_divider +remote.tmux.test_set_frame +sidebar.custom.reload +sidebar.custom.select +sidebar.custom.validate +""".split()) + + +def switch_cases(source): + methods = set() + for match in re.finditer(r"switch\s+(?:request\.)?method\b", source): + opening = source.find("{", match.end()) + if opening < 0: + continue + depth = 0 + closing = None + for index in range(opening, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + closing = index + break + if closing is None: + continue + switch_body = source[opening:closing] + for case in re.finditer( + r'case\s+((?:"[A-Za-z0-9_.-]+"\s*,?\s*)+)', + switch_body, + flags=re.MULTILINE, + ): + methods.update(re.findall(r'"([A-Za-z0-9_.-]+)"', case.group(1))) + return methods + + +def capability_methods(source, simulator_source=""): + anchor = source.find("var methods: [String] = [") + if anchor < 0: + raise ValueError("v2 capability method list is missing") + closing = source.find("\n ]", anchor) + if closing < 0: + raise ValueError("v2 capability method list is unclosed") + methods = set(re.findall(r'"([A-Za-z0-9_.-]+)"', source[anchor:closing])) + # A small number of capabilities are appended conditionally after the + # base list. Keep those additions in the parity check too, otherwise the + # guard would report a false gap for methods that discovery really emits. + for appended in re.findall(r'for method in \[([^\]]+)\]', source, flags=re.DOTALL): + methods.update(re.findall(r'"([A-Za-z0-9_.-]+)"', appended)) + methods.update(re.findall(r'"([A-Za-z0-9_.-]+)"', simulator_source)) + return methods + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=pathlib.Path(__file__).resolve().parents[1]) + args = parser.parse_args(argv) + root = pathlib.Path(args.root).resolve() + capability_path = root / "Sources/TerminalController+Capabilities.swift" + capability_text = capability_path.read_text(encoding="utf-8") + simulator_path = root / ( + "Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/" + "ControlCommandExecutionPolicy+Simulator.swift" + ) + simulator_text = simulator_path.read_text(encoding="utf-8") if simulator_path.exists() else "" + advertised = capability_methods(capability_text, simulator_text) + + dispatched = set() + for relative in ( + "Sources/TerminalController.swift", + "Sources/Cloud", + "Sources/Surfaces", + "Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator", + ): + path = root / relative + paths = [path] if path.is_file() else sorted(path.rglob("*.swift")) + for source_path in paths: + dispatched.update(switch_cases(source_path.read_text(encoding="utf-8"))) + + public = dispatched - INTENTIONALLY_UNADVERTISED_METHODS + if not public: + print("socket capability parity: no public dispatcher methods found", file=sys.stderr) + return 1 + missing = sorted(public - advertised) + if missing: + print("Missing advertised capabilities:", file=sys.stderr) + for method in missing: + print(" - " + method, file=sys.stderr) + return 1 + print("socket capability parity: ok ({0} public dispatcher methods)".format(len(public))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-execution.toml b/tests/test-execution.toml index ce81b66eeceb..a5a6b83b3531 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -633,6 +633,10 @@ lane = "linux-guard" path = "tests/test_ci_cli_contract_verb_guard.py" lane = "linux-guard" +[[test]] +path = "tests/test_ci_socket_capability_guard.py" +lane = "linux-guard" + [[test]] path = "tests/test_ci_source_lint_guard_structure.py" lane = "linux-guard" diff --git a/tests/test_ci_socket_capability_guard.py b/tests/test_ci_socket_capability_guard.py new file mode 100644 index 000000000000..adbe5cfc8170 --- /dev/null +++ b/tests/test_ci_socket_capability_guard.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Regression guard for public socket capability discovery.""" + +import importlib.util +import os +import subprocess +import sys +import tempfile + + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GUARD = os.path.join(ROOT, "scripts", "check-socket-capabilities.py") + + +def load_guard(): + spec = importlib.util.spec_from_file_location("socket_capability_guard", GUARD) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_public_dispatcher_methods_are_advertised(): + result = subprocess.run( + [sys.executable, GUARD, "--root", ROOT], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert result.returncode == 0, result.stdout + assert "socket capability parity: ok" in result.stdout + count = int(result.stdout.split("(", 1)[1].split(" ", 1)[0]) + assert count > 0 + + +def test_compound_case_labels_are_all_checked(): + guard = load_guard() + methods = guard.switch_cases( + 'switch request.method {\n' + 'case "vm.pause",\n' + ' "vm.only-second-label": break\n' + '}' + ) + assert methods == {"vm.pause", "vm.only-second-label"} + + +def test_compound_case_reports_missing_second_label(): + with tempfile.TemporaryDirectory() as directory: + root = os.path.abspath(directory) + os.makedirs(os.path.join(root, "Sources")) + with open(os.path.join(root, "Sources", "TerminalController.swift"), "w", encoding="utf-8") as handle: + handle.write( + 'switch request.method {\n' + 'case "vm.pause",\n' + ' "vm.only-second-label": break\n' + '}\n' + ) + with open(os.path.join(root, "Sources", "TerminalController+Capabilities.swift"), "w", encoding="utf-8") as handle: + handle.write( + 'var methods: [String] = [\n' + ' "vm.pause"\n' + ' ]\n' + ) + result = subprocess.run( + [sys.executable, GUARD, "--root", root], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert result.returncode != 0 + assert "vm.only-second-label" in result.stdout + + +if __name__ == "__main__": + test_public_dispatcher_methods_are_advertised() + test_compound_case_labels_are_all_checked() + test_compound_case_reports_missing_second_label() + print("test_ci_socket_capability_guard: ok (3 tests)")